From ebbf4709c877efb47e201a16a2592c7ebfecb89c Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 23 Sep 2026 05:51:05 -0400 Subject: [PATCH] CLOSES #2411, CLOSES #2418, CLOSES #2420, CLOSES #2422, CLOSES #2423, CLOSES #2424 --- mod_option_schemas.json | 2 +- src/core/Game3.lua | 27 +- src/core/Sound.lua | 3 + src/core/game3/audio.lua | 100 +- src/core/game3/bag.lua | 16 +- src/core/game3/battle/ai_items.lua | 122 +-- src/core/game3/battle/anim_port/g1_sprite.lua | 4 - src/core/game3/battle/anim_port/g2_pret.lua | 4 - src/core/game3/battle/anim_port/g3_e3b.lua | 9 +- src/core/game3/battle/anim_port/g3_e3c.lua | 9 +- src/core/game3/battle/anim_port/g3_pret.lua | 4 - src/core/game3/battle/catching.lua | 2 +- src/core/game3/battle/effects/special.lua | 4 +- src/core/game3/battle/init.lua | 10 +- src/core/game3/battle/items.lua | 34 +- src/core/game3/battle/moves.lua | 21 - src/core/game3/battle/ui.lua | 46 +- src/core/game3/battle_bridge.lua | 7 +- src/core/game3/collision.lua | 1 + src/core/game3/dataset.lua | 9 + src/core/game3/dex.lua | 37 +- src/core/game3/field.lua | 657 ++++++++----- src/core/game3/field_effects.lua | 58 +- src/core/game3/field_move_show_mon.lua | 237 +++++ src/core/game3/field_moves.lua | 8 +- src/core/game3/field_view.lua | 31 +- src/core/game3/forced_movement.lua | 65 +- src/core/game3/ghosts.lua | 6 +- src/core/game3/item_use.lua | 263 ++++- src/core/game3/itemfinder.lua | 242 +++++ src/core/game3/items_data.lua | 8 +- src/core/game3/league_lighting.lua | 146 +++ src/core/game3/link/init.lua | 10 +- src/core/game3/map.lua | 36 +- src/core/game3/objects.lua | 456 +++++++-- src/core/game3/ow_sprites.lua | 11 + src/core/game3/player.lua | 77 +- src/core/game3/pokedex_data.lua | 37 +- src/core/game3/pokedex_rating.lua | 37 +- src/core/game3/pokemon.lua | 357 ++++--- src/core/game3/profiles/firered.lua | 2 + src/core/game3/rom_text.lua | 20 + src/core/game3/runtime.lua | 5 + src/core/game3/save_schema_firered.lua | 37 + src/core/game3/scripting/adapters.lua | 52 +- src/core/game3/scripting/collision.lua | 6 +- src/core/game3/scripting/flags.lua | 58 +- src/core/game3/scripting/flags_table.lua | 8 - src/core/game3/scripting/gfx_ids.lua | 124 ++- src/core/game3/scripting/natives.lua | 32 +- src/core/game3/scripting/natives_cutscene.lua | 21 +- src/core/game3/scripting/natives_daycare.lua | 9 +- src/core/game3/scripting/natives_events.lua | 43 +- src/core/game3/scripting/natives_queries.lua | 8 + src/core/game3/scripting/natives_trade.lua | 16 +- src/core/game3/scripting/natives_wireless.lua | 41 +- src/core/game3/scripting/ops_a.lua | 20 +- src/core/game3/scripting/space.lua | 77 +- src/core/game3/scripting/stdscripts.lua | 299 +----- src/core/game3/scripting/text_ir.lua | 19 + src/core/game3/scripting/vm.lua | 18 +- src/core/game3/ss_anne_cutscene.lua | 78 +- src/core/game3/step_events.lua | 130 ++- src/core/game3/tileset_native.lua | 73 ++ src/core/game3/trade_scene.lua | 8 +- src/core/game3/trainer_pic.lua | 218 +---- src/core/game3/trainer_sight.lua | 8 +- src/core/game3/vs_seeker.lua | 10 +- src/core/game3/warp.lua | 9 +- src/import/CacheContract.lua | 50 + src/import/RomExtractorGen3.lua | 18 +- src/import/gba/SPEC.md | 7 +- src/import/gba/credits_extract.lua | 376 ++++++++ src/import/gba/editions/leafgreen_1_0.lua | 207 ++++ src/import/gba/extract_island1.lua | 3 + src/import/gba/extract_map_events.lua | 2 + src/import/gba/extract_scripts.lua | 105 +- src/import/gba/field_effect_extract.lua | 46 + src/import/gba/island1_content.lua | 402 -------- src/import/gba/items_extract.lua | 64 +- src/import/gba/league_extract.lua | 184 ++++ src/import/gba/native_pack.lua | 15 + src/import/gba/pokemon_extract.lua | 110 ++- src/import/gba/register.lua | 6 - src/import/gba/versions.lua | 285 +++++- src/mods/Gen3Compat.lua | 14 +- src/mods/Schemas.lua | 1 - src/script/Commands.lua | 14 +- src/ui/game3/bag_menu.lua | 70 +- src/ui/game3/battle_chrome.lua | 32 +- src/ui/game3/berry_pouch.lua | 19 +- src/ui/game3/berry_powder_box.lua | 88 ++ src/ui/game3/box_storage_ui.lua | 11 +- src/ui/game3/chrome.lua | 14 - src/ui/game3/credits.lua | 905 ++++++++++++++++++ src/ui/game3/diploma.lua | 155 +++ src/ui/game3/egg_hatch.lua | 11 +- src/ui/game3/evolution_scene.lua | 15 +- src/ui/game3/fame_checker.lua | 3 +- src/ui/game3/frlg_font.lua | 8 - src/ui/game3/hall_of_fame.lua | 579 ++++++++--- src/ui/game3/hall_of_fame_gfx.lua | 63 ++ src/ui/game3/hall_of_fame_pc.lua | 230 +++++ src/ui/game3/hud.lua | 3 + src/ui/game3/link_trade_menu.lua | 6 +- src/ui/game3/map_name_popup.lua | 9 +- src/ui/game3/minigame_records.lua | 249 +++++ src/ui/game3/mon_pic.lua | 8 +- src/ui/game3/naming.lua | 8 +- src/ui/game3/party_chrome.lua | 1 - src/ui/game3/party_menu.lua | 102 +- src/ui/game3/pc_chrome.lua | 9 +- src/ui/game3/pc_menu.lua | 106 +- src/ui/game3/pokedex.lua | 18 +- src/ui/game3/pokedex_chrome.lua | 64 +- src/ui/game3/region_map.lua | 6 - src/ui/game3/release_seq.lua | 2 +- src/ui/game3/shop_menu.lua | 11 +- src/ui/game3/slot_machine.lua | 5 +- src/ui/game3/start_menu.lua | 91 +- src/ui/game3/summary_chrome.lua | 45 - src/ui/game3/summary_menu.lua | 8 +- src/ui/game3/tm_case.lua | 5 +- src/ui/game3/trade_scene.lua | 8 +- src/ui/game3/ui_pass.lua | 6 + src/ui/game3/whiteout_rush.lua | 104 ++ src/ui/game3/window.lua | 56 +- src/world/OverworldController.lua | 14 +- src/world/PikachuFollower.lua | 49 +- .../game3_battle_league_friendship.lua | 1 + tests/drivers/game3_berry_powder_man_2416.lua | 104 ++ tests/drivers/game3_bw_flute_rom_text.lua | 201 ++++ .../drivers/game3_collision_surf_channel.lua | 5 + tests/drivers/game3_encounters_cave.lua | 17 +- tests/drivers/game3_encounters_dark_cave.lua | 9 +- tests/drivers/game3_field_poison_whiteout.lua | 103 ++ tests/drivers/game3_field_victory_road.lua | 6 + .../drivers/game3_gym_leader_intro_music.lua | 77 ++ tests/drivers/game3_hidden_coins.lua | 140 +++ tests/drivers/game3_hof_credits.lua | 153 +++ tests/drivers/game3_importer_round2.lua | 195 ++++ .../drivers/game3_league_lighting_diploma.lua | 222 +++++ .../game3_league_lighting_soft_reset.lua | 102 ++ tests/drivers/game3_league_rerun.lua | 76 ++ tests/drivers/game3_link_counter.lua | 53 +- tests/drivers/game3_neighbor_var_gfx.lua | 68 ++ tests/drivers/game3_npc_movement_types.lua | 242 +++++ tests/drivers/game3_npc_player_collision.lua | 10 +- tests/drivers/game3_ops_icefall_hole.lua | 87 +- tests/drivers/game3_pc_rom_script.lua | 208 ++++ tests/drivers/game3_pc_se_2415.lua | 84 +- tests/drivers/game3_picbox_start_cancel.lua | 97 ++ tests/drivers/game3_pp_items_2420.lua | 284 ++++++ tests/drivers/game3_run_pose.lua | 99 ++ tests/drivers/game3_safari_start_menu.lua | 260 +++++ tests/drivers/game3_sign_walk_into.lua | 114 +++ tests/drivers/game3_special_trade.lua | 23 + tests/drivers/game3_spinda_unown_dex.lua | 176 ++++ tests/drivers/game3_std_item_text.lua | 127 +++ tests/drivers/game3_stitchcoll_fall_shake.lua | 29 +- tests/drivers/game3_stitchfield_movement.lua | 2 + .../game3_stitchimp_heal_locations.lua | 3 +- tests/drivers/game3_stitchmap_heal.lua | 3 +- tests/drivers/game3_stitchmap_items.lua | 1 + tests/drivers/game3_stitchsave_continue.lua | 196 ---- tests/drivers/game3_stitchsave_summary.lua | 1 + .../drivers/game3_stitchuid_summary_dexno.lua | 1 + tests/drivers/game3_strength_push.lua | 169 ++++ tests/drivers/game3_surf_connections.lua | 12 +- tests/drivers/game3_surf_music_2423.lua | 162 ++++ tests/drivers/game3_tower_run.lua | 1 + tests/drivers/game3_ui_fly_map.lua | 1 + tests/drivers/game3_ui_party_dig.lua | 1 + tests/drivers/game3_ui_summary_pokerus.lua | 1 + tests/drivers/game3_whiteout_rush.lua | 135 +++ tests/drivers/util.lua | 20 + tests/drivers/yellow_bill_transform_2411.lua | 214 +++++ .../game3_bills_pc_label_bug2404_test.lua | 1 + tests/engine/game3_egg_icon_species_test.lua | 7 +- tests/engine/game3_egg_screens_test.lua | 1 + .../engine/game3_script_verbs_subset_test.lua | 2 - .../nickname_mon_icon_scale_bug2319.lua | 24 +- .../nickname_title_fits_box_bug2319.lua | 1 + tests/fixture_data/game3_pc_scripts.lua | 116 +++ tests/game3_bag_test.lua | 3 - tests/game3_bedroom_pc_test.lua | 1 + tests/game3_bufferstdstring_test.lua | 2 + tests/game3_cache.lua | 2 +- tests/game3_corner_screen_test.lua | 1 + tests/game3_credits_hall_of_fame_test.lua | 8 +- tests/game3_emote_movement_test.lua | 32 +- tests/game3_event_flags_test.lua | 2 +- tests/game3_field_boulder_test.lua | 27 +- tests/game3_field_cut_test.lua | 2 +- tests/game3_field_fly_test.lua | 1 + tests/game3_field_forced_movement_test.lua | 36 +- tests/game3_field_items_test.lua | 6 +- tests/game3_hall_of_fame_test.lua | 63 +- tests/game3_hidden_item_persistence_test.lua | 2 + tests/game3_hidden_items_test.lua | 24 +- tests/game3_import_hardening_test.lua | 8 + tests/game3_item_pc_potion_test.lua | 2 + tests/game3_item_use_and_parcel_test.lua | 20 +- tests/game3_link_trade_test.lua | 1 + tests/game3_menu_sfx_test.lua | 1 + tests/game3_moveteach_dig_escape_test.lua | 5 + tests/game3_nurse_joy_heal_test.lua | 246 ----- tests/game3_object_interactions_rom_test.lua | 15 +- tests/game3_old_man_tutorial_battle_test.lua | 34 - tests/game3_oneoff_specials_test.lua | 16 +- tests/game3_pc_anim_test.lua | 7 +- tests/game3_pc_sfx_2415_test.lua | 64 +- tests/game3_pokedex_rating_test.lua | 7 +- tests/game3_runtime_registered_item_test.lua | 1 + tests/game3_seafoam_puzzle_test.lua | 61 +- tests/game3_step_events_queue_test.lua | 33 +- tests/game3_stitchfield_flash_test.lua | 10 + tests/game3_stitchsave_schema_fields_test.lua | 16 +- tests/game3_stitchuif_bag_use_test.lua | 7 + tests/game3_storage_test.lua | 17 +- tests/game3_strings_module_tables_test.lua | 4 +- tests/game3_text_colors_test.lua | 23 +- tests/game3_town_map_test.lua | 7 - tests/game3_trade_scene_test.lua | 1 + tests/game3_ui_party_fieldmove_test.lua | 1 + tests/game3_waterfall_test.lua | 1 + tests/game3_wireless_specials_test.lua | 38 +- tests/parity_bills_pc.lua | 39 +- tests/parity_yellow_bills_pikachu.lua | 4 + tests/parity_yellow_disabled_pikachu.lua | 6 +- 230 files changed, 11607 insertions(+), 3514 deletions(-) create mode 100644 src/core/game3/field_move_show_mon.lua create mode 100644 src/core/game3/itemfinder.lua create mode 100644 src/core/game3/league_lighting.lua create mode 100644 src/core/game3/rom_text.lua create mode 100644 src/import/gba/credits_extract.lua delete mode 100644 src/import/gba/island1_content.lua create mode 100644 src/import/gba/league_extract.lua create mode 100644 src/ui/game3/berry_powder_box.lua create mode 100644 src/ui/game3/credits.lua create mode 100644 src/ui/game3/diploma.lua create mode 100644 src/ui/game3/hall_of_fame_gfx.lua create mode 100644 src/ui/game3/hall_of_fame_pc.lua create mode 100644 src/ui/game3/minigame_records.lua create mode 100644 src/ui/game3/whiteout_rush.lua create mode 100644 tests/drivers/game3_bw_flute_rom_text.lua create mode 100644 tests/drivers/game3_field_poison_whiteout.lua create mode 100644 tests/drivers/game3_gym_leader_intro_music.lua create mode 100644 tests/drivers/game3_hidden_coins.lua create mode 100644 tests/drivers/game3_hof_credits.lua create mode 100644 tests/drivers/game3_importer_round2.lua create mode 100644 tests/drivers/game3_league_lighting_diploma.lua create mode 100644 tests/drivers/game3_league_lighting_soft_reset.lua create mode 100644 tests/drivers/game3_league_rerun.lua create mode 100644 tests/drivers/game3_neighbor_var_gfx.lua create mode 100644 tests/drivers/game3_npc_movement_types.lua create mode 100644 tests/drivers/game3_pc_rom_script.lua create mode 100644 tests/drivers/game3_picbox_start_cancel.lua create mode 100644 tests/drivers/game3_pp_items_2420.lua create mode 100644 tests/drivers/game3_run_pose.lua create mode 100644 tests/drivers/game3_safari_start_menu.lua create mode 100644 tests/drivers/game3_sign_walk_into.lua create mode 100644 tests/drivers/game3_spinda_unown_dex.lua create mode 100644 tests/drivers/game3_std_item_text.lua delete mode 100644 tests/drivers/game3_stitchsave_continue.lua create mode 100644 tests/drivers/game3_strength_push.lua create mode 100644 tests/drivers/game3_surf_music_2423.lua create mode 100644 tests/drivers/game3_whiteout_rush.lua create mode 100644 tests/drivers/yellow_bill_transform_2411.lua create mode 100644 tests/fixture_data/game3_pc_scripts.lua delete mode 100644 tests/game3_nurse_joy_heal_test.lua diff --git a/mod_option_schemas.json b/mod_option_schemas.json index 4002f364..592ffeba 100644 --- a/mod_option_schemas.json +++ b/mod_option_schemas.json @@ -1 +1 @@ -{"schema_version":1,"mods":[]} \ No newline at end of file +{"mods":[],"schema_version":1} \ No newline at end of file diff --git a/src/core/Game3.lua b/src/core/Game3.lua index 74daf487..91020135 100644 --- a/src/core/Game3.lua +++ b/src/core/Game3.lua @@ -354,6 +354,8 @@ function Game3:_handleRegisteredItem() if Runtime and Runtime.uiBusy and Runtime.uiBusy() then return end local Field = package.loaded["src.core.game3.field"] if Field and Field.locked then return end + local P = package.loaded["src.core.game3.player"] + if P and P.boulderPush then return end local Bag = require("src.core.game3.bag") local ItemUse = require("src.core.game3.item_use") if not session.bag or not Bag.has(session.bag, item, 1) then @@ -458,8 +460,9 @@ function Game3:fixedUpdate(dt) ModRuntime.call("input.step", noop, self, dt or FixedStep.STEP) end if self.input and self.input.step then self.input:step() end - if self.input and self.input.softResetStep and self.input:softResetStep() then - self.input:reset() + if (self.input and self.input.softResetStep and self.input:softResetStep()) or self.softResetRequested then + self.softResetRequested = nil + if self.input then self.input:reset() end if self.touchControls then self.touchControls:reset() end self:returnToTitle() return @@ -650,7 +653,7 @@ end function Game3:_hotkey(key) if key == "f1" then - if self.phase == "field" then self:saveGame() end + if self.phase == "field" and self:saveOffered() then self:saveGame() end return true elseif key == "f2" then if self.phase == "field" then @@ -760,6 +763,12 @@ function Game3:gamepadreleased(joystick, button) { phase = "released", joystick = joystick, button = button }) end +-- pokefirered/src/start_menu.c:198 +function Game3:saveOffered() + local session = (Runtime.getSession and Runtime.getSession()) or self.session + return require("src.ui.game3.start_menu").saveOffered(session) +end + function Game3:saveGame() if not self.session or self.phase == "quest_log" then return end if ModRuntime.wantsHook("save.write") @@ -945,6 +954,11 @@ local FIELD_SCREENS = { "src.ui.game3.money_box", "src.ui.game3.coins_box", "src.ui.game3.elevator_window", + "src.ui.game3.berry_powder_box", + "src.ui.game3.minigame_records", + "src.ui.game3.hall_of_fame", + "src.ui.game3.hall_of_fame_pc", + "src.ui.game3.credits", } local function clearFieldScreens() @@ -955,6 +969,13 @@ local function clearFieldScreens() if fn then pcall(fn) end end end + local LeagueLighting = package.loaded["src.core.game3.league_lighting"] + if LeagueLighting and LeagueLighting.reset then pcall(LeagueLighting.reset) end + local Map = require("src.core.game3.map") + Map.disableMusicChange = Map.MUSIC_DISABLE_OFF + local FieldView = require("src.core.game3.field_view") + FieldView.hideActors = false + FieldView.setCameraPanning(0, 0) end function Game3:returnToTitle() diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 317f780f..de860866 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -771,6 +771,9 @@ function Sound.playPikaCry(data, n) local path = ("assets/generated/audio/pika_cries/cry_%02d.wav"):format(n) local ok, s = pcall(love.audio.newSource, path, "static") if not ok or not s then + if os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true then + error(("pikachu cry %d failed to load: %s"):format(n, tostring(s))) + end cache[key] = false return nil end diff --git a/src/core/game3/audio.lua b/src/core/game3/audio.lua index 4f492bef..b8cb9a53 100644 --- a/src/core/game3/audio.lua +++ b/src/core/game3/audio.lua @@ -418,13 +418,105 @@ function Audio.setMapSong(id) Audio._mapSong = tonumber(id) or id end +-- pokefirered/include/constants/songs.h:290 +Audio.MUS_CYCLING = 282 +-- pokefirered/include/constants/songs.h:313 +Audio.MUS_SURF = 305 +-- pokefirered/include/constants/region_map_sections.h:106 +local NO_RIDE_MUSIC_SECTIONS = { [97] = true, [123] = true, [132] = true } + +local function current_section() + local Map = package.loaded["src.core.game3.map"] + local def = Map and Map.currentDef and Map.currentDef() + return def and def.regionMapSectionId +end + +-- pokefirered/src/overworld.c:1193 +function Audio.canOverrideMapMusic(song, sectionId) + if song == Audio.MUS_CYCLING or song == Audio.MUS_SURF then + if sectionId == nil then sectionId = current_section() end + return not NO_RIDE_MUSIC_SECTIONS[tonumber(sectionId) or -1] + end + return true +end + +-- pokefirered/src/overworld.c:1014 +function Audio.specialMapSong(sectionId) + if Audio._savedSong then return Audio._savedSong end + local P = package.loaded["src.core.game3.player"] + if P and (P.surfing or P.surfHopping) and not P.dismounting + and Audio.canOverrideMapMusic(Audio.MUS_SURF, sectionId) then + return Audio.MUS_SURF + end + return Audio._mapSong +end + -- pokefirered/src/overworld.c:1039 function Audio.restoreMapSong(opts) - local id = Audio._savedSong or Audio._mapSong + local id = Audio.specialMapSong() if id then return Audio.playSong(id, opts) end return true end +-- pokefirered/src/sound.c:152 +function Audio.fadeOutAndPlay(id, speed) + id = tonumber(id) or id + if Audio._fanfareActive then + Audio._fanfareDeferred = id + return true + end + if not (Audio._currentSong and Audio._bgmSource) then + return Audio.playSong(id) + end + Audio.fadeOutBgm(speed) + if Audio._fadeOut then Audio._fadeOut.nextSong = id end + return true +end + +-- pokefirered/src/overworld.c:1096 +function Audio.changeMusicTo(id) + id = tonumber(id) or id + local cur = Audio._currentSong and Audio._currentSong.id + local pending = Audio._fadeOut and Audio._fadeOut.nextSong + if (pending or cur) == id then return true end + return Audio.fadeOutAndPlay(id, 8) +end + +-- pokefirered/src/overworld.c:1089 +function Audio.changeMusicToDefault() + if Audio._mapSong then return Audio.changeMusicTo(Audio._mapSong) end + return true +end + +-- pokefirered/src/field_effect.c:2986 +function Audio.startSurfMusic() + Audio.setSavedSong(nil) + if Audio.canOverrideMapMusic(Audio.MUS_SURF) then Audio.changeMusicTo(Audio.MUS_SURF) end +end + +-- pokefirered/src/field_player_avatar.c:1579 +function Audio.stopSurfMusic() + Audio.setSavedSong(nil) + Audio.changeMusicToDefault() +end + +-- pokefirered/src/bike.c:314 +function Audio.bikeMusic(on, forced) + if on then + if forced or Audio.canOverrideMapMusic(Audio.MUS_CYCLING) then + Audio.setSavedSong(Audio.MUS_CYCLING) + Audio.changeMusicTo(Audio.MUS_CYCLING) + end + return + end + Audio.setSavedSong(nil) + local id = Audio.specialMapSong() + local pending = Audio._fadeOut and Audio._fadeOut.nextSong + if id and id ~= (pending or (Audio._currentSong and Audio._currentSong.id)) then + Audio.playSong(id) + end +end + -- pokefirered/src/overworld.c:1048 function Audio.setSavedSong(id) id = tonumber(id) or id @@ -953,16 +1045,17 @@ end -- pokefirered/src/sound.c:333 function Audio.playCry(species, mode, pan) species = tonumber(species) or species - local volume + local volume, noDuck if type(mode) == "table" then local o = mode mode = o.mode if pan == nil then pan = o.pan end volume = o.volume + noDuck = o.noDuck == true end mode = tonumber(mode) or 0 local params = Sample.cryParams(mode, volume) - local doubles = params.mode == 1 + local doubles = params.mode == 1 or noDuck Audio._cryParams = params log(string.format("playCry species=%s mode=%d", tostring(species), params.mode)) if not Audio.isReady() then @@ -1099,6 +1192,7 @@ function Audio.update(dt) Audio._currentSong = nil end Audio._fadeOut = nil + if stillSame and f.nextSong then Audio.playSong(f.nextSong) end end end if Audio._fadeIn and Audio._bgmSource then diff --git a/src/core/game3/bag.lua b/src/core/game3/bag.lua index 0e766d5c..ed2ec756 100644 --- a/src/core/game3/bag.lua +++ b/src/core/game3/bag.lua @@ -85,10 +85,13 @@ local function sanitize_pockets(bag) local keep = {} for _, slot in ipairs(slots) do local correctPocket = ItemsData.pocketOf(slot.id) - if correctPocket ~= k then - misplaced[#misplaced + 1] = { id = slot.id, qty = tonumber(slot.qty) or 1, target = correctPocket } - else - keep[#keep + 1] = slot + -- pokefirered/src/item.c:92 + if ItemsData.toNumericId(slot.id) ~= 0 then + if correctPocket ~= k then + misplaced[#misplaced + 1] = { id = slot.id, qty = tonumber(slot.qty) or 1, target = correctPocket } + else + keep[#keep + 1] = slot + end end end bag.pockets[k] = compact(keep) @@ -231,7 +234,8 @@ end function Bag.canAdd(bag, id, qty) bag = ensure(bag) qty = math.max(1, math.floor(tonumber(qty) or 1)) - if not id then return false end + -- pokefirered/src/item.c:92 + if not id or ItemsData.toNumericId(id) == 0 then return false end local pocket = ItemsData.pocketOf(id) local cap = ItemsData.CAPACITY[pocket] or 42 local slots = bag.pockets[pocket] or {} @@ -273,7 +277,7 @@ Bag.FLAG_SYS_GOT_BERRY_POUCH = FLAG_SYS_GOT_BERRY_POUCH function Bag.add(bag, id, qty) bag = ensure(bag) qty = math.max(0, math.floor(tonumber(qty) or 1)) - if qty <= 0 or not id then return false, 0 end + if qty <= 0 or not id or ItemsData.toNumericId(id) == 0 then return false, 0 end -- Prefer numeric FRLG id in slots local num = ItemsData.toNumericId(id) diff --git a/src/core/game3/battle/ai_items.lua b/src/core/game3/battle/ai_items.lua index 69fdc4e7..77b01ea5 100644 --- a/src/core/game3/battle/ai_items.lua +++ b/src/core/game3/battle/ai_items.lua @@ -14,73 +14,6 @@ AiItems.HEAL_HP_FULL = 0xFF AiItems.HEAL_HP_HALF = 0xFE AiItems.HEAL_HP_LVL_UP = 0xFD --- src/data/pokemon/item_effects.h:338 -local EFFECTS = { - [13] = { 0, 0, 0, 0, 0x04, 20 }, - [14] = { 0, 0, 0, 0x10, 0 }, - [15] = { 0, 0, 0, 0x08, 0 }, - [16] = { 0, 0, 0, 0x04, 0 }, - [17] = { 0, 0, 0, 0x20, 0 }, - [18] = { 0, 0, 0, 0x02, 0 }, - [19] = { 0, 0, 0, 0x3F, 0x04, 0xFF }, - [20] = { 0, 0, 0, 0, 0x04, 0xFF }, - [21] = { 0, 0, 0, 0, 0x04, 200 }, - [22] = { 0, 0, 0, 0, 0x04, 50 }, - [23] = { 0, 0, 0, 0x3F, 0 }, - [24] = { 0, 0, 0, 0, 0x44, 0xFE }, - [25] = { 0, 0, 0, 0, 0x44, 0xFF }, - [26] = { 0, 0, 0, 0, 0x04, 50 }, - [27] = { 0, 0, 0, 0, 0x04, 60 }, - [28] = { 0, 0, 0, 0, 0x04, 80 }, - [29] = { 0, 0, 0, 0, 0x04, 100 }, - [30] = { 0, 0, 0, 0, 0x04, 50 }, - [31] = { 0, 0, 0, 0, 0x04, 200 }, - [32] = { 0, 0, 0, 0x3F, 0 }, - [33] = { 0, 0, 0, 0, 0x44, 0xFF }, - [34] = { 0, 0, 0, 0, 0x18, 10 }, - [35] = { 0, 0, 0, 0, 0x18, 0x7F }, - [36] = { 0, 0, 0, 0, 0x08, 10 }, - [37] = { 0, 0, 0, 0, 0x08, 0x7F }, - [38] = { 0, 0, 0, 0x3F, 0 }, - [39] = { 0, 0, 0, 0x20, 0 }, - [40] = { 0, 0, 0, 0x01, 0 }, - [41] = { 0x80, 0, 0, 0, 0 }, - [44] = { 0, 0, 0, 0, 0x04, 20 }, - [45] = { 0x40, 0, 0, 0, 0x44, 0xFF }, - [63] = { 0, 0, 0, 0, 0x01 }, - [64] = { 0, 0, 0, 0, 0x02 }, - [65] = { 0, 0, 0, 0, 0 }, - [66] = { 0, 0, 0, 0, 0 }, - [67] = { 0, 0, 0, 0, 0 }, - [68] = { 0, 0, 0, 0x40, 0x44, 0xFD }, - [69] = { 0, 0, 0, 0, 0x20 }, - [70] = { 0, 0, 0, 0, 0 }, - [71] = { 0, 0, 0, 0, 0 }, - [73] = { 0, 0, 0, 0x80, 0 }, - [74] = { 0x20, 0, 0, 0, 0 }, - [75] = { 0x01, 0, 0, 0, 0 }, - [76] = { 0, 0x10, 0, 0, 0 }, - [77] = { 0, 0x01, 0, 0, 0 }, - [78] = { 0, 0, 0x10, 0, 0 }, - [79] = { 0, 0, 0x01, 0, 0 }, - [93] = { 0, 0, 0, 0, 0x80 }, - [94] = { 0, 0, 0, 0, 0x80 }, - [95] = { 0, 0, 0, 0, 0x80 }, - [96] = { 0, 0, 0, 0, 0x80 }, - [97] = { 0, 0, 0, 0, 0x80 }, - [98] = { 0, 0, 0, 0, 0x80 }, - [133] = { 0, 0, 0, 0x02, 0 }, - [134] = { 0, 0, 0, 0x20, 0 }, - [135] = { 0, 0, 0, 0x10, 0 }, - [136] = { 0, 0, 0, 0x08, 0 }, - [137] = { 0, 0, 0, 0x04, 0 }, - [138] = { 0, 0, 0, 0, 0x18, 10 }, - [139] = { 0, 0, 0, 0, 0x04, 10 }, - [140] = { 0, 0, 0, 0x01, 0 }, - [141] = { 0, 0, 0, 0x3F, 0 }, - [142] = { 0, 0, 0, 0, 0x04, 30 }, -} - local function band(a, b) a, b = math.floor(tonumber(a) or 0), math.floor(tonumber(b) or 0) local r, bit = 0, 1 @@ -102,10 +35,59 @@ local function item_num(item) end AiItems.itemNum = item_num +-- src/pokemon.c:4843 GetItemEffectParamOffset +local function param_offset(e, effectByte, effectBit) + local offset = 6 + for i = 0, 5 do + if i <= 3 then + if i == effectByte then return 0 end + elseif i == 4 then + local val = tonumber(e[5]) or 0 + if band(val, 0x20) ~= 0 then val = val - 0x20 end + local j = 0 + while val > 0 do + if val % 2 == 1 then + if j == 2 and band(val, 0x10) ~= 0 then val = val - 0x10 end + if j == 0 or j == 1 or j == 2 or j == 3 then + if i == effectByte and band(val, effectBit) ~= 0 then return offset end + offset = offset + 1 + elseif j == 7 then + if i == effectByte then return 0 end + end + end + j = j + 1 + val = math.floor(val / 2) + if i == effectByte then effectBit = math.floor(effectBit / 2) end + end + else + local val = tonumber(e[6]) or 0 + local j = 0 + while val > 0 do + if val % 2 == 1 then + if j <= 6 then + if i == effectByte and band(val, effectBit) ~= 0 then return offset end + offset = offset + 1 + elseif i == effectByte then + return 0 + end + end + j = j + 1 + val = math.floor(val / 2) + if i == effectByte then effectBit = math.floor(effectBit / 2) end + end + end + end + return offset +end + function AiItems.effect(item) - local e = EFFECTS[item_num(item)] - if not e then return nil end - return { e[1], e[2], e[3], e[4], e[5], hp = e[6] } + local ok, ItemsData = pcall(require, "src.core.game3.items_data") + local info = ok and ItemsData.info(item_num(item)) or nil + local e = info and info.effect + if type(e) ~= "table" or #e < 6 then return nil end + local off = band(e[5], 0x04) ~= 0 and param_offset(e, 4, 0x04) or 0 + return { tonumber(e[1]) or 0, tonumber(e[2]) or 0, tonumber(e[3]) or 0, tonumber(e[4]) or 0, tonumber(e[5]) or 0, + tonumber(e[6]) or 0, hp = off ~= 0 and tonumber(e[off + 1]) or nil } end -- src/battle_ai_switch_items.c:546 diff --git a/src/core/game3/battle/anim_port/g1_sprite.lua b/src/core/game3/battle/anim_port/g1_sprite.lua index c8fc3977..57d6b13e 100644 --- a/src/core/game3/battle/anim_port/g1_sprite.lua +++ b/src/core/game3/battle/anim_port/g1_sprite.lua @@ -873,13 +873,9 @@ local function clone_draw(s, vm) if s.visible == false then return end local side = s._cloneSide local sp = P.species(vm, side) - local okP, Pokemon = pcall(require, "src.core.game3.pokemon") - if not okP then return end local entry local okU, Ui = pcall(require, "src.core.game3.battle.ui") if okU and Ui.battlerPic then entry = Ui.battlerPic(side, nil, sp) end - if not entry and side == "player" and Pokemon.backPic then entry = Pokemon.backPic(sp) end - if not entry and Pokemon.frontPic then entry = Pokemon.frontPic(sp) end if not (entry and entry.image) then return end local cx, cy = s.x, s.y if okU and Ui.battlerSpriteCenter then diff --git a/src/core/game3/battle/anim_port/g2_pret.lua b/src/core/game3/battle/anim_port/g2_pret.lua index 86e55cbb..a91dc011 100644 --- a/src/core/game3/battle/anim_port/g2_pret.lua +++ b/src/core/game3/battle/anim_port/g2_pret.lua @@ -1740,14 +1740,10 @@ function P.AnimSpriteOnMonPos(s) end local function mon_pic(b) - local okP, Pokemon = pcall(require, "src.core.game3.pokemon") - if not okP or not Pokemon then return nil end local sp = P.species(b) local entry local okU, Ui = pcall(require, "src.core.game3.battle.ui") if okU and Ui.battlerPic then entry = Ui.battlerPic(b, nil, sp) end - if not entry and b == "player" and Pokemon.backPic then entry = Pokemon.backPic(sp) end - if not entry and Pokemon.frontPic then entry = Pokemon.frontPic(sp) end return entry and entry.image end diff --git a/src/core/game3/battle/anim_port/g3_e3b.lua b/src/core/game3/battle/anim_port/g3_e3b.lua index b0443d7b..dd7ef4a4 100644 --- a/src/core/game3/battle/anim_port/g3_e3b.lua +++ b/src/core/game3/battle/anim_port/g3_e3b.lua @@ -591,12 +591,13 @@ C.ReversalOrb = P.cb(function(s, vm) H.reversalOrbStep(s, vm) end) -function H.picImage(species, back) +function H.picImage(species, back, side) local ok, Pokemon = pcall(require, "src.core.game3.pokemon") if not ok or not species then return nil end + local picSp, shiny, personality = require("src.core.game3.battle.ui").sidePicArgs(side, species) local e - if back and Pokemon.backPic then e = Pokemon.backPic(species) end - if not e and Pokemon.frontPic then e = Pokemon.frontPic(species) end + if back and Pokemon.backPic then e = Pokemon.backPic(picSp, nil, shiny) end + if not e and Pokemon.frontPic then e = Pokemon.frontPic(picSp, nil, shiny, personality) end return e and e.image end @@ -654,7 +655,7 @@ T.RolePlaySilhouette = P.task(function(t, vm) local y = P.coordAtk(vm, P.COORD_Y) + H.picYOffset(species, isBackPic) local c = P.CloneMon(vm, atk) if c then - local img = H.picImage(species, isBackPic) + local img = H.picImage(species, isBackPic, P.tgt(vm)) if img then c.image = img end c.x, c.y = x, y c.ox, c.oy = 0, 0 diff --git a/src/core/game3/battle/anim_port/g3_e3c.lua b/src/core/game3/battle/anim_port/g3_e3c.lua index 4dfe4861..e1e3228c 100644 --- a/src/core/game3/battle/anim_port/g3_e3c.lua +++ b/src/core/game3/battle/anim_port/g3_e3c.lua @@ -937,13 +937,14 @@ T.GetReturnPowerLevel = P.task(function(t, vm) P.DestroyAnimVisualTask(t) end) -function H.monPicSprite(vm, species, isBack, x, y, z) +function H.monPicSprite(vm, species, isBack, x, y, z, side) local ok, Pokemon = pcall(require, "src.core.game3.pokemon") local img if ok and species then + local picSp, shiny, personality = require("src.core.game3.battle.ui").sidePicArgs(side, species) local e - if isBack and Pokemon.backPic then e = Pokemon.backPic(species) end - if not isBack and Pokemon.frontPic then e = Pokemon.frontPic(species) end + if isBack and Pokemon.backPic then e = Pokemon.backPic(picSp, nil, shiny) end + if not isBack and Pokemon.frontPic then e = Pokemon.frontPic(picSp, nil, shiny, personality) end img = e and e.image end local okc, pc = pcall(require, "src.core.game3.battle.pic_coords") @@ -999,7 +1000,7 @@ function H.snatchStep(t, vm) x = -32 z = 205 end - t._s2 = H.monPicSprite(vm, species, isBack, x, P.coord(vm, tgt, P.COORD_Y), z) + t._s2 = H.monPicSprite(vm, species, isBack, x, P.coord(vm, tgt, P.COORD_Y), z, atk) d[0] = d[0] + 1 elseif k == 2 then local s2 = t._s2 diff --git a/src/core/game3/battle/anim_port/g3_pret.lua b/src/core/game3/battle/anim_port/g3_pret.lua index b979b087..64b08c2b 100644 --- a/src/core/game3/battle/anim_port/g3_pret.lua +++ b/src/core/game3/battle/anim_port/g3_pret.lua @@ -195,15 +195,11 @@ function P.monCenter(vm, side) end function P.monImage(vm, side) - local ok, Pokemon = pcall(require, "src.core.game3.pokemon") - if not ok then return nil end local sp = P.species(vm, side) if not sp then return nil end local e local okU, Ui = pcall(require, "src.core.game3.battle.ui") if okU and Ui.battlerPic then e = Ui.battlerPic(side, nil, sp) end - if not e and side == "player" and Pokemon.backPic then e = Pokemon.backPic(sp) end - if not e and Pokemon.frontPic then e = Pokemon.frontPic(sp) end return e and e.image end diff --git a/src/core/game3/battle/catching.lua b/src/core/game3/battle/catching.lua index 111e96db..96a3a2f5 100644 --- a/src/core/game3/battle/catching.lua +++ b/src/core/game3/battle/catching.lua @@ -301,7 +301,7 @@ function Catching.storeCaught(session, foeBattler, ballId) mon.name = Pokemon.name(species) or "POKéMON" end - local wasCaught = Dex.registerCapture(session.dex, species) + local wasCaught = Dex.registerCapture(session.dex, species, nil, mon.personality) local firstTimeCaught = not wasCaught local location = "party" diff --git a/src/core/game3/battle/effects/special.lua b/src/core/game3/battle/effects/special.lua index 65301d70..7f207619 100644 --- a/src/core/game3/battle/effects/special.lua +++ b/src/core/game3/battle/effects/special.lua @@ -152,7 +152,9 @@ function Special.transform(ctx) user.ability = target.ability user.expTracedAbility = target.expTracedAbility user.transformed = true - user.expTransform = { species = target.species, type1 = target.type1, type2 = target.type2 } + user.expTransform = { species = target.species, type1 = target.type1, type2 = target.type2, + -- pokefirered/src/battle_script_commands.c:7416 + personality = target.mon and target.mon.personality } user.expDisabledMove = nil user.expDisableTurns = nil user.permanentSlots = { false, false, false, false } diff --git a/src/core/game3/battle/init.lua b/src/core/game3/battle/init.lua index 1c005cf3..4a01b6e9 100644 --- a/src/core/game3/battle/init.lua +++ b/src/core/game3/battle/init.lua @@ -449,9 +449,11 @@ function Battle.start(opts) and not st.oldManTutorial and not (st.ghostBattle and not st.ghostUnveiled) then local Dex = require("src.core.game3.dex") - Dex.setSeen(session.dex, foeMon.species or foeMon.speciesId) + Dex.handleSetPokedexFlag(session.dex, foeMon.species or foeMon.speciesId, false, foeMon.personality) local b3 = st.double and not st.absent[3] and st.battlers[3] - if b3 and b3.mon then Dex.setSeen(session.dex, b3.mon.species or b3.mon.speciesId) end + if b3 and b3.mon then + Dex.handleSetPokedexFlag(session.dex, b3.mon.species or b3.mon.speciesId, false, b3.mon.personality) + end end -- pokefirered/src/pokemon.c:1796 local wildMon = st.wild and st.enemy and st.enemy.mon @@ -1641,7 +1643,7 @@ local function step_action() else local BattleItems = require("src.core.game3.battle.items") local result, _msgs, endsTurn, endsBattle = BattleItems.use( - st, ad, bag, session, meta.itemId, meta.partySlot) + st, ad, bag, session, meta.itemId, meta.partySlot, nil, meta.moveSlot) if endsBattle then Battle._actions = {} if result == "catch" then @@ -2563,7 +2565,7 @@ function D.useBag(act) return D.afterEach() end local BattleItems = require("src.core.game3.battle.items") - local result = BattleItems.use(st, ad, bag, session, act.itemId, act.partySlot, act.battler) + local result = BattleItems.use(st, ad, bag, session, act.itemId, act.partySlot, act.battler, act.moveSlot) if result == "heal" then for _, id in ipairs(SEL_ORDER) do local b = State.battler(st, id) diff --git a/src/core/game3/battle/items.lua b/src/core/game3/battle/items.lua index 2b767042..88a397c6 100644 --- a/src/core/game3/battle/items.lua +++ b/src/core/game3/battle/items.lua @@ -66,13 +66,18 @@ function BattleItems.needsPartySelect(id) return false end -function BattleItems.canUseOn(st, itemId, partySlot, mon) +function BattleItems.canUseOn(st, itemId, partySlot, mon, moveSlot) if not mon or not itemId then return false, Strings("It won't have any effect.") end if mon.isEgg then return false, Strings("An EGG can't be used on.") end local hp = tonumber(mon.hp) or 0 local maxHp = tonumber(mon.maxHp or mon.maxhp) or 1 local mk = ItemsData.medicineKind(itemId) local use = ItemsData.fieldUseKind(itemId) + -- pokefirered/src/party_menu.c:4675 TryUsePPItemInBattle + if use == "pp" then + if ItemUse.ppItemHasEffect(mon, itemId, moveSlot or 1) then return true end + return false, Strings("It won't have any effect.") + end if mk == "revive" or use == "revive" then if hp > 0 then return false, Strings("It won't have any effect.") end return true @@ -122,7 +127,7 @@ end -- msgs: string list -- endsTurn: bool (enemy may still move unless endsBattle) -- endsBattle: bool -function BattleItems.use(st, adapter, bag, session, itemId, partySlot, battlerId) +function BattleItems.use(st, adapter, bag, session, itemId, partySlot, battlerId, moveSlot) local msgs = {} local function say(t) msgs[#msgs + 1] = t @@ -228,6 +233,31 @@ function BattleItems.use(st, adapter, bag, session, itemId, partySlot, battlerId -- Medicine / berries on party mon local use = ItemsData.fieldUseKind(itemId) local info = ItemsData.info(itemId) + -- pokefirered/src/party_menu.c:4675 TryUsePPItemInBattle + if use == "pp" then + local party = st.playerParty or (session and session.party) + if not partySlot then + return "need_slot", msgs, false, false + end + local mon = party and party[partySlot] + local battler + if st.player and st.player.partyIndex == partySlot then + battler = st.player + elseif st.double and st.battlers and st.battlers[2] and st.battlers[2].partyIndex == partySlot then + battler = st.battlers[2] + end + if battler then + local State = require("src.core.game3.battle.state") + State.syncBattlerToParty(battler, party) + end + if not mon or not ItemUse.applyPpItem(mon, itemId, moveSlot or 1, battler, session) then + say(Strings("It won't have any effect.")) + return "error", msgs, false, false + end + Bag.remove(bag, itemId, 1) + say(Strings("%s used\nthe %s!", tostring(session and session.name or "RED"), name)) + return "heal", msgs, true, false + end local bu = info and tonumber(info.battleUsage) or 0 if bu == 1 or use == "heal" or use == "status" or use == "revive" or (info and info.pocket == "BERRY_POUCH") then diff --git a/src/core/game3/battle/moves.lua b/src/core/game3/battle/moves.lua index f909e760..dab05ae1 100644 --- a/src/core/game3/battle/moves.lua +++ b/src/core/game3/battle/moves.lua @@ -143,29 +143,9 @@ Moves.BY_NUM = { [249] = "ROCK_SMASH", [250] = "WHIRLPOOL", [291] = "DIVE", } -local function load_lua(rel) - local paths = { rel, "mods/Kanto-Reforged/" .. rel } - for _, p in ipairs(paths) do - local f = io.open(p, "rb") - if f then - local src = f:read("*a") - f:close() - local chunk = load(src, "@" .. rel, "t", {}) - if chunk then - local ok, t = pcall(chunk) - if ok then return t end - end - end - end - return nil -end - function Moves.loadRomPack(cache) Moves._romLoaded = true local root = (Extract.CACHE_ROOT or "data/generated/gba") .. "/pokemon/battle_moves.lua" - if root:find("sevii", 1, true) then - root = "data/generated/gba/pokemon/battle_moves.lua" - end local pack if not cache or not cache.read then local okD, Dataset = pcall(require, "src.core.game3.dataset") @@ -183,7 +163,6 @@ function Moves.loadRomPack(cache) end end end - if not pack then pack = load_lua(root) end if pack and pack.moves then Moves._rom = pack.moves Moves._runReloadHooks() diff --git a/src/core/game3/battle/ui.lua b/src/core/game3/battle/ui.lua index 624d037e..b1f48449 100644 --- a/src/core/game3/battle/ui.lua +++ b/src/core/game3/battle/ui.lua @@ -162,6 +162,22 @@ local function battler_sprite_center(side, species, base, form, ghost) end Ui.battlerSpriteCenter = battler_sprite_center +-- pokefirered/src/battle_gfx_sfx_util.c:328, :715 +local function pic_args(battler, sp) + local mon = battler and battler.mon + local personality = mon and mon.personality + local tf = battler and battler.expTransform + if tf and tonumber(tf.species) == tonumber(sp) and tf.personality ~= nil then + personality = tf.personality + end + return Pokemon.picSpecies(sp, personality), Pokemon.isShiny(mon), personality +end +Ui.picArgs = pic_args + +function Ui.sidePicArgs(side, sp) + return pic_args((live_battler(side)), sp) +end + function Ui.battlerPic(side, battler, species) local b, st = live_battler(side) if type(side) == "number" then side = (side % 2 == 0) and "player" or "enemy" end @@ -173,9 +189,10 @@ function Ui.battlerPic(side, battler, species) local sp = tonumber(species) or (battler and tonumber(battler.species)) if not sp then return nil, 0, false end local form = (sp == SPECIES_CASTFORM) and castform_form(side, battler) or 0 + local picSp, shiny, personality = pic_args(battler, sp) local entry - if side == "player" and Pokemon.backPic then entry = Pokemon.backPic(sp, form) end - if not entry and Pokemon.frontPic then entry = Pokemon.frontPic(sp, form) end + if side == "player" and Pokemon.backPic then entry = Pokemon.backPic(picSp, form, shiny) end + if not entry and Pokemon.frontPic then entry = Pokemon.frontPic(picSp, form, shiny, personality) end return entry, form, false end @@ -410,7 +427,7 @@ local function open_battle_bag() BagMenu.show(bag, { session = session, battle = true, - onBattleUse = function(itemId, partySlot) + onBattleUse = function(itemId, partySlot, moveSlot) if itemId == nil then restore_action_menu() return @@ -420,6 +437,7 @@ local function open_battle_bag() user = "player", itemId = itemId, partySlot = partySlot, + moveSlot = moveSlot, } if is_double() then Ui._pendingCommand.battler = Ui._active or 0 end Ui._mode = "none" @@ -1718,11 +1736,12 @@ local function draw_mon_sprite(battler, base, back, id) if not entry and ghost and Pokemon.ghostPic then entry = Pokemon.ghostPic() end + local picSp, shiny, personality = pic_args(battler, sp) if not entry and back and Pokemon.backPic then - entry = Pokemon.backPic(sp, form) + entry = Pokemon.backPic(picSp, form, shiny) end if not entry then - entry = Pokemon.frontPic and Pokemon.frontPic(sp, form) + entry = Pokemon.frontPic and Pokemon.frontPic(picSp, form, shiny, personality) end if entry and entry.image then local a = (pres and pres.alpha) or 1 @@ -1943,7 +1962,6 @@ local function draw_ball_entry(ball) if img == nil then local candidates = { "data/generated/gba/intro/ball_poke.png", - "data/generated/gba/intro/ballPoke.png", } for _, rel in ipairs(candidates) do if love and love.filesystem and love.filesystem.getInfo(rel) then @@ -1969,22 +1987,6 @@ local function draw_ball_entry(ball) local blended = blend and BallOpen.setBlendShader(blend.coeff, blend.r, blend.g, blend.b) love.graphics.draw(img, Ui._ballQuads[key], bx, by, rot, 1, 1, 8, 8) if blended then love.graphics.setShader() end - else - -- Procedural 16x16 Poké Ball fallback - love.graphics.push() - love.graphics.translate(bx, by) - love.graphics.rotate(rot) - love.graphics.setColor(0.9, 0.2, 0.2, 1) - love.graphics.arc("fill", 0, 0, 7, math.pi, 0) - love.graphics.setColor(0.95, 0.95, 0.95, 1) - love.graphics.arc("fill", 0, 0, 7, 0, math.pi) - love.graphics.setColor(0.15, 0.15, 0.15, 1) - love.graphics.circle("line", 0, 0, 7) - love.graphics.rectangle("fill", -7, -1, 14, 2) - love.graphics.circle("fill", 0, 0, 2.5) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.circle("fill", 0, 0, 1.2) - love.graphics.pop() end love.graphics.setColor(1, 1, 1, 1) diff --git a/src/core/game3/battle_bridge.lua b/src/core/game3/battle_bridge.lua index 9d6171ac..b1df90e0 100644 --- a/src/core/game3/battle_bridge.lua +++ b/src/core/game3/battle_bridge.lua @@ -91,9 +91,12 @@ function BattleBridge.calcMoneyLossFrlg(session, hostSave) local maxLv = 1 local party = session and session.party if type(party) == "table" then + -- pokefirered/src/pokemon.c:6085 for _, mon in ipairs(party) do - local lv = tonumber(mon and mon.level) or 1 - if lv > maxLv then maxLv = lv end + if mon and not Pokemon.isEgg(mon) then + local lv = tonumber(mon.level) or 1 + if lv > maxLv then maxLv = lv end + end end end local badges = count_badges(session, hostSave) diff --git a/src/core/game3/collision.lua b/src/core/game3/collision.lua index 7afab963..10749acd 100644 --- a/src/core/game3/collision.lua +++ b/src/core/game3/collision.lua @@ -685,6 +685,7 @@ function Collision.tryConnection(game, fromX, fromY, dir, run) Player.running = run and true or false Player.jumping = false Player.dismounting = Player.surfing and not landingWater or false + if Player.dismounting then require("src.core.game3.audio").stopSurfMusic() end Player.spriteYOffset = 0 Player.stepFrames = run and RUN_FRAMES or WALK_FRAMES Player.syncSavePosition(g) diff --git a/src/core/game3/dataset.lua b/src/core/game3/dataset.lua index be3fbd0d..f606005b 100644 --- a/src/core/game3/dataset.lua +++ b/src/core/game3/dataset.lua @@ -8,6 +8,15 @@ local MapIds = require("src.core.game3.map_ids") local Dataset = {} local function diskFallback(rel) + local override = Dataset.cacheRootOverride + if override then + local f = io.open(override .. "/" .. rel:gsub("^data/generated/gba/", ""), "rb") + if f then + local data = f:read("*a") + f:close() + if type(data) == "string" and #data > 0 then return data end + end + end local f = io.open(rel, "rb") or io.open("data/generated/gba/" .. rel, "rb") if f then local data = f:read("*a") diff --git a/src/core/game3/dex.lua b/src/core/game3/dex.lua index e691d9ff..fbebf608 100644 --- a/src/core/game3/dex.lua +++ b/src/core/game3/dex.lua @@ -113,6 +113,39 @@ function Dex.setCaught(dex, species) bit_set(dex.owned, sp, true) end +local SPECIES_UNOWN = 201 +local SPECIES_SPINDA = 308 + +local function record_personality(dex, species, personality) + local p = (tonumber(personality) or 0) % 4294967296 + if species == SPECIES_UNOWN then dex.unownPersonality = p end + if species == SPECIES_SPINDA then dex.spindaPersonality = p end +end + +-- pokefirered/src/pokemon.c:6233 +function Dex.handleSetPokedexFlag(dex, species, caught, personality) + if not dex then return end + local sp = resolve_species_id(species) + if not sp then return end + if caught then + if Dex.isCaught(dex, sp) then return end + Dex.setCaught(dex, sp) + else + if Dex.isSeen(dex, sp) then return end + Dex.setSeen(dex, sp) + end + record_personality(dex, sp, personality) +end + +-- pokefirered/src/pokedex_screen.c:2197 +function Dex.defaultPersonality(dex, species) + species = tonumber(species) + if not dex then return 0 end + if species == SPECIES_SPINDA then return tonumber(dex.spindaPersonality) or 0 end + if species == SPECIES_UNOWN then return tonumber(dex.unownPersonality) or 0 end + return 0 +end + function Dex.isSeen(dex, species) if not dex then return false end return bit_get(dex.seen, species) @@ -143,7 +176,7 @@ function Dex.registerEncounter(dex, species, session) end --- Register a capture; returns whether it was already caught. -function Dex.registerCapture(dex, species, session) +function Dex.registerCapture(dex, species, session, personality) if not dex or not species then return false end local sp = tonumber(species) or 1 if sp > (Dex.KANTO_MAX or 151) then @@ -154,6 +187,8 @@ function Dex.registerCapture(dex, species, session) end local wasCaught = Dex.isCaught(dex, species) Dex.setCaught(dex, species) + -- pokefirered/src/battle_script_commands.c:9657 + if not wasCaught then record_personality(dex, resolve_species_id(species), personality) end return wasCaught end diff --git a/src/core/game3/field.lua b/src/core/game3/field.lua index 8e14285b..d6d37922 100644 --- a/src/core/game3/field.lua +++ b/src/core/game3/field.lua @@ -2,7 +2,7 @@ local Player = require("src.core.game3.player") local ModRuntime = require("src.mods.Runtime") -local Strings = require("src.core.Strings") +local RomText = require("src.core.game3.rom_text") local Field = {} @@ -97,6 +97,9 @@ function Field.update(_dt) local Compat = package.loaded["src.mods.Gen3Compat"] if Compat and Compat.worldTick then Compat.worldTick(_dt) end + -- pokefirered/src/field_tasks.c:66 + require("src.core.game3.forced_movement").runStepCallback(game) + local Space = package.loaded["src.core.game3.scripting.space"] if Space and Space.vm then local ad = Space.vm.adapters @@ -188,6 +191,8 @@ function Field.update(_dt) end if Message and Message.tick then Message.tick() end + Field.pollObtainSequence() + require("src.core.game3.itemfinder").update() end function Field.lock() @@ -280,121 +285,207 @@ function Field.hiddenItemAt(game, x, y, elevation) return hidden_item_at(game or Field._game, x, y, elevation) end -function Field.pickUpHiddenItem(game, hidden) - if not hidden then return false end +-- pokefirered/include/constants/menu.h:107 +local STDSTRING_COINS = 23 +local POCKET_STDSTRING = { + ITEMS = 24, KEY_ITEMS = 25, POKE_BALLS = 26, TM_CASE = 27, BERRY_POUCH = 28, +} + +local function std_string(id) + return require("src.core.game3.scripting.adapters").stdString(id) +end + +local function player_name(session) + return (session and (session.playerName or session.name)) or "RED" +end + +Field._obtainSeq = nil + +-- pokefirered/data/scripts/obtain_item.inc:170 +local function message_then_after_fanfare(first, second, delay) + local Message = require("src.ui.game3.message") + Message.showStay(first, { session = Field._session }) + Field._obtainSeq = { second = second, delay = delay or 0 } +end + +function Field.pollObtainSequence() + local seq = Field._obtainSeq + if not seq then return end + local Message = package.loaded["src.ui.game3.message"] + if not (Message and Message.isOpen()) then + Field._obtainSeq = nil + return + end + if not Message.isWaiting() then return end + local Audio = package.loaded["src.core.game3.audio"] + if Audio and Audio.isFanfareFinished and not Audio.isFanfareFinished() then return end + if seq.delay > 0 then + seq.delay = seq.delay - 1 + return + end + Field._obtainSeq = nil + Message.show(seq.second, { session = Field._session, done = function() Message.close() end }) +end + +local function hidden_flag(hidden) + return hidden.flag or (hidden.hiddenItemId and (0x3E8 + hidden.hiddenItemId)) +end + +-- pokefirered/src/field_specials.c:158 +local function set_hidden_item_flag(game, hidden) + local session = Field._session + local store, Space = hidden_item_store(session) + local flag = hidden_flag(hidden) + if flag and store then + require("src.core.game3.scripting.flags").setFlag(store, nil, flag, true) + if Space then Space.persistSession(nil, game or Field._game) end + end +end + +-- pokefirered/data/scripts/obtain_item.inc:197 +local function pick_up_hidden_coins(game, hidden, qty) local session = Field._session local Bag = require("src.core.game3.bag") local Flags = require("src.core.game3.scripting.flags") + local Corner = require("src.core.game3.scripting.natives_corner") + local Message = require("src.ui.game3.message") + local Audio = require("src.core.game3.audio") + local store = hidden_item_store(session) + local ctx = { playerName = player_name(session), stringVars = { tostring(qty), std_string(STDSTRING_COINS) } } + local found = RomText.box("Text_FoundXCoins", ctx) + local function refuse(key) + Message.show(found .. "\f" .. RomText.box(key, ctx), { session = session, done = function() Message.close() end }) + return true + end + -- pokefirered/include/constants/flags.h:604 + if not Flags.getFlag(store, nil, 0x243) then + return refuse("Text_NothingToPutThemIn") + end + if Corner.checkAddCoins(Bag.Coins.get(session), qty) == 0 then + return refuse("Text_CoinCaseIsFull") + end + Bag.Coins.add(session, qty) + set_hidden_item_flag(game, hidden) + Audio.playFanfare(257) + message_then_after_fanfare(found, RomText.box("Text_PutCoinsAwayInCoinCase", ctx)) + return true +end + +-- pokefirered/data/scripts/obtain_item.inc:158 +local function pick_up_hidden_item(game, hidden, qty, foundKey, delay) + local session = Field._session + local Bag = require("src.core.game3.bag") local Items = require("src.core.game3.items") local ItemsData = require("src.core.game3.items_data") local Message = require("src.ui.game3.message") local Audio = require("src.core.game3.audio") - local store, Space = hidden_item_store(session) - - local flag = hidden.flag or (hidden.hiddenItemId and (0x3E8 + hidden.hiddenItemId)) - if flag and Flags.getFlag(store, nil, flag) then - return false - end - local itemId = hidden.item - local qty = hidden.quantity or 1 + local ctx = { playerName = player_name(session), stringVars = { "", Items.displayName(itemId) } } + local found = RomText.box(foundKey, ctx) local bag = session and session.bag - if not bag or not Bag.add(bag, itemId, qty) then - Message.show("Too bad!\nThe BAG is full…", { - done = function() - Message.close() - end, - }) + -- pokefirered/data/scripts/obtain_item.inc:190 + Message.show(found .. "\f" .. RomText.box("Text_TooBadBagFull", ctx), + { session = session, done = function() Message.close() end }) return true end - - if flag and store then - Flags.setFlag(store, nil, flag, true) - if Space then Space.persistSession(nil, game or Field._game) end - end - + set_hidden_item_flag(game, hidden) + -- pokefirered/data/scripts/obtain_item.inc:27 Audio.playFanfare(257) - - local playerName = (session and (session.playerName or session.name)) or "RED" - local itemName = Items.displayName(itemId) - local pocket = ItemsData.pocketOf(itemId) - local pocketLabel = ItemsData.POCKET_LABEL[pocket] or "ITEMS POCKET" - local text = string.format("%s found one\n%s!\f%s put away the\n%s in the %s.", playerName, itemName, playerName, itemName, pocketLabel) - - Message.show(text, { - done = function() - Message.close() - end, - }) - - if session then - local okQ, Q = pcall(require, "src.core.game3.quest_log_recorder") - if okQ and Q and Q.event then - Q.event(session, "UsedTheItem", { itemName, "" }) - end - end - + ctx.stringVars[3] = std_string(POCKET_STDSTRING[ItemsData.pocketOf(itemId)] or POCKET_STDSTRING.ITEMS) + message_then_after_fanfare(found, RomText.box("Text_PutItemAway", ctx), delay) return true end +-- pokefirered/data/scripts/obtain_item.inc:148 +function Field.pickUpHiddenItem(game, hidden) + if not hidden then return false end + local session = Field._session + local Flags = require("src.core.game3.scripting.flags") + local store = hidden_item_store(session) + local flag = hidden_flag(hidden) + if flag and Flags.getFlag(store, nil, flag) then + return false + end + local qty = hidden.quantity or 1 + if (tonumber(hidden.item) or 0) == 0 then + return pick_up_hidden_coins(game, hidden, qty) + end + return pick_up_hidden_item(game, hidden, qty, "Text_FoundOneItem") +end + +-- pokefirered/data/scripts/itemfinder.inc:1 +function Field.digUpUnderfootItem(game, hidden) + if not hidden then return false end + local Flags = require("src.core.game3.scripting.flags") + local flag = hidden_flag(hidden) + if flag and Flags.getFlag(hidden_item_store(Field._session), nil, flag) then + return false + end + -- pokefirered/src/itemfinder.c:245 + return pick_up_hidden_item(game, hidden, 1, "Text_DugUpItemFromGround", 60) +end + +-- pokefirered/src/itemfinder.c:131 function Field.useItemfinder(session, showOWMessage) session = session or Field._session local P = package.loaded["src.core.game3.player"] local px = (session and (session.playerX or session.x)) or (P and (P.cellX or P.x)) or 0 local py = (session and (session.playerY or session.y)) or (P and (P.cellY or P.y)) or 0 - local events = get_map_bg_events(Field._game, session and session.map) + local game = Field._game + local mapId = session and session.map local Flags = require("src.core.game3.scripting.flags") - local Audio = require("src.core.game3.audio") local Message = require("src.ui.game3.message") + local Itemfinder = require("src.core.game3.itemfinder") + local Map = require("src.core.game3.map") local store = hidden_item_store(session) + local layout = mapId and Map.ensureMidLayout(game, mapId) + local result = Itemfinder.scan({ + px = px, + py = py, + events = get_map_bg_events(game, mapId), + flagSet = function(ev) + local flag = hidden_flag(ev) + return not flag or Flags.getFlag(store, nil, flag) + end, + width = layout and layout.width, + height = layout and layout.height, + neighbors = Map.neighbors, + eventsFor = function(id) return get_map_bg_events(game, id) end, + }) - local found = nil - local underfoot = false - local minDistance = 999999 - - for _, ev in ipairs(events) do - if (ev.type == "hidden_item" or ev.kind == 7) then - local flag = ev.flag or (ev.hiddenItemId and (0x3E8 + ev.hiddenItemId)) - if flag and not Flags.getFlag(store, nil, flag) then - local dx = ev.x - px - local dy = ev.y - py - local dist = math.abs(dx) + math.abs(dy) - if math.abs(dx) <= 7 and math.abs(dy) <= 7 then - if dx == 0 and dy == 0 then - found = ev - underfoot = true - minDistance = 0 - break - elseif dist < minDistance then - found = ev - underfoot = false - minDistance = dist - end - end - end - end - end - - if found then - Audio.playSe(65) -- SE_ITEMFINDER - local text - if underfoot then - text = "Oh! The ITEMFINDER's responding!\nThere's an item buried right beneath your feet!" - else - text = "Huh? The ITEMFINDER's responding!\nThere's an item buried around here!" - end + if not result then + local text = RomText.box("gText_NopeTheresNoResponse") if showOWMessage then - Message.show(text, { done = function() Message.close() end }) - end - return true, "itemfinder", text, { x = found.x, y = found.y, underfoot = underfoot } - else - local text = "… … … …Nope!\nThere's no response." - if showOWMessage then - Message.show(text, { done = function() Message.close() end }) + -- pokefirered/src/itemfinder.c:150 + Message.show(text, { session = session, done = function() Message.close() end }) end return false, "itemfinder", text, nil end + local key = result.underfoot and "gText_ItemfinderShakingWildly" or "gText_ItemfinderResponding" + local text = RomText.box(key) + if showOWMessage then + Field.lock() + Itemfinder.start({ + result = result, + facing = P and P.facing, + onMessage = function(msgKey, done) + Message.show(RomText.box(msgKey), { session = session, done = done }) + end, + onDone = function() + if result.underfoot then + -- pokefirered/src/itemfinder.c:499 + Field.digUpUnderfootItem(game, result.item) + else + -- pokefirered/src/itemfinder.c:485 + Message.close() + end + Field.unlock() + end, + }) + end + local info = { x = px + result.itemX, y = py + result.itemY, underfoot = result.underfoot } + return true, "itemfinder", text, info end --- Step-onto coord events (Pallet Oak gate, etc.). Returns true if a script started. @@ -572,6 +663,65 @@ end local inInteract = false +-- pokefirered/src/field_control_avatar.c:787 +local MB_SIGNPOST = 0x84 +local MB_POKEMON_CENTER_SIGN = 0x87 +local MB_POKEMART_SIGN = 0x88 +local WALK_INTO_SIGN = { + [MB_POKEMON_CENTER_SIGN] = "EventScript_PokecenterSign", + [MB_POKEMART_SIGN] = "EventScript_PokemartSign", + [0x91] = "EventScript_Indigo_UltimateGoal", + [0x92] = "EventScript_Indigo_HighestAuthority", +} + +-- pokefirered/src/field_control_avatar.c:745 +function Field.tryWalkIntoSign(game, dir, probe) + game = game or Field._game + if dir ~= "up" and dir ~= "down" then return false end + local input = game and game.input + if input and input.isDown and (input:isDown("left") or input:isDown("right")) then return false end + if not Field.running or Field.locked then return false end + local Space = package.loaded["src.core.game3.scripting.space"] + if not (Space and Space.vm) then return false end + if Space.vm.isRunning and Space.vm:isRunning() then return false end + local P = require("src.core.game3.player") + local Collision = require("src.core.game3.collision") + local fx, fy = facing_cell(P.cellX, P.cellY, dir) + local behavior = Collision.behavior(fx, fy) + local key + if behavior == MB_POKEMON_CENTER_SIGN or behavior == MB_POKEMART_SIGN then + -- pokefirered/src/metatile_behavior.c:721 + if dir == "up" then key = WALK_INTO_SIGN[behavior] end + elseif WALK_INTO_SIGN[behavior] then + key = WALK_INTO_SIGN[behavior] + elseif behavior == MB_SIGNPOST then + -- pokefirered/src/field_control_avatar.c:815 + local layout = Collision._mapDef and Collision._mapDef.midLayout + local elevation = layout and layout:elevAt(P.cellX, P.cellY) or 0 + if elevation == 0 then elevation = P.elevation or 0 end + for _, ev in ipairs(get_map_bg_events(game)) do + if ev.scriptKey and ev.x == fx and ev.y == fy + and (not ev.elevation or ev.elevation == 0 or ev.elevation == elevation) then + key = ev.scriptKey + break + end + end + end + if not key then return false end + if probe then return true end + local facingDir = (dir == "up") and 2 or 1 + if not Space.startScript(key, nil, facingDir) then return false end + -- pokefirered/src/script.c:260 + local ctx = Space.vm.ctx + if ctx then + ctx.walkAwayFromSignInhibitTimer = 6 + ctx.msgBoxIsCancelable = true + ctx.canWalkAway = true + end + interacted(fx, fy, "sign", key) + return true +end + function Field.interact(game) game = game or Field._game if not Field.running then return false end @@ -597,7 +747,7 @@ function Field.interact(game) if not Space.active or not Space.startScript then return false end local P = require("src.core.game3.player") - if P.moving then return false end + if P.moving or P.boulderPush then return false end local Objects = require("src.core.game3.objects") local Collision = require("src.core.game3.collision") @@ -700,13 +850,9 @@ function Field.interact(game) end end - -- Hidden items: check facing tile first, then underfoot tile + -- pokefirered/src/field_control_avatar.c:498 local hidden = hidden_item_at(game, fx, fy, elevation) - if not hidden then - local pElev = layout and layout:elevAt(P.cellX, P.cellY) or P.elevation or 0 - hidden = hidden_item_at(game, P.cellX, P.cellY, pElev) - end - if hidden then + if hidden and not hidden.underfoot then if Field.pickUpHiddenItem(game, hidden) then interacted(hidden.x, hidden.y, "hidden_item", hidden) return true @@ -781,56 +927,68 @@ function Field.executeFieldMove(payload) Q.event(Field._session,key,{require("src.core.game3.pokemon").displayMonName(payload.mon), Q.location(Field._game,Field._session)}) end + -- pokefirered/src/fldeff_rocksmash.c:39 + local function showMon(fn, opts) + opts = opts or {} + require("src.core.game3.field_move_show_mon").start(payload.mon, { + pose = opts.pose ~= false, noDuck = opts.noDuck, + }, fn) + end if act == "cut_tree" then Field.locked = true - P.startFieldMove(28) - if payload.se then Audio.playSe(payload.se) end - local target = payload.target - local tx = target and (target.cellX or target.x or (target.def and target.def.x)) or P.cellX - local ty = target and (target.cellY or target.y or (target.def and target.def.y)) or P.cellY + -- pokefirered/src/fldeff_cut.c:183 + showMon(function() + if payload.se then Audio.playSe(payload.se) end + local target = payload.target + local tx = target and (target.cellX or target.x or (target.def and target.def.x)) or P.cellX + local ty = target and (target.cellY or target.y or (target.def and target.def.y)) or P.cellY - FieldEffects.startCutTree(target, tx, ty, function() - if target then - local lid = target.localId or (target.def and (target.def.localId or target.def.index)) - if lid then Objects.removeObject(lid) end - end - Field.locked = false - if payload.text then - Message.show(payload.text, function() Message.close() end) - end + FieldEffects.startCutTree(target, tx, ty, function() + if target then + local lid = target.localId or (target.def and (target.def.localId or target.def.index)) + if lid then Objects.removeObject(lid) end + end + Field.locked = false + if payload.text then + Message.show(payload.text, function() Message.close() end) + end + end) end) elseif act == "cut_grass" then Field.locked = true - P.startFieldMove(24) - if payload.se then Audio.playSe(payload.se) end - local Map = require("src.core.game3.map") - local def = Map.currentDef() - local layout = def and def.midLayout - if layout and layout.midAt and layout.setMidAt then - local Collision = require("src.core.game3.collision") - local FieldMoves = require("src.core.game3.field_moves") - FieldMoves.mowGrass3x3(P.cellX, P.cellY, function(x, y) return layout:midAt(x, y) end, - function(x, y, mid) layout:setMidAt(x, y, mid) end, - function(x, y) return Collision.isGrass(x, y) end) - local okFv, FieldView = pcall(require, "src.core.game3.field_view") - if okFv and FieldView then FieldView._nativeDirty = true end - end - FieldEffects.startCutGrass(P.cellX, P.cellY, function() - Field.locked = false - if payload.text then - Message.show(payload.text, function() Message.close() end) + -- pokefirered/src/fldeff_cut.c:169 + showMon(function() + if payload.se then Audio.playSe(payload.se) end + local Map = require("src.core.game3.map") + local def = Map.currentDef() + local layout = def and def.midLayout + if layout and layout.midAt and layout.setMidAt then + local Collision = require("src.core.game3.collision") + local FieldMoves = require("src.core.game3.field_moves") + FieldMoves.mowGrass3x3(P.cellX, P.cellY, function(x, y) return layout:midAt(x, y) end, + function(x, y, mid) layout:setMidAt(x, y, mid) end, + function(x, y) return Collision.isGrass(x, y) end) + local okFv, FieldView = pcall(require, "src.core.game3.field_view") + if okFv and FieldView then FieldView._nativeDirty = true end end + FieldEffects.startCutGrass(P.cellX, P.cellY, function() + Field.locked = false + if payload.text then + Message.show(payload.text, function() Message.close() end) + end + end) end) elseif act == "dotted_hole" then -- pokefirered/src/fldeff_cut.c:194 Field.locked = true - P.startFieldMove(24) - if payload.se then Audio.playSe(payload.se) end - FieldEffects.startCutGrass(P.cellX, P.cellY, function() - Field.openDottedHoleDoor() - if payload.text then - Message.show(payload.text, function() Message.close() end) - end + showMon(function() + if payload.se then Audio.playSe(payload.se) end + FieldEffects.startCutGrass(P.cellX, P.cellY, function() + Field.openDottedHoleDoor() + if payload.text then + Message.show(payload.text, function() Message.close() end) + end + end) end) elseif act == "fly" then -- pokefirered/src/region_map.c:3873 CB2_OpenFlyMap @@ -839,7 +997,7 @@ function Field.executeFieldMove(payload) local shown = okRm and RegionMap and RegionMap.show and pcall(RegionMap.show, { session = Field._session, mode = "fly", - onPick = function(section) Field.flyTo(section) end, + onPick = function(section) Field.flyTo(section, payload.mon) end, onClose = function() Field.locked = false end, }) if not shown then @@ -849,63 +1007,71 @@ function Field.executeFieldMove(payload) end elseif act == "rock_smash" then Field.locked = true - P.startFieldMove(28) - if payload.se then Audio.playSe(payload.se) end - local target = payload.target - local tx = target and (target.cellX or target.x or (target.def and target.def.x)) or P.cellX - local ty = target and (target.cellY or target.y or (target.def and target.def.y)) or P.cellY + -- pokefirered/src/fldeff_rocksmash.c:123 + showMon(function() + if payload.se then Audio.playSe(payload.se) end + local target = payload.target + local tx = target and (target.cellX or target.x or (target.def and target.def.x)) or P.cellX + local ty = target and (target.cellY or target.y or (target.def and target.def.y)) or P.cellY - FieldEffects.startRockSmash(target, tx, ty, function() - if target then - local lid = target.localId or (target.def and (target.def.localId or target.def.index)) - if lid then Objects.removeObject(lid) end - end - Field.locked = false - -- pokefirered/data/scripts/field_moves.inc:88 - if payload.text then - Message.show(payload.text, function() - Message.close() + FieldEffects.startRockSmash(target, tx, ty, function() + if target then + local lid = target.localId or (target.def and (target.def.localId or target.def.index)) + if lid then Objects.removeObject(lid) end + end + Field.locked = false + -- pokefirered/data/scripts/field_moves.inc:88 + if payload.text then + Message.show(payload.text, function() + Message.close() + Field.tryRockSmashEncounter() + end) + else Field.tryRockSmashEncounter() - end) - else - Field.tryRockSmashEncounter() - end + end + end) end) elseif act == "strength" then Field.locked = true - P.startFieldMove(24) - if payload.flag then - local Space = package.loaded["src.core.game3.scripting.space"] - local Flags = require("src.core.game3.scripting.flags") - if Space and Space.store then - Flags.setFlag(Space.store, nil, payload.flag, true) + -- pokefirered/src/fldeff_strength.c:34 + showMon(function() + if payload.flag then + local Space = package.loaded["src.core.game3.scripting.space"] + local Flags = require("src.core.game3.scripting.flags") + if Space and Space.store then + Flags.setFlag(Space.store, nil, payload.flag, true) + end + if Field._session and Field._session.flags then + Field._session.flags[payload.flag] = true + end end - if Field._session and Field._session.flags then - Field._session.flags[payload.flag] = true - end - end - if payload.text then - Message.show(payload.text, function() - Message.close() - Field.locked = false - end) - else - Field.locked = false - end - elseif act == "surf" then - Field.locked = true - P.startSurfing(Field._game, function() - Field.locked = false if payload.text then - Message.show(payload.text, function() Message.close() end) + Message.show(payload.text, function() + Message.close() + Field.locked = false + end) + else + Field.locked = false end end) + elseif act == "surf" then + Field.locked = true + Audio.startSurfMusic() + -- pokefirered/src/field_effect.c:3020 + showMon(function() + P.startSurfing(Field._game, function() + Field.locked = false + if payload.text then + Message.show(payload.text, function() Message.close() end) + end + end) + end, { noDuck = true }) elseif act == "waterfall" then -- pokefirered/data/scripts/field_moves.inc:178 Field.locked = true local function ride() - P.startFieldMove(28) - Field.rideWaterfall("up", 28) + -- pokefirered/src/field_effect.c:1627 + showMon(function() Field.rideWaterfall("up", 0) end, { pose = false }) end if payload.text then Message.show(payload.text, function() @@ -917,47 +1083,54 @@ function Field.executeFieldMove(payload) end elseif act == "flash" then Field.locked = true - P.startFieldMove(24) - if payload.se then Audio.playSe(payload.se) end - if payload.flag then - local Space = package.loaded["src.core.game3.scripting.space"] - local Flags = require("src.core.game3.scripting.flags") - if Space and Space.store then - Flags.setFlag(Space.store, nil, payload.flag, true) - end - end -- pokefirered/src/fldeff_flash.c:177 - if payload.text then - Message.show(payload.text, function() Message.close() end) - end - FieldEffects.startFlash(function() - Field.locked = false + showMon(function() + if payload.se then Audio.playSe(payload.se) end + if payload.flag then + local Space = package.loaded["src.core.game3.scripting.space"] + local Flags = require("src.core.game3.scripting.flags") + if Space and Space.store then + Flags.setFlag(Space.store, nil, payload.flag, true) + end + end + if payload.text then + Message.show(payload.text, function() Message.close() end) + end + FieldEffects.startFlash(function() + Field.locked = false + end) end) elseif act == "teleport" or act == "dig" then Field.locked = true - if payload.se then Audio.playSe(payload.se) end - FieldEffects.startWarpSpin(act, function() - Field.locked = false - -- pokefirered/src/field_effect.c:2126 SetWarpDestinationToEscapeWarp - Field.respawnAtHeal({ fieldMove = true, warp = payload.warp }) - end) - if payload.text then - Message.show(payload.text, function() Message.close() end) - end - elseif act == "sweet_scent" then - Field.locked = true - P.startFieldMove(24) - if payload.se then Audio.playSe(payload.se) end - FieldEffects.startSweetScent(function() - Field.locked = false - local okE, Encounters = pcall(require, "src.core.game3.encounters") - if okE and Encounters and Encounters.tryBattle then - Encounters.tryBattle(Field._game, true) + -- pokefirered/src/fldeff_dig.c:32 + -- pokefirered/src/fldeff_teleport.c:31 + showMon(function() + if payload.se then Audio.playSe(payload.se) end + FieldEffects.startWarpSpin(act, function() + Field.locked = false + -- pokefirered/src/field_effect.c:2126 + Field.respawnAtHeal({ fieldMove = true, warp = payload.warp }) + end) + if payload.text then + Message.show(payload.text, function() Message.close() end) + end + end) + elseif act == "sweet_scent" then + Field.locked = true + -- pokefirered/src/fldeff_sweetscent.c:44 + showMon(function() + if payload.se then Audio.playSe(payload.se) end + FieldEffects.startSweetScent(function() + Field.locked = false + local okE, Encounters = pcall(require, "src.core.game3.encounters") + if okE and Encounters and Encounters.tryBattle then + Encounters.tryBattle(Field._game, true) + end + end) + if payload.text then + Message.show(payload.text, function() Message.close() end) end end) - if payload.text then - Message.show(payload.text, function() Message.close() end) - end end end @@ -1104,14 +1277,14 @@ function Field.updateFishing() if (not hasMons) or (Rng.Random() % 2 == 1) then -- pokefirered/src/field_player_avatar.c:1890 Fishing12 f.anim, f.animT = "putaway", 0 - -- pokefirered/src/strings.c:1060 gText_NotEvenANibble - Message.show(Strings("Not even a nibble…"), function() fishingStop() end) + -- pokefirered/src/field_player_avatar.c:1895 + Message.show(RomText.box("gText_NotEvenANibble"), function() fishingStop() end) else -- pokefirered/src/field_player_avatar.c:1791 f.anim, f.animT = "hooked", 0 - -- pokefirered/src/strings.c:1059 gText_PokemonOnHook + -- pokefirered/src/field_player_avatar.c:1848 local rod = f.rod - Message.show(Strings("A POKéMON's on the hook!"), function() + Message.show(RomText.box("gText_PokemonOnHook"), function() fishingStop() Field.tryFishingEncounter(rod) end) @@ -1253,7 +1426,7 @@ function Field.flyDestination(section) end -- pokefirered/src/field_effect.c:1065 ReturnToFieldFromFlyMapSelect -function Field.flyTo(section) +function Field.flyTo(section, mon) local dest = Field.flyDestination(section) local Message = require("src.ui.game3.message") if not dest then @@ -1281,8 +1454,11 @@ function Field.flyTo(section) end) end if love and love.graphics and FieldEffects.startFlyTakeoff then - Player.setVisible(false) - FieldEffects.startFlyTakeoff(land, nil) + -- pokefirered/src/field_effect.c:3241 + require("src.core.game3.field_move_show_mon").start(mon, { pose = true }, function() + Player.setVisible(false) + FieldEffects.startFlyTakeoff(land, nil) + end) else land() Field._flyLanding = false @@ -1425,7 +1601,13 @@ function Field.respawnAtHeal(opts) if not session then return end local HealLocations = require("src.core.game3.heal_locations") HealLocations.normalizeSession(session) - if not (opts and opts.fieldMove) then + local whiteOut = not (opts and opts.fieldMove) + if whiteOut then + -- pokefirered/src/overworld.c:1556 + local Space = package.loaded["src.core.game3.scripting.space"] + if Space and Space.vm and Space.vm:isRunning() then Space.vm:halt(true) end + -- pokefirered/src/overworld.c:252 + Field.resetEliteFour() -- pokefirered/src/overworld.c:1553 CB2_WhiteOut local okS, Safari = pcall(require, "src.core.game3.safari") if okS and Safari and Safari.reset then Safari.reset(session) end @@ -1453,14 +1635,16 @@ function Field.respawnAtHeal(opts) hx = tonumber(warp.x) or hx hy = tonumber(warp.y) or hy end + -- pokefirered/src/overworld.c:1555 + local facing = whiteOut and "up" or "down" Map.load(Field._mod, Field._game, mapId, { x = hx, y = hy, - facing = "down", + facing = facing, depth1Connections = true, heal = true, }) - Player.reset(hx, hy, "down") + Player.reset(hx, hy, facing) Player.syncToHost(Field._game) -- pokefirered/src/heal_location.c:119 SetWhiteoutRespawnHealerNpcAsLastTalked local healerId = tonumber(session.healHealerLocalId) @@ -1472,9 +1656,36 @@ function Field.respawnAtHeal(opts) Flags.setVar(Space.store, Space.vm and Space.vm.ctx or nil, Ctx.VAR_LAST_TALKED, healerId) end end + if whiteOut then + -- pokefirered/src/overworld.c:1558 + local home = HealLocations.get(1) + require("src.ui.game3.whiteout_rush").start(Field._game, session, { + home = home and home.map == mapId, + healerLocalId = healerId, + }) + end -- Map.load already locked; ON_FRAME / releaseall own unlock. end +-- data/scripts/hall_of_fame.inc:24 +local CHAMPION_TRAINERS = { 438, 439, 440, 739, 740, 741 } + +function Field.resetEliteFour() + local Space = package.loaded["src.core.game3.scripting.space"] + local store = Space and Space.store + if not store then return end + local Flags = require("src.core.game3.scripting.flags") + local ctx = Space.vm and Space.vm.ctx or nil + for _, name in ipairs({ "FLAG_DEFEATED_LORELEI", "FLAG_DEFEATED_BRUNO", "FLAG_DEFEATED_AGATHA", + "FLAG_DEFEATED_LANCE", "FLAG_DEFEATED_CHAMP" }) do + Flags.setFlag(store, ctx, Flags.IDS[name], false) + end + for _, trainerId in ipairs(CHAMPION_TRAINERS) do + Flags.setFlag(store, ctx, Flags.trainerFlagId(trainerId), false) + end + Flags.setVar(store, ctx, Flags.IDS.VAR_MAP_SCENE_POKEMON_LEAGUE, 0) +end + function Field.setHealPoint(mapId, x, y) local session = Field._session if not session then return end diff --git a/src/core/game3/field_effects.lua b/src/core/game3/field_effects.lua index b85bc171..4538e0de 100644 --- a/src/core/game3/field_effects.lua +++ b/src/core/game3/field_effects.lua @@ -67,24 +67,12 @@ local function try_load_rgba(cache, rel, w, h) return img end -local function try_load_png(path) - if not (love and love.graphics and love.graphics.newImage) then return nil end - local ok, img = pcall(love.graphics.newImage, path) - if ok and img then - if img.setFilter then img:setFilter("nearest", "nearest") end - return img - end - return nil -end - local function load_sheet(name, fw, fh, frames) local memo = FieldEffects._sheets[name] if memo ~= nil then return memo or nil end local totalH = fh * frames local root = cache_root() .. "/field_effects/" local img = try_load_rgba(FieldEffects._cache, root .. name .. ".rgba", fw, totalH) - or try_load_rgba(FieldEffects._cache, "field_effects/" .. name .. ".rgba", fw, totalH) - or try_load_png(root .. name .. ".png") if not img then FieldEffects._sheets[name] = false return nil @@ -114,6 +102,7 @@ local function load_sheet(name, fw, fh, frames) FieldEffects._sheets[name] = sheet return sheet end +FieldEffects.loadSheet = load_sheet function FieldEffects.install(cache) FieldEffects._cache = cache @@ -130,6 +119,7 @@ function FieldEffects.install(cache) end local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") if ok and Heal and Heal.install then Heal.install(cache) end + require("src.core.game3.field_move_show_mon").invalidate() end function FieldEffects.invalidate() @@ -144,6 +134,7 @@ function FieldEffects.invalidate() end local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") if ok and Heal and Heal.invalidate then Heal.invalidate() end + require("src.core.game3.field_move_show_mon").invalidate() end -- ---------------------------------------------------------------- Tall Grass @@ -587,6 +578,8 @@ local ANIM_FEET_IN_FLOWING_WATER = { local ANIM_RIPPLE = { { 0, 12 }, { 1, 9 }, { 2, 9 }, { 3, 9 }, { 0, 9 }, { 1, 9 }, { 2, 11 }, { 4, 11 }, } +-- pokefirered/src/data/field_effects/field_effect_objects.h:295 +local ANIM_GROUND_IMPACT_DUST = { { 0, 8 }, { 1, 8 }, { 2, 8 } } local SE_PUDDLE = 63 @@ -749,6 +742,21 @@ local function start_hot_springs() table.insert(FieldEffects._anims, { kind = "hot_springs", timer = 0, frame = 0 }) end +-- pokefirered/src/field_effect_helpers.c:1112 +function FieldEffects.startDust(cx, cy) + load_sheet("ground_impact_dust", 16, 8, 3) + table.insert(FieldEffects._anims, { kind = "dust", timer = 0, frame = 0, cx = cx, cy = cy }) +end + +-- pokefirered/src/event_object_movement.c:8220 +local function flag_land_on_normal_ground(g, cur) + local Collision = package.loaded["src.core.game3.collision"] + if Collision and Collision.isGrass and Collision.isGrass(g.cx, g.cy) then return false end + if cur == MB_PUDDLE or cur == MB_SHALLOW_WATER then return false end + if Collision and Collision.isSurfable and Collision.isSurfable(cur) then return false end + return true +end + -- pokefirered/src/event_object_movement.c:8023 GetAllGroundEffectFlags_OnSpawn local function ground_effects_on_spawn(g, cur, prev) if flag_shallow_flowing_water(g, cur, prev) then start_feet_in_flowing_water() end @@ -763,7 +771,7 @@ local function ground_effects_on_begin_step(g, cur, prev) end -- pokefirered/src/event_object_movement.c:8049 GetAllGroundEffectFlags_OnFinishStep -local function ground_effects_on_finish_step(g, cur, jumped) +local function ground_effects_on_finish_step(g, cur, jumped, landingJump) -- pokefirered/src/event_object_movement.c:5343 ShiftStillObjectEventCoords (previous := current) local prev = cur if flag_shallow_flowing_water(g, cur, prev) then start_feet_in_flowing_water() end @@ -771,6 +779,8 @@ local function ground_effects_on_finish_step(g, cur, jumped) if flag_puddle(cur, prev) and not jumped then start_splash() end if flag_ripple(cur) then start_ripple(g.cx, g.cy) end if flag_hot_springs(g, cur, prev) then start_hot_springs() end + -- pokefirered/src/event_object_movement.c:8638 + if landingJump and flag_land_on_normal_ground(g, cur) then FieldEffects.startDust(g.cx, g.cy) end end --- pokefirered/src/event_object_movement.c:8721 DoGroundEffects_OnSpawn / OnBeginStep / OnFinishStep @@ -804,14 +814,17 @@ function FieldEffects.groundEffects() local wasMoving = g.moving local wasPx = g.px local wasJump = g.jumped + local wasLanding = g.landingJump g.moving = moving g.cx, g.cy = cx, cy g.px, g.py = px, py g.jumped = moving and (P.jumping and true or false) or false + -- pokefirered/src/event_object_movement.c:6646 + g.landingJump = g.jumped and not (P.surfHopping or P.dismounting) or false if wasMoving and moving and wasPx ~= nil then g.cx, g.cy = px, py - ground_effects_on_finish_step(g, behavior_at(px, py), wasJump) + ground_effects_on_finish_step(g, behavior_at(px, py), wasJump, wasLanding) g.cx, g.cy = cx, cy end @@ -822,7 +835,7 @@ function FieldEffects.groundEffects() if moving and not wasMoving then ground_effects_on_begin_step(g, cur, prev) elseif wasMoving and not moving then - ground_effects_on_finish_step(g, cur, wasJump) + ground_effects_on_finish_step(g, cur, wasJump, wasLanding) elseif wasMoving and moving then ground_effects_on_begin_step(g, cur, prev) else @@ -1009,6 +1022,10 @@ function FieldEffects.step() -- pokefirered/src/field_effect_helpers.c:626 UpdateSplashFieldEffect local frame = anim_frame(ANIM_SPLASH, anim.timer - 1, false) if frame then anim.frame = frame else finished = true end + elseif anim.kind == "dust" then + -- pokefirered/src/field_effect_helpers.c:1369 + local frame = anim_frame(ANIM_GROUND_IMPACT_DUST, anim.timer - 1, false) + if frame then anim.frame = frame else finished = true end elseif anim.kind == "feet_water" then -- pokefirered/src/field_effect_helpers.c:707 UpdateFeetInFlowingWaterFieldEffect local g = FieldEffects._ground @@ -1114,6 +1131,7 @@ function FieldEffects.step() local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") if ok and Heal and Heal.step then Heal.step() end + require("src.core.game3.field_move_show_mon").step() end -- ---------------------------------------------------------------- Drawing @@ -1302,6 +1320,14 @@ function FieldEffects.drawFront(camX, camY, playerPy) love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(sheet.image, q, P.px - camX, P.py + FEET_H - camY) end + elseif anim.kind == "dust" then + -- pokefirered/src/field_effect_helpers.c:1117 + local sheet = load_sheet("ground_impact_dust", 16, 8, 3) + local q = sheet and sheet.quads[anim.frame or 0] + if q then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(sheet.image, q, anim.cx * CELL - camX, anim.cy * CELL + 8 - camY) + end elseif anim.kind == "hot_springs" then -- pokefirered/src/field_effect_helpers.c:777 UpdateHotSpringsWaterFieldEffect local sheet = load_sheet("hot_springs_water", 16, 16, 1) @@ -1351,6 +1377,8 @@ function FieldEffects.drawOverlay(camX, camY) local ok, Heal = pcall(require, "src.core.game3.pokecenter_heal") if ok and Heal and Heal.draw then Heal.draw(camX, camY) end + require("src.core.game3.itemfinder").draw() + require("src.core.game3.field_move_show_mon").draw() end --- pret dofieldeffect / waitfieldeffect for FLDEFF_POKECENTER_HEAL (25). diff --git a/src/core/game3/field_move_show_mon.lua b/src/core/game3/field_move_show_mon.lua new file mode 100644 index 00000000..c8766c20 --- /dev/null +++ b/src/core/game3/field_move_show_mon.lua @@ -0,0 +1,237 @@ +-- pokefirered/src/field_effect.c:2577 + +local ShowMon = {} + +ShowMon._fx = nil +ShowMon._img = {} + +local BAND_TOP = 40 +local BAND_H = 80 +local BAND_W = 256 +local SCREEN_W = 240 +-- pokefirered/include/constants/sound.h:42 +local CRY_VOLUME_RS = 125 +-- pokefirered/src/overworld.c:1228 +local OUTDOOR_MAP_TYPES = { [1] = true, [2] = true, [3] = true, [5] = true, [6] = true } + +local function player() + return package.loaded["src.core.game3.player"] or require("src.core.game3.player") +end + +local function band_image(kind) + if ShowMon._img[kind] then return ShowMon._img[kind] end + local FieldEffects = require("src.core.game3.field_effects") + local cache = FieldEffects._cache + assert(cache and cache.read, "field move streaks: no ROM cache mounted") + local Extract = require("src.import.gba.extract_island1") + local rel = (Extract.CACHE_ROOT or "data/generated/gba") .. "/field_effects/field_move_streaks_" .. kind .. ".rgba" + local ok, rgba = pcall(cache.read, cache, rel) + assert(ok and rgba and #rgba == BAND_W * BAND_H * 4, "field move streaks missing from the cache: " .. rel) + local id = love.image.newImageData(BAND_W, BAND_H, "rgba8", rgba) + local img = love.graphics.newImage(id) + img:setFilter("nearest", "nearest") + ShowMon._img[kind] = img + return img +end + +local function map_is_outdoors() + local Map = package.loaded["src.core.game3.map"] + local def = Map and Map.currentDef and Map.currentDef() + return OUTDOOR_MAP_TYPES[tonumber(def and def.mapType) or 0] == true +end + +function ShowMon.invalidate() + ShowMon._img = {} +end + +function ShowMon.isActive() + return ShowMon._fx ~= nil +end + +-- pokefirered/src/fldeff_rocksmash.c:44 +function ShowMon.start(mon, opts, onDone) + opts = opts or {} + local species = tonumber(type(mon) == "table" and mon.species or mon) or 0 + local P = player() + local Pokemon = require("src.core.game3.pokemon") + local monTable = type(mon) == "table" and mon or nil + local fx = { + species = species, + -- pokefirered/src/field_effect.c:2920 + picSpecies = Pokemon.picSpecies(species, monTable and monTable.personality), + shiny = Pokemon.isShiny(monTable), + personality = monTable and monTable.personality, + noDuck = opts.noDuck == true, + onDone = onDone, + outdoors = map_is_outdoors(), + phase = opts.pose and "pose" or 1, + posed = opts.pose == true, + -- pokefirered/src/data/object_events/object_event_anims.h:633 + poseWait = 24, + hofs = 0, + winL = 240, winT = 80, winB = 81, + revealed = 0, cleared = 0, + sprite = nil, + } + ShowMon._fx = fx + if opts.pose then P.startFieldMove(24) end + return true +end + +-- pokefirered/src/field_effect.c:2929 +local function step_sprite(fx) + local s = fx.sprite + if not s or s.done then return end + if s.state == "on" then + s.x = s.x - 20 + if s.x <= 0x78 then + s.x = 0x78 + s.wait = 30 + s.state = "wait" + local Audio = require("src.core.game3.audio") + if fx.noDuck then + -- pokefirered/src/field_effect.c:2938 + Audio.playCry(fx.species, { mode = 0, volume = CRY_VOLUME_RS, noDuck = true }) + else + -- pokefirered/src/field_effect.c:2942 + Audio.playCry(fx.species, 0) + end + end + elseif s.state == "wait" then + s.wait = s.wait - 1 + if s.wait == 0 then s.state = "off" end + elseif s.state == "off" then + if s.x < -0x40 then s.done = true else s.x = s.x - 20 end + end +end + +local function start_sprite(fx) + fx.sprite = { x = 0x140, y = 0x50, state = "on" } +end + +local function finish(fx) + ShowMon._fx = nil + if fx.posed then player().fieldMoveAnim = 0 end + if fx.onDone then fx.onDone() end +end + +-- pokefirered/src/field_effect.c:2601 +local function step_outdoors(fx) + local ph = fx.phase + if ph == 1 or ph == 2 then + fx.phase = ph + 1 + elseif ph == 3 then + fx.hofs = fx.hofs - 16 + fx.winL = math.max(0, fx.winL - 16) + fx.winT = math.max(0x28, fx.winT - 2) + fx.winB = math.min(0x78, fx.winB + 2) + if fx.winL == 0 and fx.winT == 0x28 and fx.winB == 0x78 then + start_sprite(fx) + fx.phase = 4 + end + elseif ph == 4 then + fx.hofs = fx.hofs - 16 + if fx.sprite and fx.sprite.done then fx.phase = 5 end + elseif ph == 5 then + fx.hofs = fx.hofs - 16 + fx.winT = math.min(0x50, fx.winT + 6) + fx.winB = math.max(0x51, fx.winB - 6) + if fx.winT == 0x50 and fx.winB == 0x51 then fx.phase = 6 end + elseif ph == 6 then + fx.phase = 7 + fx.hidden = true + elseif ph == 7 then + finish(fx) + return + end + step_sprite(fx) +end + +-- pokefirered/src/field_effect.c:2757 +local function step_indoors(fx) + local ph = fx.phase + if ph == 1 or ph == 2 then + fx.phase = ph + 1 + elseif ph == 3 then + if fx.revealed >= 16 then + start_sprite(fx) + fx.phase = 4 + else + fx.revealed = fx.revealed + 1 + end + fx.hofs = fx.hofs - 16 + elseif ph == 4 then + fx.hofs = fx.hofs - 16 + if fx.sprite and fx.sprite.done then fx.phase = 5 end + elseif ph == 5 then + fx.hofs = fx.hofs - 16 + fx.phase = 6 + elseif ph == 6 then + fx.hofs = fx.hofs - 16 + if fx.cleared >= 16 then fx.phase = 7 else fx.cleared = fx.cleared + 1 end + elseif ph == 7 then + finish(fx) + return + end + step_sprite(fx) +end + +function ShowMon.step() + local fx = ShowMon._fx + if not fx then return end + local P = player() + if fx.phase == "pose" then + fx.poseWait = fx.poseWait - 1 + if fx.poseWait > 0 then return end + fx.phase = 1 + end + if fx.posed then P.fieldMoveAnim = 1 end + if fx.outdoors then step_outdoors(fx) else step_indoors(fx) end +end + +local function draw_band(img, x0, x1, y0, y1, hofs) + if not img or x1 <= x0 or y1 <= y0 then return end + ShowMon._quad = ShowMon._quad or love.graphics.newQuad(0, 0, 1, 1, BAND_W, BAND_H) + local q = ShowMon._quad + local sx = x0 + while sx < x1 do + local u = (sx + hofs) % BAND_W + local w = math.min(x1 - sx, BAND_W - u) + q:setViewport(u, y0 - BAND_TOP, w, y1 - y0, BAND_W, BAND_H) + love.graphics.draw(img, q, sx, y0) + sx = sx + w + end +end + +function ShowMon.draw() + local fx = ShowMon._fx + if not fx or fx.phase == "pose" or not (love and love.graphics) then return end + love.graphics.setColor(1, 1, 1, 1) + if fx.outdoors then + if not fx.hidden and fx.phase >= 3 then + local y0 = math.max(fx.winT, BAND_TOP) + local y1 = math.min(fx.winB, BAND_TOP + BAND_H) + draw_band(band_image("outdoors"), fx.winL, SCREEN_W, y0, y1, fx.hofs) + end + else + local img = band_image("indoors") + if fx.phase == 3 or fx.phase == 4 or fx.phase == 5 then + local x1 = (fx.phase == 3 and fx.revealed < 16) and math.min(SCREEN_W, 16 * (fx.revealed + 1)) or SCREEN_W + local x0 = (fx.phase == 3 and fx.revealed < 16) and 16 or 0 + draw_band(img, x0, x1, BAND_TOP, BAND_TOP + BAND_H, fx.hofs) + elseif fx.phase == 6 then + draw_band(img, math.min(SCREEN_W, 16 * fx.cleared), SCREEN_W, BAND_TOP, BAND_TOP + BAND_H, fx.hofs) + end + end + local s = fx.sprite + if s and not s.done then + local Pokemon = require("src.core.game3.pokemon") + local entry = Pokemon.frontPic and Pokemon.frontPic(fx.picSpecies, nil, fx.shiny, fx.personality) + if entry and entry.image then + local w, h = entry.w or 64, entry.h or 64 + love.graphics.draw(entry.image, math.floor(s.x - w / 2), math.floor(s.y - h / 2)) + end + end +end + +return ShowMon diff --git a/src/core/game3/field_moves.lua b/src/core/game3/field_moves.lua index 5b536cbe..5edd0311 100644 --- a/src/core/game3/field_moves.lua +++ b/src/core/game3/field_moves.lua @@ -138,8 +138,12 @@ FieldMoves.MAP_TYPES = { -- Standard Text Strings. Read through FieldMoves.TEXT, which translates each -- one when it is read: this table exists before any translation catalog. +-- pokefirered/src/strings.c:317-318 +local TEXT_ROM = { + CANT_USE_HERE = "gText_CantUseHere", + ALREADY_SURFING = "gText_AlreadySurfing", +} local TEXT_SOURCE = { - CANT_USE_HERE = Strings.source("Can't use that here."), BADGE_REQUIRED = Strings.source("Sorry! A new BADGE is required."), NOT_ENOUGH_HP = Strings.source("Not enough HP!"), CANT_BE_USED_ON_PKMN = Strings.source("It won't have any effect."), @@ -165,7 +169,6 @@ local TEXT_SOURCE = { ASK_SURF = Strings.source("The water is dyed a deep blue…\nWould you like to SURF?"), USED_SURF = Strings.source("{STR_VAR_1} used SURF!"), CANT_SURF_CURRENT = Strings.source("The current is much too fast!\nSURF can't be used here…"), - ALREADY_SURFING = Strings.source("You're already SURFING."), -- Flash USED_FLASH = Strings.source("{STR_VAR_1} used FLASH!\nA blinding light illuminates\nthe area!"), @@ -194,6 +197,7 @@ local TEXT_SOURCE = { } FieldMoves.TEXT = setmetatable({}, { __index = function(_, key) + if TEXT_ROM[key] then return require("src.core.game3.rom_text").plain(TEXT_ROM[key]) end local source = TEXT_SOURCE[key] return source and Strings(source) or nil end, diff --git a/src/core/game3/field_view.lua b/src/core/game3/field_view.lua index 62a32a09..73d38ffd 100644 --- a/src/core/game3/field_view.lua +++ b/src/core/game3/field_view.lua @@ -346,25 +346,39 @@ local function collectNeighborActors(actors, baseIndex, hostMapId, hostDef) local defs = neighborActorDefs(entry.id, entry.def) local bounds = Objects and Objects.layoutBounds and Objects.layoutBounds(entry.def) or nil + local Space = package.loaded["src.core.game3.scripting.space"] + local nb = Space and Space.neighborObjectState + and Space.neighborObjectState(entry.id) + or { store = { flags = {}, vars = {} }, perm = {}, movementType = {} } if defs then for _, obj in ipairs(defs) do - local ox, oy = tonumber(obj.x) or 0, tonumber(obj.y) or 0 + local lid = tonumber(obj.localId or obj.index) or 0 + local p = nb.perm[lid] + local ox = p and p.x or tonumber(obj.x) or 0 + local oy = p and p.y or tonumber(obj.y) or 0 local out = bounds and (ox < 0 or oy < 0 or ox >= bounds.w or oy >= bounds.h) -- src/event_object_movement.c:8014 if tonumber(obj.movementType) == 0x4C then out = true end - if objectVisible(obj) and not out then + local gid = obj.graphicsId or obj.graphics + if Space and Space.resolveObjectGraphicsId then + gid = Space.resolveObjectGraphicsId(obj, nb) + end + if objectVisible(obj) and not out and gid then + local mt = nb.movementType[lid] baseIndex = baseIndex + 1 actors[#actors + 1] = { kind = "npc", i = baseIndex, obj = obj, ghost = entry.id, - x = (entry.ox + (tonumber(obj.x) or 0)) * CELL, - y = (entry.oy + (tonumber(obj.y) or 0)) * CELL, - facing = facingFromObj(obj), + x = (entry.ox + ox) * CELL, + y = (entry.oy + oy) * CELL, + facing = (mt and GfxIds.initialFacing(mt)) + or (obj.movementType ~= nil and GfxIds.initialFacing(obj.movementType)) + or facingFromObj(obj), sprite = spriteNameForObj(obj), - graphicsId = obj.graphicsId or obj.graphics, + graphicsId = gid, } end end @@ -465,6 +479,7 @@ local function drawSingleActor(game, mapDef, a, camX, camY) fishing = a.fishing, fishFrame = a.fishFrame, frame = a.frame, + running = a.running, } drew = OwSprites.draw( a.graphicsId, a.x, a.y, camX, camY, a.facing, a.walkPhase, a.stepFlip, opts) @@ -598,6 +613,7 @@ local function collectGame3Actors(game, mapDef, camX, camY, px, py, facing, walk fieldMoveFrame = fieldMoveFrame, fishing = fishFrame ~= nil, fishFrame = fishFrame, + running = PlayerMod and PlayerMod.runPose and PlayerMod.runPose() or nil, sprite = playerSpriteName(game), graphicsId = useOw and OwSprites.playerGraphicsId(game) or nil, } @@ -1201,7 +1217,8 @@ function FieldView.draw(game, canvasW, canvasH, opts) -- Collect Game3 actors partitioned by OAM priority. local underActors, overActors = nil, nil - if not opts.skipActors then + -- pokefirered/src/credits.c:717 + if not (opts.skipActors or FieldView.hideActors) then underActors, overActors = collectGame3Actors( game, mapDef, camX, camY, px, py, facing, walkPhase, stepFlip, playerYOff, playerXOff) end diff --git a/src/core/game3/forced_movement.lua b/src/core/game3/forced_movement.lua index 0e4c027a..0908c7b8 100644 --- a/src/core/game3/forced_movement.lua +++ b/src/core/game3/forced_movement.lua @@ -259,19 +259,80 @@ function M.registerStepCallback(name, fn) M.stepCallbacks[name] = fn end +M._stepTask = nil +M._stepData = {} + -- pokefirered/src/field_tasks.c:66 Task_RunPerStepCallback -function M.runStepCallback(game, prevX, prevY) +function M.runStepCallback(game) local okCtx, Ctx = pcall(require, "src.core.game3.scripting.ctx") if not (okCtx and Ctx and Ctx.stepCallback) then return false end local Map = package.loaded["src.core.game3.map"] local okName, name = pcall(Ctx.stepCallback, Map and Map.current) if not (okName and name) then return false end + -- pokefirered/src/field_tasks.c:96 + if Ctx._stepCallback ~= M._stepTask then + M._stepTask = Ctx._stepCallback + M._stepData = {} + end local fn = M.stepCallbacks[name] if not fn then return false end - local ok, res = pcall(fn, game, prevX, prevY) + local ok, res = pcall(fn, game, M._stepData) return (ok and res) and true or false end +local function playerDestCoords() + local P = player() + if P.moving and P.targetX then return P.targetX, P.targetY end + return P.cellX, P.cellY +end + +-- pokefirered/src/field_tasks.c:173 IcefallCaveIcePerStepCallback +M.registerStepCallback("ice", function(_game, data) + local state = data.state or 0 + if state == 0 then + data.prevX, data.prevY = playerDestCoords() + data.state = 1 + elseif state == 1 then + local x, y = playerDestCoords() + if x == data.prevX and y == data.prevY then return false end + data.prevX, data.prevY = x, y + local beh = Collision.behavior(x, y) + if Collision.isThinIce(beh) then + local Events = require("src.core.game3.scripting.natives_events") + -- pokefirered/src/field_tasks.c:139 MarkIcePuzzleCoordVisited + for i, c in ipairs(Events.ICEFALL_CAVE_ICE_COORDS) do + if c[1] == x and c[2] == y then + local Flags = require("src.core.game3.scripting.flags") + local Space = require("src.core.game3.scripting.space") + Flags.setFlag(Space.store, Space.vm and Space.vm.ctx or nil, i, true) + break + end + end + data.delay, data.state, data.iceX, data.iceY = 4, 2, x, y + elseif Collision.isCrackedIce(beh) then + data.delay, data.state, data.iceX, data.iceY = 4, 3, x, y + end + elseif data.delay ~= 0 then + data.delay = data.delay - 1 + else + local Events = require("src.core.game3.scripting.natives_events") + local Field = require("src.core.game3.field") + local SE = require("src.core.game3.se_ids") + if state == 2 then + se(SE.SE_ICE_CRACK) + Field.setMetatile(data.iceX, data.iceY, Events.METATILE_SEAFOAM_CRACKED_ICE, false) + else + se(SE.SE_ICE_BREAK) + Field.setMetatile(data.iceX, data.iceY, Events.METATILE_SEAFOAM_ICE_HOLE, false) + local Flags = require("src.core.game3.scripting.flags") + local Space = require("src.core.game3.scripting.space") + Flags.setVar(Space.store, Space.vm and Space.vm.ctx or nil, "VAR_TEMP_1", 1) + end + data.state = 1 + end + return false +end) + function M.reset() M.forced = false M.lastSpinTile = nil diff --git a/src/core/game3/ghosts.lua b/src/core/game3/ghosts.lua index 75f67c44..0b062f6d 100644 --- a/src/core/game3/ghosts.lua +++ b/src/core/game3/ghosts.lua @@ -28,6 +28,8 @@ local function contextFor(entry, pool) local layout = entry.def and entry.def.midLayout local P = permissions() return { + ox = entry.ox or 0, + oy = entry.oy or 0, canEnter = function(tx, ty, fromX, fromY, dir) if not layout then return false end if tx < 0 or ty < 0 or tx >= (layout.width or 0) or ty >= (layout.height or 0) then @@ -68,7 +70,7 @@ function Ghosts.sync() if not Ghosts._pools[entry.id] then local defs = defsFor(entry.id, entry.def) if defs then - Ghosts._pools[entry.id] = Objects().spawnFromDefs(defs, entry.def) + Ghosts._pools[entry.id] = Objects().spawnFromDefs(defs, entry.def, entry.id) end end end @@ -112,7 +114,7 @@ function Ghosts.blocksOn(mapId, def, tx, ty) if not pool then local defs = defsFor(mapId, def) if not defs then return false end - pool = Objects().spawnFromDefs(defs, def) + pool = Objects().spawnFromDefs(defs, def, mapId) Ghosts._pools[mapId] = pool end for _, lid in ipairs(pool.order or {}) do diff --git a/src/core/game3/item_use.lua b/src/core/game3/item_use.lua index b876d83d..8782290b 100644 --- a/src/core/game3/item_use.lua +++ b/src/core/game3/item_use.lua @@ -5,6 +5,7 @@ local Bag = require("src.core.game3.bag") local Pokemon = require("src.core.game3.pokemon") local ModRuntime = require("src.mods.Runtime") local Strings = require("src.core.Strings") +local RomText = require("src.core.game3.rom_text") local Capabilities = require("src.core.game3.capabilities") local ItemUse = {} @@ -91,29 +92,25 @@ function ItemUse.healMon(session, mon, id) return true, restored end +-- pokefirered/include/constants/item_effects.h:20 +local STATUS_BIT = { + PSN = 0x10, TOX = 0x10, BRN = 0x08, FRZ = 0x04, SLP = 0x20, PAR = 0x02, + [1] = 0x10, [2] = 0x10, [3] = 0x08, [4] = 0x04, [5] = 0x20, [6] = 0x02, +} +local CURED_TEXT_KIND = { [0x10] = "poison", [0x08] = "burn", [0x04] = "freeze", [0x20] = "sleep", [0x02] = "paralysis" } + +-- pokefirered/src/pokemon.c:4511 function ItemUse.clearStatus(mon, id) if not mon then return false, nil end local st, sleep = mon_status(mon) if not st and sleep <= 0 then return false, nil end - local num = ItemsData.toNumericId(id) or tonumber(id) - local cured = "status" - -- Specific cures when known; FULL HEAL / powder clear all. - if num == 14 then -- ANTIDOTE - if st ~= "PSN" and st ~= "TOX" and st ~= 1 and st ~= 2 then return false, nil end - cured = "poison" - elseif num == 15 then -- BURN - if st ~= "BRN" and st ~= 3 then return false, nil end - cured = "burn" - elseif num == 16 then -- ICE / FREEZE - if st ~= "FRZ" and st ~= 4 then return false, nil end - cured = "freeze" - elseif num == 17 then -- AWAKENING - if st ~= "SLP" and sleep <= 0 and st ~= 5 then return false, nil end - cured = "sleep" - elseif num == 18 then -- PARLYZ - if st ~= "PAR" and st ~= 6 then return false, nil end - cured = "paralysis" - end + local info = ItemsData.info(id) + local e = info and info.effect + if type(e) ~= "table" then return false, nil end + local mask = bit.band(tonumber(e[4]) or 0, 0x3E) + local have = STATUS_BIT[st] or ((sleep > 0) and 0x20) or 0 + if bit.band(mask, have) == 0 then return false, nil end + local cured = CURED_TEXT_KIND[mask] or "status" mon.status = nil mon.sleep = 0 return true, cured @@ -142,6 +139,20 @@ function ItemUse.reviveAll(party) return any end +-- pokefirered/src/party_menu.c:1647 +local function bag_full_text(itemId) + local pocket = ItemsData.pocketOf(itemId) + local name + if pocket == "TM_CASE" then + name = ItemsData.displayName(ItemsData.ITEM_TM_CASE) + elseif pocket == "BERRY_POUCH" then + name = ItemsData.displayName(ItemsData.ITEM_BERRY_POUCH) + else + name = RomText.plain("gText_MenuBag") + end + return RomText.box("gText_BagFullCouldNotRemoveItem", { stringVars = { name } }) +end + --- Give item to party mon as held item. Returns ok, reason, messageText. function ItemUse.giveToMon(session, bag, id, partySlot) local party = session and session.party @@ -158,7 +169,7 @@ function ItemUse.giveToMon(session, bag, id, partySlot) if prev and prev ~= 0 and prev ~= "" and prev ~= "NONE" then -- Swap: return previous to bag if possible if not Bag.canAdd(bag, prev, 1) then - return false, "bag_full", Strings("The BAG is full.") + return false, "bag_full", bag_full_text(prev) end end Bag.remove(bag, id, 1) @@ -192,7 +203,7 @@ function ItemUse.takeFromMon(session, bag, partySlot) return false, "none", Strings("%s isn't\nholding anything.", monName) end if not Bag.canAdd(bag, held, 1) then - return false, "bag_full", Strings("The BAG is full. The\nitem could not be removed.") + return false, "bag_full", bag_full_text(held) end mon.item = nil mon.heldItem = nil @@ -318,6 +329,7 @@ function ItemUse.useBike(session) end) end Player.biking = not Player.biking + require("src.core.game3.audio").bikeMusic(Player.biking) local t if Player.biking then t = Strings("%s got on the\nBICYCLE.", tostring(session.name or "RED")) @@ -491,6 +503,167 @@ function ItemUse.useVitamin(session, mon, itemId) return true, "vitamin", t end +-- pokefirered/include/constants/item_effects.h:34 +local ITEM4_HEAL_PP_ALL, ITEM4_HEAL_PP_ONE, ITEM4_PP_UP = 0x08, 0x10, 0x20 +local ITEM5_PP_MAX = 0x10 + +local function s8(v) + v = tonumber(v) or 0 + if v >= 0x80 then return v - 0x100 end + return v +end + +-- pokefirered/src/pokemon.c:4001 +local function pp_effect(id) + local info = ItemsData.info(id) + local e = info and info.effect + if type(e) ~= "table" then return nil end + local e4, e5 = tonumber(e[5]) or 0, tonumber(e[6]) or 0 + local idx = 7 + for b = 0, 2 do + if bit.band(e4, bit.lshift(1, b)) ~= 0 then idx = idx + 1 end + end + local r = { + up = bit.band(e4, ITEM4_PP_UP) ~= 0, + max = bit.band(e5, ITEM5_PP_MAX) ~= 0, + } + if bit.band(e4, ITEM4_HEAL_PP_ALL) ~= 0 then + r.heal = tonumber(e[idx]) or 0 + r.one = bit.band(e4, ITEM4_HEAL_PP_ONE) ~= 0 + idx = idx + 1 + end + if not (r.up or r.max or r.heal) then return nil end + for b = 0, 3 do + if bit.band(e5, bit.lshift(1, b)) ~= 0 then idx = idx + 1 end + end + if bit.band(e5, 0xE0) ~= 0 then + r.friendship = {} + for k, b in ipairs({ 0x20, 0x40, 0x80 }) do + if bit.band(e5, b) ~= 0 then + r.friendship[k] = s8(e[idx]) + idx = idx + 1 + end + end + end + return r +end + +local function has_move(mon, s) + local m = Pokemon.moveIdAt(mon, s) + return m ~= nil and m > 0 +end + +-- pokefirered/src/pokemon.c:3898 +local function pp_with_bonus(mon, s, n) + local base = tonumber(Pokemon.movePp(Pokemon.moveIdAt(mon, s))) or 0 + return base + math.floor(base * 20 * n / 100) +end + +local function pp_bonus(mon, s) + if type(mon.ppBonusesPacked) == "number" then + return bit.band(bit.rshift(mon.ppBonusesPacked, (s - 1) * 2), 3) + end + local m = tonumber(mon.maxPp and mon.maxPp[s]) + if not m then return 0 end + local best = 0 + for n = 0, 3 do + local v = pp_with_bonus(mon, s, n) + if v == m then return n end + if v <= m then best = n end + end + return best +end + +-- pokefirered/src/pokemon.c:4202, :4344, :4463 +local function pp_plan(mon, id, moveSlot) + local e = pp_effect(id) + if not e or not mon then return nil, nil end + local out = {} + if e.up or e.max then + local s = tonumber(moveSlot) + if s and has_move(mon, s) then + local n = pp_bonus(mon, s) + local cur = pp_with_bonus(mon, s, n) + local nn + if e.up and n < 3 and cur > 4 then nn = n + 1 end + if e.max and n < 3 then nn = 3 end + if nn then + local newMax = pp_with_bonus(mon, s, nn) + out[#out + 1] = { + slot = s, bonus = nn, max = newMax, + pp = (tonumber(mon.pp and mon.pp[s]) or 0) + newMax - cur, + } + end + end + elseif e.heal then + for s = 1, 4 do + if (not e.one or s == tonumber(moveSlot)) and has_move(mon, s) then + local max = pp_with_bonus(mon, s, pp_bonus(mon, s)) + local cur = tonumber(mon.pp and mon.pp[s]) or 0 + if cur ~= max then + out[#out + 1] = { slot = s, pp = math.min(max, cur + e.heal) } + end + end + end + end + return out, e +end + +-- pokefirered/src/party_menu.c:4591, :4709 +function ItemUse.ppItemNeedsMove(id) + local e = pp_effect(id) + return e ~= nil and (e.one or e.up or e.max) and true or false +end + +function ItemUse.ppItemBoosts(id) + local e = pp_effect(id) + return e ~= nil and (e.up or e.max) and true or false +end + +-- pokefirered/src/pokemon.c:4529 +function ItemUse.ppItemHasEffect(mon, id, moveSlot) + local plan = pp_plan(mon, id, moveSlot) + return plan ~= nil and #plan > 0 +end + +-- pokefirered/src/party_menu.c:4340 +function ItemUse.ppItemText(mon, id, moveSlot) + if ItemUse.ppItemBoosts(id) then + return Strings("%s's PP increased.", Pokemon.moveName(Pokemon.moveIdAt(mon, moveSlot)) or "") + end + return Strings("PP was restored.") +end + +function ItemUse.applyPpItem(mon, id, moveSlot, battler, session) + local plan, e = pp_plan(mon, id, moveSlot) + if not plan or #plan == 0 then return false end + mon.pp = mon.pp or {} + for _, c in ipairs(plan) do + mon.pp[c.slot] = c.pp + if c.max then + mon.maxPp = mon.maxPp or {} + mon.maxPp[c.slot] = c.max + if type(mon.ppBonusesPacked) == "number" then + local shift = (c.slot - 1) * 2 + mon.ppBonusesPacked = bit.bor(bit.band(mon.ppBonusesPacked, bit.bnot(bit.lshift(3, shift))), + bit.lshift(c.bonus, shift)) + end + end + -- pokefirered/src/pokemon.c:4366 + local bMon = battler and battler.mon + if bMon and bMon ~= mon and not battler.transformed + and (not battler.permanentSlots or battler.permanentSlots[c.slot]) then + bMon.pp = bMon.pp or {} + bMon.pp[c.slot] = c.pp + end + end + -- pokefirered/src/pokemon.c:3976 + if e.friendship then + Pokemon.itemFriendship(mon, e.friendship, { mapSec = Pokemon.currentMapSec(session) }) + end + return true +end + function ItemUse.useEvolutionStone(session, mon, itemId, bag) local Evolution = require("src.core.game3.evolution") local target = Evolution.itemTarget and Evolution.itemTarget(mon, itemId, session) @@ -635,7 +808,7 @@ function ItemUse.canFish() return (Collision.isWater and Collision.isWater(fx, fy)) == true end return (Collision.isSurfable and Collision.isSurfable(beh)) == true - and (tonumber(Collision.cell(fx, fy)) or 0xff) == 0 + and Collision.isWater(fx, fy) == true end -- pokefirered/src/item_use.c:286 FieldUseFunc_Rod @@ -676,27 +849,35 @@ end -- pokefirered/src/item_use.c:582 FieldUseFunc_BlackWhiteFlute function ItemUse.useBlackWhiteFlute(session, num) - local name = ItemsData.displayName(num) - local player = tostring((session and session.name) or "RED") - play_se(SE_GLASS_FLUTE) + local ctx = { + playerName = tostring((session and (session.name or session.playerName)) or ""), + stringVars = { [2] = ItemsData.displayName(num) }, + } local text if num == ITEM_WHITE_FLUTE then sys_flag(session, FLAG_SYS_WHITE_FLUTE_ACTIVE, true) sys_flag(session, FLAG_SYS_BLACK_FLUTE_ACTIVE, false) - -- pokefirered/src/strings.c:199 gText_UsedVar2WildLured - text = Strings("%s used the\n%s.\\pWild POKéMON will be lured.", player, name) + -- pokefirered/src/item_use.c:590 + text = RomText.box("gText_UsedVar2WildLured", ctx) else sys_flag(session, FLAG_SYS_BLACK_FLUTE_ACTIVE, true) sys_flag(session, FLAG_SYS_WHITE_FLUTE_ACTIVE, false) - -- pokefirered/src/strings.c:200 gText_UsedVar2WildRepelled - text = Strings("%s used the\n%s.\\pWild POKéMON will be repelled.", player, name) + -- pokefirered/src/item_use.c:599 + text = RomText.box("gText_UsedVar2WildRepelled", ctx) end - return true, "flute", text + return true, "black_white_flute", text +end + +-- pokefirered/src/item_use.c:605 Task_UsedBlackWhiteFlute +ItemUse.BLACK_WHITE_FLUTE_DELAY = 8 + +function ItemUse.playBlackWhiteFlute() + play_se(SE_GLASS_FLUTE) end --- Try field use. partySlot optional for heal/status/revive/tm/give. -- Returns ok, reason, messageText -local function useField(session, bag, id, partySlot) +local function useField(session, bag, id, partySlot, moveSlot) local info = ItemsData.info(id) if not info then return false, "unknown", Strings("Unknown item.") end local use = ItemsData.fieldUseKind(id) @@ -811,7 +992,7 @@ local function useField(session, bag, id, partySlot) if num == ITEM_POKE_FLUTE then return ItemUse.usePokeFlute(session) end - if num == ITEM_WHITE_FLUTE or num == ITEM_BLACK_FLUTE then + if use == "black_white_flute" then return ItemUse.useBlackWhiteFlute(session, num) end -- pokefirered/src/item_use.c:253 FieldUseFunc_Bike @@ -830,14 +1011,14 @@ local function useField(session, bag, id, partySlot) or use == "pp" or use == "level" or use == "evo" or use == "vitamin" then local party = session and session.party if not party or #party < 1 then - return false, "noparty", Strings("There is no POKéMON.") + return false, "noparty", RomText.box("gText_ThereIsNoPokemon") end if not partySlot then return false, "need_slot", Strings("Select a POKéMON.") end local mon = party[partySlot] if not mon then - return false, "noparty", Strings("There is no POKéMON.") + return false, "noparty", RomText.box("gText_ThereIsNoPokemon") end local ok = false local text = nil @@ -878,7 +1059,13 @@ local function useField(session, bag, id, partySlot) text = Strings("%s recovered\nfrom illness!", monName) end elseif use == "pp" then - return false, "pp", Strings("It won't have any effect.") + if moveSlot == nil and ItemUse.ppItemNeedsMove(id) then + return false, "need_move", nil + end + moveSlot = moveSlot or 1 + -- pokefirered/src/party_menu.c:4657 + ok = ItemUse.applyPpItem(mon, id, moveSlot, nil, session) + if ok then text = ItemUse.ppItemText(mon, id, moveSlot) end elseif use == "vitamin" then ok, _, text = ItemUse.useVitamin(session, mon, id) else @@ -906,15 +1093,15 @@ local function useField(session, bag, id, partySlot) return false, "none", Strings("OAK: This isn't the\ntime to use that!") end -function ItemUse.useField(session,bag,id,partySlot) +function ItemUse.useField(session,bag,id,partySlot,moveSlot) local ok,kind,text if ModRuntime.wantsHook("item.use") then local Runtime=package.loaded["src.core.game3.runtime"] ok,kind,text=ModRuntime.call("item.use",function(_,_,hid,hslot) - return useField(session,bag,hid,hslot) + return useField(session,bag,hid,hslot,moveSlot) end,Runtime and Runtime._game,nil,id,partySlot,bag) else - ok,kind,text=useField(session,bag,id,partySlot) + ok,kind,text=useField(session,bag,id,partySlot,moveSlot) end if ok and kind~="tm" and kind~="tm_case" and kind~="berry_pouch" and kind~="vs_seeker" then local Items=require("src.core.game3.items") diff --git a/src/core/game3/itemfinder.lua b/src/core/game3/itemfinder.lua new file mode 100644 index 00000000..bdfe10df --- /dev/null +++ b/src/core/game3/itemfinder.lua @@ -0,0 +1,242 @@ +local Itemfinder = {} + +local SE_ITEMFINDER = 65 +local CENTER_X, CENTER_Y = 120, 76 +local STAR_ANIM = 4 + +Itemfinder._task = nil +Itemfinder._sprites = {} + +local function is_hidden(ev) + return ev.type == "hidden_item" or ev.kind == 7 +end + +-- pokefirered/src/itemfinder.c:383 +local function register_if_closer(t, dx, dy) + if not t.found then + t.itemX, t.itemY, t.found = dx, dy, true + return + end + local dx2, dy2 = math.abs(t.itemX), math.abs(t.itemY) + local dx3, dy3 = math.abs(dx), math.abs(dy) + if dx2 + dy2 > dx3 + dy3 then + t.itemX, t.itemY = dx, dy + elseif dx2 + dy2 == dx3 + dy3 and (dy2 > dy3 or (dy2 == dy3 and t.itemY < dy)) then + t.itemX, t.itemY = dx, dy + end +end + +-- pokefirered/src/itemfinder.c:287 +local function hidden_item_at_pos(events, x, y, flagSet) + for _, ev in ipairs(events or {}) do + if is_hidden(ev) and ev.x == x and ev.y == y then + return not ev.underfoot and not flagSet(ev) + end + end + return false +end + +local function neighbor(neighbors, a, b) + return neighbors and (neighbors[a] or neighbors[b]) +end + +local function layout_size(def) + local L = def and def.midLayout + if L then return L.width or 0, L.height or 0 end + return (tonumber(def and def.width) or 0) * 2, (tonumber(def and def.height) or 0) * 2 +end + +-- pokefirered/src/fieldmap.c:761, src/itemfinder.c:312 +local function connected_hidden_item(opts, lx, ly) + local curW, curH = opts.width, opts.height + local n = opts.neighbors + local conn, cx, cy + if ly < 0 then + conn = neighbor(n, "north", "up") + if conn then + local w, h = layout_size(conn.def) + local off = tonumber(conn.offset) or 0 + if lx - off >= 0 and lx - off < w then cx, cy = lx - off, h + ly end + end + end + if not cx and ly >= curH then + conn = neighbor(n, "south", "down") + if conn then + local w = layout_size(conn.def) + local off = tonumber(conn.offset) or 0 + if lx - off >= 0 and lx - off < w then cx, cy = lx - off, ly - curH end + end + end + if not cx and lx < 0 then + conn = neighbor(n, "west", "left") + if conn then + local w, h = layout_size(conn.def) + local off = tonumber(conn.offset) or 0 + if ly - off >= 0 and ly - off < h then cx, cy = w + lx, ly - off end + end + end + if not cx and lx >= curW then + conn = neighbor(n, "east", "right") + if conn then + local _, h = layout_size(conn.def) + local off = tonumber(conn.offset) or 0 + if ly - off >= 0 and ly - off < h then cx, cy = lx - curW, ly - off end + end + end + if not cx then return false end + return hidden_item_at_pos(opts.eventsFor(conn.map or conn.mapId), cx, cy, opts.flagSet) +end + +-- pokefirered/src/itemfinder.c:202 +function Itemfinder.scan(opts) + local t = { found = false, itemX = 0, itemY = 0 } + for _, ev in ipairs(opts.events or {}) do + if is_hidden(ev) and not opts.flagSet(ev) then + local dx, dy = ev.x - opts.px, ev.y - opts.py + if ev.underfoot then + if dx == 0 and dy == 0 then + -- pokefirered/src/itemfinder.c:241 + return { underfoot = true, item = ev, itemX = 0, itemY = 0, dings = 3 } + end + elseif dx >= -7 and dx <= 7 and dy >= -5 and dy <= 5 then + register_if_closer(t, dx, dy) + end + end + end + -- pokefirered/src/itemfinder.c:354 + if opts.width and opts.height and opts.eventsFor then + for x = opts.px - 7, opts.px + 7 do + for y = opts.py - 5, opts.py + 5 do + if x < 0 or x >= opts.width or y < 0 or y >= opts.height then + if connected_hidden_item(opts, x, y) then + register_if_closer(t, x - opts.px, y - opts.py) + end + end + end + end + end + if not t.found then return nil end + -- pokefirered/src/itemfinder.c:255 + local ax, ay = math.abs(t.itemX), math.abs(t.itemY) + local dings = 4 + if not (t.itemX == 0 and t.itemY == 0) then + if ax > ay then + if ax > 3 then dings = 2 end + elseif ay > 3 then + dings = 2 + end + end + return { underfoot = false, itemX = t.itemX, itemY = t.itemY, dings = dings } +end + +-- pokefirered/src/itemfinder.c:434, :535 +local AFFINE_TURN = { left = 0, down = 0x40, right = 0x80, up = 0xC0 } +local function arrow_motion(itemX, itemY, facing) + if itemX == 0 and itemY == 0 then + if facing == "left" then return -100, 0, AFFINE_TURN.left end + if facing == "up" then return 0, -100, AFFINE_TURN.up end + if facing == "right" then return 100, 0, AFFINE_TURN.right end + return 0, 100, AFFINE_TURN.down + end + local ax, ay = math.abs(itemX), math.abs(itemY) + if ax > ay then + if itemX < 0 then return -100, 0, AFFINE_TURN.left end + return 100, 0, AFFINE_TURN.right + end + if itemY < 0 then return 0, -100, AFFINE_TURN.up end + return 0, 100, AFFINE_TURN.down +end + +local function spawn(anim, dx, dy, turn) + local s = { anim = anim, dx = dx, dy = dy, curX = 0, curY = 0, x = CENTER_X, y = CENTER_Y, turn = turn or 0 } + Itemfinder._sprites[#Itemfinder._sprites + 1] = s + return s +end + +-- pokefirered/src/itemfinder.c:595, :632 +local function step_sprites() + local keep = {} + for _, s in ipairs(Itemfinder._sprites) do + s.curX = s.curX + s.dx + s.curY = s.curY + s.dy + s.x = CENTER_X + math.floor(s.curX / 256) + s.y = CENTER_Y + math.floor(s.curY / 256) + if not (s.x <= 104 or s.x > 132 or s.y <= 60 or s.y > 88) then + keep[#keep + 1] = s + end + end + Itemfinder._sprites = keep +end + +-- pokefirered/src/itemfinder.c:131 +function Itemfinder.start(task) + Itemfinder._sprites = {} + task.timer = 0 + task.dingNum = 0 + task.remaining = task.result.dings + task.phase = "dings" + Itemfinder._task = task +end + +function Itemfinder.isActive() + return Itemfinder._task ~= nil +end + +function Itemfinder.sprites() + return Itemfinder._sprites +end + +-- pokefirered/src/itemfinder.c:158, :181 +function Itemfinder.update() + Itemfinder.runTask() + step_sprites() +end + +function Itemfinder.runTask() + local t = Itemfinder._task + if not (t and t.phase == "dings") then return end + if t.timer % 25 == 0 then + if t.remaining == 0 then + t.phase = "message" + local key = t.result.underfoot and "gText_ItemfinderShakingWildly" or "gText_ItemfinderResponding" + t.onMessage(key, function() + Itemfinder._task = nil + Itemfinder._sprites = {} + t.onDone() + end) + return + end + local Audio = require("src.core.game3.audio") + Audio.playSe(SE_ITEMFINDER) + if t.result.underfoot then + spawn(STAR_ANIM, 0, -100, 0) + else + local dx, dy, turn = arrow_motion(t.result.itemX, t.result.itemY, t.facing) + spawn(t.dingNum, dx, dy, turn) + end + t.dingNum = t.dingNum + 1 + t.remaining = t.remaining - 1 + end + t.timer = t.timer + 1 +end + +function Itemfinder.draw() + if #Itemfinder._sprites == 0 then return end + local FieldEffects = require("src.core.game3.field_effects") + local sheet = FieldEffects.loadSheet("itemfinder_arrow_star", 16, 16, 5) + if not sheet then return end + love.graphics.setColor(1, 1, 1, 1) + for _, s in ipairs(Itemfinder._sprites) do + local q = sheet.quads[s.anim] + if q then + love.graphics.draw(sheet.image, q, s.x, s.y, -s.turn * math.pi / 128, 1, 1, 8, 8) + end + end +end + +function Itemfinder.reset() + Itemfinder._task = nil + Itemfinder._sprites = {} +end + +return Itemfinder diff --git a/src/core/game3/items_data.lua b/src/core/game3/items_data.lua index 53692d86..027466c8 100644 --- a/src/core/game3/items_data.lua +++ b/src/core/game3/items_data.lua @@ -312,6 +312,7 @@ function ItemsData.info(id) registrability = e.registrability, importance = e.importance, secondaryId = e.secondaryId, + effect = e.effect, } end local s = tostring(id) @@ -497,7 +498,7 @@ function ItemsData.medicineKind(id) local info = ItemsData.info(id) if info and info.fieldUse == "revive" then return "revive" end if info and info.fieldUse == "status" then return "status" end - if info and (info.fieldUse == "heal" or info.fieldUse == "pp") then return "heal" end + if info and info.fieldUse == "heal" then return "heal" end return info and info.fieldUse or "none" end @@ -505,7 +506,6 @@ end local LEVEL_IDS = { [68] = true } local EVO_IDS = { [93] = true, [94] = true, [95] = true, [96] = true, [97] = true, [98] = true } local VITAMIN_IDS = { [63] = true, [64] = true, [65] = true, [66] = true, [67] = true, [70] = true } -local PP_IDS = { [34] = true, [35] = true, [36] = true, [37] = true, [69] = true, [71] = true } local ESCAPE_IDS = { [85] = true } local REPEL_IDS = { [83] = true, [84] = true, [86] = true } -- pokefirered/src/data/items.h:3962 FieldUseFunc_Bike @@ -531,10 +531,6 @@ function ItemsData.fieldUseKind(id) or host == "CARBOS" or host == "CALCIUM" or host == "ZINC" then return "vitamin" end - if PP_IDS[num] or host:find("ETHER", 1, true) or host:find("ELIXIR", 1, true) - or host == "PP_UP" or host == "PP_MAX" then - return "pp" - end if (num and num >= 289 and num <= 346) or host:find("^TM%d") or host:find("^HM%d") or host:find("TM_") or host:find("HM_") then return "tm" diff --git a/src/core/game3/league_lighting.lua b/src/core/game3/league_lighting.lua new file mode 100644 index 00000000..ad83cc4f --- /dev/null +++ b/src/core/game3/league_lighting.lua @@ -0,0 +1,146 @@ +-- pokefirered/src/field_specials.c:2133 +local Extract = require("src.import.gba.extract_island1") + +local LeagueLighting = {} + +LeagueLighting.task = nil +LeagueLighting._data = nil + +-- pokefirered/include/constants/flags.h:12 +local FLAG_TEMP_2 = 0x2 +local FLAG_TEMP_3 = 0x3 +local FLAG_TEMP_4 = 0x4 +local FLAG_TEMP_5 = 0x5 + +-- pokefirered/src/field_specials.c:2143 +local CHAMPIONS_ROOM = "FR_POKEMON_LEAGUE_CHAMPIONS_ROOM" + +local function data() + if LeagueLighting._data then return LeagueLighting._data end + local Dataset = require("src.core.game3.dataset") + local rel = (Extract.CACHE_ROOT or "data/generated/gba") .. "/league/lighting.lua" + local src = Dataset.cache():read(rel) + if not src then return nil end + local chunk = load(src, "@" .. rel, "t", {}) + local ok, val = pcall(chunk) + if ok and type(val) == "table" then LeagueLighting._data = val end + return LeagueLighting._data +end + +local function flag(id) + local Space = package.loaded["src.core.game3.scripting.space"] + local Flags = require("src.core.game3.scripting.flags") + return Flags.getFlag(Space and Space.store, nil, id) == true +end + +local function currentMap() + local Map = package.loaded["src.core.game3.map"] + return Map and Map.current +end + +local function currentPair() + local Map = package.loaded["src.core.game3.map"] + local def = Map and Map.currentDef and Map.currentDef() + return def and (def.pair or (def.midLayout and def.midLayout.pair)) +end + +local function apply(st, index) + local NativeTileset = require("src.core.game3.tileset_native") + local pal = st.set.pals[index + 1] + if not pal then return end + st.pair = st.pair or currentPair() + if not st.pair then return end + NativeTileset.setSlotPalette(st.pair, st.slot, pal) + st.patched = true +end + +local function restore(st) + if not (st and st.patched and st.pair) then return end + local NativeTileset = require("src.core.game3.tileset_native") + NativeTileset.resetSlotPalette(st.pair, st.slot) + st.patched = false +end + +local function paused() + local Fade = package.loaded["src.ui.game3.fade"] + if Fade and Fade.isActive and Fade.isActive() then return true end + local Battle = package.loaded["src.core.game3.battle"] + if Battle and Battle.isActive and Battle.isActive() then return true end + return false +end + +-- pokefirered/src/field_specials.c:2160 +-- pokefirered/src/field_specials.c:2187 +function LeagueLighting.tick(st) + if currentMap() ~= st.mapId then + restore(st) + if LeagueLighting.task == st then LeagueLighting.task = nil end + return true + end + if st.phase == "cancel" then + if flag(FLAG_TEMP_4) then + apply(st, #st.set.pals - 1) + st.phase = "held" + end + return false + end + if st.phase ~= "run" then return false end + if paused() or not flag(FLAG_TEMP_2) or flag(FLAG_TEMP_5) then return false end + st.timer = st.timer - 1 + if st.timer ~= 0 then return false end + st.index = st.index + 1 + if st.index == st.steps then st.index = 0 end + st.timer = st.set.timers[st.index + 1] + apply(st, st.index) + return false +end + +-- pokefirered/src/field_specials.c:2133 +function LeagueLighting.start(mapId) + local d = data() + if not d then return false end + -- pokefirered/src/overworld.c:2105 + restore(LeagueLighting.task) + LeagueLighting.stop() + local set = mapId == CHAMPIONS_ROOM and d.champion or d.e4 + local st = { + mapId = mapId or currentMap(), + set = set, + slot = tonumber(d.palette_slot) or 7, + steps = #set.timers, + index = 0, + timer = set.timers[1], + } + if flag(FLAG_TEMP_3) then + st.phase = "cancel" + else + st.phase = "run" + apply(st, 0) + end + local Task = require("src.core.game3.task") + st.handle = Task.spawn(function() return LeagueLighting.tick(st) end) + LeagueLighting.task = st + return true +end + +-- pokefirered/src/field_specials.c:2205 +function LeagueLighting.stop() + local st = LeagueLighting.task + if not st then return end + if st.handle then + require("src.core.game3.task").cancel(st.handle.id) + end + LeagueLighting.task = nil +end + +function LeagueLighting.reset() + restore(LeagueLighting.task) + LeagueLighting.stop() +end + +function LeagueLighting.frame() + local st = LeagueLighting.task + return st and st.index, st and st.phase +end + +return LeagueLighting diff --git a/src/core/game3/link/init.lua b/src/core/game3/link/init.lua index c3010c12..65f222f5 100644 --- a/src/core/game3/link/init.lua +++ b/src/core/game3/link/init.lua @@ -192,6 +192,8 @@ function Link.warpToDest(ctx, adapters, dest) if not (Map and Map.load) then return false end local rt = runtime() local Player = package.loaded["src.core.game3.player"] + -- src/overworld.c:2144 + if Player and Player.setVisible then Player.setVisible(true) end Map.load(rt and rt._mod, Link.game(), mapId, { x = x, y = y, @@ -207,9 +209,13 @@ function Link.doCableClubWarp(ctx, adapters) local armedOn = Link._warpMap Link._warpMap = nil local mapId = Link.currentMap() - if armedOn and mapId ~= armedOn then return false end local s = Link.session() - Link.warpToDest(ctx, adapters, s and s.warpDestination) + local warped = not (armedOn and mapId ~= armedOn) + and Link.warpToDest(ctx, adapters, s and s.warpDestination) + if not warped then + local Player = package.loaded["src.core.game3.player"] + if Player and Player.setVisible then Player.setVisible(true) end + end return false end diff --git a/src/core/game3/map.lua b/src/core/game3/map.lua index f6a2f66b..f163d906 100644 --- a/src/core/game3/map.lua +++ b/src/core/game3/map.lua @@ -12,6 +12,10 @@ Map.neighbors = {} Map._loadedLayouts = {} Map._def = nil Map._currentDef = nil +-- pokefirered/include/overworld.h:46 +Map.MUSIC_DISABLE_OFF, Map.MUSIC_DISABLE_STOP, Map.MUSIC_DISABLE_KEEP = 0, 1, 2 +-- pokefirered/src/overworld.c:103 +Map.disableMusicChange = 0 function Map.currentDef() return Map._def or Map._currentDef @@ -436,10 +440,9 @@ function Map.load(mod, game, mapId, opts) Objects.loadMap(game, mapId, def) Ghosts.adopt(mapId) end + -- pokefirered/src/overworld.c:769 -- pokefirered/src/overworld.c:806 - if not opts.seamless then - require("src.core.game3.audio").setSavedSong(nil) - end + require("src.core.game3.audio").setSavedSong(nil) if fromMapId == mapId then if opts.reason and ModRuntime.wants("map.reloaded") then ModRuntime.emit("map.reloaded", { mapId = mapId, reason = opts.reason or "reload" }) @@ -468,10 +471,31 @@ function Map.load(mod, game, mapId, opts) if not music and Audio._pack and Audio._pack.index and Audio._pack.index.mapSongs then music = Audio._pack.index.mapSongs[mapId] end + -- pokefirered/src/overworld.c:1063 + if Map.disableMusicChange == Map.MUSIC_DISABLE_STOP then + Audio.playSong(0) + music = nil + elseif Map.disableMusicChange == Map.MUSIC_DISABLE_KEEP then + music = nil + end if music and music ~= 0xFFFF then - -- pokefirered/src/overworld.c:1039 - local id = (not opts.seamless and Audio._savedSong) or music - Audio.playMapSong(id, { mapSong = music }) + local id + if opts.seamless then + -- pokefirered/src/overworld.c:1075 + local fromDef = fromMapId and host_map_def(game, fromMapId) + if Audio._currentSong and Audio._currentSong.id == Audio.MUS_SURF then + Audio.setMapSong(music) + elseif Audio.specialMapSong(fromDef and fromDef.regionMapSectionId) == Audio.MUS_SURF then + id = Audio.MUS_SURF + else + id = music + end + else + -- pokefirered/src/overworld.c:1039 + id = Audio._savedSong or (Audio.specialMapSong(def and def.regionMapSectionId) == Audio.MUS_SURF + and Audio.MUS_SURF) or music + end + if id then Audio.playMapSong(id, { mapSong = music }) end end end diff --git a/src/core/game3/objects.lua b/src/core/game3/objects.lua index aa469cae..b2b9aac6 100644 --- a/src/core/game3/objects.lua +++ b/src/core/game3/objects.lua @@ -118,7 +118,7 @@ local function objectVisible(def) return true end -local function newEventObject(def) +local function newEventObject(def, neighbor) -- Shallow-copy template so setobjectxy / removeobject cannot poison the -- shared events.lua / mapDef.objects tables for the rest of the session. local src = def or {} @@ -129,21 +129,26 @@ local function newEventObject(def) local lid = tonumber(def.localId or def.index) or 0 local x = tonumber(def.x) or 0 local y = tonumber(def.y) or 0 - local mt = tonumber(def.movementType) or 0 - local hostMv = GfxIds.hostMovement - and GfxIds.hostMovement(mt, def.radius and def.radius.x, def.radius and def.radius.y) - or nil - local movement = def.movement - or (hostMv and hostMv.movement) - or "STAY" - local range = def.range or (hostMv and hostMv.range) or "DOWN" - local radius = def.radius or (hostMv and hostMv.radius) + local rawMt = tonumber(def.movementType) + local mt = rawMt or 0 + local spec + if rawMt then + spec = GfxIds.hostMovement(rawMt, def.rangeX, def.rangeY) + else + local r = def.radius or { x = 1, y = 1 } + spec = { + movement = def.movement or "STAY", range = def.range or "DOWN", + rangeX = r.x, rangeY = r.y, radius = r, + } + end + local facing = (def.facing and facingFromDef(def)) + or (rawMt and spec.face) or facingFromDef(def) local sprite = def.sprite local resolvedGfx = def.graphicsId or def.graphics do local okS, Space = pcall(require, "src.core.game3.scripting.space") if okS and Space and Space.resolveObjectGraphicsId then - local gid = Space.resolveObjectGraphicsId(def) + local gid = Space.resolveObjectGraphicsId(def, neighbor) if gid then resolvedGfx = gid end end end @@ -162,14 +167,18 @@ local function newEventObject(def) py = y * CELL, homeX = x, homeY = y, - facing = facingFromDef(def), + facing = facing, sprite = sprite or "SPRITE_YOUNGSTER", graphicsId = resolvedGfx, elevation = elev, movementType = mt, - movement = movement, - range = range, - radius = radius or { x = 1, y = 1 }, + movement = spec.movement, + range = spec.range, + radius = spec.radius or { x = spec.rangeX, y = spec.rangeY }, + rangeX = spec.rangeX, + rangeY = spec.rangeY, + spec = spec, + seqIndex = 0, sight = tonumber(def.sight or def.trainerRange) or 0, trainerType = tonumber(def.trainerType) or 0, scriptKey = def.scriptKey, @@ -187,7 +196,6 @@ local function newEventObject(def) targetY = y, stepFlip = false, animClock = 0, - idleTimer = 30 + (lid * 17) % 60, scriptBusy = false, } end @@ -266,6 +274,21 @@ end Objects.rememberPerm = rememberPerm +-- src/event_object_movement.c:4806 +local function setSpec(eo, mt) + local spec = GfxIds.hostMovement(mt, eo.rangeX, eo.rangeY) + spec.rangeX = tonumber(eo.rangeX) or 0 + spec.rangeY = tonumber(eo.rangeY) or 0 + spec.radius = { x = spec.rangeX, y = spec.rangeY } + eo.movementType = tonumber(mt) or 0 + eo.spec = spec + eo.movement = spec.movement + eo.range = spec.range + eo.radius = spec.radius + eo.seqIndex = 0 + eo.idleTimer = nil +end + local function applyPerm(eo, mapId) local bucket = Objects._perm[mapId] local row = bucket and bucket[eo.localId] @@ -285,15 +308,9 @@ local function applyPerm(eo, mapId) if eo.def then eo.def.y = row.y end end if row.movementType ~= nil then - eo.movementType = row.movementType - local hostMv = GfxIds.hostMovement and GfxIds.hostMovement(row.movementType) - if hostMv then - eo.movement = hostMv.movement - eo.range = hostMv.range - if hostMv.radius then eo.radius = hostMv.radius end - end - local face = ({ [7] = "up", [8] = "down", [9] = "left", [10] = "right" })[row.movementType] - if face then eo.facing = face end + setSpec(eo, row.movementType) + -- src/event_object_movement.c:1378 + eo.facing = eo.spec.face -- src/event_object_movement.c:1569 eo.invisible = tonumber(row.movementType) == MOVEMENT_TYPE_INVISIBLE if eo.def then eo.def.movementType = row.movementType end @@ -557,8 +574,10 @@ function Objects.at(tx, ty) for _, lid in ipairs(Objects._order) do local eo = Objects._byId[lid] if eo and eo.visible and not eo.hidden then - if eo.cellX == tx and eo.cellY == ty then - if eo.moving then return nil end + -- src/event_object_movement.c:1281 + local cx = eo.moving and eo.targetX or eo.cellX + local cy = eo.moving and eo.targetY or eo.cellY + if cx == tx and cy == ty then return eo end end @@ -639,6 +658,36 @@ local function finishStep(eo, game) end end +-- src/event_object_movement.c:8866 +local STEP_PIXELS = { + [16] = { 1 }, + [8] = { 2 }, + [6] = { 2, 3, 3 }, + [4] = { 4 }, + [2] = { 8 }, + -- src/event_object_movement.c:9029 + [11] = { 1, 2 }, + -- src/event_object_movement.c:8984 + [24] = { 1, 1, 0 }, + -- src/event_object_movement.c:8959 + [32] = { 1, 0 }, +} +local STEP_OFFSETS = {} +for frames, pat in pairs(STEP_PIXELS) do + local cum, n = {}, 0 + for k = 1, frames do + n = math.min(CELL, n + pat[(k - 1) % #pat + 1]) + cum[k] = n + end + STEP_OFFSETS[frames] = cum +end + +local function stepOffset(frames, progress, cells) + local cum = cells == 1 and STEP_OFFSETS[frames] + if cum then return cum[math.min(progress, frames)] end + return math.floor(cells * CELL * math.min(progress, frames) / frames) +end + local function tickMotion(eo, game) if not eo.moving then return false end eo.progress = eo.progress + 1 @@ -646,9 +695,9 @@ local function tickMotion(eo, game) local frames = eo.stepFrames or WALK_FRAMES local dx = eo.targetX - eo.cellX local dy = eo.targetY - eo.cellY - local t = math.min(1, eo.progress / frames) - eo.px = eo.cellX * CELL + dx * CELL * t - eo.py = eo.cellY * CELL + dy * CELL * t + local off = stepOffset(frames, eo.progress, math.abs(dx) + math.abs(dy)) + eo.px = eo.cellX * CELL + (dx > 0 and off or dx < 0 and -off or 0) + eo.py = eo.cellY * CELL + (dy > 0 and off or dy < 0 and -off or 0) if eo.progress >= frames then finishStep(eo, game) return true @@ -693,6 +742,16 @@ function Objects.scriptJump(eo, dir, distance) return true end +-- pokefirered/src/event_object_movement.c:5351 InitNpcForWalkSlower +function Objects.pushStep(eo, dir, frames) + local d = DELTA[dir] + if not eo or not d or eo.moving then return false end + eo.facing = dir + beginStep(eo, eo.cellX + d[1], eo.cellY + d[2]) + eo.stepFrames = frames or WALK_FRAMES * 2 + return true +end + function Objects.scriptFace(eo, dir) if not eo then return end local P = Player() @@ -795,7 +854,9 @@ local function advanceTrack(lid, tr, game) tr.sleep = act.frames or 32 elseif act.kind == "face_original" then if eo and eo ~= Player() and eo.def then - local origFace = facingFromDef(eo.def) + -- src/event_object_movement.c:7016 + local origFace = eo.def.movementType ~= nil and GfxIds.initialFacing(eo.movementType) + or facingFromDef(eo.def) Objects.scriptFace(eo, origFace) end elseif act.kind == "bow" then @@ -819,12 +880,18 @@ local function advanceTrack(lid, tr, game) elseif act.kind == "sleep" then tr.sleep = act.frames or 1 elseif act.kind == "hide" then - if eo and eo ~= Player() then + -- pokefirered/src/event_object_movement.c:7054 + if eo == Player() then + Player().setVisible(false) + elseif eo then eo.hidden = true eo.visible = false end elseif act.kind == "show" then - if eo and eo ~= Player() then + -- pokefirered/src/event_object_movement.c:7061 + if eo == Player() then + Player().setVisible(true) + elseif eo then eo.hidden = false eo.visible = true end @@ -939,6 +1006,142 @@ local function raiseHandTick(eo) end end +local function checkSight(game, eo) + local okTs, TrainerSight = pcall(require, "src.core.game3.trainer_sight") + if okTs and TrainerSight and TrainerSight.check then + TrainerSight.check(game, eo) + end +end + +-- src/event_object_movement.c:4830 +local function stepCollision(eo, game, ctx, dir) + local d = DELTA[dir] + local tx, ty = eo.cellX + d[1], eo.cellY + d[2] + -- src/event_object_movement.c:4861 + local rx = tonumber(eo.rangeX or (eo.radius and eo.radius.x)) or 0 + local ry = tonumber(eo.rangeY or (eo.radius and eo.radius.y)) or 0 + if (rx ~= 0 and math.abs(tx - eo.homeX) > rx) or (ry ~= 0 and math.abs(ty - eo.homeY) > ry) then + return "range" + end + local ok + if ctx then + local Coll = Collision() + ok = ctx.canEnter(tx, ty, eo.cellX, eo.cellY, dir) + and not ctx.blocks(tx, ty, eo.localId) + if ok and eo.mapDef and Coll.directionallyImpassableOn( + eo.mapDef, eo.cellX, eo.cellY, tx, ty, dir) then + ok = false + end + else + local Coll = Collision() + -- pokefirered/src/event_object_movement.c:8346 IsElevationMismatchAt + local onWater = Coll.isWater(eo.cellX, eo.cellY) + ok = Coll.canEnter(game, tx, ty, + { fromX = eo.cellX, fromY = eo.cellY, dir = dir, surfing = onWater }) + if ok and Coll.isWater(tx, ty) ~= onWater then ok = false end + if Objects.playerBlocks(tx, ty) then ok = false end + if Objects.blocks(tx, ty, eo.localId) then ok = false end + end + if not ok then return "blocked" end + return nil +end + +-- src/event_object_movement.c:3884 +local function walkOrInPlace(eo, dir, collision) + eo.facing = dir + if collision then + beginStep(eo, eo.cellX, eo.cellY) + else + local d = DELTA[dir] + beginStep(eo, eo.cellX + d[1], eo.cellY + d[2]) + end +end + +-- src/event_object_movement.c:2770 +local function playerCellFor(ctx) + local P = Player() + local px = P.moving and P.targetX or P.cellX + local py = P.moving and P.targetY or P.cellY + return px - (ctx and ctx.ox or 0), py - (ctx and ctx.oy or 0) +end + +local function trainerCloseToRunningPlayer(eo, ctx) + local P = Player() + if not (P and P.running) or P.biking or P.surfing then return false end + local tt = tonumber(eo.trainerType) or 0 + if tt ~= 1 and tt ~= 3 then return false end + local r = tonumber(eo.sight) or 0 + local px, py = playerCellFor(ctx) + return math.abs(px - eo.cellX) <= r and math.abs(py - eo.cellY) <= r +end + +-- src/event_object_movement.c:2801 +local function vectorDirection(dx, dy) + if math.abs(dx) > math.abs(dy) then return dx < 0 and "left" or "right" end + return dy < 0 and "up" or "down" +end + +local function southNorth(dy) return dy < 0 and "up" or "down" end +local function westEast(dx) return dx < 0 and "left" or "right" end + +-- src/data/object_events/movement_type_func_tables.h:185 +local FOLLOW = { + [0] = vectorDirection, + [1] = function(_, dy) return southNorth(dy) end, + [2] = function(dx) return westEast(dx) end, + [3] = function(dx, dy) + local d = vectorDirection(dx, dy) + if d == "down" then d = westEast(dx); if d == "right" then d = "up" end + elseif d == "right" then d = southNorth(dy); if d == "down" then d = "up" end end + return d + end, + [4] = function(dx, dy) + local d = vectorDirection(dx, dy) + if d == "down" then d = westEast(dx); if d == "left" then d = "up" end + elseif d == "left" then d = southNorth(dy); if d == "down" then d = "up" end end + return d + end, + [5] = function(dx, dy) + local d = vectorDirection(dx, dy) + if d == "up" then d = westEast(dx); if d == "right" then d = "down" end + elseif d == "right" then d = southNorth(dy); if d == "up" then d = "down" end end + return d + end, + [6] = function(dx, dy) + local d = vectorDirection(dx, dy) + if d == "up" then d = westEast(dx); if d == "left" then d = "down" end + elseif d == "left" then d = southNorth(dy); if d == "up" then d = "down" end end + return d + end, + [7] = function(dx, dy) + local d = vectorDirection(dx, dy) + if d == "right" then d = southNorth(dy) end + return d + end, + [8] = function(dx, dy) + local d = vectorDirection(dx, dy) + if d == "left" then d = southNorth(dy) end + return d + end, + [9] = function(dx, dy) + local d = vectorDirection(dx, dy) + if d == "down" then d = westEast(dx) end + return d + end, + [10] = function(dx, dy) + local d = vectorDirection(dx, dy) + if d == "up" then d = westEast(dx) end + return d + end, +} + +-- src/event_object_movement.c:2992 +local function followDirection(eo, follow, ctx) + local px, py = playerCellFor(ctx) + local fn = FOLLOW[follow] or FOLLOW[0] + return fn(px - eo.cellX, py - eo.cellY) +end + local function idleTick(eo, game, ctx) if eo.frozen or eo.scriptBusy or eo.moving or eo.hidden or not eo.visible then return @@ -951,75 +1154,94 @@ local function idleTick(eo, game, ctx) raiseHandTick(eo) return end - eo.idleTimer = (eo.idleTimer or 0) - 1 - if eo.idleTimer > 0 then return end local mv = tostring(eo.movement or "STAY"):upper() - if mv == "STAY" then - eo.idleTimer = 120 + if mv == "STAY" then return end + local spec = eo.spec + if not spec or spec.movement ~= mv then + spec = { movement = mv, dirs = dirsForRange(eo.range), delays = "MEDIUM", follow = 0 } + end + + if mv == "BACK_FORTH" then + -- src/event_object_movement.c:3849 + local dir = (eo.seqIndex or 0) ~= 0 and OPPOSITE_DIR[spec.face] or spec.face + if (eo.seqIndex or 0) ~= 0 and eo.cellX == eo.homeX and eo.cellY == eo.homeY then + eo.seqIndex = 0 + dir = OPPOSITE_DIR[dir] + end + local c = stepCollision(eo, game, ctx, dir) + if c == "range" then + eo.seqIndex = (eo.seqIndex or 0) + 1 + dir = OPPOSITE_DIR[dir] + c = stepCollision(eo, game, ctx, dir) + end + walkOrInPlace(eo, dir, c) + return + end + + if mv == "SEQUENCE" then + -- src/event_object_movement.c:3949 + local idx = eo.seqIndex or 0 + if idx == spec.skipFrom and ((spec.skipAxis == "x" and eo.cellX == eo.homeX) + or (spec.skipAxis == "y" and eo.cellY == eo.homeY)) then + idx = spec.skipFrom + 1 + end + -- src/event_object_movement.c:3914 + if idx == 3 and eo.cellX == eo.homeX and eo.cellY == eo.homeY then idx = 0 end + local dir = spec.route[idx + 1] + local c = stepCollision(eo, game, ctx, dir) + if c == "range" then + idx = (idx + 1) % 4 + dir = spec.route[idx + 1] + c = stepCollision(eo, game, ctx, dir) + end + eo.seqIndex = idx + walkOrInPlace(eo, dir, c) return end local Rng = require("src.core.game3.rng") - local dirs = dirsForRange(eo.range) - if mv == "LOOK" or mv == "LOOK_AROUND" then + local function pick(t) return t[(Rng.Random() % #t) + 1] end + + if mv == "LOOK" or mv == "LOOK_AROUND" or mv == "ROTATE" then + -- src/event_object_movement.c:3044 + local close = trainerCloseToRunningPlayer(eo, ctx) + if eo.idleTimer == nil then + eo.idleTimer = mv == "ROTATE" and 48 or pick(GfxIds.DELAYS[spec.delays or "MEDIUM"]) + end + eo.idleTimer = eo.idleTimer - 1 + if eo.idleTimer > 0 and not close then return end local oldFacing = eo.facing - local pickIdx = Rng.compat(1, #dirs) - eo.facing = dirs[pickIdx] or dirs[1] - eo.idleTimer = 48 + Rng.compat(0, 47) + -- src/event_object_movement.c:2992 + local dir = close and followDirection(eo, spec.follow or 0, ctx) + if not dir then + dir = mv == "ROTATE" and spec.next[eo.facing] or pick(spec.dirs) + end + eo.facing = dir + eo.idleTimer = mv == "ROTATE" and 48 or pick(GfxIds.DELAYS[spec.delays or "MEDIUM"]) if not ctx and eo.facing ~= oldFacing and eo.sight and eo.sight > 0 then - local okTs, TrainerSight = pcall(require, "src.core.game3.trainer_sight") - if okTs and TrainerSight and TrainerSight.check then - TrainerSight.check(game, eo) - end + checkSight(game, eo) end return end if mv == "WALK" then - local pickIdx = Rng.compat(1, #dirs) - local dir = dirs[pickIdx] or dirs[1] - local d = DELTA[dir] - local tx, ty = eo.cellX + d[1], eo.cellY + d[2] - local rx = (eo.radius and eo.radius.x) or 1 - local ry = (eo.radius and eo.radius.y) or 1 - if math.abs(tx - eo.homeX) > rx or math.abs(ty - eo.homeY) > ry then - eo.idleTimer = 30 + Rng.compat(0, 29) + -- src/event_object_movement.c:2716 + if eo.idleTimer == nil then eo.idleTimer = pick(GfxIds.DELAYS.MEDIUM) end + eo.idleTimer = eo.idleTimer - 1 + if eo.idleTimer > 0 then return end + -- src/event_object_movement.c:2731 + local dir = pick(spec.dirs or dirsForRange(eo.range)) + eo.facing = dir + eo.idleTimer = pick(GfxIds.DELAYS.MEDIUM) + if stepCollision(eo, game, ctx, dir) then + if not ctx and eo.sight and eo.sight > 0 then checkSight(game, eo) end return end - local ok - -- pokefirered/src/event_object_movement.c:4830 GetCollisionAtCoords - if ctx then - local Coll = Collision() - ok = ctx.canEnter(tx, ty, eo.cellX, eo.cellY, dir) - and not ctx.blocks(tx, ty, eo.localId) - if ok and eo.mapDef and Coll.directionallyImpassableOn( - eo.mapDef, eo.cellX, eo.cellY, tx, ty, dir) then - ok = false - end - else - local Coll = Collision() - -- pokefirered/src/event_object_movement.c:8346 IsElevationMismatchAt - local onWater = Coll.isWater(eo.cellX, eo.cellY) - ok = Coll.canEnter(game, tx, ty, - { fromX = eo.cellX, fromY = eo.cellY, dir = dir, surfing = onWater }) - if ok and Coll.isWater(tx, ty) ~= onWater then ok = false end - if Objects.playerBlocks(tx, ty) then ok = false end - if Objects.blocks(tx, ty, eo.localId) then ok = false end - end - if ok then - eo.facing = dir - beginStep(eo, tx, ty) - else - eo.facing = dir -- turn toward blocked anyway - if not ctx and eo.sight and eo.sight > 0 then - local okTs, TrainerSight = pcall(require, "src.core.game3.trainer_sight") - if okTs and TrainerSight and TrainerSight.check then - TrainerSight.check(game, eo) - end - end - end - eo.idleTimer = 40 + Rng.compat(0, 49) + local d = DELTA[dir] + beginStep(eo, eo.cellX + d[1], eo.cellY + d[2]) + -- src/event_object_movement.c:8959 + if spec.slow then eo.stepFrames = WALK_FRAMES * 2 end end end @@ -1041,12 +1263,31 @@ function Objects.update(game) end end -function Objects.spawnFromDefs(defs, mapDef) +function Objects.spawnFromDefs(defs, mapDef, mapId) local pool = { byId = {}, order = {}, bounds = layoutBounds(mapDef), mapDef = mapDef } + local Sp = mapId and Space() + local nb = Sp and Sp.neighborObjectState and Sp.neighborObjectState(mapId) + if mapId and not nb and not (Sp and Sp.mapId == mapId) then nb = { store = { flags = {}, vars = {} }, perm = {}, movementType = {} } end for _, def in ipairs(defs or {}) do - local eo = newEventObject(def) + local eo = newEventObject(def, nb) if eo.localId > 0 then eo.mapDef = mapDef + if nb then + local p = nb.perm[eo.localId] + if p then + eo.cellX, eo.cellY, eo.homeX, eo.homeY = p.x, p.y, p.x, p.y + eo.targetX, eo.targetY = p.x, p.y + eo.px, eo.py = p.x * CELL, p.y * CELL + eo.def.x, eo.def.y = p.x, p.y + end + local mt = nb.movementType[eo.localId] + if mt then + Objects.setTrainerMovementType(eo, mt) + -- pokefirered/src/event_object_movement.c:359 + eo.facing = GfxIds.initialFacing(mt) + end + if (tonumber(eo.graphicsId) or 0) >= 240 then eo.invisible = true end + end pool.byId[eo.localId] = eo pool.order[#pool.order + 1] = eo.localId end @@ -1094,14 +1335,22 @@ function Objects.adoptPool(pool) for _, lid in ipairs(pool.order or {}) do local live = Objects._byId[lid] local ghost = pool.byId[lid] - local wanders = live and tostring(live.movement or "STAY"):upper() == "WALK" - if live and ghost and wanders and not ghost.moving then + local mv = live and ghost and live.movement == ghost.movement + and tostring(live.movement or "STAY"):upper() + if mv == "WALK" or mv == "BACK_FORTH" or mv == "SEQUENCE" then live.cellX, live.cellY = ghost.cellX, ghost.cellY live.px, live.py = ghost.px, ghost.py + live.targetX, live.targetY = ghost.targetX, ghost.targetY + live.moving, live.progress = ghost.moving, ghost.progress + live.stepFrames, live.animClock = ghost.stepFrames, ghost.animClock live.facing = ghost.facing live.stepFlip = ghost.stepFlip live.idleTimer = ghost.idleTimer + live.seqIndex = ghost.seqIndex if live.def then live.def.x, live.def.y = live.cellX, live.cellY end + elseif mv == "LOOK" or mv == "ROTATE" then + live.facing = ghost.facing + live.idleTimer = ghost.idleTimer end end return true @@ -1225,12 +1474,25 @@ function Objects.removeObject(localId) return true end +-- src/event_object_movement.c:2059 +local function setObjectInvisibility(localId, state) + local lid = tonumber(localId) or 0 + if lid >= Objects.PLAYER_LOCAL_ID then + Player().setVisible(not state) + return true + end + local eo = Objects._byId[lid] + if not eo then return false end + eo.invisible = state + return true +end + function Objects.hideObject(localId) - return Objects.removeObject(localId) + return setObjectInvisibility(localId, true) end function Objects.showObject(localId) - return Objects.addObject(localId) + return setObjectInvisibility(localId, false) end function Objects.turnObject(localId, dir) @@ -1297,13 +1559,7 @@ function Objects.setTrainerMovementType(localId, mt) local eo = type(localId) == "table" and localId or Objects._byId[tonumber(localId) or 0] if not eo then return end mt = tonumber(mt) or 0 - eo.movementType = mt - local hostMv = GfxIds.hostMovement and GfxIds.hostMovement(mt) - if hostMv then - eo.movement = hostMv.movement - eo.range = hostMv.range - if hostMv.radius then eo.radius = hostMv.radius end - end + setSpec(eo, mt) clearRaiseHand(eo) -- src/event_object_movement.c:4543 if mt == MOVEMENT_TYPE_INVISIBLE then eo.invisible = true end diff --git a/src/core/game3/ow_sprites.lua b/src/core/game3/ow_sprites.lua index cc875aac..00f38152 100644 --- a/src/core/game3/ow_sprites.lua +++ b/src/core/game3/ow_sprites.lua @@ -15,6 +15,10 @@ OwSprites._logged = false local STAND = { down = 0, up = 1, left = 2, right = 2 } local WALK_A = { down = 3, up = 5, left = 7, right = 7 } local WALK_B = { down = 4, up = 6, left = 8, right = 8 } +-- src/data/object_events/object_event_anims.h:601 +local RUN_BASE = { down = 9, up = 12, left = 15, right = 15 } +local RUN_A = { down = 10, up = 13, left = 16, right = 16 } +local RUN_B = { down = 11, up = 14, left = 17, right = 17 } local function owRoot() -- Must follow Dataset.mountExtractRoots() — do not bake CACHE_ROOT at require. @@ -367,6 +371,13 @@ function OwSprites.pose(spr, facing, walkPhase, stepFlip, opts) return f, false end + if opts and opts.running ~= nil and spr.frameCount >= 18 then + if opts.running == 1 then + return (stepFlip and RUN_A[facing] or RUN_B[facing]) or RUN_BASE[facing] or 9, flip + end + return RUN_BASE[facing] or 9, flip + end + if spr.frameCount == 3 then -- Surfing mount pose (0 = down, 1 = up, 2 = left, 2 + hflip = right) local f = STAND[facing] or 0 diff --git a/src/core/game3/player.lua b/src/core/game3/player.lua index f95ef932..0ef57280 100644 --- a/src/core/game3/player.lua +++ b/src/core/game3/player.lua @@ -153,6 +153,7 @@ function Player.reset(x, y, facing) Player.spinning = false Player.walkInPlace = false Player.walkInPlaceFast = false + Player.boulderPush = nil if not Player._logged then log(string.format("avatar ready @ %d,%d %s", Player.cellX, Player.cellY, Player.facing)) @@ -243,6 +244,16 @@ function Player.walkPhase() return (p >= math.floor(frames / 4) and p < mid + math.floor(frames / 4)) and 1 or 0 end +-- src/event_object_movement.c:5333 +function Player.runPose() + if not (Player.moving and Player.running) or Player.biking or Player.jumping + or Player.surfing or Player.animDisabled then + return nil + end + -- src/data/object_events/object_event_anims.h:601 + return ((Player.animClock or 0) - 1) % 8 >= 5 and 1 or 0 +end + function Player.drawFlip() return Player.stepFlip and true or false end @@ -302,9 +313,10 @@ local function beginStep(tx, ty, run, ledge) end function Player.tryMove(dir, game, run) - if Player.moving then return nil end + if Player.moving or Player.boulderPush then return nil end if not DELTA[dir] then return nil end + local wasFacing = Player.facing if Player.facing ~= dir then Player.facing = dir if Player.turnArmed then @@ -398,6 +410,17 @@ function Player.tryMove(dir, game, run) end end + -- pokefirered/src/field_control_avatar.c:262 + local Field = package.loaded["src.core.game3.field"] + if Field and Field.tryWalkIntoSign then + -- pokefirered/src/field_control_avatar.c:260 + if wasFacing ~= dir then + if Field.tryWalkIntoSign(game, dir, true) then return "sign_turn" end + elseif Field.tryWalkIntoSign(game, dir) then + return "sign" + end + end + local ok, why = Collision.canEnter(game, tx, ty, { fromX = Player.cellX, fromY = Player.cellY, @@ -415,30 +438,32 @@ function Player.tryMove(dir, game, run) local Objects = require("src.core.game3.objects") local obj = Objects.at(tx, ty) if obj and (obj.def and (obj.def.graphicsId == FieldMoves.GFX_IDS.PUSHABLE_BOULDER or obj.def.gfx == FieldMoves.GFX_IDS.PUSHABLE_BOULDER)) then + -- pokefirered/src/field_player_avatar.c:638 local canPush, destBx, destBy = FieldMoves.canPushBoulder(obj, dir, function(bx, by) - return Collision.canEnter(game, bx, by, { fromX = tx, fromY = ty, dir = dir }) + local beh = Collision.behavior(bx, by) + if Collision.isFallWarp(beh) then return true end + return Collision.canEnter(game, bx, by, { fromX = tx, fromY = ty, dir = dir }) == true + and not Collision.isNonAnimDoor(beh) end) - if canPush then - obj.cellX = destBx - obj.cellY = destBy - obj.px = destBx * CELL - obj.py = destBy * CELL - obj.targetX = destBx - obj.targetY = destBy - obj.moving = false - obj.facing = dir - if obj.def then obj.def.x = destBx; obj.def.y = destBy end + if canPush and not obj.moving then + -- pokefirered/src/field_player_avatar.c:1417 DoBoulderInit + Player.boulderPush = { obj = obj } + -- pokefirered/src/field_player_avatar.c:1425 DoBoulderDust + Player.facing = dir + Player.walkInPlace = true + Player.walkInPlaceFast = false + Player.animClock = 0 + Objects.pushStep(obj, dir, WALK_FRAMES * 2) + local FieldEffects = require("src.core.game3.field_effects") + FieldEffects.startDust(tx, ty) + require("src.core.game3.audio").playSe(require("src.core.game3.se_ids").SE_M_STRENGTH) if ModRuntime.wants("world.boulder_moved") then local Map = package.loaded["src.core.game3.map"] ModRuntime.emit("world.boulder_moved", { mapId = Map and Map.current, npcId = obj.localId, x = destBx, y = destBy, }) end - beginStep(tx, ty, false, false) - -- pokefirered/src/field_player_avatar.c:1452 - local Field = require("src.core.game3.field") - if Field.onBoulderMoved then Field.onBoulderMoved(game, obj, destBx, destBy) end - return "step" + return "push" end end end @@ -457,6 +482,7 @@ function Player.tryMove(dir, game, run) local isDismount = Player.surfing and (not (Collision.isWater and Collision.isWater(tx, ty))) if isDismount then Player.dismounting = true + require("src.core.game3.audio").stopSurfMusic() else Player.dismounting = false end @@ -551,6 +577,8 @@ function Player.forcedStep(dir, frames, opts) local tx, ty = Player.cellX + d[1], Player.cellY + d[2] Player.dismounting = Player.surfing and (not (Collision.isWater and Collision.isWater(tx, ty))) or false + -- pokefirered/src/field_player_avatar.c:1609 + if Player.dismounting then require("src.core.game3.audio").stopSurfMusic() end beginStep(tx, ty, false, false) end if frames and not opts.ledgeX and not Player.dismounting then @@ -707,9 +735,6 @@ local function finishStep(game) end end - -- pokefirered/src/field_tasks.c:66 - ForcedMovement.runStepCallback(game, Player.prevCellX, Player.prevCellY) - -- pokefirered/src/field_player_avatar.c:136 local Warp = package.loaded["src.core.game3.warp"] local warping = (Warp and Warp.isBusy and Warp.isBusy()) and true or false @@ -824,6 +849,18 @@ end function Player.update(game, input) Player.tick(game) if Player.moving then return end + local push = Player.boulderPush + if push then + if Player.walkInPlace and Player.animClock >= WALK_FRAMES then + Player.walkInPlace = false + end + -- pokefirered/src/field_player_avatar.c:1445 DoBoulderFinish + if Player.walkInPlace or push.obj.moving then return end + Player.boulderPush = nil + local Field = require("src.core.game3.field") + Field.onBoulderMoved(game, push.obj, push.obj.cellX, push.obj.cellY) + return + end if Player.fieldMoveAnim and Player.fieldMoveAnim > 0 then return end local Field = package.loaded["src.core.game3.field"] diff --git a/src/core/game3/pokedex_data.lua b/src/core/game3/pokedex_data.lua index 6259290d..8381e8e4 100644 --- a/src/core/game3/pokedex_data.lua +++ b/src/core/game3/pokedex_data.lua @@ -184,10 +184,7 @@ end function PokedexData.getEntry(speciesId) PokedexData.init() local sp = tonumber(speciesId) or 1 - local natId = Pokemon.national and Pokemon.national(sp) or sp - - local raw = (PokedexData._entries and PokedexData._entries[natId]) - or (PokedexData._entries and PokedexData._entries[sp]) + local raw = PokedexData._entries and PokedexData._entries[sp] if not raw then local name = (Pokemon.name and Pokemon.name(sp)) or "POKéMON" @@ -296,28 +293,42 @@ function PokedexData.getOrderList(orderKey, dex) elseif orderKey == "numerical_national" then local maxNat = Dex.NATIONAL_MAX or 386 local highestSeen = 0 - for i = 1, maxNat do - if Dex.isSeen(dex, i) then - highestSeen = i + for nat = 1, maxNat do + if Dex.isSeen(dex, Pokemon.speciesFromNational(nat)) then + highestSeen = nat end end local result = {} - for i = 1, highestSeen do - table.insert(result, i) + for nat = 1, highestSeen do + table.insert(result, Pokemon.speciesFromNational(nat)) end return result elseif orderKey == "atoz" then + -- pokefirered/src/pokedex_screen.c:1404 local result = {} - for _, sp in ipairs(rawList) do - if sp <= maxN and Dex.isSeen(dex, sp) then + for _, nat in ipairs(rawList) do + local sp = Pokemon.speciesFromNational(nat) + if nat <= maxN and sp and Dex.isSeen(dex, sp) then table.insert(result, sp) end end return result - elseif orderKey == "type" or orderKey == "lightest" or orderKey == "smallest" then + elseif orderKey == "lightest" or orderKey == "smallest" then + -- pokefirered/src/pokedex_screen.c:1438 + local result = {} + for _, nat in ipairs(rawList) do + local sp = Pokemon.speciesFromNational(nat) + if nat <= maxN and sp and Dex.isCaught(dex, sp) then + table.insert(result, sp) + end + end + return result + elseif orderKey == "type" then + -- pokefirered/src/pokedex_screen.c:1421 local result = {} for _, sp in ipairs(rawList) do - if sp <= maxN and Dex.isCaught(dex, sp) then + local nat = Pokemon.national(sp) + if nat and nat <= maxN and Dex.isCaught(dex, sp) then table.insert(result, sp) end end diff --git a/src/core/game3/pokedex_rating.lua b/src/core/game3/pokedex_rating.lua index 154b21a7..8c9cb6bd 100644 --- a/src/core/game3/pokedex_rating.lua +++ b/src/core/game3/pokedex_rating.lua @@ -6,24 +6,24 @@ local PokedexRating = {} local KANTO_DEX_COUNT = 151 local SPECIES_MEW = 151 --- pokefirered/data/text/pokedex_rating.inc +-- pokefirered/src/prof_pc.c:6-21 local RATING_MESSAGES = { - LESS_THAN_10 = "You still have lots to do.\nGo into every patch of grass you\nsee and look for POKéMON!", - LESS_THAN_20 = "It looks as if you're getting on\nthe right track!\nI've given one of my AIDES a FLASH\nHM. Make sure you go get it!", - LESS_THAN_30 = "Your POKéDEX could use a bit more\nvolume still!\nTry to catch other species of\nPOKéMON!", - LESS_THAN_40 = "Good, it's apparent that you're\ntrying hard!\nI've given one of my AIDES an\nITEMFINDER. Be sure to collect it!", - LESS_THAN_50 = "Your POKéDEX is coming along quite\nwell!\nI've given one of my AIDES an\nAMULET COIN. Be sure to get it!", - LESS_THAN_60 = "Ah, you've finally topped 50\nspecies!\nI've given one of my AIDES an EXP.\nSHARE. Be sure to go get it!", - LESS_THAN_70 = "Hoho! This is turning into quite the\nrespectable POKéDEX!", - LESS_THAN_80 = "Very good!\nI think you'll collect even more\nPOKéMON by going fishing!", - LESS_THAN_90 = "Wonderful! Let me guess… You\nlike to collect things, don't you?", - LESS_THAN_100 = "I'm impressed!\nIt must have been difficult to do!", - LESS_THAN_110 = "You've finally hit 100 species!\nI can't believe how good you are!", - LESS_THAN_120 = "You even have the evolved forms\nof POKéMON! Super!", - LESS_THAN_130 = "Excellent! Trade with friends to\nget some more!", - LESS_THAN_140 = "Outstanding!\nYou've become a real pro at this!", - LESS_THAN_150 = "I have nothing left to say!\nYou're the POKéMON PROFESSOR now!", - COMPLETE = "Your POKéDEX is entirely complete!\nCongratulations!!", + LESS_THAN_10 = "PokedexRating_Text_LessThan10", + LESS_THAN_20 = "PokedexRating_Text_LessThan20", + LESS_THAN_30 = "PokedexRating_Text_LessThan30", + LESS_THAN_40 = "PokedexRating_Text_LessThan40", + LESS_THAN_50 = "PokedexRating_Text_LessThan50", + LESS_THAN_60 = "PokedexRating_Text_LessThan60", + LESS_THAN_70 = "PokedexRating_Text_LessThan70", + LESS_THAN_80 = "PokedexRating_Text_LessThan80", + LESS_THAN_90 = "PokedexRating_Text_LessThan90", + LESS_THAN_100 = "PokedexRating_Text_LessThan100", + LESS_THAN_110 = "PokedexRating_Text_LessThan110", + LESS_THAN_120 = "PokedexRating_Text_LessThan120", + LESS_THAN_130 = "PokedexRating_Text_LessThan130", + LESS_THAN_140 = "PokedexRating_Text_LessThan140", + LESS_THAN_150 = "PokedexRating_Text_LessThan150", + COMPLETE = "PokedexRating_Text_Complete", } PokedexRating.TEXT = RATING_MESSAGES @@ -87,7 +87,8 @@ function PokedexRating.getProfOaksRatingMessage(session, ctx, adapters) local count = tonumber(flagsMod.getVar(scriptStore, ctx, 0x8004)) or 0 local dex = dexOf(session, ctx) - local msg, isComplete = PokedexRating.getRatingMessage(count, dex) + local key, isComplete = PokedexRating.getRatingMessage(count, dex) + local msg = require("src.core.game3.rom_text").box(key, ctx) flagsMod.setVar(scriptStore, ctx, 0x800D, isComplete and 1 or 0) diff --git a/src/core/game3/pokemon.lua b/src/core/game3/pokemon.lua index b3bccb5a..5866134d 100644 --- a/src/core/game3/pokemon.lua +++ b/src/core/game3/pokemon.lua @@ -15,7 +15,6 @@ Pokemon._manifest = nil Pokemon._byName = nil -- normalized host/FRLG name → internal SPECIES Pokemon._icons = {} -- [species] = { image, w, h } Pokemon._front = {} -- [species] = { image, w, h } -Pokemon._romBytes = nil -- cached full ROM string for lazy front-pic decode Pokemon._stats = nil Pokemon._abilities = nil Pokemon._abilityNames = nil @@ -131,6 +130,8 @@ end function Pokemon.install(cache) Pokemon._cache = resolve_cache(cache) + Pokemon._spinda = nil + Pokemon._spindaPics = nil Pokemon._names = nil Pokemon._types = nil Pokemon._national = nil @@ -151,7 +152,6 @@ function Pokemon.install(cache) Pokemon._battleMoves = nil Pokemon._icons = {} Pokemon._front = {} - Pokemon._romBytes = nil Pokemon._logged = false local root = (CachePaths.CACHE_ROOT or "data/generated/gba") .. "/pokemon" local c = Pokemon._cache @@ -215,7 +215,6 @@ function Pokemon.invalidate() Pokemon._icons = {} Pokemon._front = {} Pokemon._back = nil - Pokemon._romBytes = nil Pokemon._names = nil Pokemon._types = nil Pokemon._national = nil @@ -1268,6 +1267,55 @@ function Pokemon.speciesOrEgg(mon) return Pokemon.speciesOf(mon) end +local SPECIES_UNOWN = 201 +local SPECIES_UNOWN_B = 413 + +-- pokefirered/src/pokemon_icon.c:1080 +function Pokemon.unownLetter(personality) + local p = (tonumber(personality) or 0) % 4294967296 + if p == 0 then return 0 end + local function bits(shift) return math.floor(p / 2 ^ shift) % 4 end + return (bits(24) * 64 + bits(16) * 16 + bits(8) * 4 + bits(0)) % 28 +end + +-- pokefirered/src/decompress.c:85, src/pokemon_icon.c:1056 +function Pokemon.picSpecies(species, personality) + species = tonumber(species) + if species ~= SPECIES_UNOWN then return species end + local letter = Pokemon.unownLetter(personality) + if letter == 0 then return SPECIES_UNOWN end + return SPECIES_UNOWN_B + letter - 1 +end + +-- pokefirered/src/pokemon.c:6062 +function Pokemon.isShiny(mon) + if not mon then return false end + if mon.isShiny ~= nil then return not not mon.isShiny end + local p = (tonumber(mon.personality) or 0) % 4294967296 + local tid = (tonumber(mon.otId or mon.trainerId) or 0) % 65536 + local sid = (tonumber(mon.otSecretId) or 0) % 65536 + local value = bit.bxor(bit.bxor(tid, sid), bit.bxor(math.floor(p / 65536), p % 65536)) + return value < 8 +end + +function Pokemon.monPicSpecies(mon) + if Pokemon.isEgg(mon) then return Pokemon.SPECIES_EGG end + return Pokemon.picSpecies(Pokemon.speciesOf(mon), mon and mon.personality) +end + +function Pokemon.monFrontPic(mon, form) + return Pokemon.frontPic(Pokemon.monPicSpecies(mon), form, Pokemon.isShiny(mon), mon and mon.personality) +end + +function Pokemon.monBackPic(mon, form) + return Pokemon.backPic(Pokemon.monPicSpecies(mon), form, Pokemon.isShiny(mon)) +end + +-- pokefirered/src/pokemon_icon.c:1116 +function Pokemon.monIcon(mon) + return Pokemon.icon(Pokemon.monPicSpecies(mon)) +end + local function read_rgba(species) local cache = resolve_cache(Pokemon._cache) local root = (CachePaths.CACHE_ROOT or "data/generated/gba") .. "/pokemon" @@ -1322,146 +1370,11 @@ function Pokemon.icon(species) return entry end -local function bgr555_to_rgb8(c) - c = (tonumber(c) or 0) % 32768 - local r5 = c % 32 - local g5 = math.floor(c / 32) % 32 - local b5 = math.floor(c / 1024) % 32 - return math.floor(r5 * 255 / 31 + 0.5), - math.floor(g5 * 255 / 31 + 0.5), - math.floor(b5 * 255 / 31 + 0.5) -end - -local function load_rom_bytes() - if Pokemon._romBytes then return Pokemon._romBytes end - local candidates = { - "1636 - Pokemon Fire Red (U)(Squirrels).gba", - "firered.gba", - "Pokemon FireRed.gba", - } - for _, path in ipairs(candidates) do - local f = io.open(path, "rb") - if f then - local data = f:read("*a") - f:close() - if data and #data >= 0x1000000 then - Pokemon._romBytes = data - return data - end - end - if love and love.filesystem and love.filesystem.read then - local ok, data = pcall(love.filesystem.read, path) - if ok and data and #data >= 0x1000000 then - Pokemon._romBytes = data - return data - end - end - end - local okC, CacheFs = pcall(require, "src.import.CacheFs") - if okC and CacheFs and CacheFs.readActive then - for _, path in ipairs(candidates) do - local data = CacheFs.readActive(path) - if data and #data >= 0x1000000 then - Pokemon._romBytes = data - return data - end - end - end - return nil -end - -local function rom_u8(data, off) - return data:byte(off + 1) or 0 -end - -local function rom_u16(data, off) - return rom_u8(data, off) + rom_u8(data, off + 1) * 256 -end - -local function rom_u32(data, off) - return rom_u8(data, off) - + rom_u8(data, off + 1) * 256 - + rom_u8(data, off + 2) * 65536 - + rom_u8(data, off + 3) * 16777216 -end - ---- Linear 4bpp decode helper shared by front/back. -local function decode_pic_rgba(species, picTable, palTable, cacheRel, form, fileOffs) - species = tonumber(species) - if not species or species < 1 then return nil end +local function read_pic(rel) local cache = resolve_cache(Pokemon._cache) - if cache and cache.read then - local d = cache:read(cacheRel) - if d and #d >= 64 * 64 * 4 then return d end - end - - local data = load_rom_bytes() - if not data then return nil end - local Lz77 = require("src.import.gba.lz77") - local tileFile, palFile - if fileOffs then - tileFile, palFile = fileOffs[1], fileOffs[2] - else - tileFile = Versions.gbaToFile(rom_u32(data, picTable + species * 8)) - palFile = Versions.gbaToFile(rom_u32(data, palTable + species * 8)) - end - if not tileFile or not palFile then return nil end - form = tonumber(form) or 0 - local function get(i) - return rom_u8(data, i) - end - local okT, tiles = pcall(Lz77.decompress, get, tileFile) - local okP, palBytes = pcall(Lz77.decompress, get, palFile) - if not okT or not okP or type(tiles) ~= "table" or type(palBytes) ~= "table" then - return nil - end - local pal = {} - for c = 0, 15 do - local lo = palBytes[form * 32 + c * 2 + 1] or 0 - local hi = palBytes[form * 32 + c * 2 + 2] or 0 - pal[c] = lo + hi * 256 - end - local w, h = 64, 64 - local rgb = {} - for c = 0, 15 do - local r, g, b = bgr555_to_rgb8(pal[c] or 0) - rgb[c] = { r, g, b } - end - local tilesW, tilesH = 8, 8 - local chunks = {} - local ti = 0 - for ty = 0, tilesH - 1 do - for tx = 0, tilesW - 1 do - local tileOff = form * 2048 + ti * 32 - for row = 0, 7 do - for bx = 0, 3 do - local bi = tileOff + row * 4 + bx + 1 - local byte = tiles[bi] or 0 - local p0 = byte % 16 - local p1 = math.floor(byte / 16) % 16 - local x0 = tx * 8 + bx * 2 - local y0 = ty * 8 + row - local function put(x, y, idx) - local i = y * w + x + 1 - if idx == 0 then - chunks[i] = string.char(0, 0, 0, 0) - else - local c = rgb[idx] or rgb[0] - chunks[i] = string.char(c[1], c[2], c[3], 255) - end - end - put(x0, y0, p0) - put(x0 + 1, y0, p1) - end - end - ti = ti + 1 - end - end - local rgba = table.concat(chunks) - if cache and cache.write then - pcall(cache.write, cache, cacheRel, rgba) - end - return rgba + local d = cache and cache.read and cache:read(rel) + if type(d) == "string" and #d >= 64 * 64 * 4 then return d end + return nil end local SPECIES_CASTFORM = 385 @@ -1478,22 +1391,6 @@ local function pic_rel(kind, species, form) return root .. species .. ".rgba" end -local function decode_front_rgba(species, form) - species = tonumber(species) - if not species or species < 1 then return nil end - local picTable = (Versions.OAK_SPEECH and Versions.OAK_SPEECH.mon_front_pic_table) or 0x2350AC - local palTable = (Versions.OAK_SPEECH and Versions.OAK_SPEECH.mon_palette_table) or 0x23730C - return decode_pic_rgba(species, picTable, palTable, pic_rel("front", species, form), form) -end - -local function decode_back_rgba(species, form) - species = tonumber(species) - if not species or species < 1 then return nil end - local picTable = Versions.MON_BACK_PIC_TABLE or 0x23654C - local palTable = (Versions.OAK_SPEECH and Versions.OAK_SPEECH.mon_palette_table) or 0x23730C - return decode_pic_rgba(species, picTable, palTable, pic_rel("back", species, form), form) -end - local function pic_entry(store, key, rgba) local image = image_from_rgba(rgba, 64, 64) if not image then return nil end @@ -1502,40 +1399,142 @@ local function pic_entry(store, key, rgba) return entry end ---- 64×64 front pic for showmonpic (ROM-lazy or cache), else nil. --- pokefirered/src/battle_gfx_sfx_util.c:354 -function Pokemon.frontPic(species, form) +-- pokefirered/src/data/pokemon_graphics/shiny_palette_table.h:415 +local function pic(store, kind, species, form, shiny) species = tonumber(species) if not species or species < 1 then return nil end form = form_of(species, form) + if shiny and species ~= Pokemon.SPECIES_EGG then kind = kind .. "_shiny" end local key = form > 0 and (species .. "_" .. form) or species - if Pokemon._front[key] then return Pokemon._front[key] end - local entry = pic_entry(Pokemon._front, key, decode_front_rgba(species, form)) - if not entry and form > 0 then return Pokemon.frontPic(species) end - return entry + if kind:find("_shiny", 1, true) then key = "shiny:" .. key end + if store[key] then return store[key] end + return pic_entry(store, key, read_pic(pic_rel(kind, species, form))) +end + +local SPECIES_SPINDA = 308 +local SPINDA_ROOT = "/pokemon/spinda/" + +local function spinda_file(name) + local cache = resolve_cache(Pokemon._cache) + return cache and cache.read and cache:read((CachePaths.CACHE_ROOT or "data/generated/gba") .. SPINDA_ROOT .. name) +end + +local function spinda_data() + if Pokemon._spinda then return Pokemon._spinda end + local tiles, normal, shinyPal, spots = spinda_file("front.4bpp"), spinda_file("normal.gbapal"), + spinda_file("shiny.gbapal"), spinda_file("spots.bin") + if not (tiles and #tiles >= 2048 and normal and #normal >= 32 and shinyPal and #shinyPal >= 32 + and spots and #spots >= 144) then + return nil + end + Pokemon._spinda = { tiles = tiles, normal = normal, shiny = shinyPal, spots = spots } + return Pokemon._spinda +end + +-- pokefirered/src/pokemon.c:5276 +function Pokemon.drawSpindaSpots(buf, spots, personality) + local p = (tonumber(personality) or 0) % 4294967296 + for i = 0, 3 do + local base = i * 36 + local x = (spots:byte(base + 1) + (p % 16) - 8) % 256 + local y = (spots:byte(base + 2) + (math.floor(p / 16) % 16) - 8) % 256 + for row = 0, 15 do + local bits = spots:byte(base + 3 + row * 2) + spots:byte(base + 4 + row * 2) * 256 + for column = x, x + 15 do + local off = math.floor(column / 8) * 32 + math.floor((column % 8) / 2) + + math.floor(y / 8) * 256 + (y % 8) * 4 + if bits % 2 == 1 then + local b = buf[off] or 0 + if column % 2 == 1 then + local hi = math.floor(b / 16) + if hi >= 1 and hi <= 3 then buf[off] = b + 64 end + else + local lo = b % 16 + if lo >= 1 and lo <= 3 then buf[off] = b + 4 end + end + end + bits = math.floor(bits / 2) + end + y = (y + 1) % 256 + end + p = math.floor(p / 256) + end + return buf +end + +local function spinda_rgba(personality, shiny) + local d = spinda_data() + if not d then return nil end + local buf = {} + for i = 0, 2047 do buf[i] = d.tiles:byte(i + 1) end + Pokemon.drawSpindaSpots(buf, d.spots, personality) + local pal = shiny and d.shiny or d.normal + local rgb = {} + for c = 0, 15 do + local v = pal:byte(c * 2 + 1) + pal:byte(c * 2 + 2) * 256 + rgb[c] = string.char(math.floor((v % 32) * 255 / 31 + 0.5), + math.floor((math.floor(v / 32) % 32) * 255 / 31 + 0.5), + math.floor((math.floor(v / 1024) % 32) * 255 / 31 + 0.5), 255) + end + local out = {} + local clear = string.char(0, 0, 0, 0) + for ty = 0, 7 do + for tx = 0, 7 do + local tileOff = (ty * 8 + tx) * 32 + for row = 0, 7 do + for bx = 0, 3 do + local b = buf[tileOff + row * 4 + bx] + local x0 = tx * 8 + bx * 2 + local i0 = (ty * 8 + row) * 64 + x0 + 1 + local lo, hi = b % 16, math.floor(b / 16) + out[i0] = lo == 0 and clear or rgb[lo] + out[i0 + 1] = hi == 0 and clear or rgb[hi] + end + end + end + end + return table.concat(out) +end +Pokemon.spindaRgba = spinda_rgba + +-- pokefirered/src/decompress.c:105, src/pokemon.c:5339 +local function spinda_pic(personality, shiny) + local p = (tonumber(personality) or 0) % 4294967296 + local key = (shiny and "spinda_shiny:" or "spinda:") .. p + Pokemon._spindaPics = Pokemon._spindaPics or {} + if Pokemon._spindaPics[key] then return Pokemon._spindaPics[key] end + return pic_entry(Pokemon._spindaPics, key, spinda_rgba(p, shiny)) +end + +-- pokefirered/src/battle_gfx_sfx_util.c:354 +function Pokemon.frontPic(species, form, shiny, personality) + if tonumber(species) == SPECIES_SPINDA then return spinda_pic(personality, shiny) end + return pic(Pokemon._front, "front", species, form, shiny) +end + +-- pokefirered/src/pokedex_screen.c:2212 +function Pokemon.dexFrontPic(species, personality) + local p = (tonumber(personality) or 0) % 4294967296 + -- include/constants/pokemon.h:185 + local shiny = Pokemon.isShiny({ personality = p, otId = 8, otSecretId = 0 }) + return Pokemon.frontPic(Pokemon.picSpecies(species, p), 0, shiny, p) +end + +-- pokefirered/src/pokedex_screen.c:3058 +function Pokemon.dexIcon(species, personality) + return Pokemon.icon(Pokemon.picSpecies(species, personality)) end Pokemon.frontSprite = Pokemon.frontPic ---- 64×64 back pic for battle (ROM-lazy or cache). -function Pokemon.backPic(species, form) - species = tonumber(species) - if not species or species < 1 then return nil end +function Pokemon.backPic(species, form, shiny) Pokemon._back = Pokemon._back or {} - form = form_of(species, form) - local key = form > 0 and (species .. "_" .. form) or species - if Pokemon._back[key] then return Pokemon._back[key] end - local entry = pic_entry(Pokemon._back, key, decode_back_rgba(species, form)) - if not entry and form > 0 then return Pokemon.backPic(species) end - return entry + return pic(Pokemon._back, "back", species, form, shiny) end -- pokefirered/src/battle_gfx_sfx_util.c:422 function Pokemon.ghostPic() if Pokemon._front.ghost then return Pokemon._front.ghost end - local offs = Versions.GHOST_FRONT_PIC and Versions.GHOST_PALETTE - and { Versions.GHOST_FRONT_PIC, Versions.GHOST_PALETTE } or nil - local rgba = decode_pic_rgba(1, 0, 0, pic_rel("front", "ghost", 0), 0, offs or { false, false }) - return pic_entry(Pokemon._front, "ghost", rgba) + return pic_entry(Pokemon._front, "ghost", read_pic(pic_rel("front", "ghost", 0))) end Pokemon.NUMBERING_INTERNAL = "internal" diff --git a/src/core/game3/profiles/firered.lua b/src/core/game3/profiles/firered.lua index 1c541f47..8a6274ad 100644 --- a/src/core/game3/profiles/firered.lua +++ b/src/core/game3/profiles/firered.lua @@ -139,5 +139,7 @@ return { "egg_extract", "battle_anim_extract", "battle_ai_extract", + "credits_extract", + "league_extract", }, } diff --git a/src/core/game3/rom_text.lua b/src/core/game3/rom_text.lua new file mode 100644 index 00000000..a25f545d --- /dev/null +++ b/src/core/game3/rom_text.lua @@ -0,0 +1,20 @@ +local TextIR = require("src.core.game3.scripting.text_ir") + +local RomText = {} + +function RomText.ir(key) + local Space = require("src.core.game3.scripting.space") + local bundle = Space.ensureBundle() + local ir = bundle and bundle.text and bundle.text[key] + return assert(ir, "ROM text " .. tostring(key) .. " is not in the script cache") +end + +function RomText.box(key, ctx) + return TextIR.toTextBox(RomText.ir(key), ctx or {}) +end + +function RomText.plain(key, ctx) + return TextIR.toPlain(RomText.ir(key), ctx or {}) +end + +return RomText diff --git a/src/core/game3/runtime.lua b/src/core/game3/runtime.lua index fdada096..7702ce9f 100644 --- a/src/core/game3/runtime.lua +++ b/src/core/game3/runtime.lua @@ -75,6 +75,11 @@ end function Runtime.noteFieldFocus(screenOpen) local was = Runtime._menuFocus == true Runtime._menuFocus = screenOpen and true or false + if screenOpen and not was then + -- pokefirered/src/start_menu.c:453 + local Lighting = package.loaded["src.core.game3.league_lighting"] + if Lighting then Lighting.stop() end + end if screenOpen or not was then return false end local Battle = package.loaded["src.core.game3.battle"] if Battle and Battle.isActive and Battle.isActive() then return false end diff --git a/src/core/game3/save_schema_firered.lua b/src/core/game3/save_schema_firered.lua index 157c6445..4a3551ba 100644 --- a/src/core/game3/save_schema_firered.lua +++ b/src/core/game3/save_schema_firered.lua @@ -61,6 +61,14 @@ local function reset_state_on_continue(session) Flags.setFlag(session, nil, FLAG_SYS_SAFARI_MODE, false) clear_saved_var(session, VAR_MAP_SCENE_FUCHSIA_CITY_SAFARI_ZONE_ENTRANCE) session.safari = nil + if type(session.map) == "string" and session.map:find("^FR_SAFARI_ZONE_") + and not Flags.getFlag(session, nil, FLAG_SYS_SAFARI_MODE) then + -- pokefirered/data/scripts/safari_zone.inc:7 SafariZone_EventScript_Exit + local Safari = require("src.core.game3.safari") + Flags.setVar(session, nil, VAR_MAP_SCENE_FUCHSIA_CITY_SAFARI_ZONE_ENTRANCE, 1) + session.map, session.x, session.y = Safari.EXIT_MAP, Safari.EXIT_X, Safari.EXIT_Y + session.facing = "down" + end end -- pokefirered/include/constants/region_map_sections.h:211 KANTO_MAPSEC_START @@ -260,10 +268,39 @@ function Schema.toSaveTable(session) } end +function Schema.hasNoneItemSlot(bag) + if type(bag) ~= "table" then return false end + local ItemsData = require("src.core.game3.items_data") + if type(bag.pockets) == "table" then + for _, slots in pairs(bag.pockets) do + if type(slots) == "table" then + for _, slot in ipairs(slots) do + if type(slot) == "table" and slot.id ~= nil and ItemsData.toNumericId(slot.id) == 0 then + return true + end + end + end + end + end + if type(bag.stacks) == "table" then + for id, qty in pairs(bag.stacks) do + if ItemsData.toNumericId(id) == 0 and (tonumber(qty) or 0) > 0 then return true end + end + end + return false +end + function Schema.fromSaveTable(save) if type(save) ~= "table" then return Schema.newGame() end local Bag = require("src.core.game3.bag") local bag = save.bag or save.inventory or {} + if Schema.hasNoneItemSlot(bag) and type(save.flags) == "table" then + -- pokefirered/include/constants/flags.h:1083 + for id = 0x3E8 + 51, 0x3E8 + 62 do + save.flags[id] = nil + save.flags[tostring(id)] = nil + end + end if type(bag) ~= "table" or not bag.pockets then bag = Bag.migrate(type(bag) == "table" and bag or {}) else diff --git a/src/core/game3/scripting/adapters.lua b/src/core/game3/scripting/adapters.lua index a22746f4..9342d7a7 100644 --- a/src/core/game3/scripting/adapters.lua +++ b/src/core/game3/scripting/adapters.lua @@ -18,20 +18,14 @@ local WARP_SLOT_FIELD = { } -- pokefirered/src/script_menu.c:574 -local STD_STRINGS = { - [0] = "COOL", "BEAUTY", "CUTE", "SMART", "TOUGH", - "COOL", "BEAUTY", "CUTE", "SMART", "TOUGH", - "ITEMS", "KEY ITEMS", "POKé BALLS", "TMs & HMs", "BERRIES", - "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", "RAINBOWBADGE", - "SOULBADGE", "MARSHBADGE", "VOLCANOBADGE", "EARTHBADGE", - "COINS", - "ITEMS POCKET", "KEY ITEMS POCKET", "POKé BALLS POCKET", "TM CASE", "BERRY POUCH", -} +local STD_STRING_COUNT = 29 local function stdString(id) - local s = STD_STRINGS[tonumber(id) or -1] - return s and Strings(s) or nil + id = tonumber(id) + if not id or id < 0 or id >= STD_STRING_COUNT then return nil end + return require("src.core.game3.rom_text").plain("stdstring:" .. id) end +Adapters.stdString = stdString -- pokefirered/src/event_object_movement.c:5208 GetOppositeDirection local OPPOSITE_DIR = { down = "up", up = "down", left = "right", right = "left" } @@ -122,6 +116,7 @@ function Adapters.stub(opts) end a.nurseHeal = opts.nurseHeal or function(done) if done then done() end end a.openPc = opts.openPc or function(done) if done then done() end end + a.hallOfFamePc = opts.hallOfFamePc or function(done) if done then done() end end a.openShop = opts.openShop or function(_items, done) if done then done() end end a.askYesNo = opts.askYesNo or function(cb) if cb then cb(true) end end a.fadeScreen = opts.fadeScreen or function(_mode, _speed, done) if done then done() end end @@ -719,27 +714,36 @@ function Adapters.host(mod, game, world) end end, openPc = function(done, pcOpts) - local function finish() + local function finish(result) local okMsg, Message = pcall(require, "src.ui.game3.message") if okMsg and Message and Message.close then Message.close() end - if done then done() end + if done then done(result) end tick_vm() end local Runtime = package.loaded["src.core.game3.runtime"] if Runtime and Runtime.isActive and Runtime.isActive() then - if a.closeMessage then a.closeMessage() end local okMsg, Message = pcall(require, "src.ui.game3.message") + local prompt = okMsg and Message and Message.isOpen and Message.isOpen() + and Message.currentPage() or nil + if a.closeMessage then a.closeMessage() end if okMsg and Message and Message.close then Message.close() end local okHud, Hud = pcall(require, "src.ui.game3.hud") if okHud and Hud and Hud.clearWaitButton then Hud.clearWaitButton() end a.log("[game3] openPc via game3 PcMenu") local PcMenu = require("src.ui.game3.pc_menu") local bedroom = type(pcOpts) == "table" and pcOpts.bedroom == true + local mode = type(pcOpts) == "table" and pcOpts.mode or nil + local startMode = bedroom and "player_pc" or nil + if mode == "select" then startMode = "select" end + if mode == "storage" then startMode = "storage" end + if mode == "player" then startMode = "player_pc" end PcMenu.show({ session = Runtime.getSession(), onClose = finish, - startMode = bedroom and "player_pc" or nil, - closeOnExit = bedroom, + startMode = startMode, + closeOnExit = bedroom or mode == "storage" or mode == "player", + silentClose = mode ~= nil, + prompt = prompt, }) return end @@ -1266,6 +1270,8 @@ function Adapters.host(mod, game, world) local Map = require("src.core.game3.map") local Runtime = package.loaded["src.core.game3.runtime"] local mod = Runtime and Runtime._mod + -- src/overworld.c:2144 + require("src.core.game3.player").setVisible(true) Map.load(mod, resolveGame(), mapId, { x = cx, y = cy, @@ -1439,6 +1445,20 @@ function Adapters.host(mod, game, world) end, }) end, + -- pokefirered/src/hof_pc.c:23 + hallOfFamePc = function(done) + local Runtime = package.loaded["src.core.game3.runtime"] + local session = (Runtime and Runtime.getSession and Runtime.getSession()) or (resolveGame() and resolveGame().session) + local okMsg, Message = pcall(require, "src.ui.game3.message") + if okMsg and Message and Message.close then Message.close() end + require("src.ui.game3.hall_of_fame_pc").show({ + session = session, + onDone = function() + if done then done() end + tick_vm() + end, + }) + end, startTrainerBattle = function(foe, done, battleOpts) local BattleBridge = require("src.core.game3.battle_bridge") battleOpts = battleOpts or {} diff --git a/src/core/game3/scripting/collision.lua b/src/core/game3/scripting/collision.lua index 2dbf00e0..0f4cf6e3 100644 --- a/src/core/game3/scripting/collision.lua +++ b/src/core/game3/scripting/collision.lua @@ -90,7 +90,11 @@ local NUM_PRIMARY = 640 function Collision.classify(mid, mapColl, behavior, kind) kind = kind or "route" local beh = behavior or 0 - if WATER_BEH[beh] then return "WATER", nil end + if WATER_BEH[beh] then + -- pokefirered/src/event_object_movement.c:4835 + if (mapColl or 0) ~= 0 then return "BLOCKED", nil end + return "WATER", nil + end if WALK_ON_WATER_BEH[beh] then if (mapColl or 0) ~= 0 then return "BLOCKED", nil end return "PATH", nil diff --git a/src/core/game3/scripting/flags.lua b/src/core/game3/scripting/flags.lua index 0d5dba85..bca99b51 100644 --- a/src/core/game3/scripting/flags.lua +++ b/src/core/game3/scripting/flags.lua @@ -67,13 +67,7 @@ function Flags.setTrainerDefeated(store, session, trainerId, on) end -- pret EventScript_ResetAllMapFlags (derived from event_scripts.s). -Flags.NEW_GAME_HIDE_FLAGS = FlagsTable.NEW_GAME_HIDE_FLAGS or { - 43, 44, 51, 146, 53, 54, 55, 60, 61, 161, 63, - 66, 67, 68, 69, 70, 71, 76, 77, - 79, 90, 163, 164, 98, 107, 162, - 140, 108, 109, 110, 111, 117, 116, 123, 124, 125, 118, 145, 151, 144, 148, - 149, 150, 153, 165, 166, 167, 168, 169, 170, 171, 172, 157, 158, 159, 160, 174, -} +Flags.NEW_GAME_HIDE_FLAGS = FlagsTable.NEW_GAME_HIDE_FLAGS Flags.NEW_GAME_RESET_VARS = FlagsTable.NEW_GAME_RESET_VARS or {} @@ -192,60 +186,10 @@ function Flags.ensurePalletOakHidden(store) end end ---- Fix Seafoam Islands boulder and current flags for saves created before seeding. -function Flags.repairSeafoamFlags(store) - if not store then return end - - -- B4F boulders and current - local b3f_boulder3_dropped = Flags.getFlag(store, nil, 0x48) -- FLAG_HIDE_SEAFOAM_B3F_BOULDER_3 (B3F obj 6 pushed) - local b3f_boulder5_dropped = Flags.getFlag(store, nil, 0x4A) -- FLAG_HIDE_SEAFOAM_B3F_BOULDER_5 (B3F obj 3 pushed) - - if not b3f_boulder3_dropped and not b3f_boulder5_dropped then - -- Neither boulder was pushed into hole from B3F - Flags.setFlag(store, nil, 0x4C, true) -- FLAG_HIDE_SEAFOAM_B4F_BOULDER_1 - Flags.setFlag(store, nil, 0x4D, true) -- FLAG_HIDE_SEAFOAM_B4F_BOULDER_2 - Flags.setFlag(store, nil, 0x2D3, false) -- FLAG_STOPPED_SEAFOAM_B4F_CURRENT - elseif b3f_boulder3_dropped and not b3f_boulder5_dropped then - Flags.setFlag(store, nil, 0x4C, false) - Flags.setFlag(store, nil, 0x4D, true) - Flags.setFlag(store, nil, 0x2D3, false) - elseif not b3f_boulder3_dropped and b3f_boulder5_dropped then - Flags.setFlag(store, nil, 0x4C, true) - Flags.setFlag(store, nil, 0x4D, false) - Flags.setFlag(store, nil, 0x2D3, false) - else - -- Both dropped - Flags.setFlag(store, nil, 0x4C, false) - Flags.setFlag(store, nil, 0x4D, false) - Flags.setFlag(store, nil, 0x2D3, true) - end - - -- B1F..B3F boulders and current - local f1_boulder1_dropped = Flags.getFlag(store, nil, 0x40) -- FLAG_HIDE_SEAFOAM_1F_BOULDER_1 - local f1_boulder2_dropped = Flags.getFlag(store, nil, 0x41) -- FLAG_HIDE_SEAFOAM_1F_BOULDER_2 - - if not f1_boulder1_dropped then - Flags.setFlag(store, nil, 0x42, true) -- B1F boulder 1 - Flags.setFlag(store, nil, 0x44, true) -- B2F boulder 1 - Flags.setFlag(store, nil, 0x46, true) -- B3F boulder 1 - end - - if not f1_boulder2_dropped then - Flags.setFlag(store, nil, 0x43, true) -- B1F boulder 2 - Flags.setFlag(store, nil, 0x45, true) -- B2F boulder 2 - Flags.setFlag(store, nil, 0x47, true) -- B3F boulder 2 - end - - if not f1_boulder1_dropped and not f1_boulder2_dropped then - Flags.setFlag(store, nil, 0x2D2, false) -- FLAG_STOPPED_SEAFOAM_B3F_CURRENT - end -end - --- Repair/normalize legacy saves that missed initial hide flags. function Flags.repairSaveState(store) if not store then return end Flags.ensurePalletOakHidden(store) - Flags.repairSeafoamFlags(store) -- Bill human in sea cottage: if helped flag (0x233) is false, human is hidden (0x033) if not Flags.getFlag(store, nil, 0x233) then diff --git a/src/core/game3/scripting/flags_table.lua b/src/core/game3/scripting/flags_table.lua index 28ede38d..3219a161 100644 --- a/src/core/game3/scripting/flags_table.lua +++ b/src/core/game3/scripting/flags_table.lua @@ -3531,14 +3531,6 @@ FlagsTable.NEW_GAME_HIDE_FLAGS = { 0x03D, -- FLAG_HIDE_SS_ANNE_RIVAL (61) 0x0A1, -- FLAG_HIDE_VERMILION_CITY_OAKS_AIDE (161) 0x03F, -- FLAG_HIDE_SAFFRON_CIVILIANS (63) - 0x042, -- FLAG_HIDE_SEAFOAM_B1F_BOULDER_1 (66) - 0x043, -- FLAG_HIDE_SEAFOAM_B1F_BOULDER_2 (67) - 0x044, -- FLAG_HIDE_SEAFOAM_B2F_BOULDER_1 (68) - 0x045, -- FLAG_HIDE_SEAFOAM_B2F_BOULDER_2 (69) - 0x046, -- FLAG_HIDE_SEAFOAM_B3F_BOULDER_1 (70) - 0x047, -- FLAG_HIDE_SEAFOAM_B3F_BOULDER_2 (71) - 0x04C, -- FLAG_HIDE_SEAFOAM_B4F_BOULDER_1 (76) - 0x04D, -- FLAG_HIDE_SEAFOAM_B4F_BOULDER_2 (77) 0x04F, -- FLAG_HIDE_ROUTE_22_RIVAL (79) 0x05A, -- FLAG_HIDE_OAK_IN_CHAMP_ROOM (90) 0x0A3, -- FLAG_HIDE_CREDITS_RIVAL (163) diff --git a/src/core/game3/scripting/gfx_ids.lua b/src/core/game3/scripting/gfx_ids.lua index 320b9e4e..556b9db6 100644 --- a/src/core/game3/scripting/gfx_ids.lua +++ b/src/core/game3/scripting/gfx_ids.lua @@ -46,46 +46,106 @@ GfxIds.TO_SPRITE = { [92] = "SPRITE_POKE_BALL", } --- FRLG MOVEMENT_TYPE_* → host movement/range/radius (Gen1 strings or LOOK). -GfxIds.MOVEMENT = { - [0x01] = { movement = "LOOK", range = "DOWN" }, -- LOOK_AROUND - [0x02] = { movement = "WALK", range = "ANY_DIR" }, -- WANDER_AROUND - [0x03] = { movement = "WALK", range = "UP_DOWN" }, - [0x04] = { movement = "WALK", range = "UP_DOWN" }, - [0x05] = { movement = "WALK", range = "LEFT_RIGHT" }, - [0x06] = { movement = "WALK", range = "LEFT_RIGHT" }, - [0x07] = { movement = "STAY", range = "UP" }, - [0x08] = { movement = "STAY", range = "DOWN" }, - [0x09] = { movement = "STAY", range = "LEFT" }, - [0x0A] = { movement = "STAY", range = "RIGHT" }, - [0x0D] = { movement = "LOOK", range = "DOWN" }, -- FACE_DOWN_AND_UP - [0x0E] = { movement = "LOOK", range = "LEFT" }, -- FACE_LEFT_AND_RIGHT - [0x17] = { movement = "LOOK", range = "DOWN" }, -- ROTATE_CCW - [0x18] = { movement = "LOOK", range = "DOWN" }, -- ROTATE_CW - [0x19] = { movement = "WALK", range = "UP_DOWN" }, - [0x1A] = { movement = "WALK", range = "UP_DOWN" }, - [0x1B] = { movement = "WALK", range = "LEFT_RIGHT" }, - [0x1C] = { movement = "WALK", range = "LEFT_RIGHT" }, - [0x4D] = { movement = "RAISE_HAND", range = "DOWN" }, - [0x4E] = { movement = "RAISE_HAND", range = "DOWN" }, - [0x4F] = { movement = "RAISE_HAND", range = "DOWN" }, +local S, N, W, E = "down", "up", "left", "right" + +-- src/event_object_movement.c:679 +GfxIds.DELAYS = { + MEDIUM = { 32, 64, 96, 128 }, + LONG = { 32, 64, 128, 192 }, + SHORT = { 32, 48, 64, 80 }, } +-- include/constants/event_object_movement.h:5 +-- src/event_object_movement.c:359 +-- src/data/object_events/movement_type_func_tables.h:183 +GfxIds.MOVEMENT = { + [0x01] = { movement = "LOOK", dirs = { S, N, W, E }, delays = "MEDIUM", follow = 0, face = S }, + [0x02] = { movement = "WALK", range = "ANY_DIR", dirs = { S, N, W, E }, face = S }, + [0x03] = { movement = "WALK", range = "UP_DOWN", dirs = { S, N }, face = N }, + [0x04] = { movement = "WALK", range = "UP_DOWN", dirs = { S, N }, face = S }, + [0x05] = { movement = "WALK", range = "LEFT_RIGHT", dirs = { W, E }, face = W }, + [0x06] = { movement = "WALK", range = "LEFT_RIGHT", dirs = { W, E }, face = E }, + [0x07] = { movement = "STAY", face = N }, + [0x08] = { movement = "STAY", face = S }, + [0x09] = { movement = "STAY", face = W }, + [0x0A] = { movement = "STAY", face = E }, + [0x0D] = { movement = "LOOK", dirs = { S, N }, delays = "MEDIUM", follow = 1, face = S }, + [0x0E] = { movement = "LOOK", dirs = { W, E }, delays = "MEDIUM", follow = 2, face = W }, + [0x0F] = { movement = "LOOK", dirs = { N, W }, delays = "SHORT", follow = 3, face = N }, + [0x10] = { movement = "LOOK", dirs = { N, E }, delays = "SHORT", follow = 4, face = N }, + [0x11] = { movement = "LOOK", dirs = { S, W }, delays = "SHORT", follow = 5, face = S }, + [0x12] = { movement = "LOOK", dirs = { S, E }, delays = "SHORT", follow = 6, face = S }, + [0x13] = { movement = "LOOK", dirs = { N, S, W, S }, delays = "SHORT", follow = 7, face = S }, + [0x14] = { movement = "LOOK", dirs = { S, N, E, S }, delays = "SHORT", follow = 8, face = S }, + [0x15] = { movement = "LOOK", dirs = { N, W, E, N }, delays = "SHORT", follow = 9, face = N }, + [0x16] = { movement = "LOOK", dirs = { W, E, S, S }, delays = "SHORT", follow = 10, face = S }, + [0x17] = { movement = "ROTATE", next = { [S] = E, [E] = N, [N] = W, [W] = S }, follow = 0, face = S }, + [0x18] = { movement = "ROTATE", next = { [S] = W, [W] = N, [N] = E, [E] = S }, follow = 0, face = S }, + [0x19] = { movement = "BACK_FORTH", face = N }, + [0x1A] = { movement = "BACK_FORTH", face = S }, + [0x1B] = { movement = "BACK_FORTH", face = W }, + [0x1C] = { movement = "BACK_FORTH", face = E }, + [0x4D] = { movement = "RAISE_HAND", face = S }, + [0x4E] = { movement = "RAISE_HAND", face = S }, + [0x4F] = { movement = "RAISE_HAND", face = S }, + [0x50] = { movement = "WALK", range = "ANY_DIR", dirs = { S, N, W, E }, slow = true, face = S }, +} + +-- src/event_object_movement.c:3949 +local SEQUENCES = { + [0x1D] = { { N, E, W, S }, 2, "x" }, + [0x1E] = { { E, W, S, N }, 1, "x" }, + [0x1F] = { { S, N, E, W }, 1, "y" }, + [0x20] = { { W, S, N, E }, 2, "y" }, + [0x21] = { { N, W, E, S }, 2, "x" }, + [0x22] = { { W, E, S, N }, 1, "x" }, + [0x23] = { { S, N, W, E }, 1, "y" }, + [0x24] = { { E, S, N, W }, 2, "y" }, + [0x25] = { { W, N, S, E }, 2, "y" }, + [0x26] = { { N, S, E, W }, 1, "y" }, + [0x27] = { { E, W, N, S }, 1, "x" }, + [0x28] = { { S, E, W, N }, 2, "x" }, + [0x29] = { { E, N, S, W }, 2, "y" }, + [0x2A] = { { N, S, W, E }, 1, "y" }, + [0x2B] = { { W, E, N, S }, 1, "x" }, + [0x2C] = { { S, W, E, N }, 2, "x" }, + [0x2D] = { { N, W, S, E }, 2, "y" }, + [0x2E] = { { S, E, N, W }, 2, "y" }, + [0x2F] = { { W, S, E, N }, 2, "x" }, + [0x30] = { { E, N, W, S }, 2, "x" }, + [0x31] = { { N, E, S, W }, 2, "y" }, + [0x32] = { { S, W, N, E }, 2, "y" }, + [0x33] = { { W, N, E, S }, 2, "x" }, + [0x34] = { { E, S, W, N }, 2, "x" }, +} +for mt, s in pairs(SEQUENCES) do + GfxIds.MOVEMENT[mt] = { + movement = "SEQUENCE", route = s[1], skipFrom = s[2], skipAxis = s[3], face = s[1][1], + } +end + function GfxIds.spriteFor(graphicsId) return GfxIds.TO_SPRITE[tonumber(graphicsId) or -1] or "SPRITE_YOUNGSTER" end +function GfxIds.initialFacing(movementType) + local spec = GfxIds.MOVEMENT[tonumber(movementType) or 0] + return spec and spec.face or S +end + function GfxIds.hostMovement(movementType, rangeX, rangeY) local mt = tonumber(movementType) or 0 - local spec = GfxIds.MOVEMENT[mt] or { movement = "STAY", range = "DOWN" } - local out = { - movement = spec.movement, - range = spec.range, - } - if out.movement == "WALK" then - local rx = math.max(1, tonumber(rangeX) or 1) - local ry = math.max(1, tonumber(rangeY) or 1) - out.radius = { x = rx, y = ry } + local spec = GfxIds.MOVEMENT[mt] or { movement = "STAY", face = S } + local out = {} + for k, v in pairs(spec) do out[k] = v end + out.range = spec.range or spec.face:upper() + out.rangeX = tonumber(rangeX) or 0 + out.rangeY = tonumber(rangeY) or 0 + if out.movement == "WALK" or out.movement == "BACK_FORTH" or out.movement == "SEQUENCE" then + -- src/event_object_movement.c:1381 + out.rangeX = math.max(1, out.rangeX) + out.rangeY = math.max(1, out.rangeY) + out.radius = { x = out.rangeX, y = out.rangeY } end return out end diff --git a/src/core/game3/scripting/natives.lua b/src/core/game3/scripting/natives.lua index 202329a0..dd548c64 100644 --- a/src/core/game3/scripting/natives.lua +++ b/src/core/game3/scripting/natives.lua @@ -497,13 +497,19 @@ Natives.ALLOW = { if not (adapters and adapters.nurseHeal) then return false end return yield_host(ctx, adapters, adapters.nurseHeal) end, + -- pokefirered/src/pokemon_storage_system_menu.c:354 ["special:" .. Std.SPECIAL.ShowPokemonStorageSystemPC] = function(ctx, adapters) if not (adapters and adapters.openPc) then return false end - return yield_host(ctx, adapters, adapters.openPc) + return yield_host(ctx, adapters, function(done) + adapters.openPc(function() done() end, { mode = "storage" }) + end) end, + -- pokefirered/src/player_pc.c:163 ["special:" .. Std.SPECIAL.PlayerPC] = function(ctx, adapters) if not (adapters and adapters.openPc) then return false end - return yield_host(ctx, adapters, adapters.openPc) + return yield_host(ctx, adapters, function(done) + adapters.openPc(function() done() end, { mode = "player" }) + end) end, -- pokefirered/src/player_pc.c:151 ["special:" .. Std.SPECIAL.BedroomPC] = function(ctx, adapters) @@ -528,10 +534,28 @@ Natives.ALLOW = { require("src.core.game3.pc_anim").turnOff(ctx) return false end, + -- pokefirered/src/script_menu.c:977 ["special:" .. Std.SPECIAL.CreatePCMenu] = function(ctx, adapters) - -- Cart builds a menu; host PC UI is the whole menu — open it directly. if not (adapters and adapters.openPc) then return false end - return yield_host(ctx, adapters, adapters.openPc) + return yield_host(ctx, adapters, function(done) + adapters.openPc(function(result) + setResult(ctx, tonumber(result) or 127) + done() + end, { mode = "select" }) + end) + end, + -- pokefirered/src/hof_pc.c:23 + ["special:" .. Std.SPECIAL.HallOfFamePCBeginFade] = function(ctx, adapters) + if not (adapters and adapters.hallOfFamePc) then return false end + return yield_host(ctx, adapters, function(done) + adapters.hallOfFamePc(function() + -- pokefirered/src/hof_pc.c:40 + adapters.openPc(function(result) + setResult(ctx, tonumber(result) or 127) + done() + end, { mode = "select", reshow = true }) + end) + end) end, ["special:" .. Std.SPECIAL.FieldShowRegionMap] = function(ctx, adapters) if not (adapters and adapters.showTownMap) then return false end diff --git a/src/core/game3/scripting/natives_cutscene.lua b/src/core/game3/scripting/natives_cutscene.lua index 4712c722..8fb47995 100644 --- a/src/core/game3/scripting/natives_cutscene.lua +++ b/src/core/game3/scripting/natives_cutscene.lua @@ -140,17 +140,32 @@ Cutscene.HANDLERS = { end, -- pokefirered/src/credits.c:711, data/maps/IndigoPlateau_Exterior/scripts.inc:80 - [Std.SPECIAL.DoCredits] = function() + [Std.SPECIAL.DoCredits] = function(ctx) + local ok, Credits = pcall(require, "src.ui.game3.credits") + if not (ok and ctx) then return false end + local okStart, started = pcall(Credits.start) + if okStart and started then + require("src.core.game3.scripting.natives").awaitState(ctx, function() return false end) + end return false end, - -- pokefirered/src/field_specials.c:90, diploma.c:100, data/maps/CeladonCity_Condominiums_3F/scripts.inc:34, engine/events/diploma.asm - [Std.SPECIAL.ShowDiploma] = function() + -- pokefirered/src/field_specials.c:90, src/diploma.c:100, data/maps/CeladonCity_Condominiums_3F/scripts.inc:34 + [Std.SPECIAL.ShowDiploma] = function(ctx) + local ok, Diploma = pcall(require, "src.ui.game3.diploma") + if not (ok and ctx) then return false end + local done = false + local okShow, shown = pcall(Diploma.show, { onDone = function() done = true end }) + if okShow and shown then + require("src.core.game3.scripting.natives").awaitState(ctx, function() return done end) + end return false end, -- pokefirered/src/field_specials.c:2133, data/scripts/pokemon_league.inc:63 [Std.SPECIAL.DoPokemonLeagueLightingEffect] = function() + local Space = package.loaded["src.core.game3.scripting.space"] + pcall(require("src.core.game3.league_lighting").start, Space and Space.mapId) return false end, diff --git a/src/core/game3/scripting/natives_daycare.lua b/src/core/game3/scripting/natives_daycare.lua index 853cc1ff..e1c47cd1 100644 --- a/src/core/game3/scripting/natives_daycare.lua +++ b/src/core/game3/scripting/natives_daycare.lua @@ -96,16 +96,17 @@ Daycare.compatibility = Breeding.compatibility -- pokefirered/src/strings.c:1252 sCompatibilityMessages function Daycare.compatibilityText(score) + local RomText = require("src.core.game3.rom_text") if score == PARENTS_INCOMPATIBLE then - return Strings("The two prefer to play with other\nPOKéMON than each other.") + return RomText.plain("gDaycareText_PlayOther") end if score == PARENTS_LOW_COMPATIBILITY then - return Strings("The two don't seem to like\neach other much.") + return RomText.plain("gDaycareText_DontLikeOther") end if score == PARENTS_MED_COMPATIBILITY then - return Strings("The two seem to get along.") + return RomText.plain("gDaycareText_GetAlong") end - return Strings("The two seem to get along\nvery well.") + return RomText.plain("gDaycareText_GetAlongVeryWell") end local function levelMenu() diff --git a/src/core/game3/scripting/natives_events.lua b/src/core/game3/scripting/natives_events.lua index 1f8d4099..1f1a71ea 100644 --- a/src/core/game3/scripting/natives_events.lua +++ b/src/core/game3/scripting/natives_events.lua @@ -17,6 +17,7 @@ local ICEFALL_CAVE_ICE_COORDS = { -- pokefirered/include/constants/metatile_labels.h:188 local METATILE_SEAFOAM_CRACKED_ICE = 0x35A +local METATILE_SEAFOAM_ICE_HOLE = 0x35B -- pokefirered/include/constants/songs.h:290 local MUS_CYCLING = 282 @@ -71,6 +72,7 @@ end Events.ICEFALL_CAVE_ICE_COORDS = ICEFALL_CAVE_ICE_COORDS Events.METATILE_SEAFOAM_CRACKED_ICE = METATILE_SEAFOAM_CRACKED_ICE +Events.METATILE_SEAFOAM_ICE_HOLE = METATILE_SEAFOAM_ICE_HOLE Events.HANDLERS = { -- pokefirered/src/field_camera.c:93 @@ -122,6 +124,7 @@ Events.HANDLERS = { Player.biking = true Player.surfHopping = false end + require("src.core.game3.audio").bikeMusic(true, true) return false end, -- pokefirered/src/field_specials.c:1513 @@ -406,12 +409,10 @@ Events.HANDLERS = { -- pokefirered/src/script.c:245 [Std.SPECIAL.DisableMsgBoxWalkaway] = function(ctx) if ctx then - ctx.msgBoxIsCancelable = false ctx.canWalkAway = false end local session = sessionOf(ctx) if session then - session.msgBoxIsCancelable = false session.canWalkAway = false end return false @@ -465,7 +466,7 @@ Events.HANDLERS[Std.SPECIAL.SetPostgameFlagsUnusedSlot] = -- pokefirered/include/constants/global.h local DIR_BY_NAME = { down = 1, up = 2, left = 3, right = 4 } -local WALKAWAY_ORDER = { "down", "up", "left", "right" } +local WALKAWAY_ORDER = { "up", "down", "left", "right" } -- pokefirered/src/field_control_avatar.c:301, overworld.c:1402, data/event_scripts.s:1166 function Events.pollWalkaway(vm, input) @@ -510,33 +511,30 @@ function Events.pollWalkaway(vm, input) if cancelable == nil then cancelable = session.msgBoxIsCancelable end if canWalk == nil then canWalk = session.canWalkAway end end - if cancelable ~= true or canWalk ~= true then return end + if cancelable ~= true then return end if ctx.messageOpen ~= true then return end local dir - if input then + if input and input.isDown then + -- pokefirered/src/field_control_avatar.c:147 for _, d in ipairs(WALKAWAY_ORDER) do - if input.wasPressed and input:wasPressed(d) then + if input:isDown(d) then dir = d break end end - if not dir then - for _, d in ipairs(WALKAWAY_ORDER) do - if input.isDown and input:isDown(d) then - dir = d - break - end - end - end end - if not dir then return end local facing = ctx.specialVars and tonumber(ctx.specialVars[VAR_FACING]) if not facing then local P = package.loaded["src.core.game3.player"] facing = P and DIR_BY_NAME[P.facing] or nil end - if not facing or facing == DIR_BY_NAME[dir] then return end + local walked = dir and facing and facing ~= DIR_BY_NAME[dir] + -- pokefirered/src/field_control_avatar.c:311 + if walked and canWalk ~= true then return end + -- pokefirered/src/field_control_avatar.c:324 + local started = not walked and input and input.wasPressed and input:wasPressed("start") + if not walked and not started then return end -- data/event_scripts.s:1166 if vm.adapters and vm.adapters.closeMessage then @@ -549,6 +547,19 @@ function Events.pollWalkaway(vm, input) end clearWalkaway() vm:halt(true) + if started then + -- pokefirered/src/field_control_avatar.c:329 + local Runtime = package.loaded["src.core.game3.runtime"] + if Runtime and Runtime.defer then + Runtime.defer(function() + local Hud = package.loaded["src.ui.game3.hud"] + local Field = package.loaded["src.core.game3.field"] + if Hud and Hud.openStartMenu and Field then + Hud.openStartMenu(Field._game, Field._session) + end + end) + end + end end return Events diff --git a/src/core/game3/scripting/natives_queries.lua b/src/core/game3/scripting/natives_queries.lua index e1ec57a5..f3a65712 100644 --- a/src/core/game3/scripting/natives_queries.lua +++ b/src/core/game3/scripting/natives_queries.lua @@ -423,6 +423,14 @@ Queries.HANDLERS = { [Std.SPECIAL.GetPCBoxToSendMon] = function() return false, Queries.pcBoxToSendMon or 0 end, + -- pokefirered/src/field_specials.c:2056 + [Std.SPECIAL.BufferTMHMMoveName] = function(ctx, adapters) + local Pokemon = require("src.core.game3.pokemon") + local move = Pokemon.moveFromTmItem(varGet(ctx, 0x8004)) + if not move then return boolReturn(false) end + setStringVar(ctx, adapters, 1, Pokemon.moveName(move)) + return boolReturn(true) + end, } -- pokefirered/src/field_specials.c:1975 diff --git a/src/core/game3/scripting/natives_trade.lua b/src/core/game3/scripting/natives_trade.lua index 03809263..b2fc7003 100644 --- a/src/core/game3/scripting/natives_trade.lua +++ b/src/core/game3/scripting/natives_trade.lua @@ -262,17 +262,15 @@ end -- pokefirered/src/trade.c:546 sMessages function Trade.refusalText(code) + local RomText = require("src.core.game3.rom_text") if code == Trade.CANT_TRADE_LAST_MON then - -- pokefirered/src/strings.c:301 gText_OnlyPkmnForBattle - return Strings("That's your only\nPOKéMON for battle.") + return RomText.plain("gText_OnlyPkmnForBattle") end if code == Trade.CANT_TRADE_EGG_YET or code == Trade.CANT_TRADE_PARTNER_EGG_YET then - -- pokefirered/src/strings.c:303 gText_EggCantBeTradedNow - return Strings("An EGG can't be traded now.") + return RomText.plain("gText_EggCantBeTradedNow") end if code == Trade.CANT_TRADE_NATIONAL or code == Trade.CANT_TRADE_INVALID_MON then - -- pokefirered/src/strings.c:302 gText_PkmnCantBeTradedNow - return Strings("That POKéMON can't be traded\nnow.") + return RomText.plain("gText_PkmnCantBeTradedNow") end return nil end @@ -282,9 +280,8 @@ function Trade.badEggText() return Strings("You have at least one POKéMON\nthat can't be taken.") end --- pokefirered/src/strings.c:304 gText_OtherTrainersPkmnCantBeTraded function Trade.peerMonRefusalText() - return Strings("The other TRAINER's POKéMON\ncan't be traded now.") + return require("src.core.game3.rom_text").plain("gText_OtherTrainersPkmnCantBeTraded") end -- pokefirered/src/trade_scene.c:2500 GetInGameTradeMail @@ -383,8 +380,7 @@ function Trade.tradeMons(session, playerSlot, offered) local species = speciesOf(offered) if species ~= SPECIES_NONE then session.dex.seen[species] = true - session.dex.owned[species] = true - session.dex.caught[species] = true + require("src.core.game3.dex").handleSetPokedexFlag(session.dex, species, true, offered.personality) end return sent end diff --git a/src/core/game3/scripting/natives_wireless.lua b/src/core/game3/scripting/natives_wireless.lua index aa5a2fd7..ab16abbb 100644 --- a/src/core/game3/scripting/natives_wireless.lua +++ b/src/core/game3/scripting/natives_wireless.lua @@ -49,6 +49,21 @@ local function setStringVar(ctx, adapters, index, text) if ctx and ctx.stringVars then ctx.stringVars[index] = text end end +-- pokefirered/src/quest_log.c:860 +local function qlAvoidDisplay() + local Runtime = package.loaded["src.core.game3.runtime"] + local game = Runtime and Runtime._game + return (game and game.phase == "quest_log") and true or false +end + +local function showRecords(ctx, kind) + local Natives = require("src.core.game3.scripting.natives") + local done = false + Natives.awaitState(ctx, function() return done end) + require("src.ui.game3.minigame_records").show(kind, sessionOf(ctx), function() done = true end) + return false +end + -- pokefirered/src/battle_tower.c:1354 local function visitingEReaderTrainer(session) local trainer = session and session.ereaderTrainer @@ -83,38 +98,36 @@ Wireless.HANDLERS = { end, -- pokefirered/src/pokemon_jump.c:4487, data/scripts/cable_club.inc:1278 - [Std.SPECIAL.ShowPokemonJumpRecords] = function() - return false + [Std.SPECIAL.ShowPokemonJumpRecords] = function(ctx) + return showRecords(ctx, "pokemon_jump") end, -- pokefirered/src/dodrio_berry_picking.c:2929, data/scripts/cable_club.inc:1286 - [Std.SPECIAL.ShowDodrioBerryPickingRecords] = function() - return false + [Std.SPECIAL.ShowDodrioBerryPickingRecords] = function(ctx) + return showRecords(ctx, "dodrio") end, -- pokefirered/src/berry_crush.c:3189, data/maps/CeruleanCity_House5/scripts.inc:169 - [Std.SPECIAL.ShowBerryCrushRankings] = function() - return false + [Std.SPECIAL.ShowBerryCrushRankings] = function(ctx) + return showRecords(ctx, "berry_crush") end, -- pokefirered/src/berry_powder.c:113 - [Std.SPECIAL.DisplayBerryPowderVendorMenu] = function(ctx) - if ctx then ctx.berryPowderVendorOpen = true end - local session = sessionOf(ctx) - if session then session.berryPowderVendorOpen = true end + [Std.SPECIAL.DisplayBerryPowderVendorMenu] = function() + if qlAvoidDisplay() then return false end + require("src.ui.game3.berry_powder_box").show() return false end, -- pokefirered/src/berry_powder.c:128 - [Std.SPECIAL.RemoveBerryPowderVendorMenu] = function(ctx) - if ctx then ctx.berryPowderVendorOpen = false end - local session = sessionOf(ctx) - if session then session.berryPowderVendorOpen = false end + [Std.SPECIAL.RemoveBerryPowderVendorMenu] = function() + require("src.ui.game3.berry_powder_box").hide() return false end, -- pokefirered/src/berry_powder.c:108 [Std.SPECIAL.PrintPlayerBerryPowderAmount] = function() + require("src.ui.game3.berry_powder_box").update() return false end, diff --git a/src/core/game3/scripting/ops_a.lua b/src/core/game3/scripting/ops_a.lua index 7e409448..21faaf7f 100644 --- a/src/core/game3/scripting/ops_a.lua +++ b/src/core/game3/scripting/ops_a.lua @@ -1063,7 +1063,8 @@ local function dispatch(vm, row) return false elseif op == "hideobjectat" or op == "showobjectat" then local lid = var_get(store, ctx, row.localId or row[1]) - if not objectat_same_map(store, ctx, row, 2) then + -- src/event_object_movement.c:1258 + if (tonumber(lid) or 0) < 0xFF and not objectat_same_map(store, ctx, row, 2) then if a.log then a.log("[game3] " .. op .. " targets another map — skipped") end @@ -1071,8 +1072,6 @@ local function dispatch(vm, row) a.hideObject(lid) elseif op == "showobjectat" and a.showObject then a.showObject(lid) - elseif op == "hideobjectat" and a.removeObject then - a.removeObject(lid) end return false elseif op == "applymovementat" or op == "waitmovementat" @@ -1202,7 +1201,7 @@ local function dispatch(vm, row) local Warp = require("src.core.game3.warp") local Runtime = package.loaded["src.core.game3.runtime"] local started = Warp.startFall(Runtime and Runtime._mod, Runtime and Runtime._game, - destMap, destX, destY) + destMap, destX, destY, nil, nil, { prologue = false }) if not started then return false end ctx.warpPending = true local Task = require("src.core.game3.task") @@ -1499,7 +1498,9 @@ local function dispatch(vm, row) } ctx.trainerBattleBeatenScript = eventScript - if op == "trainerbattle" and not earlyRival and not isRematch and Flags.getFlag(store, ctx, trainerFlag) then + -- data/scripts/trainer_battle.inc:44 + if op == "trainerbattle" and not earlyRival and not isRematch and battleType ~= 3 + and Flags.getFlag(store, ctx, trainerFlag) then -- Already defeated → fall through (gotopostbattlescript). return false end @@ -1656,13 +1657,16 @@ local function dispatch(vm, row) end end - if introText and introText ~= "" and a.openMessageAsync then - -- Play trainer encounter music if not already playing (pret PlayTrainerEncounterMusic / EventScript_TryDoNormalTrainerBattle) + local hasIntro = introText and introText ~= "" and a.openMessageAsync + -- pokefirered/src/battle_setup.c:1007 + if (hasIntro or battleType == 3 or battleType == 9) and battleType ~= 1 and battleType ~= 8 then local song = Trainers.getEncounterMusic and Trainers.getEncounterMusic(opponentA) local okA, Audio = pcall(require, "src.core.game3.audio") if okA and Audio and Audio.playSong and song then Audio.playSong(song) end + end + if hasIntro then a.openMessageAsync(introText, function() beginBattle() end) @@ -2157,6 +2161,8 @@ function Ops.dispatch(vm, row) return ModRuntime.call("script.command", commandVanilla(vm), Ctx.modCtx(vm), row.op, row) end +Ops.dispatchUnhooked = dispatch + Ops.warpHoleDest = warp_hole_dest Ops.setMapLayout = set_map_layout Ops.brailleWidth = braille_width diff --git a/src/core/game3/scripting/space.lua b/src/core/game3/scripting/space.lua index 5232b6f3..cb4d3f03 100644 --- a/src/core/game3/scripting/space.lua +++ b/src/core/game3/scripting/space.lua @@ -476,10 +476,10 @@ end --- Resolve FRLG OBJ_EVENT_GFX id (honours graphicsVar + OBJ_EVENT_GFX_VAR_*). -- pret: graphicsId >= 240 → VarGetObjectEventGraphicsId(id - 240). -function Space.resolveObjectGraphicsId(obj) +function Space.resolveObjectGraphicsId(obj, neighbor) if not obj then return nil end local graphics = tonumber(obj.graphics or obj.graphicsId) - local store = Space.store or Flags.newStore() + local store = (neighbor and neighbor.store) or Space.store or Flags.newStore() local ctx = (Space.vm and Space.vm.ctx) or Ctx.new() if obj.graphicsVar then local v = Flags.getVar(store, ctx, obj.graphicsVar) @@ -487,12 +487,77 @@ function Space.resolveObjectGraphicsId(obj) end if graphics and graphics >= 240 and graphics <= 255 then local varId = Ctx.GFX_VAR_LO + (graphics - 240) - local v = Flags.getVar(store, ctx, varId) - if type(v) == "number" and v > 0 and v < 240 then - graphics = v + if neighbor and store.vars[varId] == nil then return nil end + -- src/event_object_movement.c:2043 + graphics = (tonumber(Flags.getVar(store, ctx, varId)) or 0) % 256 + end + -- src/event_object_movement.c:2045 + if graphics and graphics >= 152 then graphics = 16 end + return graphics +end + +local NEIGHBOR_FLOW_OPS = { + ["end"] = true, ["return"] = true, call = true, ["goto"] = true, + call_if = true, goto_if = true, compare_var_to_value = true, + compare_var_to_var = true, checkflag = true, setvar = true, + addvar = true, subvar = true, copyvar = true, +} + +local function runNeighborTransition(mapId) + local ev = Space.bundle and Space.bundle.events and Space.bundle.events[mapId] + if not ev then return nil end + local src = Space.store or Flags.newStore() + local store = { flags = {}, vars = {} } + for k, v in pairs(src.flags or {}) do store.flags[k] = v end + for k, v in pairs(src.vars or {}) do store.vars[k] = v end + for id = Ctx.GFX_VAR_LO, Ctx.GFX_VAR_HI do store.vars[id] = nil end + local state = { store = store, perm = {}, movementType = {} } + local key = ev.mapScripts and ev.mapScripts.onTransition + local scripts = Space.vm and Space.vm.scripts + if type(key) ~= "string" or not (scripts and scripts[key]) then return state end + local Ops = require("src.core.game3.scripting.ops_a") + local vm = Vm.new({ store = store, scripts = scripts }) + local ctx = vm.ctx + vm:setPc(key, 1) + ctx.status = "running" + -- src/overworld.c:807 + for _ = 1, 2000 do + local pc = ctx.pc + local list = pc and scripts[pc.listKey] + local row = list and list[pc.index] + if not row then break end + pc.index = pc.index + 1 + local op = row.op + if op == "setobjectxyperm" then + local lid = Flags.getVar(store, ctx, row.localId or row[1]) + state.perm[lid] = { + x = Flags.getVar(store, ctx, row[2]), + y = Flags.getVar(store, ctx, row[3]), + } + elseif op == "setobjectmovementtype" then + local lid = Flags.getVar(store, ctx, row.localId or row[1]) + state.movementType[lid] = tonumber(row[2]) or 0 + elseif NEIGHBOR_FLOW_OPS[op] then + Ops.dispatchUnhooked(vm, row) end end - return graphics + return state +end + +function Space.neighborObjectState(mapId) + if not mapId or mapId == Space.mapId then return nil end + local cache = Space._neighborState + if not cache or cache.host ~= Space.mapId or cache.store ~= Space.store then + cache = { host = Space.mapId, store = Space.store, maps = {} } + Space._neighborState = cache + end + local st = cache.maps[mapId] + if st == nil then + local ok, res = pcall(runNeighborTransition, mapId) + st = (ok and res) or false + cache.maps[mapId] = st + end + return st or nil end --- After ON_TRANSITION sets VAR_OBJ_GFX_ID_*, refresh spawned sprites. diff --git a/src/core/game3/scripting/stdscripts.lua b/src/core/game3/scripting/stdscripts.lua index c3a4ee80..409307cd 100644 --- a/src/core/game3/scripting/stdscripts.lua +++ b/src/core/game3/scripting/stdscripts.lua @@ -1,17 +1,5 @@ --- Shared FRLG std / common scripts (not map-local). Same role as pret --- data/scripts/pc.inc + pkmn_center_nurse.inc — one definition, every map. - -local Flags = require("src.core.game3.scripting.flags") -local Opcodes = require("src.core.game3.scripting.opcodes") -local TextIR = require("src.core.game3.scripting.text_ir") - local Std = {} -local function T(ascii) - ascii = ascii:gsub("^\n", ""):gsub("\r\n", "\n"):gsub("\n", "\\n") - return TextIR.fromAscii(ascii) -end - -- 0-based def_special order of pokefirered/data/specials.inc Std.SPECIAL = { HealPlayerParty = 0x00, @@ -35,6 +23,7 @@ Std.SPECIAL = { BedroomPC = 0xF9, -- pokefirered/data/specials.inc:260 PlayerPC = 0xFA, CreatePCMenu = 0x106, + HallOfFamePCBeginFade = 0x107, -- pokefirered/data/specials.inc:274 EnterHallOfFame = 0x110, -- 272 (special HallOfFame / GameClear) EnableNationalPokedex = 0x16F, -- pokefirered/data/specials.inc:378 SetUnlockedPokedexFlags = 0x181, -- pokefirered/data/specials.inc:396 @@ -110,6 +99,7 @@ Std.SPECIAL = { HasAllMons = 0x1B0, -- pokefirered/data/specials.inc:443 IsPlayerNotInTrainerTowerLobby = 0x1B1, -- pokefirered/data/specials.inc:444 CallTrainerTowerFunc = 0x194, -- pokefirered/data/specials.inc:415 + BufferTMHMMoveName = 0x196, -- pokefirered/data/specials.inc:417 SavePlayerParty = 0x27, -- pokefirered/data/specials.inc:50 LoadPlayerParty = 0x28, -- pokefirered/data/specials.inc:51 ChooseHalfPartyForBattle = 0x29, -- pokefirered/data/specials.inc:52 @@ -234,289 +224,4 @@ for name, id in pairs(Std.SPECIAL) do end end -Std.TEXT = { - Text_TownMap = T([[ -It's a TOWN MAP.]]), - Text_WelcomeWantToHealPkmn = T([[ -Welcome to our POKéMON CENTER!\p -Would you like me to heal your -POKéMON to perfect health?]]), - Text_TakeYourPkmnForFewSeconds = T([[ -OK, may I see your POKéMON?]]), - Text_RestoredPkmnToFullHealth = T([[ -Thank you for waiting. -Your POKéMON are fully healed.]]), - Text_WeHopeToSeeYouAgain = T([[ -We hope to see you again!]]), - Text_BootedUpPC = T([[ -{PLAYER} booted up the PC.]]), - Text_UsualPCServicesUnavailable = T([[ -The usual PC services aren't -available right now…]]), - -- obtain_item.inc (simplified host strings) - Text_ObtainedTheX = T([[ -{PLAYER} obtained -the {STR_VAR_2}!]]), - Text_PutItemAway = T([[ -{PLAYER} put away the -{STR_VAR_2} in the {STR_VAR_3}.]]), - Text_TooBadBagFull = T([[ -Too bad! -The BAG is full…]]), - Text_FoundOneItem = T([[ -{PLAYER} found one {STR_VAR_2}!]]), - Text_FoundTMHMContainsMove = T([[ -{PLAYER} found -{STR_VAR_2}!]]), -} - --- Cart EventScript_PC (simplified host path: open full storage UI). -Std.SCRIPTS = { - EventScript_WallTownMap = { - { op = "lockall" }, - { op = "loadword", dest = 0, value = "Text_TownMap" }, - { op = "callstd", std = Opcodes.STD.MSGBOX_DEFAULT }, - { op = "fadescreen", [1] = 1 }, - { op = "special", id = Std.SPECIAL.FieldShowRegionMap }, - { op = "waitstate" }, - { op = "releaseall" }, - { op = "end" }, - }, - EventScript_PC = { - { op = "lockall" }, - { op = "setvar", var = 0x8004, value = 0 }, - { op = "special", id = Std.SPECIAL.AnimatePcTurnOn }, - { op = "playse", [1] = 4 }, -- data/scripts/pc.inc:9 - { op = "loadword", dest = 0, value = "Text_BootedUpPC" }, - { op = "message", ptr = 0 }, - { op = "waitmessage" }, - { op = "waitbuttonpress" }, - { op = "special", id = Std.SPECIAL.CreatePCMenu }, - { op = "waitstate" }, - { op = "setvar", var = 0x8004, value = 0 }, - { op = "special", id = Std.SPECIAL.AnimatePcTurnOff }, - { op = "releaseall" }, - { op = "end" }, - }, - -- Pret shape: welcome → YES/NO → take&heal (turn left → FLDEFF_POKECENTER_HEAL - -- → turn down → HealPlayerParty) → restored → bow → goodbye. - EventScript_PkmnCenterNurse = { - { op = "loadword", dest = 0, value = "Text_WelcomeWantToHealPkmn" }, - { op = "callstd", std = Opcodes.STD.MSGBOX_YESNO }, - { op = "compare_var_to_value", var = 0x800D, value = 0 }, - { op = "goto_if", cond = 1, target = "EventScript_PkmnCenterNurse_Goodbye" }, - { op = "loadword", dest = 0, value = "Text_TakeYourPkmnForFewSeconds" }, - { op = "message", ptr = 0 }, - { op = "waitmessage" }, - { op = "call", target = "EventScript_PkmnCenterNurse_TakeAndHealPkmn" }, - { op = "goto", target = "EventScript_PkmnCenterNurse_ReturnPkmn" }, - }, - -- pret EventScript_PkmnCenterNurse_TakeAndHealPkmn - EventScript_PkmnCenterNurse_TakeAndHealPkmn = { - -- WalkInPlaceFasterLeft / Down (0x2F / 0x2D) + step_end - { op = "applymovement", localId = 0x800F, movement = { 0x2F, 0xFE } }, - { op = "waitmovement", localId = 0x800F }, - { op = "dofieldeffect", [1] = 25 }, - { op = "waitfieldeffect", [1] = 25 }, - { op = "applymovement", localId = 0x800F, movement = { 0x2D, 0xFE } }, - { op = "waitmovement", localId = 0x800F }, - { op = "special", id = Std.SPECIAL.HealPlayerParty }, - { op = "return" }, - }, - EventScript_PkmnCenterNurse_ReturnPkmn = { - { op = "loadword", dest = 0, value = "Text_RestoredPkmnToFullHealth" }, - { op = "message", ptr = 0 }, - { op = "waitmessage" }, - -- nurse_joy_bow (0x5B) + delay_4 (0x1A) + step_end - { op = "applymovement", localId = 0x800F, movement = { 0x5B, 0x1A, 0xFE } }, - { op = "waitmovement", localId = 0x800F }, - { op = "goto", target = "EventScript_PkmnCenterNurse_Goodbye" }, - }, - EventScript_PkmnCenterNurse_Goodbye = { - { op = "loadword", dest = 0, value = "Text_WeHopeToSeeYouAgain" }, - { op = "callstd", std = Opcodes.STD.MSGBOX_DEFAULT }, - { op = "return" }, - }, - -- Economy / item stds (pret obtain_item.inc). Pocket name → STR_VAR_3. - EventScript_RestorePrevTextColor = { -- data/scripts/obtain_item.inc:6 - { op = "copyvar", [1] = 0x8012, [2] = 0x8013 }, - { op = "return" }, - }, - ["std:0"] = { -- STD_OBTAIN_ITEM, data/scripts/obtain_item.inc:10 - { op = "copyvar", [1] = 0x8013, [2] = 0x8012 }, - { op = "textcolor", color = 3, [1] = 3 }, - { op = "additem", [1] = 0x8000, [2] = 0x8001 }, - { op = "copyvar", [1] = 0x8007, [2] = 0x800D }, - { op = "call", target = "EventScript_ObtainItemMessage" }, - { op = "copyvar", [1] = 0x8012, [2] = 0x8013 }, - { op = "return" }, - }, - EventScript_ObtainItemMessage = { - { op = "bufferitemname", dest = 1, src = 0x8000 }, -- STR_VAR_2 - { op = "checkitemtype", [1] = 0x8000 }, - { op = "call", target = "EventScript_BufferPocketName" }, - { op = "compare_var_to_value", var = 0x8007, value = 1 }, - { op = "goto_if", cond = 1, target = "EventScript_ObtainedItem" }, - { op = "setvar", var = 0x800D, value = 0 }, - { op = "return" }, - }, - EventScript_ObtainedItem = { - { op = "playfanfare", [1] = 257 }, -- MUS_LEVEL_UP - { op = "loadword", dest = 0, value = "Text_ObtainedTheX" }, - { op = "message", ptr = 0 }, - { op = "waitfanfare" }, - { op = "waitmessage" }, - { op = "loadword", dest = 0, value = "Text_PutItemAway" }, - { op = "message", ptr = 0 }, - { op = "waitmessage" }, - { op = "waitbuttonpress" }, - { op = "setvar", var = 0x800D, value = 1 }, - { op = "return" }, - }, - EventScript_BufferPocketName = { - { op = "compare_var_to_value", var = 0x800D, value = 1 }, - { op = "goto_if", cond = 1, target = "EventScript_BufferItemsPocket" }, - { op = "compare_var_to_value", var = 0x800D, value = 2 }, - { op = "goto_if", cond = 1, target = "EventScript_BufferKeyItemsPocket" }, - { op = "compare_var_to_value", var = 0x800D, value = 3 }, - { op = "goto_if", cond = 1, target = "EventScript_BufferPokeBallsPocket" }, - { op = "compare_var_to_value", var = 0x800D, value = 4 }, - { op = "goto_if", cond = 1, target = "EventScript_BufferTMCase" }, - { op = "compare_var_to_value", var = 0x800D, value = 5 }, - { op = "goto_if", cond = 1, target = "EventScript_BufferBerryPouch" }, - { op = "bufferstdstring", dest = 2, src = 24 }, -- STR_VAR_3 ITEMS POCKET - { op = "return" }, - }, - EventScript_BufferItemsPocket = { - { op = "bufferstdstring", dest = 2, src = 24 }, - { op = "return" }, - }, - EventScript_BufferKeyItemsPocket = { - { op = "bufferstdstring", dest = 2, src = 25 }, - { op = "return" }, - }, - EventScript_BufferPokeBallsPocket = { - { op = "bufferstdstring", dest = 2, src = 26 }, - { op = "return" }, - }, - EventScript_BufferTMCase = { - { op = "bufferstdstring", dest = 2, src = 27 }, - { op = "return" }, - }, - EventScript_BufferBerryPouch = { - { op = "bufferstdstring", dest = 2, src = 28 }, - { op = "return" }, - }, - ["std:1"] = { -- STD_FIND_ITEM - { op = "checkitemspace", [1] = 0x8000, [2] = 0x8001 }, - { op = "copyvar", [1] = 0x8007, [2] = 0x800D }, - { op = "bufferitemname", dest = 1, src = 0x8000 }, - { op = "checkitemtype", [1] = 0x8000 }, - { op = "call", target = "EventScript_BufferPocketName" }, - { op = "compare_var_to_value", var = 0x8007, value = 1 }, - { op = "goto_if", cond = 1, target = "EventScript_PickUpItem" }, - { op = "loadword", dest = 0, value = "Text_TooBadBagFull" }, - { op = "message", ptr = 0 }, - { op = "waitmessage" }, - { op = "waitbuttonpress" }, - { op = "setvar", var = 0x800D, value = 0 }, - { op = "return" }, - }, - EventScript_PickUpItem = { - { op = "removeobject", [1] = 0x800F }, - { op = "additem", [1] = 0x8000, [2] = 0x8001 }, - { op = "playfanfare", [1] = 257 }, -- MUS_LEVEL_UP - { op = "loadword", dest = 0, value = "Text_FoundOneItem" }, - { op = "message", ptr = 0 }, - { op = "waitfanfare" }, - { op = "waitmessage" }, - { op = "loadword", dest = 0, value = "Text_PutItemAway" }, - { op = "message", ptr = 0 }, - { op = "waitmessage" }, - { op = "waitbuttonpress" }, - { op = "setvar", var = 0x800D, value = 1 }, - { op = "return" }, - }, - ["std:2"] = { -- MSGBOX_NPC, data/scripts/std_msgbox.inc:6 - { op = "lock" }, - { op = "faceplayer" }, - { op = "message", ptr = 0 }, - { op = "waitmessage" }, - { op = "waitbuttonpress" }, - { op = "release" }, - { op = "return" }, - }, - ["std:3"] = { -- MSGBOX_SIGN, data/scripts/std_msgbox.inc:14 - { op = "lockall" }, - { op = "message", ptr = 0 }, - { op = "waitmessage" }, - { op = "waitbuttonpress" }, - { op = "releaseall" }, - { op = "return" }, - }, - ["std:4"] = { -- MSGBOX_DEFAULT, data/scripts/std_msgbox.inc:22 - { op = "message", ptr = 0 }, - { op = "waitmessage" }, - { op = "waitbuttonpress" }, - { op = "return" }, - }, - ["std:5"] = { -- MSGBOX_YESNO, data/scripts/std_msgbox.inc:27 - { op = "message", ptr = 0 }, - { op = "waitmessage" }, - { op = "yesnobox", [1] = 20, [2] = 8 }, - { op = "return" }, - }, - ["std:6"] = { -- MSGBOX_AUTOCLOSE, data/scripts/std_msgbox.inc:32 - { op = "message", ptr = 0 }, - { op = "waitmessage" }, - { op = "waitbuttonpress" }, - { op = "release" }, - { op = "return" }, - }, - ["std:8"] = { -- STD_PUT_ITEM_AWAY - { op = "bufferitemname", dest = 1, src = 0x8000 }, - { op = "checkitemtype", [1] = 0x8000 }, - { op = "call", target = "EventScript_BufferPocketName" }, - { op = "loadword", dest = 0, value = "Text_PutItemAway" }, - { op = "message", ptr = 0 }, - { op = "waitmessage" }, - { op = "waitbuttonpress" }, - { op = "return" }, - }, - ["std:9"] = { -- STD_RECEIVED_ITEM (msgreceiveditem) - { op = "textcolor", color = 3, [1] = 3 }, -- data/scripts/std_msgbox.inc:30 - { op = "compare_var_to_value", var = 0x8002, value = 318 }, -- MUS_OBTAIN_KEY_ITEM - { op = "goto_if", cond = 1, target = "EventScript_ReceivedItemFanfareKeyItem" }, - { op = "compare_var_to_value", var = 0x8002, value = 258 }, -- MUS_OBTAIN_ITEM - { op = "goto_if", cond = 1, target = "EventScript_ReceivedItemFanfareItem" }, - { op = "compare_var_to_value", var = 0x8002, value = 257 }, -- MUS_LEVEL_UP - { op = "goto_if", cond = 1, target = "EventScript_ReceivedItemFanfareLevelUp" }, - { op = "goto", target = "EventScript_ReceivedItemFanfareDefault" }, - }, - EventScript_ReceivedItemFanfareKeyItem = { - { op = "playfanfare", [1] = 318 }, -- MUS_OBTAIN_KEY_ITEM - { op = "goto", target = "EventScript_ReceivedItemShowMsg" }, - }, - EventScript_ReceivedItemFanfareItem = { - { op = "playfanfare", [1] = 258 }, -- MUS_OBTAIN_ITEM - { op = "goto", target = "EventScript_ReceivedItemShowMsg" }, - }, - EventScript_ReceivedItemFanfareLevelUp = { - { op = "playfanfare", [1] = 257 }, -- MUS_LEVEL_UP - { op = "goto", target = "EventScript_ReceivedItemShowMsg" }, - }, - EventScript_ReceivedItemFanfareDefault = { - { op = "playfanfare", [1] = 0x8002 }, -- VAR_0x8002 fallback - { op = "goto", target = "EventScript_ReceivedItemShowMsg" }, - }, - EventScript_ReceivedItemShowMsg = { - { op = "message", ptr = 0 }, - { op = "waitfanfare" }, - { op = "waitmessage" }, - { op = "callstd", std = 8 }, -- STD_PUT_ITEM_AWAY - { op = "call", target = "EventScript_RestorePrevTextColor" }, - { op = "return" }, - }, -} - return Std diff --git a/src/core/game3/scripting/text_ir.lua b/src/core/game3/scripting/text_ir.lua index 5b71d6cf..f9eab9a2 100644 --- a/src/core/game3/scripting/text_ir.lua +++ b/src/core/game3/scripting/text_ir.lua @@ -51,10 +51,20 @@ TextIR.PH = { [0x06] = "rival", } +TextIR.KEYGFX = { + [0x00] = "A_BUTTON", [0x01] = "B_BUTTON", [0x02] = "L_BUTTON", [0x03] = "R_BUTTON", + [0x04] = "START_BUTTON", [0x05] = "SELECT_BUTTON", [0x06] = "DPAD_UP", [0x07] = "DPAD_DOWN", + [0x08] = "DPAD_LEFT", [0x09] = "DPAD_RIGHT", [0x0A] = "DPAD_UPDOWN", [0x0B] = "DPAD_LEFTRIGHT", + [0x0C] = "DPAD_ANY", +} + local function expand_seg(seg, ctx) local t = seg.t if t == "text" then return seg.s + elseif t == "dynamic" then + local dyn = ctx and ctx.dynamic + return (dyn and dyn[seg.n]) or "" elseif t == "player" then return (ctx and ctx.playerName) or "PLAYER" elseif t == "rival" then @@ -139,6 +149,15 @@ function TextIR.decode(bytes) out[#out + 1] = { t = "ph", code = nn } end i = i + 2 + elseif c == 0xF7 then + flush_text(out, buf); buf = {} + out[#out + 1] = { t = "dynamic", n = b(i + 1) or 0 } + i = i + 2 + elseif c == 0xF8 then + flush_text(out, buf); buf = {} + local key = TextIR.KEYGFX[b(i + 1) or -1] + out[#out + 1] = { t = "tag", tag = key and ("{" .. key .. "}") or "" } + i = i + 2 elseif c == 0xFC then flush_text(out, buf); buf = {} local cmd = b(i + 1) or 0 diff --git a/src/core/game3/scripting/vm.lua b/src/core/game3/scripting/vm.lua index db643920..8227a37d 100644 --- a/src/core/game3/scripting/vm.lua +++ b/src/core/game3/scripting/vm.lua @@ -18,21 +18,9 @@ function Vm.new(opts) self.text = opts.text or {} self.movements = opts.movements or {} self.adapters = opts.adapters or Adapters.stub(opts) - local std = opts.stdscripts or require("src.core.game3.scripting.stdscripts") - self.stdscripts = std - local stdScripts = (std and std.SCRIPTS) or std - if type(stdScripts) == "table" then - for k, rows in pairs(stdScripts) do - if not self.scripts[k] then - self.scripts[k] = rows - end - end - end - if std and type(std.TEXT) == "table" then - for k, v in pairs(std.TEXT) do - if not self.text[k] then - self.text[k] = v - end + for k, rows in pairs(opts.stdscripts or {}) do + if not self.scripts[k] then + self.scripts[k] = rows end end return self diff --git a/src/core/game3/ss_anne_cutscene.lua b/src/core/game3/ss_anne_cutscene.lua index be0a2c66..601f9ae3 100644 --- a/src/core/game3/ss_anne_cutscene.lua +++ b/src/core/game3/ss_anne_cutscene.lua @@ -40,80 +40,20 @@ local function playSe(id) end end -local function makeTransparentImage(path) - if not (love and love.image and love.image.newImageData and love.graphics and love.graphics.newImage) then - return nil - end - local okData, imgData = pcall(love.image.newImageData, path) - if not (okData and imgData) then return nil end - - local w, h = imgData:getWidth(), imgData:getHeight() - for y = 0, h - 1 do - for x = 0, w - 1 do - local r, g, b, _ = imgData:getPixel(x, y) - local r8 = math.floor(r * 255 + 0.5) - local g8 = math.floor(g * 255 + 0.5) - local b8 = math.floor(b * 255 + 0.5) - -- Key out GBA background blue palette entry (57, 115, 180) - if (r8 >= 45 and r8 <= 68) and (g8 >= 105 and g8 <= 130) and (b8 >= 165 and b8 <= 195) then - imgData:setPixel(x, y, 0, 0, 0, 0) - end - end - end - local img = love.graphics.newImage(imgData) - if img.setFilter then img:setFilter("nearest", "nearest") end - return img -end - local function loadGfx() - if not (love and love.graphics and love.graphics.newQuad) then - return - end + local FieldEffects = require("src.core.game3.field_effects") if not SSAnneCutscene._wakeImage then - local paths = { - "pokefirered/graphics/ss_anne/wake_transparent.png", - "pokefirered/graphics/ss_anne/wake.png", - "graphics/ss_anne/wake.png", - } - for _, p in ipairs(paths) do - local img = makeTransparentImage(p) - if img then - SSAnneCutscene._wakeImage = img - -- 16x64 image with two 16x32 frames - SSAnneCutscene._wakeQuads = { - [0] = love.graphics.newQuad(0, 0, 16, 32, 16, 64), - [1] = love.graphics.newQuad(0, 32, 16, 32, 16, 64), - } - break - end + local sheet = FieldEffects.loadSheet("ss_anne_wake", 16, 32, 2) + if sheet then + SSAnneCutscene._wakeImage = sheet.image + SSAnneCutscene._wakeQuads = sheet.quads end end if not SSAnneCutscene._smokeImage then - local paths = { - "pokefirered/graphics/ss_anne/smoke_transparent.png", - "pokefirered/graphics/ss_anne/smoke.png", - "graphics/ss_anne/smoke.png", - } - for _, p in ipairs(paths) do - local img = makeTransparentImage(p) - if img then - SSAnneCutscene._smokeImage = img - local sw, sh = 16, 72 - if img.getDimensions then - local iw, ih = img:getDimensions() - if iw and ih then sw, sh = iw, ih end - elseif img.getHeight and img:getHeight() then - sh = img:getHeight() - end - -- Four 16x16 frames - SSAnneCutscene._smokeQuads = { - [0] = love.graphics.newQuad(0, 0, 16, 16, sw, sh), - [1] = love.graphics.newQuad(0, 16, 16, 16, sw, sh), - [2] = love.graphics.newQuad(0, 32, 16, 16, sw, sh), - [3] = love.graphics.newQuad(0, 48, 16, 16, sw, sh), - } - break - end + local sheet = FieldEffects.loadSheet("ss_anne_smoke", 16, 16, 4) + if sheet then + SSAnneCutscene._smokeImage = sheet.image + SSAnneCutscene._smokeQuads = sheet.quads end end end diff --git a/src/core/game3/step_events.lua b/src/core/game3/step_events.lua index ca98f285..6ea7c0a0 100644 --- a/src/core/game3/step_events.lua +++ b/src/core/game3/step_events.lua @@ -5,11 +5,10 @@ -- 3. VS Seeker Battery (100 steps): Increments while the VS SEEKER is in the bag. -- 4. Overworld Poison (4 steps): 4-frame reddish screen flash, SE_FIELD_POISON, lethal faint at 0 HP. -- 5. Egg Cycles & Daycare (daycare stepCounter == 255): Decrements egg cycles -> EggHatch; +1 EXP per step in Daycare. --- 6. Repel Counter: Decrements steps -> "Repel's effect wore off..." on expiration. +-- 6. Repel Counter: Decrements steps -> Text_RepelWoreOff on expiration. local Pokemon = require("src.core.game3.pokemon") -local ModRuntime = require("src.mods.Runtime") -local Strings = require("src.core.Strings") +local RomText = require("src.core.game3.rom_text") local StepEvents = {} @@ -73,55 +72,57 @@ local function party_is_wiped(party) return not hasAlive end -local function trigger_white_out(session, game) - StepEvents.flush() - if session and session.onWhiteout then - session.onWhiteout() - return - end - local Runtime = require("src.core.game3.runtime") - local Map = require("src.core.game3.map") - local Fade = require("src.ui.game3.fade") - - -- 1. Faint message - local Hud = require("src.ui.game3.hud") - local playerName = (session and (session.name or session.playerName)) or "PLAYER" - local msg = Strings("%s is out of usable\nPOKéMON!\n\n%s whited out!", playerName, playerName) - - Hud.openMessage(game, msg, { - done = function() - -- 2. Fade to black and warp to last heal location (or Pallet Town player house) - Fade.begin(Fade.MODE.TO_BLACK, 0.5, function() - local healMap = (session and session.healMap) or "FR_PALLET_TOWN_PLAYERS_HOUSE_2F" - local healX = (session and session.healX) or 6 - local healY = (session and session.healY) or 6 - local healFacing = (session and session.healFacing) or "down" - if ModRuntime.wants("world.blacked_out") then - ModRuntime.emit("world.blacked_out", { - save = session, - healTarget = { map = healMap, x = healX, y = healY }, - }) - end - - -- Heal all party Pokémon - if session and session.party then - for _, mon in ipairs(session.party) do - local maxHp = tonumber(mon.maxHp or mon.maxhp) or 1 - mon.hp = maxHp - mon.status = nil - mon.statusNum = 0 - end - end - - Map.load(Runtime._mod, game, healMap, { - x = healX, - y = healY, - facing = healFacing, - }) - Fade.begin(Fade.MODE.FROM_BLACK, 0.5) - end) +-- data/scripts/white_out.inc:43 +local function field_white_out_event(session, game) + local ev = { type = "poison_white_out" } + ev.run = function(onDone) + if not party_is_wiped(session.party) then + onDone() + return end - }) + local Field = package.loaded["src.core.game3.field"] or require("src.core.game3.field") + Field.lock() + local BattleBridge = require("src.core.game3.battle_bridge") + local save = game and game.save + local name = session.name or session.playerName or "" + local money = tonumber(session.money) or 0 + local msg + if money >= 1 then + -- pokefirered/src/overworld.c:260 + local loss = math.min(BattleBridge.calcMoneyLossFrlg(session, save), money) + -- data/scripts/white_out.inc:56 + msg = RomText.box("Text_WhitedOutLostMoney", { playerName = name, stringVars = { tostring(loss) } }) + else + -- data/scripts/white_out.inc:50 + msg = RomText.box("Text_WhitedOut", { playerName = name }) + end + ev.whiteOutText = msg + local Hud = require("src.ui.game3.hud") + Hud.openMessage(game, msg, { + done = function() + -- pokefirered/src/field_screen_effect.c:214 + require("src.core.game3.audio").fadeOutBgm(4) + ev.phase = "music" + end, + }) + end + ev.tick = function() + if ev.phase ~= "music" then return end + local Audio = require("src.core.game3.audio") + if Audio._fadeOut then return end + ev.phase = "fade" + local Fade = require("src.ui.game3.fade") + -- data/scripts/white_out.inc:63 + Fade.begin(Fade.MODE.TO_BLACK, 1, function() + StepEvents.flush() + local BattleBridge = require("src.core.game3.battle_bridge") + -- pokefirered/src/overworld.c:253 + BattleBridge.applyFrlgMoneyLoss(session, game and game.save) + local Field = package.loaded["src.core.game3.field"] or require("src.core.game3.field") + Field.respawnAtHeal() + end) + end + return ev end --- Evaluate step counters upon completing a grid step (Walk, Run, Bike, Surf). @@ -215,26 +216,15 @@ function StepEvents.onStepTaken(session, game) name = fainted.name, mon = fainted.mon, run = function(onDone) - -- Play mon cry - pcall(function() - local Audio = require("src.core.game3.audio") - local sp = Pokemon.speciesOf(fainted.mon) - if Audio and Audio.playCry and sp then Audio.playCry(sp) end - end) - local Hud = require("src.ui.game3.hud") - Hud.openMessage(game, Strings("%s fainted...", fainted.name), { - done = function() - if party_is_wiped(party) then - trigger_white_out(session, game) - else - onDone() - end - end - }) + -- pokefirered/src/field_poison.c:64 + Hud.openMessage(game, RomText.box("gText_PkmnFainted3", { stringVars = { fainted.name } }), + { done = onDone }) end, }) end + -- pokefirered/src/field_poison.c:76 + if poisonFainted then push_event(field_white_out_event(session, game)) end end end session.vars[0x4040] = psnSteps @@ -257,7 +247,7 @@ function StepEvents.onStepTaken(session, game) local Audio = require("src.core.game3.audio") local Hud = require("src.ui.game3.hud") -- pokefirered/data/scripts/day_care.inc:112 DayCare_Text_Huh - Hud.openMessage(game, Strings("Huh?"), { + Hud.openMessage(game, RomText.box("DayCare_Text_Huh"), { done = function() -- pokefirered/data/scripts/day_care.inc:113 special EggHatch EggHatch.start(hatching, { @@ -308,9 +298,9 @@ function StepEvents.onRepelStep(session, game) push_event({ type = "repel_wore_off", run = function(onDone) - se(67) -- SE_REPEL local Hud = require("src.ui.game3.hud") - Hud.openMessage(game, Strings("Repel's effect wore off..."), { + -- data/scripts/repel.inc:2 + Hud.openMessage(game, RomText.box("Text_RepelWoreOff"), { done = onDone, }) end, diff --git a/src/core/game3/tileset_native.lua b/src/core/game3/tileset_native.lua index 70d93641..a08113db 100644 --- a/src/core/game3/tileset_native.lua +++ b/src/core/game3/tileset_native.lua @@ -122,6 +122,10 @@ local function load_pair(cache, pair) overImage = nil, overImageData = nil, overQuads = {}, + idxBlob = idxBlob, + overBlob = nil, + bgr = bgr, + slotPix = {}, } if layered then @@ -134,6 +138,7 @@ local function load_pair(cache, pair) ts.layered = true ts.overImage = oImg ts.overImageData = oData + ts.overBlob = overBlob end end end @@ -224,4 +229,72 @@ function NativeTileset.overQuad(pairOrTs, slot) return q end +local function scan_slot(blob, cols, slot, skipZero) + local out = {} + if type(blob) ~= "string" or #blob < 12 then return out end + local midCount = blob:byte(7) + blob:byte(8) * 256 + local base = 13 + midCount * 2 + local lo, hi = slot * 16, slot * 16 + 15 + local n = 0 + for i = 0, midCount * 256 - 1 do + local b = blob:byte(base + i) + if b and b >= lo and b <= hi and not (skipZero and b == 0) then + local mid = math.floor(i / 256) + local within = i % 256 + n = n + 1 + out[n] = { + (mid % cols) * 16 + within % 16, + math.floor(mid / cols) * 16 + math.floor(within / 16), + b - lo, + } + end + end + return out +end + +local function paint(image, imageData, list, colors) + if not (image and imageData and #list > 0) then return end + for i = 1, #list do + local p = list[i] + local c = colors[p[3]] + imageData:setPixel(p[1], p[2], c[1] / 255, c[2] / 255, c[3] / 255, 1) + end + if image.replacePixels then image:replacePixels(imageData) end +end + +-- pokefirered/src/palette.c:88 +function NativeTileset.setSlotPalette(pairOrTs, slot, bgr16) + local ts = type(pairOrTs) == "table" and pairOrTs or NativeTileset.get(pairOrTs) + if not (ts and ts.imageData and type(bgr16) == "table") then return false end + slot = tonumber(slot) or 0 + local pix = ts.slotPix[slot] + if not pix then + pix = { + under = scan_slot(ts.idxBlob, ts.cols, slot, false), + over = ts.overImageData and scan_slot(ts.overBlob, ts.cols, slot, true) or {}, + } + ts.slotPix[slot] = pix + end + local src = {} + for c = 0, 15 do src[c] = bgr16[c + 1] or 0 end + local colors = NativePack.palsToRgb8({ [0] = src })[0] + paint(ts.image, ts.imageData, pix.under, colors) + paint(ts.overImage, ts.overImageData, pix.over, colors) + ts.patchedSlots = ts.patchedSlots or {} + ts.patchedSlots[slot] = true + return true +end + +function NativeTileset.resetSlotPalette(pairOrTs, slot) + local ts = type(pairOrTs) == "table" and pairOrTs or NativeTileset._pairs[pairOrTs] + if not (ts and ts.patchedSlots and ts.patchedSlots[slot]) then return false end + local base = ts.bgr and ts.bgr[slot] + if not base then return false end + local list = {} + for c = 0, 15 do list[c + 1] = base[c] or 0 end + NativeTileset.setSlotPalette(ts, slot, list) + ts.patchedSlots[slot] = nil + return true +end + return NativeTileset diff --git a/src/core/game3/trade_scene.lua b/src/core/game3/trade_scene.lua index be5318fc..c3c7fb8f 100644 --- a/src/core/game3/trade_scene.lua +++ b/src/core/game3/trade_scene.lua @@ -1,5 +1,6 @@ local SE = require("src.core.game3.se_ids") local Strings = require("src.core.Strings") +local RomText = require("src.core.game3.rom_text") local TradeScene = {} @@ -791,14 +792,14 @@ end) -- pokefirered/src/trade_scene.c:2572 phase("link_standby", function(s) - setText(s, Strings("Communication standby…")) + setText(s, RomText.plain("gText_CommunicationStandby4")) if not s.awaitSave then return true end return s.linkTaskDone == true end, "link") -- pokefirered/src/trade_scene.c:2595 phase("link_save", function(s) - setText(s, Strings("SAVING…\nDON'T TURN OFF THE POWER.")) + setText(s, RomText.plain("gText_SavingDontTurnOffThePower2")) if not s.awaitSave then return true end return s.saveDone == true end, "link") @@ -897,9 +898,10 @@ function TradeScene.hasArt() return (s and s.art) and true or false end +-- pokefirered/src/trade_scene.c:1772 function TradeScene.pressA() local s = TradeScene._s - if s then s.aPressed = true end + if s and TradeScene.phase() == "end_link_trade" then s.aPressed = true end end function TradeScene.isLink() diff --git a/src/core/game3/trainer_pic.lua b/src/core/game3/trainer_pic.lua index d46713b5..6a5fab80 100644 --- a/src/core/game3/trainer_pic.lua +++ b/src/core/game3/trainer_pic.lua @@ -1,6 +1,5 @@ --- Trainer front / player back pics (ROM-lazy + cache write-through). +-- Trainer front / player back pics (cache only). -local Versions = require("src.import.gba.versions") local Extract = require("src.import.gba.extract_island1") local TrainerPic = {} @@ -8,7 +7,6 @@ local TrainerPic = {} TrainerPic._front = {} TrainerPic._back = {} TrainerPic._cache = nil -TrainerPic._rom = nil local function cache_root() return (Extract.CACHE_ROOT or "data/generated/gba") .. "/trainers" @@ -37,101 +35,6 @@ local function resolve_cache(cache) } end -local function bgr555_to_rgb8(c) - c = (tonumber(c) or 0) % 32768 - local r5 = c % 32 - local g5 = math.floor(c / 32) % 32 - local b5 = math.floor(c / 1024) % 32 - return math.floor(r5 * 255 / 31 + 0.5), - math.floor(g5 * 255 / 31 + 0.5), - math.floor(b5 * 255 / 31 + 0.5) -end - -local function wrap_rom_data(data) - if not data or #data < 0xC0 then return data end - local code = data:sub(0xAD, 0xB0) - local ver = data:byte(0xBD) - local okV, Versions = pcall(require, "src.import.gba.versions") - if okV and Versions and Versions.select then - if code == "BPGE" then - Versions.select("leafgreen") - else - Versions.select("firered") - end - end - if ver == 1 then - local sha1 - if love and love.data and love.data.hash then - sha1 = love.data.hash("sha1", data) - end - local okR, RevisionView = pcall(require, "src.import.gba.revision_view") - if okR and RevisionView then - local rev = (sha1 and RevisionView.forSha1 and RevisionView.forSha1(sha1)) - or (code == "BPGE" and require("src.import.gba.revisions.leafgreen_1_1")) - or require("src.import.gba.revisions.firered_1_1") - if rev and RevisionView.build then - data = RevisionView.build(data, rev) - end - end - end - return data -end - -local function load_rom_bytes() - if TrainerPic._rom then return TrainerPic._rom end - local candidates = { - "1636 - Pokemon Fire Red (U)(Squirrels).gba", - "firered.gba", - "firered_dump.gba", - "Pokemon - FireRed Version (USA).gba", - "Pokemon - Fire Red Version (U) (V1.1).gba", - "Pokemon - LeafGreen Version (USA).gba", - "Pokemon - LeafGreen Version (USA, Europe) (Rev 1).gba", - "leafgreen.gba", - } - local bases = { - "", - (os.getenv("HOME") or "") .. "/src/gen1recomp-gaia/", - } - for _, base in ipairs(bases) do - for _, name in ipairs(candidates) do - local path = (base ~= "" and (base .. name)) or name - local f = io.open(path, "rb") - if f then - local data = f:read("*a") - f:close() - if data and #data > 0 then - local wrapped = wrap_rom_data(data) - TrainerPic._rom = wrapped - return wrapped - end - end - end - end - if love and love.filesystem and love.filesystem.read then - for _, name in ipairs(candidates) do - local data = love.filesystem.read(name) - if data and #data > 0 then - local wrapped = wrap_rom_data(data) - TrainerPic._rom = wrapped - return wrapped - end - end - end - return nil -end - -local function rom_u8(data, i) - return data:byte(i + 1) or 0 -end - -local function rom_u32(data, off) - return rom_u8(data, off) - + rom_u8(data, off + 1) * 256 - + rom_u8(data, off + 2) * 65536 - + rom_u8(data, off + 3) * 16777216 -end - local function image_from_rgba(rgba, w, h) if not (love and love.image and love.graphics) then return nil end if not rgba or #rgba < w * h * 4 then return nil end @@ -142,102 +45,12 @@ local function image_from_rgba(rgba, w, h) return image end -local function decode_pic_rgba(index, picTable, palTable, cacheRel, frames) - index = tonumber(index) - frames = math.max(1, tonumber(frames) or 1) - if not index or index < 0 then return nil end +local function read_pic_rgba(cacheRel, frames) local cache = TrainerPic._cache - if cache and cache.read then - local d = cache:read(cacheRel) - if d and #d >= 64 * 64 * frames * 4 then return d end - end - - local data = load_rom_bytes() - if not data then return nil end - local Lz77 = require("src.import.gba.lz77") - local sheetOff = picTable + index * 8 - local palOff = palTable + index * 8 - local tilePtr = rom_u32(data, sheetOff) - local palPtr = rom_u32(data, palOff) - local size = rom_u8(data, sheetOff + 4) + rom_u8(data, sheetOff + 5) * 256 - local tileFile = Versions.gbaToFile(tilePtr) - local palFile = Versions.gbaToFile(palPtr) - if not tileFile or not palFile then return nil end - local function get(i) - return rom_u8(data, i) - end - local tiles - local okP, palBytes = pcall(Lz77.decompress, get, palFile) - if not okP or type(palBytes) ~= "table" then - return nil - end - if frames > 1 then - -- Player back pics: uncompressed 4bpp strip. - if size < 0x800 then size = frames * 0x800 end - tiles = {} - for i = 0, size - 1 do - tiles[i + 1] = rom_u8(data, tileFile + i) - end - else - local okT - okT, tiles = pcall(Lz77.decompress, get, tileFile) - if not okT or type(tiles) ~= "table" then - return nil - end - -- Multi-frame front sheets (size 0x1000): use first 2048 tile bytes only. - if size >= 0x1000 and #tiles > 2048 then - local trimmed = {} - for i = 1, 2048 do trimmed[i] = tiles[i] end - tiles = trimmed - end - end - local pal = {} - for c = 0, 15 do - local lo = palBytes[c * 2 + 1] or 0 - local hi = palBytes[c * 2 + 2] or 0 - pal[c] = lo + hi * 256 - end - local rgb = {} - for c = 0, 15 do - local r, g, b = bgr555_to_rgb8(pal[c] or 0) - rgb[c] = { r, g, b } - end - local w, h = 64, 64 * frames - local chunks = {} - local ti = 0 - local tilesH = 8 * frames - for ty = 0, tilesH - 1 do - for tx = 0, 7 do - local tileOff = ti * 32 - for row = 0, 7 do - for bx = 0, 3 do - local bi = tileOff + row * 4 + bx + 1 - local byte = tiles[bi] or 0 - local p0 = byte % 16 - local p1 = math.floor(byte / 16) % 16 - local x0 = tx * 8 + bx * 2 - local y0 = ty * 8 + row - local function put(x, y, idx) - local i = y * w + x + 1 - if idx == 0 then - chunks[i] = string.char(0, 0, 0, 0) - else - local c = rgb[idx] or rgb[0] - chunks[i] = string.char(c[1], c[2], c[3], 255) - end - end - put(x0, y0, p0) - put(x0 + 1, y0, p1) - end - end - ti = ti + 1 - end - end - local rgba = table.concat(chunks) - if cache and cache.write then - pcall(cache.write, cache, cacheRel, rgba) - end - return rgba + if not (cache and cache.read) then return nil end + local d = cache:read(cacheRel) + if d and #d >= 64 * 64 * frames * 4 then return d end + return nil end function TrainerPic.install(cache) @@ -253,12 +66,7 @@ function TrainerPic.front(picId) if TrainerPic._front[picId] then return TrainerPic._front[picId] end if not TrainerPic._cache then TrainerPic.install(nil) end local rel = cache_root() .. "/front/" .. picId .. ".rgba" - local rgba = decode_pic_rgba( - picId, - Versions.TRAINER_FRONT_PIC_TABLE or 0x23957C, - Versions.TRAINER_FRONT_PIC_PAL_TABLE or 0x239A1C, - rel, - 1) + local rgba = read_pic_rgba(rel, 1) local image = image_from_rgba(rgba, 64, 64) if not image then return nil end local entry = { image = image, w = 64, h = 64 } @@ -273,18 +81,8 @@ function TrainerPic.back(gender) if TrainerPic._back[gender] then return TrainerPic._back[gender] end if not TrainerPic._cache then TrainerPic.install(nil) end local rel = cache_root() .. "/back_" .. gender .. ".rgba" - local cache = TrainerPic._cache - local rgba = cache and cache.read and cache:read(rel) local frames = (gender == 0 or gender == 1) and 5 or 4 - local expectedBytes = 64 * 64 * frames * 4 - if not rgba or #rgba < expectedBytes then - rgba = decode_pic_rgba( - gender, - Versions.TRAINER_BACK_PIC_TABLE or 0x239FA4, - Versions.TRAINER_BACK_PIC_PAL_TABLE or 0x239FD4, - rel, - frames) - end + local rgba = read_pic_rgba(rel, frames) if not rgba then return nil end local actualFrames = math.floor(#rgba / (64 * 64 * 4)) if actualFrames <= 0 then return nil end diff --git a/src/core/game3/trainer_sight.lua b/src/core/game3/trainer_sight.lua index 72c8f3f4..0c27e5ed 100644 --- a/src/core/game3/trainer_sight.lua +++ b/src/core/game3/trainer_sight.lua @@ -166,7 +166,9 @@ function TrainerSight.checkLineOfSight(eo, P, game) local d = DELTA[facing] if not d then return false, 0 end - local ex, ey = eo.cellX, eo.cellY + -- src/trainer_see.c:151 + local ex = eo.moving and eo.targetX or eo.cellX + local ey = eo.moving and eo.targetY or eo.cellY local px, py = P.cellX, P.cellY local dx, dy = d[1], d[2] local dist = 0 @@ -377,7 +379,7 @@ function TrainerSight.check(game, specificTrainer) if specificTrainer then local eo = specificTrainer - if eo.visible and not eo.hidden and not eo.moving and not eo.scriptBusy and not eo.frozen then + if eo.visible and not eo.hidden and not eo.scriptBusy and not eo.frozen then local sight = tonumber(eo.sight or (eo.def and (eo.def.sight or eo.def.trainerRange))) or 0 if sight > 0 and TrainerSight.isTrainerType(eo) and not TrainerSight.isDefeated(eo, store, ctx) then @@ -395,7 +397,7 @@ function TrainerSight.check(game, specificTrainer) local order = Objs._order or {} for _, lid in ipairs(order) do local eo = Objs.find(lid) - if eo and eo ~= P and eo.visible and not eo.hidden and not eo.moving and not eo.scriptBusy and not eo.frozen then + if eo and eo ~= P and eo.visible and not eo.hidden and not eo.scriptBusy and not eo.frozen then local sight = tonumber(eo.sight or (eo.def and (eo.def.sight or eo.def.trainerRange))) or 0 if sight > 0 and TrainerSight.isTrainerType(eo) and not TrainerSight.isDefeated(eo, store, ctx) then diff --git a/src/core/game3/vs_seeker.lua b/src/core/game3/vs_seeker.lua index 0a2009aa..9d20c1fc 100644 --- a/src/core/game3/vs_seeker.lua +++ b/src/core/game3/vs_seeker.lua @@ -620,10 +620,18 @@ function VsSeeker.chargingDoneEvent() if not ev.done then return end local Player = package.loaded["src.core.game3.player"] if Player and Player.moving then return end + -- src/vs_seeker.c:616 + local waiting = false for _, lid in ipairs(Objects._order or {}) do local eo = Objects._byId[lid] - if eo and (eo.moving or (eo.raiseY or 0) ~= 0) then return end + if eo then + if not eo.frozen then + if (eo.raiseY or 0) ~= 0 then waiting = true else eo.frozen = true end + end + if eo.moving then waiting = true end + end end + if waiting then return end if Objects.hasActiveTracks and Objects.hasActiveTracks() then return end freezeAll(Objects) VsSeeker.resetObjectMovementAfterChargeComplete(Objects) diff --git a/src/core/game3/warp.lua b/src/core/game3/warp.lua index 8c923f4c..8786a696 100644 --- a/src/core/game3/warp.lua +++ b/src/core/game3/warp.lua @@ -549,8 +549,9 @@ local FALL_START_Y = -112 --- Complete fall hole sequence (Mt. Moon, Seafoam drop holes) -- pokefirered/data/scripts/hole.inc:23 EventScript_DoFallWarp -function Warp.startFall(mod, game, destMap, destX, destY, srcX, srcY) +function Warp.startFall(mod, game, destMap, destX, destY, srcX, srcY, opts) if Warp._busy then return false end + opts = opts or {} destMap, destX, destY = announce(game, destMap, destX, destY, "fall", srcX, srcY) Warp._busy = true @@ -616,12 +617,14 @@ function Warp.startFall(mod, game, destMap, destX, destY, srcX, srcY) end -- pokefirered/data/scripts/hole.inc:24 + -- pokefirered/src/scrcmd.c:773 + local prologue = opts.prologue ~= false Task.spawn(function(t) - if t.frames == 20 then + if prologue and t.frames == 20 then Player.setVisible(false) playSe(SE.SE_FALL or 37) end - if t.frames < 80 then return false end + if prologue and t.frames < 80 then return false end Fade.begin(toMode, 1, function() local Map = require("src.core.game3.map") Map.load(mod, game, destMap, { diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index 003e36f5..bfc6f107 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -392,6 +392,56 @@ CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = { "data/generated/gba/trainer_card/screen_4_female.rgba", "data/generated/gba/trainer_card/star.rgba", "data/generated/gba/trainer_card/stickers.rgba", + -- src/credits.c:815, :1101, :1230 + "data/generated/gba/credits/manifest.lua", + "data/generated/gba/credits/pack.lua", + "data/generated/gba/credits/copyright.rgba", + "data/generated/gba/credits/the_end.rgba", + "data/generated/gba/credits/circle.rgba", + "data/generated/gba/credits/pokeball_0.rgba", + "data/generated/gba/credits/mon_0_1.rgba", + "data/generated/gba/credits/player_male.rgba", + "data/generated/gba/credits/rival.rgba", + "data/generated/gba/credits/ground_grass.rgba", + -- src/field_specials.c:2133 + "data/generated/gba/league/lighting.lua", + -- src/diploma.c:119 + "data/generated/gba/diploma/manifest.lua", + "data/generated/gba/diploma/kanto.rgba", + "data/generated/gba/diploma/national.rgba", + -- src/hall_of_fame.c:1163, :1181 + "data/generated/gba/hall_of_fame/manifest.lua", + "data/generated/gba/hall_of_fame/bands.rgba", + "data/generated/gba/hall_of_fame/stripes.rgba", + "data/generated/gba/hall_of_fame/confetti.rgba", + -- src/data/field_effects/field_effect_objects.h:288, src/itemfinder.c:39 + "data/generated/gba/field_effects/ground_impact_dust.rgba", + "data/generated/gba/field_effects/itemfinder_arrow_star.rgba", + -- src/field_effect.c:73, :77 + "data/generated/gba/field_effects/field_move_streaks_outdoors.rgba", + "data/generated/gba/field_effects/field_move_streaks_indoors.rgba", + -- src/pokemon.c:5904, src/decompress.c:83 + "data/generated/gba/pokemon/front_shiny/1.rgba", + "data/generated/gba/pokemon/back_shiny/1.rgba", + "data/generated/gba/pokemon/front_shiny/385_3.rgba", + "data/generated/gba/pokemon/front/413.rgba", + "data/generated/gba/pokemon/back/439.rgba", + "data/generated/gba/pokemon/front_shiny/439.rgba", + "data/generated/gba/pokemon/back_shiny/439.rgba", + "data/generated/gba/pokemon/icons/439.rgba", + -- src/pokemon.c:1350, :5339 + "data/generated/gba/pokemon/spinda/front.4bpp", + "data/generated/gba/pokemon/spinda/normal.gbapal", + "data/generated/gba/pokemon/spinda/shiny.gbapal", + "data/generated/gba/pokemon/spinda/spots.bin", + -- src/ss_anne.c:21-22 + "data/generated/gba/field_effects/ss_anne_wake.rgba", + "data/generated/gba/field_effects/ss_anne_smoke.rgba", + -- src/data/trainer_graphics/front_pic_tables.h:153 + "data/generated/gba/trainers/front/0.rgba", + "data/generated/gba/trainers/front/147.rgba", + -- src/pokeball.c:61 + "data/generated/gba/intro/ball_poke.png", }, } CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.leafgreen = {} diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index 6a864b2c..95c01d7b 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -328,6 +328,8 @@ function RomExtractorGen3:runAuxExtracts(sha1) local EggExtract = require("src.import.gba.egg_extract") local BattleAnimExtract = require("src.import.gba.battle_anim_extract") local BattleAiExtract = require("src.import.gba.battle_ai_extract") + local CreditsExtract = require("src.import.gba.credits_extract") + local LeagueExtract = require("src.import.gba.league_extract") local Extract = require("src.import.gba.extract_island1") local prevRoot = Extract.CACHE_ROOT Extract.CACHE_ROOT = GBA_ROOT @@ -352,11 +354,13 @@ function RomExtractorGen3:runAuxExtracts(sha1) local needEgg = wantedExtractor("egg_extract") and not EggExtract.ready(cache, GBA_ROOT) local needAnims = wantedExtractor("battle_anim_extract") and not BattleAnimExtract.ready(cache, GBA_ROOT) local needAi = wantedExtractor("battle_ai_extract") and not BattleAiExtract.ready(cache, GBA_ROOT) + local needCredits = wantedExtractor("credits_extract") and not CreditsExtract.ready(cache, GBA_ROOT) + local needLeague = wantedExtractor("league_extract") and not LeagueExtract.ready(cache, GBA_ROOT) if not (needRegion or needSections or needChoices or needHeal or needDoors or needSlots or needTrade or needLinkArt or needFame or needTeachy or needGift or needTower or needTutor or needMuseum or needRelearner - or needEgg or needAnims or needAi) then + or needEgg or needAnims or needAi or needCredits or needLeague) then Extract.CACHE_ROOT = prevRoot return true, { skipped = true } end @@ -370,7 +374,7 @@ function RomExtractorGen3:runAuxExtracts(sha1) local ok, detail = pcall(function() local out = {} local step = 0 - local totalSteps = 17 + local totalSteps = 19 local function auxTick(name) step = step + 1 self:report(step / totalSteps, "Game Data: " .. name, step, totalSteps) @@ -507,6 +511,16 @@ function RomExtractorGen3:runAuxExtracts(sha1) end auxTick("Battle AI Scripts") + if needCredits then + out.credits = CreditsExtract.run(rom, cache, { cacheRoot = GBA_ROOT }) + end + auxTick("Staff Credits") + + if needLeague then + out.league = LeagueExtract.run(rom, cache, { cacheRoot = GBA_ROOT }) + end + auxTick("League Lighting & Diploma") + return out end) diff --git a/src/import/gba/SPEC.md b/src/import/gba/SPEC.md index 99164883..2273dd52 100644 --- a/src/import/gba/SPEC.md +++ b/src/import/gba/SPEC.md @@ -73,11 +73,10 @@ FRLG ROM → MapEvents + script/text/movement BFS → maps.lua / space.lua load cache only ``` -- `island1_content.lua` is curated fallback for CI / no-ROM only. - Events are annotated with `scriptKey = g3:%08x` at extract time. -- Stdscripts (nurse/PC/`std:N`) overlay the bundle like Gen2 `gen2StdScripts`. -- Cache contract: ready bundle has events+scripts+text; incomplete cache does - not silently replace a prior ROM extract during normal boots. +- `std:N`, `Versions.NAMED_SCRIPTS` and `Versions.NAMED_TEXTS` are ROM seeds + aliased by name; there is no hand-written script overlay and no no-ROM bundle. +- Cache contract: ready bundle has events+scripts+text. ## Collision → shared std scripts diff --git a/src/import/gba/credits_extract.lua b/src/import/gba/credits_extract.lua new file mode 100644 index 00000000..56ce889c --- /dev/null +++ b/src/import/gba/credits_extract.lua @@ -0,0 +1,376 @@ +-- src/credits.c:815, :1101 + +local Versions = require("src.import.gba.versions") +local BgBake = require("src.import.gba.bg_bake") +local Lz77 = require("src.import.gba.lz77") +local TextIR = require("src.core.game3.scripting.text_ir") + +local CreditsExtract = {} + +CreditsExtract.CACHE_SUB = "credits" +CreditsExtract.FORMAT_VERSION = 1 + +-- src/credits.c:1084 +CreditsExtract.MONS = { + { key = "charizard", species = 6, frames = { "charizard_1", "charizard_2" }, windows = "windows_charizard" }, + { key = "venusaur", species = 3, frames = { "venusaur_1", "venusaur_2" }, windows = "windows_venusaur" }, + { key = "blastoise", species = 9, frames = { "blastoise_1", "blastoise_2" }, windows = "windows_blastoise" }, + { key = "pikachu", species = 25, frames = { "pikachu_1", "pikachu_2" }, windows = "windows_pikachu" }, +} + +-- src/credits.c:466 +CreditsExtract.SPRITES = { + { key = "player_male", w = 64, h = 64, frames = 6 }, + { key = "player_female", w = 64, h = 64, frames = 6 }, + { key = "rival", w = 64, h = 64, frames = 6 }, + { key = "ground_grass", w = 64, h = 32, frames = 8 }, + { key = "ground_dirt", w = 64, h = 32, frames = 8 }, + { key = "ground_city", w = 64, h = 32, frames = 8 }, +} + +CreditsExtract.FILES = { + "manifest.lua", "pack.lua", "copyright.rgba", "the_end.rgba", "circle.rgba", + "pokeball_0.rgba", "pokeball_1.rgba", "pokeball_2.rgba", "pokeball_3.rgba", + "mon_0_1.rgba", "mon_0_2.rgba", "mon_1_1.rgba", "mon_1_2.rgba", + "mon_2_1.rgba", "mon_2_2.rgba", "mon_3_1.rgba", "mon_3_2.rgba", + "player_male.rgba", "player_female.rgba", "rival.rgba", + "ground_grass.rgba", "ground_dirt.rgba", "ground_city.rgba", +} + +local SCREEN_W, SCREEN_H = 240, 160 +-- include/overworld.h:38 +local SCENE_ROW = 8 + +local function default_cache_root() + local ok, Extract = pcall(require, "src.import.gba.extract_island1") + if ok and Extract and Extract.CACHE_ROOT then + return Extract.CACHE_ROOT + end + return "data/generated/gba" +end + +local function rom_bytes(rom, off, len) + local out = {} + for i = 1, len do out[i] = rom:get(off + i - 1) end + out._len = len + return out +end + +local function lz(rom, off) + local out = Lz77.decompress(function(i) return rom:get(i) end, off) + out._len = Lz77.len(out) + return out +end + +local function s16(v) + if v >= 0x8000 then return v - 0x10000 end + return v +end + +local function pointer(rom, off) + local p = rom:ptrOffset(rom:u32(off)) + assert(p and p < rom.size, string.format("credits: bad pointer at 0x%X", off)) + return p +end + +-- src/credits.c:925 +local function decode_text(rom, off) + local bytes = {} + for i = 0, 1023 do + local b = rom:get(off + i) + bytes[#bytes + 1] = string.char(b) + if b == 0xFF then break end + end + local out = {} + for _, seg in ipairs(TextIR.decode(table.concat(bytes))) do + if seg.t == "nl" then + out[#out + 1] = "\n" + elseif seg.t == "ext" and seg.cmd == 0x13 then + out[#out + 1] = string.format("{CLEAR_TO %d}", seg.raw:byte(3)) + elseif seg.t == "text" then + out[#out + 1] = seg.s + elseif seg.t ~= "eos" then + error("credits: unexpected text control " .. tostring(seg.t) .. string.format(" at 0x%X", off)) + end + end + return table.concat(out) +end + +local function quote(s) + local lit = string.format("%q", s) + return (lit:gsub("\\\n", "\\n")) +end + +local function fill(color, w, h) + local r, g, b = BgBake.bgr555ToRgb8(color) + return string.rep(string.char(r, g, b, 255), w * h) +end + +local function over(base, top) + local out = {} + for i = 1, #base / 4 do + local o = (i - 1) * 4 + if top:byte(o + 4) > 0 then + out[i] = top:sub(o + 1, o + 4) + else + out[i] = base:sub(o + 1, o + 4) + end + end + return table.concat(out) +end + +-- src/credits.c:1230 +local function bake_bg4(gfx, banks, map, mapW) + local top = BgBake.bakeRegionRgba(gfx, banks, map, SCREEN_W, SCREEN_H, + { mapW = mapW or 32, alpha0 = true }) + return over(fill(banks[0][0], SCREEN_W, SCREEN_H), top) +end + +local function used_banks(map) + local hi = 0 + for i = 1, BgBake.byteLen(map), 2 do + local entry = (map[i] or 0) + (map[i + 1] or 0) * 256 + hi = math.max(hi, math.floor(entry / 4096) % 16) + end + return hi + 1 +end + +local function closing_screen(rom, palOff, tilesOff, mapOff) + local gfx = lz(rom, tilesOff) + local map = lz(rom, mapOff) + local banks = BgBake.loadPalBanks(rom_bytes(rom, palOff, used_banks(map) * 32)) + return bake_bg4(gfx, banks, map, 32) +end + +-- src/credits.c:1128 +local function bake_circle(rom) + local C = Versions.CREDITS + local tiles = lz(rom, C.circle_tiles) + local map = lz(rom, C.circle_map) + local pal = BgBake.loadPalBanks(rom_bytes(rom, C.circle_pal, 32), 1)[0] + local size = 256 + local tilesPerRow = size / 8 + local out = {} + for y = 0, size - 1 do + for x = 0, size - 1 do + local tile = map[math.floor(y / 8) * tilesPerRow + math.floor(x / 8) + 1] or 0 + local v = tiles[tile * 64 + (y % 8) * 8 + (x % 8) + 1] or 0 + if v == 0 then + out[#out + 1] = "\0\0\0\0" + else + assert(v >= 0xF0, string.format("credits: circle pixel %d outside BG palette 15", v)) + local r, g, b = BgBake.bgr555ToRgb8(pal[v - 0xF0]) + out[#out + 1] = string.char(r, g, b, 255) + end + end + end + return table.concat(out), size +end + +local function mon_palette(rom, species) + local tbl = Versions.INTRO.mon_palette_table + local pal = lz(rom, pointer(rom, tbl + species * 8)) + return BgBake.loadPalBanks(pal, 1)[0] +end + +local function read_windows(rom, off) + local out = {} + for i = 0, 3 do + local b = off + i * 8 + if rom:get(b) == 0xFF then break end + out[#out + 1] = { + bg = rom:get(b), + left = rom:get(b + 1), + top = rom:get(b + 2), + width = rom:get(b + 3), + height = rom:get(b + 4), + palette = rom:get(b + 5), + baseBlock = rom:u16(b + 6), + } + end + assert(#out == 3, "credits: mon scene window templates") + return out +end + +-- src/credits.c:383 +local function read_script(rom) + local C = Versions.CREDITS + local rows = {} + for i = 0, C.script_max - 1 do + local b = C.script + i * 4 + local row = { cmd = rom:get(b), param = rom:get(b + 1), duration = rom:u16(b + 2) } + rows[#rows + 1] = row + if row.cmd == 5 then return rows end + assert(row.cmd <= 5, string.format("credits: unknown script cmd %d", row.cmd)) + end + error("credits: script has no WAITBUTTON") +end + +-- src/overworld.c:2384 +local function read_scene(rom, off) + local rows = {} + local i = 0 + while true do + local b = off + i * SCENE_ROW + local a0, a2, a4 = s16(rom:u16(b)), s16(rom:u16(b + 2)), s16(rom:u16(b + 4)) + if a0 == 0xFD then break end + if a0 == 0xFE then + local n = off + (i + 1) * SCENE_ROW + rows[#rows + 1] = { + op = "loadmap", mapGroup = a2, mapNum = a4, + x = s16(rom:u16(n)), y = s16(rom:u16(n + 2)), delay = s16(rom:u16(n + 4)), + } + i = i + 2 + else + rows[#rows + 1] = { op = "scroll", xspeed = a0, yspeed = a2, length = a4 } + i = i + 1 + end + assert(i < 16, "credits: overworld scene has no end") + end + return rows +end + +local function write_pack(rom, game) + local C = Versions.CREDITS + local L = { + "return {", + string.format(" format_version = %d,", CreditsExtract.FORMAT_VERSION), + string.format(" title = %s,", + quote(decode_text(rom, game == "leafgreen" and C.staff_leafgreen or C.staff_firered))), + " script = {", + } + for _, r in ipairs(read_script(rom)) do + L[#L + 1] = string.format(" { cmd = %d, param = %d, duration = %d },", r.cmd, r.param, r.duration) + end + L[#L + 1] = " }," + L[#L + 1] = " texts = {" + for i = 0, C.text_count - 1 do + local b = C.texts + i * 12 + L[#L + 1] = string.format(" [%d] = { title = %s, names = %s, unused = %s },", i, + quote(decode_text(rom, pointer(rom, b))), quote(decode_text(rom, pointer(rom, b + 4))), + tostring(rom:get(b + 8) ~= 0)) + end + L[#L + 1] = " }," + L[#L + 1] = " scenes = {" + for i = 0, C.scene_count - 1 do + local parts = {} + for _, r in ipairs(read_scene(rom, pointer(rom, C.scenes + i * 4))) do + if r.op == "loadmap" then + parts[#parts + 1] = string.format( + '{ op = "loadmap", mapGroup = %d, mapNum = %d, x = %d, y = %d, delay = %d }', + r.mapGroup, r.mapNum, r.x, r.y, r.delay) + else + parts[#parts + 1] = string.format('{ op = "scroll", xspeed = %d, yspeed = %d, length = %d }', + r.xspeed, r.yspeed, r.length) + end + end + L[#L + 1] = string.format(" [%d] = { %s },", i, table.concat(parts, ", ")) + end + L[#L + 1] = " }," + L[#L + 1] = " spriteParams = {" + for i = 0, C.sprite_param_count - 1 do + local b = C.sprite_params + i * 6 + L[#L + 1] = string.format(" [%d] = { character = %d, ground = %d, motion = %d },", + i, rom:u16(b), rom:u16(b + 2), rom:u16(b + 4)) + end + L[#L + 1] = " }," + L[#L + 1] = " mons = {" + for i, m in ipairs(CreditsExtract.MONS) do + local wins = {} + for _, w in ipairs(read_windows(rom, C[m.windows])) do + wins[#wins + 1] = string.format( + "{ left = %d, top = %d, width = %d, height = %d }", w.left, w.top, w.width, w.height) + end + L[#L + 1] = string.format(" [%d] = { key = %q, species = %d, windows = { %s } },", + i - 1, m.key, m.species, table.concat(wins, ", ")) + end + L[#L + 1] = " }," + L[#L + 1] = "}" + return table.concat(L, "\n") .. "\n" +end + +function CreditsExtract.run(rom, cache, opts) + opts = opts or {} + local C = Versions.CREDITS + local root = (opts.cacheRoot or default_cache_root()) .. "/" .. CreditsExtract.CACHE_SUB + local game = opts.game + if not game then + local ver, why = Versions.lookup(rom.md5) + game = assert(ver and ver.game, "credits: unknown FRLG edition: " .. tostring(why)) + end + local function put(name, body) assert(cache:write(root .. "/" .. name, body)) end + + put("pack.lua", write_pack(rom, game)) + put("copyright.rgba", closing_screen(rom, C.copyright_pal, C.copyright_tiles, C.copyright_map)) + put("the_end.rgba", closing_screen(rom, C.the_end_pal, C.the_end_tiles, C.the_end_map)) + local circle, circleSize = bake_circle(rom) + put("circle.rgba", circle) + + local ballGfx = lz(rom, C.pokeball_tiles) + local ballMap = lz(rom, C.pokeball_map) + local wins = {} + for i, m in ipairs(CreditsExtract.MONS) do + local banks = BgBake.loadPalBanks(rom_bytes(rom, C.pokeball_pals + (i - 1) * 32, 32), 1) + put(string.format("pokeball_%d.rgba", i - 1), bake_bg4(ballGfx, banks, ballMap, 32)) + local pal = mon_palette(rom, m.species) + local tmpl = read_windows(rom, C[m.windows]) + wins[i] = tmpl + for f = 1, 2 do + local w, h = tmpl[f + 1].width * 8, tmpl[f + 1].height * 8 + local gfx = lz(rom, C[m.frames[f]]) + assert(BgBake.byteLen(gfx) == w * h / 2, + string.format("credits: %s is %d bytes, window is %dx%d", m.frames[f], BgBake.byteLen(gfx), w, h)) + put(string.format("mon_%d_%d.rgba", i - 1, f), BgBake.bakeSpriteRgba(gfx, pal, 0, w, h, false, false)) + end + end + + local sheets = {} + for _, s in ipairs(CreditsExtract.SPRITES) do + local gfx = lz(rom, C[s.key .. "_tiles"]) + local tilesPerFrame = (s.w / 8) * (s.h / 8) + assert(BgBake.byteLen(gfx) >= tilesPerFrame * 32 * s.frames, + string.format("credits: %s holds %d bytes", s.key, BgBake.byteLen(gfx))) + local bank = BgBake.loadPalBanks(rom_bytes(rom, C[s.key .. "_pal"], 32), 1)[0] + local parts = {} + for f = 0, s.frames - 1 do + parts[#parts + 1] = BgBake.bakeSpriteRgba(gfx, bank, f * tilesPerFrame, s.w, s.h, false, false) + end + put(s.key .. ".rgba", table.concat(parts)) + sheets[#sheets + 1] = string.format(" %s = { w = %d, h = %d, frames = %d },", s.key, s.w, s.h, s.frames) + end + + local monLines = {} + for i, tmpl in ipairs(wins) do + monLines[#monLines + 1] = string.format( + " [%d] = { frame1 = { w = %d, h = %d }, frame2 = { w = %d, h = %d } },", i - 1, + tmpl[2].width * 8, tmpl[2].height * 8, tmpl[3].width * 8, tmpl[3].height * 8) + end + put("manifest.lua", table.concat({ + "return {", + string.format(" format_version = %d,", CreditsExtract.FORMAT_VERSION), + string.format(" screen = { w = %d, h = %d },", SCREEN_W, SCREEN_H), + string.format(" circle = { w = %d, h = %d },", circleSize, circleSize), + " sheets = {", + table.concat(sheets, "\n"), + " },", + " mons = {", + table.concat(monLines, "\n"), + " },", + "}", + "", + }, "\n")) + + print(string.format("[credits_extract] staff roll, closing screens and scene art -> %s", root)) + return { root = root } +end + +function CreditsExtract.ready(cache, cacheRoot) + local root = (cacheRoot or default_cache_root()) .. "/" .. CreditsExtract.CACHE_SUB + if not (cache and cache.exists) then return false end + for _, rel in ipairs(CreditsExtract.FILES) do + if not cache:exists(root .. "/" .. rel) then return false end + end + return true +end + +return CreditsExtract diff --git a/src/import/gba/editions/leafgreen_1_0.lua b/src/import/gba/editions/leafgreen_1_0.lua index d5afaf47..318aa81a 100644 --- a/src/import/gba/editions/leafgreen_1_0.lua +++ b/src/import/gba/editions/leafgreen_1_0.lua @@ -3,8 +3,62 @@ return { [0x6D000] = 0x6D000, -- field_control_avatar.o:GetInteractedObjectEventScript + 12 [0x6D900] = 0x6D900, -- field_control_avatar.o:GetFacingSignpostType + 104 + [0xA16E0] = 0xA16B4, -- :FieldUseFunc_Medicine + 0 + [0xA16FC] = 0xA16D0, -- :FieldUseFunc_Ether + 0 + [0xA1718] = 0xA16EC, -- :FieldUseFunc_PpUp + 0 + [0xA1734] = 0xA1708, -- :FieldUseFunc_RareCandy + 0 + [0xA1750] = 0xA1724, -- :FieldUseFunc_EvoItem + 0 + [0xA176C] = 0xA1740, -- :FieldUseFunc_SacredAsh + 0 + [0xA1998] = 0xA196C, -- :FieldUseFunc_Repel + 0 + [0xA1A94] = 0xA1A68, -- :FieldUseFunc_BlackWhiteFlute + 0 + [0xA1BAC] = 0xA1B80, -- :ItemUseOutOfBattle_EscapeRope + 0 + [0x160450] = 0x16042C, -- :gStdScripts + 0 + [0x1A5218] = 0x1A51F4, -- :Text_PutItemAway + 0 + [0x1A5231] = 0x1A520D, -- :Text_FoundOneItem + 0 + [0x1A5242] = 0x1A521E, -- :Text_TooBadBagFull + 0 + [0x1A525C] = 0x1A5238, -- :Text_FoundXCoins + 0 + [0x1A526C] = 0x1A5248, -- :Text_PutCoinsAwayInCoinCase + 0 + [0x1A5294] = 0x1A5270, -- :Text_CoinCaseIsFull + 0 + [0x1A52B4] = 0x1A5290, -- :Text_NothingToPutThemIn + 0 + [0x1A5476] = 0x1A5452, -- :gText_PkmnFainted3 + 0 + [0x1A5700] = 0x1A56DC, -- :Text_DugUpItemFromGround + 0 + [0x1A6197] = 0x1A6173, -- :Text_WhitedOutLostMoney + 0 + [0x1A61E5] = 0x1A61C1, -- :Text_WhitedOut + 0 + [0x1A6955] = 0x1A6931, -- :EventScript_PC + 0 + [0x1A698E] = 0x1A696A, -- :EventScript_PCDisabled + 0 + [0x1A6998] = 0x1A6974, -- :EventScript_PCMainMenu + 0 + [0x1A69A8] = 0x1A6984, -- :EventScript_ChoosePCMenu + 0 + [0x1A69F0] = 0x1A69CC, -- :EventScript_AccessPlayersPC + 0 + [0x1A6A05] = 0x1A69E1, -- :EventScript_AccessPokemonStorage + 0 + [0x1A6A34] = 0x1A6A10, -- :EventScript_AccessSomeonesPC + 0 + [0x1A6A3D] = 0x1A6A19, -- :EventScript_AccessBillsPC + 0 + [0x1A6A46] = 0x1A6A22, -- :EventScript_TurnOffPC + 0 + [0x1A6A56] = 0x1A6A32, -- :EventScript_AccessHallOfFame + 0 + [0x1A6A7A] = 0x1A6A56, -- :EventScript_AccessProfOaksPC + 0 + [0x1A6AB2] = 0x1A6A8E, -- :EventScript_ExitOaksPC + 0 + [0x1A6D17] = 0x1A6CF3, -- :PokedexRating_Text_LessThan10 + 0 + [0x1A6D6D] = 0x1A6D49, -- :PokedexRating_Text_LessThan20 + 0 + [0x1A6DDF] = 0x1A6DBB, -- :PokedexRating_Text_LessThan30 + 0 + [0x1A6E36] = 0x1A6E12, -- :PokedexRating_Text_LessThan40 + 0 + [0x1A6EA4] = 0x1A6E80, -- :PokedexRating_Text_LessThan50 + 0 + [0x1A6F0B] = 0x1A6EE7, -- :PokedexRating_Text_LessThan60 + 0 + [0x1A6F71] = 0x1A6F4D, -- :PokedexRating_Text_LessThan70 + 0 + [0x1A6FAB] = 0x1A6F87, -- :PokedexRating_Text_LessThan80 + 0 + [0x1A6FF1] = 0x1A6FCD, -- :PokedexRating_Text_LessThan90 + 0 [0x1A7000] = 0x1A6FDC, -- :PokedexRating_Text_LessThan90 + 15 + [0x1A7031] = 0x1A700D, -- :PokedexRating_Text_LessThan100 + 0 + [0x1A7063] = 0x1A703F, -- :PokedexRating_Text_LessThan110 + 0 + [0x1A70A5] = 0x1A7081, -- :PokedexRating_Text_LessThan120 + 0 + [0x1A70D8] = 0x1A70B4, -- :PokedexRating_Text_LessThan130 + 0 + [0x1A7108] = 0x1A70E4, -- :PokedexRating_Text_LessThan140 + 0 + [0x1A7137] = 0x1A7113, -- :PokedexRating_Text_LessThan150 + 0 + [0x1A7175] = 0x1A7151, -- :PokedexRating_Text_Complete + 0 + [0x1A73E0] = 0x1A73BC, -- :PokedexRating_EventScript_Rate + 0 [0x1A8000] = 0x1A7FDC, -- :PokemonMansion_EventScript_PressSwitch_B1F + 180 + [0x1A8D97] = 0x1A8D73, -- :EventScript_AfterWhiteOutHeal + 0 + [0x1A8DD8] = 0x1A8DB4, -- :EventScript_AfterWhiteOutMomHeal + 0 + [0x1BFB5A] = 0x1BFB36, -- :DayCare_Text_Huh + 0 + [0x1BFB6E] = 0x1BFB4A, -- :Text_RepelWoreOff + 0 [0x1C68F4] = 0x1C68D0, -- :gBattleAnims_Moves + 0 [0x1C6E84] = 0x1C6E60, -- :gBattleAnims_StatusConditions + 0 [0x1C6EA8] = 0x1C6E84, -- :gBattleAnims_General + 0 @@ -20,6 +74,7 @@ return { [0x2350AC] = 0x235088, -- :gMonFrontPicTable + 0 [0x23654C] = 0x236528, -- :gMonBackPicTable + 0 [0x23730C] = 0x2372E8, -- :gMonPaletteTable + 0 + [0x2380CC] = 0x2380A8, -- :gMonShinyPaletteTable + 0 [0x23957C] = 0x239558, -- :gTrainerFrontPicTable + 0 [0x239A1C] = 0x2399F8, -- :gTrainerFrontPicPaletteTable + 0 [0x239FA4] = 0x239F80, -- :gTrainerBackPicTable + 0 @@ -40,6 +95,8 @@ return { [0x250C04] = 0x250BE0, -- :gBattleMoves + 0 [0x251EE0] = 0x251EBC, -- pokemon.o:sSpeciesToHoennPokedexNum + 552 [0x251FEE] = 0x251FCA, -- pokemon.o:sSpeciesToNationalPokedexNum + 0 + [0x25265C] = 0x252638, -- pokemon.o:sSpindaSpotGraphics + 0 + [0x2528BC] = 0x252898, -- :gItemEffectTable + 0 [0x252BC8] = 0x252BA4, -- pokemon.o:sTMHMLearnsets + 0 [0x2538A8] = 0x253884, -- :gFacilityClassToPicIndex + 0 [0x25393E] = 0x25391A, -- :gFacilityClassToTrainerClass + 0 @@ -105,6 +162,8 @@ return { [0x2A35AC] = 0x2A358C, -- :gMetatileAttributes_ViridianCity + 0 [0x2A3728] = 0x2A3708, -- :gMetatiles_PewterCity + 0 [0x2A3C18] = 0x2A3BF8, -- :gMetatileAttributes_PewterCity + 0 + [0x2A65F4] = 0x2A65D4, -- :gMetatiles_CeladonCity + 0 + [0x2A78B4] = 0x2A7894, -- :gMetatiles_FuchsiaCity + 0 [0x2AAA14] = 0x2AA9F4, -- :gMetatiles_PewterGym + 0 [0x2AB064] = 0x2AB044, -- :gMetatileAttributes_PewterGym + 0 [0x2AD7B4] = 0x2AD794, -- :gMetatiles_Building + 0 @@ -115,10 +174,15 @@ return { [0x2B4FCC] = 0x2B4FAC, -- :gMetatileAttributes_GenericBuilding1 + 0 [0x2B68A0] = 0x2B6880, -- :gMetatiles_Lab + 0 [0x2B7390] = 0x2B7370, -- :gMetatileAttributes_Lab + 0 + [0x2B90B4] = 0x2B9094, -- :gMetatiles_ViridianForest + 0 + [0x2BB51C] = 0x2BB4FC, -- :gMetatiles_SeafoamIslands + 0 [0x2BEF14] = 0x2BEEF4, -- :gMetatiles_GenericBuilding2 + 0 [0x2BFA94] = 0x2BFA74, -- :gMetatileAttributes_GenericBuilding2 + 0 + [0x2C0968] = 0x2C0948, -- :gMetatiles_SeaCottage + 0 + [0x2C0E04] = 0x2C0DE4, -- :gMetatiles_SilphCo + 0 [0x2CD1CC] = 0x2CD1AC, -- :gMetatiles_SeviiIslands123 + 0 [0x2CE39C] = 0x2CE37C, -- :gMetatileAttributes_SeviiIslands123 + 0 + [0x2CF3F0] = 0x2CF3D0, -- :gMetatiles_SeviiIslands67 + 0 [0x2D1D30] = 0x2D1D10, -- :gMetatiles_IslandHarbor + 0 [0x2D2220] = 0x2D2200, -- :gMetatileAttributes_IslandHarbor + 0 [0x2D50FC] = 0x2D50DC, -- :PalletTown_PlayersHouse_1F_Layout_Blockdata + 0 @@ -163,11 +227,13 @@ return { [0x36D888] = 0x36D868, -- :gObjectEventPal_NpcWhite + 0 [0x38A428] = 0x38A408, -- :gObjectEventPic_Blue + 0 [0x3947A8] = 0x394788, -- :gObjectEventPic_RockSmashRock + 0 + [0x395AE8] = 0x395AC8, -- :gObjectEventPal_SSAnne + 0 [0x396B08] = 0x396AE8, -- :gObjectEventPic_SurfBlob + 0 [0x398648] = 0x398628, -- :gFieldEffectObjectPic_CutGrass + 0 [0x3986A8] = 0x398688, -- :gFieldEffectObjectPic_Ripple + 0 [0x398FA8] = 0x398F88, -- :gFieldEffectObjectPalette0 + 0 [0x398FC8] = 0x398FA8, -- :gFieldEffectObjectPalette1 + 0 + [0x399008] = 0x398FE8, -- :gFieldEffectObjectPic_GroundImpactDust + 0 [0x39A008] = 0x399FE8, -- :gFieldEffectObjectPic_TallGrass + 0 [0x39AC48] = 0x39AC28, -- :gFieldEffectObjectPic_Splash + 0 [0x39C508] = 0x39C4E8, -- :gFieldEffectObjectPic_HotSpringsWater + 0 @@ -192,6 +258,12 @@ return { [0x3CAF90] = 0x3CADCC, -- field_effect.o:sPokeballGlow_Gfx + 0 [0x3CAFB0] = 0x3CADEC, -- field_effect.o:sPokeballGlow_Pal + 0 [0x3CAFD0] = 0x3CAE0C, -- field_effect.o:sPokecenterMonitor_Gfx + 0 + [0x3CB5F0] = 0x3CB42C, -- field_effect.o:sFieldMoveStreaksOutdoors_Gfx + 0 + [0x3CB7F0] = 0x3CB62C, -- field_effect.o:sFieldMoveStreaksOutdoors_Pal + 0 + [0x3CB810] = 0x3CB64C, -- field_effect.o:sFieldMoveStreaksOutdoors_Tilemap + 0 + [0x3CBA90] = 0x3CB8CC, -- field_effect.o:sFieldMoveStreaksIndoors_Gfx + 0 + [0x3CBB10] = 0x3CB94C, -- field_effect.o:sFieldMoveStreaksIndoors_Pal + 0 + [0x3CBB30] = 0x3CB96C, -- field_effect.o:sFieldMoveStreaksIndoors_Tilemap + 0 [0x3CBDB0] = 0x3CBBEC, -- field_effect.o:sRockFragment_TopLeft + 0 [0x3CC2E4] = 0x3CC120, -- option_menu.o:sOptionMenuPalette + 0 [0x3CC368] = 0x3CC1A4, -- trainer_card.o:sTrainerCardStickers_Gfx + 0 @@ -232,6 +304,7 @@ return { [0x3D4294] = 0x3D40D0, -- item_menu_icons.o:sItemIconTable + 0 [0x3DB028] = 0x3DAE64, -- :gItems + 0 [0x3E04B0] = 0x3E02EC, -- script_menu.o:sMultichoiceLists + 0 + [0x3E06B8] = 0x3E04F4, -- :gStdStringPtrs + 0 [0x3E0780] = 0x3E05BC, -- script_menu.o:sMuseumAerodactylSprTiles + 0 [0x3E0F80] = 0x3E0DBC, -- script_menu.o:sMuseumAerodactylSprPalette + 0 [0x3E0FA0] = 0x3E0DDC, -- script_menu.o:sMuseumKabutopsSprTiles + 0 @@ -269,6 +342,10 @@ return { [0x3F1B3C] = 0x3F1978, -- region_map.o:sDungeonInfo + 0 [0x3F1CAC] = 0x3F1AE8, -- region_map.o:sMapNames + 0 [0x3F2EE0] = 0x3F2D1C, -- region_map.o:sMapFlyDestinations + 0 + [0x3F5F50] = 0x3F5D8C, -- field_specials.o:sEliteFourLightingPalettes + 0 + [0x3F60D0] = 0x3F5F0C, -- field_specials.o:sChampionRoomLightingPalettes + 0 + [0x3F61F0] = 0x3F602C, -- field_specials.o:sEliteFourLightingTimers + 0 + [0x3F61FB] = 0x3F6037, -- field_specials.o:sChampionRoomLightingTimers + 0 [0x3F6346] = 0x3F6182, -- field_specials.o:sDeoxysObjectPals + 320 [0x3F6388] = 0x3F61C4, -- battle_records.o:sTiles + 0 [0x3F6448] = 0x3F6284, -- battle_records.o:sPalette + 0 @@ -340,8 +417,127 @@ return { [0x40BF48] = 0x40BD84, -- :gBallParticleSpritesheets + 0 [0x40BFA8] = 0x40BDE4, -- :gBallParticlePalettes + 0 [0x40C1C4] = 0x40C000, -- battle_anim_special.o:sBallOpenFadeColors + 0 + [0x40C39C] = 0x40C1D8, -- hall_of_fame.o:sHallOfFame_Pal + 0 + [0x40C3BC] = 0x40C1F8, -- hall_of_fame.o:sHallOfFame_Gfx + 0 + [0x40C5B0] = 0x40C3EC, -- credits.o:sWindowTemplates_Charizard + 0 + [0x40C5D0] = 0x40C40C, -- credits.o:sWindowTemplates_Venusaur + 0 + [0x40C5F0] = 0x40C42C, -- credits.o:sWindowTemplates_Blastoise + 0 + [0x40C610] = 0x40C44C, -- credits.o:sWindowTemplates_Pikachu + 0 + [0x40C630] = 0x40C46C, -- credits.o:sCreditsMonCircle_Pal + 0 + [0x40C650] = 0x40C48C, -- credits.o:sCreditsMonCircle_Tiles + 0 + [0x40CA54] = 0x40C890, -- credits.o:sCreditsMonCircle_Tilemap + 0 + [0x40CB8C] = 0x40C9C8, -- credits.o:sCharizard1_Tiles + 0 + [0x40D228] = 0x40D064, -- credits.o:sCharizard2_Tiles + 0 + [0x40E158] = 0x40DF94, -- credits.o:sVenusaur1_Tiles + 0 + [0x40E904] = 0x40E740, -- credits.o:sVenusaur2_Tiles + 0 + [0x40F240] = 0x40F07C, -- credits.o:sBlastoise1_Tiles + 0 + [0x40F944] = 0x40F780, -- credits.o:sBlastoise2_Tiles + 0 + [0x410198] = 0x40FFD4, -- credits.o:sPikachu1_Tiles + 0 + [0x4105B4] = 0x4103F0, -- credits.o:sPikachu2_Tiles + 0 + [0x410B00] = 0x41093C, -- credits.o:sTheEnd_Pal + 0 + [0x410B20] = 0x41095C, -- credits.o:sTheEnd_Tiles + 0 + [0x410B94] = 0x4109D0, -- credits.o:sTheEnd_Tilemap + 0 + [0x410CF4] = 0x410B30, -- credits.o:sCreditsScript + 0 + [0x410E10] = 0x410C4C, -- credits.o:sPlayerMale_Pal + 0 + [0x410E30] = 0x410C6C, -- credits.o:sPlayerMale_Tiles + 0 + [0x411BF8] = 0x411A34, -- credits.o:sPlayerFemale_Pal + 0 + [0x411C18] = 0x411A54, -- credits.o:sPlayerFemale_Tiles + 0 + [0x4129A0] = 0x4127DC, -- credits.o:sRival_Pal + 0 + [0x4129C0] = 0x4127FC, -- credits.o:sRival_Tiles + 0 + [0x413318] = 0x413154, -- credits.o:sGround_Grass_Pal + 0 + [0x413338] = 0x413174, -- credits.o:sGround_Grass_Tiles + 0 + [0x413854] = 0x413690, -- credits.o:sGround_Dirt_Pal + 0 + [0x413874] = 0x4136B0, -- credits.o:sGround_Dirt_Tiles + 0 + [0x413D98] = 0x413BD4, -- credits.o:sGround_City_Pal + 0 + [0x413DB8] = 0x413BF4, -- credits.o:sGround_City_Tiles + 0 + [0x41431C] = 0x414158, -- credits.o:sPlayerRivalSpriteParams + 0 + [0x414588] = 0x4143C4, -- credits.o:sOverworldMapScenes + 0 + [0x4145BC] = 0x4143F8, -- credits.o:sCreditsTexts + 0 + [0x4147C0] = 0x4145FC, -- diploma.o:sDiplomaGfx + 0 + [0x4154E8] = 0x415324, -- diploma.o:sDiplomaTilemap + 0 + [0x415954] = 0x415790, -- diploma.o:sDiplomaPal + 0 + [0x415A62] = 0x41589E, -- :gText_EggNickname + 0 + [0x415CE8] = 0x415B24, -- :gText_MainMenuTime + 0 + [0x415D60] = 0x415B9C, -- :gText_UPDOWNPick_ABUTTONNext_BBUTTONBack + 0 + [0x415D78] = 0x415BB4, -- :gText_UPDOWNPick_ABUTTONBBUTTONCancel + 0 + [0x415D8C] = 0x415BC8, -- :gText_ABUTTONExit + 0 + [0x416008] = 0x415E44, -- :gText_WelcomeToHOF + 0 + [0x416090] = 0x415ECC, -- :gText_HOFCorrupted + 0 + [0x4160B4] = 0x415EF0, -- :gText_HOFNumber + 0 + [0x4160C8] = 0x415F04, -- :gText_LeagueChamp + 0 + [0x4160EC] = 0x415F28, -- :gText_Number + 0 + [0x4160F4] = 0x415F30, -- :gText_Level + 0 + [0x4160FC] = 0x415F38, -- :gText_Name + 0 + [0x416104] = 0x415F40, -- :gText_IDNumber + 0 + [0x416262] = 0x41609E, -- :gText_WhatWouldYouLikeToDo + 0 + [0x416285] = 0x4160C1, -- :gText_MenuBag + 0 + [0x41632A] = 0x416166, -- :gText_ThereIsNoPokemon + 0 + [0x416476] = 0x4162B2, -- :gText_ItemfinderResponding + 0 + [0x4164BE] = 0x4162FA, -- :gText_ItemfinderShakingWildly + 0 + [0x416513] = 0x41634F, -- :gText_NopeTheresNoResponse + 0 + [0x4165D2] = 0x41640E, -- :gText_UsedVar2WildLured + 0 + [0x416600] = 0x41643C, -- :gText_UsedVar2WildRepelled + 0 + [0x4166E1] = 0x41651D, -- :gText_TMCaseWillBePutAway + 0 + [0x416716] = 0x416552, -- :gText_TheBerryPouchWillBePutAway + 0 + [0x4167E7] = 0x416623, -- :gText_HereYouGoThankYou + 0 + [0x41689E] = 0x4166DA, -- :gText_AnythingElseICanHelp + 0 + [0x416D78] = 0x416BB4, -- :gText_BagFullCouldNotRemoveItem + 0 + [0x417094] = 0x416ED0, -- :gText_OnlyPkmnForBattle + 0 + [0x4170BC] = 0x416EF8, -- :gText_PkmnCantBeTradedNow + 0 + [0x4170E0] = 0x416F1C, -- :gText_EggCantBeTradedNow + 0 + [0x4170FC] = 0x416F38, -- :gText_OtherTrainersPkmnCantBeTraded + 0 + [0x4171DF] = 0x41701B, -- :gText_ChoosePokemon + 0 + [0x4171F1] = 0x41702D, -- :gText_MoveToWhere + 0 + [0x417200] = 0x41703C, -- :gText_TeachWhichPokemon + 0 + [0x417215] = 0x417051, -- :gText_UseOnWhichPokemon + 0 + [0x41722B] = 0x417067, -- :gText_GiveToWhichPokemon + 0 + [0x417281] = 0x4170BD, -- :gText_AlreadySurfing + 0 + [0x417299] = 0x4170D5, -- :gText_CantUseHere + 0 + [0x4172AE] = 0x4170EA, -- :gText_RestoreWhichMove + 0 + [0x4172C2] = 0x4170FE, -- :gText_BoostPp + 0 + [0x4172D5] = 0x417111, -- :gText_DoWhatWithItem + 0 + [0x4172EB] = 0x417127, -- :gText_NoPokemonForBattle + 0 + [0x4176CF] = 0x41750B, -- :gText_CancelBattle + 0 + [0x41778A] = 0x4175C6, -- :gText_NoMoreRoomInBag + 0 + [0x4178F0] = 0x41772C, -- :gText_HallOfFame + 0 + [0x417BCB] = 0x417A07, -- :gText_LogOff + 0 + [0x417BD3] = 0x417A0F, -- :gText_ProfOakSPc + 0 + [0x4181C3] = 0x417FFF, -- :gFameCheckerText_FameCheckerWillBeClosed + 0 + [0x418408] = 0x418244, -- :gText_BagIsFull2 + 0 + [0x41856C] = 0x4183A8, -- :gText_WithdrawPokemon + 0 + [0x41857D] = 0x4183B9, -- :gText_DepositPokemon + 0 + [0x41858D] = 0x4183C9, -- :gText_MovePokemon + 0 + [0x41859A] = 0x4183D6, -- :gText_MoveItems + 0 + [0x4185A5] = 0x4183E1, -- :gText_SeeYa + 0 + [0x4185AD] = 0x4183E9, -- :gText_WithdrawMonDescription + 0 + [0x4185E2] = 0x41841E, -- :gText_DepositMonDescription + 0 + [0x418611] = 0x41844D, -- :gText_MoveMonDescription + 0 + [0x418642] = 0x41847E, -- :gText_MoveItemsDescription + 0 + [0x418681] = 0x4184BD, -- :gText_SeeYaDescription + 0 + [0x418690] = 0x4184CC, -- :gText_JustOnePkmn + 0 + [0x4186B0] = 0x4184EC, -- :gText_PartyFull + 0 + [0x419F54] = 0x419D90, -- :gText_SavingDontTurnOffThePower2 + 0 [0x41A000] = 0x419E3C, -- :gStartMenuDesc_Pokemon + 69 [0x41A500] = 0x41A33C, -- :gText_QuestLog_MultiBattleWithPeopleResultedInOutcome + 58 + [0x41B554] = 0x41B390, -- :gText_PlayerScurriedToCenter + 0 + [0x41B5B6] = 0x41B3F2, -- :gText_PlayerScurriedBackHome + 0 + [0x41B60E] = 0x41B44A, -- :gText_Diploma_Player + 0 + [0x41B619] = 0x41B455, -- :gText_Diploma_ThisDocument + 0 + [0x41B684] = 0x41B4C0, -- :gText_Diploma_GameFreak + 0 + [0x41B68F] = 0x41B4CB, -- :gText_Diploma_National + 0 + [0x41B698] = 0x41B4D4, -- :gText_Diploma_Kanto + 0 + [0x41B747] = 0x41B583, -- :gString_OutOfCoins + 0 + [0x41B76B] = 0x41B5A7, -- :gString_QuitPlaying + 0 + [0x41CC64] = 0x41CAA0, -- :gText_CommunicationStandby4 + 0 + [0x41D14E] = 0x41CF8A, -- :gText_PokemonOnHook + 0 + [0x41D169] = 0x41CFA5, -- :gText_NotEvenANibble + 0 + [0x41D17E] = 0x41CFBA, -- :gText_ItGotAway + 0 + [0x41D198] = 0x41CFD4, -- :gString_PokemonFireRed_Staff + 0 + [0x41D1B8] = 0x41CFF4, -- :gString_PokemonLeafGreen_Staff + 0 + [0x41E33C] = 0x41E178, -- :gDaycareText_GetAlongVeryWell + 0 + [0x41E361] = 0x41E19D, -- :gDaycareText_GetAlong + 0 + [0x41E37C] = 0x41E1B8, -- :gDaycareText_DontLikeOther + 0 + [0x41E3A8] = 0x41E1E4, -- :gDaycareText_PlayOther + 0 [0x41E5E9] = 0x41E425, -- :gFameCheckerOakName + 0 [0x41E5ED] = 0x41E429, -- :gFameCheckerDaisyName + 0 [0x41E5F3] = 0x41E42F, -- :gFameCheckerBillName + 0 @@ -474,6 +670,7 @@ return { [0x464258] = 0x463C78, -- wild_pokemon_area.o:sDexAreas_Sevii5 + 0 [0x464274] = 0x463C94, -- wild_pokemon_area.o:sDexAreas_Sevii6 + 0 [0x464290] = 0x463CB0, -- wild_pokemon_area.o:sDexAreas_Sevii7 + 0 + [0x4644D0] = 0x463EF0, -- itemfinder.o:sArrowAndStarSpriteTiles + 0 [0x464974] = 0x464394, -- slot_machine.o:sReelIcons_Pal + 0 [0x464A14] = 0x464434, -- slot_machine.o:sReelIcons_Tiles + 0 [0x46504C] = 0x464A1C, -- slot_machine.o:sClefairy_Pal + 0 @@ -512,6 +709,8 @@ return { [0x471E4C] = 0x471728, -- :gTextWindowPalettes + 96 [0x471E8C] = 0x471768, -- :gUserFrames + 0 [0x479590] = 0x478E6C, -- teachy_tv.o:sBg1EndGraphic + 0 + [0x479838] = 0x479114, -- ss_anne.o:sWakeTiles + 0 + [0x479A38] = 0x479314, -- ss_anne.o:sSmokeTiles + 0 [0x479ED8] = 0x4797B4, -- trainer_tower.o:sSingleBattleTrainerInfo + 0 [0x47A024] = 0x479900, -- trainer_tower.o:sDoubleBattleTrainerInfo + 0 [0x47A074] = 0x479950, -- trainer_tower.o:sTrainerEncounterMusicLUT + 0 @@ -552,6 +751,8 @@ return { [0xD2D090] = 0xD2D090, -- :gSubstituteDollPal + 0 [0xD2D0B4] = 0xD2D0B4, -- :gSubstituteDollFrontGfx + 0 [0xD2D2F4] = 0xD2D2F4, -- :gSubstituteDollBackGfx + 0 + [0xD2D5FC] = 0xD2D5FC, -- :gFile_graphics_misc_confetti_sheet + 0 + [0xD2D71C] = 0xD2D71C, -- :gFile_graphics_misc_confetti_palette + 0 [0xD2D8F4] = 0xD2D8F4, -- :gBattleStatMask_Gfx + 0 [0xD2DB04] = 0xD2DB04, -- :gBattleStatMask1_Tilemap + 0 [0xD2DC20] = 0xD2DC20, -- :gBattleStatMask2_Tilemap + 0 @@ -674,6 +875,9 @@ return { [0xEAA9F0] = 0xEAAA70, -- :gUnionRoomChat_Panel_Pal + 0 [0xEAAA10] = 0xEAAA90, -- :gUnionRoomChat_Panel_Gfx + 0 [0xEAAA6C] = 0xEAAAEC, -- :gUnionRoomChat_Panel_Tilemap + 0 + [0xEAAB18] = 0xEAAB98, -- :gCreditsMonPokeball_Pals + 0 + [0xEAAB98] = 0xEAAC18, -- :gCreditsMonPokeball_Tiles + 0 + [0xEAB30C] = 0xEAB38C, -- :gCreditsMonPokeball_Tilemap + 0 [0xEAB6C4] = 0xEAB744, -- :gGraphics_TitleScreen_GameTitleLogoPals + 0 [0xEAB8C4] = 0xEAB944, -- :gGraphics_TitleScreen_GameTitleLogoTiles + 0 [0xEAD390] = 0xEAD508, -- :gGraphics_TitleScreen_GameTitleLogoMap + 0 @@ -683,6 +887,9 @@ return { [0xEAE094] = 0xEAE388, -- :gGraphics_TitleScreen_BackgroundPals + 0 [0xEAE0B4] = 0xEAE3A8, -- :gGraphics_TitleScreen_CopyrightPressStartTiles + 0 [0xEAE374] = 0xEAE668, -- :gGraphics_TitleScreen_CopyrightPressStartMap + 0 + [0xEAE528] = 0xEAE81C, -- :gCreditsCopyright_Pal + 0 + [0xEAE548] = 0xEAE83C, -- :gCreditsCopyright_Tiles + 0 + [0xEAE900] = 0xEAEBF4, -- :gCreditsCopyright_Tilemap + 0 [0xEAEA20] = 0xEAED14, -- :gTradeGba2_Pal + 0 [0xEAEA80] = 0xEAED74, -- :gTradeGba_Gfx + 0 [0x8075D9D] = 0x8075D9D, -- :AnimSpriteOnMonPos + 1 diff --git a/src/import/gba/extract_island1.lua b/src/import/gba/extract_island1.lua index 03de4c68..9e3ee9c2 100644 --- a/src/import/gba/extract_island1.lua +++ b/src/import/gba/extract_island1.lua @@ -166,6 +166,9 @@ local function unique_mids_by_pair(grids, scriptMids) end end end + for pair, seen in pairs(byPair) do + require("src.import.gba.native_pack").addDynamicMids(seen, pair) + end local NativePack = require("src.import.gba.native_pack") local out = {} for pair, seen in pairs(byPair) do diff --git a/src/import/gba/extract_map_events.lua b/src/import/gba/extract_map_events.lua index 5ff71bd9..3926aed9 100644 --- a/src/import/gba/extract_map_events.lua +++ b/src/import/gba/extract_map_events.lua @@ -82,6 +82,8 @@ local function parse_objects(rom, ptr, count) y = y, elevation = elev, movementType = movementType, + rangeX = rangeX, + rangeY = rangeY, movement = host.movement, range = host.range, radius = host.radius, diff --git a/src/import/gba/extract_scripts.lua b/src/import/gba/extract_scripts.lua index e5d23b37..4e931dbc 100644 --- a/src/import/gba/extract_scripts.lua +++ b/src/import/gba/extract_scripts.lua @@ -1,7 +1,5 @@ --- Write game3 script/event cache blobs from ROM MapEvents + BFS (primary), --- or curated island1_content (dev/CI fallback when no ROM extract). +-- Write game3 script/event cache blobs from ROM MapEvents + BFS. -local Content = require("src.import.gba.island1_content") local Disasm = require("src.core.game3.scripting.disasm") local TextIR = require("src.core.game3.scripting.text_ir") local Movement = require("src.core.game3.scripting.movement") @@ -16,7 +14,7 @@ ExtractScripts.CACHE_SUB = "scripts" local SCRIPT_CHUNK = 8192 local TEXT_MAX = 1024 local MOVE_MAX = 256 -local BFS_MAX = 4000 +local BFS_MAX = 16000 local function json_escape(s) return (tostring(s):gsub("\\", "\\\\"):gsub('"', '\\"'):gsub("\n", "\\n")) @@ -64,27 +62,6 @@ local function serialize_lua(val, indent) return table.concat(parts) end -local function merge_std_scripts() - local scripts = {} - for k, v in pairs(Content.STDSCRIPTS) do scripts[k] = v end - return scripts -end - -local function merge_std_text() - local text = {} - for k, v in pairs(Content.TEXT) do - -- Prefer extracted text; curated TEXT is only used for std nurse/PC labels - -- that are not in ROM BFS (EventScript_PC host path). - if type(k) == "string" and (k:find("^Text_", 1, true) or k:find("^EventScript", 1, true)) then - text[k] = v - end - end - -- Always include Std nurse/PC text keys. - local Std = require("src.core.game3.scripting.stdscripts") - for k, v in pairs(Std.TEXT) do text[k] = v end - return text -end - local function is_rom_ptr(ptr) ptr = tonumber(ptr) or 0 return ptr >= 0x08000000 and ptr < 0x0A000000 @@ -282,11 +259,11 @@ function ExtractScripts.bfsFromSeeds(rom, seedPtrs) if row.op == "end" or row.op == "return" then break end - -- gotostd / callstd — std overlay covers runtime; no ROM enqueue end scripts[key] = rows ::continue:: end + assert(#queue == 0, "script BFS stopped at BFS_MAX with " .. #queue .. " scripts queued") return { scripts = scripts, @@ -299,14 +276,38 @@ function ExtractScripts.bfsFromSeeds(rom, seedPtrs) } end ---- Full Island 1 extract: MapEvents + BFS + stdscripts overlay. function ExtractScripts.extractFromRom(rom, version) local events, seeds = ExtractMapEvents.extractIsland1(rom, version) + local aliases = {} + -- data/event_scripts.s:77 + for i = 0, Versions.STD_SCRIPTS_COUNT - 1 do + local ptr = rom:u32(Versions.STD_SCRIPTS + i * 4) + assert(rom:ptrOffset(ptr), "gStdScripts entry " .. i .. " is not a ROM pointer") + seeds[#seeds + 1] = ptr + aliases["std:" .. i] = Opcodes.key(ptr) + end + -- data/scripts/pc.inc:1 + for name, off in pairs(Versions.NAMED_SCRIPTS) do + local ptr = 0x08000000 + off + seeds[#seeds + 1] = ptr + aliases[name] = Opcodes.key(ptr) + end local bfs = ExtractScripts.bfsFromSeeds(rom, seeds) - local scripts = merge_std_scripts() + local scripts = {} for k, v in pairs(bfs.scripts) do scripts[k] = v end - local text = merge_std_text() + for name, key in pairs(aliases) do + scripts[name] = assert(scripts[key], "ROM script " .. name .. " was not extracted") + end + local text = {} for k, v in pairs(bfs.text) do text[k] = v end + for name, off in pairs(Versions.NAMED_TEXTS) do + text[name] = assert(read_text_ir(rom, 0x08000000 + off), "ROM text " .. name .. " is not readable") + end + -- src/script_menu.c:574 + for i = 0, Versions.STD_STRING_COUNT - 1 do + local ptr = rom:u32(Versions.STD_STRING_PTRS + i * 4) + text["stdstring:" .. i] = assert(read_text_ir(rom, ptr), "gStdStringPtrs entry " .. i .. " is not a ROM pointer") + end local movements = {} for k, v in pairs(bfs.movements) do movements[k] = v end return { @@ -329,7 +330,6 @@ local function write_tables(cache, root, scripts, text, movements, events, metaE cache:write(base .. "/scripts.lua", "return " .. serialize_lua(scripts) .. "\n") cache:write(base .. "/text.lua", "return " .. serialize_lua(text) .. "\n") cache:write(base .. "/movements.lua", "return " .. serialize_lua(movements) .. "\n") - cache:write(base .. "/stdscripts.lua", "return " .. serialize_lua(merge_std_scripts()) .. "\n") cache:write(base .. "/events.lua", "return " .. serialize_lua(events) .. "\n") local meta = { cache_version = Versions.CACHE_VERSION, @@ -404,20 +404,6 @@ function ExtractScripts.writeBundleFromRom(rom, cache, root, version, extracted) return bundle end ---- Dev/CI fallback: curated island1_content (no ROM). -function ExtractScripts.writeBundle(cache, root) - local scripts = merge_std_scripts() - for k, v in pairs(Content.SCRIPTS) do scripts[k] = v end - write_tables(cache, root, scripts, Content.TEXT, Content.MOVEMENTS or {}, Content.EVENTS, { - source = "curated", - }) - do - local FlagsExtract = require("src.import.gba.flags_extract") - FlagsExtract.write(cache, root) - end - return true -end - --- Cache contract: ready extract has events+scripts+text. function ExtractScripts.bundleReady(bundle) if not bundle then return false, "nil bundle" end @@ -458,22 +444,12 @@ function ExtractScripts.loadBundle(cache, root, opts) for k,v in pairs(objects.text or {}) do text[k]=v end for k,v in pairs(objects.movements or {}) do movements[k]=v end end - -- Overlay stdscripts always (nurse/PC host arms). - for k, v in pairs(merge_std_scripts()) do - scripts[k] = v - end - local stdText = merge_std_text() - text = text or {} - for k, v in pairs(stdText) do - if text[k] == nil then text[k] = v end - end local bundle = { scripts = scripts, - text = text, + text = text or {}, movements = movements or {}, events = events, fromCache = true, - fromCurated = false, } do local Marts = require("src.core.game3.marts") @@ -482,35 +458,18 @@ function ExtractScripts.loadBundle(cache, root, opts) end local ok, why = ExtractScripts.bundleReady(bundle) if not ok and not opts.allowIncomplete then - -- Incomplete cache: refuse silent curated substitution for "normal" boots. if opts.strict then return nil, why end end return bundle end - -- No cache: curated fallback (CI / no ROM). - if opts.forbidCurated then - return nil, "extract cache missing" - end - return { - scripts = (function() - local s = merge_std_scripts() - for k, v in pairs(Content.SCRIPTS) do s[k] = v end - return s - end)(), - text = Content.TEXT, - movements = Content.MOVEMENTS or {}, - events = Content.EVENTS, - fromCache = false, - fromCurated = true, - } + return nil, "extract cache missing" end ExtractScripts.Disasm = Disasm ExtractScripts.TextIR = TextIR ExtractScripts.Movement = Movement -ExtractScripts.Content = Content ExtractScripts.serialize_lua = serialize_lua return ExtractScripts diff --git a/src/import/gba/field_effect_extract.lua b/src/import/gba/field_effect_extract.lua index 0bf65bd1..c099641b 100644 --- a/src/import/gba/field_effect_extract.lua +++ b/src/import/gba/field_effect_extract.lua @@ -96,6 +96,41 @@ local function bake_pal(rom, palOff) return table.concat(bytes) end +-- pokefirered/src/field_effect.c:2738 +local function bake_streaks(rom, spec) + local cols, rows = 32, 10 + local w, h = cols * 8, rows * 8 + local rgb = load_palette(rom, spec.pal) + local tiles = {} + for t = 0, (spec.tiles or 16) - 1 do + tiles[t] = decode_frame(rom, spec.gfx + t * 32, 8, 8) + end + local out = {} + for i = 1, w * h do out[i] = "\0\0\0\0" end + for r = 0, rows - 1 do + for c = 0, cols - 1 do + local e = rom:u16(spec.tilemap + (r * cols + c) * 2) + local tile = tiles[e % 1024] + local hflip = math.floor(e / 1024) % 2 == 1 + local vflip = math.floor(e / 2048) % 2 == 1 + if tile then + for y = 0, 7 do + for x = 0, 7 do + local sx = hflip and (7 - x) or x + local sy = vflip and (7 - y) or y + local idx = tile[sy * 8 + sx + 1] or 0 + if idx ~= 0 then + local col = rgb[idx] + out[(r * 8 + y) * w + c * 8 + x + 1] = string.char(col[1], col[2], col[3], 255) + end + end + end + end + end + end + return table.concat(out), w, h +end + function FieldEffectExtract.writeExtract(rom, cache, root, version) root = root or "data/generated/gba" version = version or {} @@ -127,6 +162,17 @@ function FieldEffectExtract.writeExtract(rom, cache, root, version) end end + for kind, spec in pairs(Versions.FIELD_MOVE_STREAKS or {}) do + local name = "field_move_streaks_" .. kind + local rgba, w, h = bake_streaks(rom, spec) + cache:write(rel .. "/" .. name .. ".rgba", rgba) + cache:write(rel .. "/" .. name .. ".meta", string.format( + "return { w = %d, h = %d, frames = 1, fw = %d, fh = %d, indexed = false, format = %d }\n", + w, h, w, h, FieldEffectExtract.FORMAT_VERSION)) + log(string.format("%s %dx%d → %s", name, w, h, rel)) + results[name] = { path = rel .. "/" .. name .. ".rgba", w = w, h = h, frames = 1 } + end + return results end diff --git a/src/import/gba/island1_content.lua b/src/import/gba/island1_content.lua deleted file mode 100644 index e8964dd0..00000000 --- a/src/import/gba/island1_content.lua +++ /dev/null @@ -1,402 +0,0 @@ --- Curated One Island (FRLG) events / scripts / text / movements for game3 v1. --- Shared nurse/PC scripts live in stdscripts.lua (behavior-type, not map coords). - -local TextIR = require("src.core.game3.scripting.text_ir") -local Flags = require("src.core.game3.scripting.flags") -local Movement = require("src.core.game3.scripting.movement") -local Opcodes = require("src.core.game3.scripting.opcodes") -local Std = require("src.core.game3.scripting.stdscripts") - -local Content = {} - -local F = Flags.IDS -local STD = Opcodes.STD - -local function T(ascii) - -- Long strings may use real newlines; pret IR wants \n / \p / \l sequences. - ascii = ascii:gsub("^\n", ""):gsub("\r\n", "\n"):gsub("\n", "\\n") - return TextIR.fromAscii(ascii) -end - -Content.TEXT = { - OneIsland_Text_LuckyToHaveCelioHere = T([[ -Here we are on an island smack in -the middle of nowhere.\pWe're lucky to have an upstanding -young man like CELIO here.\pMy granddaughter was in a tizzy -over CELIO's friend.\pSomething about a famous -POKéMANIAC?\pI'm not sure what that means, but -CELIO is quite the man!]]), - OneIsland_Text_HavePCLinkageWithKanto = T([[ -My granddaughter was in a tizzy -over a new breakthrough.\pShe said we now have PC linkage -with people in KANTO.\pI'm not sure what that means, but -CELIO is quite the man!]]), - OneIsland_Text_HavePCLinkageWithHoenn = T([[ -My granddaughter was in a tizzy -over a new breakthrough.\pShe said we finally have PC linkage -with people in HOENN.\pI'm not sure what that means, but -CELIO is quite the man.\pHe would make a fine husband for -my granddaughter!]]), - OneIsland_Text_IsntWarmClimateHereGreat = T([[ -Hi, sight-seeing, are you? -Isn't the warm climate here great?]]), - OneIsland_Text_IslandSign = T([[ -ONE ISLAND -Friends Gather at Knot Island]]), - OneIsland_Text_PokemonNetCenterSign = T([[ -Expanding the POKéMON World! -POKéMON NET CENTER]]), - OneIsland_Text_BillLetsGoSeeCelio = T([[ -BILL: Here we are! -This is ONE ISLAND.\pLet's just go see CELIO!]]), - Test_BufferGreeting = T("Hello, {STR_VAR_1}!"), - NetworkCenter_Text_LittleBoy = T([[ -There are lots of islands around here. -I want to visit every one someday!]]), - NetworkCenter_Text_Hiker = T([[ -I've been climbing Mt. Ember. -It's rough going for a HIKER!]]), - NetworkCenter_Text_CrushGirl = T([[ -The NETWORK MACHINE looks so cool! -I wonder what CELIO does with it.]]), - NetworkCenter_Text_NetworkMachine = T([[ -It's a huge machine. -It's humming with some unknown power.]]), -} - --- Merge shared FRLG nurse/PC text (pret data/text). -for k, v in pairs(Std.TEXT) do - Content.TEXT[k] = v -end - --- Std msgbox bodies (callstd) + shared EventScript_PC / nurse. -Content.STDSCRIPTS = { - ["std:2"] = { -- MSGBOX_NPC - { op = "lock" }, - { op = "faceplayer" }, - { op = "message", ptr = 0 }, - { op = "waitmessage" }, - { op = "waitbuttonpress" }, - { op = "release" }, - { op = "return" }, - }, - ["std:3"] = { -- MSGBOX_SIGN - { op = "lockall" }, - { op = "message", ptr = 0 }, - { op = "waitmessage" }, - { op = "waitbuttonpress" }, - { op = "releaseall" }, - { op = "return" }, - }, - ["std:4"] = { -- MSGBOX_DEFAULT - { op = "message", ptr = 0 }, - { op = "waitmessage" }, - { op = "waitbuttonpress" }, - { op = "return" }, - }, - ["std:5"] = { -- MSGBOX_YESNO (stay + host yes/no → VAR_RESULT) - { op = "message", ptr = 0, stay = true }, - { op = "waitmessage" }, - { op = "yesnobox" }, - { op = "return" }, - }, - ["std:6"] = { - { op = "message", ptr = 0 }, - { op = "waitmessage" }, - { op = "waitbuttonpress" }, - { op = "return" }, - }, - ["std:8"] = { - { op = "return" }, - }, - ["std:9"] = { -- STD_RECEIVED_ITEM - { op = "compare_var_to_value", var = 0x8002, value = 318 }, - { op = "goto_if", cond = 1, target = "EventScript_ReceivedItemFanfareKeyItem" }, - { op = "compare_var_to_value", var = 0x8002, value = 258 }, - { op = "goto_if", cond = 1, target = "EventScript_ReceivedItemFanfareItem" }, - { op = "compare_var_to_value", var = 0x8002, value = 257 }, - { op = "goto_if", cond = 1, target = "EventScript_ReceivedItemFanfareLevelUp" }, - { op = "goto", target = "EventScript_ReceivedItemFanfareDefault" }, - }, -} -for k, v in pairs(Std.SCRIPTS) do - Content.STDSCRIPTS[k] = v -end - -Content.SCRIPTS = { - OneIsland_OnTransition = { - { op = "setworldmapflag", flag = F.WORLD_MAP_ONE_ISLAND }, - { op = "end" }, - }, - OneIsland_EventScript_OldMan = { - { op = "lock" }, - { op = "faceplayer" }, - { op = "checkflag", flag = F.SYS_CAN_LINK_WITH_RS }, - { op = "goto_if", cond = 1, target = "OneIsland_EventScript_OldManLinkHoenn" }, - { op = "checkflag", flag = F.SEVII_DETOUR_FINISHED }, - { op = "goto_if", cond = 1, target = "OneIsland_EventScript_OldManLinkKanto" }, - { op = "loadword", dest = 0, value = "OneIsland_Text_LuckyToHaveCelioHere" }, - { op = "callstd", std = STD.MSGBOX_DEFAULT }, - { op = "release" }, - { op = "end" }, - }, - OneIsland_EventScript_OldManLinkKanto = { - { op = "loadword", dest = 0, value = "OneIsland_Text_HavePCLinkageWithKanto" }, - { op = "callstd", std = STD.MSGBOX_DEFAULT }, - { op = "release" }, - { op = "end" }, - }, - OneIsland_EventScript_OldManLinkHoenn = { - { op = "loadword", dest = 0, value = "OneIsland_Text_HavePCLinkageWithHoenn" }, - { op = "callstd", std = STD.MSGBOX_DEFAULT }, - { op = "release" }, - { op = "end" }, - }, - OneIsland_EventScript_BaldingMan = { - { op = "loadword", dest = 0, value = "OneIsland_Text_IsntWarmClimateHereGreat" }, - { op = "callstd", std = STD.MSGBOX_NPC }, - { op = "end" }, - }, - OneIsland_EventScript_IslandSign = { - { op = "loadword", dest = 0, value = "OneIsland_Text_IslandSign" }, - { op = "callstd", std = STD.MSGBOX_SIGN }, - { op = "end" }, - }, - OneIsland_EventScript_PokemonNetCenterSign = { - { op = "loadword", dest = 0, value = "OneIsland_Text_PokemonNetCenterSign" }, - { op = "callstd", std = STD.MSGBOX_SIGN }, - { op = "end" }, - }, - -- Map nurse: pret shape — call shared EventScript_PkmnCenterNurse - OneIsland_PokemonCenter_1F_EventScript_Nurse = { - { op = "lock" }, - { op = "faceplayer" }, - { op = "call", target = "EventScript_PkmnCenterNurse" }, - { op = "release" }, - { op = "end" }, - }, - OneIsland_PokemonCenter_1F_EventScript_LittleBoy = { - { op = "loadword", dest = 0, value = "NetworkCenter_Text_LittleBoy" }, - { op = "callstd", std = STD.MSGBOX_NPC }, - { op = "end" }, - }, - OneIsland_PokemonCenter_1F_EventScript_Hiker = { - { op = "loadword", dest = 0, value = "NetworkCenter_Text_Hiker" }, - { op = "callstd", std = STD.MSGBOX_NPC }, - { op = "end" }, - }, - OneIsland_PokemonCenter_1F_EventScript_CrushGirl = { - { op = "loadword", dest = 0, value = "NetworkCenter_Text_CrushGirl" }, - { op = "callstd", std = STD.MSGBOX_NPC }, - { op = "end" }, - }, - OneIsland_PokemonCenter_1F_EventScript_NetworkMachine = { - { op = "loadword", dest = 0, value = "NetworkCenter_Text_NetworkMachine" }, - { op = "callstd", std = STD.MSGBOX_SIGN }, - { op = "end" }, - }, -} - --- walk_up = MOVEMENT_ACTION_WALK_NORMAL_UP (0x11), step_end = 0xFE -Content.MOVEMENTS = { - OneIsland_Movement_PlayerExitHarbor = Movement.ensureTerminated({ - Movement.CMD.WALK_UP, Movement.STEP_END, - }), -} - --- graphicsVar demo: template object that resolves OBJ_GFX from 0x4010 -Content.EVENTS = { - SEVII_ONE_ISLAND = { - objects = { - { - localId = 1, - name = "SEVII_ONE_ISLAND_BILL", - graphics = "OBJ_EVENT_GFX_BILL", - sprite = "SPRITE_BILL", -- Gen2 host; may fall back - x = 12, y = 16, - elevation = 3, - movement = "STAY", - range = "DOWN", - scriptKey = nil, -- no talk - flag = F.HIDE_ONE_ISLAND_BILL, - -- Example of var-driven gfx (story templates); default mirrors Bill. - graphicsVar = 0x4010, - }, - { - localId = 2, - name = "SEVII_ONE_ISLAND_OLD_MAN", - graphics = "OBJ_EVENT_GFX_OLD_MAN_1", - sprite = "SPRITE_GRAMPS", - x = 16, y = 12, - elevation = 3, - -- pret MOVEMENT_TYPE_WANDER_AROUND, range 1×1 - movement = "WALK", - range = "ANY_DIR", - radius = { x = 1, y = 1 }, - scriptKey = "OneIsland_EventScript_OldMan", - flag = 0, - }, - { - localId = 3, - name = "SEVII_ONE_ISLAND_BALDING_MAN", - graphics = "OBJ_EVENT_GFX_BALDING_MAN", - sprite = "SPRITE_POKEFAN_M", - x = 13, y = 9, - elevation = 3, - -- pret MOVEMENT_TYPE_FACE_DOWN_AND_UP → host LOOK/spin - movement = "LOOK", - range = "DOWN", - scriptKey = "OneIsland_EventScript_BaldingMan", - flag = 0, - }, - }, - bgEvents = { - { - type = "sign", - x = 14, y = 13, - scriptKey = "OneIsland_EventScript_IslandSign", - }, - { - type = "sign", - x = 15, y = 6, - scriptKey = "OneIsland_EventScript_PokemonNetCenterSign", - }, - }, - mapScripts = { - onTransition = "OneIsland_OnTransition", - onResume = nil, - onFrame = { - -- Bill intro deferred (Tier B): harbor scene var == 2 - -- { var = 0x4075, value = 2, script = "OneIsland_EventScript_EnterOneIslandFirstTime" }, - }, - }, - }, - SEVII_ONE_ISLAND_POKECENTER = { - -- Object/bg rows mirror pret map.json / FRLG MapEvents (ROM event table). - -- PC is NOT listed: FRLG uses MB_PC metatile → COLL_PC → EventScript_PC. - objects = { - { - localId = 1, - name = "SEVII_ONE_PC_NURSE", - graphics = "OBJ_EVENT_GFX_NURSE", - sprite = "SPRITE_NURSE", - x = 5, y = 2, - movement = "STAY", - range = "DOWN", - scriptKey = "OneIsland_PokemonCenter_1F_EventScript_Nurse", - flag = 0, - }, - { - localId = 2, - name = "SEVII_ONE_PC_BILL", - graphics = "OBJ_EVENT_GFX_BILL", - sprite = "SPRITE_BILL", - x = 14, y = 6, - movement = "STAY", - range = "UP", - scriptKey = nil, - flag = F.HIDE_ONE_ISLAND_POKECENTER_BILL, - }, - { - localId = 3, - name = "SEVII_ONE_PC_CELIO", - graphics = "OBJ_EVENT_GFX_CELIO", - sprite = "SPRITE_SUPER_NERD", - x = 15, y = 6, - movement = "STAY", - range = "UP", - scriptKey = nil, - flag = F.HIDE_ONE_ISLAND_POKECENTER_CELIO, - }, - { - localId = 4, - name = "SEVII_ONE_PC_LITTLE_BOY", - graphics = "OBJ_EVENT_GFX_LITTLE_BOY", - sprite = "SPRITE_YOUNGSTER", - x = 6, y = 8, - -- pret MOVEMENT_TYPE_WANDER_AROUND, range 1×1 - movement = "WALK", - range = "ANY_DIR", - radius = { x = 1, y = 1 }, - scriptKey = "OneIsland_PokemonCenter_1F_EventScript_LittleBoy", - flag = 0, - }, - { - localId = 5, - name = "SEVII_ONE_PC_HIKER", - graphics = "OBJ_EVENT_GFX_HIKER", - sprite = "SPRITE_POKEFAN_M", - x = 3, y = 7, - movement = "STAY", - range = "DOWN", - scriptKey = "OneIsland_PokemonCenter_1F_EventScript_Hiker", - flag = 0, - }, - { - localId = 6, - name = "SEVII_ONE_PC_CRUSH_GIRL", - graphics = "OBJ_EVENT_GFX_CRUSH_GIRL", - sprite = "SPRITE_COOLTRAINER_F", - x = 7, y = 4, - movement = "STAY", - range = "RIGHT", - scriptKey = "OneIsland_PokemonCenter_1F_EventScript_CrushGirl", - flag = 0, - }, - }, - bgEvents = { - -- From FRLG MapEvents bg_events (Network Machine), not invented coords. - { type = "sign", x = 12, y = 2, scriptKey = "OneIsland_PokemonCenter_1F_EventScript_NetworkMachine" }, - { type = "sign", x = 12, y = 3, scriptKey = "OneIsland_PokemonCenter_1F_EventScript_NetworkMachine" }, - { type = "sign", x = 12, y = 4, scriptKey = "OneIsland_PokemonCenter_1F_EventScript_NetworkMachine" }, - { type = "sign", x = 12, y = 5, scriptKey = "OneIsland_PokemonCenter_1F_EventScript_NetworkMachine" }, - { type = "sign", x = 13, y = 5, scriptKey = "OneIsland_PokemonCenter_1F_EventScript_NetworkMachine" }, - { type = "sign", x = 14, y = 5, scriptKey = "OneIsland_PokemonCenter_1F_EventScript_NetworkMachine" }, - { type = "sign", x = 15, y = 5, scriptKey = "OneIsland_PokemonCenter_1F_EventScript_NetworkMachine" }, - { type = "sign", x = 16, y = 5, scriptKey = "OneIsland_PokemonCenter_1F_EventScript_NetworkMachine" }, - { type = "sign", x = 17, y = 5, scriptKey = "OneIsland_PokemonCenter_1F_EventScript_NetworkMachine" }, - }, - mapScripts = { - onTransition = nil, - onResume = nil, - onFrame = {}, - }, - }, -} - --- Host sprite lookup for FRLG graphics constants / resolved var values. -Content.GFX_TO_SPRITE = { - OBJ_EVENT_GFX_BILL = "SPRITE_BILL", - OBJ_EVENT_GFX_OLD_MAN_1 = "SPRITE_GRAMPS", - OBJ_EVENT_GFX_BALDING_MAN = "SPRITE_POKEFAN_M", - OBJ_EVENT_GFX_NURSE = "SPRITE_NURSE", - OBJ_EVENT_GFX_LITTLE_BOY = "SPRITE_YOUNGSTER", - OBJ_EVENT_GFX_HIKER = "SPRITE_POKEFAN_M", - OBJ_EVENT_GFX_CRUSH_GIRL = "SPRITE_COOLTRAINER_F", - OBJ_EVENT_GFX_CELIO = "SPRITE_SUPER_NERD", -} - -function Content.resolveSprite(graphics, graphicsVar, getVar) - if graphicsVar and getVar then - local v = getVar(graphicsVar) - if v and v ~= 0 then - if type(v) == "string" and Content.GFX_TO_SPRITE[v] then - return Content.GFX_TO_SPRITE[v] - end - if Content.GFX_TO_SPRITE[v] then - return Content.GFX_TO_SPRITE[v] - end - -- Unknown resolved id — still prefer static template if present. - if graphics and Content.GFX_TO_SPRITE[graphics] then - return Content.GFX_TO_SPRITE[graphics] - end - return "SPRITE_YOUNGSTER" - end - end - if graphics and Content.GFX_TO_SPRITE[graphics] then - return Content.GFX_TO_SPRITE[graphics] - end - return "SPRITE_YOUNGSTER" -end - -return Content diff --git a/src/import/gba/items_extract.lua b/src/import/gba/items_extract.lua index fb33245d..e8dfcec3 100644 --- a/src/import/gba/items_extract.lua +++ b/src/import/gba/items_extract.lua @@ -25,10 +25,6 @@ local POCKET_NAMES = { [5] = "BERRY_POUCH", } -local STATUS_BERRIES = { - [133] = true, [134] = true, [135] = true, [136] = true, [137] = true, [141] = true -} - local function get_byte(rom, off) if rom.get then return rom:get(off) @@ -99,18 +95,54 @@ local function escape_lua(s) return (tostring(s or ""):gsub("\\", "\\\\"):gsub("\"", "\\\""):gsub("\n", "\\n")) end -local function determine_field_use(nid, pocket, battleUsage) +local function popcount(v) + local n = 0 + while v > 0 do + n = n + v % 2 + v = math.floor(v / 2) + end + return n +end + +-- src/data/pokemon/item_effects.h:338, src/pokemon.c:4202 +local function read_effect(rom, id) + if id < Versions.ITEM_EFFECT_FIRST or id > Versions.ITEM_EFFECT_LAST then return nil end + local ptr = get_u32(rom, Versions.ITEM_EFFECT_TABLE + (id - Versions.ITEM_EFFECT_FIRST) * 4) + if ptr < 0x08000000 or ptr >= 0x0A000000 then return nil end + local off = ptr - 0x08000000 + local e4, e5 = get_byte(rom, off + 4), get_byte(rom, off + 5) + local len = 6 + popcount(e4 % 8) + ((math.floor(e4 / 8) % 4 ~= 0) and 1 or 0) + + popcount(e5 % 16) + popcount(math.floor(e5 / 32)) + local out = {} + for i = 1, len do out[i] = get_byte(rom, off + i - 1) end + return out +end + +-- src/item_use.c:409 +local function medicine_kind(effect) + if not effect then return "heal" end + local e3, e4, e5 = effect[4], effect[5], effect[6] + if math.floor(e4 / 64) % 2 == 1 then return "revive" end + if e4 % 4 ~= 0 or e5 % 16 ~= 0 then return "vitamin" end + if math.floor(e4 / 4) % 2 == 1 then return "heal" end + if e3 % 64 ~= 0 or effect[1] >= 0x80 then return "status" end + return "heal" +end + +local function determine_field_use(pocket, battleUsage, fieldUseFunc, effect) if pocket == "KEY_ITEMS" then return "key" end if pocket == "TM_CASE" then return "tm" end if pocket == "POKE_BALLS" then return "battle" end - if pocket == "BERRY_POUCH" then - if STATUS_BERRIES[nid] then return "status" end - return "heal" - end - if nid >= 13 and nid <= 33 then return "heal" end - if nid == 34 then return "escape" end - if nid >= 35 and nid <= 37 then return "repel" end - if nid >= 44 and nid <= 48 then return "evo" end + local F = Versions.FIELD_USE_FUNCS + local fn = fieldUseFunc - 0x08000001 + if fn == F.medicine then return medicine_kind(effect) end + if fn == F.ether or fn == F.pp_up then return "pp" end + if fn == F.rare_candy then return "level" end + if fn == F.evo_item then return "evo" end + if fn == F.sacred_ash then return "revive" end + if fn == F.repel then return "repel" end + if fn == F.escape_rope then return "escape" end + if fn == F.black_white_flute then return "black_white_flute" end if (battleUsage or 0) > 0 then return "battle" end return "none" end @@ -176,13 +208,14 @@ function ItemsExtract.run(rom, cache, opts) local desc = decode_text(rom, descPtr, 256) local pocket = POCKET_NAMES[pocketId] or "ITEMS" - local fieldUse = determine_field_use(id, pocket, battleUsage) + local effect = read_effect(rom, id) + local fieldUse = determine_field_use(pocket, battleUsage, fieldUseFunc, effect) lines[#lines + 1] = string.format( ' [%d] = { name="%s", pocket="%s", fieldUse="%s", price=%d, ' .. 'holdEffect=%d, holdEffectParam=%d, importance=%d, registrability=%d, ' .. 'battleUsage=%d, secondaryId=%d, itemId=%d, itemType=%d, ' .. - 'fieldUseFunc=%d, battleUseFunc=%d, description="%s" },', + 'fieldUseFunc=%d, battleUseFunc=%d, effect=%s, description="%s" },', id, escape_lua(name ~= "" and name or "????????"), pocket, @@ -198,6 +231,7 @@ function ItemsExtract.run(rom, cache, opts) itemType, fieldUseFunc, battleUseFunc, + effect and ("{" .. table.concat(effect, ",") .. "}") or "nil", escape_lua(desc) ) end diff --git a/src/import/gba/league_extract.lua b/src/import/gba/league_extract.lua new file mode 100644 index 00000000..80654ddc --- /dev/null +++ b/src/import/gba/league_extract.lua @@ -0,0 +1,184 @@ +-- src/field_specials.c:2133, src/diploma.c:119 + +local Versions = require("src.import.gba.versions") +local BgBake = require("src.import.gba.bg_bake") +local Lz77 = require("src.import.gba.lz77") + +local LeagueExtract = {} + +LeagueExtract.FORMAT_VERSION = 1 +LeagueExtract.LIGHTING = "league/lighting.lua" +LeagueExtract.FILES = { + "league/lighting.lua", + "diploma/manifest.lua", + "diploma/kanto.rgba", + "diploma/national.rgba", + "hall_of_fame/manifest.lua", + "hall_of_fame/bands.rgba", + "hall_of_fame/stripes.rgba", + "hall_of_fame/confetti.rgba", +} + +local SCREEN_W, SCREEN_H = 240, 160 + +local function default_cache_root() + local ok, Extract = pcall(require, "src.import.gba.extract_island1") + if ok and Extract and Extract.CACHE_ROOT then + return Extract.CACHE_ROOT + end + return "data/generated/gba" +end + +local function rom_bytes(rom, off, len) + local out = {} + for i = 1, len do out[i] = rom:get(off + i - 1) end + out._len = len + return out +end + +local function lz(rom, off) + local out = Lz77.decompress(function(i) return rom:get(i) end, off) + out._len = Lz77.len(out) + return out +end + +local function pal_list(rom, off, count) + local rows = {} + for p = 0, count - 1 do + local cols = {} + for c = 0, 15 do cols[#cols + 1] = string.format("0x%04X", rom:u16(off + p * 32 + c * 2)) end + rows[#rows + 1] = " { " .. table.concat(cols, ", ") .. " }," + end + return table.concat(rows, "\n") +end + +local function byte_list(rom, off, count) + local out = {} + for i = 0, count - 1 do out[#out + 1] = tostring(rom:get(off + i)) end + return table.concat(out, ", ") +end + +-- src/field_specials.c:2133 +local function lighting(rom) + local L = Versions.LEAGUE_LIGHTING + return table.concat({ + "return {", + string.format(" format_version = %d,", LeagueExtract.FORMAT_VERSION), + " palette_slot = 7,", + " e4 = {", + " pals = {", + pal_list(rom, L.e4_pals, L.e4_pal_count), + " },", + " timers = { " .. byte_list(rom, L.e4_timers, L.e4_steps) .. " },", + " },", + " champion = {", + " pals = {", + pal_list(rom, L.champ_pals, L.champ_pal_count), + " },", + " timers = { " .. byte_list(rom, L.champ_timers, L.champ_steps) .. " },", + " },", + "}", + "", + }, "\n") +end + +-- src/diploma.c:137 +local function diploma_screen(gfx, banks, map, block) + local sub = {} + local base = block * 2048 + for i = 1, 2048 do sub[i] = map[base + i] or 0 end + sub._len = 2048 + local top = BgBake.bakeRegionRgba(gfx, banks, sub, SCREEN_W, SCREEN_H, { mapW = 32, alpha0 = true }) + local r, g, b = BgBake.bgr555ToRgb8(banks[0][0]) + local backdrop = string.char(r, g, b, 255) + local out = {} + for i = 1, SCREEN_W * SCREEN_H do + local o = (i - 1) * 4 + if top:byte(o + 4) > 0 then + out[i] = top:sub(o + 1, o + 4) + else + out[i] = backdrop + end + end + return table.concat(out) +end + +local function fill_map(rows) + local map = {} + for i = 1, 32 * 32 * 2 do map[i] = 0 end + for _, r in ipairs(rows) do + for y = r.y, r.y + r.h - 1 do + for x = 0, 31 do + map[(y * 32 + x) * 2 + 1] = r.tile + end + end + end + map._len = 32 * 32 * 2 + return map +end + +-- src/hall_of_fame.c:1181, :1163 +local function hall_of_fame(rom, put) + local H = Versions.HALL_OF_FAME + local gfx = lz(rom, H.gfx) + local banks = BgBake.loadPalBanks(rom_bytes(rom, H.pal, 32), 1) + put("hall_of_fame/bands.rgba", BgBake.bakeRegionRgba(gfx, banks, fill_map({ + { y = 0, h = 2, tile = 1 }, + { y = 3, h = 11, tile = 0 }, + { y = 14, h = 6, tile = 1 }, + }), SCREEN_W, SCREEN_H, { alpha0 = true })) + put("hall_of_fame/stripes.rgba", BgBake.bakeRegionRgba(gfx, banks, + fill_map({ { y = 0, h = 32, tile = 2 } }), SCREEN_W, SCREEN_H, {})) + local sheet = lz(rom, H.confetti_sheet) + assert(BgBake.byteLen(sheet) == H.confetti_frames * 32, "confetti sheet is not 17 8x8 tiles") + local pal = BgBake.loadPalBanks(lz(rom, H.confetti_pal), 1)[0] + local frames = {} + for f = 0, H.confetti_frames - 1 do + frames[#frames + 1] = BgBake.bakeSpriteRgba(sheet, pal, f, 8, 8, false, false) + end + put("hall_of_fame/confetti.rgba", table.concat(frames)) + put("hall_of_fame/manifest.lua", string.format([[ +return { + format_version = %d, + width = %d, + height = %d, + confetti = { w = 8, h = 8, frames = %d }, +} +]], LeagueExtract.FORMAT_VERSION, SCREEN_W, SCREEN_H, H.confetti_frames)) +end + +function LeagueExtract.run(rom, cache, opts) + opts = opts or {} + local root = opts.cacheRoot or default_cache_root() + local function put(name, body) assert(cache:write(root .. "/" .. name, body)) end + put(LeagueExtract.LIGHTING, lighting(rom)) + + local D = Versions.DIPLOMA + local gfx = lz(rom, D.gfx) + local map = lz(rom, D.tilemap) + assert(BgBake.byteLen(map) == 4096, "diploma tilemap is not 64x32") + local banks = BgBake.loadPalBanks(rom_bytes(rom, D.pal, 64), 2) + put("diploma/kanto.rgba", diploma_screen(gfx, banks, map, 0)) + put("diploma/national.rgba", diploma_screen(gfx, banks, map, 1)) + put("diploma/manifest.lua", string.format([[ +return { + format_version = %d, + width = %d, + height = %d, +} +]], LeagueExtract.FORMAT_VERSION, SCREEN_W, SCREEN_H)) + hall_of_fame(rom, put) + print(string.format("[league_extract] league lighting palettes, diploma and hall of fame screens -> %s", root)) + return { root = root } +end + +function LeagueExtract.ready(cache, cacheRoot) + local root = cacheRoot or default_cache_root() + if not (cache and cache.exists) then return false end + for _, rel in ipairs(LeagueExtract.FILES) do + if not cache:exists(root .. "/" .. rel) then return false end + end + return true +end + +return LeagueExtract diff --git a/src/import/gba/native_pack.lua b/src/import/gba/native_pack.lua index debc0628..71b78e8d 100644 --- a/src/import/gba/native_pack.lua +++ b/src/import/gba/native_pack.lua @@ -340,6 +340,20 @@ NativePack.PC_ON_BY_OFF = { [0x28F] = 0x28A, -- pokefirered/include/constants/metatile_labels.h:75 } +function NativePack.addDynamicMids(seen, pairName) + local spec = Versions.TILESET_PAIRS and Versions.TILESET_PAIRS[pairName] + if not spec then return seen end + for _, rule in ipairs(Versions.DYNAMIC_METATILES) do + for _, name in ipairs({ spec.primary, spec.secondary }) do + local ts = Versions.TILESETS[name] + if ts and ts.metatiles == rule.metatiles then + for _, mid in ipairs(rule.mids) do seen[mid] = true end + end + end + end + return seen +end + function NativePack.addPcOnMids(seen) for off, on in pairs(NativePack.PC_ON_BY_OFF) do if seen[off] then seen[on] = true end @@ -452,6 +466,7 @@ function NativePack.collectMidsForPair(grids, borders, pairName, scriptMids) seen[mid] = true end end + NativePack.addDynamicMids(seen, pairName) NativePack.addPcOnMids(seen) seen[0] = true -- void / default border local list = {} diff --git a/src/import/gba/pokemon_extract.lua b/src/import/gba/pokemon_extract.lua index b315898a..fcc4deda 100644 --- a/src/import/gba/pokemon_extract.lua +++ b/src/import/gba/pokemon_extract.lua @@ -39,6 +39,7 @@ local function bgr555_to_rgb8(c) end local SPECIES_CASTFORM = 385 +local SPECIES_SPINDA = 308 local function gba_off(ptr) return Versions.gbaToFile(ptr) @@ -697,6 +698,59 @@ function PokemonExtract.run(rom, cache, opts) picsMissing[#picsMissing + 1] = kind .. "/" .. sp end + local function lz(off) + local ok, out = pcall(Lz77.decompress, function(i) return rom:get(i) end, off) + if ok then return out end + return nil + end + + -- src/pokemon.c:5904 + local function write_pics(sp) + local palOff = gba_off(rom:u32(palTable + sp * 8)) + local shinyOff = gba_off(rom:u32(Versions.MON_SHINY_PALETTE_TABLE + sp * 8)) + local palBytes = palOff and lz(palOff) + local shinyBytes = shinyOff and lz(shinyOff) + for _, kind in ipairs({ "front", "back" }) do + local tableOff = kind == "front" and frontPicTable or backPicTable + local picOff = gba_off(rom:u32(tableOff + sp * 8)) + if picOff and palOff then + local tiles = lz(picOff) + local rgba = tiles and palBytes and decode_pic_sheet(tiles, palBytes) + local shiny = tiles and shinyBytes and decode_pic_sheet(tiles, shinyBytes) + if rgba and shiny then + put(cache, root .. "/" .. kind .. "/" .. sp .. ".rgba", rgba) + put(cache, root .. "/" .. kind .. "_shiny/" .. sp .. ".rgba", shiny) + picsWritten[kind] = picsWritten[kind] + 1 + -- pokefirered/graphics_file_rules.mk:29 + -- src/pokemon.c:1350, :5339 + if sp == SPECIES_SPINDA and kind == "front" then + local function raw(t, n) + local out = {} + for i = 1, n do out[i] = string.char(t[i] or 0) end + return table.concat(out) + end + local spots = {} + for i = 0, 143 do spots[i + 1] = string.char(rom:get(Versions.SPINDA_SPOT_GRAPHICS + i)) end + put(cache, root .. "/spinda/front.4bpp", raw(tiles, 2048)) + put(cache, root .. "/spinda/normal.gbapal", raw(palBytes, 32)) + put(cache, root .. "/spinda/shiny.gbapal", raw(shinyBytes, 32)) + put(cache, root .. "/spinda/spots.bin", table.concat(spots)) + end + if sp == SPECIES_CASTFORM then + for form = 1, 3 do + put(cache, root .. "/" .. kind .. "/" .. sp .. "_" .. form .. ".rgba", + decode_pic_sheet(tiles, palBytes, form, form)) + put(cache, root .. "/" .. kind .. "_shiny/" .. sp .. "_" .. form .. ".rgba", + decode_pic_sheet(tiles, shinyBytes, form, form)) + end + end + else + noteMissing(kind, sp) + end + end + end + end + for sp = 0, num - 1 do if progress and sp % 40 == 0 then progress("pokemon", sp, num) @@ -746,57 +800,13 @@ function PokemonExtract.run(rom, cache, opts) put(cache, root .. "/icons/" .. sp .. ".rgba", icon_rgba(rom, sp, pals)) picsWritten.icons = picsWritten.icons + 1 + write_pics(sp) + end - -- Front Pic (64x64 RGBA) - local frontPtr = rom:u32(frontPicTable + sp * 8) - local palPtr = rom:u32(palTable + sp * 8) - local frontOff = gba_off(frontPtr) - local palOff = gba_off(palPtr) - if frontOff and palOff then - local made = false - local okT, tiles = pcall(Lz77.decompress, function(i) return rom:get(i) end, frontOff) - local okP, palBytes = pcall(Lz77.decompress, function(i) return rom:get(i) end, palOff) - if okT and okP and tiles and palBytes then - local frontRgba = decode_pic_sheet(tiles, palBytes) - if frontRgba then - put(cache, root .. "/front/" .. sp .. ".rgba", frontRgba) - picsWritten.front = picsWritten.front + 1 - made = true - end - -- pokefirered/graphics_file_rules.mk:29 - if sp == SPECIES_CASTFORM then - for form = 1, 3 do - local rgba = decode_pic_sheet(tiles, palBytes, form, form) - if rgba then put(cache, root .. "/front/" .. sp .. "_" .. form .. ".rgba", rgba) end - end - end - end - if not made then noteMissing("front", sp) end - end - - -- Back Pic (64x64 RGBA) - local backPtr = rom:u32(backPicTable + sp * 8) - local backOff = gba_off(backPtr) - if backOff and palOff then - local made = false - local okT, tiles = pcall(Lz77.decompress, function(i) return rom:get(i) end, backOff) - local okP, palBytes = pcall(Lz77.decompress, function(i) return rom:get(i) end, palOff) - if okT and okP and tiles and palBytes then - local backRgba = decode_pic_sheet(tiles, palBytes) - if backRgba then - put(cache, root .. "/back/" .. sp .. ".rgba", backRgba) - picsWritten.back = picsWritten.back + 1 - made = true - end - if sp == SPECIES_CASTFORM then - for form = 1, 3 do - local rgba = decode_pic_sheet(tiles, palBytes, form, form) - if rgba then put(cache, root .. "/back/" .. sp .. "_" .. form .. ".rgba", rgba) end - end - end - end - if not made then noteMissing("back", sp) end - end + -- include/constants/species.h:425 + for sp = Versions.SPECIES_UNOWN_B, Versions.SPECIES_UNOWN_QMARK do + put(cache, root .. "/icons/" .. sp .. ".rgba", icon_rgba(rom, sp, pals)) + write_pics(sp) end if #picsMissing > 0 then diff --git a/src/import/gba/register.lua b/src/import/gba/register.lua index 24e994e1..433a4d4f 100644 --- a/src/import/gba/register.lua +++ b/src/import/gba/register.lua @@ -302,12 +302,6 @@ function Register.apply(mod, MapsModule) end end - -- Ensure game3 script cache exists (ROM extract preferred; curated fallback). - local ExtractScripts = require("src.import.gba.extract_scripts") - if not cache:exists(CACHE .. "/scripts/events.lua") then - ExtractScripts.writeBundle(cache, CACHE) - end - if MapsModule and MapsModule.registerFromExtract then local ok, mapErr = pcall(MapsModule.registerFromExtract, mod, bundle) if not ok then diff --git a/src/import/gba/versions.lua b/src/import/gba/versions.lua index c4dd9868..8b839c93 100644 --- a/src/import/gba/versions.lua +++ b/src/import/gba/versions.lua @@ -41,7 +41,7 @@ Versions.ROM_SIZE = 16777216 -- v114: pokemon/icons/412.rgba, the SPECIES_EGG menu icon — eggs were drawn -- with the icon of the species they hatch into. -- v115: LeafGreen profiles, edition-specific title assets and Deoxys stats. -Versions.CACHE_VERSION = 117 +Versions.CACHE_VERSION = 118 Versions.NATIVE_VERSION = 6 Versions.OW_VERSION = 3 Versions.ANIM_VERSION = 1 @@ -1409,6 +1409,21 @@ Versions.FIELD_EFFECTS = { deoxys_rock_fragments = { pic = 0x3CBDB0, pal = 0x3F6346, w = 8, h = 8, frames = 4, }, + -- pokefirered/src/data/field_effects/field_effect_objects.h:288 + ground_impact_dust = { pic = 0x399008, pal = 0x398FA8, w = 16, h = 8, frames = 3 }, + -- pokefirered/src/itemfinder.c:40, src/event_object_movement.c:490 + itemfinder_arrow_star = { pic = 0x4644D0, pal = 0x35B968, w = 16, h = 16, frames = 5 }, + -- src/ss_anne.c:21, :156, include/event_object_movement.h:20 + ss_anne_wake = { pic = 0x479838, pal = 0x395AE8, w = 16, h = 32, frames = 2 }, + -- src/ss_anne.c:22, :190 + ss_anne_smoke = { pic = 0x479A38, pal = 0x395AE8, w = 16, h = 16, frames = 4 }, +} + +Versions.FIELD_MOVE_STREAKS = { + -- pokefirered/src/field_effect.c:73 + outdoors = { gfx = 0x3CB5F0, pal = 0x3CB7F0, tilemap = 0x3CB810, tiles = 16 }, + -- pokefirered/src/field_effect.c:77 + indoors = { gfx = 0x3CBA90, pal = 0x3CBB10, tilemap = 0x3CBB30, tiles = 4 }, } -- Battle interface chrome (FireRed USA 1.0 file offsets; LZ unless noted). @@ -1472,6 +1487,13 @@ Versions.BATTLE_TRANSITION = { } Versions.MON_BACK_PIC_TABLE = 0x23654C -- gMonBackPicTable +-- src/data/pokemon_graphics/shiny_palette_table.h:1 +Versions.MON_SHINY_PALETTE_TABLE = 0x2380CC +-- src/pokemon.c:1350 +Versions.SPINDA_SPOT_GRAPHICS = 0x25265C +-- include/constants/species.h:425-451 +Versions.SPECIES_UNOWN_B = 413 +Versions.SPECIES_UNOWN_QMARK = 439 Versions.GHOST_PALETTE = 0xE93B14 -- pokefirered/src/graphics.c:1135 Versions.GHOST_FRONT_PIC = 0xE93B38 -- pokefirered/src/graphics.c:1136 @@ -1742,6 +1764,267 @@ Versions.BATTLE_RECORDS_GFX = 0x3F6388 Versions.BATTLE_RECORDS_PAL = 0x3F6448 Versions.BATTLE_RECORDS_TILEMAP = 0x3F6468 +-- data/event_scripts.s:77 +Versions.STD_SCRIPTS = 0x160450 +Versions.STD_SCRIPTS_COUNT = 10 + +-- data/scripts/pc.inc:1, data/scripts/pokedex_rating.inc:35 +Versions.NAMED_SCRIPTS = { + EventScript_PC = 0x1A6955, + EventScript_PCDisabled = 0x1A698E, + EventScript_PCMainMenu = 0x1A6998, + EventScript_ChoosePCMenu = 0x1A69A8, + EventScript_AccessPlayersPC = 0x1A69F0, + EventScript_AccessPokemonStorage = 0x1A6A05, + EventScript_AccessSomeonesPC = 0x1A6A34, + EventScript_AccessBillsPC = 0x1A6A3D, + EventScript_TurnOffPC = 0x1A6A46, + EventScript_AccessHallOfFame = 0x1A6A56, + EventScript_AccessProfOaksPC = 0x1A6A7A, + EventScript_ExitOaksPC = 0x1A6AB2, + PokedexRating_EventScript_Rate = 0x1A73E0, + -- data/scripts/white_out.inc:1, :22 + EventScript_AfterWhiteOutHeal = 0x1A8D97, + EventScript_AfterWhiteOutMomHeal = 0x1A8DD8, +} + +-- src/strings.c:190-192, :961-962, data/event_scripts.s:884, data/text/white_out.inc:43, :50, +-- data/text/obtain_item.inc:7-30, data/text/itemfinder.inc:8 +Versions.NAMED_TEXTS = { + gText_ItemfinderResponding = 0x416476, + gText_ItemfinderShakingWildly = 0x4164BE, + gText_NopeTheresNoResponse = 0x416513, + gText_PlayerScurriedToCenter = 0x41B554, + gText_PlayerScurriedBackHome = 0x41B5B6, + gText_PkmnFainted3 = 0x1A5476, + Text_WhitedOutLostMoney = 0x1A6197, + Text_WhitedOut = 0x1A61E5, + Text_PutItemAway = 0x1A5218, + Text_FoundOneItem = 0x1A5231, + Text_TooBadBagFull = 0x1A5242, + Text_FoundXCoins = 0x1A525C, + Text_PutCoinsAwayInCoinCase = 0x1A526C, + Text_CoinCaseIsFull = 0x1A5294, + Text_NothingToPutThemIn = 0x1A52B4, + Text_DugUpItemFromGround = 0x1A5700, + -- src/strings.c:17, :36, :50-52, :103-112, :896 + gText_WelcomeToHOF = 0x416008, + gText_LeagueChamp = 0x4160C8, + gText_SavingDontTurnOffThePower2 = 0x419F54, + gText_HOFCorrupted = 0x416090, + gText_HOFNumber = 0x4160B4, + gText_UPDOWNPick_ABUTTONNext_BBUTTONBack = 0x415D60, + gText_UPDOWNPick_ABUTTONBBUTTONCancel = 0x415D78, + gText_ABUTTONExit = 0x415D8C, + gText_Number = 0x4160EC, + gText_Level = 0x4160F4, + gText_Name = 0x4160FC, + gText_IDNumber = 0x416104, + gText_MainMenuTime = 0x415CE8, + gText_EggNickname = 0x415A62, + -- src/strings.c:963-967 + gText_Diploma_Player = 0x41B60E, + gText_Diploma_ThisDocument = 0x41B619, + gText_Diploma_GameFreak = 0x41B684, + gText_Diploma_National = 0x41B68F, + gText_Diploma_Kanto = 0x41B698, + -- data/text/pokedex_rating.inc:13-85 + PokedexRating_Text_LessThan10 = 0x1A6D17, + PokedexRating_Text_LessThan20 = 0x1A6D6D, + PokedexRating_Text_LessThan30 = 0x1A6DDF, + PokedexRating_Text_LessThan40 = 0x1A6E36, + PokedexRating_Text_LessThan50 = 0x1A6EA4, + PokedexRating_Text_LessThan60 = 0x1A6F0B, + PokedexRating_Text_LessThan70 = 0x1A6F71, + PokedexRating_Text_LessThan80 = 0x1A6FAB, + PokedexRating_Text_LessThan90 = 0x1A6FF1, + PokedexRating_Text_LessThan100 = 0x1A7031, + PokedexRating_Text_LessThan110 = 0x1A7063, + PokedexRating_Text_LessThan120 = 0x1A70A5, + PokedexRating_Text_LessThan130 = 0x1A70D8, + PokedexRating_Text_LessThan140 = 0x1A7108, + PokedexRating_Text_LessThan150 = 0x1A7137, + PokedexRating_Text_Complete = 0x1A7175, + -- data/scripts/repel.inc:5, data/text/day_care.inc:129 + Text_RepelWoreOff = 0x1BFB6E, + DayCare_Text_Huh = 0x1BFB5A, + -- src/strings.c:1059-1061 + gText_PokemonOnHook = 0x41D14E, + gText_NotEvenANibble = 0x41D169, + gText_ItGotAway = 0x41D17E, + -- src/strings.c:160, :278, :382, :629 + gText_BagFullCouldNotRemoveItem = 0x416D78, + gText_MenuBag = 0x416285, + gText_BagIsFull2 = 0x418408, + gText_NoMoreRoomInBag = 0x41778A, + -- src/strings.c:199-200 + gText_UsedVar2WildLured = 0x4165D2, + gText_UsedVar2WildRepelled = 0x416600, + -- src/strings.c:178, :209, :214, :301-304, :317-318, :601, :682-683, :1252-1255 + gText_CantUseHere = 0x417299, + gText_AlreadySurfing = 0x417281, + gDaycareText_GetAlongVeryWell = 0x41E33C, + gDaycareText_GetAlong = 0x41E361, + gDaycareText_DontLikeOther = 0x41E37C, + gDaycareText_PlayOther = 0x41E3A8, + gText_OnlyPkmnForBattle = 0x417094, + gText_EggCantBeTradedNow = 0x4170E0, + gText_PkmnCantBeTradedNow = 0x4170BC, + gText_OtherTrainersPkmnCantBeTraded = 0x4170FC, + gText_ThereIsNoPokemon = 0x41632A, + gText_TheBerryPouchWillBePutAway = 0x416716, + gText_TMCaseWillBePutAway = 0x4166E1, + gText_JustOnePkmn = 0x418690, + gText_PartyFull = 0x4186B0, + gFameCheckerText_FameCheckerWillBeClosed = 0x4181C3, + -- src/strings.c:158, :401, :477-478, :672-681 + gText_WhatWouldYouLikeToDo = 0x416262, + gText_HallOfFame = 0x4178F0, + gText_LogOff = 0x417BCB, + gText_ProfOakSPc = 0x417BD3, + gText_WithdrawPokemon = 0x41856C, + gText_DepositPokemon = 0x41857D, + gText_MovePokemon = 0x41858D, + gText_MoveItems = 0x41859A, + gText_SeeYa = 0x4185A5, + gText_WithdrawMonDescription = 0x4185AD, + gText_DepositMonDescription = 0x4185E2, + gText_MoveMonDescription = 0x418611, + gText_MoveItemsDescription = 0x418642, + gText_SeeYaDescription = 0x418681, + -- src/strings.c:224, :230, :309-322, :370, :980-981, :1004 + gText_ChoosePokemon = 0x4171DF, + gText_MoveToWhere = 0x4171F1, + gText_TeachWhichPokemon = 0x417200, + gText_UseOnWhichPokemon = 0x417215, + gText_GiveToWhichPokemon = 0x41722B, + gText_RestoreWhichMove = 0x4172AE, + gText_BoostPp = 0x4172C2, + gText_DoWhatWithItem = 0x4172D5, + gText_NoPokemonForBattle = 0x4172EB, + gText_CancelBattle = 0x4176CF, + gText_HereYouGoThankYou = 0x4167E7, + gText_AnythingElseICanHelp = 0x41689E, + gString_OutOfCoins = 0x41B747, + gString_QuitPlaying = 0x41B76B, + gText_CommunicationStandby4 = 0x41CC64, +} + +-- src/script_menu.c:574 +Versions.STD_STRING_PTRS = 0x3E06B8 +Versions.STD_STRING_COUNT = 29 + +-- src/field_specials.c:2081, :2096, :2108, :2122 +Versions.LEAGUE_LIGHTING = { + e4_pals = 0x3F5F50, + e4_pal_count = 12, + e4_timers = 0x3F61F0, + e4_steps = 11, + champ_pals = 0x3F60D0, + champ_pal_count = 9, + champ_timers = 0x3F61FB, + champ_steps = 8, +} + +-- src/hall_of_fame.c:143, :148, :288, :289 +Versions.HALL_OF_FAME = { + pal = 0x40C39C, + gfx = 0x40C3BC, + confetti_sheet = 0xD2D5FC, + confetti_pal = 0xD2D71C, + confetti_frames = 17, +} + +-- src/diploma.c:42 +Versions.DIPLOMA = { + gfx = 0x4147C0, + tilemap = 0x4154E8, + pal = 0x415954, +} + +-- src/credits.c:341, :383, :456, :479, :649, :665, src/graphics.c:1329, :1368, src/strings.c:1063 +Versions.CREDITS = { + script = 0x410CF4, + script_max = 0x42, + texts = 0x4145BC, + text_count = 43, + scenes = 0x414588, + scene_count = 13, + sprite_params = 0x41431C, + sprite_param_count = 5, + staff_firered = 0x41D198, + staff_leafgreen = 0x41D1B8, + copyright_pal = 0xEAE528, + copyright_tiles = 0xEAE548, + copyright_map = 0xEAE900, + the_end_pal = 0x410B00, + the_end_tiles = 0x410B20, + the_end_map = 0x410B94, + pokeball_pals = 0xEAAB18, + pokeball_tiles = 0xEAAB98, + pokeball_map = 0xEAB30C, + circle_pal = 0x40C630, + circle_tiles = 0x40C650, + circle_map = 0x40CA54, + player_male_pal = 0x410E10, + player_male_tiles = 0x410E30, + player_female_pal = 0x411BF8, + player_female_tiles = 0x411C18, + rival_pal = 0x4129A0, + rival_tiles = 0x4129C0, + ground_grass_pal = 0x413318, + ground_grass_tiles = 0x413338, + ground_dirt_pal = 0x413854, + ground_dirt_tiles = 0x413874, + ground_city_pal = 0x413D98, + ground_city_tiles = 0x413DB8, + charizard_1 = 0x40CB8C, + charizard_2 = 0x40D228, + venusaur_1 = 0x40E158, + venusaur_2 = 0x40E904, + blastoise_1 = 0x40F240, + blastoise_2 = 0x40F944, + pikachu_1 = 0x410198, + pikachu_2 = 0x4105B4, + windows_charizard = 0x40C5B0, + windows_venusaur = 0x40C5D0, + windows_blastoise = 0x40C5F0, + windows_pikachu = 0x40C610, +} + +Versions.DYNAMIC_METATILES = { + -- src/field_tasks.c:225, :241 + { metatiles = 0x2BB51C, mids = { 0x35A, 0x35B } }, + -- src/field_specials.c:776 + { metatiles = 0x2C0E04, mids = { 0x2E8, 0x2E9, 0x2EA, 0x2F0, 0x2F1, 0x2F2, 0x2F8, 0x2F9, 0x2FA } }, + -- src/field_specials.c:2312 + { metatiles = 0x2CF3F0, mids = { 0x358 } }, + -- src/special_field_anim.c:257 + { metatiles = 0x2C0968, mids = { 0x285, 0x28A, 0x296, 0x2B4, 0x2B5, 0x2B6, 0x2B7, 0x2B8, 0x2B9, 0x2BA } }, + -- src/fldeff_cut.c:36 + { metatiles = 0x29F6C8, mids = { 0x001, 0x013, 0x00E, 0x00F } }, + { metatiles = 0x2A65F4, mids = { 0x33E } }, + { metatiles = 0x2A78B4, mids = { 0x310, 0x311, 0x312 } }, + { metatiles = 0x2B90B4, mids = { 0x281 } }, +} + +-- src/data/pokemon/item_effects.h:338, src/item_use.c:409 +Versions.ITEM_EFFECT_TABLE = 0x2528BC +Versions.ITEM_EFFECT_FIRST = 13 +Versions.ITEM_EFFECT_LAST = 175 +Versions.FIELD_USE_FUNCS = { + medicine = 0x0A16E0, + ether = 0x0A16FC, + pp_up = 0x0A1718, + rare_candy = 0x0A1734, + evo_item = 0x0A1750, + sacred_ash = 0x0A176C, + repel = 0x0A1998, + escape_rope = 0x0A1BAC, + -- src/item_use.c:582 + black_white_flute = 0x0A1A94, +} + -- Title screen + Oak speech graphics (FireRed USA 1.0 file offsets). -- Verified by matching pret .gbapal bytes + LZ sizes in local dump. Versions.INTRO = { diff --git a/src/mods/Gen3Compat.lua b/src/mods/Gen3Compat.lua index 0d43006d..e9094d7c 100644 --- a/src/mods/Gen3Compat.lua +++ b/src/mods/Gen3Compat.lua @@ -767,7 +767,9 @@ local function buildGame() function translate.saveGame() return function() local g = live() - if g and g.saveGame then return g:saveGame() end + if not (g and g.saveGame) then return end + if g.saveOffered and not g:saveOffered() then return false end + return g:saveGame() end end @@ -1410,7 +1412,7 @@ local function buildOverworld() if not ready("warpToHealPoint") then return nil end local Field = g3("field") if not Field then return nil end - Field.respawnAtHeal() + Field.respawnAtHeal({ fieldMove = true }) if onDone then onDone() end return true end @@ -2078,22 +2080,22 @@ local function wrapPics(P) wrappedModules[P] = true local frontOrig, backOrig = P.frontPic, P.backPic if frontOrig then - P.frontPic = function(species, form) + P.frontPic = function(species, form, shiny, personality) local sp = tonumber(species) local path = sp and (tonumber(form) or 0) == 0 and spriteOverrides.front[sp] local entry = path and centredEntry(path) - if not entry then entry = frontOrig(species, form) end + if not entry then entry = frontOrig(species, form, shiny, personality) end if sp then return hookedEntry("front", sp, form, entry) end return entry end P.frontSprite = P.frontPic end if backOrig then - P.backPic = function(species, form) + P.backPic = function(species, form, shiny) local sp = tonumber(species) local path = sp and (tonumber(form) or 0) == 0 and spriteOverrides.back[sp] local entry = path and centredEntry(path) - if not entry then entry = backOrig(species, form) end + if not entry then entry = backOrig(species, form, shiny) end if sp then return hookedEntry("back", sp, form, entry) end return entry end diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 411b9a4e..a571a96b 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -2320,7 +2320,6 @@ R.text = { example = 'mod.content.text:override("_PalletTownText1", "HELLO!")', gen3Value = f.union{ f.str, f.list(f.any) }, gen3Write = G3.textWrite, - gen3Example = 'mod.content.text:override("Text_BootedUpPC", "HELLO!")', } -- Gen 2's data/generated/text.lua is VM script text keyed by bank:address; diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 37abbb44..992783e8 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -636,7 +636,7 @@ function Commands.hide_object(ctx, mapId, objName) end function Commands.play_sound(ctx, soundId) - require("src.core.Sound").play(ctx.game.data, soundId) + ctx.lastSfxSrc = require("src.core.Sound").play(ctx.game.data, soundId) end -- wait_sound: WaitForSoundToFinish (home/delay.asm:15), the drain that @@ -646,12 +646,20 @@ local WAIT_SOUND_CEILING = 600 function Commands.wait_sound(ctx) local Sound = require("src.core.Sound") - if not Sound.sfxBusy() then return end + local src = ctx.lastSfxSrc + ctx.lastSfxSrc = nil + local function busy() + if Sound.sfxBusy() then return true end + if not src then return false end + local ok, playing = pcall(function() return src:isPlaying() end) + return ok and playing and true or false + end + if not busy() then return end local runner = ctx.runner local left = WAIT_SOUND_CEILING runner.waitingCheck = function() left = left - 1 - return left <= 0 or not Sound.sfxBusy() + return left <= 0 or not busy() end runner:yield() end diff --git a/src/ui/game3/bag_menu.lua b/src/ui/game3/bag_menu.lua index 14d73b8f..e0af82e3 100644 --- a/src/ui/game3/bag_menu.lua +++ b/src/ui/game3/bag_menu.lua @@ -11,6 +11,7 @@ local Options = require("src.core.game3.options") local Trig = require("src.core.game3.trig") local PartyView = require("src.core.game3.battle.party_view") local Strings = require("src.core.Strings") +local RomText = require("src.core.game3.rom_text") local BagMenu = {} @@ -214,7 +215,7 @@ end -- Close the bag and report the chosen item to the battle system. partySlot is -- the real party index for party-targeted items, or nil otherwise. Shared with -- the Berry Pouch so a berry picked there takes the same route as a potion. -function BagMenu.battleUse(itemId, partySlot) +function BagMenu.battleUse(itemId, partySlot, moveSlot) begin_exit(true, function() save_pos() local cb = BagMenu._onBattleUse @@ -223,10 +224,45 @@ function BagMenu.battleUse(itemId, partySlot) BagMenu._battle = false BagMenu._onBattleUse = nil Stack.pop("bag") - if cb then cb(itemId, partySlot) end + if cb then cb(itemId, partySlot, moveSlot) end end) end +-- pokefirered/src/party_menu.c:4591 ItemUseCB_TryRestorePP +function BagMenu.commitBattlePartyUse(st, itemId, realSlot, mon, beforeUse) + local PartyMenu = require("src.ui.game3.party_menu") + local BattleItems = require("src.core.game3.battle.items") + local isPp = ItemsData.fieldUseKind(itemId) == "pp" + local function commit(moveSlot) + local canUse, err = BattleItems.canUseOn(st, itemId, realSlot, mon, moveSlot) + if not canUse then + se(5) -- pokefirered/src/party_menu.c:4490 + PartyMenu.showMessage(err or Strings("It won't have any effect."), function() + PartyMenu.mode = "use" + end) + return + end + local function go() + PartyMenu.close() + if beforeUse then beforeUse() end + BagMenu.battleUse(itemId, realSlot, moveSlot) + end + if isPp then + -- pokefirered/src/party_menu.c:4696 TryUsePPItemInBattle + se(1) + PartyMenu.showMessage(ItemUse.ppItemText(mon, itemId, moveSlot or 1), go) + return + end + go() + end + if isPp and mon and not mon.isEgg and ItemUse.ppItemNeedsMove(itemId) then + se(5) + PartyMenu.pickPpMove(mon, itemId, commit) + return + end + commit(nil) +end + function BagMenu.show(sessionBag, opts) opts = opts or {} BagMenu.open = true @@ -427,6 +463,17 @@ local function use_field_from_bag(session, bag, id) end local function handle_menu_input(input) + if BagMenu.mode == "flute_wait" then + -- pokefirered/src/item_use.c:607 + local w = BagMenu._fluteWait + w.frames = w.frames + 1 + if w.frames >= ItemUse.BLACK_WHITE_FLUTE_DELAY then + BagMenu._fluteWait = nil + ItemUse.playBlackWhiteFlute() + show_bag_message(w.text) + end + return + end if BagMenu.mode == "toss" then local rows = BagMenu.list() local row = rows[BagMenu.cursor] @@ -521,16 +568,7 @@ local function handle_menu_input(input) -- party slot, so translating again would heal the wrong mon. local realSlot = slot local mon = liveParty and liveParty[realSlot] - local canUse, err = BattleItems.canUseOn(st, row.id, realSlot, mon) - if not canUse then - se(5) -- pokefirered/src/party_menu.c:4490 - PartyMenu.showMessage(err or Strings("It won't have any effect."), function() - PartyMenu.mode = "use" - end) - return - end - PartyMenu.close() - BagMenu.battleUse(row.id, realSlot) + BagMenu.commitBattlePartyUse(st, row.id, realSlot, mon) end, onClose = function() BagMenu.mode = "list" @@ -584,7 +622,7 @@ local function handle_menu_input(input) elseif ItemUse.needsPartyTarget(row.id) then if #party == 0 then BagMenu.mode = "message" - BagMenu.messageText = Strings("There is no POKéMON.") + BagMenu.messageText = RomText.plain("gText_ThereIsNoPokemon") else local PartyMenu = require("src.ui.game3.party_menu") open_submenu(function() @@ -664,6 +702,10 @@ local function handle_menu_input(input) if fade then field_fade_in() end end) return + elseif ok and kind == "black_white_flute" then + -- pokefirered/src/item_use.c:582 + BagMenu.mode = "flute_wait" + BagMenu._fluteWait = { frames = 0, text = text } elseif ok and kind == "map" then -- pokefirered/src/item_use.c:649 BagMenu.mode = "list" @@ -684,7 +726,7 @@ local function handle_menu_input(input) BagMenu.messageText = Strings("This item can't be held.") elseif #party == 0 then BagMenu.mode = "message" - BagMenu.messageText = Strings("There is no POKéMON.") + BagMenu.messageText = RomText.plain("gText_ThereIsNoPokemon") else local PartyMenu = require("src.ui.game3.party_menu") -- src/item_menu.c:1620 diff --git a/src/ui/game3/battle_chrome.lua b/src/ui/game3/battle_chrome.lua index acd22dac..1836f739 100644 --- a/src/ui/game3/battle_chrome.lua +++ b/src/ui/game3/battle_chrome.lua @@ -190,20 +190,6 @@ function BattleChrome.install(cache) end end -local function rom_bytes() - local okP, Pokemon = pcall(require, "src.core.game3.pokemon") - if okP and Pokemon and type(Pokemon._romBytes) == "string" then return Pokemon._romBytes end - for _, p in ipairs({ "1636 - Pokemon Fire Red (U)(Squirrels).gba", "firered.gba", "Pokemon FireRed.gba" }) do - local f = io.open(p, "rb") - if f then - local d = f:read("*a") - f:close() - if d and #d >= 0x1000000 then return d end - end - end - return nil -end - local function load_doubles_boxes() if BattleChrome._doublesTried then return end BattleChrome._doublesTried = true @@ -211,17 +197,10 @@ local function load_doubles_boxes() local files = BattleChromeExtract.DOUBLES_FILES or {} local pRgba = read_bytes(root .. "/" .. (files.player or "healthbox_doubles_player.rgba")) local oRgba = read_bytes(root .. "/" .. (files.opponent or "healthbox_doubles_opponent.rgba")) - if not (pRgba and oRgba) then - local rom = rom_bytes() - if rom then - local ok, p2, o2 = pcall(BattleChromeExtract.bakeDoubles, function(i) return rom:byte(i + 1) or 0 end) - if ok and p2 and o2 then pRgba, oRgba = p2, o2 end - end - end BattleChrome._doublesPlayerBox = rgba_to_image(pRgba, 128, 32) BattleChrome._doublesOpponentBox = rgba_to_image(oRgba, 128, 32) if not (BattleChrome._doublesPlayerBox and BattleChrome._doublesOpponentBox) then - print("[game3/battle_chrome] doubles healthboxes missing; re-import the ROM to extract them") + print("[game3/battle_chrome] cache missing doubles healthboxes") end end @@ -230,17 +209,10 @@ local function load_hp_bold() BattleChrome._hpBoldTried = true local w, h = BattleChromeExtract.HP_BOLD_W or 88, BattleChromeExtract.HP_BOLD_H or 8 local rgba = read_bytes(battle_root() .. "/" .. (BattleChromeExtract.HP_BOLD_FILE or "hp_bold_digits.rgba")) - if not rgba then - local rom = rom_bytes() - if rom then - local ok, r2 = pcall(BattleChromeExtract.bakeHpBoldDigits, function(i) return rom:byte(i + 1) or 0 end) - if ok and r2 then rgba = r2 end - end - end BattleChrome._hpBold = rgba_to_image(rgba, w, h) BattleChrome._hpBoldQuads = {} if not BattleChrome._hpBold then - print("[game3/battle_chrome] bold HP digits missing; re-import the ROM to extract them") + print("[game3/battle_chrome] cache missing bold HP digits") end end diff --git a/src/ui/game3/berry_pouch.lua b/src/ui/game3/berry_pouch.lua index 45677159..f1809cc1 100644 --- a/src/ui/game3/berry_pouch.lua +++ b/src/ui/game3/berry_pouch.lua @@ -24,6 +24,7 @@ local Bag = require("src.core.game3.bag") local ItemUse = require("src.core.game3.item_use") local PartyView = require("src.core.game3.battle.party_view") local Strings = require("src.core.Strings") +local RomText = require("src.core.game3.rom_text") local BerryPouch = {} @@ -118,17 +119,7 @@ local function party_opts(row, mode, party) return end local mon = party[slot] - local canUse, err = BattleItems.canUseOn(st, row.id, slot, mon) - if not canUse then - se(5) -- pokefirered/src/party_menu.c:4490 - PartyMenu.showMessage(err or Strings("It won't have any effect."), function() - PartyMenu.mode = "use" - end) - return - end - PartyMenu.close() - BerryPouch.close() - BagMenu.battleUse(row.id, slot) + BagMenu.commitBattlePartyUse(st, row.id, slot, mon, function() BerryPouch.close() end) end return opts end @@ -256,7 +247,7 @@ function BerryPouch.handleInput(input) if ItemUse.needsPartyTarget(row.id) then if #party == 0 then BerryPouch.mode = "message" - BerryPouch.messageText = Strings("There is no POKéMON.") + BerryPouch.messageText = RomText.plain("gText_ThereIsNoPokemon") else local PartyMenu = require("src.ui.game3.party_menu") PartyMenu.show(party, BerryPouch._session and BerryPouch._session.moveOverlay, @@ -269,7 +260,7 @@ function BerryPouch.handleInput(input) elseif act == "GIVE" then if #party == 0 then BerryPouch.mode = "message" - BerryPouch.messageText = Strings("There is no POKéMON.") + BerryPouch.messageText = RomText.plain("gText_ThereIsNoPokemon") else local PartyMenu = require("src.ui.game3.party_menu") PartyMenu.show(party, BerryPouch._session and BerryPouch._session.moveOverlay, { @@ -402,7 +393,7 @@ function BerryPouch.draw() -- 5. Description Box (WIN 1: tilemapLeft=5, tilemapTop=16, width=25, height=4 -> screen (40, 128, 200, 32)) if BerryPouch.cursor == total then - local closeDesc = Strings("The BERRY POUCH will be\nput away.") + local closeDesc = RomText.plain("gText_TheBerryPouchWillBePutAway") FrlgFont.draw(closeDesc, 40, 130, { colors = FrlgFont.COLOR.LIGHT, linePitch = 14 }) elseif sel then local desc = sel.description or ItemsData.description(sel.id) or "" diff --git a/src/ui/game3/berry_powder_box.lua b/src/ui/game3/berry_powder_box.lua new file mode 100644 index 00000000..df8315a2 --- /dev/null +++ b/src/ui/game3/berry_powder_box.lua @@ -0,0 +1,88 @@ +-- pokefirered/src/berry_powder.c:113 + +local Window = require("src.ui.game3.window") +local FrlgFont = require("src.ui.game3.frlg_font") +local Strings = require("src.core.Strings") + +local BerryPowderBox = {} + +-- pokefirered/src/berry_powder.c:13 +BerryPowderBox.MAX_BERRY_POWDER = 99999 + +-- pokefirered/src/berry_powder.c:120 +local TEMPLATE = Window.template(1, 1, 8, 3) + +BerryPowderBox.visible = false +BerryPowderBox._amount = 0 +BerryPowderBox._owner = nil + +local function fieldOwner() + local Space = package.loaded["src.core.game3.scripting.space"] + return Space and Space.vm or nil +end + +local function clamp(n) + n = math.floor(tonumber(n) or 0) + if n < 0 then return 0 end + if n > BerryPowderBox.MAX_BERRY_POWDER then return BerryPowderBox.MAX_BERRY_POWDER end + return n +end + +-- pokefirered/src/berry_powder.c:90 +local function sessionPowder() + local Runtime = package.loaded["src.core.game3.runtime"] + local session = Runtime and Runtime.getSession and Runtime.getSession() + return clamp(session and session.berryPowder) +end + +function BerryPowderBox.show(amount) + BerryPowderBox.visible = true + BerryPowderBox._owner = fieldOwner() + BerryPowderBox._amount = clamp(amount ~= nil and amount or sessionPowder()) + return true +end + +-- pokefirered/src/berry_powder.c:108 +function BerryPowderBox.update(amount) + if not BerryPowderBox.visible then return false end + BerryPowderBox._amount = clamp(amount ~= nil and amount or sessionPowder()) + return true +end + +-- pokefirered/src/berry_powder.c:128 +function BerryPowderBox.hide() + BerryPowderBox.visible = false + BerryPowderBox._owner = nil + return true +end + +BerryPowderBox.reset = BerryPowderBox.hide + +function BerryPowderBox.isVisible() + if BerryPowderBox.visible and BerryPowderBox._owner ~= fieldOwner() then + BerryPowderBox.hide() + end + return BerryPowderBox.visible +end + +function BerryPowderBox.amount() + return BerryPowderBox._amount +end + +-- pokefirered/src/berry_powder.c:97 +function BerryPowderBox.amountText(amount) + return string.format("%5d", clamp(amount)) +end + +function BerryPowderBox.draw() + if not BerryPowderBox.visible then return end + local px, py = TEMPLATE.tilemapLeft * 8, TEMPLATE.tilemapTop * 8 + Window.stdFrame(TEMPLATE) + -- pokefirered/src/berry_powder.c:104 + FrlgFont.draw(Strings("POWDER"), px, py, { small = true, colors = FrlgFont.COLOR.NORMAL }) + -- pokefirered/src/berry_powder.c:105 + FrlgFont.draw(BerryPowderBox.amountText(BerryPowderBox._amount), px + 39, py + 12, + { small = true, colors = FrlgFont.COLOR.NORMAL }) +end + +return BerryPowderBox diff --git a/src/ui/game3/box_storage_ui.lua b/src/ui/game3/box_storage_ui.lua index b33ff0d9..1b9daff7 100644 --- a/src/ui/game3/box_storage_ui.lua +++ b/src/ui/game3/box_storage_ui.lua @@ -18,6 +18,7 @@ local ReleaseSeq = require("src.ui.game3.release_seq") local SummaryMenu = require("src.ui.game3.summary_menu") local ItemsData = require("src.core.game3.items_data") local Strings = require("src.core.Strings") +local RomText = require("src.core.game3.rom_text") local BoxStorageUI = {} @@ -404,7 +405,7 @@ function BoxStorageUI.handleInput(input) if loc == "party" and mon then local party = (BoxStorageUI._session and BoxStorageUI._session.party) or {} if #party <= 1 then - BoxStorageUI._status = Strings("Can't deposit the last POKéMON!") + BoxStorageUI._status = RomText.plain("gText_JustOnePkmn") BoxStorageUI.mode = "message" se(26) -- pokefirered/src/pokemon_storage_system_tasks.c:1052 else @@ -447,7 +448,7 @@ function BoxStorageUI.handleInput(input) BoxStorageUI.mode = "message" se(246) elseif err == "bag_full" then - BoxStorageUI._status = Strings("The BAG is full.") + BoxStorageUI._status = RomText.plain("gText_BagIsFull2") BoxStorageUI.mode = "message" se(26) -- pokefirered/src/pokemon_storage_system_tasks.c:1487 else @@ -742,8 +743,7 @@ function BoxStorageUI.draw() local isHovered = (BoxStorageUI.cursorSlot == s and BoxStorageUI.mode ~= "party_drawer" and not BoxStorageUI.holdingMon) local bounceY = (isHovered and BoxStorageUI.hoverFrame == 1) and -2 or 0 local f = (isHovered and BoxStorageUI.hoverFrame == 1) and 1 or 0 - local sp = Pokemon.speciesOrEgg(mon) - local icon = Pokemon.icon(sp) + local icon = Pokemon.monIcon(mon) if icon and icon.image then local q = icon.quads and (icon.quads[f] or icon.quads[0]) @@ -808,8 +808,7 @@ function BoxStorageUI.draw() -- If holding a mon, draw floating mini-icon under hand cursor if BoxStorageUI.holdingMon then - local hSp = Pokemon.speciesOrEgg(BoxStorageUI.holdingMon) - local hIcon = Pokemon.icon(hSp) + local hIcon = Pokemon.monIcon(BoxStorageUI.holdingMon) if hIcon and hIcon.image then local q = hIcon.quads and hIcon.quads[0] love.graphics.setColor(1, 1, 1, 1) diff --git a/src/ui/game3/chrome.lua b/src/ui/game3/chrome.lua index f3935f7b..aba14e82 100644 --- a/src/ui/game3/chrome.lua +++ b/src/ui/game3/chrome.lua @@ -24,23 +24,14 @@ local PATHS = { dlg = { { path = "chrome/menu_message_rgba.rgba", w = 48, h = 24 }, { path = "data/generated/gba/chrome/menu_message_rgba.rgba", w = 48, h = 24 }, - { path = "src/import/gba/chrome/menu_message_rgba.png", w = 48, h = 24 }, - { path = "chrome/menu_message_rgba.png", w = 48, h = 24 }, - { path = "data/generated/gba/chrome/menu_message_rgba.png", w = 48, h = 24 }, }, std = { { path = "chrome/std_rgba.rgba", w = 24, h = 24 }, { path = "data/generated/gba/chrome/std_rgba.rgba", w = 24, h = 24 }, - { path = "src/import/gba/chrome/std_rgba.png", w = 24, h = 24 }, - { path = "chrome/std_rgba.png", w = 24, h = 24 }, - { path = "data/generated/gba/chrome/std_rgba.png", w = 24, h = 24 }, }, sign = { { path = "chrome/signpost_rgba.rgba", w = 40, h = 32 }, { path = "data/generated/gba/chrome/signpost_rgba.rgba", w = 40, h = 32 }, - { path = "src/import/gba/chrome/signpost_rgba.png", w = 40, h = 32 }, - { path = "chrome/signpost_rgba.png", w = 40, h = 32 }, - { path = "data/generated/gba/chrome/signpost_rgba.png", w = 40, h = 32 }, }, textCursor = { { path = "chrome/fonts/text_cursor.rgba", w = 16, h = 16 }, @@ -49,9 +40,6 @@ local PATHS = { arrow = { { path = "chrome/fonts/down_arrows_fg.rgba", w = 128, h = 16 }, { path = "data/generated/gba/chrome/fonts/down_arrows_fg.rgba", w = 128, h = 16 }, - { path = "src/import/gba/chrome/fonts/down_arrows_fg.rgba", w = 128, h = 16 }, - { path = "chrome/fonts/down_arrows_fg.png", w = 128, h = 16 }, - { path = "data/generated/gba/chrome/fonts/down_arrows_fg.png", w = 128, h = 16 }, }, } @@ -325,8 +313,6 @@ local function ensureUser(frameType) local img, path = loadImage({ { path = rel, w = 24, h = 24 }, { path = "data/generated/gba/" .. rel, w = 24, h = 24 }, - { path = "src/import/gba/chrome/user_frame_" .. n .. ".png", w = 24, h = 24 }, - { path = "src/import/gba/chrome/user_frame_rgba.png", w = 24, h = 24 }, }) Chrome._user[n] = img and { image = img, quads = makeQuads(img, 3, 3), path = path } or false return Chrome._user[n] or nil diff --git a/src/ui/game3/credits.lua b/src/ui/game3/credits.lua new file mode 100644 index 00000000..73dabf38 --- /dev/null +++ b/src/ui/game3/credits.lua @@ -0,0 +1,905 @@ +-- pokefirered/src/credits.c:711 DoCredits + +local Stack = require("src.ui.game3.stack") +local FrlgFont = require("src.ui.game3.frlg_font") +local Extract = require("src.import.gba.extract_island1") + +local Credits = {} + +Credits.ID = "credits" +Credits.open = false + +-- pokefirered/src/credits.c:25 +local SCENE = { + INIT_WIN0 = 0, SETUP_DARKEN_EFFECT = 1, OPEN_WIN0 = 2, LOAD_PLAYER_SPRITE_AT_INDIGO = 3, + PRINT_TITLE_STAFF = 4, WAIT_TITLE_STAFF = 5, EXEC_CMD = 6, PRINT_ADDPRINTER1 = 7, + PRINT_ADDPRINTER2 = 8, PRINT_DELAY = 9, MAPNEXT_DESTROYWINDOW = 10, MAPNEXT_LOADMAP = 11, + MAP_LOADMAP_CREATESPRITES = 12, MON_DESTROY_ASSETS = 13, MON_SHOW = 14, + THEEND_DESTROY_ASSETS = 15, THEEND_SHOW = 16, WAITBUTTON = 17, TERMINATE = 18, +} +Credits.SCENE = SCENE + +-- pokefirered/src/credits.c:48 +local CMD_PRINT, CMD_MAPNEXT, CMD_MAP, CMD_MON, CMD_THEENDGFX, CMD_WAITBUTTON = 0, 1, 2, 3, 4, 5 +Credits.CMD = { + PRINT = CMD_PRINT, MAPNEXT = CMD_MAPNEXT, MAP = CMD_MAP, MON = CMD_MON, + THEENDGFX = CMD_THEENDGFX, WAITBUTTON = CMD_WAITBUTTON, +} + +-- pokefirered/include/constants/flags.h:1530 +local FLAG_DONT_SHOW_MAP_NAME_POPUP = 0x4000 +-- pokefirered/src/credits.c:456 +local WIN_X, WIN_Y = 0, 32 +-- pokefirered/src/credits.c:453 +local HEADER_FG, HEADER_SHADOW = 5, 2 +local REGULAR_FG, REGULAR_SHADOW = 1, 2 +-- pokefirered/src/new_menu_helpers.c:74 +local LINE_HEIGHT = 14 +local BLACK, WHITE = { 0, 0, 0 }, { 1, 1, 1 } + +-- pokefirered/src/palette.c:151 +local ALL = { world = true, text = true, sprite = true, screen = true } +local TEXT_PAL = { text = true } +local FIELD_PALS = { world = true, text = true, screen = true } + +local M +local images = {} + +local function root() + return (Extract.CACHE_ROOT or "data/generated/gba") .. "/credits/" +end + +local function read(rel) + return require("src.core.game3.dataset").cache():read(root() .. rel) +end + +local function load_table(rel) + local src = read(rel) + if not src then return nil end + local chunk = load(src, "@credits/" .. rel, "t", {}) + if not chunk then return nil end + local ok, v = pcall(chunk) + if ok and type(v) == "table" then return v end + return nil +end + +local function image(name, w, h) + local key = name + if images[key] ~= nil then return images[key] or nil end + local bytes = read(name .. ".rgba") + local entry = false + if bytes and #bytes == w * h * 4 then + local data = love.image.newImageData(w, h, "rgba8", bytes) + local img = love.graphics.newImage(data) + img:setFilter("nearest", "nearest") + entry = { image = img, data = data, w = w, h = h } + end + images[key] = entry + return entry or nil +end + +local function quad_cache(entry, fw, fh, frame) + entry.quads = entry.quads or {} + local q = entry.quads[frame] + if not q then + q = love.graphics.newQuad(0, frame * fh, fw, fh, entry.w, entry.h) + entry.quads[frame] = q + end + return q +end + +local function flags() + return require("src.core.game3.scripting.flags") +end + +local function store() + local Space = package.loaded["src.core.game3.scripting.space"] + return Space and Space.store +end + +local function session() + local rt = package.loaded["src.core.game3.runtime"] + return rt and rt.getSession and rt.getSession() +end + +local function game() + local rt = package.loaded["src.core.game3.runtime"] + return rt and rt._game +end + +-- pokefirered/src/palette.c:151 BeginNormalPaletteFade +local function begin_fade(groups, delay, startY, targetY, color) + local f = M.fade + if f and f.active then return false end + M.fade = { + groups = groups, delay = delay, counter = delay, y = startY, target = targetY, + color = color or BLACK, toggle = false, finishing = false, fcount = 0, active = true, + } + Credits.updateFade() + return true +end + +local function fade_active() + return M.fade ~= nil and M.fade.active +end + +-- pokefirered/src/palette.c:393 UpdateNormalPaletteFade +function Credits.updateFade() + local f = M.fade + if not (f and f.active) then return end + if f.finishing then + -- pokefirered/src/palette.c:757 + if f.fcount == 4 then + f.active = false + f.finishing = false + f.fcount = 0 + else + f.fcount = f.fcount + 1 + end + return + end + if not f.toggle then + if f.counter < f.delay then + f.counter = f.counter + 1 + return + end + f.counter = 0 + end + for g in pairs(f.groups) do + M.level[g] = { y = f.y, color = f.color } + end + f.toggle = not f.toggle + if not f.toggle then + if f.y == f.target then + f.finishing = true + elseif f.y < f.target then + f.y = math.min(f.target, f.y + 2) + else + f.y = math.max(f.target, f.y - 2) + end + end +end + +local function set_level(g, y, color) + M.level[g] = { y = y, color = color or BLACK } +end + +-- pokefirered/src/credits.c:769 +local function create_window() + M.text = nil + M.windowActive = true + -- pokefirered/src/credits.c:830 + set_level("text", M.level.world.y, M.level.world.color) +end + +-- pokefirered/src/credits.c:778 +local function destroy_window() + if M.windowActive then + M.text = nil + M.windowActive = false + end +end + +-- pokefirered/src/credits.c:1336 LoadPlayerOrRivalSprite +local function load_player_or_rival(whichScene) + if M.task then return end + local p = M.pack.spriteParams[whichScene] + if not p then return end + local x, y + if p.motion == 1 then + x, y = 240 + 32, 80 + elseif p.motion == 2 then + x, y = 240 - 32, 160 + else + x, y = 240 - 32, 80 + end + local s = session() or {} + local g = s.gender or s.playerGender + local female = g == 1 or g == "female" or g == "F" + local sheet = p.character == 1 and "rival" or (female and "player_female" or "player_male") + local ground = ({ [0] = "ground_grass", [1] = "ground_grass", [2] = "ground_dirt", [3] = "ground_city" })[p.ground] + M.task = { + motion = p.motion, + sheet = sheet, + ground = ground, + groundRunning = p.ground ~= 1, + x = x, y = y, gx = x, gy = y + 38, + frame = 0, frameTimer = 0, gframe = 0, gframeTimer = 0, + } + set_level("sprite", 0) +end + +-- pokefirered/src/credits.c:1322 DestroyPlayerOrRivalSprite +local function destroy_player_or_rival() + M.task = nil +end + +-- pokefirered/src/credits.c:1280 Task_MovePlayerAndGroundSprites +local function run_tasks() + local t = M.task + if not t then return end + if t.motion == 1 then + if t.x ~= 0xD0 then + t.x, t.gx = t.x - 1, t.gx - 1 + else + t.motion = 0 + end + elseif t.motion == 2 then + if M.parity % 2 == 1 then + if t.y ~= 0x50 then + t.y, t.gy = t.y - 1, t.gy - 1 + else + t.motion = 0 + end + end + elseif t.motion == 3 then + if M.mainseq == SCENE.THEEND_DESTROY_ASSETS then + t.x, t.gx = t.x - 1, t.gx - 1 + end + end +end + +-- pokefirered/src/credits.c:499 +local function animate_sprites() + local t = M.task + if not t then return end + t.frameTimer = t.frameTimer + 1 + if t.frameTimer >= 8 then + t.frameTimer = 0 + t.frame = (t.frame + 1) % 6 + end + if t.groundRunning then + t.gframeTimer = t.gframeTimer + 1 + if t.gframeTimer >= 8 then + t.gframeTimer = 0 + t.gframe = (t.gframe + 1) % 8 + end + end +end + +-- pokefirered/src/overworld.c:2490 CameraCB_CreditsPan +local function camera_update() + local c = M.cam + if not c then return end + if c.len == 0 then + c.idx = c.idx + 1 + local cmd = c.cmds[c.idx] + if not cmd then + c.sx, c.sy = 0, 0 + M.cam = nil + return + end + c.len = cmd.length + c.sx, c.sy = cmd.xspeed, cmd.yspeed + end + c.len = c.len - 1 + c.px = c.px + c.sx + c.py = c.py + c.sy + local FieldView = require("src.core.game3.field_view") + FieldView.setCameraPanning(c.px, c.py) +end + +local function overworld_main_cb() + camera_update() + local okA, TilesetAnim = pcall(require, "src.core.game3.tileset_anim") + if okA and TilesetAnim and TilesetAnim.step then TilesetAnim.step() end +end + +-- pokefirered/src/overworld.c:2384 SetUpScrollSceneForCredits +local function load_scene_map(which) + local scene = M.pack.scenes[which] + local load = scene and scene[1] + if not (load and load.op == "loadmap") then return end + local MapCatalog = require("src.import.gba.map_catalog") + local mapId = MapCatalog.mapIdFor(load.mapGroup, load.mapNum) + local cmds = {} + for i = 2, #scene do + if scene[i].op == "scroll" then cmds[#cmds + 1] = scene[i] end + end + local FieldView = require("src.core.game3.field_view") + FieldView.setCameraPanning(0, 0) + M.cam = { cmds = cmds, idx = 0, len = load.delay, sx = 0, sy = 0, px = 0, py = 0 } + local g = game() + if g and mapId then + require("src.core.game3.map").load(nil, g, mapId, { x = load.x, y = load.y, facing = "down" }) + end + set_level("world", 16) + set_level("text", 16) +end + +local function scroll_scene_ready() + -- pokefirered/src/credits.c:803 + create_window() + M.win0 = { top = 36, bottom = 160 - 36 } + M.darken = true +end + +-- pokefirered/src/credits.c:788 DoOverworldMapScrollScene +local function do_overworld_map_scroll_scene() + if M.subseq == 0 then + flags().setFlag(store(), nil, FLAG_DONT_SHOW_MAP_NAME_POPUP, true) + local Map = require("src.core.game3.map") + Map.disableMusicChange = Map.MUSIC_DISABLE_KEEP + M.ovwld = 0 + M.subseq = 1 + end + -- pokefirered/src/overworld.c:2384 + M.ovwld = M.ovwld + 1 + if M.ovwld == 3 then + load_scene_map(M.whichMon) + elseif M.ovwld == 11 then + -- pokefirered/src/overworld.c:2481 + begin_fade(FIELD_PALS, 0, 16, 0, BLACK) + elseif M.ovwld == 13 then + M.ovwld = 0 + scroll_scene_ready() + return true + end + return false +end + +-- pokefirered/src/credits.c:1101 DoCreditsMonScene +local function do_credits_mon_scene() + local mon = M.mon + local s = M.subseq + if s == 0 then + M.fade = nil + destroy_player_or_rival() + M.win0 = nil + M.darken = false + local def = M.pack.mons[M.whichMon] + local size = M.manifest.mons[M.whichMon] + M.mon = { + def = def, + timer = 0, + shown = 0, + circleScale = 0, + showCircle = false, + showWindows = false, + showBall = false, + ball = image("pokeball_" .. M.whichMon, 240, 160), + frames = { + image("mon_" .. M.whichMon .. "_1", size.frame1.w, size.frame1.h), + image("mon_" .. M.whichMon .. "_2", size.frame2.w, size.frame2.h), + }, + } + set_level("screen", 16) + M.subseq = 1 + elseif s == 1 then + mon.shown = 0 + M.subseq = 2 + elseif s == 2 then + mon.showCircle = true + mon.showWindows = true + begin_fade(ALL, 0, 16, 0, BLACK) + mon.timer = 40 + M.subseq = 3 + elseif s == 3 then + if mon.timer ~= 0 then + mon.timer = mon.timer - 1 + else + M.subseq = 4 + end + elseif s == 4 then + if not fade_active() then + mon.timer = 8 + mon.next = 1 + M.subseq = 5 + end + elseif s == 5 then + if mon.timer ~= 0 then + mon.timer = mon.timer - 1 + elseif mon.next < 3 then + mon.shown = mon.next + mon.timer = 4 + mon.next = mon.next + 1 + else + M.subseq = 6 + end + elseif s == 6 then + if mon.timer < 256 then + mon.timer = mon.timer + 16 + mon.circleScale = mon.timer + else + mon.circleScale = 0x100 + mon.timer = 32 + M.subseq = 7 + end + elseif s == 7 then + if mon.timer ~= 0 then + mon.timer = mon.timer - 1 + else + mon.showCircle = false + mon.showBall = true + pcall(function() require("src.core.game3.audio").playCry(mon.def.species) end) + mon.timer = 128 + M.subseq = 8 + end + elseif s == 8 then + if mon.timer ~= 0 then + mon.timer = mon.timer - 1 + else + begin_fade(ALL, 0, 0, 16, BLACK) + M.subseq = 9 + end + elseif s == 9 then + if not fade_active() then + M.subseq = 0 + M.mon = nil + return true + end + end + return false +end + +-- pokefirered/src/credits.c:1230 DoCopyrightOrTheEndGfxScene +local function do_copyright_or_the_end_gfx_scene() + local s = M.subseq + if s == 0 then + M.fade = nil + destroy_player_or_rival() + M.win0 = nil + M.darken = false + M.world = false + M.closing = { img = image(M.whichMon == 0 and "copyright" or "the_end", 240, 160), shown = false } + M.subseq = 1 + elseif s == 1 then + M.subseq = 2 + elseif s == 2 then + M.closing.shown = true + if M.whichMon ~= 0 then + begin_fade(ALL, 0, 0, 0, BLACK) + else + begin_fade(ALL, 0, 16, 0, BLACK) + end + M.subseq = 3 + elseif s == 3 then + if not fade_active() then + M.subseq = 0 + return true + end + end + return false +end + +local function cmd_at(i) + return M.pack.script[i] +end + +-- pokefirered/src/credits.c:815 RollCredits +local function roll_credits() + local seq = M.mainseq + if seq == SCENE.INIT_WIN0 then + M.win0 = { top = 80 - 1, bottom = 80 + 1 } + M.mainseq = SCENE.SETUP_DARKEN_EFFECT + return 0 + elseif seq == SCENE.SETUP_DARKEN_EFFECT then + M.darken = true + create_window() + M.mainseq = SCENE.OPEN_WIN0 + return 0 + elseif seq == SCENE.OPEN_WIN0 then + if M.win0.top == 0x24 then + M.timer = 0 + M.mainseq = SCENE.LOAD_PLAYER_SPRITE_AT_INDIGO + else + M.win0.top = M.win0.top - 1 + M.win0.bottom = M.win0.bottom + 1 + end + return 0 + elseif seq == SCENE.LOAD_PLAYER_SPRITE_AT_INDIGO then + if M.timer ~= 0 then + M.timer = M.timer - 1 + return 0 + end + load_player_or_rival(0) + M.timer = 100 + M.mainseq = SCENE.PRINT_TITLE_STAFF + return 0 + elseif seq == SCENE.PRINT_TITLE_STAFF then + if M.timer ~= 0 then + M.timer = M.timer - 1 + return 0 + end + M.timer = 360 + -- pokefirered/src/credits.c:868 + M.text = { staff = true, title = M.pack.title } + M.mainseq = SCENE.WAIT_TITLE_STAFF + return 0 + elseif seq == SCENE.WAIT_TITLE_STAFF then + if M.timer ~= 0 then + M.timer = M.timer - 1 + return 0 + end + destroy_window() + M.mainseq = SCENE.EXEC_CMD + M.timer = 0 + M.scrcmdidx = 1 + return 0 + elseif seq == SCENE.EXEC_CMD then + if M.timer ~= 0 then + M.timer = M.timer - 1 + return M.canSpeedThrough + end + local c = cmd_at(M.scrcmdidx) + if not c then + M.mainseq = SCENE.TERMINATE + return 0 + end + if c.cmd == CMD_PRINT then + begin_fade(TEXT_PAL, 0, 0, 16, BLACK) + M.mainseq = SCENE.PRINT_ADDPRINTER1 + M.text = nil + return M.canSpeedThrough + elseif c.cmd == CMD_MAPNEXT then + M.mainseq = SCENE.MAPNEXT_DESTROYWINDOW + M.whichMon = c.param + -- pokefirered/src/credits.c:898 + begin_fade(FIELD_PALS, 0, 0, 16, BLACK) + elseif c.cmd == CMD_MAP then + M.mainseq = SCENE.MAP_LOADMAP_CREATESPRITES + M.whichMon = c.param + elseif c.cmd == CMD_MON then + M.mainseq = SCENE.MON_DESTROY_ASSETS + M.whichMon = c.param + -- pokefirered/src/credits.c:907 + begin_fade(ALL, 0, 0, 16, BLACK) + elseif c.cmd == CMD_THEENDGFX then + M.mainseq = SCENE.THEEND_DESTROY_ASSETS + M.whichMon = c.param + begin_fade(ALL, 4, 0, 16, BLACK) + elseif c.cmd == CMD_WAITBUTTON then + M.mainseq = SCENE.WAITBUTTON + end + M.timer = c.duration + M.scrcmdidx = M.scrcmdidx + 1 + return 0 + elseif seq == SCENE.PRINT_ADDPRINTER1 then + if fade_active() then return M.canSpeedThrough end + local c = cmd_at(M.scrcmdidx) + local t = M.pack.texts[c.param] or {} + M.text = { title = t.title or "" } + M.mainseq = SCENE.PRINT_ADDPRINTER2 + return M.canSpeedThrough + elseif seq == SCENE.PRINT_ADDPRINTER2 then + local c = cmd_at(M.scrcmdidx) + local t = M.pack.texts[c.param] or {} + M.text.names = t.names or "" + M.mainseq = SCENE.PRINT_DELAY + return M.canSpeedThrough + elseif seq == SCENE.PRINT_DELAY then + local c = cmd_at(M.scrcmdidx) + M.timer = c.duration + M.scrcmdidx = M.scrcmdidx + 1 + begin_fade(TEXT_PAL, 0, 16, 0, BLACK) + M.mainseq = SCENE.EXEC_CMD + return M.canSpeedThrough + elseif seq == SCENE.MAPNEXT_DESTROYWINDOW then + if not fade_active() then + destroy_window() + M.subseq = 0 + M.mainseq = SCENE.MAPNEXT_LOADMAP + end + return 0 + elseif seq == SCENE.MAPNEXT_LOADMAP then + if do_overworld_map_scroll_scene() then + M.canSpeedThrough = 1 + M.mainseq = SCENE.EXEC_CMD + end + return 0 + elseif seq == SCENE.MAP_LOADMAP_CREATESPRITES then + if not fade_active() then + destroy_window() + M.subseq = 0 + M.world = true + set_level("screen", 0) + while not do_overworld_map_scroll_scene() do end + -- pokefirered/src/credits.c:966 + local scene = ({ [3] = 1, [6] = 2, [9] = 3, [12] = 4 })[M.whichMon] or 1 + load_player_or_rival(scene) + M.canSpeedThrough = 1 + M.mainseq = SCENE.EXEC_CMD + end + return 0 + elseif seq == SCENE.MON_DESTROY_ASSETS then + if not fade_active() then + destroy_player_or_rival() + destroy_window() + M.subseq = 0 + M.canSpeedThrough = 0 + M.world = false + M.mainseq = SCENE.MON_SHOW + end + return 0 + elseif seq == SCENE.MON_SHOW then + if do_credits_mon_scene() then + M.mainseq = SCENE.EXEC_CMD + end + return 0 + elseif seq == SCENE.THEEND_DESTROY_ASSETS then + if not fade_active() then + destroy_window() + M.subseq = 0 + M.canSpeedThrough = 0 + M.mainseq = SCENE.THEEND_SHOW + end + return 0 + elseif seq == SCENE.THEEND_SHOW then + if do_copyright_or_the_end_gfx_scene() then + M.mainseq = SCENE.EXEC_CMD + end + return 0 + elseif seq == SCENE.WAITBUTTON then + if M.aPressed then + begin_fade(ALL, 0, 0, 16, WHITE) + M.mainseq = SCENE.TERMINATE + return 0 + end + if M.timer ~= 0 then + M.timer = M.timer - 1 + else + M.mainseq = SCENE.TERMINATE + begin_fade(ALL, 0, 0, 16, WHITE) + end + return 0 + elseif seq == SCENE.TERMINATE then + if not fade_active() then destroy_window() end + end + return 2 +end + +function Credits.isOpen() + return Credits.open +end + +function Credits.state() + if not M then return nil end + local c = M.pack and M.pack.script[M.scrcmdidx - 1] + return { + mainseq = M.mainseq, + subseq = M.subseq, + scrcmdidx = M.scrcmdidx, + lastCmd = c and c.cmd, + whichMon = M.whichMon, + text = M.text, + textLevel = M.level.text.y, + task = M.task, + mon = M.mon, + closing = M.closing, + frames = M.frames, + world = M.world, + } +end + +function Credits.start(opts) + opts = opts or {} + local pack = load_table("pack.lua") + local manifest = load_table("manifest.lua") + if not (pack and manifest) then return false end + images = {} + M = { + pack = pack, + manifest = manifest, + mainseq = SCENE.INIT_WIN0, + subseq = 0, + timer = 0, + scrcmdidx = 1, + canSpeedThrough = 0, + whichMon = 0, + windowActive = false, + parity = 0, + frames = 0, + world = true, + darken = false, + level = { + world = { y = 0, color = BLACK }, text = { y = 0, color = BLACK }, + sprite = { y = 0, color = BLACK }, screen = { y = 0, color = BLACK }, + }, + } + local FieldView = require("src.core.game3.field_view") + FieldView.hideActors = true + FieldView.setCameraPanning(0, 0) + Credits.open = true + Stack.push(Credits.ID, Credits, { hideBelow = true }) + return true +end + +-- pokefirered/src/credits.c:746 +local function terminate() + Credits.open = false + Stack.pop(Credits.ID) + flags().setFlag(store(), nil, FLAG_DONT_SHOW_MAP_NAME_POPUP, false) + local Map = require("src.core.game3.map") + Map.disableMusicChange = Map.MUSIC_DISABLE_OFF + local FieldView = require("src.core.game3.field_view") + FieldView.hideActors = false + FieldView.setCameraPanning(0, 0) + M.done = true + local g = game() + if g then g.softResetRequested = true end +end + +function Credits.reset() + Credits.open = false + M = nil + local Map = require("src.core.game3.map") + Map.disableMusicChange = Map.MUSIC_DISABLE_OFF + local FieldView = require("src.core.game3.field_view") + FieldView.hideActors = false + FieldView.setCameraPanning(0, 0) +end + +function Credits.update(_dt) + if not (Credits.open and M) then return end + M.frames = M.frames + 1 + local r = roll_credits() + M.aPressed = false + if r == 2 then + terminate() + return + end + run_tasks() + animate_sprites() + if r == 1 and M.parity % 2 == 1 then overworld_main_cb() end + Credits.updateFade() + if r == 1 then M.parity = M.parity + 1 end +end + +-- pokefirered/src/credits.c:1015 +function Credits.handleInput(input) + if not (Credits.open and M and input) then return end + if M.mainseq == SCENE.WAITBUTTON and input:wasPressed("a") then + M.aPressed = true + end +end + +local function lerp_color(c, level) + local a = (level.y or 0) / 16 + local t = level.color or BLACK + return { c[1] + (t[1] - c[1]) * a, c[2] + (t[2] - c[2]) * a, c[3] + (t[3] - c[3]) * a, 1 } +end + +local function print_block(text, x, y, fg, shadow, pitch) + local colors = { + fg = lerp_color(FrlgFont.STDPAL[fg], M.level.text), + shadow = lerp_color(FrlgFont.STDPAL[shadow], M.level.text), + bg = FrlgFont.STDPAL[0], + } + local line = 0 + for raw in (tostring(text) .. "\n"):gmatch("(.-)\n") do + local lx, body = x, raw + local clear, rest = raw:match("^{CLEAR_TO (%d+)}(.*)$") + if clear then + lx = x + tonumber(clear) + body = rest + end + if body ~= "" then + FrlgFont.draw(body, WIN_X + lx, WIN_Y + y + line * pitch, { colors = colors }) + end + line = line + 1 + end +end + +local function with_band(fn) + local w = M.win0 + if not w then return end + local sx, sy, sw, sh = love.graphics.getScissor() + love.graphics.intersectScissor(0, w.top, 240, math.max(0, w.bottom - w.top)) + fn() + if sx then love.graphics.setScissor(sx, sy, sw, sh) else love.graphics.setScissor() end +end + +local function draw_sprite(entry, fw, fh, frame, cx, cy, level) + if not entry then return end + local k = 1 - level.y / 16 + love.graphics.setColor(k, k, k, 1) + love.graphics.draw(entry.image, quad_cache(entry, fw, fh, frame), cx - fw / 2, cy - fh / 2) + love.graphics.setColor(1, 1, 1, 1) +end + +local function draw_field_layer() + local Renderer = package.loaded["src.render.Renderer"] + local wl = M.level.world + if Renderer then + Renderer.worldFadeAlpha = wl.y / 16 + Renderer.worldFadeColor = wl.color + end + -- pokefirered/src/credits.c:762 InitBgDarkenEffect + if M.darken and M.win0 then + love.graphics.setColor(0, 0, 0, 10 / 16) + love.graphics.rectangle("fill", 0, M.win0.top, 240, M.win0.bottom - M.win0.top) + love.graphics.setColor(1, 1, 1, 1) + end + with_band(function() + local t = M.text + if t and M.windowActive then + if t.staff then + print_block(t.title, 8, 0x29, HEADER_FG, HEADER_SHADOW, LINE_HEIGHT + 2) + else + print_block(t.title or "", 2, 6, HEADER_FG, HEADER_SHADOW, LINE_HEIGHT) + if t.names then print_block(t.names, 8, 6, REGULAR_FG, REGULAR_SHADOW, LINE_HEIGHT) end + end + end + local task = M.task + if task then + local sl = M.level.sprite + local ground = image(task.ground, 64, 32 * 8) + draw_sprite(ground, 64, 32, task.groundRunning and task.gframe or 0, task.gx, task.gy, sl) + draw_sprite(image(task.sheet, 64, 64 * 6), 64, 64, task.frame, task.x, task.y, sl) + end + end) +end + +local function screen_overlay() + local l = M.level.screen + if l.y > 0 then + love.graphics.setColor(l.color[1], l.color[2], l.color[3], l.y / 16) + love.graphics.rectangle("fill", 0, 0, 240, 160) + love.graphics.setColor(1, 1, 1, 1) + end +end + +local function draw_mon_scene() + local mon = M.mon + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", 0, 0, 240, 160) + love.graphics.setColor(1, 1, 1, 1) + if not mon then return end + if mon.showBall and mon.ball then + love.graphics.draw(mon.ball.image, 0, 0) + end + if mon.showCircle then + local circle = image("circle", M.manifest.circle.w, M.manifest.circle.h) + if circle then + -- pokefirered/src/credits.c:1126 + local s = mon.circleScale + if s <= 0 then + local r, g, b, a = circle.data:getPixel(circle.w / 2, circle.h / 2) + love.graphics.setColor(r, g, b, a) + love.graphics.rectangle("fill", 0, 0, 240, 160) + love.graphics.setColor(1, 1, 1, 1) + else + local k = 256 / s + love.graphics.draw(circle.image, 0x78, 0x50, 0, k, k, circle.w / 2, circle.h / 2) + end + end + end + if mon.showWindows then + local wins = mon.def.windows + if mon.shown == 0 then + local Pokemon = require("src.core.game3.pokemon") + local pic = Pokemon.frontPic(mon.def.species) + if pic and pic.image then + love.graphics.draw(pic.image, wins[1].left * 8, wins[1].top * 8) + end + else + local f = mon.frames[mon.shown] + local w = wins[mon.shown + 1] + if f and w then love.graphics.draw(f.image, w.left * 8, w.top * 8) end + end + end + screen_overlay() +end + +function Credits.draw() + if not (Credits.open and M) then return end + if M.mainseq == SCENE.MON_SHOW then + local Renderer = package.loaded["src.render.Renderer"] + if Renderer then Renderer.worldFadeAlpha = 1 Renderer.worldFadeColor = BLACK end + draw_mon_scene() + return + end + if M.closing then + local Renderer = package.loaded["src.render.Renderer"] + if Renderer then Renderer.worldFadeAlpha = 1 Renderer.worldFadeColor = BLACK end + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", 0, 0, 240, 160) + love.graphics.setColor(1, 1, 1, 1) + if M.closing.shown and M.closing.img then love.graphics.draw(M.closing.img.image, 0, 0) end + screen_overlay() + return + end + draw_field_layer() +end + +return Credits diff --git a/src/ui/game3/diploma.lua b/src/ui/game3/diploma.lua new file mode 100644 index 00000000..f4f6912f --- /dev/null +++ b/src/ui/game3/diploma.lua @@ -0,0 +1,155 @@ +-- pokefirered/src/diploma.c:100 + +local Display = require("src.core.game3.display") +local Stack = require("src.ui.game3.stack") +local FrlgFont = require("src.ui.game3.frlg_font") +local RomText = require("src.core.game3.rom_text") +local Extract = require("src.import.gba.extract_island1") + +local Diploma = {} + +Diploma.ID = "diploma" + +-- pokefirered/include/constants/songs.h:267 +local MUS_OBTAIN_BADGE = 260 +-- pokefirered/src/diploma.c:58 +-- pokefirered/src/diploma.c:97 +local TEXT_COLORS = { fg = FrlgFont.STDPAL[2], shadow = FrlgFont.STDPAL[3], bg = { 0, 0, 0, 0 } } +-- pokefirered/src/new_menu_helpers.c:84 +local LINE_PITCH = 14 +-- pokefirered/src/diploma.c:84 +local WIN_X, WIN_Y = 0, 16 + +Diploma.open = false +Diploma._phase = "idle" +Diploma._national = false +Diploma._onDone = nil +Diploma._images = {} + +local function fade() + return require("src.ui.game3.fade") +end + +local function image(key) + if Diploma._images[key] ~= nil then return Diploma._images[key] or nil end + local rel = (Extract.CACHE_ROOT or "data/generated/gba") .. "/diploma/" .. key .. ".rgba" + local bytes = require("src.core.game3.dataset").cache():read(rel) + local img = false + if bytes and #bytes == Display.W * Display.H * 4 and love and love.image then + local data = love.image.newImageData(Display.W, Display.H, "rgba8", bytes) + img = love.graphics.newImage(data) + img:setFilter("nearest", "nearest") + end + Diploma._images[key] = img + return img or nil +end + +-- pokefirered/src/pokedex.c:123 +local function has_all_mons() + local Std = require("src.core.game3.scripting.stdscripts") + local Queries = require("src.core.game3.scripting.natives_queries") + local _, v = Queries.HANDLERS[Std.SPECIAL.HasAllMons](nil) + return v == 1 +end + +local function player_name() + local rt = package.loaded["src.core.game3.runtime"] + local session = rt and rt.getSession and rt.getSession() + return tostring((session and (session.name or session.playerName)) or "") +end + +function Diploma.isOpen() + return Diploma.open +end + +function Diploma.isNational() + return Diploma._national +end + +function Diploma.phase() + return Diploma._phase +end + +-- pokefirered/src/diploma.c:119 +function Diploma.show(opts) + opts = opts or {} + if not image("kanto") or not image("national") then return false end + Diploma._onDone = opts.onDone + -- pokefirered/src/diploma.c:138 + Diploma._national = has_all_mons() + local name = player_name() + -- pokefirered/src/diploma.c:260 + local dynamic = { + [0] = name, + [1] = RomText.plain(Diploma._national and "gText_Diploma_National" or "gText_Diploma_Kanto"), + } + Diploma._player = RomText.plain("gText_Diploma_Player", { dynamic = dynamic }) + Diploma._body = RomText.plain("gText_Diploma_ThisDocument", { dynamic = dynamic }) + Diploma._gameFreak = RomText.plain("gText_Diploma_GameFreak") + Diploma._playerX = 120 - math.floor(FrlgFont.measure(Diploma._player) / 2) + Diploma._bodyX = 120 - math.floor(FrlgFont.measure(Diploma._body) / 2) + Diploma.open = true + Diploma._phase = "in" + Stack.push(Diploma.ID, Diploma, { hideBelow = true }) + local Fade = fade() + -- pokefirered/src/diploma.c:150 + Fade.begin(Fade.MODE.FROM_BLACK, 1, function() + if Diploma._phase ~= "in" then return end + Diploma._phase = "fanfare" + -- pokefirered/src/diploma.c:158 + require("src.core.game3.audio").playFanfare(MUS_OBTAIN_BADGE) + end) + return true +end + +local function finish() + Diploma.open = false + Diploma._phase = "idle" + Stack.pop(Diploma.ID) + local cb = Diploma._onDone + Diploma._onDone = nil + -- pokefirered/src/overworld.c:1677 + local Space = package.loaded["src.core.game3.scripting.space"] + if Space and Space.returnToField then pcall(Space.returnToField) end + local Fade = fade() + Fade.begin(Fade.MODE.FROM_BLACK, 1, function() end) + if cb then cb() end +end + +-- pokefirered/src/diploma.c:164 +function Diploma.update(_dt) + if not Diploma.open then return end + if Diploma._phase == "fanfare" then + if require("src.core.game3.audio").isFanfareFinished() then + Diploma._phase = "wait" + end + end +end + +function Diploma.handleInput(input) + if not Diploma.open or not input then return end + if Diploma._phase ~= "wait" then return end + if input:wasPressed("a") then + Diploma._phase = "out" + local Fade = fade() + -- pokefirered/src/diploma.c:175 + Fade.begin(Fade.MODE.TO_BLACK, 1, function() + if Diploma._phase == "out" then finish() end + end) + end +end + +function Diploma.draw() + if not Diploma.open then return end + -- pokefirered/src/diploma.c:138 + local img = image(Diploma._national and "national" or "kanto") + love.graphics.setColor(1, 1, 1, 1) + if img then love.graphics.draw(img, 0, 0) end + -- pokefirered/src/diploma.c:269 + local opts = { colors = TEXT_COLORS, linePitch = LINE_PITCH } + FrlgFont.draw(Diploma._player or "", WIN_X + Diploma._playerX, WIN_Y + 4, opts) + FrlgFont.draw(Diploma._body or "", WIN_X + Diploma._bodyX, WIN_Y + 30, opts) + FrlgFont.draw(Diploma._gameFreak or "", WIN_X + 120, WIN_Y + 105, opts) +end + +return Diploma diff --git a/src/ui/game3/egg_hatch.lua b/src/ui/game3/egg_hatch.lua index bf02e6c0..b888e21d 100644 --- a/src/ui/game3/egg_hatch.lua +++ b/src/ui/game3/egg_hatch.lua @@ -155,6 +155,13 @@ local function finish_scene() end EggHatch._finish = finish_scene +-- pokefirered/src/daycare.c:1705 +local function hatched_pic() + local mon = EggHatch._mon + return Pokemon.frontPic(Pokemon.picSpecies(EggHatch._species, mon and mon.personality), nil, + Pokemon.isShiny(mon), mon and mon.personality) +end + function EggHatch.start(mon, opts) opts = opts or {} if not mon then @@ -184,7 +191,7 @@ function EggHatch.start(mon, opts) EggHatch.open = true if Oam and Oam.destroyAll then Oam.destroyAll() end if Message.setFrame then Message.setFrame("dialogue") end - pcall(Pokemon.frontPic, EggHatch._species) + pcall(hatched_pic) pcall(Pokemon.frontPic, Pokemon.SPECIES_EGG) Stack.push(STACK_ID, EggHatch, { hideBelow = true }) return true @@ -413,7 +420,7 @@ function EggHatch.draw() end end else - local pic = Pokemon.frontPic(EggHatch._species) + local pic = hatched_pic() if pic and pic.image then love.graphics.draw(pic.image, MON_X, MON_Y, 0, 1, 1, 32, 32) end diff --git a/src/ui/game3/evolution_scene.lua b/src/ui/game3/evolution_scene.lua index e4ace88a..30ab164d 100644 --- a/src/ui/game3/evolution_scene.lua +++ b/src/ui/game3/evolution_scene.lua @@ -71,6 +71,13 @@ function EvolutionScene.isOpen() return EvolutionScene.open end +-- pokefirered/src/evolution_scene.c:266 +local function evo_pic(species) + local mon = EvolutionScene._mon + return Pokemon.frontPic(Pokemon.picSpecies(species, mon and mon.personality), nil, Pokemon.isShiny(mon), + mon and mon.personality) +end + --- Start an evolution scene. -- @param mon table The Pokémon table -- @param postSpecies number Target species ID @@ -132,8 +139,8 @@ function EvolutionScene.start(mon, postSpecies, opts) end -- Ensure pre-evolution and post-evolution pics are cached - Pokemon.frontPic(EvolutionScene._preSpecies) - Pokemon.frontPic(EvolutionScene._postSpecies) + evo_pic(EvolutionScene._preSpecies) + evo_pic(EvolutionScene._postSpecies) -- Open message box in battle frame if Message.setFrame then Message.setFrame("battle") end @@ -583,8 +590,8 @@ function EvolutionScene.draw() -- 4. Draw Pokémon sprites (silhouette or full color) local st = EvolutionScene._state - local prePic = Pokemon.frontPic(EvolutionScene._preSpecies) - local postPic = Pokemon.frontPic(EvolutionScene._postSpecies) + local prePic = evo_pic(EvolutionScene._preSpecies) + local postPic = evo_pic(EvolutionScene._postSpecies) if st == "cycle" then -- Solid white silhouette shader diff --git a/src/ui/game3/fame_checker.lua b/src/ui/game3/fame_checker.lua index f477e379..7eb389d6 100644 --- a/src/ui/game3/fame_checker.lua +++ b/src/ui/game3/fame_checker.lua @@ -470,8 +470,7 @@ function FameCheckerUi.messageText() return pageList and pageList[1] or nil end if row.cancel then - -- pokefirered/src/strings.c:601 gFameCheckerText_FameCheckerWillBeClosed - return Strings("The FAME CHECKER will be closed.") + return require("src.core.game3.rom_text").plain("gFameCheckerText_FameCheckerWillBeClosed") end return nil end diff --git a/src/ui/game3/frlg_font.lua b/src/ui/game3/frlg_font.lua index 3a3875ba..1e5fe0ad 100644 --- a/src/ui/game3/frlg_font.lua +++ b/src/ui/game3/frlg_font.lua @@ -210,26 +210,18 @@ FrlgFont._logged = false local FG_PATHS = { { path = "chrome/fonts/latin_normal_fg.rgba", w = 256, h = 512 }, { path = "data/generated/gba/chrome/fonts/latin_normal_fg.rgba", w = 256, h = 512 }, - { path = "chrome/fonts/latin_normal_fg.png", w = 256, h = 512 }, - { path = "data/generated/gba/chrome/fonts/latin_normal_fg.png", w = 256, h = 512 }, } local SH_PATHS = { { path = "chrome/fonts/latin_normal_shadow.rgba", w = 256, h = 512 }, { path = "data/generated/gba/chrome/fonts/latin_normal_shadow.rgba", w = 256, h = 512 }, - { path = "chrome/fonts/latin_normal_shadow.png", w = 256, h = 512 }, - { path = "data/generated/gba/chrome/fonts/latin_normal_shadow.png", w = 256, h = 512 }, } local SMALL_FG_PATHS = { { path = "chrome/fonts/latin_small_fg.rgba", w = 256, h = 288 }, { path = "data/generated/gba/chrome/fonts/latin_small_fg.rgba", w = 256, h = 288 }, - { path = "chrome/fonts/latin_small_fg.png", w = 256, h = 288 }, - { path = "data/generated/gba/chrome/fonts/latin_small_fg.png", w = 256, h = 288 }, } local SMALL_SH_PATHS = { { path = "chrome/fonts/latin_small_shadow.rgba", w = 256, h = 288 }, { path = "data/generated/gba/chrome/fonts/latin_small_shadow.rgba", w = 256, h = 288 }, - { path = "chrome/fonts/latin_small_shadow.png", w = 256, h = 288 }, - { path = "data/generated/gba/chrome/fonts/latin_small_shadow.png", w = 256, h = 288 }, } local function log(msg) diff --git a/src/ui/game3/hall_of_fame.lua b/src/ui/game3/hall_of_fame.lua index 9275393a..1e3e1b18 100644 --- a/src/ui/game3/hall_of_fame.lua +++ b/src/ui/game3/hall_of_fame.lua @@ -1,11 +1,13 @@ --- FRLG Hall of Fame Induction Screen (pret hall_of_fame.c). --- Displays team members 1..6 (strictly skipping eggs), saves clear status --- and debut timestamp before yielding to credits. +-- pokefirered/src/hall_of_fame.c:361 CB2_DoHallOfFameScreen local Stack = require("src.ui.game3.stack") +local Window = require("src.ui.game3.window") local FrlgFont = require("src.ui.game3.frlg_font") local Pokemon = require("src.core.game3.pokemon") -local Strings = require("src.core.Strings") +local RomText = require("src.core.game3.rom_text") +local HofGfx = require("src.ui.game3.hall_of_fame_gfx") +local Rng = require("src.core.game3.rng") +local Trig = require("src.core.game3.trig") local HallOfFame = {} @@ -14,17 +16,42 @@ HallOfFame._session = nil HallOfFame._onDone = nil HallOfFame._mons = {} HallOfFame._currentIndex = 1 -HallOfFame._phase = "idle" -- "saving", "mon", "congrats", "done" +HallOfFame._phase = "idle" HallOfFame._timer = 0 local FLAG_SYS_GAME_CLEAR = 0x82C -- 2092 +-- pokefirered/src/hall_of_fame.c:30 +local BG_PAL = { 22 / 31, 24 / 31, 29 / 31 } +-- pokefirered/src/hall_of_fame.c:152 +local FULL_TEAM = { + { 120, 210, 120, 40 }, { 326, 220, 56, 40 }, { -86, 220, 184, 40 }, + { 120, -62, 120, 88 }, { -70, -92, 200, 88 }, { 310, -92, 40, 88 }, +} +-- pokefirered/src/hall_of_fame.c:162 +local HALF_TEAM = { { 120, 234, 120, 64 }, { 326, 244, 56, 64 }, { -86, 244, 184, 64 } } +-- pokefirered/include/constants/songs.h:294 +local MUS_HALL_OF_FAME = 286 +-- pokefirered/include/constants/songs.h:52 +local SE_SAVE = 48 +-- pokefirered/include/constants/songs.h:102 +local SE_APPLAUSE = 98 +-- pokefirered/include/constants/trainers.h:156 +local TRAINER_PIC_RED, TRAINER_PIC_LEAF = 135, 136 +-- pokefirered/include/constants/species.h:33 +local SPECIES_NIDORAN_F, SPECIES_NIDORAN_M = 29, 32 +-- pokefirered/src/hall_of_fame.c:126 +local PLAYER_WIN = { left = 2, top = 2, width = 17, height = 6 } +local WHITE_TEXT = { fg = FrlgFont.STDPAL[1], shadow = FrlgFont.STDPAL[2], bg = FrlgFont.STDPAL[0] } +local GRAY_TEXT = { fg = FrlgFont.STDPAL[2], shadow = FrlgFont.STDPAL[3], bg = FrlgFont.STDPAL[0] } + +-- pokefirered/src/hall_of_fame.c:382 Task_Hof_InitMonData local function extract_eligible_mons(party) local eligible = {} if type(party) ~= "table" then return eligible end for i = 1, math.min(6, #party) do local mon = party[i] - if mon and not Pokemon.isEgg(mon) then + if mon and (tonumber(mon.species) or 0) ~= 0 then table.insert(eligible, mon) end end @@ -52,19 +79,32 @@ local function commit_clear_and_save(session, eligibleMons) -- 3. Record Hall of Fame team session.hallOfFameTeams = session.hallOfFameTeams or {} local teamRecord = {} - for _, mon in ipairs(eligibleMons) do - local sp = mon.species or 1 + -- pokefirered/src/hall_of_fame.c:389 + for i = 1, math.min(6, #eligibleMons) do + local mon = eligibleMons[i] + local egg = Pokemon.isEgg(mon) + local sp = egg and 412 or (mon.species or 1) local lvl = tonumber(mon.level) or 1 - local nick = mon.nickname or mon.name or Pokemon.name(sp) or "POKéMON" + local nick = egg and RomText.plain("gText_EggNickname") or (mon.nickname or mon.name or Pokemon.name(sp) or "POKéMON") local tid = tonumber(mon.otId or mon.tid or session.trainerId or 0) table.insert(teamRecord, { species = sp, level = lvl, nickname = nick, trainerId = tid, + otSecretId = tonumber(mon.otSecretId) or 0, + personality = tonumber(mon.personality) or 0, }) end table.insert(session.hallOfFameTeams, teamRecord) + -- pokefirered/src/hall_of_fame.c:443 + while #session.hallOfFameTeams > 50 do + table.remove(session.hallOfFameTeams, 1) + end + -- pokefirered/src/save.c:665 + session.gameStats = session.gameStats or {} + local entered = tonumber(session.gameStats[10]) or 0 + if entered < 999 then session.gameStats[10] = entered + 1 end -- 4. Commit to disk through the engine's save path. This used to pcall -- "src.core.game3.save", which does not exist -- so the clear flag, the debut @@ -85,173 +125,446 @@ end -- debut timestamp and the HOF team, then commits the save. HallOfFame._commitClearAndSave = commit_clear_and_save +local function audio() + return require("src.core.game3.audio") +end + +local function player_female(session) + local g = session and (session.gender or session.playerGender) + return g == 1 or g == "female" or g == "F" +end + +local function national_enabled(session) + local ok, PokedexData = pcall(require, "src.core.game3.pokedex_data") + return ok and PokedexData.isNationalUnlocked + and PokedexData.isNationalUnlocked(session, session and session.dex) or false +end + +-- pokefirered/src/palette.c:151 +local function begin_fade(y, target, delay, color) + HallOfFame._fade = { + y = y, target = target, delay = delay or 0, counter = delay or 0, + color = color or { 0, 0, 0 }, active = y ~= target, + } +end + +local function fade_active() + return HallOfFame._fade and HallOfFame._fade.active +end + +local function tick_fade() + local f = HallOfFame._fade + if not (f and f.active) then return end + if f.counter > 0 then + f.counter = f.counter - 1 + return + end + f.counter = f.delay + if f.y < f.target then + f.y = math.min(f.target, f.y + 2) + else + f.y = math.max(f.target, f.y - 2) + end + if f.y == f.target then f.active = false end +end + +local blendShader + +local function shader() + if blendShader == nil then + local ok, s = pcall(love.graphics.newShader, [[ + extern vec3 target; + extern number coeff; + vec4 effect(vec4 color, Image tex, vec2 uv, vec2 sc) { + vec4 p = Texel(tex, uv); + return vec4(mix(p.rgb, target, coeff), p.a) * color; + } + ]]) + blendShader = ok and s or false + end + return blendShader or nil +end + function HallOfFame.start(opts) opts = opts or {} local session = opts.session or {} HallOfFame._session = session HallOfFame._onDone = opts.onDone + HallOfFame._dontSave = opts.dontSave == true + HallOfFame._warp = opts.warp ~= false HallOfFame.open = true - HallOfFame._currentIndex = 1 - HallOfFame._timer = 0 - - -- Filter non-egg party members HallOfFame._mons = extract_eligible_mons(session.party) - - -- Audio - local okAudio, Audio = pcall(require, "src.core.game3.audio") - if okAudio and Audio and Audio.playBGM then - Audio.playBGM(297) -- MUS_HALL_OF_FAME - end - - -- Atomic save - commit_clear_and_save(session, HallOfFame._mons) - - if #HallOfFame._mons > 0 then - HallOfFame._phase = "mon" - else - HallOfFame._phase = "congrats" - end - + HallOfFame._currentIndex = 1 + HallOfFame._sprites = {} + HallOfFame._selected = {} + HallOfFame._dimmed = false + HallOfFame._info = nil + HallOfFame._welcome = false + HallOfFame._saving = false + HallOfFame._band = 7 + HallOfFame._bandEva = 16 + HallOfFame._confetti = {} + HallOfFame._player = nil + HallOfFame._playerInfo = false + HallOfFame._national = national_enabled(session) + HallOfFame._timer = 0 + HallOfFame._phase = "fadein" + -- pokefirered/src/hall_of_fame.c:344 + begin_fade(16, 0, 0) + require("src.ui.game3.fade").clear() Stack.push("hall_of_fame", HallOfFame, { hideBelow = true }) end +-- pokefirered/src/hall_of_fame.c:699 SetWarpsToRollCredits +function HallOfFame.setWarpsToRollCredits() + local Flags = require("src.core.game3.scripting.flags") + local Space = package.loaded["src.core.game3.scripting.space"] + local Map = require("src.core.game3.map") + local Runtime = package.loaded["src.core.game3.runtime"] + local game = Runtime and Runtime._game + assert(Space and Space.store, "SetWarpsToRollCredits: no script space") + assert(game, "SetWarpsToRollCredits: no running game") + local store = Space.store + Flags.setVar(store, nil, Flags.VAR_IDS.VAR_MAP_SCENE_INDIGO_PLATEAU_EXTERIOR, 1) + Flags.setFlag(store, nil, Flags.IDS.FLAG_DONT_SHOW_MAP_NAME_POPUP, true) + Map.disableMusicChange = Map.MUSIC_DISABLE_KEEP + -- pokefirered/src/hall_of_fame.c:704 + Map.load(nil, game, "FR_INDIGO_PLATEAU_EXTERIOR", { x = 11, y = 6, facing = "down" }) + local Player = package.loaded["src.core.game3.player"] + if Player and Player.setVisible then Player.setVisible(true) end + local Fade = require("src.ui.game3.fade") + Fade.begin(Fade.MODE.FROM_BLACK, 1, function() end) + return true +end + function HallOfFame.close() if not HallOfFame.open then return end HallOfFame.open = false HallOfFame._phase = "done" Stack.pop("hall_of_fame") + if HallOfFame._warp then + HallOfFame.setWarpsToRollCredits() + end local cb = HallOfFame._onDone HallOfFame._onDone = nil if cb then cb() end end +function HallOfFame.reset() + HallOfFame.open = false + HallOfFame._phase = "idle" + HallOfFame._onDone = nil + HallOfFame._session = nil + HallOfFame._fade = nil +end + function HallOfFame.isOpen() return HallOfFame.open end +function HallOfFame.phase() + return HallOfFame._phase +end + function HallOfFame.getCurrentMon() - if not HallOfFame.open or HallOfFame._phase ~= "mon" then return nil end + if not HallOfFame.open then return nil end return HallOfFame._mons[HallOfFame._currentIndex] end -function HallOfFame.advance() - if HallOfFame._phase == "mon" then - if HallOfFame._currentIndex < #HallOfFame._mons then - HallOfFame._currentIndex = HallOfFame._currentIndex + 1 - HallOfFame._timer = 0 - else - HallOfFame._phase = "congrats" - HallOfFame._timer = 0 +local function positions() + return #HallOfFame._mons > 3 and FULL_TEAM or HALF_TEAM +end + +-- pokefirered/src/hall_of_fame.c:1223 SpriteCB_GetOnScreen +local function slide(s) + if s.x == s.dx and s.y == s.dy then return true end + if s.x < s.dx then s.x = s.x + 15 end + if s.x > s.dx then s.x = s.x - 15 end + if s.y < s.dy then s.y = s.y + 10 end + if s.y > s.dy then s.y = s.y - 10 end + return false +end + +-- pokefirered/src/hall_of_fame.c:1267 Hof_SpawnConfetti +local function spawn_confetti() + local x = Rng.Random() % 240 + local y = -(Rng.Random() % 8) + local frame = Rng.Random() % 17 + local fall = (Rng.Random() % 4 ~= 0) and 0 or 1 + local list = HallOfFame._confetti + list[#list + 1] = { x = x, y = y, frame = frame, fall = fall, x2 = 0, y2 = 0, angle = 0 } +end + +-- pokefirered/src/hall_of_fame.c:1245 SpriteCB_Confetti +local function update_confetti() + local keep = {} + for _, c in ipairs(HallOfFame._confetti or {}) do + if c.y2 <= 120 then + c.y2 = c.y2 + 1 + c.fall + local amp = Rng.Random() % 4 + 8 + local v = amp * Trig.SINE[c.angle % 256 + 1] + c.x2 = v >= 0 and math.floor(v / 256) or -math.floor(-v / 256) + c.angle = c.angle + 4 + keep[#keep + 1] = c end - elseif HallOfFame._phase == "congrats" then - HallOfFame.close() + end + HallOfFame._confetti = keep +end + +local function run_phase() + local phase = HallOfFame._phase + if phase == "fadein" then + if fade_active() then return end + -- pokefirered/src/hall_of_fame.c:353 + audio().playSong(MUS_HALL_OF_FAME) + HallOfFame._phase = HallOfFame._dontSave and "display" or "save" + elseif phase == "save" then + -- pokefirered/src/hall_of_fame.c:456 + HallOfFame._saving = true + HallOfFame._phase = "trysave" + elseif phase == "trysave" then + -- pokefirered/src/hall_of_fame.c:462 + local session = HallOfFame._session + commit_clear_and_save(session, type(session.party) == "table" and session.party or {}) + audio().playSe(SE_SAVE) + HallOfFame._timer = 32 + HallOfFame._phase = "savewait" + elseif phase == "savewait" then + -- pokefirered/src/hall_of_fame.c:471 + if HallOfFame._timer ~= 0 then + HallOfFame._timer = HallOfFame._timer - 1 + else + HallOfFame._phase = "display" + end + elseif phase == "display" then + -- pokefirered/src/hall_of_fame.c:484 + local i = HallOfFame._currentIndex + local pos = positions()[i] + local mon = HallOfFame._mons[i] + if not (pos and mon) then + HallOfFame._phase = "welcome" + return + end + HallOfFame._sprites[i] = { x = pos[1], y = pos[2], dx = pos[3], dy = pos[4], mon = mon } + HallOfFame._saving = false + HallOfFame._info = nil + HallOfFame._phase = "slide" + elseif phase == "slide" then + local s = HallOfFame._sprites[HallOfFame._currentIndex] + slide(s) + -- pokefirered/src/hall_of_fame.c:521 + if s.x == s.dx and s.y == s.dy then + local sp = tonumber(s.mon.species) or 0 + if not Pokemon.isEgg(s.mon) then + pcall(function() audio().playCry(sp) end) + end + HallOfFame._info = s.mon + HallOfFame._timer = 120 + HallOfFame._phase = "hold" + end + elseif phase == "hold" then + -- pokefirered/src/hall_of_fame.c:535 + if HallOfFame._timer ~= 0 then + HallOfFame._timer = HallOfFame._timer - 1 + return + end + local i = HallOfFame._currentIndex + HallOfFame._selected[i] = true + if i < 6 and HallOfFame._mons[i + 1] then + HallOfFame._currentIndex = i + 1 + HallOfFame._dimmed = true + HallOfFame._phase = "display" + else + HallOfFame._phase = "welcome" + end + elseif phase == "welcome" then + -- pokefirered/src/hall_of_fame.c:561 + HallOfFame._dimmed = false + HallOfFame._info = nil + HallOfFame._welcome = true + audio().playSe(SE_APPLAUSE) + HallOfFame._timer = 400 + HallOfFame._phase = "applause" + elseif phase == "applause" then + -- pokefirered/src/hall_of_fame.c:578 + if HallOfFame._timer ~= 0 then + HallOfFame._timer = HallOfFame._timer - 1 + if HallOfFame._timer % 4 == 0 and HallOfFame._timer > 110 then spawn_confetti() end + return + end + HallOfFame._dimmed = true + HallOfFame._welcome = false + HallOfFame._timer = 7 + HallOfFame._phase = "bands" + elseif phase == "bands" then + -- pokefirered/src/hall_of_fame.c:602 + if HallOfFame._timer > 15 then + HallOfFame._phase = "playerpic" + else + HallOfFame._timer = HallOfFame._timer + 1 + HallOfFame._band = HallOfFame._timer + HallOfFame._bandEva = 0 + end + elseif phase == "playerpic" then + -- pokefirered/src/hall_of_fame.c:615 + HallOfFame._band = 16 + local pic = player_female(HallOfFame._session) and TRAINER_PIC_LEAF or TRAINER_PIC_RED + HallOfFame._player = { x = 0x78, y = 0x48, pic = pic } + HallOfFame._timer = 120 + HallOfFame._phase = "playerwait" + elseif phase == "playerwait" then + -- pokefirered/src/hall_of_fame.c:628 + if HallOfFame._timer ~= 0 then + HallOfFame._timer = HallOfFame._timer - 1 + elseif HallOfFame._player.x ~= 192 then + HallOfFame._player.x = HallOfFame._player.x + 1 + else + HallOfFame._playerInfo = true + HallOfFame._phase = "exitwait" + end + elseif phase == "exit" then + -- pokefirered/src/hall_of_fame.c:665 + if not fade_active() then HallOfFame.close() end end end +function HallOfFame.update(_dt) + if not HallOfFame.open then return end + tick_fade() + run_phase() + update_confetti() +end + +-- pokefirered/src/hall_of_fame.c:649 Task_Hof_ExitOnKeyPressed function HallOfFame.handleInput(inp) if not HallOfFame.open or not inp then return end - if inp:wasPressed("a") or inp:wasPressed("b") or inp:wasPressed("start") then - HallOfFame.advance() + if HallOfFame._phase ~= "exitwait" then return end + if inp:wasPressed("a") then + audio().fadeOutBgm(4) + -- pokefirered/src/hall_of_fame.c:661 + begin_fade(0, 16, 8) + HallOfFame._phase = "exit" end end -function HallOfFame.update(dt) - if not HallOfFame.open then return end - HallOfFame._timer = (HallOfFame._timer or 0) + (dt or 0) - -- Auto-advance after 5 seconds per mon if no button pressed - if HallOfFame._timer >= 5.0 then - HallOfFame.advance() +-- pokefirered/src/hall_of_fame.c:1181 DrawHofBackground +local function draw_background() + HofGfx.drawStripes() + HofGfx.drawBands(HallOfFame._bandEva or 16, HallOfFame._band or 7) +end + +local function draw_confetti() + local sheet = HofGfx.confetti() + if not sheet then return end + love.graphics.setColor(1, 1, 1, 1) + for _, c in ipairs(HallOfFame._confetti or {}) do + love.graphics.draw(sheet.image, sheet.quads[c.frame], c.x + c.x2 - 4, c.y + c.y2 - 4) end end +local function draw_mon(s, dim) + local pic = Pokemon.monFrontPic(s.mon) + if not (pic and pic.image) then return end + local sh = dim and shader() or nil + if sh then + love.graphics.setShader(sh) + sh:send("target", BG_PAL) + sh:send("coeff", 12 / 16) + end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(pic.image, s.x - 32, s.y - 32) + if sh then love.graphics.setShader() end +end + +-- pokefirered/src/hall_of_fame.c:993 HallOfFame_PrintMonInfo +local function draw_mon_info(mon) + local x0, y0 = 16, 120 + local sp = tonumber(mon.species) or 0 + local egg = Pokemon.isEgg(mon) + if not egg then + local dex = Pokemon.national(sp) or sp + local digits = (not HallOfFame._national and dex > 151) and "???" or string.format("%03d", dex) + FrlgFont.draw(RomText.plain("gText_Number") .. digits, x0 + 16, y0 + 1, { colors = WHITE_TEXT }) + end + local nick = egg and RomText.plain("gText_EggNickname") or Pokemon.displayName(mon) + local w = FrlgFont.measure(nick) + local nx = egg and (0x80 - math.floor(w / 2)) or (0x80 - w) + FrlgFont.draw(nick, x0 + nx, y0 + 1, { colors = WHITE_TEXT }) + if egg then return end + local gender = " " + if sp ~= SPECIES_NIDORAN_M and sp ~= SPECIES_NIDORAN_F then + local g = Pokemon.gender(sp, tonumber(mon.personality) or 0) + if g == "M" then gender = "♂" elseif g == "F" then gender = "♀" end + end + FrlgFont.draw("/" .. tostring(Pokemon.name(sp) or "") .. gender, x0 + 0x80, y0 + 1, { colors = WHITE_TEXT }) + FrlgFont.draw(RomText.plain("gText_Level") .. tostring(tonumber(mon.level) or 0), x0 + 0x20, y0 + 0x11, + { colors = WHITE_TEXT }) + local tid = tonumber(mon.otId or mon.tid or HallOfFame._session.trainerId) or 0 + FrlgFont.draw(RomText.plain("gText_IDNumber") .. string.format("%05d", tid % 65536), x0 + 0x60, y0 + 0x11, + { colors = WHITE_TEXT }) +end + +-- pokefirered/src/hall_of_fame.c:1080 HallOfFame_PrintPlayerInfo +local function draw_player_info() + local session = HallOfFame._session or {} + local ox, oy = PLAYER_WIN.left * 8, PLAYER_WIN.top * 8 + local textWidth = PLAYER_WIN.width * 8 - 6 + Window.fill(PLAYER_WIN, 1, 1, 1, 1) + Window.stdFrame(PLAYER_WIN) + local name = tostring(session.name or session.playerName or "") + FrlgFont.draw(RomText.plain("gText_Name"), ox + 4, oy + 3, { colors = GRAY_TEXT }) + FrlgFont.draw(name, ox + textWidth - FrlgFont.measure(name), oy + 3, { colors = GRAY_TEXT }) + local tid = (tonumber(session.trainerId or session.playerTrainerId) or 0) % 65536 + FrlgFont.draw(RomText.plain("gText_IDNumber"), ox + 4, oy + 18, { colors = GRAY_TEXT }) + FrlgFont.draw(string.format("%05d", tid), ox + textWidth - 30, oy + 18, { colors = GRAY_TEXT }) + local h = tonumber(session.playTimeHours or session.hours) or 0 + local m = tonumber(session.playTimeMinutes or session.minutes) or 0 + FrlgFont.draw(RomText.plain("gText_MainMenuTime"), ox + 4, oy + 32, { colors = GRAY_TEXT }) + FrlgFont.draw(string.format("%3d:%02d", h % 1000, m % 100), ox + textWidth - 36, oy + 32, + { colors = GRAY_TEXT }) +end + function HallOfFame.draw() if not HallOfFame.open then return end - local session = HallOfFame._session or {} - - -- Background: deep dark navy / slate tone - love.graphics.setColor(0.08, 0.09, 0.14, 1) - love.graphics.rectangle("fill", 0, 0, 240, 160) - - -- Inner card frame - love.graphics.setColor(0.18, 0.22, 0.32, 1) - love.graphics.rectangle("fill", 8, 8, 224, 144) - love.graphics.setColor(0.12, 0.14, 0.22, 1) - love.graphics.rectangle("fill", 10, 10, 220, 140) - - if HallOfFame._phase == "mon" then - local mon = HallOfFame.getCurrentMon() - if mon then - -- Header - local hdr = Strings("HALL OF FAME No. %d", HallOfFame._currentIndex) - FrlgFont.draw(hdr, 20, 16, { colors = FrlgFont.COLOR.WHITE or FrlgFont.COLOR.NORMAL }) - - -- Mon Sprite Frame - love.graphics.setColor(0.25, 0.30, 0.42, 1) - love.graphics.rectangle("fill", 18, 34, 68, 68) - love.graphics.setColor(0.15, 0.18, 0.28, 1) - love.graphics.rectangle("fill", 20, 36, 64, 64) - - local sp = mon.species or 1 - local frontImg = Pokemon.front and Pokemon.front(sp) - if frontImg then - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(frontImg, 20, 36) - end - - -- Mon Stats - -- pokefirered/src/hall_of_fame.c:1011 - local dexNo = (Pokemon.national and Pokemon.national(sp)) or tonumber(sp) or 1 - local monName = mon.nickname or mon.name or Pokemon.name(sp) or "POKéMON" - local lvl = tonumber(mon.level) or 1 - local otId = tonumber(mon.otId or mon.tid or session.trainerId or 0) % 65536 - local otName = tostring(mon.otName or mon.ot or session.name or session.playerName or "RED") - - FrlgFont.draw(Strings("No. %03d", dexNo), 96, 36, { colors = FrlgFont.COLOR.NORMAL }) - FrlgFont.draw(monName, 96, 50, { colors = FrlgFont.COLOR.NORMAL }) - FrlgFont.draw(Strings("Lv. %d", lvl), 96, 64, { colors = FrlgFont.COLOR.NORMAL }) - FrlgFont.draw(Strings("IDNo. %05d", otId), 96, 78, { colors = FrlgFont.COLOR.NORMAL }) - FrlgFont.draw(Strings("OT/ %s", otName), 96, 92, { colors = FrlgFont.COLOR.NORMAL }) - - -- Moves Section (4 moves) - love.graphics.setColor(0.18, 0.22, 0.32, 1) - love.graphics.rectangle("fill", 18, 108, 204, 36) - - local moves = mon.moves or mon.moveIds or {} - for slot = 1, 4 do - local mv = moves[slot] - local mvName = "-" - if mv then - if type(mv) == "table" then - mvName = mv.name or (mv.id and Pokemon.moveName and Pokemon.moveName(mv.id)) or "-" - elseif type(mv) == "number" or type(mv) == "string" then - mvName = (Pokemon.moveName and Pokemon.moveName(mv)) or tostring(mv) - end - end - local col = (slot - 1) % 2 - local row = math.floor((slot - 1) / 2) - local mx = 24 + col * 100 - local my = 112 + row * 14 - FrlgFont.draw(mvName:upper(), mx, my, { colors = FrlgFont.COLOR.NORMAL }) - end + draw_background() + for i = 1, 6 do + local s = HallOfFame._sprites[i] + if s then + draw_mon(s, HallOfFame._dimmed and HallOfFame._selected[i]) end - elseif HallOfFame._phase == "congrats" then - -- League Champions Congratulations Screen - FrlgFont.draw(Strings("LEAGUE CHAMPION!"), 54, 24, { colors = FrlgFont.COLOR.WHITE or FrlgFont.COLOR.NORMAL }) - FrlgFont.draw(Strings("CONGRATULATIONS!"), 48, 44, { colors = FrlgFont.COLOR.NORMAL }) - - local name = tostring(session.name or session.playerName or "RED") - local rawId = tonumber(session.trainerId or session.id or session.playerTrainerId) or 0 - local idStr = string.format("%05d", rawId % 65536) - - FrlgFont.draw(Strings("NAME: %s", name), 32, 72, { colors = FrlgFont.COLOR.NORMAL }) - FrlgFont.draw(Strings("IDNo. %s", idStr), 140, 72, { colors = FrlgFont.COLOR.NORMAL }) - - local h = session.hofDebutHours or tonumber(session.playTimeHours or session.hours) or 0 - local m = session.hofDebutMinutes or tonumber(session.playTimeMinutes or session.minutes) or 0 - local s = session.hofDebutSeconds or tonumber(session.playTimeSeconds or session.seconds) or 0 - FrlgFont.draw(Strings("HOF DEBUT: %d:%02d:%02d", h, m, s), 32, 94, { colors = FrlgFont.COLOR.NORMAL }) - - FrlgFont.draw(Strings("PRESS A TO CONTINUE"), 52, 126, { colors = FrlgFont.COLOR.NORMAL }) + end + if HallOfFame._player then + local TrainerPic = require("src.core.game3.trainer_pic") + local pic = TrainerPic.front(HallOfFame._player.pic) + if pic and pic.image then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(pic.image, HallOfFame._player.x - 32, HallOfFame._player.y - 32) + end + end + if HallOfFame._saving then + -- pokefirered/src/hall_of_fame.c:456 + Window.dialogueFrame() + Window.printPx(RomText.plain("gText_SavingDontTurnOffThePower2"), 16, 121) + end + if HallOfFame._info then draw_mon_info(HallOfFame._info) end + if HallOfFame._welcome then + -- pokefirered/src/hall_of_fame.c:984 + local text = RomText.plain("gText_WelcomeToHOF") + local x = math.floor((0xD0 - FrlgFont.measure(text)) / 2) + FrlgFont.draw(text, 16 + x, 121, { colors = WHITE_TEXT }) + end + if HallOfFame._playerInfo then + draw_player_info() + -- pokefirered/src/hall_of_fame.c:642 + Window.dialogueFrame() + Window.printPx(RomText.plain("gText_LeagueChamp"), 16, 121) + end + draw_confetti() + local f = HallOfFame._fade + if f and f.y > 0 then + love.graphics.setColor(f.color[1], f.color[2], f.color[3], f.y / 16) + love.graphics.rectangle("fill", 0, 0, 240, 160) + love.graphics.setColor(1, 1, 1, 1) end end diff --git a/src/ui/game3/hall_of_fame_gfx.lua b/src/ui/game3/hall_of_fame_gfx.lua new file mode 100644 index 00000000..8f469220 --- /dev/null +++ b/src/ui/game3/hall_of_fame_gfx.lua @@ -0,0 +1,63 @@ +local Extract = require("src.import.gba.extract_island1") + +local HofGfx = {} + +local W, H = 240, 160 +local images = {} + +local function image(name, w, h) + if images[name] then return images[name] end + if not (love and love.image and love.graphics and love.graphics.newImage) then return nil end + local rel = (Extract.CACHE_ROOT or "data/generated/gba") .. "/hall_of_fame/" .. name .. ".rgba" + local bytes = require("src.core.game3.dataset").cache():read(rel) + assert(bytes and #bytes == w * h * 4, "hall of fame art missing from the cache: " .. rel) + local img = love.graphics.newImage(love.image.newImageData(w, h, "rgba8", bytes)) + img:setFilter("nearest", "nearest") + local entry = { image = img, w = w, h = h } + images[name] = entry + return entry +end + +-- pokefirered/src/hall_of_fame.c:1181 +function HofGfx.drawStripes() + local stripes = image("stripes", W, H) + if not stripes then return end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(stripes.image, 0, 0) +end + +-- pokefirered/src/hall_of_fame.c:333, :611 +function HofGfx.drawBands(eva, evb) + local entry = image("bands", W, H) + if not entry then return end + local bands = entry.image + local mode, alphaMode = love.graphics.getBlendMode() + love.graphics.setColor(0, 0, 0, 1 - math.min(16, evb) / 16) + love.graphics.draw(bands, 0, 0) + if eva > 0 then + local k = math.min(16, eva) / 16 + love.graphics.setBlendMode("add") + love.graphics.setColor(k, k, k, 1) + love.graphics.draw(bands, 0, 0) + end + love.graphics.setBlendMode(mode, alphaMode) + love.graphics.setColor(1, 1, 1, 1) +end + +-- pokefirered/src/hall_of_fame.c:142, :169 +function HofGfx.confetti() + local entry = image("confetti", 8, 8 * 17) + if entry and not entry.quads then + entry.quads = {} + for f = 0, 16 do + entry.quads[f] = love.graphics.newQuad(0, f * 8, 8, 8, entry.w, entry.h) + end + end + return entry +end + +function HofGfx.reset() + images = {} +end + +return HofGfx diff --git a/src/ui/game3/hall_of_fame_pc.lua b/src/ui/game3/hall_of_fame_pc.lua new file mode 100644 index 00000000..05e324ca --- /dev/null +++ b/src/ui/game3/hall_of_fame_pc.lua @@ -0,0 +1,230 @@ +-- pokefirered/src/hall_of_fame.c:709 + +local Stack = require("src.ui.game3.stack") +local Window = require("src.ui.game3.window") +local FrlgFont = require("src.ui.game3.frlg_font") +local Pokemon = require("src.core.game3.pokemon") +local RomText = require("src.core.game3.rom_text") +local HofGfx = require("src.ui.game3.hall_of_fame_gfx") + +local HofPc = {} + +HofPc.open = false +HofPc._teams = {} +HofPc._team = 0 +HofPc._number = 0 +HofPc._mon = 1 +HofPc._corrupted = false + +local MAX_TEAMS = 50 -- pokefirered/src/hall_of_fame.c:29 +local SPECIES_EGG = 412 -- pokefirered/include/constants/species.h:421 +local SPECIES_NIDORAN_F = 29 -- pokefirered/include/constants/species.h:33 +local SPECIES_NIDORAN_M = 32 -- pokefirered/include/constants/species.h:36 +local KANTO_SPECIES_END = 151 -- pokefirered/include/constants/species.h:157 +local GAME_STAT_ENTERED_HOF = 10 -- pokefirered/include/constants/game_stat.h:14 +local BG = { 22 / 31, 24 / 31, 29 / 31 } -- pokefirered/src/hall_of_fame.c:30 +local BLEND_COEFF = 12 / 16 -- pokefirered/src/hall_of_fame.c:873 +-- pokefirered/src/hall_of_fame.c:136 +local WHITE_TEXT = { fg = FrlgFont.STDPAL[1], shadow = FrlgFont.STDPAL[2], bg = FrlgFont.STDPAL[0] } + +-- pokefirered/src/hall_of_fame.c:152 +local FULL_TEAM = { { 120, 40 }, { 56, 40 }, { 184, 40 }, { 120, 88 }, { 200, 88 }, { 40, 88 } } +-- pokefirered/src/hall_of_fame.c:162 +local HALF_TEAM = { { 120, 64 }, { 56, 64 }, { 184, 64 } } + +local blendShader + +local function shader() + if blendShader == nil then + local ok, s = pcall(love.graphics.newShader, [[ + extern vec3 target; + extern number coeff; + vec4 effect(vec4 color, Image tex, vec2 uv, vec2 sc) { + vec4 p = Texel(tex, uv); + return vec4(mix(p.rgb, target, coeff), p.a) * color; + } + ]]) + blendShader = ok and s or false + end + return blendShader or nil +end + +local function members(team) + local list = {} + for i = 1, 6 do + local mon = team and team[i] + if mon and (tonumber(mon.species) or 0) ~= 0 then list[#list + 1] = mon end + end + return list +end + +local function cry() + local team = HofPc._teams[HofPc._team] + local mon = members(team)[HofPc._mon] + if not mon or tonumber(mon.species) == SPECIES_EGG then return end + pcall(function() + local Audio = require("src.core.game3.audio") + if Audio.stopCry then Audio.stopCry() end + Audio.playCry(tonumber(mon.species)) + end) +end + +local function nationalEnabled(session) + local ok, PokedexData = pcall(require, "src.core.game3.pokedex_data") + return ok and PokedexData.isNationalUnlocked(session, session and session.dex) or false +end + +-- pokefirered/src/hall_of_fame.c:758 +function HofPc.show(opts) + opts = opts or {} + local session = opts.session or {} + HofPc._session = session + HofPc._onDone = opts.onDone + HofPc._teams = type(session.hallOfFameTeams) == "table" and session.hallOfFameTeams or {} + HofPc._corrupted = #HofPc._teams == 0 + HofPc._team = math.min(#HofPc._teams, MAX_TEAMS) + local stats = session.gameStats or {} + HofPc._number = tonumber(stats[GAME_STAT_ENTERED_HOF]) or 0 + HofPc._mon = 1 + HofPc._national = nationalEnabled(session) + HofPc.open = true + Stack.push("hall_of_fame_pc", HofPc, { hideBelow = true }) + if not HofPc._corrupted then cry() end +end + +function HofPc.isOpen() + return HofPc.open +end + +-- pokefirered/src/hall_of_fame.c:937 +function HofPc.close() + if not HofPc.open then return end + HofPc.open = false + pcall(function() require("src.core.game3.audio").stopCry() end) + Stack.pop("hall_of_fame_pc") + local cb = HofPc._onDone + HofPc._onDone = nil + if cb then cb() end +end + +function HofPc.reset() + HofPc.open = false + HofPc._onDone = nil + HofPc._session = nil + HofPc._teams = {} +end + +-- pokefirered/src/hall_of_fame.c:886 +function HofPc.handleInput(input) + if not HofPc.open then return end + if HofPc._corrupted then + if input:wasPressed("a") then HofPc.close() end -- pokefirered/src/hall_of_fame.c:980 + return + end + local count = #members(HofPc._teams[HofPc._team]) + if input:wasPressed("a") then + if HofPc._team > 1 then + HofPc._team = HofPc._team - 1 + if HofPc._number ~= 0 then HofPc._number = HofPc._number - 1 end + HofPc._mon = 1 + cry() + else + HofPc.close() + end + elseif input:wasPressed("b") then + HofPc.close() + elseif input:wasPressed("up") and HofPc._mon > 1 then + HofPc._mon = HofPc._mon - 1 + cry() + elseif input:wasPressed("down") and HofPc._mon < count then + HofPc._mon = HofPc._mon + 1 + cry() + end +end + +local function drawBackground() + -- pokefirered/src/hall_of_fame.c:1193 + HofGfx.drawStripes() + -- pokefirered/src/hall_of_fame.c:749 + HofGfx.drawBands(16, 7) +end + +-- pokefirered/src/menu.c:163 +local function drawTopBar(left, right) + love.graphics.setColor(FrlgFont.STDPAL[2]) + love.graphics.rectangle("fill", 0, 0, 240, 16) + love.graphics.setColor(1, 1, 1, 1) + local white = { fg = FrlgFont.STDPAL[1], shadow = FrlgFont.STDPAL[2], bg = FrlgFont.STDPAL[0] } + if left then Window.printPx(left, 2, 2, { colors = white }) end + if right then + local ok, PokedexChrome = pcall(require, "src.ui.game3.pokedex_chrome") + if ok and PokedexChrome.drawControlInfo then PokedexChrome.drawControlInfo(right, 238, 2) end + end +end + +-- pokefirered/src/hall_of_fame.c:993 +local function drawMonInfo(mon) + local x0, y0 = 16, 120 + local sp = tonumber(mon.species) or 0 + local egg = sp == SPECIES_EGG + local nick = tostring(mon.nickname or "") + if not egg then + local dex = (Pokemon.national and Pokemon.national(sp)) or sp + local digits = (not HofPc._national and dex > KANTO_SPECIES_END) and "???" or string.format("%03d", dex) + FrlgFont.draw(RomText.plain("gText_Number") .. digits, x0 + 16, y0 + 1, { colors = WHITE_TEXT }) + end + local w = FrlgFont.measure(nick) + local nx = egg and (x0 + 0x80 - math.floor(w / 2)) or (x0 + 0x80 - w) + FrlgFont.draw(nick, nx, y0 + 1, { colors = WHITE_TEXT }) + if egg then return end + local gender = " " + if sp ~= SPECIES_NIDORAN_M and sp ~= SPECIES_NIDORAN_F then + local g = Pokemon.gender(sp, tonumber(mon.personality) or 0) + if g == "M" then gender = "♂" elseif g == "F" then gender = "♀" end + end + -- pokefirered/src/hall_of_fame.c:1066 + FrlgFont.draw("/" .. tostring(Pokemon.name(sp) or "") .. gender, x0 + 0x80, y0 + 1, { colors = WHITE_TEXT }) + FrlgFont.draw(RomText.plain("gText_Level") .. tostring(tonumber(mon.level) or 0), x0 + 0x20, y0 + 0x11, + { colors = WHITE_TEXT }) + FrlgFont.draw(RomText.plain("gText_IDNumber") .. string.format("%05d", (tonumber(mon.trainerId) or 0) % 65536), x0 + 0x60, + y0 + 0x11, { colors = WHITE_TEXT }) +end + +function HofPc.draw() + if not HofPc.open then return end + drawBackground() + if HofPc._corrupted then + -- pokefirered/src/hall_of_fame.c:969 + drawTopBar(nil, RomText.plain("gText_ABUTTONExit")) + Window.dialogueFrame() + Window.printPx(RomText.plain("gText_HOFCorrupted"), 16, 121) + return + end + local list = members(HofPc._teams[HofPc._team]) + local pos = #list > 3 and FULL_TEAM or HALF_TEAM + local sh = shader() + for i, mon in ipairs(list) do + local p = pos[i] + local pic = p and Pokemon.monFrontPic(mon) + if pic and pic.image then + local dim = i ~= HofPc._mon + if dim and sh then + love.graphics.setShader(sh) + sh:send("target", BG) + sh:send("coeff", BLEND_COEFF) + end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(pic.image, p[1] - 32, p[2] - 32) + if dim and sh then love.graphics.setShader() end + end + end + -- pokefirered/src/hall_of_fame.c:843 + local title = RomText.plain("gText_HOFNumber", { stringVars = { tostring(HofPc._number) } }) + local hint = HofPc._team <= 1 and RomText.plain("gText_UPDOWNPick_ABUTTONBBUTTONCancel") + or RomText.plain("gText_UPDOWNPick_ABUTTONNext_BBUTTONBack") + drawTopBar(title, hint) + local mon = list[HofPc._mon] + if mon then drawMonInfo(mon) end +end + +return HofPc diff --git a/src/ui/game3/hud.lua b/src/ui/game3/hud.lua index f4ccdbc1..dbca5844 100644 --- a/src/ui/game3/hud.lua +++ b/src/ui/game3/hud.lua @@ -156,11 +156,14 @@ local function start_button_allowed() local Space = package.loaded["src.core.game3.scripting.space"] local Forced = package.loaded["src.core.game3.forced_movement"] local Warp = package.loaded["src.core.game3.warp"] + local P = package.loaded["src.core.game3.player"] local scriptBusy = Space and Space.vm and Space.vm.isRunning and Space.vm:isRunning() -- pokefirered/src/field_effect.c:1155 FieldCB_FallWarpExit local locked = (Field and Field.locked) or (Forced and Forced.isForced and Forced.isForced()) or (Warp and Warp.isBusy and Warp.isBusy()) + -- pokefirered/src/field_player_avatar.c:1419 + or (P and P.boulderPush ~= nil) return not locked and not scriptBusy end diff --git a/src/ui/game3/link_trade_menu.lua b/src/ui/game3/link_trade_menu.lua index e289e1b9..ac0e0fed 100644 --- a/src/ui/game3/link_trade_menu.lua +++ b/src/ui/game3/link_trade_menu.lua @@ -105,9 +105,9 @@ function LinkTradeMenu.confirm() LinkTradeMenu.message = Strings("Waiting...") return true end - local okT, Trade = pcall(require, "src.core.game3.scripting.natives_trade") - LinkTradeMenu.message = (okT and Trade and Trade.refusalText and Trade.refusalText(code)) - or Strings("That POKéMON can't be traded\nnow.") + local Trade = require("src.core.game3.scripting.natives_trade") + LinkTradeMenu.message = Trade.refusalText(code) + or require("src.core.game3.rom_text").plain("gText_PkmnCantBeTradedNow") return false end diff --git a/src/ui/game3/map_name_popup.lua b/src/ui/game3/map_name_popup.lua index 20125d40..34c88d9b 100644 --- a/src/ui/game3/map_name_popup.lua +++ b/src/ui/game3/map_name_popup.lua @@ -35,17 +35,20 @@ MapNamePopup._widthTiles = 14 MapNamePopup._contentWidth = 112 -- 14 tiles * 8px MapNamePopup._floorNum = 0 ---- Check if flag FLAG_DONT_SHOW_MAP_NAME_POPUP (0x8000) is set in session +-- pokefirered/include/constants/flags.h:1530 +local FLAG_DONT_SHOW_MAP_NAME_POPUP = 0x4000 + +-- pokefirered/src/map_name_popup.c:30 local function isFlagSuppressed() local Space = package.loaded["src.core.game3.scripting.space"] local Flags = package.loaded["src.core.game3.scripting.flags"] local store = Space and Space.store if store and Flags and Flags.getFlag then - if Flags.getFlag(store, nil, 0x8000) then return true end + if Flags.getFlag(store, nil, FLAG_DONT_SHOW_MAP_NAME_POPUP) then return true end end local Runtime = package.loaded["src.core.game3.runtime"] local session = Runtime and Runtime.getSession and Runtime.getSession() - if session and session.flags and session.flags[0x8000] then + if session and session.flags and session.flags[FLAG_DONT_SHOW_MAP_NAME_POPUP] then return true end return false diff --git a/src/ui/game3/minigame_records.lua b/src/ui/game3/minigame_records.lua new file mode 100644 index 00000000..8eb82666 --- /dev/null +++ b/src/ui/game3/minigame_records.lua @@ -0,0 +1,249 @@ +-- pokefirered/src/berry_crush.c:3095, src/pokemon_jump.c:4509, src/dodrio_berry_picking.c:2956 + +local Stack = require("src.ui.game3.stack") +local Window = require("src.ui.game3.window") +local FrlgFont = require("src.ui.game3.frlg_font") +local Strings = require("src.core.Strings") + +local Records = {} + +Records.ID = "minigame_records" + +Records.KIND = { + BERRY_CRUSH = "berry_crush", + POKEMON_JUMP = "pokemon_jump", + DODRIO = "dodrio", +} + +-- pokefirered/src/berry_crush.c:545 +-- pokefirered/src/pokemon_jump.c:4493 +-- pokefirered/src/dodrio_berry_picking.c:2935 +local TEMPLATES = { + berry_crush = Window.template(3, 4, 24, 13), + pokemon_jump = Window.template(1, 1, 28, 9), + dodrio = Window.template(1, 1, 28, 11), +} + +-- pokefirered/src/berry_crush.c:635 +Records.PRESSING_SPEED_CONVERSION = { + 50000000, 25000000, 12500000, 6250000, 3125000, 1562500, 781250, 390625, +} + +-- pokefirered/src/berry_crush.c:540 +local COLOR_BLUE = { + fg = FrlgFont.STDPAL[8], shadow = FrlgFont.STDPAL[9], bg = FrlgFont.STDPAL[0], +} +-- pokefirered/src/berry_crush.c:537 +local COLOR_GRAY = FrlgFont.COLOR.NORMAL + +Records.open = false +Records._kind = nil +Records._lines = {} +Records._onDone = nil +Records._frames = 0 + +local function num(v, digits) + v = math.floor(tonumber(v) or 0) + if v < 0 then v = 0 end + if digits then v = v % (10 ^ digits) end + return v +end + +-- pokefirered/src/berry_crush.c:3146 +function Records.pressingSpeedText(speed) + speed = num(speed) % 0x10000 + local hi = math.floor(speed / 256) + local lo = speed % 256 + local score = 0 + for j = 0, 7 do + if math.floor(lo / 2 ^ (7 - j)) % 2 == 1 then + score = score + Records.PRESSING_SPEED_CONVERSION[j + 1] + end + end + local frac = math.floor(score / 1000000) % 100 + -- pokefirered/src/strings.c:1048 + return string.format("%3d.%02d ", hi, frac) .. Strings("Times/sec.") +end + +-- pokefirered/src/berry_crush.c:3189 +local function crushSpeeds(session) + local stored = type(session) == "table" and session.berryCrushPressingSpeeds or nil + local out = {} + for i = 1, 4 do + out[i] = num(type(stored) == "table" and stored[i] or 0) % 0x10000 + end + return out +end + +local function jumpRecords(session) + local r = type(session) == "table" and type(session.pokemonJumpRecords) == "table" + and session.pokemonJumpRecords or {} + -- pokefirered/src/pokemon_jump.c:4550 + return { + num(r.jumpsInRow, 5), + num(r.bestJumpScore, 5), + num(r.excellentsInRow, 5), + } +end + +-- pokefirered/src/dodrio_berry_picking.c:3001 +local function dodrioRecords(session) + local r = type(session) == "table" and type(session.dodrioBerryPickingRecords) == "table" + and session.dodrioBerryPickingRecords or {} + -- pokefirered/src/dodrio_berry_picking.c:2947 + return { + num(r.berriesPicked, 4), + num(r.bestScore, 7), + num(r.berriesPickedInRow, 4), + } +end + +local function line(text, x, y, colors, spacing) + return { text = text, x = x, y = y, colors = colors or COLOR_GRAY, spacing = spacing or 0 } +end + +local function buildBerryCrush(session) + local lines = {} + -- pokefirered/src/berry_crush.c:3112 + local title = Strings("BERRY CRUSH") + lines[#lines + 1] = line(title, 96 - math.floor(FrlgFont.measure(title) / 2), 2, COLOR_BLUE) + -- pokefirered/src/berry_crush.c:3122 + local sub = Strings("Pressing-Speed Rankings") + lines[#lines + 1] = line(sub, 96 - math.floor(FrlgFont.measure(sub) / 2), 18, COLOR_BLUE) + local speeds = crushSpeeds(session) + local y = 42 + for i = 1, 4 do + -- pokefirered/src/berry_crush.c:3135 + lines[#lines + 1] = line(Strings("%d PLAYERS", i + 1), 4, y) + -- pokefirered/src/berry_crush.c:3156 + local s = Records.pressingSpeedText(speeds[i]) + lines[#lines + 1] = line(s, 192 - FrlgFont.measure(s), y) + y = y + 14 + end + return lines +end + +local function buildPokemonJump(session) + local lines = {} + -- pokefirered/src/pokemon_jump.c:4560 + lines[#lines + 1] = line(Strings("POKéMON JUMP RECORDS"), 0, 0, nil, 1) + -- pokefirered/src/pokemon_jump.c:4504 + local labels = { Strings("Jumps in a row:"), Strings("Best score:"), Strings("EXCELLENTS in a row:") } + local values = jumpRecords(session) + for i = 1, 3 do + local y = 20 + (i - 1) * 14 + lines[#lines + 1] = line(labels[i], 0, y, nil, 1) + -- pokefirered/src/pokemon_jump.c:4566 + local s = tostring(values[i]) + lines[#lines + 1] = line(s, 0xDE - FrlgFont.measure(s), y) + end + return lines +end + +local function buildDodrio(session) + local lines = {} + -- pokefirered/src/dodrio_berry_picking.c:3008 + lines[#lines + 1] = line(Strings("DODRIO BERRY-PICKING RECORDS"), 1, 1) + -- pokefirered/src/dodrio_berry_picking.c:2946 + local labels = { + Strings("BERRIES picked:"), + Strings("Best score:"), + Strings("BERRIES picked in a row with\nfive players:"), + } + -- pokefirered/src/dodrio_berry_picking.c:2950 + local textY, numY = { 24, 40, 56 }, { 24, 40, 70 } + local values = dodrioRecords(session) + for i = 1, 3 do + local s = tostring(values[i]) + lines[#lines + 1] = line(labels[i], 1, textY[i]) + -- pokefirered/src/dodrio_berry_picking.c:3014 + lines[#lines + 1] = line(s, 240 - 16 - FrlgFont.measure(s), numY[i]) + end + return lines +end + +local BUILDERS = { + berry_crush = buildBerryCrush, + pokemon_jump = buildPokemonJump, + dodrio = buildDodrio, +} + +function Records.lines() + return Records._lines +end + +function Records.kind() + return Records._kind +end + +function Records.isOpen() + return Records.open +end + +function Records.show(kind, session, onDone) + local build = BUILDERS[kind] + if not build then error("unknown minigame records kind: " .. tostring(kind)) end + Records._kind = kind + Records._lines = build(session) + Records._onDone = onDone + Records._frames = 0 + Records.open = true + Stack.push(Records.ID, Records, { hideBelow = false, drawUnder = true }) + return Records +end + +function Records.close() + if not Records.open then return false end + Records.open = false + Records._kind = nil + Stack.pop(Records.ID) + local cb = Records._onDone + Records._onDone = nil + if cb then cb() end + return true +end + +function Records.reset() + Records.open = false + Records._kind = nil + Records._lines = {} + Records._onDone = nil + Stack.pop(Records.ID) +end + +function Records.update(_dt) + if Records.open then Records._frames = Records._frames + 1 end +end + +function Records.handleInput(input) + if not Records.open or not input then return end + if Records._frames < 2 then return end + if input:wasPressed("a") or input:wasPressed("b") then + Records.close() + end +end + +local function drawSpaced(text, x, y, colors, spacing) + for ttype, val in FrlgFont.scanTokens(text) do + if ttype == "char" then + x = x + FrlgFont.drawGlyph(FrlgFont.glyphId(val), x, y, { colors = colors }) + spacing + end + end +end + +function Records.draw() + if not Records.open then return end + local tpl = TEMPLATES[Records._kind] + if not tpl then return end + Window.stdFrame(tpl) + local ox, oy = tpl.tilemapLeft * 8, tpl.tilemapTop * 8 + for _, l in ipairs(Records._lines) do + if l.spacing > 0 then + drawSpaced(l.text, ox + l.x, oy + l.y, l.colors, l.spacing) + else + FrlgFont.draw(l.text, ox + l.x, oy + l.y, { colors = l.colors }) + end + end +end + +return Records diff --git a/src/ui/game3/mon_pic.lua b/src/ui/game3/mon_pic.lua index e87b6f1c..787d7a22 100644 --- a/src/ui/game3/mon_pic.lua +++ b/src/ui/game3/mon_pic.lua @@ -1,5 +1,5 @@ -- Field script mon pic (pret ScriptMenu_ShowPokemonPic / showmonpic). --- 8×8 tile window + 64×64 front (or scaled icon fallback). +-- 8×8 tile window + 64×64 front. local Window = require("src.ui.game3.window") local Display = require("src.core.game3.display") @@ -25,10 +25,8 @@ function MonPic.show(species, x, y) MonPic._h = 64 local ok, Pokemon = pcall(require, "src.core.game3.pokemon") if ok and Pokemon then - local entry = Pokemon.frontPic and Pokemon.frontPic(species) - if not entry then - entry = Pokemon.icon and Pokemon.icon(species) - end + -- pokefirered/src/field_effect.c:610 + local entry = Pokemon.frontPic(Pokemon.picSpecies(species, 0x8000), nil, false, 0x8000) if entry and entry.image then MonPic._img = entry.image MonPic._w = entry.w or 64 diff --git a/src/ui/game3/naming.lua b/src/ui/game3/naming.lua index 4e03b001..4a9e72fd 100644 --- a/src/ui/game3/naming.lua +++ b/src/ui/game3/naming.lua @@ -357,15 +357,11 @@ local function drawPlayerIcon(st) if species > 0 then local ok, Pokemon = pcall(require, "src.core.game3.pokemon") if ok and Pokemon then - local entry = Pokemon.icon and Pokemon.icon(species) - if not entry then - entry = Pokemon.frontPic and Pokemon.frontPic(species) - end + -- pokefirered/src/naming_screen.c:1422 + local entry = Pokemon.icon and Pokemon.icon(Pokemon.picSpecies(species, st.personality)) if entry and entry.image then local iw = entry.w or entry.image:getWidth() local ih = entry.h or (entry.quads and entry.h) or entry.image:getHeight() - -- A mon icon is 32×32 and fills its frame 1:1; the frontPic fallback - -- is 64×64 and shrinks into the same box. local sc = math.min(L.monIconW / iw, L.monIconH / ih) -- pret passes SpriteCallbackDummy, so the icon shows its frame 0. local q = entry.quads and entry.quads[0] diff --git a/src/ui/game3/party_chrome.lua b/src/ui/game3/party_chrome.lua index ac648188..86ecdeec 100644 --- a/src/ui/game3/party_chrome.lua +++ b/src/ui/game3/party_chrome.lua @@ -111,7 +111,6 @@ end local function load_status_png() local candidates = { party_root() .. "/status_icons.png", - "src/import/gba/chrome/menus/party/status_icons.png", "data/generated/gba/pokemon/summary/status_icons.png", } for _, rel in ipairs(candidates) do diff --git a/src/ui/game3/party_menu.lua b/src/ui/game3/party_menu.lua index d5d86b07..8a98366d 100644 --- a/src/ui/game3/party_menu.lua +++ b/src/ui/game3/party_menu.lua @@ -13,6 +13,7 @@ local ItemUse = require("src.core.game3.item_use") local ItemsData = require("src.core.game3.items_data") local Bag = require("src.core.game3.bag") local Strings = require("src.core.Strings") +local RomText = require("src.core.game3.rom_text") local PartyMenu = {} @@ -446,7 +447,7 @@ local function ensure_slot_sprites(i, mon, selected) local maxHp = tonumber(mon.maxHp) or tonumber(mon.maxhp) or 1 local hpLevel = get_hp_bar_level(hp, maxHp, mon.isEgg) - local icon = Pokemon.icon(Pokemon.speciesOrEgg(mon)) + local icon = Pokemon.monIcon(mon) local q0 = icon and icon.quads and icon.quads[0] if not slot.mon then local id = select(1, Oam.createSprite({ @@ -920,6 +921,29 @@ function PartyMenu.showForgetPrompt(promptText, moveNames, cb) PartyMenu._forgetCursor = 1 end +-- pokefirered/src/party_menu.c:4548 ShowMoveSelectWindow +function PartyMenu.pickPpMove(mon, item, cb) + local names, slots = {}, {} + for i = 1, 4 do + local id = Pokemon.moveIdAt(mon, i) + if id and id > 0 then + names[#names + 1] = Pokemon.moveName(id) or Strings("MOVE %s", id) + slots[#slots + 1] = i + end + end + -- pokefirered/src/strings.c:319 gText_RestoreWhichMove, :320 gText_BoostPp + local prompt = ItemUse.ppItemBoosts(item) and RomText.plain("gText_BoostPp") + or RomText.plain("gText_RestoreWhichMove") + PartyMenu.showForgetPrompt(prompt, names, function(idx) + if idx == nil then + -- pokefirered/src/party_menu.c:4628 ReturnToUseOnWhichMon + PartyMenu.mode = "use" + return + end + cb(slots[idx + 1]) + end) +end + -- pokefirered/src/party_menu.c:4841 Task_LearnNextMoveOrClosePartyMenu local function tutor_learned(moveLearned) if moveLearned then PartyMenu._tutorResult = true end @@ -1046,7 +1070,7 @@ function PartyMenu.confirmChosenMons() if #order == 0 then se(26) -- pokefirered/src/strings.c:322 - PartyMenu.showMessage(Strings("No battling this way!"), function() + PartyMenu.showMessage(RomText.plain("gText_NoPokemonForBattle"), function() PartyMenu.mode = "choose_multi" end) return false @@ -1063,7 +1087,7 @@ end function PartyMenu.askCancelChooseMons() se(5) -- pokefirered/src/strings.c:370 - PartyMenu.showYesNo(Strings("Cancel the battle?"), function(yes) + PartyMenu.showYesNo(RomText.plain("gText_CancelBattle"), function(yes) if not yes then PartyMenu.mode = "choose_multi" return @@ -1422,7 +1446,7 @@ function PartyMenu.handleInput(input) local res = FieldMoves.fromMenu(act, ctx) if not res or not res.ok then se(5) -- pokefirered/src/party_menu.c:3910 - PartyMenu.showMessage((res and res.text) or Strings("Can't use that here."), function() + PartyMenu.showMessage((res and res.text) or RomText.plain("gText_CantUseHere"), function() PartyMenu.mode = "list" end) else @@ -1804,16 +1828,26 @@ function PartyMenu.handleInput(input) end -- Case 4: General Medicine / Potions / Status - local startHp = tonumber(mon and mon.hp) or 0 - local maxHp = tonumber(mon and (mon.maxHp or mon.maxhp)) or 1 - local realSlot = (PartyMenu._order and PartyMenu._order[PartyMenu.cursor]) or PartyMenu.cursor - local ok, reason, msgText = ItemUse.useField(PartyMenu._session, PartyMenu._bag, PartyMenu._item, realSlot) - local endHp = tonumber(mon and mon.hp) or startHp - if ok then - se(2) - local hasRemaining = Bag.has(PartyMenu._bag, PartyMenu._item, 1) - if endHp > startHp then - PartyMenu.startHpAnim(PartyMenu.cursor, startHp, endHp, maxHp, function() + local function use_general(moveSlot) + local startHp = tonumber(mon and mon.hp) or 0 + local maxHp = tonumber(mon and (mon.maxHp or mon.maxhp)) or 1 + local realSlot = (PartyMenu._order and PartyMenu._order[PartyMenu.cursor]) or PartyMenu.cursor + local ok, reason, msgText = ItemUse.useField(PartyMenu._session, PartyMenu._bag, PartyMenu._item, realSlot, moveSlot) + local endHp = tonumber(mon and mon.hp) or startHp + if ok then + se(1) -- pokefirered/src/party_menu.c:4500 + local hasRemaining = Bag.has(PartyMenu._bag, PartyMenu._item, 1) + if endHp > startHp then + PartyMenu.startHpAnim(PartyMenu.cursor, startHp, endHp, maxHp, function() + PartyMenu.showMessage(msgText, function() + if hasRemaining then + PartyMenu.mode = "use" + else + PartyMenu.close() + end + end) + end) + else PartyMenu.showMessage(msgText, function() if hasRemaining then PartyMenu.mode = "use" @@ -1821,22 +1855,21 @@ function PartyMenu.handleInput(input) PartyMenu.close() end end) - end) + end else - PartyMenu.showMessage(msgText, function() - if hasRemaining then - PartyMenu.mode = "use" - else - PartyMenu.close() - end + se(5) -- pokefirered/src/party_menu.c:4490 + PartyMenu.showMessage(msgText or Strings("It won't have any effect."), function() + PartyMenu.mode = "use" end) end - else - se(5) -- pokefirered/src/party_menu.c:4490 - PartyMenu.showMessage(msgText or Strings("It won't have any effect."), function() - PartyMenu.mode = "use" - end) end + -- pokefirered/src/party_menu.c:4591 ItemUseCB_TryRestorePP + if ItemsData.fieldUseKind(PartyMenu._item) == "pp" and ItemUse.ppItemNeedsMove(PartyMenu._item) then + se(5) + PartyMenu.pickPpMove(mon, PartyMenu._item, use_general) + return + end + use_general(nil) elseif input:wasPressed("b") or input:wasPressed("start") then se(5) -- pokefirered/src/party_menu.c:1246 PartyMenu.close() @@ -2270,7 +2303,7 @@ function PartyMenu.draw() end elseif PartyMenu.mode == "forget" then Window.stdFrame(Window.template(1, 17, 15, 2)) - FrlgFont.draw(Strings("Which move?"), 1 * 8 + 2, 17 * 8 + 2, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(PartyMenu._forgetPrompt or Strings("Which move?"), 1 * 8 + 2, 17 * 8 + 2, { colors = FrlgFont.COLOR.NORMAL }) local moves = PartyMenu._forgetMoves or {} local popW = 11 @@ -2287,7 +2320,7 @@ function PartyMenu.draw() end elseif PartyMenu.mode == "item_action" then Window.stdFrame(Window.template(1, 17, 18, 2)) - FrlgFont.draw(Strings("Do what with an item?"), 1 * 8 + 2, 17 * 8 + 2, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(RomText.plain("gText_DoWhatWithItem"), 1 * 8 + 2, 17 * 8 + 2, { colors = FrlgFont.COLOR.NORMAL }) local actCount = #PartyMenu.ITEM_ACTIONS local popW = 7 @@ -2323,21 +2356,22 @@ function PartyMenu.draw() end else Window.stdFrame(Window.template(1, 17, 21, 2)) - local promptText = Strings("Choose a POKéMON.") + local promptKey = "gText_ChoosePokemon" if PartyMenu.mode == "switch" then - promptText = Strings("Move to where?") + promptKey = "gText_MoveToWhere" elseif PartyMenu.mode == "use" then if PartyMenu._item and ItemsData.isTm(PartyMenu._item) then - promptText = Strings("Teach which POKéMON?") + promptKey = "gText_TeachWhichPokemon" else - promptText = Strings("Use on which POKéMON?") + promptKey = "gText_UseOnWhichPokemon" end elseif PartyMenu.mode == "give" then - promptText = Strings("Give to which POKéMON?") + promptKey = "gText_GiveToWhichPokemon" elseif PartyMenu.mode == "move_tutor" then -- pokefirered/src/data/party_menu.h:609 - promptText = Strings("Teach which POKéMON?") + promptKey = "gText_TeachWhichPokemon" end + local promptText = RomText.plain(promptKey) FrlgFont.draw(promptText, 1 * 8 + 2, 17 * 8 + 2, { colors = FrlgFont.COLOR.NORMAL }) if PartyMenu.mode == "choose_multi" then -- pokefirered/src/party_menu.c:1063 DrawCancelConfirmButtons diff --git a/src/ui/game3/pc_chrome.lua b/src/ui/game3/pc_chrome.lua index cb505a16..26a0e3a8 100644 --- a/src/ui/game3/pc_chrome.lua +++ b/src/ui/game3/pc_chrome.lua @@ -77,7 +77,6 @@ local function load_texture(name) local candidates = { "pokemon/storage/" .. name, "data/generated/gba/pokemon/storage/" .. name, - "src/import/gba/chrome/menus/storage/" .. name, } local okA, Assets = pcall(require, "src.render.Assets") for _, path in ipairs(candidates) do @@ -219,7 +218,7 @@ function PcChrome.drawLeftDataPanel(hoveredMon, hoverFrame) -- 1. Front Sprite in TV Screen (X: 10..73, Y: 19..80, W: 64, H: 61) -- pokefirered/src/pokemon_storage_system_data.c:1034, :1057 MON_DATA_SPECIES_OR_EGG local sp = Pokemon.speciesOrEgg(hoveredMon) - local sprite = Pokemon.frontPic(sp) + local sprite = Pokemon.monFrontPic(hoveredMon) if sprite and sprite.image then love.graphics.setColor(1, 1, 1, 1) local sw, sh = sprite.image:getDimensions() @@ -399,8 +398,7 @@ function PcChrome.drawPartyDrawer(party, partyCursor, hoverFrame, holdingSource) local isLeadPickedUp = (holdingSource and holdingSource.loc == "party" and holdingSource.slot == 1) local leadMon = (not isLeadPickedUp) and party[1] if leadMon then - local sp = Pokemon.speciesOrEgg(leadMon) - local icon = Pokemon.icon(sp) + local icon = Pokemon.monIcon(leadMon) if icon and icon.image then local isHovered = (partyCursor == 1) local bounce = (isHovered and (hoverFrame % 2 == 1)) and -2 or 0 @@ -419,8 +417,7 @@ function PcChrome.drawPartyDrawer(party, partyCursor, hoverFrame, holdingSource) local isPickedUp = (holdingSource and holdingSource.loc == "party" and holdingSource.slot == p) local pMon = (not isPickedUp) and party[p] if pMon then - local sp = Pokemon.speciesOrEgg(pMon) - local icon = Pokemon.icon(sp) + local icon = Pokemon.monIcon(pMon) if icon and icon.image then local isHovered = (partyCursor == p) local bounce = (isHovered and (hoverFrame % 2 == 1)) and -2 or 0 diff --git a/src/ui/game3/pc_menu.lua b/src/ui/game3/pc_menu.lua index b7107178..a95cd8f0 100644 --- a/src/ui/game3/pc_menu.lua +++ b/src/ui/game3/pc_menu.lua @@ -14,6 +14,7 @@ local ItemsData = require("src.core.game3.items_data") local Bag = require("src.core.game3.bag") local Storage = require("src.core.game3.storage") local Strings = require("src.core.Strings") +local RomText = require("src.core.game3.rom_text") local PcMenu = {} @@ -91,9 +92,9 @@ function PcMenu._rootEntries() { id = "storage", label = who }, { id = "player", label = player }, } - if dex then rows[#rows + 1] = { id = "oak", label = Strings("PROF. OAK's PC") } end - if clear then rows[#rows + 1] = { id = "hall", label = Strings("HALL OF FAME") } end - rows[#rows + 1] = { id = "quit", label = Strings("LOG OFF") } + if dex then rows[#rows + 1] = { id = "oak", label = RomText.plain("gText_ProfOakSPc") } end + if clear then rows[#rows + 1] = { id = "hall", label = RomText.plain("gText_HallOfFame") } end + rows[#rows + 1] = { id = "quit", label = RomText.plain("gText_LogOff") } PcMenu._rootRows = rows end return PcMenu._rootRows @@ -105,6 +106,9 @@ function PcMenu.show(opts) PcMenu._session = opts.session PcMenu._onClose = opts.onClose PcMenu._closeOnExit = opts.closeOnExit == true + PcMenu._silentClose = opts.silentClose == true + PcMenu._select = opts.startMode == "select" + PcMenu._result = nil PcMenu.cursor = 1 PcMenu._prevStatus = nil PcMenu._prevCursor = nil @@ -112,6 +116,14 @@ function PcMenu.show(opts) if opts.startMode == "player_pc" then PcMenu.mode = "player_pc" PcMenu._status = Strings(PcMenu.TEXT_WHAT_TO_DO) -- pokefirered/src/player_pc.c:160 + elseif opts.startMode == "storage" then + PcMenu.mode = "storage_menu" + PcMenu.storageCursor = 1 + PcMenu._status = PcMenu._storageOptions()[1].desc + elseif PcMenu._select then + PcMenu.mode = "root" + if opts.prompt then PcMenu._lastPrompt = opts.prompt end + PcMenu._status = PcMenu._lastPrompt else PcMenu.mode = "root" PcMenu._status = Strings("Which PC would you like to access?") @@ -119,13 +131,21 @@ function PcMenu.show(opts) Stack.push("pc_menu", PcMenu, { hideBelow = false }) end -function PcMenu.close() +function PcMenu.close(result) PcMenu.open = false Stack.pop("pc_menu") - se(3) -- SE_PC_OFF + if not PcMenu._silentClose then se(3) end + PcMenu._result = result local cb = PcMenu._onClose PcMenu._onClose = nil - if cb then cb() end + if cb then cb(result) end +end + +-- pokefirered/src/script_menu.c:831 +local SCR_MENU_CANCEL = 127 +local function return_row(index) + PcMenu._silentClose = true + PcMenu.close(index) end function PcMenu.isOpen() @@ -171,6 +191,16 @@ local function draw_status_lines() if #lines > 1 then Window.print(lines[2], 2, 17, { clipTiles = 26 }) end end +function PcMenu._storageOptions() + return { + { id = "withdraw", label = RomText.plain("gText_WithdrawPokemon"), desc = RomText.plain("gText_WithdrawMonDescription") }, + { id = "deposit", label = RomText.plain("gText_DepositPokemon"), desc = RomText.plain("gText_DepositMonDescription") }, + { id = "move", label = RomText.plain("gText_MovePokemon"), desc = RomText.plain("gText_MoveMonDescription") }, + { id = "move_items", label = RomText.plain("gText_MoveItems"), desc = RomText.plain("gText_MoveItemsDescription") }, + { id = "quit", label = RomText.plain("gText_SeeYa"), desc = RomText.plain("gText_SeeYaDescription") }, + } +end + function PcMenu.handleInput(input) if not PcMenu.open then return end @@ -188,13 +218,7 @@ function PcMenu.handleInput(input) end -- Storage System Menu (WITHDRAW POKéMON, DEPOSIT POKéMON, MOVE POKéMON, MOVE ITEMS, SEE YA!) - local STORAGE_OPTIONS = { - { id = "withdraw", label = Strings("WITHDRAW POKéMON"), desc = Strings("You can withdraw a POKéMON if you\nhave any in a BOX.") }, - { id = "deposit", label = Strings("DEPOSIT POKéMON"), desc = Strings("You can deposit your party\nPOKéMON in any BOX.") }, - { id = "move", label = Strings("MOVE POKéMON"), desc = Strings("You can move POKéMON that are\nstored in any BOX.") }, - { id = "move_items", label = Strings("MOVE ITEMS"), desc = Strings("You can move items held by any\nPOKéMON in a BOX or your party.") }, - { id = "quit", label = Strings("SEE YA!"), desc = Strings("See you later!") }, - } + local STORAGE_OPTIONS = PcMenu._storageOptions() -- Root Menu if PcMenu.mode == "root" then @@ -208,7 +232,10 @@ function PcMenu.handleInput(input) se(5) elseif input:wasPressed("a") then local choice = entries[PcMenu.cursor] - if choice.id == "quit" then + if PcMenu._select or choice.id == "oak" or choice.id == "hall" then + se(5) -- pokefirered/src/menu.c:347 + return_row(PcMenu.cursor - 1) + elseif choice.id == "quit" then se(5) -- pokefirered/src/menu.c:347 PcMenu.close() elseif choice.id == "storage" then @@ -223,27 +250,15 @@ function PcMenu.handleInput(input) se(2) -- data/scripts/pc.inc:39 PcMenu.mode = "player_pc" PcMenu.cursor = 1 - PcMenu._status = Strings("What would you like to do?") - elseif choice.id == "oak" then - se(5) - se(2) -- data/scripts/pc.inc:88 - local Dex = require("src.core.game3.dex") - local dex = PcMenu._session and PcMenu._session.dex - local caught = dex and Dex.countCaught(dex, "kanto") or 0 - local seen = dex and Dex.countSeen(dex, "kanto") or 0 - PcMenu._status = Strings("Current POKéDEX status:\nSeen: %d Owned: %d", seen, caught) - PcMenu._prevMode = "root" - PcMenu.mode = "msg" - elseif choice.id == "hall" then - se(5) - se(2) -- data/scripts/pc.inc:76 - PcMenu._status = Strings("No records in the HALL OF FAME.") - PcMenu._prevMode = "root" - PcMenu.mode = "msg" + PcMenu._status = RomText.plain("gText_WhatWouldYouLikeToDo") end elseif input:wasPressed("b") then se(5) -- pokefirered/src/script_menu.c:831 - PcMenu.close() + if PcMenu._select then + return_row(SCR_MENU_CANCEL) + else + PcMenu.close() + end end return end @@ -262,7 +277,10 @@ function PcMenu.handleInput(input) se(5) elseif input:wasPressed("a") then local choice = STORAGE_OPTIONS[PcMenu.cursor] - if choice.id == "quit" then + if choice.id == "quit" and PcMenu._closeOnExit then + se(5) + PcMenu.close() + elseif choice.id == "quit" then PcMenu.mode = "root" PcMenu.cursor = 1 PcMenu._status = Strings("Which PC would you like to access?") @@ -270,7 +288,7 @@ function PcMenu.handleInput(input) elseif choice.id == "withdraw" then local party = (PcMenu._session and PcMenu._session.party) or {} if #party >= 6 then - PcMenu._status = Strings("Can't take any more POKéMON.") + PcMenu._status = RomText.plain("gText_PartyFull") PcMenu._prevMode = "storage_menu" PcMenu.mode = "msg" se(5) -- pokefirered/src/pokemon_storage_system_tasks.c:992 @@ -291,7 +309,7 @@ function PcMenu.handleInput(input) elseif choice.id == "deposit" then local party = (PcMenu._session and PcMenu._session.party) or {} if #party <= 1 then - PcMenu._status = Strings("Can't deposit the last POKéMON!") + PcMenu._status = RomText.plain("gText_JustOnePkmn") PcMenu._prevMode = "storage_menu" PcMenu.mode = "msg" se(26) -- pokefirered/src/pokemon_storage_system_tasks.c:1052 @@ -324,6 +342,9 @@ function PcMenu.handleInput(input) end, }) end + elseif input:wasPressed("b") and PcMenu._closeOnExit then + se(5) + PcMenu.close() elseif input:wasPressed("b") then PcMenu.mode = "root" PcMenu.cursor = 1 @@ -438,7 +459,7 @@ function PcMenu.handleInput(input) "item_storage", Strings(PcMenu.ITEM_STORAGE_ACTIONS[1].desc), 1) se(246) else - PcMenu._status = Strings("The BAG is full.") + PcMenu._status = RomText.plain("gText_NoMoreRoomInBag") PcMenu._prevMode = "withdraw_item" PcMenu.mode = "msg" se(5) -- pokefirered/src/item_pc.c:863 @@ -476,7 +497,7 @@ function PcMenu.handleInput(input) "item_storage", Strings(PcMenu.ITEM_STORAGE_ACTIONS[1].desc), 1) se(246) else - PcMenu._status = Strings("The BAG is full.") + PcMenu._status = RomText.plain("gText_NoMoreRoomInBag") PcMenu._prevMode = "withdraw_item" PcMenu.mode = "msg" se(5) -- pokefirered/src/item_pc.c:984 @@ -621,18 +642,11 @@ function PcMenu.draw() -- Storage Submenu Box (WITHDRAW, DEPOSIT, MOVE, MOVE ITEMS, SEE YA!) if PcMenu.mode == "storage_menu" then - local storageOptions = { - "WITHDRAW POKéMON", - "DEPOSIT POKéMON", - "MOVE POKéMON", - "MOVE ITEMS", - "SEE YA!", - } Window.stdFrame(Window.template(1, 1, 16, 10)) - for i, opt in ipairs(storageOptions) do + for i, opt in ipairs(PcMenu._storageOptions()) do local yPx = 10 + (i - 1) * 16 if i == PcMenu.cursor then Window.cursorPx(12, yPx) end - Window.printPx(Strings(opt), 20, yPx) + Window.printPx(opt.label, 20, yPx) end -- Bottom Dialogue diff --git a/src/ui/game3/pokedex.lua b/src/ui/game3/pokedex.lua index 5fe4c1dc..c06810d9 100644 --- a/src/ui/game3/pokedex.lua +++ b/src/ui/game3/pokedex.lua @@ -904,7 +904,7 @@ local function draw_ordered_list() end -- Number: №001 (FONT_SMALL at x=28) - local natId = Pokemon.nationalPokedexNumber and Pokemon.nationalPokedexNumber(sp) or sp + local natId = Pokemon.national(sp) or 0 local num = (Pokedex.currentOrder == "numerical_kanto") and sp or natId local numStr = string.format("№%03d", num) FrlgFont.draw(numStr, 28, rowY + 1, { @@ -961,7 +961,7 @@ end local function draw_data_screen() local dex = Pokedex._dex local sp = Pokedex._regSpecies or Pokedex.selectedSpecies - local natId = Pokemon.nationalPokedexNumber and Pokemon.nationalPokedexNumber(sp) or sp + local natId = Pokemon.national(sp) or 0 local dispNum = (Pokedex.currentOrder == "numerical_kanto") and sp or natId local name = species_label(sp) local entry = PokedexChrome.getEntry(sp) @@ -1029,7 +1029,7 @@ local function draw_data_screen() end -- Front Sprite (64x64) at screen x=152, y=24 (sWindowTemplate_DexEntry_MonPic at x=152, y=24) - local pic = Pokemon.frontPic and Pokemon.frontPic(sp) + local pic = Pokemon.dexFrontPic(sp, Dex.defaultPersonality(Pokedex._dex, sp)) if pic and pic.image then love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(pic.image, 152, 24) @@ -1082,7 +1082,7 @@ local function draw_data_screen() end -- 3. Top Left: Mon Icon (32x32) at (14, 20) - local icon = Pokemon.icon and Pokemon.icon(sp) + local icon = Pokemon.dexIcon(sp, Dex.defaultPersonality(Pokedex._dex, sp)) if icon and icon.image then local q = icon.quads and icon.quads[0] love.graphics.setColor(1, 1, 1, 1) @@ -1118,7 +1118,7 @@ local function draw_data_screen() if isCaught then -- pokefirered/src/pokedex_screen.c:3107 - local pic = Pokemon.frontPic and Pokemon.frontPic(sp) + local pic = Pokemon.dexFrontPic(sp, Dex.defaultPersonality(Pokedex._dex, sp)) if pic and pic.image then PokedexChrome.drawSilhouette(pic.image, 40, 104 + (entry.pokemonOffset or 0), Pokedex.silhouetteScale(entry.pokemonScale), Pokedex.silhouetteScale(entry.pokemonScale), 32, 32) @@ -1229,7 +1229,7 @@ local function draw_habitat_grid() -- Pokémon Front Sprite if seen then - local pic = Pokemon.frontPic and Pokemon.frontPic(sp) + local pic = Pokemon.dexFrontPic(sp, Dex.defaultPersonality(Pokedex._dex, sp)) if pic and pic.image then love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(pic.image, coords.pic.x, coords.pic.y) @@ -1269,7 +1269,7 @@ local function draw_area_screen() PokedexChrome.drawHeader(Strings("AREA: %s", name), nil, 2) -- Left Column: Mon Icon & Types & Name - local icon = Pokemon.icon and Pokemon.icon(sp) + local icon = Pokemon.dexIcon(sp, Dex.defaultPersonality(Pokedex._dex, sp)) if icon and icon.image then local q = icon.quads and icon.quads[0] love.graphics.setColor(1, 1, 1, 1) @@ -1297,7 +1297,7 @@ local function draw_area_screen() local entry = PokedexChrome.getEntry(sp) love.graphics.setColor(0.25, 0.25, 0.30, 0.85) local pScale = (entry.pokemonScale or 256) / 256 * 0.45 - local pic = Pokemon.frontPic and Pokemon.frontPic(sp) + local pic = Pokemon.dexFrontPic(sp, Dex.defaultPersonality(Pokedex._dex, sp)) if pic and pic.image then love.graphics.draw(pic.image, 14, 80 + (entry.pokemonOffset or 0), 0, pScale, pScale) end @@ -1361,7 +1361,7 @@ local function draw_size_screen() FrlgFont.draw(Strings("WT %s", (entry.weightFormatted or Strings("---.- lbs."))), 114, 62, { color = { 0.2, 0.2, 0.2, 1 } }) local pScale = (entry.pokemonScale or 256) / 256 * 0.75 - local pic = Pokemon.frontPic and Pokemon.frontPic(sp) + local pic = Pokemon.dexFrontPic(sp, Dex.defaultPersonality(Pokedex._dex, sp)) if pic and pic.image then love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(pic.image, 140, 72 + (entry.pokemonOffset or 0), 0, pScale, pScale) diff --git a/src/ui/game3/pokedex_chrome.lua b/src/ui/game3/pokedex_chrome.lua index 6e5c830d..35229902 100644 --- a/src/ui/game3/pokedex_chrome.lua +++ b/src/ui/game3/pokedex_chrome.lua @@ -241,21 +241,6 @@ function PokedexChrome.drawKeypadIcon(iconName, x, y) end end end - if not img then - local candidates = { - "chrome/keypad_icons.png", - "data/generated/gba/chrome/keypad_icons.png", - } - for _, p in ipairs(candidates) do - local ok, newImg = pcall(love.graphics.newImage, p) - if ok and newImg then - if newImg.setFilter then newImg:setFilter("nearest", "nearest") end - img = newImg - PokedexChrome._images["keypad_icons"] = img - break - end - end - end if img then if not KEYPAD_ICON_QUADS then @@ -657,21 +642,6 @@ local function get_pokedex_quad(key, x, y, w, h, sw, sh) return PokedexChrome._quads[key] end -local function sanitize_menu_info_imagedata(id) - if not id or not id.getPixel or not id.setPixel then return id end - local ok, w, h = pcall(function() return id:getDimensions() end) - if not ok or not w or not h then return id end - for y = 0, h - 1 do - for x = 0, w - 1 do - local r, g, b, a = id:getPixel(x, y) - if math.abs(r - 123/255) < 0.02 and math.abs(g - 156/255) < 0.02 and math.abs(b - 131/255) < 0.02 then - id:setPixel(x, y, 1, 1, 1, 0) - end - end - end - return id -end - --- Load authentic GBA menu_info texture containing all 18 type badges & caught ball function PokedexChrome.menuInfoImage() if PokedexChrome._menuInfo then return PokedexChrome._menuInfo end @@ -680,38 +650,6 @@ function PokedexChrome.menuInfoImage() PokedexChrome._menuInfo = SummaryChrome.menuInfoImage() if PokedexChrome._menuInfo then return PokedexChrome._menuInfo end end - local paths = { - "data/generated/gba/pokemon/summary/menu_info.png", - "src/import/gba/chrome/menus/menu_info.png", - } - for _, p in ipairs(paths) do - local bytes = read_bytes(p) - if bytes and love and love.image and love.graphics then - local ok, img = pcall(function() - local fd = love.filesystem.newFileData(bytes, "menu_info.png") - local id = sanitize_menu_info_imagedata(love.image.newImageData(fd)) - local image = love.graphics.newImage(id) - if image.setFilter then image:setFilter("nearest", "nearest") end - return image - end) - if ok and img then - PokedexChrome._menuInfo = img - return img - end - end - if love and love.graphics and love.image and love.image.newImageData then - local ok, img = pcall(function() - local id = sanitize_menu_info_imagedata(love.image.newImageData(p)) - local image = love.graphics.newImage(id) - if image.setFilter then image:setFilter("nearest", "nearest") end - return image - end) - if ok and img then - PokedexChrome._menuInfo = img - return img - end - end - end return nil end @@ -1012,7 +950,7 @@ function PokedexChrome.drawMiniCard(speciesId, x, y, isCaught, isSeen, isSelecte local Pokemon = require("src.core.game3.pokemon") local sp = tonumber(speciesId) or 1 - local natId = Pokemon.nationalPokedexNumber and Pokemon.nationalPokedexNumber(sp) or sp + local natId = Pokemon.national(sp) or 0 local name = isSeen and (Pokemon.name and Pokemon.name(sp) or Strings("POKéMON %d", sp)) or "----------" -- Draw authentic 64x40 mini page background (white top, brown dividing line, beige bottom with simulated text) diff --git a/src/ui/game3/region_map.lua b/src/ui/game3/region_map.lua index 5ccc3f11..38d1e9ac 100644 --- a/src/ui/game3/region_map.lua +++ b/src/ui/game3/region_map.lua @@ -309,7 +309,6 @@ local function get_map_image() "data/generated/gba/region_map/kanto_map.rgba", "region_map/kanto_map.png", "data/generated/gba/region_map/kanto_map.png", - "assets/generated/region_map/kanto_map.png", } local img = try_load_image(candidates, 240, 160) RegionMap._images["kanto_map"] = img or false @@ -325,7 +324,6 @@ local function get_cursor_image() "data/generated/gba/region_map/cursor.rgba", "region_map/cursor.png", "data/generated/gba/region_map/cursor.png", - "pokefirered/graphics/region_map/cursor.png", } local img = try_load_image(candidates, 16, 16) RegionMap._images["cursor"] = img or false @@ -341,7 +339,6 @@ local function get_dungeon_icon_image() "data/generated/gba/region_map/dungeon_icon.rgba", "region_map/dungeon_icon.png", "data/generated/gba/region_map/dungeon_icon.png", - "pokefirered/graphics/region_map/dungeon_icon.png", } local img = try_load_image(candidates, 8, 8) RegionMap._images["dungeon_icon"] = img or false @@ -354,7 +351,6 @@ local function get_dungeon_icon_visited_image() return RegionMap._images["dungeon_icon_visited"] or nil end local img = try_load_image("data/generated/gba/region_map/dungeon_icon_visited.png") - or try_load_image("data/generated/gba/region_map/dungeon_icon_1.png") RegionMap._images["dungeon_icon_visited"] = img or false return img end @@ -483,8 +479,6 @@ local function get_player_image(female) "data/generated/gba/region_map/" .. key .. ".rgba", "region_map/" .. key .. ".png", "data/generated/gba/region_map/" .. key .. ".png", - female and "pokefirered/graphics/region_map/player_icon_leaf.png" - or "pokefirered/graphics/region_map/player_icon_red.png", } local img = try_load_image(candidates, 16, 16) RegionMap._images[key] = img or false diff --git a/src/ui/game3/release_seq.lua b/src/ui/game3/release_seq.lua index 3bf4ffde..d844c851 100644 --- a/src/ui/game3/release_seq.lua +++ b/src/ui/game3/release_seq.lua @@ -125,7 +125,7 @@ function ReleaseSeq.draw() local curX = ReleaseSeq.startX local curY = ReleaseSeq.startY - (progress * 40) -- float upward 40px - local icon = ReleaseSeq.mon and Pokemon.icon(Pokemon.speciesOrEgg(ReleaseSeq.mon)) + local icon = ReleaseSeq.mon and Pokemon.monIcon(ReleaseSeq.mon) if icon and icon.image then local q = icon.quads and icon.quads[0] love.graphics.setColor(1, 1, 1, alpha) diff --git a/src/ui/game3/shop_menu.lua b/src/ui/game3/shop_menu.lua index aa435353..289f94cc 100644 --- a/src/ui/game3/shop_menu.lua +++ b/src/ui/game3/shop_menu.lua @@ -8,6 +8,7 @@ local ItemsData = require("src.core.game3.items_data") local Bag = require("src.core.game3.bag") local MoneyBox = require("src.ui.game3.money_box") local Strings = require("src.core.Strings") +local RomText = require("src.core.game3.rom_text") local ShopMenu = {} @@ -280,7 +281,7 @@ local function commit_buy() if premierBonus > 0 then ShopMenu._status = Strings("Here you are! Thank you!\nI'll also include a PREMIER BALL!") else - ShopMenu._status = Strings("Here you are!\nThank you!") + ShopMenu._status = RomText.plain("gText_HereYouGoThankYou") end queue_shop_se(ShopMenu._status, session.money) -- pokefirered/src/shop.c:999 @@ -467,7 +468,7 @@ function ShopMenu.handleInput(input) do_fade_transition(function() ShopMenu.mode = "root" ShopMenu.cursor = 1 - ShopMenu._status = Strings("Is there anything else I can do?") + ShopMenu._status = RomText.plain("gText_AnythingElseICanHelp") end) else begin_buy_qty(rows[ShopMenu.cursor]) @@ -477,7 +478,7 @@ function ShopMenu.handleInput(input) do_fade_transition(function() ShopMenu.mode = "root" ShopMenu.cursor = 1 - ShopMenu._status = Strings("Is there anything else I can do?") + ShopMenu._status = RomText.plain("gText_AnythingElseICanHelp") end) end return @@ -501,7 +502,7 @@ function ShopMenu.handleInput(input) do_fade_transition(function() ShopMenu.mode = "root" ShopMenu.cursor = 2 - ShopMenu._status = Strings("Is there anything else I can do?") + ShopMenu._status = RomText.plain("gText_AnythingElseICanHelp") end) else begin_sell_qty(rows[ShopMenu.cursor]) @@ -511,7 +512,7 @@ function ShopMenu.handleInput(input) do_fade_transition(function() ShopMenu.mode = "root" ShopMenu.cursor = 2 - ShopMenu._status = Strings("Is there anything else I can do?") + ShopMenu._status = RomText.plain("gText_AnythingElseICanHelp") end) end return diff --git a/src/ui/game3/slot_machine.lua b/src/ui/game3/slot_machine.lua index c9fe3b78..69f3d9a0 100644 --- a/src/ui/game3/slot_machine.lua +++ b/src/ui/game3/slot_machine.lua @@ -4,6 +4,7 @@ local Stack = require("src.ui.game3.stack") local Window = require("src.ui.game3.window") local FrlgFont = require("src.ui.game3.frlg_font") local Strings = require("src.core.Strings") +local RomText = require("src.core.game3.rom_text") local Model = require("src.core.game3.slot_machine") local SlotMachineUi = {} @@ -371,7 +372,7 @@ end local function update_bet(st, input) if coins() == 0 then SlotMachineUi.task = "nocoins" - SlotMachineUi._message = Strings("You've run out of COINS.\nGame over!") + SlotMachineUi._message = RomText.plain("gString_OutOfCoins") return end if input:wasPressed("down") then @@ -392,7 +393,7 @@ local function update_bet(st, input) SlotMachineUi.task = "spin" elseif input:wasPressed("b") then SlotMachineUi.task = "quit" - SlotMachineUi._message = Strings("Quit playing?") + SlotMachineUi._message = RomText.plain("gString_QuitPlaying") SlotMachineUi._yesNo = 1 elseif input:wasPressed("right") then -- pokefirered/src/slot_machine.c:993 diff --git a/src/ui/game3/start_menu.lua b/src/ui/game3/start_menu.lua index fe35db8a..fb9e5809 100644 --- a/src/ui/game3/start_menu.lua +++ b/src/ui/game3/start_menu.lua @@ -20,8 +20,66 @@ StartMenu.ENTRIES = {} StartMenu._confirmExit = false StartMenu._confirmCursor = 2 -- 1=YES, 2=NO (default NO) +local function player_label(session) + local name = (session and (session.name or session.playerName)) or "PLAYER" + name = tostring(name) + if #name > 7 then name = name:sub(1, 7) end + return string.upper(name) +end + +-- pokefirered/src/overworld.c:1386 IsUpdateLinkStateCBActive +local function link_state_active() + local Link = package.loaded["src.core.game3.link"] + if not (type(Link) == "table" and Link.link and Link.inLinkRoom) then return false end + local ok, inRoom = pcall(Link.inLinkRoom) + return ok and inRoom == true +end + +-- pokefirered/src/union_room.c:4558 InUnionRoom +local function in_union_room(session) + local Map = package.loaded["src.core.game3.map"] + local cur = (Map and type(Map.current) == "string" and Map.current) or (session and session.map) + return cur == "FR_UNION_ROOM" +end + +local function safari_active(session) + return require("src.core.game3.safari").isActive(session) == true +end + -- pret MENU_POKEDEX..MENU_EXIT order for normal field. local function build_entries(session) + if link_state_active() then + -- pokefirered/src/start_menu.c:236 SetUpStartMenu_Link + return { + { id = "pokemon", label = "POKéMON" }, + { id = "bag", label = "BAG" }, + { id = "trainer_link", label = player_label(session) }, + { id = "option", label = "OPTION" }, + { id = "exit", label = "EXIT" }, + } + end + if in_union_room(session) then + -- pokefirered/src/start_menu.c:245 SetUpStartMenu_UnionRoom + return { + { id = "pokemon", label = "POKéMON" }, + { id = "bag", label = "BAG" }, + { id = "trainer", label = player_label(session) }, + { id = "option", label = "OPTION" }, + { id = "exit", label = "EXIT" }, + } + end + if safari_active(session) then + -- pokefirered/src/start_menu.c:226 SetUpStartMenu_SafariZone + return { + { id = "retire", label = "RETIRE" }, + { id = "pokedex", label = "POKéDEX" }, + { id = "pokemon", label = "POKéMON" }, + { id = "bag", label = "BAG" }, + { id = "trainer", label = player_label(session) }, + { id = "option", label = "OPTION" }, + { id = "exit", label = "EXIT" }, + } + end local entries = {} local Flags = package.loaded["src.core.game3.scripting.flags"] local Space = package.loaded["src.core.game3.scripting.space"] @@ -43,10 +101,7 @@ local function build_entries(session) entries[#entries + 1] = { id = "pokemon", label = "POKéMON" } end entries[#entries + 1] = { id = "bag", label = "BAG" } - local name = (session and (session.name or session.playerName)) or "PLAYER" - name = tostring(name) - if #name > 7 then name = name:sub(1, 7) end - entries[#entries + 1] = { id = "trainer", label = string.upper(name) } + entries[#entries + 1] = { id = "trainer", label = player_label(session) } entries[#entries + 1] = { id = "save", label = "SAVE" } entries[#entries + 1] = { id = "option", label = "OPTION" } entries[#entries + 1] = { id = "exit", label = "EXIT" } @@ -57,6 +112,13 @@ function StartMenu.resetCursor() StartMenu.cursor = 1 end +function StartMenu.saveOffered(session) + for _, entry in ipairs(build_entries(session)) do + if entry.id == "save" then return true end + end + return false +end + function StartMenu.show(opts) opts = opts or {} StartMenu.open = true @@ -66,6 +128,8 @@ function StartMenu.show(opts) StartMenu._game = opts.game StartMenu._onClose = opts.onClose StartMenu.ENTRIES = build_entries(opts.session) + StartMenu._safariStats = not link_state_active() and not in_union_room(opts.session) + and safari_active(opts.session) if ModRuntime.wantsHook("ui.start_menu.items") then local hooked = ModRuntime.call("ui.start_menu.items", function(_, items) return items end, opts.game, StartMenu.ENTRIES) @@ -152,6 +216,15 @@ function StartMenu.confirm() PartyMenu.show(session and session.party, session and session.move_overlay, { session = session, }) + elseif e.id == "retire" then + -- pokefirered/src/start_menu.c:546 StartMenuSafariZoneRetireCallback + local game = StartMenu._game + StartMenu.close(true) + require("src.core.game3.safari").retirePrompt(session, game) + elseif e.id == "trainer_link" then + -- pokefirered/src/start_menu.c:556 StartMenuLinkPlayerCallback + local TrainerCard = require("src.ui.game3.trainer_card") + TrainerCard.show({ session = require("src.core.game3.link").localTrainerCard() }) elseif e.id == "trainer" then local TrainerCard = require("src.ui.game3.trainer_card") TrainerCard.show({ session = session }) @@ -179,6 +252,16 @@ end function StartMenu.draw() if not StartMenu.open then return end + if StartMenu._safariStats then + -- pokefirered/src/start_menu.c:255 DrawSafariZoneStatsWindow + local Safari = require("src.core.game3.safari") + local stats = Window.template(1, 1, 10, 4) + Window.stdFrame(stats) + -- pokefirered/src/strings.c:167 gText_MenuSafariStats + local text = string.format("%3d/%3d\nBALLS %2d", + Safari.steps(StartMenu._session), Safari.STEPS, Safari.balls(StartMenu._session)) + Window.printPx(text, stats.left * 8 + 4, stats.top * 8 + 3) + end local tpl = StartMenu.contentTemplate() Window.stdFrame(tpl) local leftPx = tpl.left * 8 diff --git a/src/ui/game3/summary_chrome.lua b/src/ui/game3/summary_chrome.lua index d8b5bfe0..44884068 100644 --- a/src/ui/game3/summary_chrome.lua +++ b/src/ui/game3/summary_chrome.lua @@ -110,45 +110,6 @@ local function rgba_to_image(rgba, w, h) return image end -local function sanitize_menu_info_imagedata(id) - if not id or not id.getPixel or not id.setPixel then return id end - local ok, w, h = pcall(function() return id:getDimensions() end) - if not ok or not w or not h then return id end - for y = 0, h - 1 do - for x = 0, w - 1 do - local r, g, b, a = id:getPixel(x, y) - if math.abs(r - 123/255) < 0.02 and math.abs(g - 156/255) < 0.02 and math.abs(b - 131/255) < 0.02 then - id:setPixel(x, y, 1, 1, 1, 0) - end - end - end - return id -end - -local function load_png(rel) - local bytes = read_bytes(rel) - if bytes and love and love.image and love.graphics then - local ok, img = pcall(function() - local fd = love.filesystem.newFileData(bytes, "img.png") - local id = sanitize_menu_info_imagedata(love.image.newImageData(fd)) - local image = love.graphics.newImage(id) - if image.setFilter then image:setFilter("nearest", "nearest") end - return image - end) - if ok and img then return img end - end - if love and love.graphics and love.image and love.image.newImageData then - local ok, img = pcall(function() - local id = sanitize_menu_info_imagedata(love.image.newImageData(rel)) - local image = love.graphics.newImage(id) - if image.setFilter then image:setFilter("nearest", "nearest") end - return image - end) - if ok and img then return img end - end - return nil -end - function SummaryChrome.install(cache) if not cache or not cache.read then local okD, Dataset = pcall(require, "src.core.game3.dataset") @@ -233,12 +194,6 @@ function SummaryChrome.menuInfoImage() if SummaryChrome._menuInfo then return SummaryChrome._menuInfo end local raw = read_bytes(summary_root() .. "/menu_info.rgba") local img = raw and rgba_to_image(raw, 128, 128) - if not img then - img = load_png(summary_root() .. "/menu_info.png") - end - if not img then - img = load_png("src/import/gba/chrome/menus/menu_info.png") - end SummaryChrome._menuInfo = img return img end diff --git a/src/ui/game3/summary_menu.lua b/src/ui/game3/summary_menu.lua index b6a580cf..2d65a3f2 100644 --- a/src/ui/game3/summary_menu.lua +++ b/src/ui/game3/summary_menu.lua @@ -85,7 +85,7 @@ local function moves_for_mon(mon) if moveId and (type(moveId) ~= "number" or moveId > 0) and moveId ~= "" and moveId ~= "-------" then mdef = Pokemon.battleMove(moveId) - maxPp = (mdef and mdef.pp) or 5 + maxPp = tonumber(mon.maxPp and mon.maxPp[i]) or (mdef and mdef.pp) or 5 if not pp then pp = maxPp end local name = Pokemon.moveName(moveId) if not name or name == "" or name:match("^MOVE ") then @@ -474,7 +474,7 @@ local function draw_header(mon) -- On PAGE_MOVES (Known Moves) and PAGE_MOVES_INFO (Move Details), the large 64x64 front pic is HIDDEN. -- Instead, the 32x32 party mon icon is displayed below the level/name plate at (24, 34). if isMovesPage then - local icon = Pokemon.icon(species) + local icon = Pokemon.monIcon(mon) if icon and icon.image and love and love.graphics then local iw = icon.w or 32 local ih = icon.h or 32 @@ -489,14 +489,14 @@ local function draw_header(mon) else local pic = c.monPic or { x = 60, y = 65 } local cx, cy = pic.x or 60, pic.y or 65 - local front = Pokemon.frontPic(species) + local front = Pokemon.monFrontPic(mon) if front and front.image and love and love.graphics then local iw = front.w or 64 local ih = front.h or 64 love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(front.image, cx - iw / 2, cy - ih / 2) else - local icon = Pokemon.icon(species) + local icon = Pokemon.monIcon(mon) if icon and icon.image and love and love.graphics then local iw = icon.w or 32 local ih = icon.h or 32 diff --git a/src/ui/game3/tm_case.lua b/src/ui/game3/tm_case.lua index c54fd318..997779f6 100644 --- a/src/ui/game3/tm_case.lua +++ b/src/ui/game3/tm_case.lua @@ -17,6 +17,7 @@ local Pokemon = require("src.core.game3.pokemon") local SummaryData = require("src.core.game3.summary_data") local SummaryChrome = require("src.ui.game3.summary_chrome") local Strings = require("src.core.Strings") +local RomText = require("src.core.game3.rom_text") local TmCase = {} @@ -149,7 +150,7 @@ function TmCase.handleInput(input) local party = (TmCase._session and TmCase._session.party) or {} if #party == 0 then TmCase.mode = "message" - TmCase.messageText = Strings("There is no POKéMON.") + TmCase.messageText = RomText.plain("gText_ThereIsNoPokemon") else local PartyMenu = require("src.ui.game3.party_menu") PartyMenu.show(party, TmCase._session and TmCase._session.moveOverlay, { @@ -387,7 +388,7 @@ function TmCase.draw() if TmCase.mode ~= "action" then local descText if isCancel then - descText = Strings("The TM CASE will be\nput away.") + descText = RomText.plain("gText_TMCaseWillBePutAway") elseif sel then local moveId = Pokemon.moveFromTmItem(sel.id) local moveName = Pokemon.moveName(moveId) or "---" diff --git a/src/ui/game3/trade_scene.lua b/src/ui/game3/trade_scene.lua index d8015ca3..f59521d8 100644 --- a/src/ui/game3/trade_scene.lua +++ b/src/ui/game3/trade_scene.lua @@ -260,21 +260,21 @@ function TradeSceneUi.draw() -- pokefirered/src/trade_scene.c:757 draws every mon by MON_DATA_SPECIES_OR_EGG -- pokefirered/src/trade_scene.c:1541 if s.crossMonVisible then - draw_mon(Pokemon.frontPic(Pokemon.speciesOrEgg(s.offer)), + draw_mon(Pokemon.monFrontPic(s.offer), 60, 192 + (s.monY2a or 0), 1) - draw_mon(Pokemon.frontPic(Pokemon.speciesOrEgg(s.received)), + draw_mon(Pokemon.monFrontPic(s.received), 180, -32 + (s.monY2b or 0), 1) end -- pokefirered/src/trade_scene.c:772 if s.playerVisible then - draw_mon(Pokemon.frontPic(Pokemon.speciesOrEgg(s.offer)), + draw_mon(Pokemon.monFrontPic(s.offer), 120 + (s.monX2 or 0), 60, s.monScale or 1) end -- pokefirered/src/trade_scene.c:1716 if s.partnerVisible then - draw_mon(Pokemon.frontPic(Pokemon.speciesOrEgg(s.received)), + draw_mon(Pokemon.monFrontPic(s.received), 120, 60, 1) end diff --git a/src/ui/game3/ui_pass.lua b/src/ui/game3/ui_pass.lua index 6570be09..fec0bf66 100644 --- a/src/ui/game3/ui_pass.lua +++ b/src/ui/game3/ui_pass.lua @@ -62,6 +62,12 @@ function UiPass.drawUi() tryDraw(CoinsBox) end + -- pokefirered/src/berry_powder.c:113 + local BerryPowderBox = require("src.ui.game3.berry_powder_box") + if BerryPowderBox.isVisible() then + tryDraw(BerryPowderBox) + end + -- pokefirered/src/field_specials.c:1094 if ElevatorWindow.isVisible and ElevatorWindow.isVisible() then tryDraw(ElevatorWindow) diff --git a/src/ui/game3/whiteout_rush.lua b/src/ui/game3/whiteout_rush.lua new file mode 100644 index 00000000..aa3c756a --- /dev/null +++ b/src/ui/game3/whiteout_rush.lua @@ -0,0 +1,104 @@ +-- pokefirered/src/field_screen_effect.c:387 Task_RushInjuredPokemonToCenter + +local Stack = require("src.ui.game3.stack") +local Font = require("src.ui.game3.frlg_font") +local Display = require("src.core.game3.display") +local RomText = require("src.core.game3.rom_text") + +local Rush = {} + +Rush.ID = "whiteout_rush" +-- pokefirered/src/overworld.c:1549 +Rush.HOLD_FRAMES = 120 +Rush._state = nil + +local function stopMusic() + local Audio = require("src.core.game3.audio") + if Audio.currentSong and Audio.currentSong() then Audio.playSong(0) end +end + +function Rush.start(game, session, opts) + opts = opts or {} + local name = (session and (session.name or session.playerName)) or "" + -- pokefirered/src/field_screen_effect.c:414, :421 + local key = opts.home and "gText_PlayerScurriedBackHome" or "gText_PlayerScurriedToCenter" + local text = RomText.plain(key, { playerName = name }) + Rush._state = { + phase = "hold", + frames = 0, + text = text, + total = Font.countChars(text), + shown = 0, + home = opts.home and true or false, + healerLocalId = opts.healerLocalId, + game = game, + } + local Field = package.loaded["src.core.game3.field"] + if Field and Field.lock then Field.lock() end + -- pokefirered/src/overworld.c:1552 + stopMusic() + Stack.push(Rush.ID, Rush, { hideBelow = true }) +end + +function Rush.isActive() + return Rush._state ~= nil +end + +function Rush.phase() + return Rush._state and Rush._state.phase +end + +function Rush.text() + return Rush._state and Rush._state.text +end + +local function finish() + local st = Rush._state + Rush._state = nil + Stack.pop(Rush.ID) + local Fade = require("src.ui.game3.fade") + -- pokefirered/src/field_screen_effect.c:434 + Fade.begin(Fade.MODE.FROM_BLACK, 1, function() + local Space = package.loaded["src.core.game3.scripting.space"] + if not (Space and Space.startScript) then return end + -- pokefirered/src/field_screen_effect.c:441 + local key = st.home and "EventScript_AfterWhiteOutMomHeal" or "EventScript_AfterWhiteOutHeal" + Space.startScript(key, st.healerLocalId) + end) +end + +function Rush.update(_dt) + local st = Rush._state + if not st then return end + stopMusic() + if st.phase == "hold" then + st.frames = st.frames + 1 + if st.frames >= Rush.HOLD_FRAMES then st.phase = "print" end + elseif st.phase == "print" then + st.shown = st.shown + 1 + if st.shown >= st.total then st.phase = "wait" end + end +end + +function Rush.handleInput(input) + local st = Rush._state + if not (st and st.phase == "wait" and input and input.wasPressed) then return end + if input:wasPressed("a") or input:wasPressed("b") then finish() end +end + +function Rush.draw() + local st = Rush._state + local Renderer = package.loaded["src.render.Renderer"] + if Renderer then + Renderer.worldFadeAlpha = 1 + Renderer.worldFadeColor = { 0, 0, 0 } + end + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", 0, 0, Display.W, Display.H) + love.graphics.setColor(1, 1, 1, 1) + if not st or st.phase == "hold" then return end + -- pokefirered/src/field_screen_effect.c:21 sWindowTemplate_WhiteoutText + Font.draw(st.text, 2, 5 * 8 + 8, { colors = Font.COLOR.WHITE, limitChars = st.shown }) +end + +return Rush diff --git a/src/ui/game3/window.lua b/src/ui/game3/window.lua index ed4c018a..c49dc002 100644 --- a/src/ui/game3/window.lua +++ b/src/ui/game3/window.lua @@ -18,46 +18,6 @@ Window.TEXT_OY = 0 -- pixel nudge inside a row (pret printers often +0/+1) Window.CURSOR_WIDTH = 8 Window.OPTION_HEIGHT = 15 -- start menu / Menu_InitCursor pitch in pixels -Window._cursorImg = nil -Window._cursorTried = false - -local CURSOR_PATHS = { - "mods/Kanto-Reforged/sevii/gba/chrome/fonts/menu_cursor_right.png", - "sevii/gba/chrome/fonts/menu_cursor_right.png", -} - -local function load_cursor_image() - if Window._cursorTried then return Window._cursorImg end - Window._cursorTried = true - local okA, Assets = pcall(require, "src.render.Assets") - for _, path in ipairs(CURSOR_PATHS) do - if okA and Assets and Assets.image then - local ok, img = pcall(Assets.image, path) - if ok and img then - if img.setFilter then img:setFilter("nearest", "nearest") end - Window._cursorImg = img - return img - end - end - local ok, img = pcall(love.graphics.newImage, path) - if ok and img then - if img.setFilter then img:setFilter("nearest", "nearest") end - Window._cursorImg = img - return img - end - end - return nil -end - ---- ROM-baked selector pip (latin glyph 0xEF from FireRed font). Returns true if drawn. -local function draw_rom_cursor(px, py) - local img = load_cursor_image() - if not img then return false end - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(img, px, py) - return true -end - --- Build a template table (pret field names + short aliases). function Window.template(left, top, width, height, opts) opts = opts or {} @@ -144,21 +104,17 @@ function Window.cursor(tx, ty, opts) opts = opts or {} local px = tx * T + (opts.ox or 0) local py = ty * T + (opts.oy ~= nil and opts.oy or Window.TEXT_OY) - if not draw_rom_cursor(px, py) then - FrlgFont.drawGlyph(FrlgFont.CHAR_SELECTOR_ARROW, px, py, { - colors = opts.colors or FrlgFont.COLOR.NORMAL, - }) - end + FrlgFont.drawGlyph(FrlgFont.CHAR_SELECTOR_ARROW, px, py, { + colors = opts.colors or FrlgFont.COLOR.NORMAL, + }) end --- Pixel-space cursor (for 15px start-menu rows). function Window.cursorPx(px, py, opts) opts = opts or {} - if not draw_rom_cursor(px, py) then - FrlgFont.drawGlyph(FrlgFont.CHAR_SELECTOR_ARROW, px, py, { - colors = opts.colors or FrlgFont.COLOR.NORMAL, - }) - end + FrlgFont.drawGlyph(FrlgFont.CHAR_SELECTOR_ARROW, px, py, { + colors = opts.colors or FrlgFont.COLOR.NORMAL, + }) end --- Label X after cursor column (pret text at +8px). diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 11538e58..0a977ca9 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3020,6 +3020,9 @@ function OverworldState:billsHousePC() { "wait", 32 }, { "play_sound", "Get_Item1" }, { "wait_sound" }, }, { onDone = function() + -- bills_house_pc.asm:38 + require("src.core.Music").playMap(Game.data, self.map.id, + Game.save.onBike, self.player.surfing) -- bills_house_pc.asm:39 flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true self:billsHouseBillExits() @@ -3057,23 +3060,24 @@ function OverworldState:billsHousePokemonList() end -- BillsHouseBillExitsMachineScript: human Bill appears inside the machine --- at (1,2) and walks out to his spot at (4,4); the map music resumes and --- EVENT_MET_BILL / EVENT_MET_BILL_2 arm the SS-Ticket dialogue. The Eevee +-- at (1,2) and walks out to his spot at (4,4); EVENT_MET_BILL / +-- EVENT_MET_BILL_2 arm the SS-Ticket dialogue. The Eevee -- PC list arms later, on the first Route 25 load after the ticket -- (EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING). function OverworldState:billsHouseBillExits() local Commands = require("src.script.Commands") local ctx = { game = Game, save = Game.save, overworld = self } Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1") - require("src.world.PikachuFollower").onBillExitedMachine(Game, self) local bill local function done() Game.save.flags.EVENT_MET_BILL = true Game.save.flags.EVENT_MET_BILL_2 = true - require("src.core.Music").playMap(Game.data, self.map.id, - Game.save.onBike, self.player.surfing) self:billsHouseSSTicketScene(bill) end + -- scripts/BillsHouse.asm:81 + self.emote = { frames = 8, bubble = false, onDone = function() + require("src.world.PikachuFollower").onBillExitedMachine(Game, self) + end } for _, n in ipairs(self.npcs) do if n.def and n.def.name == "BILLSHOUSE_BILL1" then bill = n break end end diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index bd3d67f3..8149a1e8 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -968,18 +968,28 @@ function playEmotion(game, ow, npc, emotion, opts) return ow.emote end - local function cry() - if e.turnAway then - -- engine/pikachu/pikachu_emotions.asm:203 - npc.facing = OPPOSITE[ow.player.facing] or npc.facing - end + -- audio/pikachu_pcm.asm:15 + local function pcm(after) + if not e.cry then return after() end local Sound = require("src.core.Sound") - if e.cry then - if not Sound.playPikaCry(game.data, e.cry) then - Sound.playCry(game.data, "PIKACHU") - end - end - return pikapic() + local src = Sound.playPikaCry(game.data, e.cry) + local kind = type(src) + if kind ~= "userdata" and kind ~= "table" then return after() end + ow.emote = { + npc = npc, frames = 3 + Sound.waitFrames(src), bubble = false, + onDone = after, + } + return ow.emote + end + + local function cry() + return pcm(pikapic) + end + + if e.turnAway then + -- data/pikachu/pikachu_emotions.asm:203 + -- engine/pikachu/pikachu_emotions.asm:203 + npc.facing = OPPOSITE[ow.player.facing] or npc.facing end -- caches built before the Yellow bubble sheet only carry the three @@ -993,13 +1003,7 @@ function playEmotion(game, ow, npc, emotion, opts) end if e.cryFirst then if not bi then return cry() end - if e.cry then - local Sound = require("src.core.Sound") - if not Sound.playPikaCry(game.data, e.cry) then - Sound.playCry(game.data, "PIKACHU") - end - end - return bubbleHold(pikapic) + return pcm(function() return bubbleHold(pikapic) end) end if bi then return bubbleHold(cry) end return cry() @@ -1235,9 +1239,12 @@ function PikachuFollower.onBillExitedMachine(game, ow) local npc = findFollower(ow) if not npc then return end idleReset(npc) - npc.facing = "left" - -- BillsHouse_CheckPikachuEmotion SCRIPT5 -- scripts/BillsHouse_2.asm:88 - billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE", 27) + -- scripts/BillsHouse.asm:170 + ow.emote = { npc = npc, frames = 12, bubble = false, onDone = function() + npc.facing = "left" + -- BillsHouse_CheckPikachuEmotion SCRIPT5 -- scripts/BillsHouse_2.asm:88 + billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE", 27) + end } end -- OaksLabPikachuMovementScript (pokeyellow scripts/OaksLab_2.asm): the diff --git a/tests/drivers/game3_battle_league_friendship.lua b/tests/drivers/game3_battle_league_friendship.lua index f62b79c8..7e002335 100644 --- a/tests/drivers/game3_battle_league_friendship.lua +++ b/tests/drivers/game3_battle_league_friendship.lua @@ -54,6 +54,7 @@ return function(game) local session = Runtime.getSession() if not result(session ~= nil, "new game reached the game3 field") then return finish() end session.party = {} + require("src.core.game3.scripting.flags").setFlag(require("src.core.game3.scripting.space").store, nil, 0x828, true) -- data/maps/PalletTown_ProfessorOaksLab/scripts.inc:1120 Party.giveMon(session, 1, 14) Party.giveMon(session, 4, 14) Bag.add(session.bag, ITEM_MASTER_BALL, 2) diff --git a/tests/drivers/game3_berry_powder_man_2416.lua b/tests/drivers/game3_berry_powder_man_2416.lua index aada1a4e..315863cf 100644 --- a/tests/drivers/game3_berry_powder_man_2416.lua +++ b/tests/drivers/game3_berry_powder_man_2416.lua @@ -86,5 +86,109 @@ return function(game) result(Bag.has(session.bag, 372, 1), "POWDER JAR landed in the bag") result(Flags.getFlag(Space.store, nil, "FLAG_GOT_POWDER_JAR") == true, "FLAG_GOT_POWDER_JAR set") + local BerryPowderBox = require("src.ui.game3.berry_powder_box") + local Records = require("src.ui.game3.minigame_records") + local Choice = require("src.ui.game3.choice") + local ListMenu = require("src.core.game3.scripting.natives_listmenu").Menu + + local function idle() + return not Message.isOpen() and not vmBusy() and not Field.locked + and not Choice.active and not ListMenu.isOpen() and not Records.isOpen() + end + + session.berryPowder = 1234 + U.tap(game, "a") + for _ = 1, 400 do + if ListMenu.isOpen() then break end + if Message.isOpen() and Message.isTyping() then + Message.skipReveal() + elseif Message.isOpen() and Message.isWaiting() and not Choice.active + and not page():find("EXCHANGE IT", 1, true) then + U.tap(game, "a") + end + U.wait(3) + end + if not result(ListMenu.isOpen(), "exchange list opened") then return finish() end + result(BerryPowderBox.isVisible(), "POWDER box is up with the exchange list") + result(BerryPowderBox.amount() == 1234, "POWDER box reads 1234 (got " .. tostring(BerryPowderBox.amount()) .. ")") + U.wait(10) + result(U.shot(game, DIR .. "/powd_box_with_exchange_list.png"), "screenshot powd_box_with_exchange_list") + + U.tap(game, "a") + for _ = 1, 400 do + if Choice.active then break end + if Message.isOpen() and Message.isTyping() then Message.skipReveal() end + U.wait(3) + end + if not result(Choice.active, "YES/NO for ENERGYPOWDER") then return finish() end + result(BerryPowderBox.isVisible(), "POWDER box stays up through YES/NO") + U.tap(game, "a") + local traded = false + for _ = 1, 600 do + local p = page() + if p:find("TRADE MORE", 1, true) then + if Choice.active then traded = true break end + if Message.isOpen() and Message.isTyping() then Message.skipReveal() end + elseif Message.isOpen() and Message.isTyping() then + Message.skipReveal() + elseif Message.isOpen() and Message.isWaiting() and not Choice.active then + U.tap(game, "a") + end + U.wait(3) + end + if not result(traded, "man asks to trade more") then return finish() end + result(Bag.has(session.bag, 30, 1), "ENERGYPOWDER landed in the bag") + result(session.berryPowder == 1184, "berryPowder 1234 - 50 = 1184") + result(BerryPowderBox.amount() == 1184, "POWDER box reprinted 1184 (got " .. tostring(BerryPowderBox.amount()) .. ")") + U.wait(10) + result(U.shot(game, DIR .. "/powd_box_reprint_after_exchange.png"), "screenshot powd_box_reprint_after_exchange") + + U.tap(game, "b") + for _ = 1, 400 do + if idle() then break end + if Message.isOpen() and Message.isTyping() then Message.skipReveal() end + if Message.isOpen() and Message.isWaiting() then U.tap(game, "a") end + U.wait(3) + end + result(idle(), "vendor script finished") + result(not BerryPowderBox.isVisible(), "POWDER box removed after goodbye") + + local function readSign(label, mapName, x, y, kind, expect) + local id = (okC and MapCatalog.pretToEngine and MapCatalog.pretToEngine(mapName)) or mapName + Map.load(nil, game, id, { x = x, y = y, facing = "up" }) + game.session.x, game.session.y, game.session.facing = x, y, "up" + U.wait(30) + U.tap(game, "a") + for _ = 1, 120 do + if Records.isOpen() then break end + U.wait(1) + end + if not result(Records.isOpen() and Records.kind() == kind, label .. " window opened") then return end + local text = {} + for _, l in ipairs(Records.lines()) do text[#text + 1] = l.text end + text = table.concat(text, "|") + for _, want in ipairs(expect) do + result(text:find(want, 1, true) ~= nil, label .. " shows '" .. want .. "'") + end + U.wait(10) + result(vmBusy(), label .. " holds the script at waitstate") + result(U.shot(game, DIR .. "/" .. label .. ".png"), "screenshot " .. label) + U.tap(game, "a") + for _ = 1, 120 do + if idle() then break end + U.wait(1) + end + result(not Records.isOpen() and idle(), label .. " closed on A and released the player") + end + + readSign("powd_berry_crush_rankings", "CeruleanCity_House5", 3, 2, "berry_crush", + { "BERRY CRUSH", "Pressing-Speed Rankings", "2 PLAYERS", "5 PLAYERS", "0.00 Times/sec." }) + -- data/maps/TwoIsland_JoyfulGameCorner/scripts.inc:29 + Flags.setVar(Space.store, Space.vm and Space.vm.ctx, 0x4079, 3) + readSign("powd_pokemon_jump_records", "TwoIsland_JoyfulGameCorner", 1, 2, "pokemon_jump", + { "JUMP RECORDS", "Jumps in a row:", "Best score:", "EXCELLENTS in a row:", "|0" }) + readSign("powd_dodrio_berry_picking_records", "TwoIsland_JoyfulGameCorner", 0, 2, "dodrio", + { "DODRIO BERRY-PICKING RECORDS", "BERRIES picked:", "five players:", "|0" }) + return finish() end diff --git a/tests/drivers/game3_bw_flute_rom_text.lua b/tests/drivers/game3_bw_flute_rom_text.lua new file mode 100644 index 00000000..1de16706 --- /dev/null +++ b/tests/drivers/game3_bw_flute_rom_text.lua @@ -0,0 +1,201 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_bw_flute_rom_text" + +-- pokefirered/src/item_use.c:582 +local ITEM_BLACK_FLUTE = 42 +local ITEM_WHITE_FLUTE = 43 +-- pokefirered/include/constants/flags.h:1330 +local FLAG_SYS_WHITE_FLUTE_ACTIVE = 0x803 +local FLAG_SYS_BLACK_FLUTE_ACTIVE = 0x804 +local PALLET = "FR_PALLET_TOWN" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS game3_bw_flute_rom_text") + love.event.quit(0) + else + print("FAIL game3_bw_flute_rom_text failures=" .. failures) + love.event.quit(1) + end +end + +local function run(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local Player = require("src.core.game3.player") + local Bag = require("src.core.game3.bag") + local Party = require("src.core.game3.party") + local ItemsData = require("src.core.game3.items_data") + local StartMenu = require("src.ui.game3.start_menu") + local BagMenu = require("src.ui.game3.bag_menu") + local Message = require("src.ui.game3.message") + local StepEvents = require("src.core.game3.step_events") + local RomText = require("src.core.game3.rom_text") + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the field") then return end + + session.party = {} + Party.giveMon(session, 7, 15) + session.bag = session.bag or {} + Bag.add(session.bag, ITEM_WHITE_FLUTE, 1) + Bag.add(session.bag, ITEM_BLACK_FLUTE, 1) + + Map.load(nil, game, PALLET, { x = 7, y = 10, facing = "down" }) + U.wait(90) + + result(ItemsData.fieldUseKind(ITEM_WHITE_FLUTE) == "black_white_flute", + "WHITE FLUTE carries FieldUseFunc_BlackWhiteFlute from the ROM item table") + + U.tap(game, "start") + U.wait(30) + U.shot(game, DIR .. "/refix_start_menu_rom_glyph_cursor.png") + + local function selectBag() + for _ = 1, 12 do + local e = StartMenu.ENTRIES and StartMenu.ENTRIES[StartMenu.cursor] + if e and e.id == "bag" then break end + U.tap(game, "down") + U.wait(8) + end + U.tap(game, "a") + U.wait(60) + end + + local function onItem(itemId) + for _ = 1, 5 do + if BagMenu.currentPocket() == "ITEMS" then break end + U.tap(game, "left") + U.wait(15) + end + for _ = 1, 24 do + local want + for i, r in ipairs(BagMenu.list() or {}) do + if ItemsData.toNumericId(r.id) == itemId then want = i end + end + if not want or BagMenu.cursor == want then break end + U.tap(game, (BagMenu.cursor > want) and "up" or "down") + U.wait(8) + end + local row = BagMenu.isOpen() and BagMenu.list()[BagMenu.cursor] + return row and ItemsData.toNumericId(row.id) == itemId + end + + local Audio = require("src.core.game3.audio") + local seFrame + local frameNo = 0 + local realPlaySe = Audio.playSe + Audio.playSe = function(id, ...) + if id == 110 then seFrame = frameNo end + return realPlaySe(id, ...) + end + + local function useSelected(label) + U.tap(game, "a") + U.wait(25) + for _ = 1, 6 do + if BagMenu.ACTIONS[BagMenu.actionCursor] == "USE" then break end + U.tap(game, "down") + U.wait(8) + end + seFrame = nil + frameNo = 0 + U.tap(game, "a") + local waitSeen = BagMenu.mode == "flute_wait" + local msgAt + for i = 1, 60 do + frameNo = i + if BagMenu.mode == "flute_wait" then waitSeen = true end + if not msgAt and BagMenu.mode == "message" then msgAt = i end + U.wait(1) + end + result(waitSeen and msgAt ~= nil and msgAt >= 7 and msgAt <= 9 and seFrame ~= nil and math.abs(seFrame - msgAt) <= 1, + label .. " waits 8 frames before SE_GLASS_FLUTE + message (msg at +" + .. tostring(msgAt) .. ", se at +" .. tostring(seFrame) .. ")") + end + + local function firstPage(text) + return (tostring(text):match("^([^\f]*)")) or "" + end + + local function dismiss() + for _ = 1, 60 do + if BagMenu.mode ~= "message" then break end + U.tap(game, "a") + U.wait(8) + end + end + + selectBag() + if not result(onItem(ITEM_WHITE_FLUTE), "bag cursor on the WHITE FLUTE") then return end + useSelected("WHITE FLUTE") + local ctx = { playerName = tostring(session.name or session.playerName or ""), + stringVars = { [2] = ItemsData.displayName(ITEM_WHITE_FLUTE) } } + local want = firstPage(RomText.box("gText_UsedVar2WildLured", ctx)) + result(BagMenu.mode == "message" and BagMenu.messageText == want, + "WHITE FLUTE prints ROM gText_UsedVar2WildLured page 1 (" .. tostring(BagMenu.messageText) .. ")") + result(Flags.getFlag(Space.store, nil, FLAG_SYS_WHITE_FLUTE_ACTIVE) == true + and not Flags.getFlag(Space.store, nil, FLAG_SYS_BLACK_FLUTE_ACTIVE), + "FLAG_SYS_WHITE_FLUTE_ACTIVE set, BLACK clear") + U.shot(game, DIR .. "/refix_white_flute_lured_text.png") + dismiss() + result(BagMenu.isOpen() and BagMenu.mode == "list", "message paged back to the bag list") + + if not result(onItem(ITEM_BLACK_FLUTE), "bag cursor on the BLACK FLUTE") then return end + useSelected("BLACK FLUTE") + ctx.stringVars[2] = ItemsData.displayName(ITEM_BLACK_FLUTE) + want = firstPage(RomText.box("gText_UsedVar2WildRepelled", ctx)) + result(BagMenu.mode == "message" and BagMenu.messageText == want, + "BLACK FLUTE prints ROM gText_UsedVar2WildRepelled page 1 (" .. tostring(BagMenu.messageText) .. ")") + result(Flags.getFlag(Space.store, nil, FLAG_SYS_BLACK_FLUTE_ACTIVE) == true + and not Flags.getFlag(Space.store, nil, FLAG_SYS_WHITE_FLUTE_ACTIVE), + "FLAG_SYS_BLACK_FLUTE_ACTIVE set, WHITE clear") + dismiss() + + U.tap(game, "b") + for _ = 1, 180 do + if not BagMenu.isOpen() then break end + U.wait(1) + end + U.wait(30) + if StartMenu.isOpen and StartMenu.isOpen() then + U.tap(game, "b") + U.wait(30) + end + result(not BagMenu.isOpen(), "bag closed back to the field") + + session.repelSteps = 1 + session.vars[0x4021] = 1 + StepEvents.onRepelStep(session, game) + local page + for _ = 1, 240 do + U.wait(1) + page = Message.currentPage and Message.currentPage() or nil + if page and page ~= "" and Message.isWaiting and Message.isWaiting() then break end + end + result(page == RomText.box("Text_RepelWoreOff"), + "repel expiry prints ROM Text_RepelWoreOff (" .. tostring(page) .. ")") + U.shot(game, DIR .. "/refix_repel_wore_off_text.png") +end + +return function(game) + local ok, err = pcall(run, game) + if not ok then result(false, "driver error: " .. tostring(err)) end + finish() +end diff --git a/tests/drivers/game3_collision_surf_channel.lua b/tests/drivers/game3_collision_surf_channel.lua index db653109..6427af69 100644 --- a/tests/drivers/game3_collision_surf_channel.lua +++ b/tests/drivers/game3_collision_surf_channel.lua @@ -82,6 +82,11 @@ return function(game) end print("[driver] 1. the Seafoam B3F current channel") + local Flags = require("src.core.game3.scripting.flags") + -- data/maps/Route20/scripts.inc:17 + Flags.setFlag(Space.store, nil, 0x46, true) + -- data/maps/SeafoamIslands_B2F/map.json:41 + Flags.setFlag(Space.store, nil, 0x47, false) goTo(B3F, 23, 8, "right") print(string.format("[driver] on %s at (%d,%d) surfing=%s", tostring(Space.mapId), Player.cellX, Player.cellY, tostring(Player.surfing))) diff --git a/tests/drivers/game3_encounters_cave.lua b/tests/drivers/game3_encounters_cave.lua index b0bfb69b..6b8804bb 100644 --- a/tests/drivers/game3_encounters_cave.lua +++ b/tests/drivers/game3_encounters_cave.lua @@ -97,7 +97,18 @@ return function(game) di = (di % 4) + 1 else local bx, by = Player.cellX, Player.cellY - U.hold(game, DIRS[di], 20) + local btn = DIRS[di] + for _ = 1, 30 do + table.insert(game.input.pressQueue, btn) + game.input.state[btn] = true + coroutine.yield() + if Player.moving or (Battle.isActive and Battle.isActive()) then break end + end + game.input.state[btn] = false + for _ = 1, 60 do + if not Player.moving then break end + coroutine.yield() + end U.wait(4) if Battle.isActive and Battle.isActive() then return true, steps end if Map.current ~= mapId then return false, steps end @@ -250,11 +261,11 @@ return function(game) placeAt(MT_MOON, cx, cy, "right") session.party = {} Party.giveMon(session, 6, topLevel) - session.repelSteps = 250 + session.repelSteps = 500 Rng.SeedRng(0x1234) Rng.SeedWildEncounterRng(0x1234) Encounters.resetRateModifiers() - local equalHit, eSteps = patrol(MT_MOON, 1, 150) + local equalHit, eSteps = patrol(MT_MOON, 1, 400) U.log(string.format("repel patrol (lead L%d) ended at (%d,%d) after %d steps", topLevel, Player.cellX, Player.cellY, eSteps)) result(equalHit, diff --git a/tests/drivers/game3_encounters_dark_cave.lua b/tests/drivers/game3_encounters_dark_cave.lua index f069823d..fa4050fa 100644 --- a/tests/drivers/game3_encounters_dark_cave.lua +++ b/tests/drivers/game3_encounters_dark_cave.lua @@ -8,6 +8,8 @@ local ROUTE_10 = "FR_ROUTE_10" local FLAG_BADGE01_GET = 0x820 -- pokefirered/include/constants/flags.h:1333 local FLAG_SYS_FLASH_ACTIVE = 0x806 +-- pokefirered/include/constants/flags.h:1374 +local FLAG_SYS_POKEMON_GET = 0x828 -- pokefirered/include/constants/moves.h:152 local MOVE_FLASH = 148 local ENTRY_X, ENTRY_Y = 17, 3 @@ -61,6 +63,7 @@ return function(game) lead.pp = { 20 } lead.maxPp = { 20 } Flags.setFlag(Space.store, ctx(), FLAG_BADGE01_GET, true) + Flags.setFlag(Space.store, ctx(), FLAG_SYS_POKEMON_GET, true) local function placeAt(mapId, x, y, facing) Map.load(nil, game, mapId, { x = x, y = y, facing = facing or "down" }) @@ -95,6 +98,7 @@ return function(game) for i, e in ipairs(StartMenu.ENTRIES or {}) do if e.id == "pokemon" then pokeIdx = i end end + if not result(pokeIdx ~= nil, "start menu lists POKEMON") then return finish() end for _ = 1, 20 do if StartMenu.cursor == pokeIdx then break end U.tap(game, "down") @@ -121,7 +125,10 @@ return function(game) U.wait(6) end U.tap(game, "a") - U.wait(40) + for _ = 1, 60 do + if flashActive() then break end + U.wait(2) + end local txt = Message.currentPage and Message.currentPage() if type(txt) == "string" and txt ~= "" then diff --git a/tests/drivers/game3_field_poison_whiteout.lua b/tests/drivers/game3_field_poison_whiteout.lua new file mode 100644 index 00000000..2342b1b0 --- /dev/null +++ b/tests/drivers/game3_field_poison_whiteout.lua @@ -0,0 +1,103 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_field_poison_whiteout" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS game3_field_poison_whiteout") + love.event.quit(0) + else + print("FAIL game3_field_poison_whiteout failures=" .. failures) + love.event.quit(1) + end +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Field = require("src.core.game3.field") + local Party = require("src.core.game3.party") + local BattleBridge = require("src.core.game3.battle_bridge") + local Rush = require("src.ui.game3.whiteout_rush") + local Message = package.loaded["src.ui.game3.message"] or require("src.ui.game3.message") + local MapCatalog = require("src.import.gba.map_catalog") + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the game3 field") then return finish() end + session.party = {} + Party.giveMon(session, 1, 12) + Party.giveMon(session, 4, 10) + for _, mon in ipairs(session.party) do + mon.hp = 1 + mon.status = "PSN" + end + session.money = 3000 + Field.setRespawn(2) + local loss = math.min(BattleBridge.calcMoneyLossFrlg(session, game.save), 3000) + session.vars[0x4040] = 3 + + local startX, startY = Player.cellX, Player.cellY + for _, dir in ipairs({ "left", "right", "down", "up" }) do + U.hold(game, dir, 20) + U.wait(10) + if Player.cellX ~= startX or Player.cellY ~= startY then break end + end + result(Player.cellX ~= startX or Player.cellY ~= startY, "took a poisoned step") + + local function page() + return Message.currentPage and Message.currentPage() or "" + end + local faints, lostShown = {}, false + local want = string.format("panicked and lost ¥%d…", loss) + for _ = 1, 1200 do + local p = page() + if Message.isOpen() and p:find("fainted…", 1, true) then + local name = p:match("^(.-) fainted") + if name and faints[#faints] ~= name then faints[#faints + 1] = name end + end + if Message.isOpen() and p:find(want, 1, true) then lostShown = true break end + if Message.isOpen() and Message.isTyping() then Message.skipReveal() end + if Message.isOpen() and Message.isWaiting() then U.tap(game, "a") end + U.wait(2) + end + result(#faints == 2, "both fainted… messages shown before the whiteout (" .. table.concat(faints, ",") .. ")") + if result(lostShown, "field whiteout text: '" .. want .. "'") then + Message.skipReveal() + U.wait(4) + result(U.shot(game, DIR .. "/spec_field_poison_lost_money.png"), "screenshot spec_field_poison_lost_money") + end + + for _ = 1, 900 do + if Rush.isActive() then break end + if Message.isOpen() and Message.isTyping() then Message.skipReveal() end + if Message.isOpen() and Message.isWaiting() then U.tap(game, "a") end + U.wait(2) + end + result(Rush.isActive(), "music faded, screen went black and CB2_WhiteOut ran") + local center = MapCatalog.pretToEngine("ViridianCity_PokemonCenter_1F") + result(Map.current == center, "respawned in " .. tostring(center)) + result(session.money == 3000 - loss, "money after whiteout " .. tostring(session.money)) + local healed = true + for _, mon in ipairs(session.party) do + if (mon.hp or 0) <= 0 or mon.status then healed = false end + for i, pp in ipairs(mon.pp or {}) do + if mon.maxPp and mon.maxPp[i] and pp ~= mon.maxPp[i] then healed = false end + end + end + result(healed, "party fully healed (HP, status, PP)") + return finish() +end diff --git a/tests/drivers/game3_field_victory_road.lua b/tests/drivers/game3_field_victory_road.lua index 632cca28..a554fa3e 100644 --- a/tests/drivers/game3_field_victory_road.lua +++ b/tests/drivers/game3_field_victory_road.lua @@ -106,6 +106,12 @@ return function(game) for _ = 1, 30 do U.hold(game, dir, 1) if Player.moving then break end + if Player.boulderPush then + for _ = 1, 40 do + if not Player.boulderPush then break end + U.hold(game, dir, 1) + end + end end for _ = 1, 48 do if not Player.moving then break end diff --git a/tests/drivers/game3_gym_leader_intro_music.lua b/tests/drivers/game3_gym_leader_intro_music.lua new file mode 100644 index 00000000..3d4dce9b --- /dev/null +++ b/tests/drivers/game3_gym_leader_intro_music.lua @@ -0,0 +1,77 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_gym_leader_intro_music" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS game3_gym_leader_intro_music") + love.event.quit(0) + else + print("FAIL game3_gym_leader_intro_music failures=" .. failures) + love.event.quit(1) + end +end + +local ENCOUNTER = { [283] = true, [284] = true, [285] = true } + +return function(game) + for _ = 1, 600 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(180) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Party = require("src.core.game3.party") + local Audio = require("src.core.game3.audio") + local Battle = require("src.core.game3.battle") + local Objects = require("src.core.game3.objects") + local Message = package.loaded["src.ui.game3.message"] or require("src.ui.game3.message") + local MapCatalog = require("src.import.gba.map_catalog") + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the field") then return finish() end + session.party = {} + Party.giveMon(session, 6, 100) + + local gym = MapCatalog.pretToEngine("PewterCity_Gym") + Map.load(nil, game, gym, { x = 6, y = 6, facing = "up" }) + game.session.x, game.session.y, game.session.facing = 6, 6, "up" + U.wait(60) + result(Map.current == gym, "loaded " .. tostring(gym)) + if not result(Objects.find(1) ~= nil, "Brock is in the gym") then return finish() end + local before = Audio.currentSong() and Audio.currentSong().id + result(before ~= nil and not ENCOUNTER[before], "gym BGM playing before the talk (" .. tostring(before) .. ")") + + U.tap(game, "a") + local intro = false + for _ = 1, 400 do + if Message.isOpen() then intro = true break end + U.wait(2) + end + if result(intro, "Brock's intro speech opened") then + U.wait(30) + local during = Audio.currentSong() and Audio.currentSong().id + result(during == before, "NO_MUSIC mode keeps the gym BGM through the intro (" .. tostring(during) .. ")") + Message.skipReveal() + U.wait(4) + result(U.shot(game, DIR .. "/spec_brock_intro_gym_bgm.png"), "screenshot spec_brock_intro_gym_bgm") + end + local started = false + for _ = 1, 900 do + if Battle.isActive() then started = true break end + if Message.isOpen() and Message.isTyping() then Message.skipReveal() end + if Message.isOpen() and Message.isWaiting() then U.tap(game, "a") end + U.wait(2) + end + result(started, "Brock's battle still starts") + return finish() +end diff --git a/tests/drivers/game3_hidden_coins.lua b/tests/drivers/game3_hidden_coins.lua new file mode 100644 index 00000000..3ccde213 --- /dev/null +++ b/tests/drivers/game3_hidden_coins.lua @@ -0,0 +1,140 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_hidden_coins" + +local CORNER = "FR_CELADON_CITY_GAME_CORNER" +-- pokefirered/include/constants/flags.h:604 +local FLAG_GOT_COIN_CASE = 0x243 + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS hidden_coins") + love.event.quit(0) + else + print("FAIL hidden_coins failures=" .. failures) + love.event.quit(1) + end +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local Field = require("src.core.game3.field") + local Collision = require("src.core.game3.collision") + local Bag = require("src.core.game3.bag") + local Message = require("src.ui.game3.message") + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the game3 field") then return finish() end + Flags.setVar(Space.store, nil, 0x4070, 1) + + local function goTo(x, y, facing) + Map.load(nil, game, CORNER, { x = x, y = y, facing = facing }) + Player.cellX, Player.cellY, Player.targetX, Player.targetY = x, y, x, y + Player.px, Player.py, Player.facing = x * 16, y * 16, facing + session.x, session.y, session.facing = x, y, facing + U.wait(120) + end + + local function noneSlot() + for _, slots in pairs(session.bag.pockets or {}) do + for _, slot in ipairs(slots) do + if tonumber(slot.id) == 0 then return true end + end + end + return false + end + + local function closeMessage() + for _ = 1, 40 do + if not Message.isOpen() then return end + U.tap(game, "a") + U.wait(10) + end + end + + local function waitPage(pattern) + for _ = 1, 400 do + if Message.isOpen() and Message.isWaiting() + and tostring(Message.currentPage()):find(pattern, 1, true) then + return true + end + U.wait(1) + end + return false + end + + local SX, SY = 17, 5 + Map.load(nil, game, CORNER, { x = SX, y = SY + 1, facing = "up" }) + U.wait(60) + local spot = Field.hiddenItemAt(game, SX, SY, 0) + if not result(spot ~= nil and (tonumber(spot.item) or -1) == 0 and spot.quantity == 40, + "the Game Corner hides 40 coins at (17,5)") then return finish() end + + local stand, facing + for _, c in ipairs({ { 0, 1, "up" }, { -1, 0, "right" }, { 1, 0, "left" }, { 0, -1, "down" } }) do + local x, y = SX + c[1], SY + c[2] + if Collision.canEnter(game, x, y, { fromX = SX, fromY = SY }) then + stand, facing = { x, y }, c[3] + break + end + end + if not result(stand ~= nil, "found a floor cell facing the coins") then return finish() end + + Flags.setFlag(Space.store, nil, FLAG_GOT_COIN_CASE, false) + session.coins = 0 + goTo(stand[1], stand[2], facing) + U.tap(game, "a") + result(waitPage("found\n40 COINS!"), "no case: RED found 40 COINS!") + U.shot(game, DIR .. "/COIN_no_case_found.png") + U.tap(game, "a") + result(waitPage("There's nothing to put them in"), "no case: nothing to put them in") + U.shot(game, DIR .. "/COIN_no_case_refused.png") + closeMessage() + result((session.coins or 0) == 0, "no case: coins unchanged") + result(not Flags.getFlag(Space.store, nil, spot.flag), "no case: the coins stay on the floor") + result(not noneSlot(), "no case: no ???????? slot in the bag") + + Flags.setFlag(Space.store, nil, FLAG_GOT_COIN_CASE, true) + session.coins = 9980 + U.tap(game, "a") + result(waitPage("found\n40 COINS!"), "full case: RED found 40 COINS!") + U.tap(game, "a") + result(waitPage("The COIN CASE is full"), "full case: the COIN CASE is full") + U.shot(game, DIR .. "/COIN_case_full.png") + closeMessage() + result(session.coins == 9980, "full case: coins unchanged") + result(not Flags.getFlag(Space.store, nil, spot.flag), "full case: the coins stay on the floor") + + session.coins = 100 + U.tap(game, "a") + result(waitPage("found\n40 COINS!"), "with case: RED found 40 COINS!") + U.shot(game, DIR .. "/COIN_with_case_found.png") + result(waitPage("put the COINS away in\nthe COIN CASE."), "with case: put the COINS away") + U.shot(game, DIR .. "/COIN_with_case_put_away.png") + closeMessage() + result(session.coins == 140, "with case: 40 coins added, coins=" .. tostring(session.coins)) + result(Flags.getFlag(Space.store, nil, spot.flag) == true, "with case: the hidden flag is set") + result(not noneSlot(), "with case: no ???????? slot in the bag") + U.tap(game, "a") + U.wait(30) + result(not Message.isOpen(), "the spot is empty afterwards") + + finish() +end diff --git a/tests/drivers/game3_hof_credits.lua b/tests/drivers/game3_hof_credits.lua new file mode 100644 index 00000000..87911aa1 --- /dev/null +++ b/tests/drivers/game3_hof_credits.lua @@ -0,0 +1,153 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_hof_credits" + +local HOF = "FR_POKEMON_LEAGUE_HALL_OF_FAME" +-- pokefirered/src/hall_of_fame.c:704 +local PLATEAU = "FR_INDIGO_PLATEAU_EXTERIOR" +-- pokefirered/include/constants/vars.h:185 +local VAR_MAP_SCENE_INDIGO_PLATEAU_EXTERIOR = 0x4085 + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS game3_hof_credits") + love.event.quit(0) + else + print("FAIL game3_hof_credits failures=" .. failures) + love.event.quit(1) + end +end + +local function run(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local Party = require("src.core.game3.party") + local Message = require("src.ui.game3.message") + local HallOfFame = require("src.ui.game3.hall_of_fame") + local Credits = require("src.ui.game3.credits") + local Player = require("src.core.game3.player") + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the field") then return end + session.party = {} + Party.giveMon(session, 6, 62) + Party.giveMon(session, 25, 58) + Party.giveMon(session, 131, 57) + Party.giveMon(session, 143, 59) + + local function pumpUntil(frames, pred) + for _ = 1, frames do + if pred() then return true end + U.wait(1) + end + return pred() + end + + local function mashMessages(frames, pred) + for _ = 1, frames do + if pred() then return true end + if Message.isOpen() and Message.isTyping() then Message.skipReveal() end + if Message.isOpen() and Message.isWaiting() then U.tap(game, "a") end + U.wait(2) + end + return pred() + end + + -- pokefirered/data/maps/PokemonLeague_HallOfFame/scripts.inc:18 + Map.load(nil, game, HOF, { x = 5, y = 12, facing = "up" }) + game.session.x, game.session.y, game.session.facing = 5, 12, "up" + local opened = mashMessages(3000, function() return HallOfFame.isOpen() end) + if not result(opened, "Hall of Fame room script called EnterHallOfFame") then return end + + local first = pumpUntil(900, function() return HallOfFame.phase() == "hold" end) + result(first, "first mon slid in and its info printed") + local Audio = require("src.core.game3.audio") + result(Audio.currentSong() and Audio.currentSong().id == 286, "MUS_HALL_OF_FAME is playing") + U.shot(game, DIR .. "/cred_hof_first_mon.png") + result(Flags.getFlag(Space.store, nil, 0x82C) or session.flags[0x82C] == true, "the induction saved the clear flag") + + local card = pumpUntil(6000, function() return HallOfFame.phase() == "exitwait" end) + result(card, "induction reached the player card") + U.shot(game, DIR .. "/cred_hof_player_card.png") + U.tap(game, "a") + local closed = pumpUntil(300, function() return not HallOfFame.isOpen() end) + result(closed, "A faded the Hall of Fame out") + U.wait(5) + result(Map.current == PLATEAU, "SetWarpsToRollCredits warped to " .. tostring(Map.current)) + result(Player.cellX == 11 and Player.cellY == 6, + string.format("player at 11,6 (got %s,%s)", tostring(Player.cellX), tostring(Player.cellY))) + result(Flags.getVar(Space.store, nil, VAR_MAP_SCENE_INDIGO_PLATEAU_EXTERIOR) == 1, + "VAR_MAP_SCENE_INDIGO_PLATEAU_EXTERIOR = 1") + + local rolled = pumpUntil(6000, function() return Credits.isOpen() end) + if not result(rolled, "the plateau walk ended in special DoCredits") then return end + result(Audio.currentSong() and Audio.currentSong().id == 290, "MUS_CREDITS kept playing into the credits") + + local S = Credits.SCENE + local function st() return Credits.state() or {} end + + local staff = pumpUntil(1200, function() local s = st() return s.text and s.text.staff end) + result(staff, "title staff card printed over Indigo Plateau") + U.wait(20) + U.shot(game, DIR .. "/cred_title_staff.png") + + local page = pumpUntil(3000, function() + local s = st() + return s.mainseq == S.EXEC_CMD and s.text and s.text.names and s.textLevel == 0 + end) + result(page, "first staff page faded in") + U.shot(game, DIR .. "/cred_text_page.png") + + local runner = pumpUntil(6000, function() + local s = st() + return s.task and s.task.sheet and s.textLevel == 0 and s.text and s.text.names and s.scrcmdidx > 6 + end) + result(runner, "map scroll scene with the running player") + U.shot(game, DIR .. "/cred_map_scene_runner.png") + + local mon = pumpUntil(6000, function() + local s = st() + return s.mainseq == S.MON_SHOW and s.mon and s.mon.showBall + end) + result(mon, "Charizard pokeball scene") + U.wait(30) + U.shot(game, DIR .. "/cred_mon_scene.png") + + local theEnd = pumpUntil(20000, function() + local s = st() + return s.closing and s.whichMon == 1 and s.mainseq == S.EXEC_CMD + end) + result(theEnd, "THE END screen") + U.wait(20) + U.shot(game, DIR .. "/cred_the_end.png") + + local back = pumpUntil(1200, function() return game.phase == "boot" and game.boot ~= nil end) + result(back, "WAITBUTTON timed out and the game soft reset to the title") + result(not Credits.isOpen(), "credits closed") + U.wait(600) + U.shot(game, DIR .. "/cred_back_at_title.png") +end + +return function(game) + local ok, err = pcall(run, game) + if not ok then + print("FAIL game3_hof_credits driver error: " .. tostring(err)) + failures = failures + 1 + end + finish() +end diff --git a/tests/drivers/game3_importer_round2.lua b/tests/drivers/game3_importer_round2.lua new file mode 100644 index 00000000..7d52e92c --- /dev/null +++ b/tests/drivers/game3_importer_round2.lua @@ -0,0 +1,195 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_importer_round2" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS game3_importer_round2") + love.event.quit(0) + else + print("FAIL game3_importer_round2 failures=" .. failures) + love.event.quit(1) + end +end + +local function is_hidden(ev) + return ev.type == "hidden_item" or ev.kind == 7 +end + +-- pokefirered/src/itemfinder.c:354 +local function find_edge_item(game) + local maps = game.data and game.data.maps or {} + local Map = require("src.core.game3.map") + local names = {} + for id in pairs(maps) do names[#names + 1] = id end + table.sort(names) + for _, id in ipairs(names) do + local def = maps[id] + for dir, conn in pairs(def.connections or {}) do + local nid = type(conn) == "table" and conn.map or conn + local ndef = maps[nid] + if ndef and (dir == "north" or dir == "up") then + for _, ev in ipairs(ndef.bgEvents or {}) do + if is_hidden(ev) and not ev.underfoot then + local L = Map.ensureMidLayout(game, nid, ndef) + local off = tonumber(type(conn) == "table" and conn.offset) or 0 + local fromBottom = L and (L.height - 1 - ev.y) + if fromBottom and fromBottom <= 2 then + return id, nid, ev, ev.x + off, fromBottom + end + end + end + end + end + end + return nil +end + +local function run(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Field = require("src.core.game3.field") + local Message = require("src.ui.game3.message") + local Itemfinder = require("src.core.game3.itemfinder") + local Audio = require("src.core.game3.audio") + local Pokemon = require("src.core.game3.pokemon") + local Party = require("src.core.game3.party") + local HallOfFame = require("src.ui.game3.hall_of_fame") + local RomText = require("src.core.game3.rom_text") + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the field") then return end + + local ses = {} + local playSe = Audio.playSe + Audio.playSe = function(id, ...) ses[#ses + 1] = id return playSe(id, ...) end + + result(RomText.plain("gText_PlayerScurriedToCenter", { playerName = "RED" }) + :find("RED scurried to a POKéMON CENTER,", 1, true) ~= nil, "whiteout text comes from the ROM cache") + result(Runtime and require("src.core.game3.scripting.space").ensureBundle().scripts.EventScript_AfterWhiteOutHeal ~= nil, + "EventScript_AfterWhiteOutHeal is a ROM script") + + local mapId, nid, ev, itemX, fromBottom = find_edge_item(game) + if result(mapId ~= nil, "found a hidden item just across a north map connection") then + local px, py = itemX, 1 + Map.load(nil, game, mapId, { x = px, y = py, facing = "up" }) + Player.cellX, Player.cellY, Player.targetX, Player.targetY = px, py, px, py + Player.px, Player.py, Player.facing = px * 16, py * 16, "up" + session.x, session.y, session.map = px, py, mapId + U.wait(60) + local ok, _, _, info = Field.useItemfinder(session, true) + result(ok and info and info.y < 0, string.format("itemfinder answers for %s item in %s (dy=%s)", + tostring(ev.item), tostring(nid), tostring(info and info.y))) + U.wait(6) + result(#Itemfinder.sprites() > 0, "itemfinder arrow sprite is up") + result(ses[#ses] == 65, "SE_ITEMFINDER played") + U.shot(game, DIR .. "/r2_itemfinder_arrow_connected_map.png") + local shown = false + for _ = 1, 300 do + if Message.isOpen() and tostring(Message.currentPage()):find("ITEMFINDER's responding", 1, true) then + shown = true + break + end + U.wait(1) + end + for _ = 1, 300 do + if not shown or Message.isWaiting() then break end + U.wait(1) + end + result(shown and Message.isWaiting(), "gText_ItemfinderResponding printed after the dings") + if shown and Message.isWaiting() then U.shot(game, DIR .. "/r2_itemfinder_responding_text.png") end + for _ = 1, 600 do + if not Message.isOpen() then break end + if Message.isWaiting() then U.tap(game, "a") else U.wait(1) end + end + U.wait(2) + result(not Message.isOpen() and not Field.locked and not Itemfinder.isActive(), + "field unlocked after the itemfinder message") + end + + do + local Objects = require("src.core.game3.objects") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local FieldMoves = require("src.core.game3.field_moves") + local FieldEffects = require("src.core.game3.field_effects") + Map.load(nil, game, "FR_SEAFOAM_ISLANDS_B3F", { x = 6, y = 16, facing = "down" }) + session.x, session.y, session.facing = 6, 16, "down" + Player.moving = false + Player.cellX, Player.cellY, Player.targetX, Player.targetY = 6, 16, 6, 16 + Player.px, Player.py, Player.facing = 6 * 16, 16 * 16, "down" + U.wait(60) + Flags.setFlag(Space.store, Space.vm and Space.vm.ctx, FieldMoves.SYS_FLAGS.USE_STRENGTH, true) + for _ = 1, 20 do + U.hold(game, "down", 1) + if Player.boulderPush then break end + end + result(Player.boulderPush ~= nil, "boulder push started") + U.wait(3) + local dust + for _, a in ipairs(FieldEffects._anims or {}) do + if a.kind == "dust" then dust = a end + end + result(dust ~= nil and FieldEffects.loadSheet("ground_impact_dust", 16, 8, 3) ~= nil, + "boulder push dust uses the ROM ground_impact_dust sheet") + U.shot(game, DIR .. "/r2_boulder_push_dust.png") + for _ = 1, 60 do + if not Player.boulderPush then break end + U.wait(1) + end + end + + session.party = {} + local tid = (tonumber(session.trainerId) or 0) % 65536 + local sid = (tonumber(session.secretId) or 0) % 65536 + Party.giveMon(session, 6, 62) + Party.giveMon(session, 201, 40) + local shinyMon, unownD = session.party[1], session.party[2] + shinyMon.personality = bit.bxor(tid, sid) * 65536 + shinyMon.isShiny = nil + unownD.personality = 0xFC000003 + unownD.isShiny = nil + result(Pokemon.isShiny(shinyMon), "the Charizard is shiny for this trainer") + result(not Pokemon.isShiny(unownD), "the Unown is not shiny") + result(Pokemon.monPicSpecies(unownD) == 415, "Unown personality 0xFC000003 picks SPECIES_UNOWN_D") + local shinyPic = Pokemon.monFrontPic(shinyMon) + local plainPic = Pokemon.frontPic(6) + result(shinyPic and plainPic and shinyPic.image ~= plainPic.image, "shiny Charizard uses the shiny front pic") + result(Pokemon.monIcon(unownD) ~= Pokemon.icon(201), "Unown D uses its own menu icon") + + HallOfFame.start({ session = session, dontSave = true, warp = false }) + local applause = false + for _ = 1, 4000 do + if HallOfFame.phase() == "applause" and #(HallOfFame._confetti or {}) >= 20 then + applause = true + break + end + U.wait(1) + end + result(applause, "Hall of Fame applause spawned confetti sprites") + U.shot(game, DIR .. "/r2_hof_confetti_shiny_unown.png") + HallOfFame.close() + U.wait(10) + + Audio.playSe = playSe +end + +return function(game) + local ok, err = pcall(run, game) + if not ok then result(false, "driver error: " .. tostring(err)) end + finish() +end diff --git a/tests/drivers/game3_league_lighting_diploma.lua b/tests/drivers/game3_league_lighting_diploma.lua new file mode 100644 index 00000000..51174fbd --- /dev/null +++ b/tests/drivers/game3_league_lighting_diploma.lua @@ -0,0 +1,222 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_league_lighting_diploma" + +local LORELEI = "FR_POKEMON_LEAGUE_LORELEIS_ROOM" +local CHAMPION = "FR_POKEMON_LEAGUE_CHAMPIONS_ROOM" +local PC_1F = "FR_INDIGO_PLATEAU_POKEMON_CENTER_1F" +local CONDO_3F = "FR_CELADON_CITY_CONDOMINIUMS_3F" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS game3_league_lighting_diploma") + love.event.quit(0) + else + print("FAIL game3_league_lighting_diploma failures=" .. failures) + love.event.quit(1) + end +end + +local function run(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local NativeTileset = require("src.core.game3.tileset_native") + local Lighting = require("src.core.game3.league_lighting") + local Message = require("src.ui.game3.message") + local Dex = require("src.core.game3.dex") + local Pokemon = require("src.core.game3.pokemon") + local Diploma = require("src.ui.game3.diploma") + local Audio = require("src.core.game3.audio") + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the field") then return end + + local function flag(id) return Flags.getFlag(Space.store, nil, id) end + local function setFlag(id, on) Flags.setFlag(Space.store, nil, id, on) end + + local function goTo(mapId, x, y, facing) + Map.load(nil, game, mapId, { x = x, y = y, facing = facing or "up" }) + game.session.x, game.session.y, game.session.facing = x, y, facing or "up" + end + + local function pumpUntil(frames, pred) + for _ = 1, frames do + if pred() then return true end + U.wait(1) + end + return pred() + end + + local function slotPixel() + local def = Map.currentDef() + local ts = def and NativeTileset.get(def.pair) + local pix = ts and ts.slotPix[7] + if not (pix and #pix.under > 0) then return nil end + local parts = {} + for i = 1, #pix.under do + local p = pix.under[i] + if p[3] >= 8 then + local r, g, b = ts.imageData:getPixel(p[1], p[2]) + parts[#parts + 1] = string.format("%.2f%.2f%.2f", r, g, b) + end + end + return table.concat(parts), ts + end + + -- pokefirered/data/scripts/pokemon_league.inc:10 + Flags.setVar(Space.store, nil, Flags.IDS.VAR_MAP_SCENE_POKEMON_LEAGUE, 0) + goTo(LORELEI, 6, 12, "up") + result(Map.current == LORELEI, "loaded Lorelei's room") + result(Lighting.task ~= nil and Lighting.task.phase == "run", + "ON_RESUME started DoPokemonLeagueLightingEffect") + local walked = pumpUntil(600, function() return flag(0x2) end) + result(walked, "EnterRoom walked the player in and set FLAG_TEMP_2") + pumpUntil(600, function() return Lighting.task and Lighting.task.index == 1 end) + local idxA = Lighting.task and Lighting.task.index + local pixA = slotPixel() + result(idxA == 1, "E4 cycle advanced to palette 1 after the 40-frame hold") + U.shot(game, DIR .. "/leag_lorelei_lighting_frame1.png") + pumpUntil(200, function() return Lighting.task and Lighting.task.index == 4 end) + local idxB = Lighting.task and Lighting.task.index + local pixB = slotPixel() + result(idxB == 4, "E4 cycle reached palette 4 on the 12-frame cadence") + result(pixA ~= nil and pixA ~= pixB, "floor light pixels changed between frames 1 and 4") + U.shot(game, DIR .. "/leag_lorelei_lighting_frame4.png") + + setFlag(0x5, true) + local frozen = Lighting.task.index + U.wait(60) + result(Lighting.task.index == frozen, "FLAG_TEMP_5 (talk/battle) freezes the cycle") + setFlag(0x5, false) + + local StartMenu = require("src.ui.game3.start_menu") + local BagMenu = require("src.ui.game3.bag_menu") + U.tap(game, "start") + pumpUntil(60, function() return StartMenu.isOpen() end) + local bagIdx + for i, e in ipairs(StartMenu.ENTRIES or {}) do + if e.id == "bag" then bagIdx = i end + end + for _ = 1, 10 do + if StartMenu.cursor == bagIdx then break end + U.tap(game, "down") + U.wait(4) + end + result(Lighting.task ~= nil, "the lighting task keeps running while only the start menu is up") + U.tap(game, "a") + local bagOpen = pumpUntil(120, function() return BagMenu.isOpen and BagMenu.isOpen() end) + result(bagOpen, "BAG opened from the start menu") + U.wait(60) + result(Lighting.task == nil, "leaving the field for the BAG stops the lighting task") + for _ = 1, 8 do + if not (BagMenu.isOpen and BagMenu.isOpen()) and not StartMenu.isOpen() then break end + U.tap(game, "b") + U.wait(30) + end + pumpUntil(120, function() return Lighting.task ~= nil end) + result(Lighting.task and Lighting.task.phase == "run" and Lighting.task.index == 0, + "back on the field ON_RESUME restarts the cycle from palette 0 (index=" + .. tostring(Lighting.task and Lighting.task.index) .. ")") + + -- pokefirered/data/maps/PokemonLeague_LoreleisRoom/scripts.inc:59 + setFlag(0x3, true) + Space.returnToField() + result(Lighting.task and Lighting.task.phase == "cancel", + "return from battle with FLAG_TEMP_3 starts Task_CancelPokemonLeagueLightingEffect") + U.wait(10) + -- pokefirered/data/scripts/pokemon_league.inc:7 + setFlag(0x4, true) + pumpUntil(10, function() return Lighting.task and Lighting.task.phase == "held" end) + result(Lighting.task and Lighting.task.phase == "held", "door open (FLAG_TEMP_4) loads the final palette") + local pixFinal = slotPixel() + result(pixFinal ~= nil and pixFinal ~= pixB, "final palette differs from the cycle frame") + U.wait(30) + result(slotPixel() == pixFinal, "the room holds the final palette") + U.shot(game, DIR .. "/leag_lorelei_final_palette.png") + + local _, tsL = slotPixel() + goTo(PC_1F, 11, 8, "up") + U.wait(30) + result(Lighting.task == nil, "leaving the room ends the lighting task") + result(tsL and not (tsL.patchedSlots and tsL.patchedSlots[7]), "slot 7 restored on the shared atlas") + + local function diploma(label, national) + session.dex = Dex.new() + for nat = 1, national and 386 or 151 do + local sp = Pokemon.speciesFromNational(nat) + Dex.setSeen(session.dex, sp) + Dex.setCaught(session.dex, sp) + end + goTo(CONDO_3F, 3, 9, "up") + U.wait(60) + U.tap(game, "a") + local opened = pumpUntil(900, function() + if Diploma.isOpen() then return true end + if Message.isOpen() and Message.isTyping() then Message.skipReveal() end + if Message.isOpen() and Message.isWaiting() then U.tap(game, "a") end + return Diploma.isOpen() + end) + if not result(opened, label .. ": designer opened the diploma") then return end + result(Diploma.isNational() == national, label .. ": " .. (national and "NATIONAL" or "KANTO") .. " half chosen") + pumpUntil(60, function() return Diploma.phase() == "fanfare" end) + result(Diploma.phase() == "fanfare", label .. ": fade in then badge fanfare") + U.tap(game, "a") + U.wait(2) + result(Diploma.phase() == "fanfare", label .. ": A ignored until the fanfare ends") + pumpUntil(600, function() return Diploma.phase() == "wait" end) + result(Diploma.phase() == "wait" and Audio.isFanfareFinished(), label .. ": fanfare finished") + U.shot(game, DIR .. "/leag_diploma_" .. (national and "national" or "kanto") .. ".png") + U.tap(game, "b") + U.wait(5) + result(Diploma.isOpen(), label .. ": B does not close the diploma") + U.tap(game, "a") + local closed = pumpUntil(120, function() return not Diploma.isOpen() end) + result(closed, label .. ": A fades out and closes") + local released = pumpUntil(300, function() return not (Space.vm and Space.vm:isRunning()) end) + result(released, label .. ": script resumed past waitstate and released") + end + diploma("kanto", false) + diploma("national", true) + + Flags.setVar(Space.store, nil, Flags.IDS.VAR_MAP_SCENE_POKEMON_LEAGUE, 0) + goTo(CHAMPION, 6, 19, "up") + pumpUntil(600, function() return flag(0x2) end) + local champSteps = Lighting.task and Lighting.task.steps + result(champSteps == 8, "Champion room runs the 8-palette cycle") + local t0 + for i = 1, 200 do + if Lighting.task and Lighting.task.index == 1 then t0 = i break end + U.wait(1) + end + local t1 + for i = 1, 30 do + U.wait(1) + if Lighting.task and Lighting.task.index == 2 then t1 = i break end + end + result(t0 ~= nil and t1 == 8, "Champion cycle steps every 8 frames (got " .. tostring(t1) .. ")") + U.shot(game, DIR .. "/leag_champion_lighting.png") +end + +return function(game) + local ok, err = pcall(run, game) + if not ok then + print("FAIL game3_league_lighting_diploma driver error: " .. tostring(err)) + failures = failures + 1 + end + finish() +end diff --git a/tests/drivers/game3_league_lighting_soft_reset.lua b/tests/drivers/game3_league_lighting_soft_reset.lua new file mode 100644 index 00000000..aaa97077 --- /dev/null +++ b/tests/drivers/game3_league_lighting_soft_reset.lua @@ -0,0 +1,102 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_league_lighting_soft_reset" + +local LORELEI = "FR_POKEMON_LEAGUE_LORELEIS_ROOM" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS game3_league_lighting_soft_reset") + love.event.quit(0) + else + print("FAIL game3_league_lighting_soft_reset failures=" .. failures) + love.event.quit(1) + end +end + +local function waitBoot(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then return true end + U.wait(1) + end + return false +end + +local function run(game) + waitBoot(game) + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local NativeTileset = require("src.core.game3.tileset_native") + local Lighting = require("src.core.game3.league_lighting") + + if not result(Runtime.getSession() ~= nil, "new game reached the field") then return end + + local function pumpUntil(frames, pred) + for _ = 1, frames do + if pred() then return true end + U.wait(1) + end + return pred() + end + + local function slotPixels(ts) + local pix = ts and ts.slotPix[7] + if not (pix and #pix.under > 0) then return nil end + local parts = {} + for i = 1, #pix.under do + local p = pix.under[i] + local r, g, b = ts.imageData:getPixel(p[1], p[2]) + parts[#parts + 1] = string.format("%.3f%.3f%.3f", r, g, b) + end + return table.concat(parts) + end + + -- pokefirered/data/scripts/pokemon_league.inc:10 + Flags.setVar(Space.store, nil, Flags.IDS.VAR_MAP_SCENE_POKEMON_LEAGUE, 0) + Map.load(nil, game, LORELEI, { x = 6, y = 12, facing = "up" }) + game.session.x, game.session.y, game.session.facing = 6, 12, "up" + if not result(Map.current == LORELEI and Lighting.task ~= nil, "Lorelei's room started the lighting task") then return end + local pair = Map.currentDef().pair + local ts = NativeTileset.get(pair) + local base = slotPixels(ts) + pumpUntil(600, function() return Flags.getFlag(Space.store, nil, 0x2) end) + pumpUntil(600, function() return Lighting.task and Lighting.task.index >= 3 end) + local lit = slotPixels(ts) + result(Lighting.task and Lighting.task.index >= 3 and base ~= nil and lit ~= base, + "lighting cycled to palette " .. tostring(Lighting.task and Lighting.task.index)) + U.shot(game, DIR .. "/leag_soft_reset_before_lorelei_lit.png") + + game.softResetRequested = true + U.wait(2) + result(waitBoot(game), "soft reset returned to the title") + result(Lighting.task == nil, "soft reset stopped the lighting task") + result(not (ts.patchedSlots and ts.patchedSlots[7]), "soft reset cleared the slot 7 patch") + result(slotPixels(ts) == base, "shared league atlas slot 7 back to the tileset palette") + + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + result(Runtime.getSession() ~= nil, "new game after soft reset reached the field") + U.wait(120) + result(Lighting.task == nil, "lighting inactive in the new game") + local ts2 = NativeTileset._pairs[pair] + result(ts2 == nil or (not (ts2.patchedSlots and ts2.patchedSlots[7])), + "league tileset palette normal in the new game") + U.shot(game, DIR .. "/leag_soft_reset_new_game_field.png") +end + +return function(game) + local ok, err = pcall(run, game) + if not ok then result(false, "driver error: " .. tostring(err)) end + finish() +end diff --git a/tests/drivers/game3_league_rerun.lua b/tests/drivers/game3_league_rerun.lua new file mode 100644 index 00000000..57610357 --- /dev/null +++ b/tests/drivers/game3_league_rerun.lua @@ -0,0 +1,76 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_league_rerun" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS game3_league_rerun") + love.event.quit(0) + else + print("FAIL game3_league_rerun failures=" .. failures) + love.event.quit(1) + end +end + +return function(game) + for _ = 1, 600 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(180) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local Party = require("src.core.game3.party") + local Battle = require("src.core.game3.battle") + local Objects = require("src.core.game3.objects") + local Message = package.loaded["src.ui.game3.message"] or require("src.ui.game3.message") + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the field") then return finish() end + session.party = {} + Party.giveMon(session, 6, 100) + + local okC, MapCatalog = pcall(require, "src.import.gba.map_catalog") + local mapId = (okC and MapCatalog.pretToEngine and MapCatalog.pretToEngine("PokemonLeague_LoreleisRoom")) + or "FR_POKEMON_LEAGUE_LORELEIS_ROOM" + + -- data/scripts/hall_of_fame.inc:24 leaves the E4 trainer flags set after a clear + Flags.setFlag(Space.store, nil, Flags.trainerFlagId(410), true) + Flags.setFlag(Space.store, nil, Flags.IDS.FLAG_DEFEATED_LORELEI, false) + Flags.setVar(Space.store, nil, Flags.IDS.VAR_MAP_SCENE_POKEMON_LEAGUE, 1) + + Map.load(nil, game, mapId, { x = 6, y = 6, facing = "up" }) + game.session.x, game.session.y, game.session.facing = 6, 6, "up" + U.wait(40) + result(Map.current == mapId, "loaded " .. tostring(mapId)) + result(Flags.getFlag(Space.store, nil, Flags.trainerFlagId(410)) == true, + "TRAINER_ELITE_FOUR_LORELEI flag still set from the first clear") + if not result(Objects.find(1) ~= nil, "Lorelei is in her room") then return finish() end + + U.tap(game, "a") + local started = false + for _ = 1, 900 do + if Battle.isActive() then started = true break end + if Message.isOpen() and Message.isTyping() then Message.skipReveal() end + if Message.isOpen() and Message.isWaiting() then U.tap(game, "a") end + U.wait(2) + end + result(started, "second League run: Lorelei's battle starts") + result(Flags.getFlag(Space.store, nil, Flags.IDS.FLAG_DEFEATED_LORELEI) ~= true, + "the door script did not run (FLAG_DEFEATED_LORELEI still clear)") + if started then + U.wait(90) + result(U.shot(game, DIR .. "/spec_lorelei_rerun_battle.png"), "screenshot spec_lorelei_rerun_battle") + end + return finish() +end diff --git a/tests/drivers/game3_link_counter.lua b/tests/drivers/game3_link_counter.lua index 382e720b..b8383a9a 100644 --- a/tests/drivers/game3_link_counter.lua +++ b/tests/drivers/game3_link_counter.lua @@ -211,11 +211,12 @@ return function(game) hosting = hosting + 20 end print("[driver] connect screen: stage=" .. tostring(LinkMenu.stage) - .. " link=" .. tostring(Link.link ~= nil)) + .. " transport=" .. tostring(LinkMenu._transport ~= nil) .. " link=" .. tostring(Link.link ~= nil)) U.shot(game, DIR .. "/link_counter_07_hosting.png") -- pokefirered/src/link.c:386 OpenLink result(LinkMenu.stage == "hosting", "HOST A GAME opens the port and waits for the other GBA") - result(Link.link ~= nil, "and a real game3 link session is live, not a test loopback") + result(LinkMenu._transport ~= nil and Link.link == nil, + "and a real host transport is open, waiting for the other GBA to pair") local closing = 0 while closing < 180 and LinkMenu.isOpen() do U.tap(game, "b") @@ -225,5 +226,53 @@ return function(game) result(not LinkMenu.isOpen(), "B backs out of the connect screen") end + -- data/scripts/cable_club.inc:306 + local settle = 0 + while settle < 600 and busy() do + if Message.isOpen and Message.isOpen() then U.tap(game, "a") end + U.wait(10) + settle = settle + 10 + end + goTo(CENTER_2F, 9, 2, "up") + local hideKey, hideIdx + for key, rows in pairs(Space.vm and Space.vm.scripts or {}) do + for i, row in ipairs(rows) do + local nxt = rows[i + 1] + local warpsAfter = false + for j = i + 1, i + 8 do + if rows[j] and rows[j].op == "warp" then warpsAfter = true end + end + if row.op == "hideobjectat" and tonumber(row.localId or row[1]) == 0xFF + and nxt and nxt.op == "closedoor" and warpsAfter then + hideKey, hideIdx = key, i + break + end + end + if hideKey then break end + end + if result(hideKey ~= nil, "found the cable club hideobjectat LOCALID_PLAYER / closedoor rows") then + local vctx = Space.vm.ctx + vctx.mode = "bytecode" + vctx.status = "running" + vctx.stack = {} + Space.vm:setPc(hideKey, hideIdx) + Space.vm:resume() + local hiddenAtDoor = mapId() == CENTER_2F and not Player.isVisible() + result(hiddenAtDoor, "hideobjectat LOCALID_PLAYER hid the player before the door closed") + U.wait(6) + if mapId() == CENTER_2F then + U.shot(game, DIR .. "/link_counter_08_player_hidden_door_close.png") + end + local after = 0 + while after < 900 and busy() do + if Message.isOpen and Message.isOpen() then U.tap(game, "a") end + U.wait(10) + after = after + 10 + end + U.wait(60) + print("[driver] after the hidden door close: map=" .. tostring(mapId())) + result(Player.isVisible(), "the player is visible again once the door-close script is done") + end + finish() end diff --git a/tests/drivers/game3_neighbor_var_gfx.lua b/tests/drivers/game3_neighbor_var_gfx.lua new file mode 100644 index 00000000..5c5adbe2 --- /dev/null +++ b/tests/drivers/game3_neighbor_var_gfx.lua @@ -0,0 +1,68 @@ +local U = require("tests.drivers.util") + +local failed = false +local function check(label, cond, detail) + print((cond and "PASS " or "FAIL ") .. label .. (detail and (" " .. detail) or "")) + if not cond then failed = true end +end + +local function shotPath(name) + local dir = os.getenv("POKEPORT_SHOT_DIR") + if not dir or dir == "" then return nil end + return dir .. "/" .. name +end + +local function ghost(mapId, lid) + local Ghosts = require("src.core.game3.ghosts") + local pool = Ghosts._pools[mapId] + return pool and pool.byId[lid] +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + local Map = require("src.core.game3.map") + local ok, err = pcall(function() + love.window.setMode(700, 1300, { resizable = true }) + U.wait(10) + Map.load(nil, game, "FR_ROUTE_2", { x = 9, y = 79, facing = "down" }) + U.wait(60) + local eo = ghost("FR_VIRIDIAN_CITY", 4) + check("viridian_old_man_ghost", eo ~= nil) + if eo then + check("viridian_old_man_lying_gfx", eo.graphicsId == 34, "gid=" .. tostring(eo.graphicsId)) + check("viridian_old_man_moved", eo.cellX == 21 and eo.cellY == 11, + tostring(eo.cellX) .. "," .. tostring(eo.cellY)) + check("viridian_old_man_drawn", not eo.invisible) + end + local p = shotPath("gfxv_route2_viridian_old_man.png") + if p then U.shot(game, p) end + U.wait(10) + + love.window.setMode(1700, 800, { resizable = true }) + U.wait(10) + Map.load(nil, game, "FR_ROUTE_18", { x = 60, y = 0, facing = "right" }) + U.wait(60) + local st + local pool = require("src.core.game3.ghosts")._pools["FR_FUCHSIA_CITY"] + for _, lid in ipairs(pool and pool.order or {}) do + local o = pool.byId[lid] + local raw = tonumber(o.def and (o.def.graphicsId or o.def.graphics)) + if raw and raw >= 240 then st = o end + end + check("fuchsia_statue_ghost", st ~= nil) + if st then + check("fuchsia_statue_kabuto_gfx", st.graphicsId == 147, "gid=" .. tostring(st.graphicsId)) + check("fuchsia_statue_drawn", not st.invisible) + end + p = shotPath("gfxv_route18_fuchsia_kabuto.png") + if p then U.shot(game, p) end + U.wait(10) + end) + if not ok then check("driver_error", false, tostring(err)) end + love.event.quit(failed and 1 or 0) +end diff --git a/tests/drivers/game3_npc_movement_types.lua b/tests/drivers/game3_npc_movement_types.lua new file mode 100644 index 00000000..8a5147b2 --- /dev/null +++ b/tests/drivers/game3_npc_movement_types.lua @@ -0,0 +1,242 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_npc_movement_types" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS game3_npc_movement_types") + love.event.quit(0) + else + print("FAIL game3_npc_movement_types failures=" .. failures) + love.event.quit(1) + end +end + +local MEDIUM = { [32] = true, [64] = true, [96] = true, [128] = true } +local SHORT = { [32] = true, [48] = true, [64] = true, [80] = true } + +local function keys(t) + local out = {} + for k in pairs(t) do out[#out + 1] = tostring(k) end + table.sort(out) + return table.concat(out, ",") +end + +return function(game) + for _ = 1, 600 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(180) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Objects = require("src.core.game3.objects") + local Player = require("src.core.game3.player") + local Field = require("src.core.game3.field") + local Collision = require("src.core.game3.collision") + local TrainerSight = require("src.core.game3.trainer_sight") + + if not result(Runtime.getSession() ~= nil, "new game reached the field") then return finish() end + + local function go(mapId, x, y, facing) + Map.load(nil, game, mapId, { x = x, y = y, facing = facing or "down" }) + game.session.x, game.session.y, game.session.facing = x, y, facing or "down" + U.wait(30) + for _ = 1, 600 do + if not Field.locked then break end + U.wait(1) + end + end + + local function track(lids, frames, onFrame) + local s = {} + for _, lid in ipairs(lids) do + local eo = Objects.find(lid) + s[lid] = { + eo = eo, cells = {}, facings = {}, moving = 0, frames = 0, + minX = eo.cellX, maxX = eo.cellX, minY = eo.cellY, maxY = eo.cellY, + delays = {}, badDelay = nil, prevTimer = eo.idleTimer, stepFrames = {}, + homeVisits = 0, wasHome = true, + } + end + for f = 1, frames do + U.wait(1) + for _, lid in ipairs(lids) do + local r = s[lid] + local eo = r.eo + r.frames = r.frames + 1 + if eo.moving then + r.moving = r.moving + 1 + r.stepFrames[eo.stepFrames] = true + end + if eo.px % 1 ~= 0 or eo.py % 1 ~= 0 then r.fractional = true end + r.cells[eo.cellX .. "," .. eo.cellY] = true + r.minX, r.maxX = math.min(r.minX, eo.cellX), math.max(r.maxX, eo.cellX) + r.minY, r.maxY = math.min(r.minY, eo.cellY), math.max(r.maxY, eo.cellY) + r.facings[eo.facing] = true + local t = eo.idleTimer + if t and r.prevTimer and t > r.prevTimer then r.delays[#r.delays + 1] = t end + if t and r.prevTimer == nil then r.delays[#r.delays + 1] = t end + r.prevTimer = t + local home = eo.cellX == eo.homeX and eo.cellY == eo.homeY + if home and not r.wasHome then r.homeVisits = r.homeVisits + 1 end + r.wasHome = home + end + if onFrame and onFrame(f) then break end + end + return s + end + + local function delaysIn(r, set) + if #r.delays == 0 then return false end + for _, d in ipairs(r.delays) do + if not set[d] then return false end + end + return true + end + + local function delayList(r) + local out = {} + for i = 1, math.min(#r.delays, 8) do out[i] = tostring(r.delays[i]) end + return table.concat(out, ",") + end + + go("FR_ROUTE_17", 12, 70, "down") + local bikers = { 1, 2, 4 } + for _, lid in ipairs(bikers) do + local eo = Objects.find(lid) + if not result(eo and eo.movement == "BACK_FORTH" and eo.movementType == 0x1A, + "route17 biker " .. lid .. " is WALK_DOWN_AND_UP") then return finish() end + end + local seq = Objects.find(3) + if not result(seq and seq.movement == "SEQUENCE" and seq.movementType == 0x34, + "route17 biker 3 is WALK_SEQUENCE_RIGHT_DOWN_LEFT_UP") then return finish() end + local seqHomeX, seqHomeY = seq.homeX, seq.homeY + local s17 = track({ 1, 2, 3, 4 }, 720) + for _, lid in ipairs(bikers) do + local r = s17[lid] + local eo = r.eo + print(string.format("[driver] biker %d home=(%d,%d) x=%d..%d y=%d..%d moving=%d/%d facings=%s homeVisits=%d", + lid, eo.homeX, eo.homeY, r.minX, r.maxX, r.minY, r.maxY, r.moving, r.frames, keys(r.facings), r.homeVisits)) + result(r.minX == eo.homeX and r.maxX == eo.homeX, "biker " .. lid .. " never leaves its column") + result(r.minY == eo.homeY and r.maxY == eo.homeY + eo.rangeY, + "biker " .. lid .. " shuttles home..home+rangeY (" .. r.minY .. ".." .. r.maxY .. ")") + result(r.homeVisits >= 2, "biker " .. lid .. " came back home repeatedly (" .. r.homeVisits .. ")") + result(r.moving >= r.frames * 0.95, "biker " .. lid .. " walks nonstop (" .. r.moving .. "/" .. r.frames .. ")") + result(keys(r.facings) == "down,up", "biker " .. lid .. " only faces down/up (" .. keys(r.facings) .. ")") + result(not r.fractional, "biker " .. lid .. " moves in whole pixels") + end + do + local r = s17[3] + print(string.format("[driver] seq biker home=(%d,%d) x=%d..%d y=%d..%d moving=%d/%d facings=%s homeVisits=%d", + seqHomeX, seqHomeY, r.minX, r.maxX, r.minY, r.maxY, r.moving, r.frames, keys(r.facings), r.homeVisits)) + result(r.minX == seqHomeX and r.maxX == seqHomeX + 4 and r.minY == seqHomeY and r.maxY == seqHomeY + 2, + "walk sequence biker traces its right/down/left/up box") + result(r.cells[(seqHomeX + 4) .. "," .. (seqHomeY + 2)] and r.cells[seqHomeX .. "," .. (seqHomeY + 2)] + and r.cells[(seqHomeX + 4) .. "," .. seqHomeY] and true or false, "walk sequence biker reached all three far corners") + result(r.homeVisits >= 2, "walk sequence biker loops back home (" .. r.homeVisits .. ")") + result(r.moving >= r.frames * 0.95, "walk sequence biker walks nonstop (" .. r.moving .. "/" .. r.frames .. ")") + result(keys(r.facings) == "down,left,right,up", "walk sequence biker faces all four legs") + end + Player.reset(8, 21, "left") + U.wait(20) + U.shot(game, DIR .. "/mov_route17_bikers_pacing.png") + + -- src/trainer_see.c:151 + do + local eo = Objects.find(2) + local engagedWhileMoving, engaged = false, false + local realEngage = TrainerSight.engage + TrainerSight.engage = function(_, who) + engaged = true + if who == eo and who.moving then engagedWhileMoving = true end + end + local bottom = eo.homeY + eo.rangeY + Player.reset(eo.homeX, bottom + 1, "up") + for _ = 1, 400 do + U.wait(1) + if engaged then break end + end + TrainerSight.engage = realEngage + result(engaged and engagedWhileMoving, + "walking biker spots the player from its destination mid-step") + end + + go("FR_ROUTE_11", 40, 10, "down") + do + local eo = Objects.find(1) + if not result(eo and eo.movement == "LOOK" and eo.movementType == 0x11, + "route11 trainer 1 is FACE_DOWN_AND_LEFT") then return finish() end + local r = track({ 1 }, 1500)[1] + print("[driver] face_down_and_left facings=" .. keys(r.facings) .. " delays=" .. delayList(r)) + result(keys(r.facings) == "down,left", "FACE_DOWN_AND_LEFT only looks down and left") + result(delaysIn(r, SHORT), "FACE_DOWN_AND_LEFT waits sMovementDelaysShort frames") + result(r.moving == 0 and r.minX == r.maxX and r.minY == r.maxY, "FACE_DOWN_AND_LEFT never walks") + end + + go("FR_ROUTE_8", 30, 10, "down") + local looker = Objects.find(3) + if not result(looker and looker.movement == "LOOK" and looker.movementType == 0x01, + "route8 trainer 3 is LOOK_AROUND") then return finish() end + do + local r = track({ 3 }, 2400)[3] + print("[driver] look_around facings=" .. keys(r.facings) .. " delays=" .. delayList(r)) + result(keys(r.facings) == "down,left,right,up", "LOOK_AROUND looks in all four directions") + result(delaysIn(r, MEDIUM), "LOOK_AROUND waits sMovementDelaysMedium frames") + end + + go("FR_FUCHSIA_CITY", 20, 30, "down") + do + local eo = Objects.find(2) + if not result(eo and eo.movement == "WALK" and eo.movementType == 0x50, + "fuchsia slowpoke is WANDER_AROUND_SLOWER") then return finish() end + local r = track({ 2 }, 3000)[2] + print(string.format("[driver] slowpoke home=(%d,%d) x=%d..%d y=%d..%d moving=%d stepFrames=%s delays=%s", + eo.homeX, eo.homeY, r.minX, r.maxX, r.minY, r.maxY, r.moving, keys(r.stepFrames), delayList(r))) + result(r.moving > 0 and keys(r.stepFrames) == "32", "slowpoke steps take 32 frames") + result(not r.fractional, "slowpoke sprite moves in whole pixels") + result(r.minX >= eo.homeX - eo.rangeX and r.maxX <= eo.homeX + eo.rangeX + and r.minY >= eo.homeY - eo.rangeY and r.maxY <= eo.homeY + eo.rangeY, "slowpoke stays inside its range") + result(delaysIn(r, MEDIUM), "slowpoke waits sMovementDelaysMedium frames") + end + + -- src/event_object_movement.c:3044 + go("FR_ROUTE_8", 30, 10, "down") + looker = Objects.find(3) + local spot + for _, off in ipairs({ { 2, 0, "left" }, { -2, 0, "right" }, { 0, -2, "down" } }) do + local mx, my = looker.cellX + off[1] / 2, looker.cellY + off[2] / 2 + local px, py = looker.cellX + off[1], looker.cellY + off[2] + if Collision.canEnter(game, mx, my, {}) and Collision.canEnter(game, px, py, {}) + and not Objects.at(mx, my) and not Objects.at(px, py) then + spot = { x = px, y = py, face = off[3], + want = off[1] > 0 and "right" or off[1] < 0 and "left" or "up" } + break + end + end + if not result(spot ~= nil, "found a side cell in the LOOK_AROUND trainer's reach") then return finish() end + looker.facing = "down" + Player.reset(spot.x, spot.y, spot.face) + local spottedFacing + for _ = 1, 4000 do + U.wait(1) + if Field.locked and looker.scriptBusy then + spottedFacing = looker.facing + break + end + end + print("[driver] look_around spotted facing=" .. tostring(spottedFacing) .. " want=" .. spot.want) + result(spottedFacing == spot.want, "LOOK_AROUND trainer turned and spotted the player from the side") + U.wait(20) + U.shot(game, DIR .. "/mov_lookaround_spots_from_side.png") + + return finish() +end diff --git a/tests/drivers/game3_npc_player_collision.lua b/tests/drivers/game3_npc_player_collision.lua index 4b86ccbf..38857868 100644 --- a/tests/drivers/game3_npc_player_collision.lua +++ b/tests/drivers/game3_npc_player_collision.lua @@ -62,7 +62,8 @@ return function(game) and Collision.canEnter(game, px, py, {}) and not Objects.at(px, py) then wanderer = eo - plan = { tx = tx, ty = ty, px = px, py = py, into = OPPOSITE[dir], back = dir } + plan = { hx = eo.cellX, hy = eo.cellY, tx = tx, ty = ty, px = px, py = py, + into = OPPOSITE[dir], back = dir } break end end @@ -81,7 +82,12 @@ return function(game) U.wait(60) wanderer = Objects.find(wanderer.localId) if not result(wanderer ~= nil, "wanderer survived the reload") then return finish() end - local homeX, homeY = wanderer.cellX, wanderer.cellY + local homeX, homeY = plan.hx, plan.hy + wanderer.moving = false + wanderer.progress = 0 + wanderer.cellX, wanderer.cellY = homeX, homeY + wanderer.targetX, wanderer.targetY = homeX, homeY + wanderer.px, wanderer.py = homeX * 16, homeY * 16 local overlaps, contested, midSteps, declines = 0, 0, 0, 0 local frozen, leashed = false, true diff --git a/tests/drivers/game3_ops_icefall_hole.lua b/tests/drivers/game3_ops_icefall_hole.lua index cd4ec424..c12a630b 100644 --- a/tests/drivers/game3_ops_icefall_hole.lua +++ b/tests/drivers/game3_ops_icefall_hole.lua @@ -82,37 +82,70 @@ return function(game) print("[driver] on the ice at (" .. Player.cellX .. "," .. Player.cellY .. ")") result(Player.cellX == 8 and Player.cellY == 14, "standing on the crackable ice at (" .. Player.cellX .. "," .. Player.cellY .. ")") - U.shot(game, DIR .. "/ops_icefall_hole_01_on_the_ice.png") - -- pokefirered/src/field_tasks.c:243 - Flags.setVar(Space.store, ctx(), VAR_TEMP_1, 1) + local Field = require("src.core.game3.field") + -- pokefirered/include/constants/metatile_labels.h:188 + local CRACKED, HOLE = 0x35A, 0x35B + local function iceAt(x, y) + local b = Field.metatileOverrides[CAVE_1F] + local o = b and b[y * 1024 + x] + return o and o.metatile + end - -- pokefirered/src/field_control_avatar.c:212 - local ranByItself = false - for _ = 1, 120 do + U.wait(12) + result(iceAt(8, 14) == CRACKED, + "the first step cracked the thin ice, metatile=" .. string.format("0x%X", iceAt(8, 14) or 0)) + -- pokefirered/src/field_tasks.c:139 + result(Flags.getFlag(Space.store, ctx(), 9) == true, "FLAG_TEMP_9 marks (8,14) visited") + result((tonumber(Flags.getVar(Space.store, ctx(), VAR_TEMP_1)) or 0) == 0, + "VAR_TEMP_1 is still 0 after the crack") + U.shot(game, DIR .. "/2418_ice_cracked.png") + + local Audio = require("src.core.game3.audio") + local SE = require("src.core.game3.se_ids") + local seCount = {} + local realPlaySe = Audio.playSe + Audio.playSe = function(id, ...) + seCount[id] = (seCount[id] or 0) + 1 + return realPlaySe(id, ...) + end + + while Player.cellY > 13 do + if not step("up") then break end + end + result(Player.cellY < 14, "stepped back off the cracked ice") + U.wait(8) + while Player.cellY < 14 do + if not step("down") then break end + end + result(Player.cellX == 8 and Player.cellY == 14, "stepped onto the cracked ice again") + + local broke = false + for _ = 1, 30 do U.wait(1) - if Space.vm and Space.vm:isRunning() then - ranByItself = true - break - end - end - if not ranByItself then - print("[driver] BLOCKED src/core/game3/field.lua:93 only runs ON_FRAME when " .. - "Space._pendingOnFrame is set, so setting VAR_TEMP_1 while standing on the " .. - "map starts nothing; pret polls TryRunOnFrameMapScript every frame. " .. - "Owner handoff: field.lua / space.lua. Nudging once so warphole can be exercised.") - Space.scheduleOnFrame(game and (game.overworld or game.world)) + if iceAt(8, 14) == HOLE then broke = true break end end + result(broke, "the second step broke the ice into a hole") + U.shot(game, DIR .. "/2418_ice_broken.png") local started = false for _ = 1, 120 do - U.wait(1) if Space.vm and Space.vm:isRunning() then started = true break end + U.wait(1) end - result(started, "the OnFrame fall script started") + result(started, "the OnFrame fall script started by itself") + + -- data/maps/FourIsland_IcefallCave_1F/scripts.inc:17 + for _ = 1, 60 do + if not Player.isVisible() then break end + U.wait(1) + end + result(not Player.isVisible(), "set_invisible hid the player over the hole") + U.wait(2) + U.shot(game, DIR .. "/2418_fall_vanished.png") local pendingSeen = false local landed = false @@ -124,6 +157,15 @@ return function(game) break end end + -- pokefirered/src/field_effect.c:1215 FallWarpEffect_4 + local dropping = false + for _ = 1, 300 do + local y2 = Player.spriteYOffset or 0 + if Player.isVisible() and y2 < -16 and y2 > -64 then dropping = true break end + U.wait(1) + end + result(dropping, "the player drops in from above on B1F") + U.shot(game, DIR .. "/2418_fall_dropping.png") result(pendingSeen, "warphole marked the warp pending while the fall ran") result(landed, "warphole landed the player on Icefall Cave B1F, map=" .. tostring(Runtime.getSession().map)) @@ -139,7 +181,12 @@ return function(game) result(Player.cellX == 8 and Player.cellY == 14, "landed under the hole at (" .. Player.cellX .. "," .. Player.cellY .. ")") U.wait(60) - U.shot(game, DIR .. "/ops_icefall_hole_02_landed_b1f.png") + U.shot(game, DIR .. "/2418_fell_to_b1f.png") + Audio.playSe = realPlaySe + -- pokefirered/src/field_tasks.c:236, src/field_effect.c:1210 + result((seCount[SE.SE_ICE_BREAK] or 0) == 1, "SE_ICE_BREAK played once, n=" .. tostring(seCount[SE.SE_ICE_BREAK])) + result((seCount[SE.SE_FALL] or 0) == 2, + "SE_FALL played once on each side of the warp, n=" .. tostring(seCount[SE.SE_FALL])) result(step("down") or step("left") or step("up"), "control came back to the player on B1F") diff --git a/tests/drivers/game3_pc_rom_script.lua b/tests/drivers/game3_pc_rom_script.lua new file mode 100644 index 00000000..7b23bf0c --- /dev/null +++ b/tests/drivers/game3_pc_rom_script.lua @@ -0,0 +1,208 @@ +-- pokefirered/data/scripts/pc.inc:1 +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_pc_rom_script" + +local FLAG_SYS_POKEDEX_GET = 0x829 -- include/constants/flags.h:1375 +local FLAG_SYS_GAME_CLEAR = 0x82C -- include/constants/flags.h:1378 +local FLAG_SYS_PC_STORAGE_DISABLED = 0x841 -- include/constants/flags.h:1399 +local MB_PC = 0x83 -- include/constants/metatile_behaviors.h:94 +local SE_PC_LOGIN = 2 -- include/constants/songs.h:6 +local VAR_MAP_SCENE_ONE_ISLAND_POKEMON_CENTER_1F = 0x4076 -- include/constants/vars.h:170 + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS game3_pc_rom_script") + love.event.quit(0) + else + print("FAIL game3_pc_rom_script failures=" .. failures) + love.event.quit(1) + end +end + +local function flat(s) + return (tostring(s or ""):gsub("[\n\f]", " ")) +end + +local function Message() return require("src.ui.game3.message") end +local function PcMenu() return require("src.ui.game3.pc_menu") end +local function Choice() return require("src.ui.game3.choice") end + +local function waitPage(game, pattern, frames) + local seen = {} + for _ = 1, frames do + local M = Message() + if M.isOpen() then + local page = flat(M.currentPage()) + if page:find(pattern, 1, true) then + while not M.isWaiting() do U.wait(1) end + return true, page + end + if seen[#seen] ~= page then seen[#seen + 1] = page end + if M.isWaiting() and not (Choice().active) then + U.tap(game, "a") + end + end + if Choice().active then U.tap(game, "a") end + U.wait(1) + end + return false, table.concat(seen, " | ") +end + +local function waitMenu(game, frames) + for _ = 1, frames do + local P = PcMenu() + if P.isOpen() and P.mode == "root" then return true end + local M = Message() + local prompt = flat(M.currentPage()):find("Which PC should be accessed?", 1, true) + if M.isOpen() and M.isWaiting() and not prompt then U.tap(game, "a") end + U.wait(1) + end + return false +end + +local function pickRow(game, id) + local P = PcMenu() + for i, r in ipairs(P._rootEntries()) do + if r.id == id then P.cursor = i end + end + U.tap(game, "a") +end + +local function waitReleased(game, frames) + local Space = require("src.core.game3.scripting.space") + for _ = 1, frames do + local running = Space.vm and Space.vm:isRunning() + if not running and not Message().isOpen() and not PcMenu().isOpen() then return true end + if Message().isOpen() and Message().isWaiting() then U.tap(game, "a") end + U.wait(1) + end + return false +end + +local function standAtPc(game, mapId) + local Map = require("src.core.game3.map") + Map.load(nil, game, mapId, { x = 1, y = 1, facing = "up" }) + U.wait(30) + local Collision = require("src.core.game3.collision") + for y = 1, 20 do + for x = 0, 30 do + if Collision.behavior(x, y) == MB_PC then + Map.load(nil, game, mapId, { x = x, y = y + 1, facing = "up" }) + game.session.x, game.session.y, game.session.facing = x, y + 1, "up" + U.wait(60) + return true + end + end + end + return false +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local MapCatalog = require("src.import.gba.map_catalog") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the game3 field") then return finish() end + + local Audio = require("src.core.game3.audio") + local origSe = Audio.playSe + local seLog = {} + Audio.playSe = function(id, ...) + seLog[#seLog + 1] = id + return origSe(id, ...) + end + + -- data/maps/OneIsland_PokemonCenter_1F/scripts.inc:116 + local ONE = MapCatalog.pretToEngine("OneIsland_PokemonCenter_1F") + Flags.setVar(Space.store, nil, VAR_MAP_SCENE_ONE_ISLAND_POKEMON_CENTER_1F, 1) + Flags.setFlag(Space.store, nil, FLAG_SYS_PC_STORAGE_DISABLED, true) + if not result(standAtPc(game, ONE), "One Island Pokemon Center PC found") then return finish() end + U.tap(game, "a") + local ok, page = waitPage(game, "The usual PC services aren't available…", 300) + result(ok, "One Island before Celio: \"The usual PC services aren't available…\" (got " .. page .. ")") + if ok then U.shot(game, DIR .. "/pc_one_island_services_unavailable.png") end + result(not PcMenu().isOpen(), "One Island PC does not open the PC menu") + result(waitReleased(game, 300), "One Island PC releases the player") + Flags.setFlag(Space.store, nil, FLAG_SYS_PC_STORAGE_DISABLED, false) + + local VIRIDIAN = "FR_VIRIDIAN_CITY_POKEMON_CENTER_1F" + if not result(standAtPc(game, VIRIDIAN), "Viridian Pokemon Center PC found") then return finish() end + Flags.setFlag(Space.store, nil, FLAG_SYS_POKEDEX_GET, true) + U.tap(game, "a") + if not result(waitMenu(game, 300), "PC menu opens after booted-up text") then return finish() end + pickRow(game, "oak") + ok, page = waitPage(game, "Accessed PROF. OAK's PC…", 300) + result(ok, "Oak's PC: \"Accessed PROF. OAK's PC…\" (got " .. page .. ")") + ok, page = waitPage(game, "PROF. OAK's rating:", 2400) + result(ok, "Oak's PC runs PokedexRating_EventScript_Rate (got " .. page .. ")") + if ok then U.shot(game, DIR .. "/pc_oak_pc_rating.png") end + ok, page = waitPage(game, "Closed link to PROF. OAK's PC.", 3000) + result(ok, "Oak's PC: \"Closed link to PROF. OAK's PC.\" (got " .. page .. ")") + result(waitMenu(game, 300), "Oak's PC returns to the PC menu") + pickRow(game, "quit") + result(waitReleased(game, 300), "LOG OFF releases the player") + + Flags.setFlag(Space.store, nil, FLAG_SYS_GAME_CLEAR, true) + session.gameStats = session.gameStats or {} + session.gameStats[10] = 3 + session.hallOfFameTeams = { + { + { species = 6, level = 50, nickname = "CHARIZARD", trainerId = 12345, personality = 0 }, + { species = 25, level = 42, nickname = "SPARKY", trainerId = 12345, personality = 0xFF }, + { species = 412, level = 5, nickname = "EGG", trainerId = 12345, personality = 0 }, + }, + { + { species = 3, level = 61, nickname = "VENUSAUR", trainerId = 12345, personality = 0x10 }, + { species = 9, level = 60, nickname = "BLASTOISE", trainerId = 12345, personality = 0 }, + { species = 65, level = 58, nickname = "ALAKAZAM", trainerId = 12345, personality = 0xF0 }, + { species = 143, level = 55, nickname = "SNORLAX", trainerId = 12345, personality = 0 }, + { species = 130, level = 55, nickname = "GYARADOS", trainerId = 12345, personality = 0 }, + { species = 29, level = 50, nickname = "NIDORAN", trainerId = 12345, personality = 0 }, + }, + } + U.tap(game, "a") + if not result(waitMenu(game, 300), "PC menu opens with HALL OF FAME row") then return finish() end + local mark = #seLog + pickRow(game, "hall") + local HofPc = require("src.ui.game3.hall_of_fame_pc") + for _ = 1, 120 do + if HofPc.isOpen() then break end + U.wait(1) + end + if not result(HofPc.isOpen(), "HALL OF FAME row opens the HoF PC viewer") then return finish() end + local login = false + for i = mark + 1, #seLog do if seLog[i] == SE_PC_LOGIN then login = true end end + result(login, "AccessHallOfFame plays SE_PC_LOGIN from the ROM script") + result(HofPc._team == 2 and HofPc._number == 3, "viewer starts on the newest team, HALL OF FAME No. 3") + U.wait(30) + U.shot(game, DIR .. "/pc_hof_viewer_newest_team.png") + U.tap(game, "a") + U.wait(20) + result(HofPc._team == 1 and HofPc._number == 2, "A steps to the older team, No. 2") + U.tap(game, "down") + U.wait(20) + result(HofPc._mon == 2, "DOWN selects the second member") + U.shot(game, DIR .. "/pc_hof_viewer_older_team_mon2.png") + U.tap(game, "b") + result(waitMenu(game, 300) and not HofPc.isOpen(), "B leaves the viewer and reshows the PC menu") + pickRow(game, "quit") + result(waitReleased(game, 300), "LOG OFF after the viewer releases the player") + + Audio.playSe = origSe + finish() +end diff --git a/tests/drivers/game3_pc_se_2415.lua b/tests/drivers/game3_pc_se_2415.lua index 37d46785..d9908256 100644 --- a/tests/drivers/game3_pc_se_2415.lua +++ b/tests/drivers/game3_pc_se_2415.lua @@ -93,12 +93,39 @@ return function(game) U.shot(game, DIR .. "/2415_02_storage_menu_after_login.png") local M = PcMenu() - for _ = 1, 8 do - if M.mode == "root" then break end - U.tap(game, "b") - U.wait(15) + local function advanceUntil(pred, n) + for _ = 1, n do + if pred() then return true end + local Msg = Message() + local page = Msg and Msg.isOpen() and tostring(Msg.currentPage() or ""):gsub("[\n\f]", " ") or "" + if Msg and Msg.isOpen() and Msg.isWaiting() and not page:find("Which PC", 1, true) then + U.tap(game, "a") + end + U.wait(1) + end + return pred() end - if not result(M.mode == "root", "back to root menu") then return finish() end + local function released() + local S = Space() + local running = S and S.vm and S.vm.isRunning and S.vm:isRunning() + local Msg = Message() + return not M.isOpen() and not running and not (Msg and Msg.isOpen and Msg.isOpen()) + end + local function waitReleased(n) + for _ = 1, n do + if released() then return true end + U.wait(1) + end + return released() + end + + if not result(advanceUntil(function() return M.isOpen() and M.mode == "storage_menu" end, 600), + "storage system menu opens") then return finish() end + mark = #log + U.tap(game, "b") + if not result(advanceUntil(menuOpen, 600), "B out of storage reshows the PC menu") then return finish() end + result(since(mark) == tostring(SE_SELECT), + "B out of storage plays SE_SELECT only (got " .. since(mark) .. ")") local rows = M._rootEntries() local ids = {} for _, r in ipairs(rows) do ids[#ids + 1] = r.id end @@ -111,16 +138,49 @@ return function(game) end mark = #log U.tap(game, "a") - local released = waitFor(function() - local S = Space() - local running = S and S.vm and S.vm.isRunning and S.vm:isRunning() - local Msg = Message() - return not M.isOpen() and not running and not (Msg and Msg.isOpen and Msg.isOpen()) - end, 180) - result(released, "LOG OFF releases the player") + result(waitReleased(180), "LOG OFF releases the player") result(since(mark) == SE_SELECT .. "," .. SE_PC_OFF, "LOG OFF plays SE_SELECT then SE_PC_OFF once (got " .. since(mark) .. ")") + U.wait(30) + U.tap(game, "a") + if not result(advanceUntil(menuOpen, 600), "Center PC reopens the root menu") then return finish() end + mark = #log + U.tap(game, "b") + result(waitReleased(180), "B on the PC menu releases the player") + result(since(mark) == SE_SELECT .. "," .. SE_PC_OFF, + "B on the PC menu plays SE_SELECT then SE_PC_OFF once (got " .. since(mark) .. ")") + + Map.load(nil, game, "FR_PLAYERS_HOUSE_2F", { x = 1, y = 2, facing = "up" }) + game.session.x, game.session.y, game.session.facing = 1, 2, "up" + U.wait(90) + mark = #log + U.tap(game, "a") + if not result(advanceUntil(function() return M.isOpen() and M.mode == "player_pc" end, 600), + "bedroom PC opens the player PC menu") then return finish() end + result(since(mark) == tostring(SE_PC_ON), "bedroom PC plays SE_PC_ON (got " .. since(mark) .. ")") + U.shot(game, DIR .. "/2415_03_bedroom_pc_menu.png") + for _ = 1, 4 do + if M.cursor == #M.TOP_ACTIONS then break end + U.tap(game, "down") + U.wait(8) + end + mark = #log + U.tap(game, "a") + result(waitReleased(180), "bedroom TURN OFF releases the player") + result(since(mark) == SE_SELECT .. "," .. SE_PC_OFF, + "bedroom TURN OFF plays SE_SELECT then SE_PC_OFF once (got " .. since(mark) .. ")") + + U.wait(30) + U.tap(game, "a") + if not result(advanceUntil(function() return M.isOpen() and M.mode == "player_pc" end, 600), + "bedroom PC reopens") then return finish() end + mark = #log + U.tap(game, "b") + result(waitReleased(180), "bedroom B releases the player") + result(since(mark) == SE_SELECT .. "," .. SE_PC_OFF, + "bedroom B plays SE_SELECT then SE_PC_OFF once (got " .. since(mark) .. ")") + Audio.playSe = orig finish() end diff --git a/tests/drivers/game3_picbox_start_cancel.lua b/tests/drivers/game3_picbox_start_cancel.lua new file mode 100644 index 00000000..d6130601 --- /dev/null +++ b/tests/drivers/game3_picbox_start_cancel.lua @@ -0,0 +1,97 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_picbox_start_cancel" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS game3_picbox_start_cancel") + love.event.quit(0) + else + print("FAIL game3_picbox_start_cancel failures=" .. failures) + love.event.quit(1) + end +end + +-- data/maps/PalletTown/scripts.inc:428 SignLadyShowSign / :444 SignLadyStartShowSign +local function findSignLadyScript(scripts) + local show + for key, rows in pairs(scripts) do + for i, row in ipairs(rows) do + local nxt = rows[i + 1] + if row.op == "special" and row.id == 368 and nxt and nxt.op == "special" and nxt.id == 369 then + show = key + end + end + end + if not show then return nil end + for key, rows in pairs(scripts) do + local first = rows[1] + if first and first.op == "applymovement" then + for _, row in ipairs(rows) do + if row.op == "call" and row.target == show then return key end + end + end + end + return nil +end + +return function(game) + for _ = 1, 600 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(180) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Space = require("src.core.game3.scripting.space") + local StartMenu = require("src.ui.game3.start_menu") + local Message = package.loaded["src.ui.game3.message"] or require("src.ui.game3.message") + local MapCatalog = require("src.import.gba.map_catalog") + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the field") then return finish() end + local town = MapCatalog.pretToEngine("PalletTown") + Map.load(nil, game, town, { x = 9, y = 8, facing = "down" }) + game.session.x, game.session.y, game.session.facing = 9, 8, "down" + U.wait(60) + + local key = findSignLadyScript(Space.vm.scripts) + if not result(key ~= nil, "sign lady show-sign script found in the cache") then return finish() end + Space.startScript(key, 1) + + local shown = false + for _ = 1, 600 do + local p = Message.currentPage and Message.currentPage() or "" + if Message.isOpen() and p:find("Press START to open the MENU", 1, true) then shown = true break end + if Message.isOpen() and Message.isTyping() then Message.skipReveal() end + if Message.isOpen() and Message.isWaiting() then U.tap(game, "a") end + U.wait(2) + end + if not result(shown, "the copied sign is up") then return finish() end + Message.skipReveal() + U.wait(20) + result(U.shot(game, DIR .. "/spec_picbox_sign_before_start.png"), "screenshot spec_picbox_sign_before_start") + + U.tap(game, "start") + local opened = false + for _ = 1, 60 do + if StartMenu.isOpen() then opened = true break end + U.wait(1) + end + result(not Message.isOpen(), "START closed the sign") + result(not Space.vm:isRunning(), "START ended the sign script") + result(opened, "START opened the menu") + if opened then + U.wait(10) + result(U.shot(game, DIR .. "/spec_picbox_start_menu_open.png"), "screenshot spec_picbox_start_menu_open") + end + return finish() +end diff --git a/tests/drivers/game3_pp_items_2420.lua b/tests/drivers/game3_pp_items_2420.lua new file mode 100644 index 00000000..7780b2fa --- /dev/null +++ b/tests/drivers/game3_pp_items_2420.lua @@ -0,0 +1,284 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_pp_items_2420" + +-- pokefirered/include/constants/items.h:38 +local ITEM_ETHER = 34 +local ITEM_PP_UP = 69 +local ITEM_LEPPA_BERRY = 138 +local ITEM_BERRY_POUCH = 365 +local PALLET = "FR_PALLET_TOWN" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS pp_items_2420") + love.event.quit(0) + else + print("FAIL pp_items_2420 failures=" .. failures) + love.event.quit(1) + end +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Bag = require("src.core.game3.bag") + local Party = require("src.core.game3.party") + local Pokemon = require("src.core.game3.pokemon") + local StartMenu = require("src.ui.game3.start_menu") + local BagMenu = require("src.ui.game3.bag_menu") + local PartyMenu = require("src.ui.game3.party_menu") + local BerryPouch = require("src.ui.game3.berry_pouch") + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the game3 field") then return finish() end + + session.party = {} + Party.giveMon(session, 7, 15) + local mon = session.party[1] + mon.moves = { 33, 39, 145, 110 } + mon.pp = { 2, 30, 30, 40 } + mon.maxPp = { 35, 30, 30, 40 } + mon.ppBonusesPacked = nil + session.bag = session.bag or {} + Bag.add(session.bag, ITEM_ETHER, 3) + Bag.add(session.bag, ITEM_PP_UP, 1) + Bag.add(session.bag, ITEM_LEPPA_BERRY, 2) + Bag.add(session.bag, ITEM_BERRY_POUCH, 1) + + Map.load(nil, game, PALLET, { x = 7, y = 10, facing = "down" }) + Player.moving = false + Player.cellX, Player.cellY = 7, 10 + Player.px, Player.py = 7 * 16, 10 * 16 + Player.targetX, Player.targetY = 7, 10 + U.wait(90) + + local function pickInBag(pocket, itemId) + for _ = 1, 6 do + if BagMenu.currentPocket() == pocket then break end + U.tap(game, "right") + U.wait(15) + end + for _ = 1, 30 do + local want + for i, r in ipairs(BagMenu.list() or {}) do + if tonumber(r.id) == itemId then want = i end + end + if not want or BagMenu.cursor == want then break end + U.tap(game, (BagMenu.cursor > want) and "up" or "down") + U.wait(8) + end + local row = BagMenu.isOpen() and BagMenu.list()[BagMenu.cursor] + return row and tonumber(row.id) == itemId + end + + local function openFieldBag(pocket, itemId) + U.tap(game, "start") + U.wait(30) + for _ = 1, 12 do + local e = StartMenu.ENTRIES and StartMenu.ENTRIES[StartMenu.cursor] + if e and e.id == "bag" then break end + U.tap(game, "down") + U.wait(8) + end + U.tap(game, "a") + U.wait(60) + return pickInBag(pocket, itemId) + end + + local function waitMode(m, n) + for _ = 1, n or 120 do + if PartyMenu.isOpen() and PartyMenu.mode == m then return true end + U.wait(1) + end + return false + end + + local function closeAllField() + for _ = 1, 20 do + if not (PartyMenu.isOpen() or BagMenu.isOpen() or (StartMenu.isOpen and StartMenu.isOpen())) then break end + U.tap(game, "b") + U.wait(20) + end + end + + -- pokefirered/src/party_menu.c:4591 ItemUseCB_TryRestorePP + if not result(openFieldBag("ITEMS", ITEM_ETHER), "field bag cursor on ETHER") then return finish() end + U.tap(game, "a") + U.wait(20) + U.tap(game, "a") + if not result(waitMode("use", 120), "USE opened the party menu") then return finish() end + U.wait(20) + PartyMenu.cursor = 1 + U.tap(game, "a") + U.wait(10) + result(waitMode("forget", 30), "a move-select window opened") + result(PartyMenu._forgetPrompt == "Restore which move?", + "prompt is gText_RestoreWhichMove (" .. tostring(PartyMenu._forgetPrompt) .. ")") + U.wait(10) + U.shot(game, DIR .. "/2420_field_ether_restore_which_move.png") + local hp0 = mon.hp + U.tap(game, "a") + waitMode("message", 60) + U.wait(20) + result((PartyMenu._messageText or ""):find("PP was restored.", 1, true) ~= nil, + "gText_PPWasRestored shown (" .. tostring(PartyMenu._messageText) .. ")") + U.shot(game, DIR .. "/2420_field_ether_pp_restored.png") + result(mon.pp[1] == 12, "field ETHER restored TACKLE 2 -> " .. tostring(mon.pp[1])) + result(mon.hp == hp0, "and HP unchanged") + result(Bag.has(session.bag, ITEM_ETHER, 2) and not Bag.has(session.bag, ITEM_ETHER, 3), + "one ETHER consumed") + U.tap(game, "a") + U.wait(20) + closeAllField() + + -- pokefirered/src/party_menu.c:4709 ItemUseCB_PPUp + if not result(openFieldBag("ITEMS", ITEM_PP_UP), "field bag cursor on PP UP") then return finish() end + U.tap(game, "a") + U.wait(20) + U.tap(game, "a") + if not result(waitMode("use", 120), "USE opened the party menu for PP UP") then return finish() end + U.wait(20) + PartyMenu.cursor = 1 + U.tap(game, "a") + U.wait(10) + result(waitMode("forget", 30) and PartyMenu._forgetPrompt == "Boost PP of which?", + "prompt is gText_BoostPp (" .. tostring(PartyMenu._forgetPrompt) .. ")") + U.wait(10) + U.shot(game, DIR .. "/2420_field_ppup_boost_which.png") + local base = Pokemon.movePp(33) + U.tap(game, "a") + waitMode("message", 60) + U.wait(20) + result((PartyMenu._messageText or ""):find("TACKLE's PP increased.", 1, true) ~= nil, + "gText_MovesPPIncreased shown (" .. tostring(PartyMenu._messageText) .. ")") + U.shot(game, DIR .. "/2420_field_ppup_pp_increased.png") + local want = base + math.floor(base * 20 / 100) + result(mon.maxPp[1] == want, string.format("PP UP max PP %d -> %s (+20%% of base)", base, tostring(mon.maxPp[1]))) + result(mon.pp[1] == 12 + want - base, "and current PP rose by the same step (" .. tostring(mon.pp[1]) .. ")") + U.tap(game, "a") + U.wait(20) + closeAllField() + + local BattleBridge = require("src.core.game3.battle_bridge") + local Battle = require("src.core.game3.battle") + local Ui = require("src.core.game3.battle.ui") + local ok, err = BattleBridge.startWild(Runtime._mod, game, { species = 16, level = 5 }, { fade = false }) + if not result(ok == true, "wild battle started " .. tostring(err or "")) then return finish() end + + local function at_command() + return Battle.isActive() and Battle._phase == "command" and Ui._mode == "menu" + end + local lastTap, f = 0, 0 + local function pumpToCommand(n) + for _ = 1, n or 3000 do + f = f + 1 + if at_command() then return true end + if Ui.dialogPending and Ui.dialogPending() and f - lastTap >= 14 then + lastTap = f + U.tap(game, "a") + else + U.wait(1) + end + end + return at_command() + end + if not result(pumpToCommand(), "reached the battle command menu") then return finish() end + local st = Battle.getState() + st.enemy.mon.moves = { 150, 0, 0, 0 } + st.enemy.mon.pp = { 40, 0, 0, 0 } + local State = require("src.core.game3.battle.state") + local bmon = State.ensureBattleMoves(st.player) + bmon.pp[1] = 1 + bmon.pp[2] = 0 + local party = st.playerParty + local pmon = party[st.player.partyIndex or 1] + pmon.hp = math.max(1, (tonumber(pmon.maxHp) or 20) - 5) + st.player.mon.hp = pmon.hp + + local function openBattleBag() + Ui._menuIndex = 2 + U.tap(game, "a") + for _ = 1, 120 do + if BagMenu.isOpen() and BagMenu.mode == "list" then break end + U.wait(1) + end + U.wait(30) + return BagMenu.isOpen() + end + + local function pickMonAndMove(moveCursor, shotName, msgShot) + if not waitMode("use", 120) then return false end + U.wait(20) + PartyMenu.cursor = 1 + U.tap(game, "a") + U.wait(10) + if not waitMode("forget", 30) then return false end + for _ = 2, moveCursor do + U.tap(game, "down") + U.wait(6) + end + U.wait(10) + U.shot(game, DIR .. "/" .. shotName) + U.tap(game, "a") + waitMode("message", 60) + U.wait(20) + U.shot(game, DIR .. "/" .. msgShot) + local txt = PartyMenu._messageText + U.tap(game, "a") + return true, txt + end + + -- pokefirered/src/party_menu.c:4675 TryUsePPItemInBattle + local hpBefore = pmon.hp + if not result(openBattleBag(), "battle BAG opened") then return finish() end + if not result(pickInBag("ITEMS", ITEM_ETHER), "battle bag cursor on ETHER") then return finish() end + U.tap(game, "a") + U.wait(20) + U.tap(game, "a") + local okE, txtE = pickMonAndMove(1, "2420_battle_ether_restore_which_move.png", "2420_battle_ether_pp_restored.png") + result(okE and (txtE or ""):find("PP was restored.", 1, true) ~= nil, + "battle ETHER picked a move and showed PP was restored (" .. tostring(txtE) .. ")") + pumpToCommand() + result(st.player.mon.pp[1] == 11, "battle ETHER restored the active battler's PP 1 -> " .. tostring(st.player.mon.pp[1])) + result(pmon.pp[1] == 11, "and the party mon's PP matches (" .. tostring(pmon.pp[1]) .. ")") + result(pmon.hp == hpBefore and st.player.mon.hp == hpBefore, + "HP unchanged " .. tostring(hpBefore) .. " -> " .. tostring(st.player.mon.hp)) + local sawUsed = false + for _, t in ipairs(Ui.log()) do if t:find("ETHER", 1, true) then sawUsed = true end end + result(sawUsed, "battle log has RED used the ETHER") + + if not result(openBattleBag(), "battle BAG opened again") then return finish() end + if not result(pickInBag("KEY_ITEMS", ITEM_BERRY_POUCH), "battle bag cursor on BERRY POUCH") then return finish() end + BerryPouch.show(session, session.bag, { session = session, bag = session.bag }) + U.wait(30) + for i, r in ipairs(BerryPouch.list() or {}) do + if tonumber(r.id) == ITEM_LEPPA_BERRY then BerryPouch.cursor = i end + end + U.tap(game, "a") + U.wait(15) + U.tap(game, "a") + local okL, txtL = pickMonAndMove(2, "2420_battle_leppa_restore_which_move.png", "2420_battle_leppa_pp_restored.png") + result(okL and (txtL or ""):find("PP was restored.", 1, true) ~= nil, + "battle LEPPA picked a move and showed PP was restored (" .. tostring(txtL) .. ")") + pumpToCommand() + result(st.player.mon.pp[2] == 10, "battle LEPPA restored move 2 PP 0 -> " .. tostring(st.player.mon.pp[2])) + result(pmon.pp[2] == 10, "and the party mon's PP matches (" .. tostring(pmon.pp[2]) .. ")") + result(pmon.hp == hpBefore, "HP still unchanged (" .. tostring(pmon.hp) .. ")") + + finish() +end diff --git a/tests/drivers/game3_run_pose.lua b/tests/drivers/game3_run_pose.lua new file mode 100644 index 00000000..8bf3926b --- /dev/null +++ b/tests/drivers/game3_run_pose.lua @@ -0,0 +1,99 @@ +local U = require("tests.drivers.util") + +local failed = false +local function check(label, cond, detail) + print((cond and "PASS " or "FAIL ") .. label .. (detail and (" " .. detail) or "")) + if not cond then failed = true end +end + +local STRIDE = { + down = { [10] = true, [11] = true }, + up = { [13] = true, [14] = true }, + left = { [16] = true, [17] = true }, + right = { [16] = true, [17] = true }, +} + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + local ok, err = pcall(function() + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local OwSprites = require("src.core.game3.ow_sprites") + Map.load(nil, game, "FR_ROUTE_1", { x = 11, y = 20, facing = "down" }) + U.wait(60) + Flags.setFlag(Space.getStore(), nil, Flags.IDS.SYS_B_DASH, true) + + local drawn = {} + local origPose = OwSprites.pose + OwSprites.pose = function(spr, facing, walkPhase, stepFlip, opts) + local f, flip = origPose(spr, facing, walkPhase, stepFlip, opts) + if opts and opts.running ~= nil then drawn[#drawn + 1] = { f = f, facing = facing } end + return f, flip + end + + local dirs = { "down", "up", "left", "right" } + for _, dir in ipairs(dirs) do + Map.load(nil, game, "FR_ROUTE_1", { x = 11, y = dir == "right" and 21 or 20, facing = "down" }) + U.wait(30) + game.input.state.b = true + local ready = false + for _ = 1, 240 do + game.input.state[dir] = true + table.insert(game.input.pressQueue, dir) + coroutine.yield() + if Player.moving and Player.running and Player.facing == dir + and Player.animClock == 5 and Player.runPose() == 0 then + ready = true + break + end + end + check("run_step_started_" .. dir, ready) + for _ = 1, 3 do + if Player.runPose() == 1 then break end + coroutine.yield() + end + local strideClock = Player.animClock + local origTick = Player.tick + Player.tick = function() return false end + for k in pairs(drawn) do drawn[k] = nil end + local dirShot = os.getenv("POKEPORT_SHOT_DIR") + if dirShot and dirShot ~= "" then + U.shot(game, dirShot .. "/run_stride_" .. dir .. ".png") + end + for _ = 1, 60 do + if #drawn > 0 then break end + coroutine.yield() + end + Player.tick = origTick + local hit, miss + for _, d in ipairs(drawn) do + if STRIDE[dir][d.f] then hit = d.f else miss = d.f end + end + check("run_stride_frame_" .. dir, hit ~= nil and miss == nil, + "frame=" .. tostring(miss or hit) .. " clock=" .. tostring(strideClock)) + game.input.state[dir] = false + game.input.state.b = false + U.wait(20) + end + + for k in pairs(drawn) do drawn[k] = nil end + game.input.state.b = false + for _ = 1, 20 do + game.input.state.down = true + table.insert(game.input.pressQueue, "down") + coroutine.yield() + end + game.input.state.down = false + check("walk_not_run_pose", #drawn == 0, "runDraws=" .. #drawn) + OwSprites.pose = origPose + end) + if not ok then check("driver_error", false, tostring(err)) end + love.event.quit(failed and 1 or 0) +end diff --git a/tests/drivers/game3_safari_start_menu.lua b/tests/drivers/game3_safari_start_menu.lua new file mode 100644 index 00000000..b33158d3 --- /dev/null +++ b/tests/drivers/game3_safari_start_menu.lua @@ -0,0 +1,260 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_safari_start_menu" + +local ENTRANCE = "FR_FUCHSIA_CITY_SAFARI_ZONE_ENTRANCE" +local CENTER = "FR_SAFARI_ZONE_CENTER" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS safari_start_menu") + love.event.quit(0) + else + print("FAIL safari_start_menu failures=" .. failures) + love.event.quit(1) + end +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Space = require("src.core.game3.scripting.space") + local Player = require("src.core.game3.player") + local Safari = require("src.core.game3.safari") + local Message = require("src.ui.game3.message") + local Choice = require("src.ui.game3.choice") + local StartMenu = require("src.ui.game3.start_menu") + local Party = require("src.core.game3.party") + local Collision = require("src.core.game3.collision") + local Encounters = require("src.core.game3.encounters") + local Battle = require("src.core.game3.battle") + local Ui = require("src.core.game3.battle.ui") + local Rng = require("src.core.game3.rng") + local Flags = require("src.core.game3.scripting.flags") + local Bridge = require("src.core.game3.bridge") + local SaveData = require("src.core.SaveData") + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the game3 field") then return finish() end + session.party = {} + Party.giveMon(session, 6, 40) + session.money = 5000 + local secret = session.secretId + + local function place(x, y, facing) + Player.moving = false + Player.progress = 0 + Player.cellX, Player.cellY = x, y + Player.px, Player.py = x * 16, y * 16 + Player.targetX, Player.targetY = x, y + Player.facing = facing + if game.session then + game.session.x, game.session.y, game.session.facing = x, y, facing + end + end + + local function goTo(id, x, y, facing) + Map.load(nil, game, id, { x = x, y = y, facing = facing or "down" }) + place(x, y, facing or "down") + U.wait(60) + end + + local function walk(dir) + local sx, sy = Player.cellX, Player.cellY + for _ = 1, 30 do + U.hold(game, dir, 1) + if Player.moving then break end + end + for _ = 1, 90 do + if not Player.moving then break end + U.wait(1) + end + U.wait(2) + return Player.cellX ~= sx or Player.cellY ~= sy + end + + local function mashA(n) + for _ = 1, n or 40 do + U.tap(game, "a") + U.wait(6) + if not (Space.vm and Space.vm:isRunning()) and not Message.isOpen() then break end + end + end + + local function ids() + local t = {} + for _, e in ipairs(StartMenu.ENTRIES or {}) do t[#t + 1] = e.id end + return table.concat(t, ",") + end + + -- pokefirered/data/maps/FuchsiaCity_SafariZone_Entrance/scripts.inc:114 + goTo(ENTRANCE, 4, 6, "up") + walk("up") + walk("up") + walk("up") + mashA(60) + for _ = 1, 60 do + U.wait(4) + if Space.mapId == CENTER then break end + if Space.vm and Space.vm:isRunning() then mashA(20) end + end + if not result(Space.mapId == CENTER, + "paid at the gate and entered the zone, map=" .. tostring(Space.mapId)) then return finish() end + result(Safari.isActive(session) == true, "EnterSafariMode armed safari mode") + result(Safari.balls(session) == 30, "30 SAFARI BALLS (" .. Safari.balls(session) .. ")") + walk("up") + U.wait(60) + + U.tap(game, "start") + U.wait(30) + if not result(StartMenu.isOpen(), "start menu opened in the zone") then return finish() end + -- pokefirered/src/start_menu.c:226 + result(ids() == "retire,pokedex,pokemon,bag,trainer,option,exit", + "safari start menu list (" .. ids() .. ")") + result(not ids():find("save", 1, true), "no SAVE in the zone") + result(StartMenu._safariStats == true, "steps/balls window is up") + U.shot(game, DIR .. "/2422_safari_start_menu_retire_stats.png") + U.tap(game, "b") + U.wait(20) + + local before = SaveData.load() + game:_hotkey("f1") + local after = SaveData.load() + result(not (after and after.map == CENTER) and (after and after.savedAt) == (before and before.savedAt), + "F1 quick-save refused in the zone") + result(game:saveOffered() == false, "mod saveGame gate refuses in the zone") + + -- pokefirered/src/wild_encounter.c:712 + local function grassy(cx, cy) + if Encounters.encounterTypeAt(cx, cy) ~= 1 then return false end + return Collision.isWalkable(cx, cy) and Collision.canEnter(game, cx, cy, {}) ~= false + end + local gx, gy + do + local layout = Map._def and Map._def.midLayout + local bestScore = -1 + for cy = 3, (layout and layout.height or 0) - 4 do + for cx = 3, layout.width - 4 do + if grassy(cx, cy) then + local score = 0 + for dy = -2, 2 do + for dx = -2, 2 do + if grassy(cx + dx, cy + dy) then score = score + 1 end + end + end + if score > bestScore then gx, gy, bestScore = cx, cy, score end + end + end + end + end + if not result(gx ~= nil, "found safari grass") then return finish() end + local dir = grassy(gx + 1, gy) and "right" or "down" + local back = dir == "right" and "left" or "up" + place(gx, gy, dir) + U.wait(20) + Rng.SeedRng(0x2468) + Rng.SeedWildEncounterRng(0x2468) + Encounters.resetRateModifiers() + local d = dir + for _ = 1, 300 do + if Battle.isActive and Battle.isActive() then break end + walk(d) + d = (d == dir) and back or dir + end + if not result(Battle.isActive and Battle.isActive(), "grass encounter started") then return finish() end + local st = Battle.getState() + result(st and st.safari == true, "the encounter is a Safari battle") + local atMenu = false + for _ = 1, 150 do + if Battle._phase == "command" and Ui._mode == "menu" then atMenu = true break end + U.tap(game, "a") + U.wait(6) + end + result(atMenu, "reached the BALL/BAIT/ROCK/RUN menu") + U.shot(game, DIR .. "/2422_safari_battle_menu.png") + for _ = 1, 400 do + if not (Battle.isActive and Battle.isActive()) then break end + if Battle._phase == "command" and Ui._mode == "menu" then + -- pokefirered/src/battle_controller_safari.c:162 + U.tap(game, "right") U.wait(6) + U.tap(game, "down") U.wait(6) + U.tap(game, "a") U.wait(20) + else + U.tap(game, "a") + U.wait(8) + end + end + if not result(not (Battle.isActive and Battle.isActive()), "ran from the safari battle") then + return finish() + end + U.wait(90) + + U.tap(game, "start") + U.wait(30) + StartMenu.cursor = 1 + result(StartMenu.isOpen() and StartMenu.ENTRIES[1].id == "retire", "RETIRE is the first entry") + U.tap(game, "a") + for _ = 1, 60 do + if Choice.active then break end + U.tap(game, "a") + U.wait(8) + end + result(Choice.active and Choice.cursor == 1, "retire asks YES/NO") + U.tap(game, "a") + for _ = 1, 120 do + if Space.mapId == ENTRANCE then break end + U.wait(4) + end + result(Space.mapId == ENTRANCE, "RETIRE warped back to the gate, map=" .. tostring(Space.mapId)) + result(Safari.isActive(session) == false, "safari mode is off after RETIRE") + mashA(60) + U.wait(60) + U.shot(game, DIR .. "/2422_retire_back_at_gate.png") + + goTo(CENTER, 26, 30, "up") + result(Safari.isActive(session) == false, "stranded setup: in the zone with safari mode off") + Bridge.persistSessionOnly(Runtime._mod, game) + result(game:saveGame() ~= false, "wrote a save standing in the zone") + local raw = SaveData.load() + result(type(raw) == "table" and raw.map == CENTER, "the save holds map " .. tostring(raw and raw.map)) + + game:_handleBootAction({ action = "continue" }) + U.wait(60) + for _ = 1, 400 do + if game.phase ~= "quest_log" then break end + U.tap(game, "a") + U.wait(6) + end + U.wait(120) + local loaded = Runtime.getSession() + if not result(loaded ~= nil, "Continue reached the field") then return finish() end + result(loaded.secretId == secret, "secret id came back off the save") + result(Space.mapId == ENTRANCE, "the stranded save resumes at the gate, map=" .. tostring(Space.mapId)) + U.shot(game, DIR .. "/2422_stranded_continue_at_gate.png") + mashA(60) + for _ = 1, 60 do + if Flags.getVar(Space.store, nil, Safari.VAR_ENTRANCE_SCENE) == 0 then break end + mashA(10) + U.wait(4) + end + result(Flags.getVar(Space.store, nil, Safari.VAR_ENTRANCE_SCENE) == 0, "ExitWarpIn finished") + result(Safari.isActive(loaded) == false, "safari mode stays off") + U.wait(30) + local reload = SaveData.load() + result(reload and reload.map == CENTER, "the slot itself is untouched until the next save") + + finish() +end diff --git a/tests/drivers/game3_sign_walk_into.lua b/tests/drivers/game3_sign_walk_into.lua new file mode 100644 index 00000000..26166bdf --- /dev/null +++ b/tests/drivers/game3_sign_walk_into.lua @@ -0,0 +1,114 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_sign_walk_into" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS game3_sign_walk_into") + love.event.quit(0) + else + print("FAIL game3_sign_walk_into failures=" .. failures) + love.event.quit(1) + end +end + +-- data/maps/PalletTown/map.json:158 +local SIGN_X, SIGN_Y = 9, 11 + +return function(game) + for _ = 1, 600 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(180) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Space = require("src.core.game3.scripting.space") + local Player = require("src.core.game3.player") + local Message = package.loaded["src.ui.game3.message"] or require("src.ui.game3.message") + local MapCatalog = require("src.import.gba.map_catalog") + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the field") then return finish() end + local town = MapCatalog.pretToEngine("PalletTown") + + local function placeBelowSign(facing) + Map.load(nil, game, town, { x = SIGN_X, y = SIGN_Y + 1, facing = facing }) + game.session.x, game.session.y, game.session.facing = SIGN_X, SIGN_Y + 1, facing + Player.cellX, Player.cellY = SIGN_X, SIGN_Y + 1 + Player.px, Player.py = SIGN_X * 16, (SIGN_Y + 1) * 16 + Player.targetX, Player.targetY = SIGN_X, SIGN_Y + 1 + Player.facing = facing + U.wait(60) + end + + local function waitSign(frames) + for _ = 1, frames do + if Message.isOpen() and Space.vm:isRunning() then return true end + U.wait(1) + end + return false + end + + print("[driver] 1. walking into the town sign reads it") + placeBelowSign("up") + for _ = 1, 30 do + table.insert(game.input.pressQueue, "up") + game.input.state.up = true + U.wait(1) + if Message.isOpen() then break end + end + game.input.state.up = false + local opened = waitSign(60) + result(opened, "holding UP into the sign opened its message") + result(Player.cellX == SIGN_X and Player.cellY == SIGN_Y + 1, "the player stayed below the sign") + local ctx = Space.vm and Space.vm.ctx + result(ctx and ctx.msgBoxIsCancelable == true, "SetWalkingIntoSignVars made the box cancelable") + if opened then + for _ = 1, 120 do + if not Message.isTyping() then break end + U.wait(1) + end + result(U.shot(game, DIR .. "/spec_sign_walked_into.png"), "screenshot spec_sign_walked_into") + end + + U.wait(12) + U.hold(game, "down", 20) + U.wait(4) + result(not Message.isOpen(), "pressing DOWN closed the sign") + result(not Space.vm:isRunning(), "EventScript_CancelMessageBox ended the sign script") + local walked = false + for _ = 1, 30 do + if Player.cellY > SIGN_Y + 1 then walked = true break end + U.wait(1) + end + result(walked or Player.facing == "down", "and the player turned or walked away (y=" .. tostring(Player.cellY) .. ")") + + print("[driver] 2. an A-button read is not cancelable by walking") + placeBelowSign("up") + U.tap(game, "a") + local readA = waitSign(60) + result(readA, "A opened the sign") + local ctxA = Space.vm and Space.vm.ctx + result(not (ctxA and ctxA.msgBoxIsCancelable), "an A read leaves the box non-cancelable") + U.wait(12) + U.hold(game, "down", 20) + result(Message.isOpen(), "DOWN does not close an A-read sign") + for _ = 1, 200 do + if not Message.isOpen() and not Space.vm:isRunning() then break end + if Message.isOpen() and Message.isTyping() then Message.skipReveal() end + if Message.isOpen() and Message.isWaiting() then U.tap(game, "a") end + U.wait(2) + end + result(not Message.isOpen(), "A closes it") + + return finish() +end diff --git a/tests/drivers/game3_special_trade.lua b/tests/drivers/game3_special_trade.lua index 33af900e..dae8d8cc 100644 --- a/tests/drivers/game3_special_trade.lua +++ b/tests/drivers/game3_special_trade.lua @@ -64,6 +64,7 @@ return function(game) if not result(session ~= nil, "new game reached the game3 field") then return finish() end session.party = {} + require("src.core.game3.scripting.flags").setFlag(require("src.core.game3.scripting.space").store, nil, 0x828, true) -- data/maps/PalletTown_ProfessorOaksLab/scripts.inc:1120 Party.giveMon(session, SPECIES_ABRA, 18) Party.giveMon(session, 4, 14) result(#session.party == 2 and session.party[1].species == SPECIES_ABRA, @@ -146,6 +147,27 @@ return function(game) U.wait(30) result(getVar(0x8004) == 0, "VAR_0x8004 = 0 for slot 1, got " .. tostring(getVar(0x8004))) + local TradeScene = require("src.core.game3.trade_scene") + local sawScene = false + for _ = 1, 600 do + if TradeScene.isOpen() then sawScene = true break end + U.wait(1) + end + result(sawScene, "DoInGameTradeScene started the trade animation") + U.tap(game, "a") + U.wait(4) + + local waited = false + for _ = 1, 3000 do + if TradeScene.phase() == "end_link_trade" then waited = true break end + U.wait(1) + end + result(waited, "the trade animation reaches the A-button wait") + U.wait(60) + result(TradeScene.phase() == "end_link_trade", + "an A pressed mid-animation does not skip the final A wait (trade_scene.c:1772)") + U.shot(game, DIR .. "/special_trade_02b_take_good_care.png") + local swapped = false for _ = 1, 400 do local lead = session.party and session.party[1] @@ -153,6 +175,7 @@ return function(game) swapped = true break end + if TradeScene.phase() == "end_link_trade" then U.tap(game, "a") end U.wait(4) end result(swapped, "DoInGameTradeScene put MR. MIME in the party") diff --git a/tests/drivers/game3_spinda_unown_dex.lua b/tests/drivers/game3_spinda_unown_dex.lua new file mode 100644 index 00000000..16b2d6d1 --- /dev/null +++ b/tests/drivers/game3_spinda_unown_dex.lua @@ -0,0 +1,176 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_spinda_unown_dex" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS game3_spinda_unown_dex") + love.event.quit(0) + else + print("FAIL game3_spinda_unown_dex failures=" .. failures) + love.event.quit(1) + end +end + +local SPINDA, UNOWN = 308, 201 +local P_A, P_B = 0x12345678, 0xFEDCBA98 +local P_UNOWN_D = 0xFC000003 + +local function run(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Pokemon = require("src.core.game3.pokemon") + local Party = require("src.core.game3.party") + local Dex = require("src.core.game3.dex") + local BattleBridge = require("src.core.game3.battle_bridge") + local Battle = require("src.core.game3.battle") + local Anim = require("src.core.game3.battle.anim") + local Ui = require("src.core.game3.battle.ui") + local SummaryMenu = require("src.ui.game3.summary_menu") + local Pokedex = require("src.ui.game3.pokedex") + local FieldEffects = require("src.core.game3.field_effects") + local BattleChrome = require("src.ui.game3.battle_chrome") + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the field") then return end + session.party = {} + Party.giveMon(session, 6, 50) + + local rgbaA, rgbaB = Pokemon.spindaRgba(P_A, false), Pokemon.spindaRgba(P_B, false) + result(rgbaA and rgbaB and #rgbaA == 64 * 64 * 4 and rgbaA ~= rgbaB, + "two Spinda personalities bake different spot patterns") + result(FieldEffects.loadSheet("ss_anne_wake", 16, 32, 2) ~= nil + and FieldEffects.loadSheet("ss_anne_smoke", 16, 16, 4) ~= nil, "SS Anne wake/smoke sheets come from the cache") + result(BattleChrome.hasHpBoldDigits(), "bold HP digits come from the cache") + + local function wildBattle(species, personality, shotName) + local ok, err = BattleBridge.startWild(Runtime._mod, game, + { species = species, level = 10, personality = personality }, { fade = false }) + if not result(ok == true, "wild battle started " .. tostring(err or "")) then return nil end + local ready = false + for _ = 1, 3000 do + if Ui._mode == "menu" and not Anim.busy() then ready = true break end + if Ui.dialogPending and Ui.dialogPending() then U.tap(game, "a") else U.wait(1) end + end + result(ready, "battle reached the action menu") + U.wait(20) + local st = Battle._st + local enemy = st and st.enemy + local entry = Ui.battlerPic("enemy", enemy) + local want = Pokemon.frontPic(Pokemon.picSpecies(species, personality), 0, + Pokemon.isShiny(enemy and enemy.mon), personality) + result(enemy and enemy.mon and enemy.mon.personality == personality, + string.format("foe personality is 0x%08X", personality)) + result(entry and want and entry.image == want.image, + string.format("battle foe pic is the personality 0x%08X pic", personality)) + if shotName then U.shot(game, DIR .. "/" .. shotName) end + BattleBridge.finishPending("run") + U.wait(40) + return entry + end + + local eA = wildBattle(SPINDA, P_A, "r3_spinda_battle_12345678.png") + local eB = wildBattle(SPINDA, P_B, "r3_spinda_battle_FEDCBA98.png") + result(eA and eB and eA.image ~= eB.image, "battle Spinda pics differ between personalities") + result(session.dex.spindaPersonality == P_A, + "pokedex keeps the first-seen Spinda personality (pokemon.c:6243)") + + wildBattle(UNOWN, P_UNOWN_D, nil) + result(session.dex.unownPersonality == P_UNOWN_D, "pokedex records the first-seen Unown personality") + + session.party = {} + Party.giveMon(session, SPINDA, 20) + Party.giveMon(session, SPINDA, 20) + session.party[1].personality, session.party[1].isShiny = P_A, nil + session.party[2].personality, session.party[2].isShiny = P_B, nil + local sA, sB = Pokemon.monFrontPic(session.party[1]), Pokemon.monFrontPic(session.party[2]) + result(sA and sB and sA.image ~= sB.image, "summary Spinda pics differ between personalities") + result(sA and eA and sA.image == eA.image, "summary and battle share the personality 0x12345678 pic") + + SummaryMenu.openMenu(session.party, 1, { session = session }) + U.wait(90) + U.shot(game, DIR .. "/r3_spinda_summary_12345678.png") + SummaryMenu.close() + U.wait(20) + SummaryMenu.openMenu(session.party, 2, { session = session }) + U.wait(90) + U.shot(game, DIR .. "/r3_spinda_summary_FEDCBA98.png") + SummaryMenu.close() + U.wait(20) + + local dexPic = Pokemon.dexFrontPic(UNOWN, Dex.defaultPersonality(session.dex, UNOWN)) + local unownD = Pokemon.frontPic(415) + local unownA = Pokemon.frontPic(UNOWN) + result(dexPic and unownD and dexPic.image == unownD.image and dexPic.image ~= unownA.image, + "pokedex Unown pic is the recorded letter D") + Pokedex.show(session.dex, { session = session, mode = "national" }) + U.wait(20) + Pokedex.selectedSpecies = UNOWN + Pokedex.screen = "data" + Pokedex.dataPage = 1 + Pokedex.page = "entry" + U.wait(30) + result(Pokedex.screen == "data", "dex entry page open on UNOWN") + U.shot(game, DIR .. "/r3_dex_unown_d_entry.png") + local FrlgFont = require("src.ui.game3.frlg_font") + local PokedexData = require("src.core.game3.pokedex_data") + local drawn = {} + local realDraw = FrlgFont.draw + FrlgFont.draw = function(text, ...) + drawn[#drawn + 1] = tostring(text) + return realDraw(text, ...) + end + local function pageShows(needle) + for _, s in ipairs(drawn) do + if s:find(needle, 1, true) then return true end + end + return false + end + local function showEntry(sp, shotName) + Pokedex.selectedSpecies = sp + U.wait(10) + drawn = {} + U.wait(20) + U.shot(game, DIR .. "/" .. shotName) + end + + local MUDKIP = Pokemon.speciesFromNational(258) + Party.giveMon(session, MUDKIP, 20) + result(Dex.isCaught(session.dex, SPINDA) and Dex.isCaught(session.dex, MUDKIP), "Spinda and Mudkip are caught") + local natList = PokedexData.getOrderList("numerical_national", session.dex) + result(natList[327] == SPINDA and natList[258] == MUDKIP, + "national list slot 327 is Spinda and slot 258 is Mudkip") + + showEntry(SPINDA, "r3_dex_spinda_entry.png") + result(pageShows("№327"), "Spinda page shows No327") + result(not pageShows("№308"), "Spinda page does not show the internal id 308") + result(pageShows("SPOT PANDA POKéMON"), "Spinda page shows SPOT PANDA") + result(pageShows("11.0 lbs."), "Spinda page shows 11.0 lbs") + result(pageShows("No two SPINDA"), "Spinda page shows the Spinda flavor text") + + showEntry(MUDKIP, "r3_dex_mudkip_entry.png") + result(pageShows("№258"), "Mudkip page shows No258") + result(pageShows("MUD FISH POKéMON"), "Mudkip page shows MUD FISH") + result(pageShows("16.8 lbs."), "Mudkip page shows 16.8 lbs") + FrlgFont.draw = realDraw + Pokedex.close() + U.wait(10) +end + +return function(game) + local ok, err = pcall(run, game) + if not ok then result(false, "driver error: " .. tostring(err)) end + finish() +end diff --git a/tests/drivers/game3_std_item_text.lua b/tests/drivers/game3_std_item_text.lua new file mode 100644 index 00000000..a54c8ee5 --- /dev/null +++ b/tests/drivers/game3_std_item_text.lua @@ -0,0 +1,127 @@ +-- pokefirered/data/scripts/obtain_item.inc:10 +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_std_item_text" + +local ITEM_POTION = 13 -- include/constants/items.h:17 + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS game3_std_item_text") + love.event.quit(0) + else + print("FAIL game3_std_item_text failures=" .. failures) + love.event.quit(1) + end +end + +local function flat(s) + return (tostring(s or ""):gsub("[\n\f]", " ")) +end + +local function waitPage(game, pattern, frames) + local Message = require("src.ui.game3.message") + local seen = {} + for _ = 1, frames do + if Message.isOpen() then + local page = flat(Message.currentPage()) + if page:find(pattern, 1, true) then + while not Message.isWaiting() do U.wait(1) end + return true, page + end + if Message.isWaiting() then + if seen[#seen] ~= page then seen[#seen + 1] = page end + U.tap(game, "a") + end + end + U.wait(1) + end + return false, table.concat(seen, " | ") +end + +local function shootPrinted(game, pattern, frames, path) + local Message = require("src.ui.game3.message") + for _ = 1, frames do + if Message.isOpen() and flat(Message.currentPage()):find(pattern, 1, true) then + local lastGlyph = (Message._revealed or 0) == (Message._total or 0) - 1 and (Message._delay or 0) == 0 + if lastGlyph and not Message._waiting then + U.shot(game, path) + return true, flat(Message.currentPage()) + end + end + U.wait(1) + end + return false, flat(Message.currentPage()) +end + +local function waitIdle(game, frames) + local Message = require("src.ui.game3.message") + local Space = require("src.core.game3.scripting.space") + for _ = 1, frames do + local running = Space.vm and Space.vm:isRunning() + if not running and not Message.isOpen() then return true end + if Message.isWaiting() then U.tap(game, "a") end + U.wait(1) + end + return false +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local MapCatalog = require("src.import.gba.map_catalog") + local Space = require("src.core.game3.scripting.space") + local Bag = require("src.core.game3.bag") + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the game3 field") then return finish() end + + local MT_MOON = MapCatalog.pretToEngine("MtMoon_1F") + if not result(type(MT_MOON) == "string", "Mt Moon 1F resolves to a cache map id") then + return finish() + end + Map.load(nil, game, MT_MOON, { x = 11, y = 36, facing = "up" }) + game.session.x, game.session.y, game.session.facing = 11, 36, "up" + U.wait(90) + + U.tap(game, "a") + local ok, page = waitPage(game, "found a TM09! It contains BULLET SEED.", 600) + result(ok, "TM ball: \"RED found a TM09! It contains BULLET SEED.\" (got " .. page .. ")") + if ok then U.shot(game, DIR .. "/txt_tm09_found_contains_move.png") end + U.tap(game, "a") + ok, page = waitPage(game, "RED put the TM09 in the TM CASE.", 600) + result(ok, "TM ball: \"RED put the TM09 in the TM CASE.\" (got " .. page .. ")") + if ok then U.shot(game, DIR .. "/txt_tm09_put_in_tm_case.png") end + waitIdle(game, 300) + + Space.vm.scripts.Driver_GiveItem = { + { op = "setorcopyvar", [1] = 0x8000, [2] = ITEM_POTION }, + { op = "setorcopyvar", [1] = 0x8001, [2] = 1 }, + { op = "callstd", std = 0 }, + { op = "end" }, + } + local before = Bag.get(session.bag, ITEM_POTION) or 0 + Space.startScript("Driver_GiveItem") + ok, page = shootPrinted(game, "Obtained the POTION!", 600, DIR .. "/txt_giveitem_obtained_the_potion.png") + result(ok, "giveitem: \"Obtained the POTION!\" fully printed (got " .. page .. ")") + ok, page = waitPage(game, "RED put the POTION in the ITEMS POCKET.", 600) + result(ok, "giveitem: \"RED put the POTION in the ITEMS POCKET.\" (got " .. page .. ")") + if ok then U.shot(game, DIR .. "/txt_giveitem_put_in_items_pocket.png") end + waitIdle(game, 300) + result((Bag.get(session.bag, ITEM_POTION) or 0) == before + 1, "giveitem added one POTION") + + finish() +end diff --git a/tests/drivers/game3_stitchcoll_fall_shake.lua b/tests/drivers/game3_stitchcoll_fall_shake.lua index df31b588..efe37816 100644 --- a/tests/drivers/game3_stitchcoll_fall_shake.lua +++ b/tests/drivers/game3_stitchcoll_fall_shake.lua @@ -81,8 +81,22 @@ return function(game) "standing on the cracking ice at (" .. Player.cellX .. "," .. Player.cellY .. ")") U.shot(game, DIR .. "/stitchcoll_fall_shake_01_on_the_ice.png") - -- pokefirered/src/field_tasks.c:243 IcefallCaveIcePerStepCallback - Flags.setVar(Space.store, ctx(), VAR_TEMP_1, 1) + -- pokefirered/src/field_tasks.c:173 IcefallCaveIcePerStepCallback + U.wait(12) + while Player.cellY > 13 do + if not step("up") then break end + end + U.wait(8) + while Player.cellY < 14 do + if not step("down") then break end + end + result(Player.cellX == 8 and Player.cellY == 14, "stepped back onto the cracked ice") + for _ = 1, 30 do + if (tonumber(Flags.getVar(Space.store, ctx(), VAR_TEMP_1)) or 0) == 1 then break end + U.wait(1) + end + result((tonumber(Flags.getVar(Space.store, ctx(), VAR_TEMP_1)) or 0) == 1, + "breaking the ice set VAR_TEMP_1") -- pokefirered/src/field_control_avatar.c:212 TryRunOnFrameMapScript local started = false @@ -93,17 +107,6 @@ return function(game) break end end - if not started then - print("[driver] the ON_FRAME poll did not claim the frame on its own, nudging once") - Space.scheduleOnFrame(game and (game.overworld or game.world)) - for _ = 1, 120 do - U.wait(1) - if Space.vm and Space.vm:isRunning() then - started = true - break - end - end - end result(started, "the hole script started") -- pokefirered/src/field_effect.c:1249 FallWarpEffect_5 diff --git a/tests/drivers/game3_stitchfield_movement.lua b/tests/drivers/game3_stitchfield_movement.lua index 4fbdad32..52d0dc13 100644 --- a/tests/drivers/game3_stitchfield_movement.lua +++ b/tests/drivers/game3_stitchfield_movement.lua @@ -100,6 +100,8 @@ return function(game) U.shot(game, DIR .. "/stitchfield_movement_02_player_running_off.png") end if not (Space.vm and Space.vm:isRunning()) then break end + -- data/maps/IndigoPlateau_Exterior/scripts.inc:77 + if Flags.getVar(Space.store, Space.vm.ctx, VAR_MAP_SCENE_INDIGO_PLATEAU_EXTERIOR) == 0 then break end U.wait(1) end diff --git a/tests/drivers/game3_stitchimp_heal_locations.lua b/tests/drivers/game3_stitchimp_heal_locations.lua index c8487345..21b5f3cc 100644 --- a/tests/drivers/game3_stitchimp_heal_locations.lua +++ b/tests/drivers/game3_stitchimp_heal_locations.lua @@ -69,7 +69,7 @@ return function(game) if type(mon) == "table" then mon.hp = 0 end end BattleBridge._whiteoutHook() - U.wait(180) + U.wait(2) print(string.format("[driver] %s map=%s at (%s,%s) lastTalked=%s", label, tostring(Map.current), tostring(Player.cellX), tostring(Player.cellY), tostring(session.healHealerLocalId))) @@ -77,6 +77,7 @@ return function(game) result(Map.currentDef() ~= nil, label .. ": the respawn map has a live def") result(Player.cellX == x and Player.cellY == y, label .. ": the player stands at (" .. x .. "," .. y .. ")") + U.clearWhiteoutRush(game) if shot then result(U.shot(game, DIR .. "/" .. shot), label .. ": screenshot") end end diff --git a/tests/drivers/game3_stitchmap_heal.lua b/tests/drivers/game3_stitchmap_heal.lua index a3a9434c..17097460 100644 --- a/tests/drivers/game3_stitchmap_heal.lua +++ b/tests/drivers/game3_stitchmap_heal.lua @@ -67,7 +67,7 @@ return function(game) local fainted = faintParty() result(fainted > 0, label .. ": party is fainted before the whiteout") BattleBridge._whiteoutHook() - U.wait(150) + U.wait(2) print(string.format("[driver] %s map=%s at (%s,%s)", label, tostring(Map.current), tostring(Player.cellX), tostring(Player.cellY))) result(Map.current == want, label .. ": whiteout loaded " .. tostring(want)) @@ -78,6 +78,7 @@ return function(game) if type(mon) == "table" and (mon.hp or 0) <= 0 then healed = false end end result(healed, label .. ": the party was healed on arrival") + U.clearWhiteoutRush(game) if shot then result(U.shot(game, DIR .. "/" .. shot), label .. ": screenshot") end end diff --git a/tests/drivers/game3_stitchmap_items.lua b/tests/drivers/game3_stitchmap_items.lua index 0c6560f3..bbc629ce 100644 --- a/tests/drivers/game3_stitchmap_items.lua +++ b/tests/drivers/game3_stitchmap_items.lua @@ -82,6 +82,7 @@ return function(game) Bag.Coins.set(session, 2750) result(Bag.Coins.get(session) == 2750, "the player has 2750 coins") + require("src.core.game3.scripting.flags").setFlag(require("src.core.game3.scripting.space").store, nil, 0x828, true) -- data/maps/PalletTown_ProfessorOaksLab/scripts.inc:1120 if #(session.party or {}) == 0 then Party.giveMon(session, 4, 8) end diff --git a/tests/drivers/game3_stitchsave_continue.lua b/tests/drivers/game3_stitchsave_continue.lua deleted file mode 100644 index c44dcfb5..00000000 --- a/tests/drivers/game3_stitchsave_continue.lua +++ /dev/null @@ -1,196 +0,0 @@ -local U = require("tests.drivers.util") -local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_stitchsave_continue" - --- pokefirered/data/maps/FuchsiaCity_SafariZone_Entrance/scripts.inc:82 -local ENTRANCE = "FR_FUCHSIA_CITY_SAFARI_ZONE_ENTRANCE" -local CENTER = "FR_SAFARI_ZONE_CENTER" - -local failures = 0 -local function result(ok, label) - print((ok and "PASS " or "FAIL ") .. label) - if not ok then failures = failures + 1 end - return ok -end - -local function finish() - if failures == 0 then - print("PASS stitchsave_continue") - love.event.quit(0) - else - print("FAIL stitchsave_continue failures=" .. failures) - love.event.quit(1) - end -end - -return function(game) - for _ = 1, 900 do - if game.phase == "boot" and game.boot then break end - U.wait(1) - end - - game:_handleBootAction({ action = "new_game", name = "RED" }) - U.wait(240) - - local Runtime = require("src.core.game3.runtime") - local Map = require("src.core.game3.map") - local Space = require("src.core.game3.scripting.space") - local Player = require("src.core.game3.player") - local Safari = require("src.core.game3.safari") - local Message = require("src.ui.game3.message") - local StartMenu = require("src.ui.game3.start_menu") - local SaveMenu = require("src.ui.game3.save_menu") - local SaveData = require("src.core.SaveData") - local Warp = require("src.core.game3.warp") - - local session = Runtime.getSession() - if not result(session ~= nil, "new game reached the game3 field") then return finish() end - - -- pokefirered/src/new_game.c:56 InitPlayerTrainerId - local secret = session.secretId - result(type(secret) == "number" and secret >= 0 and secret < 0x10000, - "New Game rolled a 16 bit secret id (" .. tostring(secret) .. ")") - - local function place(x, y, facing) - Player.moving = false - Player.progress = 0 - Player.cellX, Player.cellY = x, y - Player.px, Player.py = x * 16, y * 16 - Player.targetX, Player.targetY = x, y - Player.facing = facing - if game.session then - game.session.x, game.session.y, game.session.facing = x, y, facing - end - end - - local function goTo(id, x, y, facing) - Map.load(nil, game, id, { x = x, y = y, facing = facing or "down" }) - place(x, y, facing or "down") - U.wait(60) - end - - local function walk(dir) - local sx, sy = Player.cellX, Player.cellY - for _ = 1, 30 do - U.hold(game, dir, 1) - if Player.moving then break end - end - for _ = 1, 90 do - if not Player.moving then break end - U.wait(1) - end - U.wait(2) - return Player.cellX ~= sx or Player.cellY ~= sy - end - - local function mashA(n) - for _ = 1, n or 40 do - U.tap(game, "a") - U.wait(6) - if not (Space.vm and Space.vm:isRunning()) and not Message.isOpen() then break end - end - end - - -- pokefirered/data/maps/FuchsiaCity_SafariZone_Entrance/scripts.inc:114 - goTo(ENTRANCE, 4, 6, "up") - walk("up") - walk("up") - walk("up") - mashA(60) - for _ = 1, 60 do - U.wait(4) - if Space.mapId == CENTER then break end - if Space.vm and Space.vm:isRunning() then mashA(20) end - end - if not result(Space.mapId == CENTER, - "inside the Safari Zone, map=" .. tostring(Space.mapId)) then return finish() end - result(Safari.isActive(session) == true, "EnterSafariMode armed safari mode") - result(Safari.balls(session) == 30, "30 SAFARI BALLS (" .. Safari.balls(session) .. ")") - walk("up") - walk("down") - U.wait(120) - U.shot(game, DIR .. "/stitchsave_01_in_the_zone.png") - - U.tap(game, "start") - U.wait(30) - if not result(StartMenu.isOpen(), "start menu opened") then return finish() end - local saveIdx - for i, e in ipairs(StartMenu.ENTRIES or {}) do - if e.id == "save" then saveIdx = i break end - end - if not result(saveIdx ~= nil, "SAVE is on the start menu") then return finish() end - for _ = 1, 20 do - if StartMenu.cursor == saveIdx then break end - U.tap(game, "down") - U.wait(6) - end - U.tap(game, "a") - U.wait(40) - if not result(SaveMenu.isOpen(), "the save dialog opened") then return finish() end - for _ = 1, 8 do - if SaveMenu._phase == "saved" then break end - U.tap(game, "a") - U.wait(30) - end - if not result(SaveMenu._phase == "saved", - "the save was written, phase=" .. tostring(SaveMenu._phase)) then return finish() end - U.shot(game, DIR .. "/stitchsave_02_saved_in_the_zone.png") - mashA(10) - U.wait(30) - - local raw = SaveData.load() - if not result(type(raw) == "table" and raw.engine == "game3", - "the slot holds a game3 save") then return finish() end - result(raw.secretId == secret, - "the save block carries the secret id (" .. tostring(raw.secretId) .. ")") - -- pokefirered/src/safari_zone.c:9 - result(raw.safari == nil, "and no safari counters") - result(raw.flags[tostring(Safari.FLAG_SYS_SAFARI_MODE)] == true - or raw.flags[Safari.FLAG_SYS_SAFARI_MODE] == true, - "FLAG_SYS_SAFARI_MODE rides the saved flag store") - - game:_handleBootAction({ action = "continue" }) - U.wait(60) - -- pokefirered/src/quest_log.c:448 TryStartQuestLogPlayback - for _ = 1, 400 do - if game.phase ~= "quest_log" then break end - U.tap(game, "a") - U.wait(6) - end - result(game.phase ~= "quest_log", "the Continue recap finished, phase=" .. tostring(game.phase)) - U.wait(180) - for _ = 1, 120 do - if Space.mapId == CENTER then break end - U.wait(4) - end - - local loaded = Runtime.getSession() - if not result(loaded ~= nil, "Continue reached the field") then return finish() end - result(loaded.secretId == secret, - "the secret id came back off the save (" .. tostring(loaded.secretId) .. ")") - -- pokefirered/src/overworld.c:1695 CB2_ContinueSavedGame - local Flags = require("src.core.game3.scripting.flags") - result(Flags.getFlag(loaded, nil, Safari.FLAG_SYS_SAFARI_MODE) == false, - "raw FLAG_SYS_SAFARI_MODE is clear before anyone asks Safari.isActive") - result(Flags.getVar(loaded, nil, Safari.VAR_ENTRANCE_SCENE) == 0, "entrance scene var is 0") - result(Warp._pending == nil, "no warp pending right after Continue") - result(Safari.isActive(loaded) == false, "ResetSafariZoneFlag_ cleared safari mode") - result(Safari.balls(loaded) == 0, "no free thirty balls on Continue") - result(Safari.steps(loaded) == 0, "and no step counter") - result(Space.mapId == CENTER, - "still standing where we saved, map=" .. tostring(Space.mapId)) - U.wait(120) - U.shot(game, DIR .. "/stitchsave_03_after_continue.png") - - local moved = 0 - for _ = 1, 4 do - if walk("up") then moved = moved + 1 end - end - result(moved > 0, "walked " .. moved .. " steps after Continue") - result(Safari.isActive(loaded) == false, "walking did not re-arm safari mode") - result(Warp._pending == nil, "and no PA announcement ejected the player") - result(Space.mapId == CENTER, "map is still " .. tostring(Space.mapId)) - U.wait(60) - U.shot(game, DIR .. "/stitchsave_04_walked_after_continue.png") - - finish() -end diff --git a/tests/drivers/game3_stitchsave_summary.lua b/tests/drivers/game3_stitchsave_summary.lua index d62bf71d..342f1df5 100644 --- a/tests/drivers/game3_stitchsave_summary.lua +++ b/tests/drivers/game3_stitchsave_summary.lua @@ -74,6 +74,7 @@ local function run(game) result(tonumber(sec) ~= nil, "Route 1 resolves to a region map section (" .. tostring(sec) .. ")") session.party = {} + require("src.core.game3.scripting.flags").setFlag(require("src.core.game3.scripting.space").store, nil, 0x828, true) -- data/maps/PalletTown_ProfessorOaksLab/scripts.inc:1120 -- pokefirered/src/script_pokemon_util.c:48 ScriptGiveMon local code, gift = Party.giveMonToPlayer(session, 106, 25, "") if not result(code == Party.MON_GIVEN_TO_PARTY and gift ~= nil, "the script gift reached the party") then diff --git a/tests/drivers/game3_stitchuid_summary_dexno.lua b/tests/drivers/game3_stitchuid_summary_dexno.lua index f0bb24f1..f2238c9e 100644 --- a/tests/drivers/game3_stitchuid_summary_dexno.lua +++ b/tests/drivers/game3_stitchuid_summary_dexno.lua @@ -69,6 +69,7 @@ local function run(game) end session.party = {} + require("src.core.game3.scripting.flags").setFlag(require("src.core.game3.scripting.space").store, nil, 0x828, true) -- data/maps/PalletTown_ProfessorOaksLab/scripts.inc:1120 Party.giveMon(session, WURMPLE, 20) result(Pokemon.national(WURMPLE) == 265, "the cache maps species 290 to national 265") diff --git a/tests/drivers/game3_strength_push.lua b/tests/drivers/game3_strength_push.lua new file mode 100644 index 00000000..cb6fcb97 --- /dev/null +++ b/tests/drivers/game3_strength_push.lua @@ -0,0 +1,169 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_strength_push" + +local SE_FALL, SE_M_STRENGTH = 37, 207 +-- pokefirered/include/constants/vars.h:152 +local VAR_MAP_SCENE_VICTORY_ROAD_1F = 0x4064 + +local failures = 0 +local function result(ok, label, detail) + print((ok and "PASS " or "FAIL ") .. label .. (detail and (" " .. detail) or "")) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS strength_push") + love.event.quit(0) + else + print("FAIL strength_push failures=" .. failures) + love.event.quit(1) + end +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Objects = require("src.core.game3.objects") + local Audio = require("src.core.game3.audio") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local FieldMoves = require("src.core.game3.field_moves") + local FieldEffects = require("src.core.game3.field_effects") + local Collision = require("src.core.game3.collision") + + local session = Runtime.getSession() + if not result(session ~= nil, "new_game_reached_field") then return finish() end + session.repelSteps = 250 + + local seLog = {} + local origSe = Audio.playSe + Audio.playSe = function(id, ...) + seLog[#seLog + 1] = id + return origSe(id, ...) + end + + local function ctx() return Space.vm and Space.vm.ctx end + + local function goTo(mapId, x, y, facing) + Map.load(nil, game, mapId, { x = x, y = y, facing = facing }) + if game.session then game.session.x, game.session.y, game.session.facing = x, y, facing end + Player.moving = false + Player.cellX, Player.cellY = x, y + Player.px, Player.py = x * 16, y * 16 + Player.targetX, Player.targetY = x, y + Player.facing = facing + U.wait(60) + Flags.setFlag(Space.store, ctx(), FieldMoves.SYS_FLAGS.USE_STRENGTH, true) + end + + local function hasDust() + for _, a in ipairs(FieldEffects._anims or {}) do + if a.kind == "dust" then return true end + end + return false + end + + local function holdUntilPush(dir) + for _ = 1, 20 do + U.hold(game, dir, 1) + if Player.boulderPush then return true end + end + return false + end + + goTo("FR_SEAFOAM_ISLANDS_B3F", 6, 16, "down") + local b = Objects.find(6) + result(b ~= nil and b.cellX == 6 and b.cellY == 17, "seafoam_boulder_at_6_17") + if not b then return finish() end + seLog = {} + local startPy = b.py + result(holdUntilPush("down"), "seafoam_push_started") + result(seLog[1] == SE_M_STRENGTH, "seafoam_se_strength_on_push", "se=" .. table.concat(seLog, ",")) + result(hasDust(), "seafoam_dust_started") + result(Player.cellX == 6 and Player.cellY == 16 and not Player.moving, "seafoam_player_walks_in_place") + U.wait(15) + local midPy = b.py + result(b.moving and midPy > startPy and midPy < startPy + 16 and b.visible ~= false, + "seafoam_boulder_mid_slide", string.format("py %s -> %s", tostring(startPy), tostring(midPy))) + result(seLog[2] == nil, "seafoam_no_fall_before_slide_ends", "se=" .. table.concat(seLog, ",")) + U.shot(game, DIR .. "/2424_seafoam_boulder_mid_slide.png") + for _ = 1, 40 do + if not Player.boulderPush then break end + U.wait(1) + end + result(Player.boulderPush == nil, "seafoam_push_finished") + result(seLog[1] == SE_M_STRENGTH and seLog[#seLog] == SE_FALL, + "seafoam_se_207_then_fall", "se=" .. table.concat(seLog, ",")) + result(b.visible == false or b.hidden == true, "seafoam_boulder_gone_into_hole") + result(Player.cellX == 6 and Player.cellY == 16, "seafoam_player_never_stepped") + U.shot(game, DIR .. "/2424_seafoam_boulder_fell.png") + + goTo("FR_VICTORY_ROAD_1F", 20, 14, "down") + local vb + for _, lid in ipairs({ 5, 6, 7 }) do + local eo = Objects.find(lid) + if eo and eo.graphicsId == FieldMoves.GFX_IDS.PUSHABLE_BOULDER then vb = vb or eo end + end + if not result(vb ~= nil, "vr_boulder_found") then return finish() end + Objects.setObjectXY(vb.localId, 20, 15) + U.wait(2) + seLog = {} + result(holdUntilPush("down"), "vr_push_started") + U.wait(10) + result(Flags.getVar(Space.store, ctx(), VAR_MAP_SCENE_VICTORY_ROAD_1F) ~= 100 + and not (Space.vm and Space.vm:isRunning()), + "vr_switch_not_fired_mid_slide") + for _ = 1, 40 do + if not Player.boulderPush then break end + U.wait(1) + end + for _ = 1, 900 do + if not (Space.vm and Space.vm:isRunning()) then break end + U.wait(1) + end + U.wait(30) + result(vb.cellX == 20 and vb.cellY == 16, "vr_boulder_on_switch") + result(Flags.getVar(Space.store, ctx(), VAR_MAP_SCENE_VICTORY_ROAD_1F) == 100, + "vr_switch_fires_after_slide", + "var=" .. tostring(Flags.getVar(Space.store, ctx(), VAR_MAP_SCENE_VICTORY_ROAD_1F))) + result(seLog[1] == SE_M_STRENGTH, "vr_se_strength", "se=" .. table.concat(seLog, ",")) + U.shot(game, DIR .. "/2424_vr_switch_pressed.png") + + goTo("FR_ROUTE_1", 5, 5, "down") + local lx, ly + for y = 1, 60 do + for x = 1, 40 do + if not lx and Collision.canEnter(game, x, y, {}) + and Collision.ledgeLanding(game, x, y, "down") then + lx, ly = x, y + end + end + end + if not result(lx ~= nil, "ledge_found", tostring(lx) .. "," .. tostring(ly)) then return finish() end + goTo("FR_ROUTE_1", lx, ly, "down") + local jumped = false + for _ = 1, 20 do + U.hold(game, "down", 1) + if Player.jumping then jumped = true break end + end + result(jumped, "ledge_jump_started") + for _ = 1, 40 do + if not Player.moving then break end + U.wait(1) + end + U.wait(3) + result(hasDust(), "ledge_landing_dust") + U.shot(game, DIR .. "/2424_ledge_landing_dust.png") + + finish() +end diff --git a/tests/drivers/game3_surf_connections.lua b/tests/drivers/game3_surf_connections.lua index a231f33d..87018211 100644 --- a/tests/drivers/game3_surf_connections.lua +++ b/tests/drivers/game3_surf_connections.lua @@ -146,16 +146,16 @@ return function(game) end end for _ = 1, 180 do - if Player.cellX == 72 and Player.cellY == 15 then break end - local dir = pathTo(72, 15) + if Player.cellX == 60 and Player.cellY == 9 then break end + local dir = pathTo(60, 9) if not dir then result(false, "seafoam_path_available"); return finish() end if not step(dir, true) then U.wait(16) end if Map.current ~= "FR_ROUTE_20" then result(false, "seafoam_path_stayed_on_route20"); return finish() end end - if not result(Map.current == "FR_ROUTE_20" and Player.cellX == 72 and Player.cellY == 15 - and not Player.surfing and Player.elevation == 3, "seafoam_east_landing_reached_by_input") then return finish() end + if not result(Map.current == "FR_ROUTE_20" and Player.cellX == 60 and Player.cellY == 9 + and not Player.surfing and Player.elevation == 3, "seafoam_west_landing_reached_by_input") then return finish() end U.wait(45) - if not shot("04_seafoam_east_landing") then return finish() end + if not shot("04_seafoam_west_landing") then return finish() end step("up") for _ = 1, 240 do if Map.current == "FR_SEAFOAM_ISLANDS_1F" and not require("src.core.game3.warp").isBusy() then break end @@ -163,6 +163,6 @@ return function(game) end if not result(Map.current == "FR_SEAFOAM_ISLANDS_1F", "seafoam_entrance_reached_by_input") then return finish() end U.wait(90) - shot("05_inside_seafoam_east_entrance") + shot("05_inside_seafoam_west_entrance") finish() end diff --git a/tests/drivers/game3_surf_music_2423.lua b/tests/drivers/game3_surf_music_2423.lua new file mode 100644 index 00000000..3f21a24d --- /dev/null +++ b/tests/drivers/game3_surf_music_2423.lua @@ -0,0 +1,162 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_surf_music_2423" +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end +local function finish() + result(failures == 0, "game3_surf_music_2423") + love.event.quit(failures == 0 and 0 or 1) +end + +return function(game) + for _ = 1, 600 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + if not result(game.phase == "boot" and game.boot ~= nil, "boot_ready") then return finish() end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(180) + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Collision = require("src.core.game3.collision") + local Audio = require("src.core.game3.audio") + local Field = require("src.core.game3.field") + local ItemUse = require("src.core.game3.item_use") + local Party = require("src.core.game3.party") + local ShowMon = require("src.core.game3.field_move_show_mon") + local BattleBridge = require("src.core.game3.battle_bridge") + local Battle = require("src.core.game3.battle") + require("src.core.game3.encounters").onStep = function() return nil end + require("src.core.game3.trainer_sight").check = function() return false end + local session = Runtime.getSession() + if not result(session ~= nil, "new_game_reached_field") then return finish() end + session.party = {} + Party.giveMon(session, 131, 40) + local mon = session.party[1] + + local function song() return Audio._currentSong and Audio._currentSong.id end + local function waitSong(id, frames) + for _ = 1, frames or 400 do + if song() == id and not Audio._fadeOut then return true end + U.wait(1) + end + return song() == id + end + local function place(map, x, y, facing, surfing) + Player.surfing = false + Map.load(Runtime._mod, game, map, { x = x, y = y, facing = facing }) + if surfing then Player.surfing, Player.elevation = true, 1 end + U.wait(60) + end + local function settle() + for _ = 1, 120 do + if not Player.moving and not Field.locked then break end + U.wait(1) + end + end + + place("FR_ROUTE_19", 7, 11, "left", true) + result(Collision.isWater(7, 11) and not Collision.isWater(6, 11), "route19_rock_cell_not_water") + for _ = 1, 20 do U.hold(game, "left", 1) end + settle() + result(Player.cellX == 7 and Player.cellY == 11 and Player.surfing, "2423_surf_into_rock_blocked") + U.shot(game, DIR .. "/2423_rock_blocked.png") + Player.facing = "left" + result(ItemUse.canFish() == false, "2423_fishing_refuses_rock") + Player.facing = "down" + result(ItemUse.canFish() == true, "2423_fishing_open_water_ok") + + place("FR_ROUTE_19", 9, 12, "left", false) + local mapSong = Audio._mapSong + result(mapSong ~= nil and mapSong ~= Audio.MUS_SURF and song() == mapSong, "route19_map_song_" .. tostring(mapSong)) + + Field.executeFieldMove({ action = "surf", mon = mon }) + local shot = false + for _ = 1, 400 do + local fx = ShowMon._fx + if fx and fx.sprite and fx.sprite.state == "wait" and not shot then + shot = U.shot(game, DIR .. "/2423_surf_show_mon.png") + end + if not ShowMon.isActive() and Player.surfing and not Player.moving then break end + U.wait(1) + end + result(shot, "2423_surf_show_mon_shot") + settle() + result(Player.surfing and Player.cellX == 8 and Player.cellY == 12, "surf_started") + result(waitSong(Audio.MUS_SURF), "2423_surf_music_305 (" .. tostring(song()) .. ")") + result(Audio._mapSong == mapSong, "2423_map_song_kept") + + local ok = BattleBridge.startWild(Runtime._mod, game, { species = 129, level = 5 }, { fade = false }) + result(ok == true, "wild_battle_on_water_started") + for _ = 1, 240 do + if Battle.isActive() then break end + U.wait(1) + end + U.wait(60) + BattleBridge.finishPending("run") + for _ = 1, 240 do + if not Battle.isActive() then break end + U.wait(1) + end + U.wait(30) + result(waitSong(Audio.MUS_SURF, 120), "2423_battle_on_water_back_to_305 (" .. tostring(song()) .. ")") + + for _ = 1, 30 do + U.hold(game, "right", 1) + if Player.moving then break end + end + settle() + result(not Player.surfing and Player.cellX == 9, "dismounted") + result(waitSong(mapSong), "2423_dismount_map_song_back (" .. tostring(song()) .. ")") + + ItemUse.useBike(session) + result(Player.biking == true, "bike_on") + result(waitSong(Audio.MUS_CYCLING), "2423_bike_music_282 (" .. tostring(song()) .. ")") + result(Audio._savedSong == Audio.MUS_CYCLING, "bike_saved_song") + ItemUse.useBike(session) + result(Player.biking == false, "bike_off") + result(waitSong(mapSong, 60), "2423_bike_off_map_song (" .. tostring(song()) .. ")") + + place("FR_ROUTE_23", 7, 150, "up", false) + local r23 = Audio._mapSong + result(Audio.canOverrideMapMusic(Audio.MUS_SURF) == false + and Audio.canOverrideMapMusic(Audio.MUS_CYCLING) == false, "2423_route23_blocks_ride_music") + local biked = ItemUse.useBike(session) + U.wait(30) + result(biked == true and Player.biking and Audio._savedSong == nil and song() == r23, + "2423_route23_bike_keeps_map_song") + if Player.biking then ItemUse.useBike(session) end + + place("FR_ROUTE_19", 9, 12, "up", false) + Field.executeFieldMove({ action = "cut_grass", mon = mon }) + shot = false + for _ = 1, 400 do + local fx = ShowMon._fx + if fx and fx.sprite and fx.sprite.state == "wait" and not shot then + shot = U.shot(game, DIR .. "/2423_cut_show_mon.png") + end + if not ShowMon.isActive() and not Field.locked then break end + U.wait(1) + end + result(shot, "2423_cut_show_mon_shot") + + place("FR_PLAYERS_HOUSE_1F", 5, 5, "down", false) + Field.executeFieldMove({ action = "sweet_scent", mon = mon }) + shot = false + for _ = 1, 400 do + local fx = ShowMon._fx + if fx and fx.sprite and fx.sprite.state == "wait" and not shot then + shot = U.shot(game, DIR .. "/2423_show_mon_indoors.png") + end + if not ShowMon.isActive() then break end + U.wait(1) + end + result(shot, "2423_indoor_show_mon_shot") + U.wait(120) + + finish() +end diff --git a/tests/drivers/game3_tower_run.lua b/tests/drivers/game3_tower_run.lua index 80afa01d..7746e909 100644 --- a/tests/drivers/game3_tower_run.lua +++ b/tests/drivers/game3_tower_run.lua @@ -113,6 +113,7 @@ local function run(game) end session.party = {} + require("src.core.game3.scripting.flags").setFlag(require("src.core.game3.scripting.space").store, nil, 0x828, true) -- data/maps/PalletTown_ProfessorOaksLab/scripts.inc:1120 Party.giveMon(session, MEWTWO, 50) local lead = session.party[1] lead.moves, lead.pp, lead.maxPp = { PSYCHIC }, { 10 }, { 10 } diff --git a/tests/drivers/game3_ui_fly_map.lua b/tests/drivers/game3_ui_fly_map.lua index b75360c2..97f25a4d 100644 --- a/tests/drivers/game3_ui_fly_map.lua +++ b/tests/drivers/game3_ui_fly_map.lua @@ -60,6 +60,7 @@ local function run(game) Flags.setFlag(Space.store, ctx(), FLAG_BADGE03_GET, true) session.party = {} + require("src.core.game3.scripting.flags").setFlag(require("src.core.game3.scripting.space").store, nil, 0x828, true) -- data/maps/PalletTown_ProfessorOaksLab/scripts.inc:1120 Party.giveMon(session, 6, 40) session.party[1].moves = { "FLY", "EMBER", "SCRATCH", "GROWL" } session.party[1].pp = { 15, 25, 35, 40 } diff --git a/tests/drivers/game3_ui_party_dig.lua b/tests/drivers/game3_ui_party_dig.lua index a2200009..37db064d 100644 --- a/tests/drivers/game3_ui_party_dig.lua +++ b/tests/drivers/game3_ui_party_dig.lua @@ -69,6 +69,7 @@ local function run(game) Flags.setFlag(Space.store, ctx(), FLAG_BADGE03_GET, true) session.party = {} + require("src.core.game3.scripting.flags").setFlag(require("src.core.game3.scripting.space").store, nil, 0x828, true) -- data/maps/PalletTown_ProfessorOaksLab/scripts.inc:1120 Party.giveMon(session, 6, 40) local mon = session.party[1] mon.moves = { "FLY", "DIG", "TELEPORT", "EMBER" } diff --git a/tests/drivers/game3_ui_summary_pokerus.lua b/tests/drivers/game3_ui_summary_pokerus.lua index a144b12b..4d734495 100644 --- a/tests/drivers/game3_ui_summary_pokerus.lua +++ b/tests/drivers/game3_ui_summary_pokerus.lua @@ -53,6 +53,7 @@ local function run(game) if not result(session ~= nil, "new game reached the game3 field") then return finish() end session.party = {} + require("src.core.game3.scripting.flags").setFlag(require("src.core.game3.scripting.space").store, nil, 0x828, true) -- data/maps/PalletTown_ProfessorOaksLab/scripts.inc:1120 Party.giveMon(session, 25, 20) Party.giveMon(session, 1, 20) -- pokefirered/src/pokemon.c:5618 diff --git a/tests/drivers/game3_whiteout_rush.lua b/tests/drivers/game3_whiteout_rush.lua new file mode 100644 index 00000000..e1a89a16 --- /dev/null +++ b/tests/drivers/game3_whiteout_rush.lua @@ -0,0 +1,135 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_whiteout_rush" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS game3_whiteout_rush") + love.event.quit(0) + else + print("FAIL game3_whiteout_rush failures=" .. failures) + love.event.quit(1) + end +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Field = require("src.core.game3.field") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local Party = require("src.core.game3.party") + local BattleBridge = require("src.core.game3.battle_bridge") + local Rush = require("src.ui.game3.whiteout_rush") + local Message = package.loaded["src.ui.game3.message"] or require("src.ui.game3.message") + local MapCatalog = require("src.import.gba.map_catalog") + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the game3 field") then return finish() end + BattleBridge.installWhiteoutIntercept(nil, game) + session.party = {} + Party.giveMon(session, 6, 20) + + local function page() + return string.upper(Message.currentPage and Message.currentPage() or "") + end + local function vmBusy() + return Space.vm and Space.vm.isRunning and Space.vm:isRunning() + end + + local E4 = { "FLAG_DEFEATED_LORELEI", "FLAG_DEFEATED_BRUNO", "FLAG_DEFEATED_AGATHA", + "FLAG_DEFEATED_LANCE", "FLAG_DEFEATED_CHAMP" } + + local function whiteout(healId, pretMap, label) + Field.setRespawn(healId) + for _, name in ipairs(E4) do Flags.setFlag(Space.store, nil, Flags.IDS[name], true) end + Flags.setFlag(Space.store, nil, Flags.trainerFlagId(438), true) + Flags.setVar(Space.store, nil, Flags.IDS.VAR_MAP_SCENE_POKEMON_LEAGUE, 2) + session.money = 3000 + local loss = math.min(BattleBridge.calcMoneyLossFrlg(session, game.save), 3000) + for _, mon in ipairs(session.party) do mon.hp = 0 end + BattleBridge._whiteoutHook() + U.wait(2) + local want = MapCatalog.pretToEngine(pretMap) + result(Map.current == want, label .. ": respawned in " .. tostring(want)) + result(session.money == 3000 - loss, label .. ": lost " .. loss .. " money (" .. tostring(session.money) .. ")") + local cleared = true + for _, name in ipairs(E4) do + if Flags.getFlag(Space.store, nil, Flags.IDS[name]) then cleared = false end + end + result(cleared, label .. ": FLAG_DEFEATED_LORELEI..CHAMP cleared") + result(not Flags.getFlag(Space.store, nil, Flags.trainerFlagId(438)), label .. ": champion trainer flag cleared") + result(Flags.getVar(Space.store, nil, Flags.IDS.VAR_MAP_SCENE_POKEMON_LEAGUE) == 0, + label .. ": VAR_MAP_SCENE_POKEMON_LEAGUE reset") + result(Player.facing == "up", label .. ": player faces north") + result(Rush.phase() == "hold", label .. ": screen held black before the message") + for _ = 1, 600 do + if Rush.phase() == "wait" then break end + U.wait(1) + end + return Rush.phase() == "wait" + end + + local function runScene(label, want, shot, doneCheck) + U.tap(game, "a") + local seen = false + for _ = 1, 900 do + if page():find(want, 1, true) then seen = true break end + if Message.isOpen() and Message.isTyping() then Message.skipReveal() end + if Message.isOpen() and Message.isWaiting() then U.tap(game, "a") end + U.wait(2) + end + result(seen, label .. ": '" .. want .. "' shown after the fade in") + if seen then + Message.skipReveal() + U.wait(4) + result(U.shot(game, DIR .. "/" .. shot), label .. ": screenshot " .. shot) + end + local pages = {} + for _ = 1, 2400 do + if not Message.isOpen() and not vmBusy() and not Field.locked then break end + if Message.isOpen() then pages[#pages + 1] = page() end + if Message.isOpen() and Message.isTyping() then Message.skipReveal() end + if Message.isOpen() and Message.isWaiting() then U.tap(game, "a") end + U.wait(2) + end + result(not vmBusy() and not Field.locked, label .. ": scene ended and released the player") + if doneCheck then result(doneCheck(table.concat(pages, "|")), label .. ": follow-up text") end + end + + if result(whiteout(2, "ViridianCity_PokemonCenter_1F", "center"), "center: scurried text finished printing") then + result((Rush.text() or ""):find("scurried to a POKéMON CENTER", 1, true) ~= nil, "center: scurried to a POKéMON CENTER text") + result(U.shot(game, DIR .. "/spec_whiteout_scurried_center.png"), "center: screenshot spec_whiteout_scurried_center") + runScene("center", "FIRST, YOU SHOULD RESTORE", "spec_whiteout_nurse_restore.png", function(all) + return all:find("BUY SOME POTIONS", 1, true) ~= nil + end) + end + + if result(whiteout(1, "PalletTown_PlayersHouse_1F", "home"), "home: scurried text finished printing") then + result((Rush.text() or ""):find("scurried back home", 1, true) ~= nil, "home: scurried back home text") + result(U.shot(game, DIR .. "/spec_whiteout_scurried_home.png"), "home: screenshot spec_whiteout_scurried_home") + runScene("home", "WELCOME HOME", "spec_whiteout_mom_welcome.png", function(all) + return all:find("MAKE ME PROUD", 1, true) ~= nil + end) + end + local healed = true + for _, mon in ipairs(session.party) do + if (mon.hp or 0) <= 0 then healed = false end + end + result(healed, "party healed after the whiteout scenes") + return finish() +end diff --git a/tests/drivers/util.lua b/tests/drivers/util.lua index 33983c8a..e86bb238 100644 --- a/tests/drivers/util.lua +++ b/tests/drivers/util.lua @@ -99,4 +99,24 @@ end function U.frame() return frame end +-- pokefirered/src/field_screen_effect.c:387 Task_RushInjuredPokemonToCenter +function U.clearWhiteoutRush(game) + local Rush = require("src.ui.game3.whiteout_rush") + local Message = require("src.ui.game3.message") + local Space = require("src.core.game3.scripting.space") + local Field = require("src.core.game3.field") + for _ = 1, 600 do + if Rush.phase() == "wait" then break end + U.wait(1) + end + U.tap(game, "a") + for _ = 1, 2400 do + local busy = Space.vm and Space.vm:isRunning() + if not Rush.isActive() and not Message.isOpen() and not busy and not Field.locked then break end + if Message.isOpen() and Message.isTyping() then Message.skipReveal() end + if Message.isOpen() and Message.isWaiting() then U.tap(game, "a") end + U.wait(2) + end +end + return U diff --git a/tests/drivers/yellow_bill_transform_2411.lua b/tests/drivers/yellow_bill_transform_2411.lua new file mode 100644 index 00000000..8b3ef538 --- /dev/null +++ b/tests/drivers/yellow_bill_transform_2411.lua @@ -0,0 +1,214 @@ +-- engine/events/hidden_events/bills_house_pc.asm:14-63, scripts/BillsHouse.asm:144-183, +-- data/pikachu/pikachu_emotions.asm:185, audio/pikachu_pcm.asm:15 +return function(game) + local U = dofile("tests/drivers/util.lua") + local GameVersion = require("src.core.GameVersion") + local Pokemon = require("src.pokemon.Pokemon") + local Sound = require("src.core.Sound") + local Music = require("src.core.Music") + + local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots2411" + local failures = 0 + local function check(label, ok, detail) + U.log((ok and "PASS " or "FAIL ") .. label .. (detail and (" " .. detail) or "")) + if not ok then failures = failures + 1 end + return ok + end + local function finish() + U.log(failures == 0 and "DONE all checks passed" + or ("DONE " .. failures .. " check(s) failed")) + love.event.quit(failures == 0 and 0 or 1) + while true do coroutine.yield() end + end + + local yellow = GameVersion.isYellow() + local MAP = "BILLS_HOUSE" + + game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) } + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + game.save.flags = game.save.flags or {} + local flags = game.save.flags + flags.EVENT_GOT_STARTER = true + flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true + flags.EVENT_MET_BILL, flags.EVENT_MET_BILL_2 = nil, nil + flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil + flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = nil + game.save.pikachuMapScriptActive = nil + + local ow + local function objectAt(name) + for _, n in ipairs(ow and ow.npcs or {}) do + if n.def and n.def.name == name then return n end + end + end + local function follower() + for _, n in ipairs(ow and ow.npcs or {}) do + if n.pikachuFollower then return n end + end + end + + do + local wx, wy + for _, w in ipairs(game.data.maps.ROUTE_25.warps or {}) do + if w.destMap == MAP then wx, wy = w.x, w.y end + end + if not check("Route 25 has Bill's front door", wx ~= nil) then finish() end + U.teleport(game, "ROUTE_25", wx, wy + 1, "up") + U.wait(20) + for _ = 1, 300 do + ow = game.overworld + if ow and ow.map and ow.map.id == MAP then break end + table.insert(game.input.pressQueue, "up") + game.input.state.up = true + coroutine.yield() + end + game.input.state.up = false + for _ = 1, 240 do + ow = game.overworld + if ow and ow.map and ow.map.id == MAP and not ow.transitioning then break end + coroutine.yield() + end + end + U.wait(20) + ow = game.overworld + if not check("inside Bill's House", ow and ow.map and ow.map.id == MAP) then finish() end + if yellow then + check("Pikachu scene armed (following)", ow.pikachuBillsScene == true) + for _ = 1, 900 do + if ow.emote and ow.emote.pikaPic then break end + U.wait(1) + end + for _ = 1, 900 do + if not ow.emote then break end + U.wait(1) + end + end + U.wait(10) + + local f = 0 + local ev = {} + local function mark(name) + if not ev[name] then + ev[name] = f + U.log(string.format("t=%4d %s", f, name)) + end + end + local watching = {} + local sfxCount = {} + local realPlay, realPika, realPlayMap = Sound.play, Sound.playPikaCry, Music.playMap + Sound.play = function(data, name, ...) + local src = realPlay(data, name, ...) + if name == "Switch" or name == "Tink" or name == "Shrink" or name == "Get_Item1" then + sfxCount[name] = (sfxCount[name] or 0) + 1 + local key = name .. (name == "Tink" and ("#" .. sfxCount[name]) or "") + mark(key .. " start") + if src then watching[#watching + 1] = { key = key, src = src } end + end + return src + end + Sound.playPikaCry = function(data, n, ...) + local src = realPika(data, n, ...) + mark("pika cry " .. tostring(n) .. " start") + if src then watching[#watching + 1] = { key = "pika cry " .. tostring(n), src = src } end + return src + end + Music.playMap = function(...) + mark("map music restart") + return realPlayMap(...) + end + local function restore() + Sound.play, Sound.playPikaCry, Music.playMap = realPlay, realPika, realPlayMap + end + + local exclamation + for i, b in ipairs(game.data.field and game.data.field.emotionBubbles + and game.data.field.emotionBubbles.bubbles or {}) do + if b.name == "EXCLAMATION_BUBBLE" then exclamation = i end + end + + local function tick() + f = f + 1 + U.wait(1) + for i = #watching, 1, -1 do + local w = watching[i] + local ok, playing = pcall(w.src.isPlaying, w.src) + if not (ok and playing) then + mark(w.key .. " end") + table.remove(watching, i) + end + end + if objectAt("BILLSHOUSE_BILL1") then mark("Bill appears") end + local e = ow.emote + if e and exclamation and e.bubble == exclamation then mark("Pikachu ! bubble") end + if e and e.pikaPic and ev["Bill appears"] then mark("pikapic box") end + local bill = objectAt("BILLSHOUSE_BILL1") + if bill and bill.moving then mark("Bill starts walking") end + end + + flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true + flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = nil + ow:billsHousePC() + for _ = 1, 400 do + if ow.runner and ow.runner:isRunning() then break end + if #(ow.pendingScripts or {}) > 0 then break end + table.insert(game.input.pressQueue, "a") + tick() + game.input.state.a = false + tick() + end + f = 0 + mark("prompt closed") + local shotBill, shotBubble, shotPic = false, false, false + for _ = 1, 2400 do + tick() + if ev["Bill appears"] and not shotBill then + shotBill = true + local before = U.frame() + U.shot(game, SHOT_DIR .. "/2411_bill_appears.png") + f = f + (U.frame() - before) + end + if ev["Pikachu ! bubble"] and not shotBubble then + shotBubble = true + local before = U.frame() + U.shot(game, SHOT_DIR .. "/2411_pikachu_exclamation.png") + f = f + (U.frame() - before) + end + if ev["pikapic box"] and not shotPic then + shotPic = true + local before = U.frame() + U.shot(game, SHOT_DIR .. "/2411_pikapic_after_cry.png") + f = f + (U.frame() - before) + end + if ev["Bill starts walking"] then break end + end + restore() + + local function gap(a, b) return ev[a] and ev[b] and (ev[b] - ev[a]) end + local function within(label, a, b, want, tol, cart) + local g = gap(a, b) + check(label, g ~= nil and math.abs(g - want) <= tol, + string.format("got=%s want=%d+-%d cart=%s", tostring(g), want, tol, cart)) + end + local function after(label, a, b, cart) + local g = gap(a, b) + check(label, g ~= nil and g >= 0, string.format("gap=%s cart=%s", tostring(g), cart)) + end + + within("Tink #1 starts 92 frames after Switch ends", "Switch end", "Tink#1 start", 92, 10, "1.82s") + within("Shrink starts 80 frames after Tink #1 ends", "Tink#1 end", "Shrink start", 80, 10, "1.40s") + within("Tink #2 starts 48 frames after Shrink ends", "Shrink end", "Tink#2 start", 48, 10, "0.99s") + within("Get_Item1 starts 32 frames after Tink #2 ends", "Tink#2 end", "Get_Item1 start", 32, 10, "0.60s") + within("map music restarts when Get_Item1 ends", "Get_Item1 end", "map music restart", 0, 4, "0.22s") + after("Bill appears only after Get_Item1 ends", "Get_Item1 end", "Bill appears", "+0.25s") + within("Bill appears with the music restart", "map music restart", "Bill appears", 0, 2, "0.03s") + if yellow then + within("Pikachu ! ~20 frames after Bill appears", "Bill appears", "Pikachu ! bubble", 20, 3, "0.33s") + within("PikachuCry9 after the 60-frame bubble", "Pikachu ! bubble", "pika cry 9 start", 60, 3, "~0.97s") + after("pikapic box only after the cry ends", "pika cry 9 end", "pikapic box", "13.6 -> 13.7") + after("Bill walks only after the box closes", "pikapic box", "Bill starts walking", "after box") + else + within("Bill walks 8 frames after appearing", "Bill appears", "Bill starts walking", 8, 2, "BillsHouse.asm:81") + end + finish() +end diff --git a/tests/engine/game3_bills_pc_label_bug2404_test.lua b/tests/engine/game3_bills_pc_label_bug2404_test.lua index bf1cece4..029743c2 100644 --- a/tests/engine/game3_bills_pc_label_bug2404_test.lua +++ b/tests/engine/game3_bills_pc_label_bug2404_test.lua @@ -23,6 +23,7 @@ package.loaded["src.core.game3.audio"] = { playSe = function() end, playCry = function() end, } +package.loaded["src.core.game3.rom_text"] = { plain = function(key) return key end, box = function(key) return key end } local drawn = {} package.loaded["src.ui.game3.window"] = setmetatable({ diff --git a/tests/engine/game3_egg_icon_species_test.lua b/tests/engine/game3_egg_icon_species_test.lua index 744f7538..ef30ee86 100644 --- a/tests/engine/game3_egg_icon_species_test.lua +++ b/tests/engine/game3_egg_icon_species_test.lua @@ -19,6 +19,9 @@ check(Pokemon.speciesOrEgg({ species = 172, isEgg = false }) == 172, "a hatched check(Pokemon.speciesOrEgg({ species = 25 }) == 25, "and so does any other mon") check(Pokemon.speciesOrEgg(nil) == nil, "no mon, no species") +check(Pokemon.monPicSpecies({ species = 172, isEgg = true }) == 412, "the mon icon lookup draws an egg as the EGG") +check(Pokemon.monPicSpecies({ species = 201, personality = 0x00000001 }) == 413, "and an Unown by its letter") + -- Every screen that draws a party or box mon's icon goes through it (the PC -- chrome's third call is its hovered-mon front pic). local SITES = { @@ -31,8 +34,8 @@ for _, site in ipairs(SITES) do local f = assert(io.open(site[1], "rb")) local src = f:read("*a") f:close() - local n = select(2, src:gsub("Pokemon%.speciesOrEgg%(", "")) - check(n == site[2], ("%s draws %d mon(s) by speciesOrEgg (found %d)"):format(site[1], site[2], n)) + local n = select(2, src:gsub("Pokemon%.monIcon%(", "")) + select(2, src:gsub("Pokemon%.monFrontPic%(", "")) + check(n == site[2], ("%s draws %d mon(s) by monIcon/monFrontPic (found %d)"):format(site[1], site[2], n)) end T.finish("game3_egg_icon_species_test") diff --git a/tests/engine/game3_egg_screens_test.lua b/tests/engine/game3_egg_screens_test.lua index e5fa3191..c27287cc 100644 --- a/tests/engine/game3_egg_screens_test.lua +++ b/tests/engine/game3_egg_screens_test.lua @@ -11,6 +11,7 @@ local check = T.check require("src.core.GameVersion").set("firered") package.loaded["src.core.game3.audio"] = setmetatable({}, { __index = function() return function() end end }) +package.loaded["src.core.game3.rom_text"] = { plain = function(key) return key end, box = function(key) return key end } local gfx = setmetatable({}, { __index = function() return function() end end }) gfx.newQuad = function() return {} end diff --git a/tests/engine/game3_script_verbs_subset_test.lua b/tests/engine/game3_script_verbs_subset_test.lua index 65246579..983c58a7 100644 --- a/tests/engine/game3_script_verbs_subset_test.lua +++ b/tests/engine/game3_script_verbs_subset_test.lua @@ -14,7 +14,6 @@ love = love or require("tests.love_stub") local Vm = require("src.core.game3.scripting.vm") local Ops = require("src.core.game3.scripting.ops_a") -local Std = require("src.core.game3.scripting.stdscripts") local Flags = require("src.core.game3.scripting.flags") local Ctx = require("src.core.game3.scripting.ctx") local Storage = require("src.core.game3.storage") @@ -36,7 +35,6 @@ local function new_vm() local vm = Vm.new({ store = Flags.newStore(), scripts = { t_main = END, ["std:1"] = END, ["std:2"] = END }, - text = Std.TEXT, stdscripts = Std.SCRIPTS, }) vm.ctx.stack = vm.ctx.stack or {} return vm diff --git a/tests/engine/nickname_mon_icon_scale_bug2319.lua b/tests/engine/nickname_mon_icon_scale_bug2319.lua index bcdf503c..9e30153b 100644 --- a/tests/engine/nickname_mon_icon_scale_bug2319.lua +++ b/tests/engine/nickname_mon_icon_scale_bug2319.lua @@ -17,15 +17,14 @@ local function fakeImage(w, h) } end --- 32x32 icon frames stacked two high; 64x64 front pic fallback. +-- 32x32 icon frames stacked two high. local ICON_IMG = fakeImage(32, 64) -local PIC_IMG = fakeImage(64, 64) local QUAD0, QUAD1 = { frame = 0 }, { frame = 1 } -local monIcon, monFrontPic +local monIcon package.loaded["src.core.game3.pokemon"] = { icon = function() return monIcon end, - frontPic = function() return monFrontPic end, + picSpecies = function(sp) return sp end, } package.loaded["src.ui.game3.stack"] = { push = function() end, pop = function() end, @@ -73,7 +72,6 @@ end -- The mon icon: 1:1 on the (56, 40) frame centre, static frame 0. monIcon = { image = ICON_IMG, w = 32, h = 32, sheetH = 64, frames = 2, quads = { [0] = QUAD0, [1] = QUAD1 } } -monFrontPic = nil local ds, ok, err = render({ template = "NICKNAME", species = 1, maxLen = 10 }) check(ok, "nickname draw runs headless: " .. tostring(err)) local icon = drawsOf(ds, ICON_IMG) @@ -88,20 +86,8 @@ eq(d.sy, 1, "uniform scale") eq(d.ox, 16, "origin x is the frame centre, so the draw covers the frame") eq(d.oy, 16, "origin y is the frame centre") --- frontPic fallback: 64x64 shrunk into the same 32x32 box (was 0.25). -monIcon = nil -monFrontPic = { image = PIC_IMG, w = 64, h = 64 } -local ds2 = render({ template = "CAUGHT_MON", species = 4 }) -local pic = drawsOf(ds2, PIC_IMG) -eq(#pic, 1, "the front-pic fallback is drawn once") -eq(pic[1] and pic[1].x, 56, "fallback keeps the mon centre x") -eq(pic[1] and pic[1].y, 40, "fallback keeps the mon centre y") -eq(pic[1] and pic[1].sx, 0.5, "64x64 fallback fills the 32x32 mon box") -eq(pic[1] and pic[1].sy, 0.5, "uniform fallback scale") -eq(pic[1] and pic[1].ox, 32, "fallback origin is the pic centre") - -- Player/rival slot: still the 16x32 OW box. -monIcon, monFrontPic = nil, nil +monIcon = nil local portrait = fakeImage(64, 64) local ds3 = render({ template = "PLAYER", icon = portrait }) local p = drawsOf(ds3, portrait) @@ -113,7 +99,7 @@ eq(p[1] and p[1].sy, 0.25, "player OW box scale is uniform") eq(p[1] and p[1].ox, 32, "player OW origin is the portrait centre") -- Guard: the mon icon is never letterboxed below 1:1 again. -for _, dl in ipairs({ ds, ds2 }) do +for _, dl in ipairs({ ds }) do for _, e in ipairs(drawsOf(dl, ICON_IMG)) do check((e.sx or 0) >= 1, "mon icon scale is never below 1:1") end diff --git a/tests/engine/nickname_title_fits_box_bug2319.lua b/tests/engine/nickname_title_fits_box_bug2319.lua index 7061e6cc..a5c0debe 100644 --- a/tests/engine/nickname_title_fits_box_bug2319.lua +++ b/tests/engine/nickname_title_fits_box_bug2319.lua @@ -106,6 +106,7 @@ end package.loaded["src.core.game3.pokemon"] = { icon = function() return nil end, frontPic = function() return nil end, + picSpecies = function(sp) return sp end, } package.loaded["src.ui.game3.stack"] = { push = function() end, pop = function() end, diff --git a/tests/fixture_data/game3_pc_scripts.lua b/tests/fixture_data/game3_pc_scripts.lua new file mode 100644 index 00000000..db78fe3c --- /dev/null +++ b/tests/fixture_data/game3_pc_scripts.lua @@ -0,0 +1,116 @@ +-- pokefirered/data/scripts/pc.inc:1 + +local TextIR = require("src.core.game3.scripting.text_ir") +local Std = require("src.core.game3.scripting.stdscripts") + +local S = Std.SPECIAL + +local function T(s) return TextIR.fromAscii(s) end + +local M = {} + +M.TEXT = { + Text_BootedUpPC = T("{PLAYER} booted up the PC."), + Text_AccessWhichPC = T("Which PC should be accessed?"), + Text_UsualPCServicesUnavailable = T("The usual PC services aren't\\navailable…"), + Text_AccessedSomeonesPC = T("Accessed someone's PC."), + Text_OpenedPkmnStorage = T("POKéMON Storage System opened."), + Text_AccessedPlayersPC = T("Accessed {PLAYER}'s PC."), + Text_AccessedProfOaksPC = T("Accessed PROF. OAK's PC…"), +} + +M.SCRIPTS = { + -- pokefirered/data/scripts/std_msgbox.inc:22 + ["std:4"] = { + { op = "message", ptr = 0 }, + { op = "waitmessage" }, + { op = "waitbuttonpress" }, + { op = "return" }, + }, + EventScript_PC = { + { op = "lockall" }, + { op = "checkflag", flag = 0x841 }, + { op = "goto_if", cond = 1, target = "EventScript_PCDisabled" }, + { op = "setvar", var = 0x8004, value = 0 }, + { op = "special", id = S.AnimatePcTurnOn }, + { op = "playse", [1] = 4 }, + { op = "loadword", dest = 0, value = "Text_BootedUpPC" }, + { op = "callstd", std = 4 }, + { op = "goto", target = "EventScript_PCMainMenu" }, + }, + -- pokefirered/data/scripts/pc.inc:15 + EventScript_PCDisabled = { + { op = "loadword", dest = 0, value = "Text_UsualPCServicesUnavailable" }, + { op = "callstd", std = 4 }, + { op = "releaseall" }, + { op = "end" }, + }, + -- pokefirered/data/scripts/pc.inc:20 + EventScript_PCMainMenu = { + { op = "message", ptr = "Text_AccessWhichPC" }, + { op = "waitmessage" }, + { op = "special", id = S.CreatePCMenu }, + { op = "waitstate" }, + { op = "goto", target = "EventScript_ChoosePCMenu" }, + }, + -- pokefirered/data/scripts/pc.inc:28 + EventScript_ChoosePCMenu = { + { op = "compare_var_to_value", var = 0x800D, value = 0 }, + { op = "goto_if", cond = 1, target = "EventScript_AccessPokemonStorage" }, + { op = "compare_var_to_value", var = 0x800D, value = 1 }, + { op = "goto_if", cond = 1, target = "EventScript_AccessPlayersPC" }, + { op = "compare_var_to_value", var = 0x800D, value = 2 }, + { op = "goto_if", cond = 1, target = "EventScript_AccessProfOaksPC" }, + { op = "compare_var_to_value", var = 0x800D, value = 3 }, + { op = "goto_if", cond = 1, target = "EventScript_AccessHallOfFame" }, + { op = "goto", target = "EventScript_TurnOffPC" }, + }, + -- pokefirered/data/scripts/pc.inc:38 + EventScript_AccessPlayersPC = { + { op = "playse", [1] = 2 }, + { op = "loadword", dest = 0, value = "Text_AccessedPlayersPC" }, + { op = "callstd", std = 4 }, + { op = "special", id = S.PlayerPC }, + { op = "waitstate" }, + { op = "goto", target = "EventScript_PCMainMenu" }, + }, + -- pokefirered/data/scripts/pc.inc:46 + EventScript_AccessPokemonStorage = { + { op = "playse", [1] = 2 }, + { op = "loadword", dest = 0, value = "Text_AccessedSomeonesPC" }, + { op = "callstd", std = 4 }, + { op = "loadword", dest = 0, value = "Text_OpenedPkmnStorage" }, + { op = "callstd", std = 4 }, + { op = "special", id = S.ShowPokemonStorageSystemPC }, + { op = "waitstate" }, + { op = "goto", target = "EventScript_PCMainMenu" }, + }, + -- pokefirered/data/scripts/pc.inc:66 + EventScript_TurnOffPC = { + { op = "setvar", var = 0x8004, value = 0 }, + { op = "playse", [1] = 3 }, + { op = "special", id = S.AnimatePcTurnOff }, + { op = "releaseall" }, + { op = "end" }, + }, + -- pokefirered/data/scripts/pc.inc:74 + EventScript_AccessHallOfFame = { + { op = "checkflag", flag = 0x82C }, + { op = "goto_if", cond = 0, target = "EventScript_TurnOffPC" }, + { op = "playse", [1] = 2 }, + { op = "special", id = S.HallOfFamePCBeginFade }, + { op = "waitstate" }, + { op = "goto", target = "EventScript_ChoosePCMenu" }, + }, + -- pokefirered/data/scripts/pc.inc:86 + EventScript_AccessProfOaksPC = { + { op = "checkflag", flag = 0x829 }, + { op = "goto_if", cond = 0, target = "EventScript_TurnOffPC" }, + { op = "playse", [1] = 2 }, + { op = "loadword", dest = 0, value = "Text_AccessedProfOaksPC" }, + { op = "callstd", std = 4 }, + { op = "goto", target = "EventScript_PCMainMenu" }, + }, +} + +return M diff --git a/tests/game3_bag_test.lua b/tests/game3_bag_test.lua index 98c96922..cf05966b 100644 --- a/tests/game3_bag_test.lua +++ b/tests/game3_bag_test.lua @@ -20,7 +20,6 @@ local Schema = require("src.core.game3.save_schema_firered") local Flags = require("src.core.game3.scripting.flags") local Ops = require("src.core.game3.scripting.ops_a") local Vm = require("src.core.game3.scripting.vm") -local Std = require("src.core.game3.scripting.stdscripts") print("[test] 1. Items pack load") ItemsData.install(nil) @@ -100,8 +99,6 @@ local vm = Vm.new({ { op = "end" }, }, }, - text = Std.TEXT, - stdscripts = Std.SCRIPTS, }) vm:start("t_check") diff --git a/tests/game3_bedroom_pc_test.lua b/tests/game3_bedroom_pc_test.lua index c2c4a07f..c02c6148 100644 --- a/tests/game3_bedroom_pc_test.lua +++ b/tests/game3_bedroom_pc_test.lua @@ -2,6 +2,7 @@ -- pokefirered/src/player_pc.c:151 BedroomPC, pokefirered/src/player_pc.c:100 gNewGamePCItems package.path = "./?.lua;./?/init.lua;" .. package.path +package.loaded["src.core.game3.rom_text"] = { plain = function(key) return key end, box = function(key) return key end } local failed = 0 local function check(cond, msg) diff --git a/tests/game3_bufferstdstring_test.lua b/tests/game3_bufferstdstring_test.lua index d00e7218..1821c0fc 100644 --- a/tests/game3_bufferstdstring_test.lua +++ b/tests/game3_bufferstdstring_test.lua @@ -4,6 +4,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local GameVersion = require("src.core.GameVersion") GameVersion.set("firered") +local Game3Cache = require("tests.game3_cache") +if not Game3Cache.bundle() then print("[skip] bufferstdstring: " .. tostring(Game3Cache.reason)) return end local Flags = require("src.core.game3.scripting.flags") local Vm = require("src.core.game3.scripting.vm") diff --git a/tests/game3_cache.lua b/tests/game3_cache.lua index 9c760179..89f2f41b 100644 --- a/tests/game3_cache.lua +++ b/tests/game3_cache.lua @@ -164,7 +164,7 @@ end function M.requireData(label, marker) marker = marker or "meta.json" - if M.root(marker) then return end + if M.mount(marker) then return end for _, root in ipairs(datasetRoots()) do if readable(root .. "/data/generated/gba/" .. marker) then return end end diff --git a/tests/game3_corner_screen_test.lua b/tests/game3_corner_screen_test.lua index 05100eaf..4f6ccc9c 100644 --- a/tests/game3_corner_screen_test.lua +++ b/tests/game3_corner_screen_test.lua @@ -1,6 +1,7 @@ #!/usr/bin/env luajit package.path = "./?.lua;./?/init.lua;" .. package.path +package.loaded["src.core.game3.rom_text"] = { plain = function(key) return key end, box = function(key) return key end } local GameVersion = require("src.core.GameVersion") GameVersion.set("firered") diff --git a/tests/game3_credits_hall_of_fame_test.lua b/tests/game3_credits_hall_of_fame_test.lua index 87e07ed6..250d834f 100644 --- a/tests/game3_credits_hall_of_fame_test.lua +++ b/tests/game3_credits_hall_of_fame_test.lua @@ -70,12 +70,18 @@ FrlgFont.draw = function(text, x, y, opts) end HallOfFame.start({ - session = { name = "RED", trainerId = 12345, party = { + session = { name = "RED", trainerId = 12345, national_dex_unlocked = true, party = { { species = species, name = Pokemon.name(species), level = 50, otId = 12345 }, } }, onDone = function() end, + warp = false, }) check(HallOfFame.isOpen(), "the Hall of Fame opened for the induction") +for _ = 1, 400 do + if HallOfFame.phase() == "hold" then break end + HallOfFame.update(1 / 60) +end +check(HallOfFame.phase() == "hold", "the first mon slid in and its info printed") HallOfFame.draw() FrlgFont.draw = realDraw diff --git a/tests/game3_emote_movement_test.lua b/tests/game3_emote_movement_test.lua index b8856e64..d93eb9c7 100644 --- a/tests/game3_emote_movement_test.lua +++ b/tests/game3_emote_movement_test.lua @@ -9,7 +9,6 @@ local FieldEffects = require("src.core.game3.field_effects") local Flags = require("src.core.game3.scripting.flags") local Ctx = require("src.core.game3.scripting.ctx") local Vm = require("src.core.game3.scripting.vm") -local Std = require("src.core.game3.scripting.stdscripts") local Adapters = require("src.core.game3.scripting.adapters") local Audio = require("src.core.game3.audio") @@ -152,9 +151,26 @@ adapters.waitFanfare = function(cb) end local scripts = {} -for k, v in pairs(Std.SCRIPTS) do - scripts[k] = v -end +-- data/scripts/std_msgbox.inc:30 +scripts["std:9"] = { + { op = "textcolor", color = 3 }, + { op = "compare_var_to_value", var = 0x8002, value = 257 }, + { op = "call_if", cond = 1, target = "EventScript_ReceivedItemFanfare1" }, + { op = "compare_var_to_value", var = 0x8002, value = 318 }, + { op = "call_if", cond = 1, target = "EventScript_ReceivedItemFanfare2" }, + { op = "message", ptr = 0 }, + { op = "waitmessage" }, + { op = "waitfanfare" }, + { op = "return" }, +} +scripts.EventScript_ReceivedItemFanfare1 = { + { op = "playfanfare", [1] = 257 }, + { op = "return" }, +} +scripts.EventScript_ReceivedItemFanfare2 = { + { op = "playfanfare", [1] = 318 }, + { op = "return" }, +} -- Test 4A: Oak's Parcel (MUS_OBTAIN_KEY_ITEM = 318) scripts.test_parcel = { @@ -192,8 +208,8 @@ scripts.test_potion = { lastFanfarePlayed = nil fanfarePlaying = false vm:start("test_potion", 1) -assert(lastFanfarePlayed == 258, "Potion triggered MUS_OBTAIN_ITEM (258) fanfare") -print("[ok] Standard item played obtain item fanfare (258)") +assert(lastFanfarePlayed == nil, "std:9 plays no fanfare for MUS_OBTAIN_ITEM (258)") +print("[ok] std:9 leaves other fanfare ids to the caller") -- Test 4C: Level Up Fanfare (MUS_LEVEL_UP = 257) scripts.test_levelup = { @@ -224,7 +240,7 @@ scripts.test_var_fanfare = { lastFanfarePlayed = nil fanfarePlaying = false vm:start("test_var_fanfare", 1) -assert(lastFanfarePlayed == 317, "Dynamic fanfare variable resolved to 317") -print("[ok] Dynamic fanfare fallback resolved variable") +assert(lastFanfarePlayed == nil, "std:9 plays no fanfare for MUS_DEX_RATING (317)") +print("[ok] std:9 has no dynamic fanfare branch") print("All Game 3 Fanfare & Emote Parity Tests passed successfully!") diff --git a/tests/game3_event_flags_test.lua b/tests/game3_event_flags_test.lua index bbfde072..ecb5f006 100644 --- a/tests/game3_event_flags_test.lua +++ b/tests/game3_event_flags_test.lua @@ -15,7 +15,7 @@ assert(FlagsTable.FLAGS.FLAG_SYS_B_DASH == 0x82F, "SYS_B_DASH should be 0x82F") assert(FlagsTable.FLAGS.FLAG_SYS_NATIONAL_DEX == 0x840, "SYS_NATIONAL_DEX should be 0x840") assert(FlagsTable.FLAGS.FLAG_HIDE_OAK_IN_HIS_LAB == 0x02B, "HIDE_OAK_IN_HIS_LAB should be 0x2B") assert(FlagsTable.VARS.VAR_MAP_SCENE_PALLET_TOWN_OAK == 0x4050, "MAP_SCENE_PALLET_TOWN_OAK should be 0x4050") -assert(#FlagsTable.NEW_GAME_HIDE_FLAGS == 57, "Should contain 57 new game hide flags (49 EventScript_ResetAllMapFlags + 8 Seafoam boulder hide flags)") +assert(#FlagsTable.NEW_GAME_HIDE_FLAGS == 49, "Should contain the 49 EventScript_ResetAllMapFlags hide flags") local f = io.open("pokefirered/include/constants/flags.h", "r") if f then f:close() diff --git a/tests/game3_field_boulder_test.lua b/tests/game3_field_boulder_test.lua index 118b20a5..842d796b 100644 --- a/tests/game3_field_boulder_test.lua +++ b/tests/game3_field_boulder_test.lua @@ -100,6 +100,14 @@ local function standAt(x, y, facing) Player.facing = facing or "down" end +-- pokefirered/src/event_object_movement.c:8959 UpdateWalkSlowerAnim +local function pumpPush(frames) + for _ = 1, frames or 33 do + Objects.update(game) + Player.update(game, nil) + end +end + print("[test] 1. Victory Road 1F: the floor switch coord event is var-gated shut") local vrDef = enterMap(VR) check(vrDef ~= nil, VR .. " is in the cache") @@ -147,7 +155,18 @@ if not boulder then finish() end Objects.setObjectXY(boulder.localId, 20, 15) standAt(20, 14, "down") local result = Player.tryMove("down", game, false) -check(result == "step", "bumping the boulder southward pushes it (" .. tostring(result) .. ")") +check(result == "push", "bumping the boulder southward pushes it (" .. tostring(result) .. ")") +check(Player.cellX == 20 and Player.cellY == 14 and not Player.moving, + "the player walks in place instead of stepping") +check(boulder.moving == true and boulder.stepFrames == 32, + "the boulder starts a 32-frame slide") +check(Flags.getVar(Space.store, Space.vm.ctx, VAR_VR1F) ~= 100, + "the switch has not fired before the slide ends") +pumpPush(31) +check(boulder.moving == true and Player.boulderPush ~= nil, + "the push is still running on frame 31") +pumpPush(2) +check(Player.boulderPush == nil, "the push task finished") check(boulder.cellX == 20 and boulder.cellY == 16, string.format("the boulder landed on the switch (%d,%d)", boulder.cellX, boulder.cellY)) pumpVm() @@ -189,7 +208,11 @@ Flags.setFlag(Space.store, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_1, true) Flags.setFlag(Space.store, nil, FieldMoves.SYS_FLAGS.USE_STRENGTH, true) standAt(6, 16, "down") local r2 = Player.tryMove("down", game, false) -check(r2 == "step", "the boulder is pushed south into the hole (" .. tostring(r2) .. ")") +check(r2 == "push", "the boulder is pushed south into the hole (" .. tostring(r2) .. ")") +check(sfBoulder.visible ~= false and sfBoulder.moving == true, + "the boulder is still sliding toward the hole") +pumpPush(33) +check(Player.cellX == 6 and Player.cellY == 16, "the player never left (6,16)") check(sfBoulder.visible == false and sfBoulder.hidden == true, "the boulder is gone from the floor") check(Objects.at(6, 18) == nil, "nothing stands on the hole any more") diff --git a/tests/game3_field_cut_test.lua b/tests/game3_field_cut_test.lua index 48e6be8b..33faeb3e 100644 --- a/tests/game3_field_cut_test.lua +++ b/tests/game3_field_cut_test.lua @@ -151,7 +151,7 @@ print("[test] 4. executing it opens the door") standAt(RV.x, RV.y, "up") Field.executeFieldMove(FieldMoves.fromMenu("CUT", ctx)) check(Field.locked == true, "the field locks for the animation") -for _ = 1, 40 do FieldEffects.step() end +for _ = 1, 200 do FieldEffects.step() end check(Field.locked == false, "the field unlocks when the animation ends") local override = Field.metatileOverrideAt(RV.map, RV.doorX, RV.doorY) check(override ~= nil and override.metatile == RV.doorOpen, "(24,24) became the open door") diff --git a/tests/game3_field_fly_test.lua b/tests/game3_field_fly_test.lua index 03f2ac17..e09e367a 100644 --- a/tests/game3_field_fly_test.lua +++ b/tests/game3_field_fly_test.lua @@ -3,6 +3,7 @@ -- pokefirered/src/region_map.c:4022 SetFlyWarpDestination package.path = "./?.lua;./?/init.lua;" .. package.path +package.loaded["src.core.game3.rom_text"] = { plain = function(key) return key end, box = function(key) return key end } local failed = 0 local function check(cond, msg) diff --git a/tests/game3_field_forced_movement_test.lua b/tests/game3_field_forced_movement_test.lua index c4f37bef..b56ef67f 100644 --- a/tests/game3_field_forced_movement_test.lua +++ b/tests/game3_field_forced_movement_test.lua @@ -275,25 +275,35 @@ end print("[test] 10. the per-step callback registry is wired") enterMap(ICEFALL_B1F) local Ctx = require("src.core.game3.scripting.ctx") -local seen = nil -ForcedMovement.registerStepCallback("ice", function(_, px, py) - seen = { px, py } +local function walkTicking(dir, extra) + Player.facing = dir + Player.turnTimer = 0 + Player.tryMove(dir, game, false) + local frames = 0 + while Player.moving and frames < 4000 do + ForcedMovement.runStepCallback(game) + Player.tick(game) + frames = frames + 1 + end + for _ = 1, extra or 8 do ForcedMovement.runStepCallback(game) end +end +local realIce = ForcedMovement.stepCallbacks["ice"] +check(type(realIce) == "function", "STEP_CB_ICE has a registered handler") +local calls = 0 +ForcedMovement.registerStepCallback("ice", function() + calls = calls + 1 return false end) Ctx.setStepCallback(Ctx.STEP_CB.ICE, session.map) standAt(20, 9, "right") -walkAndSettle("right") -check(seen ~= nil, "STEP_CB_ICE reached the registered handler") -if seen then - eq(seen[1], 20, "the handler got the cell the player left (x)") - eq(seen[2], 9, "the handler got the cell the player left (y)") -end +walkTicking("right") +check(calls > 0, "STEP_CB_ICE reached the registered handler every frame") Ctx.resetStepCallback() -ForcedMovement.stepCallbacks["ice"] = nil standAt(20, 9, "right") -seen = nil -walkAndSettle("right") -check(seen == nil, "STEP_CB_DUMMY reaches no handler") +calls = 0 +walkTicking("right") +eq(calls, 0, "STEP_CB_DUMMY reaches no handler") +ForcedMovement.registerStepCallback("ice", realIce) print("[test] 11. a warp taken mid-slide drops the FORCED flag with the map") enterMap(ICEFALL_B1F) diff --git a/tests/game3_field_items_test.lua b/tests/game3_field_items_test.lua index d340157f..0a26f33b 100644 --- a/tests/game3_field_items_test.lua +++ b/tests/game3_field_items_test.lua @@ -109,8 +109,8 @@ check(tonumber(ItemsData.info(ITEM_GOOD_ROD).secondaryId) == 1, "GOOD ROD is rod check(tonumber(ItemsData.info(ITEM_SUPER_ROD).secondaryId) == 2, "SUPER ROD is rod 2") check(ItemsData.fieldUseKind(ITEM_OLD_ROD) == "key", "the pack files the rods under fieldUse key, which the old reject swallowed") -check(ItemsData.fieldUseKind(ITEM_WHITE_FLUTE) == "none", - "and the WHITE FLUTE under fieldUse none") +check(ItemsData.fieldUseKind(ITEM_WHITE_FLUTE) == "black_white_flute", + "and the WHITE FLUTE under FieldUseFunc_BlackWhiteFlute") print("[test] 2. Pallet Town beach: CanFish") local def = enterMap("FR_PALLET_TOWN") @@ -262,7 +262,7 @@ check(ok == true and text:find("catchy tune", 1, true) ~= nil, print("[test] 6. the WHITE and BLACK FLUTES move the encounter rate") local base = Encounters.encounterRate(20, { ignoreAbility = true }) ok, kind, text = ItemUse.useField(session, session.bag, ITEM_WHITE_FLUTE, nil) -check(ok == true and kind == "flute", "the WHITE FLUTE is usable") +check(ok == true and kind == "black_white_flute", "the WHITE FLUTE is usable") check(text:find("lured", 1, true) ~= nil, "it prints gText_UsedVar2WildLured") check(Flags.getFlag(Space.store, nil, FLAG_SYS_WHITE_FLUTE_ACTIVE) == true, "FLAG_SYS_WHITE_FLUTE_ACTIVE is set") diff --git a/tests/game3_hall_of_fame_test.lua b/tests/game3_hall_of_fame_test.lua index 7615b15c..64029310 100644 --- a/tests/game3_hall_of_fame_test.lua +++ b/tests/game3_hall_of_fame_test.lua @@ -2,6 +2,8 @@ -- FRLG Hall of Fame Induction Screen & Egg Skipping Unit Test Suite package.path = "./?.lua;./?/init.lua;" .. package.path +local Game3Cache = require("tests.game3_cache") +if not Game3Cache.bundle() then print("[skip] hall_of_fame: " .. tostring(Game3Cache.reason)) return end local failed = 0 local function check(cond, msg) @@ -19,7 +21,7 @@ local Adapters = require("src.core.game3.scripting.adapters") local Natives = require("src.core.game3.scripting.natives") local Std = require("src.core.game3.scripting.stdscripts") -print("=== [TEST 1] Hall of Fame Strict Egg Skipping & Mon Progression ===") +print("=== [TEST 1] Hall of Fame induction order and timing ===") do local party = { { species = 6, name = "CHARIZARD", level = 55, otId = 12345, moves = { "FLAMETHROWER", "FLY", "SLASH", "FIRE_SPIN" } }, @@ -44,41 +46,40 @@ do HallOfFame.start({ session = session, onDone = function() doneCalled = true end, + warp = false, }) check(HallOfFame.isOpen() == true, "HallOfFame is open") - check(#HallOfFame._mons == 4, "Eligible mons count is exactly 4 (skipped 2 eggs)") - - -- Mon 1: Charizard - local m1 = HallOfFame.getCurrentMon() - check(m1 and m1.name == "CHARIZARD", "Mon 1 is CHARIZARD") + -- pokefirered/src/hall_of_fame.c:390 + check(#HallOfFame._mons == 6, "every occupied party slot is inducted, eggs included") local inpA = { wasPressed = function(_, k) return k == "a" end, isDown = function() return false end, } - -- Advance to Mon 2: Pikachu (skipping egg at slot 2) - HallOfFame.handleInput(inpA) - local m2 = HallOfFame.getCurrentMon() - check(m2 and m2.name == "PIKACHU", "Mon 2 is PIKACHU (skipped egg)") + local shown = {} + local lastInfo = nil + local frames = 0 + while HallOfFame.isOpen() and HallOfFame.phase() ~= "exitwait" and frames < 20000 do + HallOfFame.handleInput(inpA) + HallOfFame.update(1 / 60) + frames = frames + 1 + if HallOfFame._info and HallOfFame._info ~= lastInfo then + lastInfo = HallOfFame._info + shown[#shown + 1] = lastInfo.name + end + end + check(HallOfFame.phase() == "exitwait", "the induction ran to the player card on its own timers") + check(table.concat(shown, ",") == "CHARIZARD,EGG,PIKACHU,TOGEPI EGG,BLASTOISE,SNORLAX", + "mons were shown in party order: " .. table.concat(shown, ",")) + check(HallOfFame.isOpen() == true, "A presses before the player card did not close the screen") - -- Advance to Mon 3: Blastoise (skipping egg at slot 4) - HallOfFame.handleInput(inpA) - local m3 = HallOfFame.getCurrentMon() - check(m3 and m3.name == "BLASTOISE", "Mon 3 is BLASTOISE (skipped egg)") - - -- Advance to Mon 4: Snorlax - HallOfFame.handleInput(inpA) - local m4 = HallOfFame.getCurrentMon() - check(m4 and m4.name == "SNORLAX", "Mon 4 is SNORLAX") - - -- Advance to Congratulations screen - HallOfFame.handleInput(inpA) - check(HallOfFame._phase == "congrats", "Advanced to CONGRATULATIONS phase") - - -- Advance from Congratulations to close and yield to credits HallOfFame.handleInput(inpA) + for _ = 1, 400 do + if not HallOfFame.isOpen() then break end + HallOfFame.update(1 / 60) + end check(HallOfFame.isOpen() == false, "HallOfFame closed cleanly") check(doneCalled == true, "onDone credits yield callback executed") @@ -88,7 +89,10 @@ do check(session.hofDebutTime == "14:22:35", "session.hofDebutTime stamped correctly") check(session.hofDebutHours == 14 and session.hofDebutMinutes == 22, "hofDebutHours and Minutes stamped") check(#session.hallOfFameTeams == 1, "Hall of fame team record saved") - check(#session.hallOfFameTeams[1] == 4, "Saved team record contains exactly 4 non-egg mons") + local rec = session.hallOfFameTeams[1] + check(#rec == 6, "Saved team record keeps all 6 party slots (hall_of_fame.c:389)") + check(rec[2].species == 412 and rec[4].species == 412, "Eggs are recorded as SPECIES_EGG") + check(session.gameStats[10] == 1, "GAME_STAT_ENTERED_HOF incremented (save.c:665)") end print("=== [TEST 2] Scripting Special Native EnterHallOfFame Integration ===") @@ -108,6 +112,7 @@ do HallOfFame.start({ session = session, onDone = done, + warp = false, }) end, }) @@ -120,7 +125,11 @@ do check(yielded == true, "Special EnterHallOfFame yielded execution to HallOfFame") check(hofCalled == true, "adapters.hallOfFame was invoked") check(HallOfFame.isOpen() == true, "HallOfFame UI is open") - check(session.flags[0x82C] == true, "FLAG_SYS_GAME_CLEAR set by native") + for _ = 1, 200 do + if HallOfFame.phase() == "display" then break end + HallOfFame.update(1 / 60) + end + check(session.flags[0x82C] == true, "FLAG_SYS_GAME_CLEAR saved with the induction") -- Close HallOfFame.close() diff --git a/tests/game3_hidden_item_persistence_test.lua b/tests/game3_hidden_item_persistence_test.lua index 7402db8b..e92b7fdc 100644 --- a/tests/game3_hidden_item_persistence_test.lua +++ b/tests/game3_hidden_item_persistence_test.lua @@ -1,5 +1,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path require("src.core.GameVersion").set("firered") +local Game3Cache = require("tests.game3_cache") +if not Game3Cache.bundle() then print("[skip] hidden_item_persistence: " .. tostring(Game3Cache.reason)) return end local Field = require("src.core.game3.field") local Flags = require("src.core.game3.scripting.flags") local Space = require("src.core.game3.scripting.space") diff --git a/tests/game3_hidden_items_test.lua b/tests/game3_hidden_items_test.lua index 0db63596..578ba30c 100644 --- a/tests/game3_hidden_items_test.lua +++ b/tests/game3_hidden_items_test.lua @@ -1,6 +1,8 @@ -- Unit tests for Issue #2314: FireRed Hidden Items & Itemfinder local GameVersion = require("src.core.GameVersion") GameVersion.set("firered") +local Game3Cache = require("tests.game3_cache") +if not Game3Cache.bundle() then print("[skip] hidden_items: " .. tostring(Game3Cache.reason)) return end local ExtractMapEvents = require("src.import.gba.extract_map_events") local Field = require("src.core.game3.field") @@ -203,28 +205,32 @@ local ok, kind, text = ItemUse.useField(session, session.bag, ItemsData.ITEM_ITE assert(ok == false, "Itemfinder should not respond when out of range") assert(text == "… … … …Nope!\nThere's no response.", "Response text matches pret nope string") --- Move player close to Potion at (3, 4): player at (5, 6) -> dx = -2, dy = -2 (within range <= 7) -session.playerX = 5 -session.playerY = 6 +-- pokefirered/src/itemfinder.c:216 +Flags.setFlag(session, nil, 0x3E9, false) local okNear, kindNear, textNear = ItemUse.useField(session, session.bag, ItemsData.ITEM_ITEMFINDER, nil) assert(okNear == true, "Itemfinder should detect nearby hidden item") -assert(textNear == "Huh? The ITEMFINDER's responding!\nThere's an item buried around here!", "Nearby text matches pret") +assert(textNear == "Huh?\nThe ITEMFINDER's responding!\fThere's an item buried around here!", "Nearby text matches pret") +Flags.setFlag(session, nil, 0x3E9, true) + +-- pokefirered/src/itemfinder.c:213 +session.playerX = 5 +session.playerY = 6 +local okAway = ItemUse.useField(session, session.bag, ItemsData.ITEM_ITEMFINDER, nil) +assert(okAway == false, "An underfoot item only answers when the player stands on it") --- Move player right on top of Potion at (3, 4) -> dx = 0, dy = 0 (underfoot) session.playerX = 3 session.playerY = 4 local okFeet, kindFeet, textFeet = ItemUse.useField(session, session.bag, ItemsData.ITEM_ITEMFINDER, nil) assert(okFeet == true, "Itemfinder should detect underfoot hidden item") -assert(textFeet == "Oh! The ITEMFINDER's responding!\nThere's an item buried right beneath your feet!", "Underfoot text matches pret") +assert(textFeet == "Oh!\nThe ITEMFINDER's shaking wildly!\fThere's an item buried underfoot!\f… … … … … …", "Underfoot text matches pret") --- Pick up Potion at (3, 4) local hiddenPotion = Field.hiddenItemAt(game, 3, 4, 0) assert(hiddenPotion ~= nil, "Should find hidden Potion at 3, 4") -Field.pickUpHiddenItem(game, hiddenPotion) +assert(Field.digUpUnderfootItem(game, hiddenPotion), "the Itemfinder digs the underfoot item up") assert(Bag.get(session.bag, 13) == 1, "Bag now has 1 Potion") assert(Flags.getFlag(session, nil, 0x3E8) == true, "Flag 0x3E8 is now true") +Message.close() --- Scan again at (3, 4) -> should now be nope since all items on map are collected local okDone, kindDone, textDone = ItemUse.useField(session, session.bag, ItemsData.ITEM_ITEMFINDER, nil) assert(okDone == false, "Itemfinder has no response after items collected") assert(textDone == "… … … …Nope!\nThere's no response.") diff --git a/tests/game3_import_hardening_test.lua b/tests/game3_import_hardening_test.lua index 8b107dd2..d02677c3 100644 --- a/tests/game3_import_hardening_test.lua +++ b/tests/game3_import_hardening_test.lua @@ -133,6 +133,12 @@ local function loadExtractorWithStubs(pokeRun, sectionsRun) ready = function() return true end, run = function() return {} end, } + for _, sibling in ipairs({ "credits_extract", "league_extract" }) do + package.loaded["src.import.gba." .. sibling] = { + ready = function() return true end, + run = function() return {} end, + } + end package.loaded["src.import.gba.map_sections_extract"] = { run = sectionsRun or function(_, cache, opts) cache:write((opts and opts.cacheRoot or "data/generated/gba") @@ -210,6 +216,8 @@ package.loaded["src.import.gba.rom"] = nil package.loaded["src.import.gba.pokemon_extract"] = nil package.loaded["src.import.gba.region_map_extract"] = nil package.loaded["src.import.gba.multichoice_extract"] = nil +package.loaded["src.import.gba.credits_extract"] = nil +package.loaded["src.import.gba.league_extract"] = nil package.loaded["src.import.gba.map_sections_extract"] = nil package.loaded["src.import.gba.revision_view"] = nil package.loaded["src.import.gba.extract_intro"] = nil diff --git a/tests/game3_item_pc_potion_test.lua b/tests/game3_item_pc_potion_test.lua index dbd7d804..488dd4b2 100644 --- a/tests/game3_item_pc_potion_test.lua +++ b/tests/game3_item_pc_potion_test.lua @@ -1,6 +1,8 @@ #!/usr/bin/env luajit -- Test: Game 3 Player Item PC starting Potion fidelity (pokefirered player_pc.c:100-101) +local Game3Cache = require("tests.game3_cache") +if not Game3Cache.bundle() then print("[skip] item_pc_potion: " .. tostring(Game3Cache.reason)) return end local Storage = require("src.core.game3.storage") local Schema = require("src.core.game3.save_schema_firered") local Bag = require("src.core.game3.bag") diff --git a/tests/game3_item_use_and_parcel_test.lua b/tests/game3_item_use_and_parcel_test.lua index 628a2f14..2d64af64 100644 --- a/tests/game3_item_use_and_parcel_test.lua +++ b/tests/game3_item_use_and_parcel_test.lua @@ -269,7 +269,25 @@ local Adapters = require("src.core.game3.scripting.adapters") local adapters = Adapters.host(nil, nil, nil) Space.vm = Vm.new({ store = Space.store, - scripts = { test_item_ball = testItemScript }, + scripts = { + test_item_ball = testItemScript, + -- data/scripts/obtain_item.inc:106 + ["std:1"] = { + { op = "copyvar", [1] = 0x8004, [2] = 0x8000 }, + { op = "copyvar", [1] = 0x8005, [2] = 0x8001 }, + { op = "checkitemspace", [1] = 0x8000, [2] = 0x8001 }, + { op = "copyvar", [1] = 0x8007, [2] = 0x800D }, + { op = "compare_var_to_value", var = 0x8007, value = 1 }, + { op = "call_if", cond = 1, target = "EventScript_PickUpItem" }, + { op = "return" }, + }, + -- data/scripts/obtain_item.inc:122 + EventScript_PickUpItem = { + { op = "removeobject", [1] = 0x800F }, + { op = "additem", [1] = 0x8004, [2] = 0x8005 }, + { op = "return" }, + }, + }, adapters = adapters, }) diff --git a/tests/game3_link_trade_test.lua b/tests/game3_link_trade_test.lua index 80827fba..a4a66374 100644 --- a/tests/game3_link_trade_test.lua +++ b/tests/game3_link_trade_test.lua @@ -1,5 +1,6 @@ #!/usr/bin/env luajit package.path = "./?.lua;./?/init.lua;" .. package.path +package.loaded["src.core.game3.rom_text"] = { plain = function(key) return key end, box = function(key) return key end } local failed = 0 local function check(cond, msg) diff --git a/tests/game3_menu_sfx_test.lua b/tests/game3_menu_sfx_test.lua index 74a0e7f2..1ad18ac8 100644 --- a/tests/game3_menu_sfx_test.lua +++ b/tests/game3_menu_sfx_test.lua @@ -1,6 +1,7 @@ #!/usr/bin/env luajit package.path = "./?.lua;./?/init.lua;" .. package.path +package.loaded["src.core.game3.rom_text"] = { plain = function(key) return key end, box = function(key) return key end } local failed = 0 local function check(cond, msg) diff --git a/tests/game3_moveteach_dig_escape_test.lua b/tests/game3_moveteach_dig_escape_test.lua index 41753379..6797a6d8 100644 --- a/tests/game3_moveteach_dig_escape_test.lua +++ b/tests/game3_moveteach_dig_escape_test.lua @@ -125,6 +125,11 @@ FieldEffects.startWarpSpin = function(_, cb) cb() end session.party = { mon } mon.hp, mon.status = 1, "PSN" Field.executeFieldMove({ action = "dig", warp = ESCAPE }) +local ShowMon = require("src.core.game3.field_move_show_mon") +for _ = 1, 300 do + if not ShowMon.isActive() then break end + ShowMon.step() +end FieldEffects.startWarpSpin = realSpin eq(session.map, TOWN, "executeFieldMove reached respawnAtHeal with payload.warp") eq(Player.cellX, ESCAPE.x, "and landed on the escape warp's column") diff --git a/tests/game3_nurse_joy_heal_test.lua b/tests/game3_nurse_joy_heal_test.lua deleted file mode 100644 index 4eabe9cd..00000000 --- a/tests/game3_nurse_joy_heal_test.lua +++ /dev/null @@ -1,246 +0,0 @@ -#!/usr/bin/env luajit --- Test 1:1 Nurse Joy healing flow and standard msgbox scripts (std:2..std:6). - -package.path = "./?.lua;./?/init.lua;" .. package.path - -local Vm = require("src.core.game3.scripting.vm") -local Std = require("src.core.game3.scripting.stdscripts") -local Adapters = require("src.core.game3.scripting.adapters") -local Flags = require("src.core.game3.scripting.flags") -local Ctx = require("src.core.game3.scripting.ctx") -local Opcodes = require("src.core.game3.scripting.opcodes") - -local function assert_eq(actual, expected, msg) - if actual ~= expected then - error(string.format("FAILED %s: expected %s, got %s", msg or "", tostring(expected), tostring(actual)), 2) - end -end - -print("--- Test 1: Standard Msgbox definitions exist and run ---") -do - local logs = {} - local vm = Vm.new({ - scripts = Std.SCRIPTS, - text = Std.TEXT, - adapters = Adapters.stub({ - log = function(s) logs[#logs + 1] = s end, - openMessage = function() end, - openMessageStay = function() end, - closeMessage = function() end, - }), - }) - - -- Test std:2 (MSGBOX_NPC) - local scriptNpc = { - { op = "loadword", dest = 0, value = "Text_TownMap" }, - { op = "callstd", std = Opcodes.STD.MSGBOX_NPC }, - { op = "end" }, - } - vm.scripts["test_npc"] = scriptNpc - vm:start("test_npc") - while vm:isRunning() do vm:resume() end - assert_eq(#logs, 0, "std:2 should run without missing script warnings") - - -- Test std:3 (MSGBOX_SIGN) - local scriptSign = { - { op = "loadword", dest = 0, value = "Text_TownMap" }, - { op = "callstd", std = Opcodes.STD.MSGBOX_SIGN }, - { op = "end" }, - } - vm.scripts["test_sign"] = scriptSign - vm:start("test_sign") - while vm:isRunning() do vm:resume() end - assert_eq(#logs, 0, "std:3 should run without missing script warnings") - - -- Test std:4 (MSGBOX_DEFAULT) - local scriptDef = { - { op = "loadword", dest = 0, value = "Text_TownMap" }, - { op = "callstd", std = Opcodes.STD.MSGBOX_DEFAULT }, - { op = "end" }, - } - vm.scripts["test_def"] = scriptDef - vm:start("test_def") - while vm:isRunning() do vm:resume() end - assert_eq(#logs, 0, "std:4 should run without missing script warnings") - - -- Test std:5 (MSGBOX_YESNO) - local scriptYesNo = { - { op = "loadword", dest = 0, value = "Text_TownMap" }, - { op = "callstd", std = Opcodes.STD.MSGBOX_YESNO }, - { op = "end" }, - } - vm.scripts["test_yesno"] = scriptYesNo - vm:start("test_yesno") - while vm:isRunning() do vm:resume() end - assert_eq(#logs, 0, "std:5 should run without missing script warnings") - print("ok std:2..std:6 definitions all present and functional") -end - -print("--- Test 2: Full Nurse Joy healing flow (Player chooses YES) ---") -do - local messages = {} - local movements = {} - local fieldEffects = {} - local partyHealed = false - - local session = { - party = { - { species = 1, hp = 5, maxHp = 20, moves = { { id = 1, pp = 0, maxPp = 35 } }, status = 1 }, - { species = 4, hp = 0, maxHp = 18, moves = { { id = 10, pp = 2, maxPp = 20 } }, status = 0 }, - }, - } - package.loaded["src.core.game3.runtime"] = { - isActive = function() return true end, - getSession = function() return session end, - } - - local customAdapters = Adapters.stub({ - openMessageStay = function(body) - messages[#messages + 1] = body - end, - openMessage = function(body) - messages[#messages + 1] = body - end, - closeMessage = function() - messages[#messages + 1] = "" - end, - askYesNo = function(cb) - cb(true) -- User selects YES - end, - applyMovement = function(lid, stream, done) - movements[#movements + 1] = { lid = lid, stream = stream } - if done then done() end - end, - doFieldEffect = function(id) - fieldEffects[#fieldEffects + 1] = id - end, - waitFieldEffect = function(id, done) - if done then done() end - end, - nurseHeal = function(done) - partyHealed = true - session.party[1].hp = session.party[1].maxHp - session.party[1].status = 0 - session.party[2].hp = session.party[2].maxHp - if done then done() end - end, - }) - - local vm = Vm.new({ - scripts = Std.SCRIPTS, - text = Std.TEXT, - adapters = customAdapters, - }) - - -- Map script calling Nurse Joy - local mapScript = { - { op = "lock" }, - { op = "faceplayer" }, - { op = "call", target = "EventScript_PkmnCenterNurse" }, - { op = "release" }, - { op = "end" }, - } - vm.scripts["Nurse_Test"] = mapScript - - vm:startTalk("Nurse_Test", 2) -- Nurse Joy is object localId 2 - while vm:isRunning() do - vm:resume() - end - - -- Verify messages shown in sequence - print("Messages recorded:") - for i, m in ipairs(messages) do - print(string.format(" [%d] %s", i, m:gsub("\n", "\\n"))) - end - - assert_eq(messages[1], "Welcome to our POKéMON CENTER!\fWould you like me to heal your\nPOKéMON to perfect health?", "Message 1: Welcome & Heal question") - assert_eq(messages[2], "OK, may I see your POKéMON?", "Message 2: Take pokemon") - assert_eq(messages[3], "Thank you for waiting.\nYour POKéMON are fully healed.", "Message 3: Fully healed") - assert_eq(messages[4], "We hope to see you again!", "Message 4: Goodbye") - assert_eq(messages[5], "", "Message 5: Box closed on release") - - -- Verify Nurse movements - assert_eq(#movements, 3, "Nurse had 3 movement sequences") - -- Movement 1: Turn left (0x2F) - assert_eq(movements[1].lid, 2, "Movement 1 targeted Nurse Joy") - assert_eq(movements[1].stream[1], 0x2F, "Movement 1: WalkInPlaceFasterLeft") - - -- Field effect 25 - assert_eq(#fieldEffects, 1, "FLDEFF_POKECENTER_HEAL was triggered") - assert_eq(fieldEffects[1], 25, "Field effect 25") - - -- Movement 2: Turn down (0x2D) - assert_eq(movements[2].lid, 2, "Movement 2 targeted Nurse Joy") - assert_eq(movements[2].stream[1], 0x2D, "Movement 2: WalkInPlaceFasterDown") - - -- Heal special called - assert_eq(partyHealed, true, "Party was healed") - assert_eq(session.party[1].hp, 20, "Mon 1 HP restored") - assert_eq(session.party[2].hp, 18, "Mon 2 HP restored") - - -- Movement 3: Nurse Joy bow (0x5B, 0x1A) - assert_eq(movements[3].lid, 2, "Movement 3 targeted Nurse Joy") - assert_eq(movements[3].stream[1], 0x5B, "Movement 3: nurse_joy_bow") - assert_eq(movements[3].stream[2], 0x1A, "Movement 3: delay_4") - - print("ok Full Nurse Joy YES flow matches pret pokefirered 1:1") -end - -print("--- Test 3: Nurse Joy flow (Player chooses NO) ---") -do - local messages = {} - local movements = {} - local fieldEffects = {} - - local customAdapters = Adapters.stub({ - openMessageStay = function(body) - messages[#messages + 1] = body - end, - openMessage = function(body) - messages[#messages + 1] = body - end, - closeMessage = function() - messages[#messages + 1] = "" - end, - askYesNo = function(cb) - cb(false) -- User selects NO - end, - applyMovement = function(lid, stream, done) - movements[#movements + 1] = { lid = lid, stream = stream } - if done then done() end - end, - doFieldEffect = function(id) - fieldEffects[#fieldEffects + 1] = id - end, - }) - - local vm = Vm.new({ - scripts = Std.SCRIPTS, - text = Std.TEXT, - adapters = customAdapters, - }) - - local mapScript = { - { op = "lock" }, - { op = "faceplayer" }, - { op = "call", target = "EventScript_PkmnCenterNurse" }, - { op = "release" }, - { op = "end" }, - } - vm.scripts["Nurse_Test"] = mapScript - - vm:startTalk("Nurse_Test", 2) - while vm:isRunning() do - vm:resume() - end - - assert_eq(messages[1], "Welcome to our POKéMON CENTER!\fWould you like me to heal your\nPOKéMON to perfect health?", "Message 1: Welcome") - assert_eq(messages[2], "We hope to see you again!", "Message 2: Goodbye") - assert_eq(messages[3], "", "Message 3: Closed") - assert_eq(#movements, 0, "No nurse movements on NO") - assert_eq(#fieldEffects, 0, "No field effects on NO") - - print("ok Nurse Joy NO flow matches pret pokefirered 1:1") -end - -print("ALL NURSE JOY TESTS PASSED!") diff --git a/tests/game3_object_interactions_rom_test.lua b/tests/game3_object_interactions_rom_test.lua index b78a7bbd..4eb29441 100644 --- a/tests/game3_object_interactions_rom_test.lua +++ b/tests/game3_object_interactions_rom_test.lua @@ -7,11 +7,22 @@ local E=require('src.import.gba.object_interactions_extract') local p=E.readScripts(rom) local I=require('src.core.game3.scripting.interaction_scripts') local Vm=require('src.core.game3.scripting.vm') -local Content=require('src.import.gba.island1_content') +local V=require('src.import.gba.versions') +local Opcodes=require('src.core.game3.scripting.opcodes') +local seeds,alias={},{} +for i=0,V.STD_SCRIPTS_COUNT-1 do + local ptr=rom:u32(V.STD_SCRIPTS+i*4) + seeds[#seeds+1]=ptr + alias['std:'..i]=Opcodes.key(ptr) +end +local stdBfs=require('src.import.gba.extract_scripts').bfsFromSeeds(rom,seeds) +local stdScripts={} +for k,v in pairs(stdBfs.scripts) do stdScripts[k]=v end +for name,key in pairs(alias) do stdScripts[name]=assert(stdBfs.scripts[key]) end local textCount=0 for _,row in ipairs(I.FLAVOR) do local messages={} - local vm=Vm.new({scripts=p.scripts,text=p.text,stdscripts=Content.STDSCRIPTS, + local vm=Vm.new({scripts=p.scripts,text=p.text,stdscripts=stdScripts, onMessage=function(text) messages[#messages+1]=text end}) assert(vm:start('EventScript_'..row[2])) for _=1,100 do vm:tick() end diff --git a/tests/game3_old_man_tutorial_battle_test.lua b/tests/game3_old_man_tutorial_battle_test.lua index 9469146b..dc00c71f 100644 --- a/tests/game3_old_man_tutorial_battle_test.lua +++ b/tests/game3_old_man_tutorial_battle_test.lua @@ -153,38 +153,4 @@ check(ok2, "second battle started") local res = Battle.runToEnd() eq(res, "catch", "battle concluded with catch outcome") -print("[test] 6. Old Man back pic extraction across FR 1.0, FR 1.1, LG 1.0, LG 1.1") -do - local mockLove = { - image = { newImageData = function(w, h, fmt, data) return { data = data, w = w, h = h } end }, - graphics = { newImage = function(imgData) return { imgData = imgData } end }, - } - local prevLove = _G.love - _G.love = mockLove - - local romFiles = { - { edition = "FireRed 1.0", file = "firered_dump.gba" }, - { edition = "FireRed 1.1", file = "Pokemon - Fire Red Version (U) (V1.1).gba" }, - { edition = "LeafGreen 1.0", file = "Pokemon - LeafGreen Version (USA).gba" }, - { edition = "LeafGreen 1.1", file = "Pokemon - LeafGreen Version (USA, Europe) (Rev 1).gba" }, - } - - for _, rf in ipairs(romFiles) do - local f = io.open(rf.file, "rb") - if f then - local raw = f:read("*a") - f:close() - TrainerPic._rom = nil - TrainerPic._front = {} - TrainerPic._back = {} - TrainerPic._cache = nil - local pic = TrainerPic.back(5) - check(pic ~= nil and pic.w == 64 and pic.h == 256 and pic.frames == 4, - string.format("Old Man back pic extracted successfully for %s", rf.edition)) - end - end - - _G.love = prevLove -end - print("[PASS] game3 old man tutorial battle tests") diff --git a/tests/game3_oneoff_specials_test.lua b/tests/game3_oneoff_specials_test.lua index 2a47153f..68398927 100644 --- a/tests/game3_oneoff_specials_test.lua +++ b/tests/game3_oneoff_specials_test.lua @@ -270,13 +270,25 @@ do ctx.messageOpen = true for _ = 1, 6 do Events.pollWalkaway(vm, inputDown) end Events.pollWalkaway(vm, inputDown) - checkEq(ctx.msgBoxIsCancelable, false, "disable sets ctx.msgBoxIsCancelable=false") + checkEq(ctx.msgBoxIsCancelable, true, "disable keeps ctx.msgBoxIsCancelable") checkEq(ctx.canWalkAway, false, "disable sets ctx.canWalkAway=false") - checkEq(session.msgBoxIsCancelable, false, "disable sets session.msgBoxIsCancelable=false") checkEq(session.canWalkAway, false, "disable sets session.canWalkAway=false") checkEq(halted, false, "disabled walkaway never cancels") checkEq(closed, 0, "message stays open when walkaway is disabled") + -- field_control_avatar.c:323 + local deferred + package.loaded["src.core.game3.runtime"].defer = function(fn) deferred = fn return true end + local inputStart = { + wasPressed = function(_, k) return k == "start" end, + isDown = function(_, k) return k == "start" end, + } + Events.pollWalkaway(vm, inputStart) + checkEq(closed, 1, "START cancels the sign even with walkaway disabled") + check(halted == true, "START aborts the sign script") + check(type(deferred) == "function", "START queues the start menu open") + package.loaded["src.core.game3.runtime"].defer = nil + halted, closed = false, 0 Natives.special(ctx, Std.SPECIAL.SetWalkingIntoSignVars) ctx.messageOpen = true diff --git a/tests/game3_pc_anim_test.lua b/tests/game3_pc_anim_test.lua index 69e40d96..e82ca30f 100644 --- a/tests/game3_pc_anim_test.lua +++ b/tests/game3_pc_anim_test.lua @@ -107,13 +107,12 @@ do local Vm = require("src.core.game3.scripting.vm") local Natives = require("src.core.game3.scripting.natives") - local script = Std.SCRIPTS.EventScript_PC - assert_eq(type(script), "table", "EventScript_PC exists in Std.SCRIPTS") + local Fixture = require("tests.fixture_data.game3_pc_scripts") local Adapters = require("src.core.game3.scripting.adapters") local vm = Vm.new({ - scripts = Std.SCRIPTS, - text = Std.TEXT, + scripts = Fixture.SCRIPTS, + text = Fixture.TEXT, adapters = Adapters.stub({ openPc = function(done) -- Simulate user closing PC immediately diff --git a/tests/game3_pc_sfx_2415_test.lua b/tests/game3_pc_sfx_2415_test.lua index af7bcc5a..6e511c91 100644 --- a/tests/game3_pc_sfx_2415_test.lua +++ b/tests/game3_pc_sfx_2415_test.lua @@ -2,6 +2,7 @@ -- pokefirered/data/scripts/pc.inc:1 package.path = "./?.lua;./?/init.lua;" .. package.path +package.loaded["src.core.game3.rom_text"] = { plain = function(key) return key end, box = function(key) return key end } local failed = 0 local function check(cond, msg) @@ -53,39 +54,37 @@ local Bag = require("src.core.game3.bag") local Storage = require("src.core.game3.storage") local function session() return { bag = Bag.new(), storage = Storage.new(), name = "RED" } end -print("[test] 1. EventScript_PC carries playse SE_PC_ON right after AnimatePcTurnOn") -local rows = Std.SCRIPTS.EventScript_PC -local onIdx, animIdx -for i, r in ipairs(rows) do - if r.op == "special" and r.id == Std.SPECIAL.AnimatePcTurnOn then animIdx = i end - if r.op == "playse" and r[1] == 4 then onIdx = i end -end -check(animIdx ~= nil and onIdx == animIdx + 1, "playse 4 follows AnimatePcTurnOn") - -print("[test] 2. Center PC flow: ON, message, menu, storage LOGIN, LOG OFF") +print("[test] 2. Center PC flow (pc.inc fixture): ON, menu, storage LOGIN, menu again, LOG OFF") local Vm = require("src.core.game3.scripting.vm") local Adapters = require("src.core.game3.scripting.adapters") +local Fixture = require("tests.fixture_data.game3_pc_scripts") +local picks = { 1, 3 } +local results = {} local vm = Vm.new({ - scripts = Std.SCRIPTS, - text = Std.TEXT, + scripts = Fixture.SCRIPTS, + text = Fixture.TEXT, adapters = Adapters.stub({ onMessage = function() events[#events + 1] = "msg" end, - openPc = function(done) - events[#events + 1] = "menu" - PcMenu.show({ session = session() }) + openPc = function(done, opts) + local mode = opts and opts.mode + events[#events + 1] = mode or "pc" + if mode ~= "select" then return done() end + PcMenu.show({ session = session(), startMode = "select", silentClose = true, + onClose = function(r) results[#results + 1] = r; done(r) end }) + PcMenu.cursor = table.remove(picks, 1) PcMenu.handleInput(input("a")) - PcMenu.close() - done() end, }), }) check(vm:start("EventScript_PC") == true, "vm started EventScript_PC") local steps = 0 -while vm:isRunning() and steps < 80 do vm:step(); steps = steps + 1 end +while vm:isRunning() and steps < 200 do vm:step(); steps = steps + 1 end check(not vm:isRunning(), "EventScript_PC completed") local seq = table.concat(events, ",") -check(seq == "anim_on,se4,msg,menu,se5,se2,se3", - "ordered events anim_on,se4,msg,menu,se5,se2,se3 (got " .. seq .. ")") +local want = "anim_on,se4,msg,msg,select,se5,se2,msg,msg,storage,msg,select,se5,se3" +check(seq == want, "ordered events " .. want .. " (got " .. seq .. ")") +check(results[1] == 0 and results[2] == 2, "CreatePCMenu returns row indexes 0 then 2 (got " + .. tostring(results[1]) .. "," .. tostring(results[2]) .. ")") local function flagged(dex, clear) local s = session() @@ -119,32 +118,39 @@ for _, c in ipairs({ PcMenu.close() end -print("[test] 4. root menu open plays nothing; each access row plays SELECT then LOGIN") +print("[test] 4. CreatePCMenu: open plays nothing; A plays SELECT only and returns the row index") for i, id in ipairs({ "storage", "player", "oak", "hall" }) do local s = flagged(true, true) events = {} - PcMenu.show({ session = s }) + local got + PcMenu.show({ session = s, startMode = "select", silentClose = true, onClose = function(r) got = r end }) check(seOnly() == "", id .. ": root open plays no SE") PcMenu.cursor = i check(PcMenu._rootEntries()[i].id == id, id .. " is root row " .. i) PcMenu.handleInput(input("a")) - check(seOnly() == "se5,se2", id .. ": A plays se5,se2 (got " .. seOnly() .. ")") - PcMenu.close() + check(seOnly() == "se5", id .. ": A plays se5 (got " .. seOnly() .. ")") + check(got == i - 1, id .. ": VAR_RESULT " .. (i - 1) .. " (got " .. tostring(got) .. ")") end -print("[test] 5. LOG OFF and B play SE_SELECT then SE_PC_OFF") +print("[test] 5. CreatePCMenu LOG OFF returns its row, B returns SCR_MENU_CANCEL") for _, c in ipairs({ { false, false, 3 }, { true, false, 4 }, { true, true, 5 } }) do events = {} - PcMenu.show({ session = flagged(c[1], c[2]) }) + local got + PcMenu.show({ session = flagged(c[1], c[2]), startMode = "select", silentClose = true, + onClose = function(r) got = r end }) PcMenu.cursor = c[3] check(PcMenu._rootEntries()[c[3]].id == "quit", "LOG OFF is row " .. c[3]) PcMenu.handleInput(input("a")) - check(seOnly() == "se5,se3", "LOG OFF row " .. c[3] .. " plays se5,se3 (got " .. seOnly() .. ")") + check(seOnly() == "se5", "LOG OFF row " .. c[3] .. " plays se5 (got " .. seOnly() .. ")") + check(got == c[3] - 1, "LOG OFF row " .. c[3] .. " returns " .. (c[3] - 1) .. " (got " .. tostring(got) .. ")") end events = {} -PcMenu.show({ session = flagged(false, false) }) +local cancel +PcMenu.show({ session = flagged(false, false), startMode = "select", silentClose = true, + onClose = function(r) cancel = r end }) PcMenu.handleInput(input("b")) -check(seOnly() == "se5,se3", "B plays se5,se3 (got " .. seOnly() .. ")") +check(seOnly() == "se5", "B plays se5 (got " .. seOnly() .. ")") +check(cancel == 127, "B returns 127 (got " .. tostring(cancel) .. ")") print("[test] 6. bedroom player_pc open plays nothing") events = {} diff --git a/tests/game3_pokedex_rating_test.lua b/tests/game3_pokedex_rating_test.lua index 815d3647..96da7a0d 100644 --- a/tests/game3_pokedex_rating_test.lua +++ b/tests/game3_pokedex_rating_test.lua @@ -1,7 +1,10 @@ #!/usr/bin/env luajit package.path = "./?.lua;./?/init.lua;" .. package.path +local Game3Cache = require("tests.game3_cache") +if not Game3Cache.bundle() then print("[skip] pokedex_rating: " .. tostring(Game3Cache.reason)) return end local PokedexRating = require("src.core.game3.pokedex_rating") +local RomText = require("src.core.game3.rom_text") local Dex = require("src.core.game3.dex") local Std = require("src.core.game3.scripting.stdscripts") local Natives = require("src.core.game3.scripting.natives") @@ -125,14 +128,14 @@ do -- Case A: Count = 42 Flags.setVar(session, ctx, 0x8004, 42) Natives.special(ctx, Std.SPECIAL.GetProfOaksRatingMessage, adapters) - eq(openedMsg, PokedexRating.TEXT.LESS_THAN_50, "opened message for 42 caught") + eq(openedMsg, RomText.box(PokedexRating.TEXT.LESS_THAN_50), "opened message for 42 caught") eq(Flags.getVar(session, ctx, 0x800D), 0, "VAR_RESULT = 0 for incomplete") -- Case B: Count = 150 without Mew for sp = 1, 150 do Dex.setCaught(session.dex, sp) end Flags.setVar(session, ctx, 0x8004, 150) Natives.special(ctx, Std.SPECIAL.GetProfOaksRatingMessage, adapters) - eq(openedMsg, PokedexRating.TEXT.COMPLETE, "opened complete message for 150 without Mew") + eq(openedMsg, RomText.box(PokedexRating.TEXT.COMPLETE), "opened complete message for 150 without Mew") eq(Flags.getVar(session, ctx, 0x800D), 1, "VAR_RESULT = 1 for complete") end diff --git a/tests/game3_runtime_registered_item_test.lua b/tests/game3_runtime_registered_item_test.lua index bc3c6181..7872e9e7 100644 --- a/tests/game3_runtime_registered_item_test.lua +++ b/tests/game3_runtime_registered_item_test.lua @@ -21,6 +21,7 @@ package.loaded["src.core.game3.audio"] = { playFanfare = function() end, stopAll = function() end, waitSe = function(_, cb) if cb then cb() end end, + bikeMusic = function() end, } local fieldMessages = {} diff --git a/tests/game3_seafoam_puzzle_test.lua b/tests/game3_seafoam_puzzle_test.lua index 74675cb7..6b388ce2 100644 --- a/tests/game3_seafoam_puzzle_test.lua +++ b/tests/game3_seafoam_puzzle_test.lua @@ -109,11 +109,24 @@ local function pumpVm(limit) return n end +-- pokefirered/src/field_player_avatar.c:1445 DoBoulderFinish +local function pumpPush(frames) + for _ = 1, frames or 33 do + Objects.update(game) + Player.update(game, nil) + end +end + print("[test] 1. New game initial Seafoam flag state") -check(Flags.getFlag(session, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_1) == true, "B4F boulder 1 starts HIDDEN") -check(Flags.getFlag(session, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_2) == true, "B4F boulder 2 starts HIDDEN") -check(Flags.getFlag(session, nil, FLAG_HIDE_SEAFOAM_B3F_BOULDER_1) == true, "B3F boulder 1 starts HIDDEN") -check(Flags.getFlag(session, nil, FLAG_HIDE_SEAFOAM_B3F_BOULDER_2) == true, "B3F boulder 2 starts HIDDEN") +check(Flags.getFlag(session, nil, FLAG_HIDE_SEAFOAM_B3F_BOULDER_1) == false, "B3F boulder 1 is not hidden by the new-game reset") +check(Flags.getFlag(session, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_1) == false, "B4F boulder 1 is not hidden by the new-game reset") +-- data/maps/Route20/scripts.inc:5 +enterMap("FR_ROUTE_20") +pumpVm() +check(Flags.getFlag(Space.store, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_1) == true, "B4F boulder 1 starts HIDDEN") +check(Flags.getFlag(Space.store, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_2) == true, "B4F boulder 2 starts HIDDEN") +check(Flags.getFlag(Space.store, nil, FLAG_HIDE_SEAFOAM_B3F_BOULDER_1) == true, "B3F boulder 1 starts HIDDEN") +check(Flags.getFlag(Space.store, nil, FLAG_HIDE_SEAFOAM_B3F_BOULDER_2) == true, "B3F boulder 2 starts HIDDEN") check(Flags.getFlag(session, nil, FLAG_STOPPED_SEAFOAM_B3F_CURRENT) == false, "B3F current starts ACTIVE") check(Flags.getFlag(session, nil, FLAG_STOPPED_SEAFOAM_B4F_CURRENT) == false, "B4F current starts ACTIVE") @@ -141,14 +154,16 @@ Flags.setFlag(Space.store, nil, FieldMoves.SYS_FLAGS.USE_STRENGTH, true) -- Move boulder 6 (at 6,17) into hole at (6,18) standAt(6, 16, "down") local r1 = Player.tryMove("down", game, false) -eq(r1, "step", "boulder 6 pushed into hole") +eq(r1, "push", "boulder 6 pushed into hole") +pumpPush() check(Flags.getFlag(Space.store, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_1) == false, "B4F boulder 1 is revealed (unhidden)") -- Position boulder 3 at (9,17) and push into hole at (9,18) Objects.setObjectXY(3, 9, 17) standAt(9, 16, "down") local r2 = Player.tryMove("down", game, false) -eq(r2, "step", "boulder 3 pushed into hole") +eq(r2, "push", "boulder 3 pushed into hole") +pumpPush() check(Flags.getFlag(Space.store, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_2) == false, "B4F boulder 2 is revealed (unhidden)") print("[test] 5. Enter B4F after dropping both boulders: current STOPS, calm water layout applied") @@ -179,7 +194,7 @@ eq(Player.surfing, false, "player was dumped on land and is no longer surfing") eq(Player.facing, "up", "player facing snapped to up (North)") eq(Player.cellY, 12, "player jumped up onto the stairs/land (y=12)") -print("[test] 7. Legacy save repair: empty flags restored with correct defaults and invariants") +print("[test] 7. Legacy save repair: empty flags restored with correct defaults") local legacySave = { schemaVersion = 1, name = "ASH", @@ -187,44 +202,12 @@ local legacySave = { vars = {}, } local restoredSession = Schema.fromSaveTable(legacySave) -check(Flags.getFlag(restoredSession, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_1) == true, "Legacy save: B4F boulder 1 is hidden") -check(Flags.getFlag(restoredSession, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_2) == true, "Legacy save: B4F boulder 2 is hidden") -check(Flags.getFlag(restoredSession, nil, FLAG_STOPPED_SEAFOAM_B4F_CURRENT) == false, "Legacy save: B4F current is active") check(Flags.getFlag(restoredSession, nil, 0x02C) == true, "Legacy save: Pallet Town Oak is hidden") check(Flags.getFlag(restoredSession, nil, 0x033) == true, "Legacy save: Bill (human) is hidden") check(Flags.getFlag(restoredSession, nil, 0x092) == true, "Legacy save: Pewter running shoes aide is hidden") check(Flags.getFlag(restoredSession, nil, 0x035) == true, "Legacy save: Mr. Fuji in house is hidden") check(Flags.getFlag(restoredSession, nil, 0x05A) == true, "Legacy save: Oak in champ room is hidden") -print("[test] 8. Legacy save repair: premature calm water flag fixed when boulders were not dropped") -local corruptedSave = { - schemaVersion = 1, - name = "RED", - flags = { - [tostring(FLAG_STOPPED_SEAFOAM_B4F_CURRENT)] = true, -- erroneously set - }, - vars = {}, -} -local fixedSession = Schema.fromSaveTable(corruptedSave) -check(Flags.getFlag(fixedSession, nil, FLAG_STOPPED_SEAFOAM_B4F_CURRENT) == false, "Corrupted calm water flag corrected to false") -check(Flags.getFlag(fixedSession, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_1) == true, "Corrupted save: B4F boulder 1 hidden") -check(Flags.getFlag(fixedSession, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_2) == true, "Corrupted save: B4F boulder 2 hidden") - -print("[test] 9. Legacy save repair: completed puzzle retains calm water and visible dropped boulders") -local solvedSave = { - schemaVersion = 1, - name = "RED", - flags = { - [tostring(FLAG_HIDE_SEAFOAM_B3F_BOULDER_3)] = true, -- B3F obj 6 dropped - [tostring(FLAG_HIDE_SEAFOAM_B3F_BOULDER_5)] = true, -- B3F obj 3 dropped - }, - vars = {}, -} -local solvedSession = Schema.fromSaveTable(solvedSave) -check(Flags.getFlag(solvedSession, nil, FLAG_STOPPED_SEAFOAM_B4F_CURRENT) == true, "Solved save: B4F current stopped") -check(Flags.getFlag(solvedSession, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_1) == false, "Solved save: B4F boulder 1 visible") -check(Flags.getFlag(solvedSession, nil, FLAG_HIDE_SEAFOAM_B4F_BOULDER_2) == false, "Solved save: B4F boulder 2 visible") - if failed > 0 then print("[FAIL] " .. failed .. " tests failed") os.exit(1) diff --git a/tests/game3_step_events_queue_test.lua b/tests/game3_step_events_queue_test.lua index 0a08420b..1ee69c0d 100644 --- a/tests/game3_step_events_queue_test.lua +++ b/tests/game3_step_events_queue_test.lua @@ -5,6 +5,8 @@ -- 3. TM Case & Berry Pouch Sub-Containers & Bag state persistence package.path = "./?.lua;./?/init.lua;" .. package.path +local Game3Cache = require("tests.game3_cache") +if not Game3Cache.bundle() then print("[skip] step_events_queue: " .. tostring(Game3Cache.reason)) return end local failed = 0 local function check(cond, msg) @@ -106,7 +108,7 @@ check(StepEvents.busy(), "Repel wear-off event queued") local handledRepel = false local mockHud = { openMessage = function(_game, msg, opts) - if msg:find("Repel's effect wore off") then + if msg == require("src.core.game3.rom_text").box("Text_RepelWoreOff") then handledRepel = true end if opts and opts.done then opts.done() end @@ -114,7 +116,7 @@ local mockHud = { } package.loaded["src.ui.game3.hud"] = mockHud StepEvents.update(0.1) -check(handledRepel, "Repel displayed retail 'Repel's effect wore off...' message without prompt") +check(handledRepel, "Repel displayed ROM Text_RepelWoreOff") print("=== 4. Happiness & Egg Cycles Mechanics ===") @@ -159,6 +161,8 @@ mockHud.openMessage = function(_game, msg, opts) end StepEvents.update(0.1) check(faintedMsg, "Lethal poison faint event processed") +StepEvents.update(0.1) +check(not StepEvents.busy(), "Party still standing: the whiteout check releases the queue") -- Next 4 steps drop last Pokémon to 0 HP -> Whiteout for i = 1, 4 do @@ -166,13 +170,28 @@ for i = 1, 4 do end check(poisonedMon1.hp == 0, "Poisoned mon 1 dropped to 0 HP (entire party fainted)") -local whiteoutTriggered = false -session.onWhiteout = function() - whiteoutTriggered = true +session.money = 1000 +local messages = {} +mockHud.openMessage = function(_game, msg, opts) + messages[#messages + 1] = msg + if opts and opts.done then opts.done() end end -StepEvents.update(0.1) -check(whiteoutTriggered, "Whiteout triggered and StepEvents queue cleanly flushed") +local respawned, locked = false, false +package.loaded["src.core.game3.audio"] = { fadeOutBgm = function() end, playCry = function() end } +package.loaded["src.ui.game3.fade"] = { MODE = { TO_BLACK = 1 }, begin = function(_, _, cb) if cb then cb() end end } +package.loaded["src.core.game3.field"] = { + lock = function() locked = true end, + respawnAtHeal = function() respawned = true end, +} +for _ = 1, 4 do StepEvents.update(0.1) end +check(messages[1] and messages[1]:find("BULBA fainted…", 1, true) ~= nil, "Last faint message shown before the wipe check") +check(messages[2] and messages[2]:find("panicked and lost ¥8…", 1, true) ~= nil, "Whiteout text carries the money loss") +check(session.money == 992, "Money loss applied on field whiteout (" .. tostring(session.money) .. ")") +check(locked and respawned, "Field whiteout locks the player and respawns at the heal point") check(not StepEvents.busy(), "StepEvents queue completely empty after whiteout") +package.loaded["src.core.game3.audio"] = nil +package.loaded["src.ui.game3.fade"] = nil +package.loaded["src.core.game3.field"] = nil print("=== 6. VS Seeker Map Gate & Uncharged Use ===") diff --git a/tests/game3_stitchfield_flash_test.lua b/tests/game3_stitchfield_flash_test.lua index 194d5741..92797708 100644 --- a/tests/game3_stitchfield_flash_test.lua +++ b/tests/game3_stitchfield_flash_test.lua @@ -34,6 +34,15 @@ Field.locked = false FieldEffects._anims = {} local text = FieldMoves.TEXT.USED_FLASH:gsub("{STR_VAR_1}", "PIKACHU") Field.executeFieldMove({ action = "flash", text = text }) +local ShowMon = require("src.core.game3.field_move_show_mon") +local function drainShowMon() + for _ = 1, 300 do + if not ShowMon.isActive() then break end + ShowMon.step() + end +end +check(ShowMon.isActive() == true, "the show-mon runs before the flash") +drainShowMon() check(Message.isOpen() == true, "the message box is open on the frame the move starts") check(Field.locked == true, "and the field is locked for the animation") local anims = #FieldEffects._anims @@ -57,6 +66,7 @@ print("[test] 3. a FLASH with no text still unlocks") Field.locked = false FieldEffects._anims = {} Field.executeFieldMove({ action = "flash" }) +drainShowMon() check(Message.isOpen() == false, "no message box for a textless FLASH") for _ = 1, 40 do FieldEffects.step() end check(Field.locked == false, "and the field unlocked") diff --git a/tests/game3_stitchsave_schema_fields_test.lua b/tests/game3_stitchsave_schema_fields_test.lua index dcb024ff..df2cc13c 100644 --- a/tests/game3_stitchsave_schema_fields_test.lua +++ b/tests/game3_stitchsave_schema_fields_test.lua @@ -96,7 +96,7 @@ do local save = { schemaVersion = 1, engine = "game3", version = "firered", party = {}, dex = { seen = {}, owned = {} }, - map = "FR_SAFARI_ZONE_CENTER", x = 4, y = 4, + map = "FR_FUCHSIA_CITY", x = 4, y = 4, flags = { [FLAG_SYS_SAFARI_MODE] = true, [tostring(FLAG_SYS_SAFARI_MODE)] = true, @@ -132,6 +132,20 @@ do session.safari = { balls = 30, steps = 600 } eq(Schema.toSaveTable(session).safari, nil, "and they are EWRAM, so no save block ever carries them") + + local stranded = Schema.fromSaveTable({ + schemaVersion = 1, engine = "game3", version = "firered", + party = {}, dex = { seen = {}, owned = {} }, + map = "FR_SAFARI_ZONE_CENTER", x = 26, y = 30, facing = "up", + flags = {}, vars = {}, + }) + -- pokefirered/data/scripts/safari_zone.inc:7 SafariZone_EventScript_Exit + eq(stranded.map, "FR_FUCHSIA_CITY_SAFARI_ZONE_ENTRANCE", "a save left in the zone resumes at the gate") + eq(stranded.x, 4, "gate x") + eq(stranded.y, 1, "gate y") + eq(Flags.getVar(stranded, nil, VAR_SAFARI_ENTRANCE), 1, "entrance ExitWarpIn scene queued") + local again = Schema.fromSaveTable(Schema.toSaveTable(stranded)) + eq(again.map, "FR_FUCHSIA_CITY_SAFARI_ZONE_ENTRANCE", "second Continue stays at the gate") end print("[test] 5. The mail pool rides the save through mail.lua") diff --git a/tests/game3_stitchuif_bag_use_test.lua b/tests/game3_stitchuif_bag_use_test.lua index d625fc5a..06f9a27b 100644 --- a/tests/game3_stitchuif_bag_use_test.lua +++ b/tests/game3_stitchuif_bag_use_test.lua @@ -19,6 +19,7 @@ package.loaded["src.core.game3.audio"] = { playFanfare = function() end, stopAll = function() end, waitSe = function(_, cb) if cb then cb() end end, + bikeMusic = function() end, } local water = {} @@ -196,6 +197,12 @@ print("[test] 8. the WHITE FLUTE prints in the bag too (item_use.c:610)") local s8 = new_session() Bag.add(s8.bag, 43, 1) use_from_bag(s8, 43, "ITEMS") +check(BagMenu.isOpen() == true and BagMenu.mode == "flute_wait", + "the white flute holds the bag before its message (item_use.c:607)") +input:press("none") +for _ = 1, 7 do BagMenu.handleInput(input) end +check(BagMenu.mode == "flute_wait", "no message for the first 7 frames") +BagMenu.handleInput(input) check(BagMenu.isOpen() == true and BagMenu.mode == "message", "the white flute keeps the bag open with its message") check(BagMenu.messageText == Strings("%s used the\n%s.", "RED", "WHITE FLUTE"), diff --git a/tests/game3_storage_test.lua b/tests/game3_storage_test.lua index f516b48c..4e32d809 100644 --- a/tests/game3_storage_test.lua +++ b/tests/game3_storage_test.lua @@ -1,4 +1,5 @@ -- Automated Test Suite for Game 3 FRLG PC & Pokémon Storage System. +package.loaded["src.core.game3.rom_text"] = { plain = function(key) return key end, box = function(key) return key end } local Storage = require("src.core.game3.storage") local Bag = require("src.core.game3.bag") @@ -380,34 +381,34 @@ assert_eq(PcMenu.mode, "root", "Started in root menu") PcMenu.handleInput(make_input({ a = true })) assert_eq(PcMenu.mode, "storage_menu", "Entered storage_menu") assert_eq(PcMenu.cursor, 1, "Cursor on WITHDRAW POKéMON") -assert_true(PcMenu._status:find("You can withdraw"), "Description matches WITHDRAW POKéMON") +assert_eq(PcMenu._status, "gText_WithdrawMonDescription", "Description matches WITHDRAW POKéMON") -- Navigate down to DEPOSIT POKéMON (index 2) PcMenu.handleInput(make_input({ down = true })) assert_eq(PcMenu.cursor, 2, "Cursor on DEPOSIT POKéMON") -assert_true(PcMenu._status:find("You can deposit"), "Description matches DEPOSIT POKéMON") +assert_eq(PcMenu._status, "gText_DepositMonDescription", "Description matches DEPOSIT POKéMON") -- Navigate down to MOVE POKéMON (index 3) PcMenu.handleInput(make_input({ down = true })) assert_eq(PcMenu.cursor, 3, "Cursor on MOVE POKéMON") -assert_true(PcMenu._status:find("You can move POKéMON"), "Description matches MOVE POKéMON") +assert_eq(PcMenu._status, "gText_MoveMonDescription", "Description matches MOVE POKéMON") -- Navigate down to MOVE ITEMS (index 4) PcMenu.handleInput(make_input({ down = true })) assert_eq(PcMenu.cursor, 4, "Cursor on MOVE ITEMS") -assert_true(PcMenu._status:find("You can move items"), "Description matches MOVE ITEMS") +assert_eq(PcMenu._status, "gText_MoveItemsDescription", "Description matches MOVE ITEMS") -- Navigate down to SEE YA! (index 5) PcMenu.handleInput(make_input({ down = true })) assert_eq(PcMenu.cursor, 5, "Cursor on SEE YA!") -assert_true(PcMenu._status:find("See you later"), "Description matches SEE YA!") +assert_eq(PcMenu._status, "gText_SeeYaDescription", "Description matches SEE YA!") -- Test DEPOSIT validation when party is 1 PcMenu.handleInput(make_input({ up = true, up = true, up = true })) -- index 2 PcMenu.cursor = 2 PcMenu.handleInput(make_input({ a = true })) assert_eq(PcMenu.mode, "msg", "Blocked deposit due to single party Pokémon") -assert_eq(PcMenu._status, "Can't deposit the last POKéMON!", "Error status matches FRLG text") +assert_eq(PcMenu._status, "gText_JustOnePkmn", "Error status matches FRLG text") PcMenu.handleInput(make_input({ a = true })) assert_eq(PcMenu.mode, "storage_menu", "Returned to storage_menu") @@ -419,7 +420,7 @@ assert_eq(#storageSubSession.party, 6, "Party is now full (6 mons)") PcMenu.cursor = 1 PcMenu.handleInput(make_input({ a = true })) assert_eq(PcMenu.mode, "msg", "Blocked withdraw due to full party") -assert_eq(PcMenu._status, "Can't take any more POKéMON.", "Error status matches FRLG text") +assert_eq(PcMenu._status, "gText_PartyFull", "Error status matches FRLG text") PcMenu.handleInput(make_input({ a = true })) assert_eq(PcMenu.mode, "storage_menu", "Returned to storage_menu") @@ -537,7 +538,7 @@ assert_eq(BoxStorageUI.mode, "action_menu", "Action menu opened on Rattata") BoxStorageUI.actionCursor = 1 -- STORE BoxStorageUI.handleInput(make_input({ a = true })) assert_eq(BoxStorageUI.mode, "message", "Entered message mode on last mon store attempt") -assert_eq(BoxStorageUI._status, "Can't deposit the last POKéMON!", "Error status matches") +assert_eq(BoxStorageUI._status, "gText_JustOnePkmn", "Error status matches") -- Dismiss message -> returns to party_drawer BoxStorageUI.handleInput(make_input({ a = true })) diff --git a/tests/game3_strings_module_tables_test.lua b/tests/game3_strings_module_tables_test.lua index 52ed3dcb..5b20c3a5 100644 --- a/tests/game3_strings_module_tables_test.lua +++ b/tests/game3_strings_module_tables_test.lua @@ -22,12 +22,12 @@ local ItemsData = require("src.core.game3.items_data") Trainers._pack = { trainers = {} } -- no ROM pack: use the built-in fallbacks Strings.load({ strings = { - ["Can't use that here."] = "Impossible d'utiliser ça ici.", + ["Not enough HP!"] = "Pas assez de PV!", ["RIVAL: Yeah!\nAm I great or what?"] = "RIVAL: Ouais!\nJe suis trop fort!", ["KEY ITEMS"] = "OBJETS RARES", } }) -check(FieldMoves.TEXT.CANT_USE_HERE == "Impossible d'utiliser ça ici.", +check(FieldMoves.TEXT.NOT_ENOUGH_HP == "Pas assez de PV!", "FieldMoves.TEXT translates when read") check(FieldMoves.TEXT.NOT_A_KEY == nil, "FieldMoves.TEXT has no entry for an unknown key") diff --git a/tests/game3_text_colors_test.lua b/tests/game3_text_colors_test.lua index f5144135..dfa00b0f 100644 --- a/tests/game3_text_colors_test.lua +++ b/tests/game3_text_colors_test.lua @@ -224,7 +224,6 @@ local Ctx = require("src.core.game3.scripting.ctx") local Flags = require("src.core.game3.scripting.flags") local Adapters = require("src.core.game3.scripting.adapters") local Vm = require("src.core.game3.scripting.vm") -local Std = require("src.core.game3.scripting.stdscripts") local c0 = Ctx.new() assert_eq(c0.specialVars[0x8012], 255, "fresh ctx VAR_TEXT_COLOR is DEFAULT") @@ -279,7 +278,11 @@ local adapters = Adapters.stub({ onMessage = function() seen[#seen + 1] = Adapters.resolveNpcColor(vmRef.ctx) end, }) local scripts = {} -for k, v in pairs(Std.SCRIPTS) do scripts[k] = v end +-- data/scripts/obtain_item.inc:6 +scripts.EventScript_RestorePrevTextColor = { + { op = "copyvar", [1] = 0x8012, [2] = 0x8013 }, + { op = "return" }, +} scripts.test_npc = { { op = "message", ptr = "T" }, { op = "textcolor", color = 3 }, @@ -311,22 +314,6 @@ assert_eq(seen[1], 3, "sign/trigger (LAST_TALKED 0) is NEUTRAL") print("[ok] textcolor / RestorePrevTextColor / reset rules") print("=== [TEST 11] std bookends (obtain_item.inc:10, std_msgbox.inc:29) ===") -local s0 = Std.SCRIPTS["std:0"] -assert_eq(s0[1].op, "copyvar", "std:0 saves text color first") -assert_eq(s0[1][1], 0x8013, "std:0 copies into VAR_PREV_TEXT_COLOR") -assert_eq(s0[1][2], 0x8012, "std:0 copies from VAR_TEXT_COLOR") -assert_eq(s0[2].op, "textcolor", "std:0 forces textcolor") -assert_eq(s0[2].color, 3, "std:0 forces NEUTRAL") -assert_eq(s0[#s0].op, "return", "std:0 returns") -assert_eq(s0[#s0 - 1].op, "copyvar", "std:0 restores before return") -assert_eq(s0[#s0 - 1][1], 0x8012, "std:0 restores VAR_TEXT_COLOR") -assert_eq(s0[#s0 - 1][2], 0x8013, "std:0 restores from VAR_PREV_TEXT_COLOR") -local s9 = Std.SCRIPTS["std:9"] -assert_eq(s9[1].op, "textcolor", "std:9 starts with textcolor") -assert_eq(s9[1].color, 3, "std:9 forces NEUTRAL") -local show = Std.SCRIPTS.EventScript_ReceivedItemShowMsg -assert_eq(show[#show - 1].target, "EventScript_RestorePrevTextColor", "std:9 restores after putitemaway") - seen = {} scripts.test_received = { { op = "textcolor", color = 0 }, diff --git a/tests/game3_town_map_test.lua b/tests/game3_town_map_test.lua index 1e6cfc00..1fc89c07 100644 --- a/tests/game3_town_map_test.lua +++ b/tests/game3_town_map_test.lua @@ -163,7 +163,6 @@ end print("=== [TEST 5] Wall Town Map Metatile & Script Execution ===") do local Interaction = require("src.core.game3.scripting.interaction_scripts") - local Std = require("src.core.game3.scripting.stdscripts") local CollisionStd = require("src.core.game3.scripting.collision_std") local scriptKey = Interaction.scriptFor(0x85, "up") @@ -172,12 +171,6 @@ do local collScript = CollisionStd.scriptFor(0x95) check(collScript == "EventScript_WallTownMap", "COLL_TOWN_MAP (0x95) maps to EventScript_WallTownMap") - local script = Std.SCRIPTS.EventScript_WallTownMap - check(script ~= nil, "EventScript_WallTownMap is defined in Std.SCRIPTS") - check(script[1].op == "lockall", "WallTownMap step 1 is lockall") - check(script[4].op == "fadescreen", "WallTownMap step 4 is fadescreen") - check(script[5].op == "special" and script[5].id == Std.SPECIAL.FieldShowRegionMap, "WallTownMap step 5 is special FieldShowRegionMap") - local Adapters = require("src.core.game3.scripting.adapters") local hostAdapters = Adapters.host(nil, { session = { map = "VIRIDIAN_CITY" } }, nil) local mapOpened = false diff --git a/tests/game3_trade_scene_test.lua b/tests/game3_trade_scene_test.lua index e79f770d..ffe5c792 100644 --- a/tests/game3_trade_scene_test.lua +++ b/tests/game3_trade_scene_test.lua @@ -2,6 +2,7 @@ -- pokefirered/src/trade_scene.c:1337 DoTradeAnim_Cable package.path = "./?.lua;./?/init.lua;" .. package.path +package.loaded["src.core.game3.rom_text"] = { plain = function(key) return key end, box = function(key) return key end } local failed = 0 local function check(cond, msg) diff --git a/tests/game3_ui_party_fieldmove_test.lua b/tests/game3_ui_party_fieldmove_test.lua index 96e4a5af..290d2589 100644 --- a/tests/game3_ui_party_fieldmove_test.lua +++ b/tests/game3_ui_party_fieldmove_test.lua @@ -1,6 +1,7 @@ #!/usr/bin/env luajit package.path = "./?.lua;./?/init.lua;" .. package.path +package.loaded["src.core.game3.rom_text"] = { plain = function(key) return key end, box = function(key) return key end } local failed = 0 local function check(cond, msg) diff --git a/tests/game3_waterfall_test.lua b/tests/game3_waterfall_test.lua index bf25ca80..c2bcf927 100644 --- a/tests/game3_waterfall_test.lua +++ b/tests/game3_waterfall_test.lua @@ -1,6 +1,7 @@ #!/usr/bin/env luajit package.path = "./?.lua;./?/init.lua;" .. package.path +package.loaded["src.core.game3.rom_text"] = { plain = function(key) return key end, box = function(key) return key end } local failed = 0 local function check(cond, msg) diff --git a/tests/game3_wireless_specials_test.lua b/tests/game3_wireless_specials_test.lua index 1f467914..d9a78846 100644 --- a/tests/game3_wireless_specials_test.lua +++ b/tests/game3_wireless_specials_test.lua @@ -79,14 +79,44 @@ local _, t2 = Natives.special(ctx, Std.SPECIAL.Script_TakeBerryPowder, A) checkEq(t2, 0, "cannot take 50 from 40") checkEq(session.berryPowder, 40, "balance unchanged after a refused take") -print("=== 3. vendor window pair records open/close ===") +print("=== 3. vendor powder box shows, reprints, and hides ===") +local BerryPowderBox = require("src.ui.game3.berry_powder_box") local yDisp = Natives.special(ctx, Std.SPECIAL.DisplayBerryPowderVendorMenu, A) checkEq(yDisp, false, "Display does not yield") -check(ctx.berryPowderVendorOpen == true, "vendor window marked open") +check(BerryPowderBox.isVisible(), "powder box is visible") +checkEq(BerryPowderBox.amount(), 40, "powder box shows the balance") +checkEq(BerryPowderBox.amountText(40), " 40", "amount is right-aligned in 5 digits") +session.berryPowder = 15 local yPrint = Natives.special(ctx, Std.SPECIAL.PrintPlayerBerryPowderAmount, A) -checkEq(yPrint, false, "Print is a bound no-op") +checkEq(yPrint, false, "Print does not yield") +checkEq(BerryPowderBox.amount(), 15, "Print reprints the new balance") Natives.special(ctx, Std.SPECIAL.RemoveBerryPowderVendorMenu, A) -check(ctx.berryPowderVendorOpen ~= true, "Remove clears the vendor window state") +check(not BerryPowderBox.isVisible(), "Remove hides the powder box") +session.berryPowder = 40 + +print("=== 3b. minigame record windows block waitstate until A/B ===") +local Records = require("src.ui.game3.minigame_records") +local Stack = require("src.ui.game3.stack") +local fakeInput = { pressed = {} } +function fakeInput:wasPressed(k) return self.pressed[k] == true end +for _, row in ipairs({ + { "ShowBerryCrushRankings", "berry_crush" }, + { "ShowPokemonJumpRecords", "pokemon_jump" }, + { "ShowDodrioBerryPickingRecords", "dodrio" }, +}) do + ctx.stateWait = nil + Natives.special(ctx, Std.SPECIAL[row[1]], A) + check(Records.isOpen() and Records.kind() == row[2], row[1] .. " opens the " .. row[2] .. " window") + check(Stack.top() and Stack.top().id == Records.ID, row[1] .. " pushes a modal layer") + check(type(ctx.stateWait) == "function" and not ctx.stateWait(), row[1] .. " holds waitstate") + Records.update(1 / 60); Records.update(1 / 60) + fakeInput.pressed = { a = true } + Records.handleInput(fakeInput) + fakeInput.pressed = {} + check(not Records.isOpen() and ctx.stateWait(), row[1] .. " closes on A and releases waitstate") +end +checkEq(Records.pressingSpeedText(0):match("^%s*(%d+%.%d+)"), "0.00", "empty rankings read 0.00") +checkEq(Records.pressingSpeedText(0x0580):match("^%s*(%d+%.%d+)"), "5.50", "0x0580 reads 5.50") print("=== 4. e-Reader fallbacks ===") Natives.special(ctx, Std.SPECIAL.SetEReaderTrainerGfxId, A) diff --git a/tests/parity_bills_pc.lua b/tests/parity_bills_pc.lua index 1f80994f..57870579 100644 --- a/tests/parity_bills_pc.lua +++ b/tests/parity_bills_pc.lua @@ -87,6 +87,8 @@ package.loaded["src.ui.Menu"] = menuStub local queued local fakeSelf = setmetatable({ + map = { id = "BILLS_HOUSE" }, + player = { surfing = false }, queueScript = function(_, script, extra) queued = { script, extra } end, billsHouseBillExits = function() end, }, { __index = OW }) @@ -117,11 +119,11 @@ check(tostring(lastPush().text):find("TELEPORTER", 1, true) -- === 2) Bill in machine, separator not used yet: initiated text === resetFlags() fakeGame.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true -local musicStopped = false +local musicStopped, musicRestarted = false, nil local realMusic = package.loaded["src.core.Music"] package.loaded["src.core.Music"] = { stop = function() musicStopped = true end, - playMap = function() end, + playMap = function(_, mapId) musicRestarted = mapId end, } local realSound = package.loaded["src.core.Sound"] package.loaded["src.core.Sound"] = { @@ -166,9 +168,39 @@ eq(table.concat(gotRows, " | "), table.concat(wantRows, " | "), eq(delayFrames, 268, "268 delay frames, DelayFrames call for DelayFrames call") check(not fakeGame.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL, "SetEvent waits for the chain, not the text box") +check(musicRestarted == nil, "map music stays off during the chain") queued[2].onDone() check(fakeGame.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL, "separator path sets EVENT_USED_CELL_SEPARATOR_ON_BILL") +eq(musicRestarted, "BILLS_HOUSE", + "PlayDefaultMusic right after Get_Item1, before Bill exits") + +do + local playing = true + local fakeSrc = { isPlaying = function() return playing end } + package.loaded["src.core.Sound"] = { + play = function() return fakeSrc end, + sfxBusy = function() return false end, + } + local yielded = 0 + local runner = { yield = function() yielded = yielded + 1 end } + local ctx = { game = fakeGame, runner = runner } + local Commands = require("src.script.Commands") + Commands.play_sound(ctx, "Shrink") + Commands.wait_sound(ctx) + eq(yielded, 1, "wait_sound blocks on a Gen 1 sfx that is still sounding") + check(runner.waitingCheck and not runner.waitingCheck(), + "and keeps waiting while it plays") + playing = false + check(runner.waitingCheck(), "and releases the frame it ends") + runner.waitingCheck = nil + Commands.wait_sound(ctx) + eq(yielded, 1, "a second wait_sound with nothing playing falls through") + package.loaded["src.core.Sound"] = { + play = function() end, + playCry = function() end, + } +end -- === 3) after separator, before leaving: monitor text again === resetFlags() @@ -322,6 +354,9 @@ local exitSelf = setmetatable({ fakeGame.save = SaveData.newGame() setUpvalue(OW.billsHousePC, "Game", fakeGame) exitSelf:billsHouseBillExits() +check(exitSelf.emote and exitSelf.emote.frames == 8 + and exitSelf.emote.bubble == false, + "Bill holds 8 frames in the machine before walking (BillsHouse.asm:81)") drain(exitSelf) check(fakeGame.save.flags.EVENT_MET_BILL_2, "Bill's exit sets EVENT_MET_BILL_2") eq(ticketBill, exitSelf.npcs[1], "the exit walk hands BILL1 to the ticket scene") diff --git a/tests/parity_yellow_bills_pikachu.lua b/tests/parity_yellow_bills_pikachu.lua index eb00e9b4..d2573d5d 100644 --- a/tests/parity_yellow_bills_pikachu.lua +++ b/tests/parity_yellow_bills_pikachu.lua @@ -122,6 +122,10 @@ check(npc.goalX == 9 and npc.goalY == 9, "Pikachu stays parked in Bill's House during the scene") PikachuFollower.onBillExitedMachine(yellowGame, ow) +check(ow.emote and ow.emote.bubble == false and ow.emote.frames == 12 + and npc.facing ~= "left", + "SetSpriteFacingDirectionAndDelay + DELAY/LOOK_LEFT hold before the bubble") +ow.emote.onDone() check(ow.emote and ow.emote.bubble == 2 and ow.emote.frames == 60 and npc.facing == "left", "Pikachu reacts when Bill comes back out") diff --git a/tests/parity_yellow_disabled_pikachu.lua b/tests/parity_yellow_disabled_pikachu.lua index 3be6df88..e0d89650 100644 --- a/tests/parity_yellow_disabled_pikachu.lua +++ b/tests/parity_yellow_disabled_pikachu.lua @@ -95,10 +95,10 @@ Follower.talk(fanGame, ow, pika) check(ow.emote and ow.emote.frames == 60 and type(ow.emote.bubble) == "number" and not ow.emote.pikaPic, "afterwards emotion 30 opens with the heart bubble alone") -eq(pika.facing, "up", "pikaemotion_9's turn has not happened yet") -ow.emote.onDone() eq(pika.facing, "down", - "the turn toward the player lands with the cry, facing player XOR 4") + "pikaemotion_9 turns away (player XOR 4) before the heart bubble") +ow.emote.onDone() +eq(pika.facing, "down", "and the cry leaves the facing alone") eq(cry, 5, "emotion 30 cries after its bubble") check(ow.emote and ow.emote.pikaPic, "and only then raises the pic") Sound.playPikaCry = realCry