From 7f1336a8309d62a6ad4566875c1138fdb0ca048c Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 4 Sep 2026 09:15:16 -0400 Subject: [PATCH 01/10] CLOSES #2114, CLOSES #2115, CLOSES #2116, CLOSES #2117, CLOSES #2118, CLOSES #2124, CLOSES #2125, CLOSES #2126, CLOSES #2148, CLOSES #2149, CLOSES #2150, CLOSES #2151, CLOSES #2152, CLOSES #2153, CLOSES #2155, CLOSES #2156, CLOSES #2158, CLOSES #2159, CLOSES #2161, CLOSES #2162, CLOSES #2163, CLOSES #2164, CLOSES #2170, CLOSES #2173, CLOSES #2174, CLOSES #2175, CLOSES #2176, CLOSES #2178, CLOSES #2179, CLOSES #2180, CLOSES #2181, CLOSES #2182 --- data/scripts/seafoam.lua | 3 +- data/scripts/story.lua | 31 +- data/scripts/story4.lua | 13 +- data/scripts/story5.lua | 71 +++-- data/scripts/story6.lua | 3 +- data/scripts/yellow_gifts.lua | 9 +- docs/new-features.md | 1 + src/battle/BattleState.lua | 28 +- src/battle/Damage.lua | 19 +- src/battle/MoveEffects.lua | 9 +- src/battle/Status.lua | 83 ++++++ src/battle/StatusRegistry.lua | 2 + src/battle/TrainerAI.lua | 4 + src/battle/TurnOrder.lua | 12 +- src/battle/rulesets/gen1_faithful.lua | 4 + src/battle/rulesets/modern_clean.lua | 4 + src/core/BattleCheckpoint.lua | 2 +- src/core/Game2.lua | 23 +- src/core/gen2/Save.lua | 1 + src/import/CacheContract.lua | 4 +- src/import/LauncherSettings.lua | 3 + src/import/RomExtractor.lua | 9 +- src/inventory/ItemEffects.lua | 12 +- src/mods/Gen2Compat.lua | 7 +- src/render/TextBox.lua | 29 +- src/render/Transition.lua | 39 +++ src/script/Commands.lua | 23 +- src/script/gen2/Movement.lua | 44 ++- src/ui/BagMenu.lua | 42 +-- src/ui/BoxMenu.lua | 40 ++- src/ui/DexEntryMenu.lua | 28 +- src/ui/Menu.lua | 5 +- src/ui/MoveSelectMenu.lua | 61 ++++ src/ui/PartyMenu.lua | 36 ++- src/ui/ShopMenu.lua | 6 +- src/ui/SlotMachine.lua | 6 +- src/ui/gen2/BattleState.lua | 205 +++++++++---- src/ui/gen2/Chrome.lua | 25 +- src/ui/gen2/GenderSelect.lua | 20 +- src/ui/gen2/InitClock.lua | 49 +++- src/ui/gen2/OakSpeech.lua | 13 +- src/ui/gen2/OptionsMenu.lua | 8 +- src/ui/gen2/PartyMenu.lua | 11 +- src/ui/gen2/WideBattle.lua | 64 ++++ src/world/FieldDefaults.lua | 6 + src/world/Map.lua | 3 - src/world/NPC.lua | 13 +- src/world/OverworldController.lua | 277 +++++++++++++++--- src/world/PikachuFollower.lua | 61 +++- src/world/Player.lua | 39 ++- src/world/gen2/Player.lua | 19 +- src/world/gen2/World.lua | 248 +++++++++++++--- .../battle_hud_after_cry_bug2118_test.lua | 99 +++++++ .../bill_face_rotation_bug2117_test.lua | 71 +++++ tests/drivers/bills_pc_changebox_bug2124.lua | 131 +++++++++ tests/drivers/bills_pikachu_bug2165_test.lua | 101 +++++++ tests/drivers/bills_pikachu_into_bill.lua | 148 ++++++++++ .../drivers/crystal_battle_exit_fade_2141.lua | 95 +++++- .../crystal_battle_ui_2173_2174_2175.lua | 136 +++++++++ tests/drivers/crystal_dig_anim_bug2178.lua | 74 +++++ tests/drivers/crystal_gender_shots.lua | 10 + tests/drivers/crystal_hole_fall_bug2176.lua | 77 +++++ .../drivers/cycling_road_end_bug2115_test.lua | 42 +++ .../cycling_road_speed_bug2114_test.lua | 66 +++++ tests/drivers/dig_bug196_test.lua | 44 ++- .../gen2_talk_cancels_bump_bug2170_test.lua | 83 ++++++ .../gold_battle_layout_wide_bug1709_test.lua | 225 ++++++++++++++ ...sfx_bug960_bug961_bug1044_bug1045_test.lua | 4 +- tests/drivers/party_heal_bug252_test.lua | 11 + .../drivers/party_swap_anim_bug2059_test.lua | 9 +- tests/drivers/pp_restore_menu_2155_2158.lua | 254 ++++++++++++++++ .../drivers/rare_candy_statbox_hold_test.lua | 13 + tests/drivers/rival_walkoff_test.lua | 60 ++++ .../route24_gift_jingles_2156_2159.lua | 29 ++ tests/drivers/seafoam_holes_bug599_test.lua | 23 +- .../drivers/ss_anne_departure_bug360_test.lua | 22 +- tests/drivers/text_arrow_bug2116_test.lua | 72 +++++ tests/drivers/warp_fade_bug607_test.lua | 14 +- .../drivers/yellow_map_seam_bug2152_test.lua | 76 +++++ .../engine/battle_transition_oam_bug1948.lua | 35 +++ tests/engine/bills_pc_changebox_bug2124.lua | 149 ++++++++++ tests/engine/enemy_hud_after_cry_bug2118.lua | 88 ++++++ tests/engine/ether_move_menu_2158.lua | 145 +++++++++ tests/engine/evo_stone_cancel_bug883_test.lua | 18 ++ tests/engine/gen1_text_terminator_bug2116.lua | 140 +++++++++ .../gen2_bump_walk_in_place_bug1978.lua | 9 + .../engine/gen2_talk_cancels_bump_bug2170.lua | 77 +++++ tests/engine/gen2_text_terminator_bug2045.lua | 2 +- .../engine/gen2_waitbutton_arrow_bug2045.lua | 2 +- tests/engine/mart_bag_full_bug2162.lua | 182 ++++++++++++ tests/engine/npc_stay_turns_bug2117.lua | 110 +++++++ .../overworld_world_palette_seam_bug2152.lua | 33 +++ tests/engine/pokedex_entry_layout_bug1341.lua | 7 +- .../pokedex_seen_placeholders_bug2163.lua | 95 ++++++ tests/engine/pp_restore_jingle_menu_2155.lua | 62 ++++ tests/engine/rare_candy_bag_open_bug796.lua | 5 + tests/engine/rby_translation_runtime_test.lua | 2 +- tests/engine/route24_rocket_bug1550.lua | 44 ++- tests/engine/status_penalty_baked_bug2148.lua | 119 ++++++++ tests/engine/textbox_arrow_bug2084.lua | 6 +- tests/engine/warp_fade_palette_2180.lua | 104 +++++++ tests/engine/yellow_gifts_gotmon_2159.lua | 44 +++ .../zero_damage_miss_ruleset_bug2153.lua | 57 ++++ tests/gen2_audio_test.lua | 4 + tests/gen2_bank_of_mom_test.lua | 15 +- tests/gen2_battle_options_test.lua | 259 +++++++++++++++- tests/gen2_battle_return_fade_test.lua | 168 ++++++++++- tests/gen2_battle_ui_test.lua | 85 +++++- tests/gen2_clock_test.lua | 156 ++++++---- tests/gen2_crystal_gender_test.lua | 46 +++ tests/gen2_dig_warp_test.lua | 8 +- tests/gen2_field_anim_test.lua | 33 ++- tests/gen2_field_items_test.lua | 7 +- tests/gen2_menus_test.lua | 2 +- tests/gen2_world_test.lua | 100 ++++++- tests/goldens/fixture_fingerprint.txt | 2 +- tests/mod_battle_tests.lua | 26 +- tests/parity_E.lua | 8 +- tests/parity_L.lua | 70 ++++- tests/parity_cycling_road_brake.lua | 94 ++++++ tests/parity_dungeon_warp_holes.lua | 109 +++++++ tests/parity_ghost_marowak.lua | 2 +- tests/parity_rare_candy_menu.lua | 36 ++- tests/parity_rival_walkoff.lua | 112 ++++++- tests/parity_seafoam_holes.lua | 6 +- tests/parity_ss_anne_departure.lua | 34 ++- tests/parity_text_markers.lua | 2 +- tests/parity_text_scroll_bounds.lua | 16 +- tests/parity_victory_road_hole.lua | 6 +- tests/parity_yellow_bills_pikachu.lua | 56 +++- tests/rby_translation_harvest_test.py | 1 - tests/run_tests.lua | 24 +- tools/build_rom_data.py | 10 +- tools/rom_manifest.json | 2 +- tools/rom_manifest_blue.json | 2 +- tools/rom_manifest_yellow.json | 2 +- 136 files changed, 6261 insertions(+), 543 deletions(-) create mode 100644 src/ui/MoveSelectMenu.lua create mode 100644 src/ui/gen2/WideBattle.lua create mode 100644 tests/drivers/battle_hud_after_cry_bug2118_test.lua create mode 100644 tests/drivers/bill_face_rotation_bug2117_test.lua create mode 100644 tests/drivers/bills_pc_changebox_bug2124.lua create mode 100644 tests/drivers/bills_pikachu_bug2165_test.lua create mode 100644 tests/drivers/bills_pikachu_into_bill.lua create mode 100644 tests/drivers/crystal_battle_ui_2173_2174_2175.lua create mode 100644 tests/drivers/crystal_dig_anim_bug2178.lua create mode 100644 tests/drivers/crystal_hole_fall_bug2176.lua create mode 100644 tests/drivers/cycling_road_end_bug2115_test.lua create mode 100644 tests/drivers/cycling_road_speed_bug2114_test.lua create mode 100644 tests/drivers/gen2_talk_cancels_bump_bug2170_test.lua create mode 100644 tests/drivers/gold_battle_layout_wide_bug1709_test.lua create mode 100644 tests/drivers/pp_restore_menu_2155_2158.lua create mode 100644 tests/drivers/route24_gift_jingles_2156_2159.lua create mode 100644 tests/drivers/text_arrow_bug2116_test.lua create mode 100644 tests/drivers/yellow_map_seam_bug2152_test.lua create mode 100644 tests/engine/bills_pc_changebox_bug2124.lua create mode 100644 tests/engine/enemy_hud_after_cry_bug2118.lua create mode 100644 tests/engine/ether_move_menu_2158.lua create mode 100644 tests/engine/gen1_text_terminator_bug2116.lua create mode 100644 tests/engine/gen2_talk_cancels_bump_bug2170.lua create mode 100644 tests/engine/mart_bag_full_bug2162.lua create mode 100644 tests/engine/npc_stay_turns_bug2117.lua create mode 100644 tests/engine/overworld_world_palette_seam_bug2152.lua create mode 100644 tests/engine/pokedex_seen_placeholders_bug2163.lua create mode 100644 tests/engine/pp_restore_jingle_menu_2155.lua create mode 100644 tests/engine/status_penalty_baked_bug2148.lua create mode 100644 tests/engine/warp_fade_palette_2180.lua create mode 100644 tests/engine/yellow_gifts_gotmon_2159.lua create mode 100644 tests/engine/zero_damage_miss_ruleset_bug2153.lua create mode 100644 tests/parity_dungeon_warp_holes.lua diff --git a/data/scripts/seafoam.lua b/data/scripts/seafoam.lua index 4d0206cf..46f4f0bd 100644 --- a/data/scripts/seafoam.lua +++ b/data/scripts/seafoam.lua @@ -56,8 +56,7 @@ for mapId, holes in pairs(HOLE_FALLS) do M[mapId].onStep = function(game, ow, x, y) for _, h in ipairs(holes) do if x == h[1] and y == h[2] then - require("src.core.Sound").play(game.data, "Faint_Fall") - ow:startWarpTo(h[3], h[4], h[5], ow.player.facing) + ow:fallThroughHole(h[3], h[4], h[5], ow.player.facing) return true end end diff --git a/data/scripts/story.lua b/data/scripts/story.lua index c040a352..5b7203e1 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -1004,8 +1004,7 @@ M.VICTORY_ROAD_3F = { -- fall is onStep, not a collision block. onStep = function(game, ow, x, y) if x == 23 and y == 15 then - require("src.core.Sound").play(game.data, "Faint_Fall") - ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing) + ow:fallThroughHole("VICTORY_ROAD_2F", 22, 16, ow.player.facing) return true end return false @@ -1220,20 +1219,22 @@ M.CERULEAN_CITY = { -- CeruleanCityRivalText: Bill line once beaten; pre-battle otherwise. -- Post-fight walk matches CeruleanCityMovement4 (right then into town). TEXT_CERULEANCITY_RIVAL = { - { "face_player" }, -- 1 - { "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2 - { "jump_if_true", 13 }, -- 3 - { "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4 - { "rival_battle", "OPP_RIVAL1", 7 }, -- 5 - { "jump_if_false", "end" }, -- 6 - { "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7 - { "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8 - { "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9 + { "face_player" }, + { "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, + { "jump_if_true", "beaten" }, + { "show_text", "_CeruleanCityRivalPreBattleText" }, + -- SaveEndBattleTextPointers (scripts/CeruleanCity.asm:141) + { "save_end_battle_text", "_CeruleanCityRivalDefeatedText" }, + { "rival_battle", "OPP_RIVAL1", 7 }, + { "jump_if_false", "end" }, + { "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, + { "show_text", "_CeruleanCityRivalIWentToBillsText" }, { "walk_npc", 1, { "right", "down", "down", "down", - "down", "down", "down" } }, -- 10 - { "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11 - { "jump", "end" }, -- 12 - { "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 13 + "down", "down", "down" } }, + { "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, + { "jump", "end" }, + { "label", "beaten" }, + { "show_text", "_CeruleanCityRivalIWentToBillsText" }, }, }, } diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 7ab92ea3..184e9b08 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -633,8 +633,7 @@ M.ROUTE_24 = { end if not flags.EVENT_GOT_NUGGET then local t = text(game) - push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f" - .. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function() + local function prize() if not require("src.inventory.Bag").add(game.save, "NUGGET", 1, game.data) then push(game, t._Route24CooltrainerM1NoRoomText, done) @@ -642,10 +641,18 @@ M.ROUTE_24 = { end flags.EVENT_GOT_NUGGET = true game.stringBuffer = game.data.items.NUGGET.name + -- pokeyellow scripts/Route24.asm:157 + -- pokered scripts/Route24.asm:156 + local jingle = require("src.core.GameVersion").isYellow() + and "Get_Key_Item" or "Get_Item1" push(game, t._Route24CooltrainerM1ReceivedNuggetText, function() push(game, t._Route24CooltrainerM1JoinTeamRocketText, battleOrDone) - end, require("src.render.TextBox").soundOpts(game, "Get_Item1")) + end, require("src.render.TextBox").soundOpts(game, jingle)) + end + -- scripts/Route24.asm:149-153 + push(game, t._Route24CooltrainerM1YouBeatOurContestText, function() + push(game, t._Route24CooltrainerM1YouJustEarnedAPrizeText, prize) end, require("src.render.TextBox").soundOpts(game, "Get_Item1")) return end diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index 87a4a4ff..34a14b57 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -517,7 +517,7 @@ M.PEWTER_CITY = { -- skipped (the blackout rebuilds the map mid-script). local function runAmbush(game, ow, rows, playerFacing, musicOpts) if ow.runner:isRunning() then return false end - ow.player.facing = playerFacing + if playerFacing then ow.player.facing = playerFacing end -- the rival encounter sting (MUSIC_MEET_RIVAL); the battle music -- takes over and the map theme returns after the victory jingle require("src.core.Music").play(game.data, "Music_MeetRival", nil, musicOpts) @@ -622,21 +622,28 @@ local function ceruleanRivalExitDirs(px) return { "left", "down", "down", "down", "down", "down", "down" } end -local function ceruleanRivalScene(px, py) +-- CeruleanCityMovement1 (scripts/CeruleanCity.asm:114) +local CERULEAN_RIVAL_APPROACH = { "down", "down", "down" } + +local function ceruleanRivalScene(px) return { - { "show_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 1 - { "move_npc_to", 1, px, py - 1 }, -- 2 - { "face_object", 1, "down" }, -- 3 - { "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4 - { "rival_battle", "OPP_RIVAL1", 7 }, -- 5 - { "jump_if_false", 13 }, -- 6 - { "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7 - { "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8 - { "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9 - { "play_music", "Music_MeetRival", { start = "rival" } }, -- 10 - { "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 11 + { "show_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, + -- x 20 (scripts/CeruleanCity.asm:84-91) + { "place_npc", 1, px, 2, "down" }, + { "walk_npc", 1, CERULEAN_RIVAL_APPROACH }, + { "face_object", 1, "down" }, -- CeruleanCityFaceRivalScript + { "show_text", "_CeruleanCityRivalPreBattleText" }, + -- SaveEndBattleTextPointers (scripts/CeruleanCity.asm:141) + { "save_end_battle_text", "_CeruleanCityRivalDefeatedText" }, + { "rival_battle", "OPP_RIVAL1", 7 }, + { "jump_if_false", "leave" }, + { "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, + { "show_text", "_CeruleanCityRivalIWentToBillsText" }, + { "play_music", "Music_MeetRival", { start = "rival" } }, + { "walk_npc", 1, ceruleanRivalExitDirs(px) }, { "play_default_music" }, -- scripts/CeruleanCity.asm:230 - { "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 13 + { "label", "leave" }, + { "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, } end @@ -698,7 +705,7 @@ M.CERULEAN_CITY = { end if not f.EVENT_BEAT_CERULEAN_RIVAL and inCoords({ { 20, 6 }, { 21, 6 } }, x, y) then - return runAmbush(game, ow, ceruleanRivalScene(x, y), "up") + return runAmbush(game, ow, ceruleanRivalScene(x), "up") end return false end, @@ -917,23 +924,29 @@ M.SS_ANNE_2F = { if game.save.flags.EVENT_BEAT_SS_ANNE_RIVAL then return false end if not inCoords({ { 36, 8 }, { 37, 8 } }, x, y) then return false end local onLeft = x == 36 - return runAmbush(game, ow, { - { "show_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 1 - { "move_npc_to", 2, 36, onLeft and 7 or 8 }, -- 2 - { "face_object", 2, onLeft and "down" or "right" }, -- 3 - { "show_text", "_SSAnne2FRivalText" }, -- 4 + local rows = { + { "show_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, + { "move_npc_to", 2, 36, onLeft and 7 or 8 }, + { "face_object", 2, onLeft and "down" or "right" }, + { "show_text", "_SSAnne2FRivalText" }, -- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers -- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688) - { "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5 - { "rival_battle", "OPP_RIVAL2", 1 }, -- 6 - { "jump_if_false", 13 }, -- 7 - { "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8 - { "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9 - { "play_music", "Music_MeetRival", { start = "rival" } }, -- 10 - { "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 11 + { "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, + { "rival_battle", "OPP_RIVAL2", 1 }, + { "jump_if_false", "leave" }, + { "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, + { "show_text", "_SSAnne2FRivalCutMasterText" }, + { "play_music", "Music_MeetRival", { start = "rival" } }, + { "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, { "play_default_music" }, -- scripts/SSAnne2F.asm:175 - { "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 13 - }, onLeft and "up" or "left") + { "label", "leave" }, + { "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, + } + -- the wXCoord == 37 branch (scripts/SSAnne2F.asm:73-77) + if not onLeft then + table.insert(rows, 4, { "face_player_dir", "left" }) + end + return runAmbush(game, ow, rows) end, } diff --git a/data/scripts/story6.lua b/data/scripts/story6.lua index 05a01211..29ca457b 100644 --- a/data/scripts/story6.lua +++ b/data/scripts/story6.lua @@ -122,8 +122,7 @@ local MANSION_HOLES = { M.POKEMON_MANSION_3F.onStep = function(game, ow, x, y) for _, h in ipairs(MANSION_HOLES) do if x == h[1] and y == h[2] then - require("src.core.Sound").play(game.data, "Faint_Fall") - ow:startWarpTo(h[3], h[4], h[5], ow.player.facing) + ow:fallThroughHole(h[3], h[4], h[5], ow.player.facing) return true end end diff --git a/data/scripts/yellow_gifts.lua b/data/scripts/yellow_gifts.lua index 738e4e92..989c00ef 100644 --- a/data/scripts/yellow_gifts.lua +++ b/data/scripts/yellow_gifts.lua @@ -21,7 +21,8 @@ M.CERULEAN_MELANIES_HOUSE = { rows[#rows + 1] = { "show_text", "MelanieText1" } rows[#rows + 1] = { "ask", "MelanieText2" } rows[#rows + 1] = { "jump_if_false", "declined" } - rows[#rows + 1] = { "give_pokemon", "BULBASAUR", 10 } + -- engine/events/give_pokemon.asm:45-46 + rows[#rows + 1] = { "give_pokemon", "BULBASAUR", 10, false, true } rows[#rows + 1] = { "jump_if_false", "end" } -- party + box full -- Event and HideObject before MelanieText3: give_pokemon's nickname -- prompt and the received text both yield, and a script that dies @@ -64,7 +65,8 @@ M.ROUTE_24 = { { "jump_if_true", "after" }, { "ask", "_Route24DamianText1" }, { "jump_if_false", "declined" }, - { "give_pokemon", "CHARMANDER", 10 }, + -- engine/events/give_pokemon.asm:45-46 + { "give_pokemon", "CHARMANDER", 10, false, true }, { "jump_if_false", "end" }, -- SetEvent hoisted ahead of the received text (Route24.asm writes it -- after Route24Text_515e3, where nothing in between can fail): the @@ -96,7 +98,8 @@ M.VERMILION_CITY = { else rows[#rows + 1] = { "ask", "_OfficerJennyText2" } rows[#rows + 1] = { "jump_if_false", "declined" } - rows[#rows + 1] = { "give_pokemon", "SQUIRTLE", 10 } + -- engine/events/give_pokemon.asm:45-46 + rows[#rows + 1] = { "give_pokemon", "SQUIRTLE", 10, false, true } rows[#rows + 1] = { "jump_if_false", "end" } -- event before the received text, as above (#426) rows[#rows + 1] = { "set_flag", diff --git a/docs/new-features.md b/docs/new-features.md index e9830562..773e498e 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -35,3 +35,4 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check` * **Followers** for mods, plus Gen 2-only registries and hooks * **Battle screen options** on Gold, Silver and Crystal: BATTLE SIZE (fixed or window-filling) and BATTLE BG (white, black or the dimmed map as the surround) +* **Widescreen battle layout** on Gold, Silver and Crystal: BATTLE LAYOUT -> WIDE spreads the HUDs and bottom windows across a 304-wide screen diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 2e026f88..e56309a6 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1305,7 +1305,8 @@ function BattleState:startMessage(item) self.current = item self.lines = {} self.total = 0 - local text = item.text or "" + -- constants/charmap.asm:19-20 + local text = require("src.render.TextBox").strip(item.text or "") local pos, cont = 1, false while true do local npos = text:find("[\n\v]", pos) @@ -1982,7 +1983,7 @@ function BattleState:enter() self.enemySendingOut = false self:startGrowIn(self.enemy) end) - queueEnemyCry() + self:queueEnemySendOutCry(true) end -- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz` -- gates only EnemySendOutFirstMon -- the `ld c, 40 / call DelayFrames` @@ -2025,6 +2026,7 @@ end -- any pop (finish, script teardown) must silence the alarm loop -- (end_of_battle.asm clears wLowHealthAlarm when a battle ends) function BattleState:exit() + self.enemyHudPending = nil require("src.core.Sound").stopLoop("Low_Health_Alarm") -- end_of_battle.asm clears wPartyAndBillsPCSavedMenuItem as well, so the -- field party menu comes back on slot 1 after a battle. #768 @@ -3569,6 +3571,17 @@ function BattleState:startGrowIn(battler) table.insert(self.queue, self.nextInsert, { wait = 12 }) end +-- EnemySendOutFirstMon (core.asm:1432-1435) +-- WaitForSoundToFinish (home/pokemon.asm:145-149) +function BattleState:queueEnemySendOutCry(append) + self.enemyHudPending = true + local fn = function() + self:waitSfxNext(self:playEntranceCry(self.enemy)) + self:actNext(function() self.enemyHudPending = nil end) + end + if append then self:act(fn) else self:actNext(fn) end +end + -- SendOutMon branches on IsThisPartyMonStarterPikachu before the animation: -- the starter gets no ball and no grow-in -- pokeyellow core.asm:1798-1819 function BattleState:starterPikachuSendOut() @@ -3991,9 +4004,7 @@ function BattleState:executeAction(user, target, action) self:actNext(function() self.enemySendingOut = false self:startGrowIn(self.enemy) - self:actNext(function() - self:waitSfxNext(self:playEntranceCry(self.enemy)) - end) + self:queueEnemySendOutCry(false) end) return end @@ -4611,6 +4622,7 @@ function BattleState:awardExp() if mon == self.player.mon then -- engine/battle/experience.asm:236-239 self.player.badgeExtraBoosts = nil + require("src.battle.Status").bakeOnInflict(self, self.player) self.player.shownHP = mon.hp self.player.shownPx = Timing.hpBarPixels(mon.hp, math.max(1, mon.stats.hp)) @@ -4786,9 +4798,7 @@ function BattleState:enemyMonFainted() self:actNext(function() self.enemySendingOut = false self:startGrowIn(self.enemy) - self:actNext(function() - self:waitSfxNext(self:playEntranceCry(self.enemy)) - end) + self:queueEnemySendOutCry(false) end) end) -- SwitchPlayerMon after the enemy is out (core.asm:1436-1443) @@ -6539,7 +6549,7 @@ function BattleState:drawHUDs(slide) -- AFTER PrintBeginningBattleText returns, so "Wild X appeared!" shows the -- player's ball row with no enemy HUD beside it (#317) if showStatus and self.enemy and not self.showEnemyTrainer - and not self.enemySendingOut + and not self.enemySendingOut and not self.enemyHudPending and not self:growInScale(self.enemy) and slide == 0 and not self.introBalls and not self.enemy.fainted then -- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, +level (4,1), diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index 929fff89..01b6d960 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -69,11 +69,6 @@ function Damage.reapplyBadgeBoosts(battler, changedStat) end end --- the merged status record for a battler's persistent condition, or nil -local function statusRecord(battler) - return Status.recordFor(battler.statuses, battler.mon.status) -end - -- Critical chance test, following CriticalHitTest's shift chain exactly -- (each left shift caps at 255): b = speed/2, then x2 (or /2 with -- Focus Energy's famous right-shift bug), then x4 for high-crit moves @@ -213,11 +208,8 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- hazeStatReset suppresses it: Haze (haze.asm ResetStats) copied the -- unmodified attack over the burn-halved battle stat, lifting the -- penalty until the next stat recompute. - local record = statusRecord(attacker) - local penalty = record and record.statPenalty - if penalty and penalty.stat == atkStat and not attacker.hazeStatReset then - atk = math.max(1, math.floor(atk / penalty.div)) - end + -- core.asm:6326, effects.asm:634-635 + atk = Status.applyPenalty(attacker, atkStat, atk) -- screens double the effective defense (crits bypass them). The -- confusion self-hit is the quirk case: HandleSelfConfusionDamage -- swaps the user's own defense in but leaves the screen check @@ -274,7 +266,12 @@ function Damage.compute(ruleset, attacker, defender, move, opts) if d == 0 then -- a 2-3 damage hit at 0.25x floors to zero: the original flags -- the move as missed rather than dealing a minimum 1 - return 0, { crit = false, typeMult = mult, missed = true } + -- engine/battle/core.asm:5169-5176 + if ruleset.zeroDamageMiss == false then + d = 1 + else + return 0, { crit = false, typeMult = mult, missed = true } + end end end diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 1a8b25d4..be6ed2f0 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -13,6 +13,7 @@ local Damage = require("src.battle.Damage") local Logger = require("src.core.Logger") +local Status = require("src.battle.Status") local StatusRegistry = require("src.battle.StatusRegistry") local TurnOrder = require("src.battle.TurnOrder") local TypeChart = require("src.battle.TypeChart") @@ -55,9 +56,10 @@ local function changeStage(battle, who, stat, delta, fromEnemy) return { romText(battle.data, "_NothingHappenedText", "Nothing happened!") } end who.stages[stat] = new - -- effects.asm:505-506: after any stat-stage change, modified stats are - -- recomputed and QuarterSpeedDueToParalysis/HalveAttackDueToBurn re-run, - -- re-baking the burn/para penalty and ending Haze's temporary lift. + -- effects.asm:414-415 + local foe = (who == battle.player) and battle.enemy or battle.player + Status.afterStatChange(battle, who, stat, fromEnemy and who or foe) + -- pokered engine/battle/effects.asm:505-506 who.hazeStatReset = nil if battle.ruleset and battle.ruleset.badgeBoostReapplyBug and battle.kind ~= "link" and who == battle.player then @@ -265,6 +267,7 @@ MoveEffects.primary = { -- Attack-halving and paralysis Speed-quartering on BOTH battlers -- until the next stat recompute (a stage change or switch-in). b.hazeStatReset = true + if b.statusPenaltyStacks then b.statusPenaltyStacks = {} end b.badgeExtraBoosts = nil end -- Gen 1 also removes the enemy's major status; if that cured sleep diff --git a/src/battle/Status.lua b/src/battle/Status.lua index b4318376..4784cbf3 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -167,6 +167,89 @@ function Status.recordFor(statuses, id) return (statuses or Status.RECORDS)[id] end +-- engine/battle/core.asm:6283 +local function penaltyOf(battler) + local record = Status.recordFor(battler.statuses, battler.mon.status) + return record and record.statPenalty +end + +local MAX_PENALTY_STACKS = 32 + +function Status.penaltyStacks(battler, stat) + local p = penaltyOf(battler) + if not p or p.stat ~= stat then return 0 end + local t = battler.statusPenaltyStacks + local n = t and (t[stat] or 0) or (battler.hazeStatReset and 0 or 1) + if type(n) ~= "number" or n ~= n or n < 0 then return 0 end + return math.min(n, MAX_PENALTY_STACKS) +end + +function Status.applyPenalty(battler, stat, value) + local p = penaltyOf(battler) + if not p or p.stat ~= stat then return value end + local div = math.max(1, p.div or 1) + for _ = 1, Status.penaltyStacks(battler, stat) do + value = math.max(1, math.floor(value / div)) + end + return value +end + +-- core.asm:1658 +-- experience.asm:237 +function Status.bakePenalty(battler) + local t = {} + battler.statusPenaltyStacks = t + local p = penaltyOf(battler) + if p then t[p.stat] = 1 end +end + +-- engine/battle/effects.asm:414-415 +function Status.clearPenalty(battler, stat) + local t = battler.statusPenaltyStacks + if t then t[stat] = 0 end +end + +-- engine/battle/effects.asm:505-506 +function Status.stackPenalty(battler) + local t = battler.statusPenaltyStacks + if not t then return end + local p = penaltyOf(battler) + if p then t[p.stat] = (t[p.stat] or 0) + 1 end +end + +-- pokered engine/battle/core.asm:6283 +function Status.rulesetBakes(ruleset) + return ruleset ~= nil and ruleset.statusPenaltyIsBaked ~= false +end + +local function ensureStacks(battle, battler) + if battler.statusPenaltyStacks then return true end + if not Status.rulesetBakes(battle and battle.ruleset) then return false end + Status.bakePenalty(battler) + if battler.hazeStatReset then battler.statusPenaltyStacks = {} end + return true +end + +-- move_effects/paralyze.asm:35 +function Status.bakeOnInflict(battle, battler) + if not (battler.statusPenaltyStacks + or Status.rulesetBakes(battle and battle.ruleset)) then + return + end + Status.bakePenalty(battler) +end + +-- effects.asm:414-415 +function Status.afterStatChange(battle, who, stat, nonUser) + if not ensureStacks(battle, who) then return end + if stat ~= "accuracy" and stat ~= "evasion" then + Status.clearPenalty(who, stat) + end + if nonUser and ensureStacks(battle, nonUser) then + Status.stackPenalty(nonUser) + end +end + -- the HUD label for a status id: a mod's patched hudLabel/label if the -- merged registry has one, the raw id otherwise (BattleState.statusLabel, -- SummaryMenu.draw and PartyMenu.draw all read this the same way) diff --git a/src/battle/StatusRegistry.lua b/src/battle/StatusRegistry.lua index ba3afa7e..2e334e1d 100644 --- a/src/battle/StatusRegistry.lua +++ b/src/battle/StatusRegistry.lua @@ -41,6 +41,8 @@ function StatusRegistry.inflict(battle, target, status, opts) return {} end target.mon.status = status + -- move_effects/paralyze.asm:35, effects.asm:236 + Status.bakeOnInflict(battle, target) local msgs local display = displayName(target) if record and record.onInflict then diff --git a/src/battle/TrainerAI.lua b/src/battle/TrainerAI.lua index 4861b2ba..74cc7d58 100644 --- a/src/battle/TrainerAI.lua +++ b/src/battle/TrainerAI.lua @@ -20,6 +20,7 @@ local TypeChart = require("src.battle.TypeChart") local Strings = require("src.core.Strings") +local Status = require("src.battle.Status") local romText = require("src.core.RomText") local TrainerAI = {} @@ -131,6 +132,9 @@ function TrainerAI.useItem(battle, item) elseif X_STAT[item] then local stat = X_STAT[item] enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1) + -- trainer_ai.asm:719 -> effects.asm:414-415 + Status.afterStatChange(battle, enemy, stat, battle.player) + enemy.hazeStatReset = nil table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), Strings(STAT_LABEL[stat]))) elseif item == "GUARD_SPEC" then enemy.mist = true diff --git a/src/battle/TurnOrder.lua b/src/battle/TurnOrder.lua index d681ecf5..0589496f 100644 --- a/src/battle/TurnOrder.lua +++ b/src/battle/TurnOrder.lua @@ -15,16 +15,8 @@ local function effectiveSpeed(battler) -- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from -- the battler's merged badgeBoosts with the vanilla list as fallback spd = Damage.applyBadgeBoost(battler, "speed", spd) - -- paralysis quarters speed (the status record's statPenalty); - -- hazeStatReset suppresses it because Haze (haze.asm ResetStats) - -- copied the unmodified speed over the quartered battle stat, lifting - -- the penalty until the next stat recompute. - local record = Status.recordFor(battler.statuses, battler.mon.status) - local penalty = record and record.statPenalty - if penalty and penalty.stat == "speed" and not battler.hazeStatReset then - spd = math.max(1, math.floor(spd / penalty.div)) - end - return spd + -- core.asm:6283, effects.asm:414-415 + return Status.applyPenalty(battler, "speed", spd) end local PRIORITY = { QUICK_ATTACK = 1, COUNTER = -1 } diff --git a/src/battle/rulesets/gen1_faithful.lua b/src/battle/rulesets/gen1_faithful.lua index 7c0c57ba..61f61709 100644 --- a/src/battle/rulesets/gen1_faithful.lua +++ b/src/battle/rulesets/gen1_faithful.lua @@ -25,4 +25,8 @@ return { residualAfterMove = true, -- engine/battle/effects.asm:498,689 badgeBoostReapplyBug = true, + -- engine/battle/core.asm:5169-5176 + zeroDamageMiss = true, + -- engine/battle/core.asm:6283,6326 and effects.asm:414-415 + statusPenaltyIsBaked = true, } diff --git a/src/battle/rulesets/modern_clean.lua b/src/battle/rulesets/modern_clean.lua index 42e8678a..a556473a 100644 --- a/src/battle/rulesets/modern_clean.lua +++ b/src/battle/rulesets/modern_clean.lua @@ -16,4 +16,8 @@ return { -- Gen 3+ style: poison/burn/leech seed tick in an end-of-round sweep -- after both sides have moved. residualAfterMove = false, + -- engine/battle/core.asm:5169-5176 + zeroDamageMiss = false, + -- engine/battle/core.asm:6283,6326 and effects.asm:414-415 + statusPenaltyIsBaked = false, } diff --git a/src/core/BattleCheckpoint.lua b/src/core/BattleCheckpoint.lua index f14228fb..cbf79db7 100644 --- a/src/core/BattleCheckpoint.lua +++ b/src/core/BattleCheckpoint.lua @@ -27,7 +27,7 @@ local BATTLER_FIELDS = { "chargeReady", "invulnerable", "mustRecharge", "thrashTurns", "thrashAnnounced", "focusEnergy", "leechSeeded", "lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched", - "skipMove", "hazeStatReset", "badgeExtraBoosts", "drainFloor", "drainHold", "trappingTurns", + "skipMove", "hazeStatReset", "badgeExtraBoosts", "statusPenaltyStacks", "drainFloor", "drainHold", "trappingTurns", "trapMove", "trapDamage", "fainted", "aiLayer2", } diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 22f6ea14..abe0112f 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -436,6 +436,8 @@ function Game2:openStartMenu() if self.world and self.world.cancelMapNameSign then self.world:cancelMapNameSign() end + -- ../pokecrystal/engine/overworld/events.asm:494-510 + if self.world and self.world.player then self.world.player:stopForEvent() end Screens.push(self, "Gen2StartMenu", { save = self.save, onClose = function() self.stack:pop() end, @@ -856,6 +858,8 @@ end -- the cart's fixed messages to print, the same way the START handler above -- is the whole of .MenuReturns for its own button. function Game2:useSelectItem() + -- ../pokecrystal/engine/overworld/events.asm:494-510 + if self.world.player then self.world.player:stopForEvent() end local outcome, itemId = self.world:useSelectItem() if outcome == "not_registered" then -- MayRegisterItemText. @@ -1725,19 +1729,32 @@ local function battleSurround(stack) for i = #states, 1, -1 do local state = states[i] if state and state.bgMode then - return state:bgMode(), state.BG_WORLD_DIM or 0.55 + return state:bgMode(), state.BG_WORLD_DIM or 0.55, state, i end end end function Game2:paintBattleSurround(w, h) - local mode, dim = battleSurround(self.stack) + local mode, dim, owner, at = battleSurround(self.stack) if mode ~= "black" and mode ~= "world" then return end local alpha = mode == "world" and dim or 1 if not alpha or alpha <= 0 then return end local G = love.graphics local scale, ox, oy = panelBlit(self.stack, w, h) - local pw, ph = 160 * scale, 144 * scale + local stack = self.stack + if at and stack and stack.visibleBase then + local base = stack:visibleBase() + if base and base > at then owner = stack.states[base] end + end + local sw, sh = 160, 144 + if owner and owner.panelSize then sw, sh = owner:panelSize() end + if sw ~= 160 then + if owner.battlePanelScale then + scale = owner:battlePanelScale(w, h) or scale + end + ox, oy = Chrome.fitOriginFor(w, h, scale, sw / 8, sh / 8) + end + local pw, ph = sw * scale, sh * scale G.setColor(0, 0, 0, alpha) if oy > 0 then G.rectangle("fill", 0, 0, w, oy) end if oy + ph < h then G.rectangle("fill", 0, oy + ph, w, h - oy - ph) end diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index d808b9f7..ac804e3c 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -316,6 +316,7 @@ Save.DEFAULT_OPTIONS = { palette = "", videoMode = "windowed", fpsCap = 60, + battleLayout = "og", -- BATTLE SIZE (#1709): fixed | fill battleFit = "fixed", battleBg = "white", diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index 6c77cf69..cf8347ab 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -8,7 +8,7 @@ local GameVersion = require("src.core.GameVersion") local CacheContract = {} -CacheContract.FORMAT = "rom-cache-v10:" +CacheContract.FORMAT = "rom-cache-v11:" CacheContract.VERSION_FORMAT = { -- v11: Gen 2 maps carry their object list's ROM address, which a .sav -- export re-anchoring a save onto another map writes back into @@ -18,7 +18,7 @@ CacheContract.VERSION_FORMAT = { silver = "rom-cache-v12:", crystal = "rom-cache-v12-crystal4:", -- engine/overworld/map_sprites.asm:181 - yellow = "rom-cache-v10-yellow1:", + yellow = "rom-cache-v11-yellow1:", } CacheContract.MARKER_PATH = "rom-cache.complete" diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 28d3ebbf..f3ff9e35 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -681,6 +681,9 @@ local function gen2Rows(opts, hooks, shared) end) end + add(Strings("BATTLE LAYOUT"), ladder(opts, "battleLayout", + { { "og", "OG" }, { "wide", "WIDE" } }, "og")) + add(Strings("BATTLE SIZE"), ladder(opts, "battleFit", { { "fixed", "FIXED" }, { "fill", "FILL" } }, "fixed")) diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index 8efbe402..b3ed08a1 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -1452,7 +1452,8 @@ function RomExtractor:textGlyph(value) if TEXT_GLYPH_OVERRIDES[value] then return TEXT_GLYPH_OVERRIDES[value] end local glyph = self.manifest.charmap[tostring(value)] or ("{BYTE:%02X}"):format(value) - if glyph:sub(1, 1) == "<" and glyph:sub(-1) == ">" then + -- home/text.asm:186 + if glyph:match("^<[^<>]*>$") then return "{" .. glyph:sub(2, -2) .. "}" end return glyph @@ -1468,6 +1469,8 @@ function RomExtractor:decodeTextCommands(symbol, substitutions) if command == 0x50 then assert(pending > #substitutions, symbol.name .. ": unused dynamic text substitutions") + -- home/text.asm:221 + if #out > 0 then out[#out + 1] = "{DONE}" end return table.concat(out) elseif command == 0 then while true do @@ -1477,6 +1480,10 @@ function RomExtractor:decodeTextCommands(symbol, substitutions) if value == 0x57 or value == 0x58 or value == 0x5F then assert(pending > #substitutions, symbol.name .. ": unused dynamic text substitutions") + -- constants/charmap.asm:19-20 + if value ~= 0x5F and #out > 0 then + out[#out + 1] = (value == 0x58) and "{PROMPT}" or "{DONE}" + end return table.concat(out) end out[#out + 1] = self:textGlyph(value) diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 514e8a68..b69dc899 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -97,10 +97,13 @@ end -- (engine/items/item_effects.asm:1392-1418); .useVitamin ends at -- RemoveUsedItem the same way (engine/items/item_effects.asm:1315-1322); -- stones keep the menu on screen (engine/items/item_effects.asm:772-793, --- engine/pokemon/evos_moves.asm:120-128) +-- engine/pokemon/evos_moves.asm:120-128 +-- (engine/items/item_effects.asm:2022-2039) function ItemEffects.keepsPartyMenuOpen(id) return ItemEffects.healsHP(id) or id == "RARE_CANDY" or VITAMINS[id] ~= nil or ItemEffects.isStone(id) + or id == "ELIXER" or id == "MAX_ELIXER" + or id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP" end function ItemEffects.isBattleMedicine(id) @@ -327,6 +330,9 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) } end b.stages[stat] = cur + 1 + -- effects.asm:414-415 + require("src.battle.Status") + .afterStatChange(battle, b, stat, battle.enemy) b.hazeStatReset = nil if battle.ruleset and battle.ruleset.badgeBoostReapplyBug and battle.kind ~= "link" then @@ -388,7 +394,9 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) return "failed", { noEffect(data) } end -- pokered's line names no mon, so the extracted text takes no args - return "consumed", { romText(data, "_PPRestoredText", "PP was restored.") } + -- engine/items/item_effects.asm:2035 + return "consumed", { romText(data, "_PPRestoredText", "PP was restored.") }, + USE_JINGLE end -- PIKAHAPPY_USEDITEM (item_effects.asm ItemUseMedicine, item id up to diff --git a/src/mods/Gen2Compat.lua b/src/mods/Gen2Compat.lua index c30ee6f8..0db6629a 100644 --- a/src/mods/Gen2Compat.lua +++ b/src/mods/Gen2Compat.lua @@ -1856,7 +1856,7 @@ local function buildBattleState() "syncSides", "playerHasPP", "lockedAction", "computeMusicKind", "throwBall", "ballChain", "tossAnimFor", "ballFlicker", "ballMissMessage", "storeCaughtMon", "safariAction", "safariEnemyTurn", "drawBallRow", - "drawClassic", "isWideBattleLayout", "wideLayout", "uiSize", + "drawClassic", "uiSize", "sgbPalettes", "trainerPalette", "trainerPicPath", "trainerTrueColor", "trainerSprite", "invalidate", "imageBattleScale", "resolveBattleScale", "backPlacement", @@ -1940,11 +1940,12 @@ COVERAGE["src.battle.BattleState"] = { backed = "update draw __index isOpaque openParty swapMoves " .. "lowHealthAlarmActive playVictoryMusic say sayAuto openItems " .. "openReplacementMenu finish askNicknameUI playEntranceCry stampOT " - .. "tryRun wantsFillScale bgMode BG_WORLD_DIM", + .. "tryRun wantsFillScale bgMode BG_WORLD_DIM " + .. "isWideBattleLayout wideLayout", warned = "tryRun askNicknameUI", absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn " .. "computeDamage catchAttempt runRoll enter exit sgbPalettes " - .. "isWideBattleLayout wideLayout uiSize letterboxWhite " + .. "uiSize letterboxWhite " .. "holdsUIAnchors trainerPalette trainerPicPath " .. "trainerTrueColor trainerSprite invalidate " .. "backPlacement frontPlacement StatBox drawClassic drawBallRow " diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 412a8a1a..ea385c44 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -76,6 +76,13 @@ function TextBox.ending(text) return nil end +-- pokered constants/charmap.asm:19-20 +function TextBox.strip(text) + if type(text) ~= "string" then return text end + text = text:gsub("{DONE}%s*$", ""):gsub("{PROMPT}%s*$", "") + return (text:gsub("{DONE}", ""):gsub("{PROMPT}", "")) +end + -- glyph offsets into the whole text -> [page][line][char] local function mapPauses(pages, marks) local at, acc, mi = {}, 0, 1 @@ -278,6 +285,7 @@ end -- pokered ContText waits for A/B + ▼ before scrolling that line in. function TextBox.paginate(text, maxCols) maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS + text = TextBox.strip(text) -- maxCols is a column count, so the budget is that many vanilla 8px -- cells. Measuring in pixels rather than columns is what lets a mod's -- variable-advance page wrap correctly (#186). @@ -373,12 +381,18 @@ function TextBox:sfxHeld() return false end --- TextCommand_PROMPT_BUTTON's LoadBlinkingCursor (home/text.asm:749) +function TextBox:isGold() + local save = self.game and self.game.save + return not not (save and (save.generation == 2 or save.version == "gold")) +end + +-- TextCommand_PROMPT_BUTTON's LoadBlinkingCursor (pokered home/text.asm:749) function TextBox:arrowVisible() if self.sfxWait then return false end if self.waiting then return true end -- ../pokecrystal/home/text.asm:566 DoneText - if self.waitButton then return false end + -- pokered home/text_script.asm:96 -> home/joypad2.asm:71-72 + if self.waitButton and self:isGold() then return false end return not not (self.done and not self.choice and (not self.auto or (self.auto.promptFirst and not self.autoPrompted)) @@ -386,6 +400,14 @@ function TextBox:arrowVisible() or (self.stay.prompt and not self.stayShown))) end +-- home/text.asm:209 +-- ../pokecrystal/home/text.asm:631 +function TextBox:arrowPos() + local gold = self:isGold() + return (self.boxTx + self.boxTw - 2) * 8, + gold and (self.boxTy + self.boxTh - 1) * 8 or self.line2Y +end + -- scripts/MtMoonPokecenter.asm:30 function TextBox:moneyVisible() if not self.money then return false end @@ -643,8 +665,7 @@ function TextBox:draw() -- pokegold home/joypad.asm:430 local arrowOn = self.blink % 60 < 30 if gold then arrowOn = self.blink % 32 < 16 end - local arrowX = (self.boxTx + self.boxTw - 2) * 8 - local arrowY = (self.boxTy + self.boxTh - 1) * 8 - (gold and 0 or 4) + local arrowX, arrowY = self:arrowPos() if Chrome then local base = paper and { paper, paper, paper, { 0, 0, 0 } } or Chrome.DEFAULT_BOX_PALETTE diff --git a/src/render/Transition.lua b/src/render/Transition.lua index dd46ac18..922bd84d 100644 --- a/src/render/Transition.lua +++ b/src/render/Transition.lua @@ -21,6 +21,17 @@ local FLASH_FRAMES = 7 -- `ld c, 8 / call DelayFrames`, so eight frames is the step of all of them. local FADE_STEP_FRAMES = 8 +-- FadePal1..FadePal8, pokered home/fade.asm:65-73 +local FADE_BGP = { + -- GBFadeOutToBlack / GBFadeInFromBlack, pokered home/fade.asm:43-46 + black = { out = { 0xE4, 0xF9, 0xFE, 0xFF }, + ["in"] = { 0xFF, 0xFE, 0xF9, 0xE4 } }, + -- GBFadeOutToWhite / GBFadeInFromWhite, pokered home/fade.asm:26-28 + white = { out = { 0x90, 0x40, 0x00 }, + ["in"] = { 0x40, 0x90, 0xE4 } }, +} +local BGP_IDENTITY = 0xE4 + -- Veil alpha `t` frames into a `len`-frame fade out. GBFadeOutToBlack -- (home/fade.asm:43-46) walks FadePal4 -> FadePal3 -> FadePal2 -> FadePal1 -- with b = 4, and FadePal4 IS the normal palette while FadePal1 is solid @@ -131,7 +142,35 @@ function Transition:alpha() return a end +-- the $E4 identity BGP, pokered engine/gfx/palettes.asm:517-518 +function Transition.shadeMapFor(byte) + if not byte or byte == BGP_IDENTITY then return nil end + local map = {} + for i = 0, 3 do map[i] = math.floor(byte / (4 ^ i)) % 4 end + return map +end + +function Transition:fadeTable() + local c = self.color or { 0, 0, 0 } + local white = (c[1] or 0) > 0.5 and (c[2] or 0) > 0.5 and (c[3] or 0) > 0.5 + local set = FADE_BGP[white and "white" or "black"] + return set[self.phase == "in" and "in" or "out"] +end + +-- pokered home/fade.asm:58 +function Transition:bgp() + local len = (self.phase == "out") and self.frames or self.framesIn + local tab = self:fadeTable() + local step = math.min(#tab, + math.floor(fadeAlpha(self.t, len) * (#tab - 1) + 0.5) + 1) + return tab[step] or 0xFF +end + function Transition:draw() + if self.paletteStepped then + self.paletteStepped = false + return + end local alpha = self:alpha() local c = self.color or { 0, 0, 0 } -- Survey zoom draws the overworld into a window-filling world canvas diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 62900ee6..1da34c8d 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -299,9 +299,9 @@ end -- and battle music. function Commands.pushBattle(ctx, battle, keepNpc) if ctx.overworld and ctx.overworld.pushBattle then - -- home/text_script.asm:8 - -- engine/battle/battle_transitions.asm:14 - ctx.overworld:pushBattle(battle, keepNpc or ctx.npc or nil) + -- hSpriteIndex -- pokered home/text_script.asm:2 + -- pokered engine/battle/battle_transitions.asm:14 + ctx.overworld:pushBattle(battle, keepNpc or ctx.npc or ctx.scriptSprite or nil) else Logger.warn("pushBattle: no overworld:pushBattle, skipping the transition wipe") ctx.game.stack:push(battle) @@ -439,10 +439,17 @@ local function bfsPath(map, sx, sy, tx, ty, entities, mover) return nil end +-- hSpriteIndex (pokered home/text_script.asm:2) +-- OAM block over the wipe (pokered engine/battle/battle_transitions.asm:14) +local function stampSpriteIndex(ctx, npc) + ctx.scriptSprite = npc + return npc +end + function Commands.move_npc_to(ctx, objIndex, tx, ty) local ow = ctx.overworld if not ow then return end - local npc = ow:npcByIndex(objIndex) + local npc = stampSpriteIndex(ctx, ow:npcByIndex(objIndex)) if not npc then return end local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty, ow.entities, npc) if not path then @@ -469,7 +476,8 @@ end -- face an arbitrary map object (by object_event index) function Commands.face_object(ctx, objIndex, dir) - local npc = ctx.overworld and ctx.overworld:npcByIndex(objIndex) + local npc = stampSpriteIndex(ctx, + ctx.overworld and ctx.overworld:npcByIndex(objIndex)) if npc then npc.facing = dir end end @@ -478,7 +486,7 @@ end function Commands.place_npc(ctx, objIndex, x, y, facing) local ow = ctx.overworld if not ow then return end - local npc = ow:npcByIndex(objIndex) + local npc = stampSpriteIndex(ctx, ow:npcByIndex(objIndex)) if not npc then return end npc.cellX, npc.cellY = x, y npc.px, npc.py = x * 16, y * 16 @@ -1149,7 +1157,8 @@ end function Commands.walk_npc(ctx, objIndex, dirs, opts) local ow = ctx.overworld if not ow then return end - local entity = objIndex == "player" and ow.player or ow:npcByIndex(objIndex) + local entity = objIndex == "player" and ow.player + or stampSpriteIndex(ctx, ow:npcByIndex(objIndex)) if not entity then return end claimMove(ctx, entity) local runner = ctx.runner diff --git a/src/script/gen2/Movement.lua b/src/script/gen2/Movement.lua index 13658773..b6e99713 100644 --- a/src/script/gen2/Movement.lua +++ b/src/script/gen2/Movement.lua @@ -38,6 +38,17 @@ local TURN_HEAD = 0x00 local TURN_IN = 0x24 local STEP_DIG = 0x4f local STEP_DIG_FRAMES = 16 +-- macros/scripts/movement.asm:99-106 +local SHOW_OBJECT = 0x3c +local HIDE_OBJECT = 0x3d +-- macros/scripts/movement.asm:211-214, engine/overworld/movement.asm:142-160 +local RETURN_DIG = 0x58 +-- engine/events/overworld.asm:864-872 +local DIG_SPIN_FRAMES = 32 +-- macros/scripts/movement.asm:158-160, engine/overworld/map_objects.asm:1368 +local SKYFALL = 0x4e +local SKYFALL_TOP = 0x59 +local SKYFALL_BEAT_FRAMES = 16 function Movement.dir(nibble) return DIR[nibble % 4] or "down" @@ -100,8 +111,10 @@ function Movement.decodeByte(b) -- turn one either. Route 30's two Rattata lunge under it -- (maps/Route30.asm:183-193) and ended up facing away from the fight. return { kind = "fixfacing", fixed = (b == FIX_FACING) } - elseif b == 0x3c or b == 0x3d then -- show/hide object - return { kind = "nop" } + elseif b == SHOW_OBJECT or b == HIDE_OBJECT then + -- OBJECT_FLAGS1 (macros/scripts/movement.asm:99-106) + -- (engine/events/overworld.asm:864-872) + return { kind = "visible", on = (b == SHOW_OBJECT) } elseif b >= 0x3e and b <= 0x46 then -- step_sleep N return { kind = "sleep", frames = (b - 0x3e + 1) * 16 } elseif b == TELEPORT_FROM or b == TELEPORT_TO then @@ -130,6 +143,17 @@ function Movement.decodeByte(b) -- pass and Sudowoodo's shake played in zero frames -- (maps/Route36.asm:260-262 SudowoodoShakeMovement is this byte alone). return { kind = "treeshake", frames = TREE_SHAKE_FRAMES } + elseif b == RETURN_DIG then + -- Movement_return_dig (engine/overworld/movement.asm:142-160) + -- (engine/overworld/map_objects.asm:1481-1493) + return { kind = "returndig" } + elseif b == SKYFALL or b == SKYFALL_TOP then + -- StepFunction_Skyfall (engine/overworld/map_objects.asm:1368) + return { + kind = (b == SKYFALL) and "skyfall" or "skyfalltop", + frames = (b == SKYFALL) and (2 * SKYFALL_BEAT_FRAMES) + or SKYFALL_BEAT_FRAMES, + } end return { kind = "nop" } end @@ -151,6 +175,13 @@ Movement.TREE_SHAKE = TREE_SHAKE Movement.TREE_SHAKE_FRAMES = TREE_SHAKE_FRAMES Movement.STEP_DIG = STEP_DIG Movement.STEP_DIG_FRAMES = STEP_DIG_FRAMES +Movement.SHOW_OBJECT = SHOW_OBJECT +Movement.HIDE_OBJECT = HIDE_OBJECT +Movement.RETURN_DIG = RETURN_DIG +Movement.DIG_SPIN_FRAMES = DIG_SPIN_FRAMES +Movement.SKYFALL = SKYFALL +Movement.SKYFALL_TOP = SKYFALL_TOP +Movement.SKYFALL_BEAT_FRAMES = SKYFALL_BEAT_FRAMES -- SetFacingWeirdTree's own index: it increments OBJECT_STEP_FRAME BEFORE -- masking (`inc a / maskbits NUM_DIRECTIONS, 2 / rrca / rrca`), so the count @@ -214,4 +245,13 @@ function Movement.forcedMovementBytes(back) } end +-- .DigOut / .DigReturn -- engine/events/overworld.asm:864-872 +function Movement.digOutBytes() + return { STEP_DIG, DIG_SPIN_FRAMES, HIDE_OBJECT, STEP_END } +end + +function Movement.digReturnBytes() + return { SHOW_OBJECT, RETURN_DIG, DIG_SPIN_FRAMES, STEP_END } +end + return Movement diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 83813f99..b4079f68 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -13,6 +13,9 @@ local BagMenu = {} local Bag = require("src.inventory.Bag") local Strings = require("src.core.Strings") +-- (engine/menus/start_sub_menus.asm:414-416, home/list_menu.asm:56-82) +local BAG_RETURN_WHITE = 19 + -- acquisition order like wBagItems (Bag.order), not alphabetical local function buildItems(game) local items = {} @@ -86,8 +89,14 @@ end local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) local result, payload, extra = ItemEffects.use(game.data, game.save, id, target, battle, moveIndex, game.overworld) + -- engine/menus/start_sub_menus.asm:410-416, home/list_menu.asm:56-82 + -- engine/items/item_effects.asm:1244 local function closePicker() - if picker then picker:close() end + if not picker then return end + picker:close() + if battle then return end + local Transition = require("src.render.Transition") + game.stack:push(Transition.whiteFlash(game, BAG_RETURN_WHITE)) end -- .useItem_closeMenu ends at CloseStartMenu, so the START menu kept open @@ -376,8 +385,9 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) -- mashing A burns through a stack of them (#796) -- RareCandyText carries sound_get_item_1 -- (engine/menus/party_menu.asm:289-293) - -- engine/items/item_effects.asm:1394 + -- pokered engine/items/item_effects.asm:1392-1394 if picker and picker.eraseCursors then picker:eraseCursors() end + if picker and picker.setMessage then picker:setMessage(payload[1]) end showMessages(game, payload, function() local StatBox = require("src.battle.BattleState").StatBox local statBox @@ -489,7 +499,8 @@ local function pickTargetAndUse(game, battle, id, list) -- the party menu (item_effects.asm:1392-1418); TM/HM stays up through -- `predef LearnMove` (item_effects.asm:2238) (#1686) -- engine/items/item_effects.asm:805 ItemUseMedicine, :1244 .done (#1946) - keepOpen = ItemEffects.healsHP(id) + -- engine/items/item_effects.asm:1959 + keepOpen = ItemEffects.healsHP(id) or wantsMove or ((not battle) and (ItemEffects.keepsPartyMenuOpen(id) or (def and def.machine ~= nil))), onSwitch = function(mon, picker) @@ -497,21 +508,16 @@ local function pickTargetAndUse(game, battle, id, list) useOn(game, battle, id, mon, list, nil, picker) return end - local rows = {} - for mi, mv in ipairs(mon.moves) do - local mdef = game.data.moves[mv.id] - table.insert(rows, { - value = mi, - label = mdef and mdef.name or mv.id, - right = ("%d"):format(mv.pp), - }) - end - game.stack:push(ListMenu.new(game, Strings.source("Which move?"), rows, { - onChoose = function(row, l) - l:close() - useOn(game, battle, id, mon, list, row.value) - end, - })) + -- engine/items/item_effects.asm:1973 + local prompt = (id == "PP_UP") + and romText(game.data, "_RaisePPWhichTechniqueText", + "Raise PP of which\ntechnique?") + or romText(game.data, "_RestorePPWhichTechniqueText", + "Restore PP of\nwhich technique?") + require("src.ui.Screens").push(game, "MoveSelectMenu", mon, prompt, + function(moveIndex) + useOn(game, battle, id, mon, list, moveIndex, picker) + end) end, } -- TM/HM: open the party menu in Gen 1's TM/HM display mode so each mon diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index d883a09b..c50b9a6f 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -13,6 +13,23 @@ local Strings = require("src.core.Strings") local BoxMenu = {} +-- engine/pokemon/bills_pc.asm:118 +-- vChars2 $78; engine/menus/save.asm:497 +local ballTile +local function drawBallTile(tx, ty) + if ballTile == nil then + local ok, img = pcall(love.graphics.newImage, + "assets/generated/battle/balls.png") + ballTile = ok and { img = img, + quad = love.graphics.newQuad(0, 0, 8, 8, img:getDimensions()) } or false + end + if not ballTile then return end + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(ballTile.img, ballTile.quad, tx * 8, ty * 8) + love.graphics.setColor(r, g, b, a) +end + -- PrintListMenuEntries prints the nickname at hlcoord 6,4 and PrintLevel -- one row down, 8 columns right (home/list_menu.asm:364-365, 459-461) local function monRow(game, mon, value) @@ -243,8 +260,10 @@ local function changeBoxMenu(game) Font.drawBox(0, 12, 20, 6) love.graphics.setColor(0, 0, 0, 1) local y = 112 - for line in ((t._ChooseABoxText or Strings("Choose a\nPOKéMON BOX.")) - .. "\n"):gmatch("([^\n]*)\n") do + -- data/text/text_3.asm:30 + local prompt = require("src.render.TextBox") + .strip(t._ChooseABoxText or Strings("Choose a\n BOX.")) + for line in (prompt .. "\n"):gmatch("([^\n]*)\n") do Font.draw(line, 8, y) y = y + 16 end @@ -256,7 +275,7 @@ local function changeBoxMenu(game) baseDraw(self) love.graphics.setColor(0, 0, 0, 1) for i = 1, Boxes.COUNT do - if #boxes[i] > 0 then ListMenu.drawBall(148, i * 8 + 4) end + if #boxes[i] > 0 then drawBallTile(18, i) end end love.graphics.setColor(1, 1, 1, 1) end @@ -354,6 +373,21 @@ function BoxMenu.new(game) -- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND, -- engine/menus/pokemon_pc.asm) { tx = 0, ty = 0, tw = 14, th = #items * 2 + 2, noSound = true }) + -- bills_pc.asm:176 + for _, item in ipairs(items) do + local onSelect = item.onSelect + if item.keepOpen and onSelect then + item.onSelect = function() + menu.hollowIndex = menu.index + onSelect() + end + end + end + local baseUpdate = menu.update + function menu:update(dt) + if self.game.stack:top() == self then self.hollowIndex = nil end + return baseUpdate(self, dt) + end local baseDraw = menu.draw function menu:draw() baseDraw(self) diff --git a/src/ui/DexEntryMenu.lua b/src/ui/DexEntryMenu.lua index e5d6462d..5c934770 100644 --- a/src/ui/DexEntryMenu.lua +++ b/src/ui/DexEntryMenu.lua @@ -233,20 +233,36 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page, sta Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0), 16, 64) local owned = ownedFor(game, def, forceOwned) + local metric = e.heightM ~= nil + local numbers = owned and e.heightFt + -- engine/menus/pokedex.asm:449 + if not metric then + Font.draw(Strings("HT"), 72, 48) + Font.draw("′", 112, 48) + Font.draw("″", 136, 48) + Font.draw(Strings("WT"), 72, 64) + Font.draw(Strings("lb"), 136, 64) + if not numbers then + Font.draw("?", 104, 48) + Font.draw("??", 120, 48) + Font.draw("???", 112, 64) + end + end -- engine/menus/pokedex.asm:516: everything below the divider waits on the -- cry the line above it started if state.crying then love.graphics.setColor(1, 1, 1, 1) return end - -- engine/menus/pokedex.asm:449, numbers only once owned - if owned and e.heightFt then - if e.heightM then + -- engine/menus/pokedex.asm:515-566 + if numbers then + if metric then Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 48) Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 64) else - Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 48) - Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 64) + Font.draw(("%2d"):format(e.heightFt), 96, 48) + Font.draw(("%02d"):format(e.heightIn or 0), 120, 48) + Font.draw(("%6.1f"):format((e.weight or 0) / 10), 88, 64) end end local pages = descPages(game, def, forceOwned) @@ -260,8 +276,6 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page, sta if page < #pages and state.arrow ~= false then Font.drawCode(Theme.moreArrow, 144, 128) end - else - Font.draw(Strings("Data unknown."), 8, 88) end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 3ba64d19..71620c18 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -158,7 +158,10 @@ function Menu:draw() if not item then break end Font.draw(item.label, (self.tx + 2) * 8, rowY(row)) end - Font.drawCode(Theme.cursor, (self.tx + 1) * 8, rowY(self.index - self.scroll)) + -- hollowIndex: home/window.asm:198 + Font.drawCode(self.hollowIndex == self.index + and Theme.cursorHollow or Theme.cursor, + (self.tx + 1) * 8, rowY(self.index - self.scroll)) -- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState -- use, sat on the bottom border like TextBox's page-advance cursor. It -- has to be the border row, not ty + th - 2: that is the last interior diff --git a/src/ui/MoveSelectMenu.lua b/src/ui/MoveSelectMenu.lua new file mode 100644 index 00000000..0c92ddc1 --- /dev/null +++ b/src/ui/MoveSelectMenu.lua @@ -0,0 +1,61 @@ +-- (engine/battle/core.asm:2520 .relearnmenu, engine/items/item_effects.asm:1971) + +local Font = require("src.render.Font") + +local MoveSelectMenu = {} +MoveSelectMenu.__index = MoveSelectMenu + +local CURSOR = 0xED + +function MoveSelectMenu.new(game, mon, prompt, onChoose, onCancel) + local self = setmetatable({}, MoveSelectMenu) + self.game = game + self.mon = mon + -- pokered engine/items/item_effects.asm:2136 + self.prompt = require("src.render.TextBox").strip(prompt or "") + self.onChoose = onChoose + self.onCancel = onCancel + -- pokered engine/battle/core.asm:2542-2543 + self.index = 1 + return self +end + +function MoveSelectMenu:update() + local input = self.game.input + local n = #self.mon.moves + -- engine/battle/core.asm:2692 + if n > 0 and input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or n + elseif n > 0 and input:wasPressed("down") then + self.index = self.index < n and self.index + 1 or 1 + elseif input:wasPressed("b") or (n < 1 and input:wasPressed("a")) then + -- engine/items/item_effects.asm:1985 + self.game.stack:pop() + if self.onCancel then self.onCancel() end + elseif input:wasPressed("a") then + local picked = self.index + self.game.stack:pop() + if self.onChoose then self.onChoose(picked) end + end +end + +function MoveSelectMenu:draw() + -- engine/battle/core.asm:2526 + Font.drawBox(4, 7, 16, 6) + love.graphics.setColor(0, 0, 0, 1) + for i = 1, 4 do + local mv = self.mon.moves[i] + local mdef = mv and self.game.data.moves[mv.id] + -- engine/battle/misc.asm:37 + Font.draw(mv and (mdef and mdef.name or mv.id) or "-", 48, (7 + i) * 8) + end + Font.drawCode(CURSOR, 40, (7 + self.index) * 8) + -- engine/items/item_effects.asm:1979 + Font.drawBox(0, 12, 20, 6) + local first, rest = self.prompt:match("^(.-)\n(.*)$") + Font.draw(first or self.prompt, 8, 14 * 8) + if rest then Font.draw(rest, 8, 16 * 8) end + love.graphics.setColor(1, 1, 1, 1) +end + +return MoveSelectMenu diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 62f5a637..21699a14 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -330,6 +330,8 @@ function PartyMenu.new(game, opts) self.party = party -- link/scoped battles pass their local party view self.swapFrom = nil self.swapAnim = nil + -- wPartyMenuTypeOrMessageID (engine/menus/party_menu.asm:182) + self.message = nil self.submenu = nil self.subIndex = 1 self.blink = 0 @@ -402,6 +404,12 @@ function PartyMenu:update(dt) local anim = self.swapAnim if anim then anim.frames = anim.frames + 1 + -- engine/menus/start_sub_menus.asm:664-666 + if anim.phase == 1 then + anim.blank[anim.to] = true + anim.phase = 2 + return + end local playing = false if anim.src then local ok, p = pcall(anim.src.isPlaying, anim.src) @@ -687,7 +695,9 @@ function PartyMenu:update(dt) if from ~= self.index then party[from], party[self.index] = party[self.index], party[from] end - self.swapAnim = { blank = { [from] = true }, frames = 0 } + -- wCurrentMenuItem (engine/menus/start_sub_menus.asm:662-666) + self.swapAnim = { blank = { [from] = true }, to = self.index, + phase = 1, frames = 0 } if self.game.data then self.swapAnim.src = require("src.core.Sound").play(self.game.data, "Swap") end @@ -776,23 +786,35 @@ function PartyMenu:update(dt) end -- engine/menus/party_menu.asm:229 (#147 #1610 #1901) +-- engine/menus/party_menu.asm:226-235 +function PartyMenu:setMessage(text) + if type(text) ~= "string" then self.message = nil return end + -- constants/charmap.asm:19-20 + self.message = require("src.render.TextBox").strip(text) +end + function PartyMenu:bottomMessage() - if self.swapFrom then - return self.game.data.text._PartyMenuSwapMonText + local text + if self.message then + text = self.message + elseif self.swapFrom then + text = self.game.data.text._PartyMenuSwapMonText or Strings("Move POKéMON\nwhere?") elseif self.tmhm then - return self.game.data.text._PartyMenuUseTMText + text = self.game.data.text._PartyMenuUseTMText or Strings("Use TM on which\nPOKéMON?") elseif self.softboiledFrom or self.itemUse then - return self.game.data.text._PartyMenuItemUseText + text = self.game.data.text._PartyMenuItemUseText or Strings("Use item on which\nPOKéMON?") elseif self.forceSwitch then - return self.game.data.text._PartyMenuBattleText + text = self.game.data.text._PartyMenuBattleText or Strings("Bring out which\nPOKéMON?") else - return self.game.data.text._PartyMenuNormalText + text = self.game.data.text._PartyMenuNormalText or Strings("Choose a POKéMON.") end + -- constants/charmap.asm:19-20 + return require("src.render.TextBox").strip(text) end -- Name-row pixel Y for party slot i (1-based). diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua index b50eede5..aca8d7b2 100644 --- a/src/ui/ShopMenu.lua +++ b/src/ui/ShopMenu.lua @@ -23,16 +23,16 @@ local function txt(game, key, fallback) return game.data.text[key] or fallback end --- engine/events/pokemart.asm:204 (.returnToMainPokemartMenu) +-- pokered engine/events/pokemart.asm:204 local function anythingElse(game) return txt(game, "_PokemartAnythingElseText", Strings("Is there anything\nelse I can do?")) end --- prompt refusals leave the list for the mart menu -- pokemart.asm:113 +-- .returnToMainPokemartMenu -- pokered engine/events/pokemart.asm:199-206 local function refuse(game, menu, list, text) - if list then list:close() end game.stack:push(TextBox.new(game, text, function() + if list then list:close() end menu.footer = anythingElse(game) end)) end diff --git a/src/ui/SlotMachine.lua b/src/ui/SlotMachine.lua index 524d1d31..f8269642 100644 --- a/src/ui/SlotMachine.lua +++ b/src/ui/SlotMachine.lua @@ -358,8 +358,10 @@ function SlotMachine:resolveWin(win) -- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port -- shows it in the box while the screen flashes. LinedUpText follows. self.yeah = (sym == "7") - self.message = sym .. romText(self.game.data, "_LinedUpText", - " lined up!\nScored %d coins!", pay) + -- constants/charmap.asm:19-20 + self.message = sym .. require("src.render.TextBox").strip( + romText(self.game.data, "_LinedUpText", + " lined up!\nScored %d coins!", pay)) -- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not -- credited until the player dismisses the "lined up" text (see startPayout). self.stage = "flash" diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 328abbf5..a036a7ed 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -94,6 +94,10 @@ local TEXT_WIDTH = 18 local TEXT_ROWS = 2 local TEXT_ROW_STEP = 2 +-- ../pokecrystal/home/text.asm:630 +local DOWN_ARROW = "\xe2\x96\xbc" +local ARROW_X, ARROW_Y = 18, 17 + -- ../pokecrystal/engine/battle/core.asm:9119-9137 -- ../pokecrystal/data/text/battle.asm:10-35 function BattleState.battleStartText(name, battleType) @@ -282,8 +286,32 @@ function BattleState:wantsFillScale() return (options and options.battleFit) == "fill" end +local function isWide(self) + local options = self.game and self.game.options + return (options and options.battleLayout) == "wide" +end + +BattleState.isWideBattleLayout = isWide +BattleState.wideLayout = isWide + +function BattleState:panelSize() + if isWide(self) then + local WideBattle = require("src.ui.gen2.WideBattle") + return WideBattle.WIDTH, WideBattle.HEIGHT + end + return Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8 +end + function BattleState:battlePanelScale(winW, winH) - return BattleState.panelScale(winW, winH, self:wantsFillScale()) + if isWide(self) then + local WideBattle = require("src.ui.gen2.WideBattle") + if BattleState.wantsFillScale(self) then + return WideBattle.fillScale(winW, winH) + end + return Chrome.fitScaleFor(winW, winH, WideBattle.WIDTH / 8, + WideBattle.HEIGHT / 8) + end + return BattleState.panelScale(winW, winH, BattleState.wantsFillScale(self)) end function BattleState:drawsWidescreen() return true end @@ -351,6 +379,8 @@ function BattleState.new(game, opts) self.queue = {} self.message = nil self.messageTimer = 0 + -- ../pokecrystal/home/joypad.asm:428 + self.arrowBlink = 0 self.phase = "intro" -- intro | menu | moves | resolving | evolving | done -- wEvolvableFlags (ram/wram.asm, one bit per party slot). -- engine/battle/core.asm sets a mon's bit the moment it levels up, right @@ -510,6 +540,9 @@ function BattleState.new(game, opts) -- announced. Nothing to slide when the cache has no trainer pic. if self.showEnemyTrainer then self:push({ kind = "trainer-slide" }) + else + -- ../pokecrystal/engine/battle/core.asm:3549 + self.picHidden.enemy = true end -- ShowBattleTextEnemySentOut, then ShowSetEnemyMonAndSendOutAnimation -- (core.asm:2978-2980, 3354): this is where the mon's frontpic first @@ -2546,6 +2579,8 @@ function BattleState:update(_dt) if self.phase == "fadeout" then return self:stepExitFade() end self:updateAlarm() self:stepFrontAnim() + -- ../pokecrystal/home/joypad.asm:428 + self.arrowBlink = ((self.arrowBlink or 0) + 1) % 32 local input = self.game and self.game.input if not input then return end @@ -2574,6 +2609,8 @@ function BattleState:update(_dt) if self.trainerSlide >= TRAINER_SLIDE_FRAMES then self.trainerSlide = nil self.showEnemyTrainer = false + -- ../pokecrystal/engine/battle/core.asm:3221 + self.picHidden.enemy = true self:advanceQueue() end return @@ -2884,10 +2921,7 @@ function BattleState:update(_dt) return end - -- CheckIfCurPartyMonIsFitToFight said no. Its text is read with the list - -- CLOSED here rather than over it (the party menu is its own screen in this - -- port), and the list comes back the moment the line is done -- which is - -- what ForcePickPartyMonInBattle's `jr c, .loop` does with the carry. + -- stack the refusal prints over the list itself (core.asm:2852-2857) if self.phase == "refuse-switch" then if self.messageTimer > 0 then if input:wasPressed("a") or input:wasPressed("b") then @@ -3009,7 +3043,8 @@ function BattleState:openParty(forced) local stack = self.game and self.game.stack if not stack then return false end self.phase = "submenu" - Screens.push(self.game, "Gen2PartyMenu", { + local list + list = Screens.push(self.game, "Gen2PartyMenu", { -- PARTYMENUACTION_CHOOSE_POKEMON for the voluntary list and -- PARTYMENUACTION_SWITCH for the forced one (engine/battle/core.asm:4795, -- :2702; engine/pokemon/party_menu.asm:660-679). Only the voluntary list @@ -3025,31 +3060,33 @@ function BattleState:openParty(forced) self.phase = forced and "forced-switch" or "menu" end, onChoose = function(index, mon) - stack:pop() -- TryPlayerSwitch's own order, every arm ending on -- `jp BattleMenuPKMN_Loop` (engine/battle/core.asm:4863-4888). + local refused, key, arg if not forced and mon == self.battle.player then - return self:refuseSwitch(false, TEXT_ALREADY_OUT, self:name(mon)) - end - if not forced and self.battle:switchLocked() then - return self:refuseSwitch(false, TEXT_CANT_BE_RECALLED, - self:name(self.battle.player)) - end - -- CheckIfCurPartyMonIsFitToFight's `cp EGG` arm (core.asm:3450-3456). - if mon.isEgg then - return self:refuseSwitch(forced, TEXT_EGG_CANT_BATTLE) - end - if (mon.hp or 0) <= 0 then + refused, key, arg = true, TEXT_ALREADY_OUT, self:name(mon) + elseif not forced and self.battle:switchLocked() then + refused, key, arg = true, TEXT_CANT_BE_RECALLED, + self:name(self.battle.player) + elseif mon.isEgg then + -- CheckIfCurPartyMonIsFitToFight's `cp EGG` arm (core.asm:3450-3456) + refused, key = true, TEXT_EGG_CANT_BATTLE + elseif (mon.hp or 0) <= 0 then -- CheckIfCurPartyMonIsFitToFight (engine/pokemon/party_menu.asm): -- a fainted pick prints Text_TheresNoWillToFight and returns carry, so -- the caller re-opens the list -- BattleMenu_PKMN's own loop for a -- voluntary switch, ForcePickPartyMonInBattle's `jr c` after a faint. - -- Silently dropping the player back on the list (or, worse, back on - -- the battle menu) is what made the forced switch read as taking two - -- or three presses: the cursor opens on the mon that just fainted, and - -- pressing A on it did nothing a player could see. - return self:refuseSwitch(forced) + refused = true end + -- Only the accepted switch tears the list down (core.asm:5186-5192) + if refused then + if list then + return list:refuse(Strings(key or TEXT_NO_WILL_TO_FIGHT, arg)) + end + stack:pop() + return self:refuseSwitch(forced, key, arg) + end + stack:pop() if forced then -- ForcePickPartyMonInBattle loops on carry: a pick the engine will not -- take has to come back as the list again, never as the battle menu @@ -3528,7 +3565,8 @@ function BattleState:openShiftParty() return self:advanceQueue() end self.phase = "submenu" - Screens.push(self.game, "Gen2PartyMenu", { + local list + list = Screens.push(self.game, "Gen2PartyMenu", { prompt = "which", battle = true, onCancel = function() @@ -3537,12 +3575,23 @@ function BattleState:openShiftParty() self:advanceQueue() end, onChoose = function(index, mon) - stack:pop() + -- SwitchMonAlreadyOut (core.asm:2864) + local refused, key, arg if mon == self.battle.player then - return self:refuseShift(TEXT_ALREADY_OUT, self:name(mon)) + refused, key, arg = true, TEXT_ALREADY_OUT, self:name(mon) + elseif mon.isEgg then + refused, key = true, TEXT_EGG_CANT_BATTLE + elseif (mon.hp or 0) <= 0 then + refused = true end - if mon.isEgg then return self:refuseShift(TEXT_EGG_CANT_BATTLE) end - if (mon.hp or 0) <= 0 then return self:refuseShift(nil) end + if refused then + if list then + return list:refuse(Strings(key or TEXT_NO_WILL_TO_FIGHT, arg)) + end + stack:pop() + return self:refuseShift(key, arg) + end + stack:pop() self.shiftSwitchIndex = index self.phase = "resolving" self:advanceQueue() @@ -4117,7 +4166,20 @@ end function BattleState:drawHud() local wasBattle = Font.useBattleExtra(true) - local enemy, player = self:activeMon("enemy"), self:activeMon("player") + self:drawEnemyHud() + self:drawPics() + self:drawPlayerHud() + Font.useBattleExtra(wasBattle) +end + +function BattleState:drawPics() + self:drawPic(self:activeMon("enemy"), false) + self:drawPic(self:activeMon("player"), true) +end + +function BattleState:drawEnemyHud() + local wasBattle = Font.useBattleExtra(true) + local enemy = self:activeMon("enemy") local showStatus = self:statusHUDVisible() -- Enemy HUD (DrawEnemyHUD clears (1,0) 4 rows x 11 cols): @@ -4164,14 +4226,13 @@ function BattleState:drawHud() end self.hud:drawBallRow(self.battle and self.battle.enemyParty, 8, 2, -1) end + Font.useBattleExtra(wasBattle) +end - self:drawPic(enemy, false) - - -- Player HUD (DrawPlayerHUD clears (9,7) 5 rows x 11 cols): - -- name at (10,7); PrintLevel at (14,8) with the gender at (17,8); - -- the HP bar at (10,9), its numbers below; the vertical bar at (18,9), the - -- border from (18,10) going left, and the exp bar at (10,11). - self:drawPic(player, true) +function BattleState:drawPlayerHud() + local wasBattle = Font.useBattleExtra(true) + local player = self:activeMon("player") + local showStatus = self:statusHUDVisible() -- ShowPlayerMonsRemaining (engine/battle/trainer_huds.asm:17-30): balls -- walking RIGHT from OAM (96, 96), i.e. tile (11, 10). if showStatus and self.ballRows.player then @@ -4301,13 +4362,27 @@ end -- The message on the cart's own two rows: 14 and 16, with 15 blank between -- them (home/text.asm:143 and :397). A string that will not fit two 18-tile -- lines is cut rather than spilling onto the rows Paragraph clears. -function BattleState:printMessage() +function BattleState:printMessage(ox) + ox = ox or 0 self:syncTyper() local lines = self:messageLines() for i = 1, math.min(#lines, TEXT_ROWS) do Chrome.printThrough(lines[i], TEXT_INNER_X, TEXT_INNER_Y + (i - 1) * TEXT_ROW_STEP, Chrome.DEFAULT_BOX_PALETTE) end + if self:messageArrowVisible() then + Chrome.printThrough(DOWN_ARROW, ARROW_X + ox, ARROW_Y, + Chrome.DEFAULT_BOX_PALETTE) + end +end + +-- ../pokecrystal/home/text.asm:548 +-- ../pokecrystal/home/joypad.asm:428 +function BattleState:messageArrowVisible() + if (self.messageTimer or 0) <= 0 then return false end + if self.waitSfx or (self.messageDelay or 0) > 0 then return false end + if Typer.typing(self) then return false end + return Typer.arrowOn(self) end -- ../pokecrystal/home/text.asm:660 StdBattleTextbox -> PrintText @@ -4363,18 +4438,26 @@ function BattleState:drawMoveInfoBox(move) 5, 11, Chrome.DEFAULT_BOX_PALETTE) end +local function hasBattleSides(self) + local hasPlayer = self.battle and (self.battle.player or self.tutorial) + return not not (self.battle and hasPlayer and self.battle.enemy) +end + +BattleState.hasBattleSides = hasBattleSides + function BattleState:drawPanel() Chrome.clear() - -- A tutorial battle legitimately has no player mon, so only the enemy is - -- required there; everywhere else a missing side is a caller bug. - local hasPlayer = self.battle and (self.battle.player or self.tutorial) - if not (self.battle and hasPlayer and self.battle.enemy) then + if not hasBattleSides(self) then Chrome.printThrough(Strings("NO BATTLE"), 1, 1, Chrome.DEFAULT_BOX_PALETTE) return end self:drawHud() + self:drawBottom(0) +end +function BattleState:drawBottom(ox) + ox = ox or 0 if not self:bottomUIVisible() then love.graphics.setColor(1, 1, 1, 1) return @@ -4391,18 +4474,18 @@ function BattleState:drawPanel() local moveMenu = self.phase == "moves" local forgetting = self.phase == "choose-forget" and (self.messageTimer or 0) <= 0 - Chrome.box(0, 12, 20, 6) + Chrome.box(0, 12, 20 + ox, 6) if moveMenu then -- List box first (core.asm:5074-5084), MoveInfoBox on top (:5157). - Chrome.box(4, 12, 16, 6) + Chrome.box(4, 12, 16 + ox, 6) Chrome.box(0, 8, 11, 5) end if self.phase == "menu" then - self:printMessage() - local boxX = self.contest and CONTEST_MENU_BOX_X or MENU_BOX_X + self:printMessage(ox) + local boxX = (self.contest and CONTEST_MENU_BOX_X or MENU_BOX_X) + ox local spacing = self.contest and CONTEST_MENU_COL_SPACING or MENU_COL_SPACING - Chrome.box(boxX, 12, 20 - boxX, 6) + Chrome.box(boxX, 12, 20 + ox - boxX, 6) for i, label in ipairs(self:menuLabels()) do local col = ((i - 1) % 2) * spacing local row = math.floor((i - 1) / 2) * 2 @@ -4414,7 +4497,7 @@ function BattleState:drawPanel() end elseif forgetting then -- engine/pokemon/learn.asm:136-146 - self:printMessage() + self:printMessage(ox) local learn = self.pendingLearn local mon = learn and self.battle.party[learn.index] local moves = (mon and mon.moves) or self:playerMoves() @@ -4447,7 +4530,7 @@ function BattleState:drawPanel() self:drawMoveInfoBox(moves[cursorRow]) else -- Battle messages wrap inside the box rather than running off the frame. - self:printMessage() + self:printMessage(ox) -- YesNoBox: `lb bc, SCREEN_WIDTH - 6, 7`, a 6x5 box at (14,7) with YES at -- (16,8) and NO at (16,10), drawn over the battle while the question -- stands. @@ -4458,7 +4541,7 @@ function BattleState:drawPanel() -- OfferSwitch calls PlaceYesNoBox with `lb bc, 1, 7`, so its box is at -- (1,7) instead (engine/battle/core.asm:3303, home/menu.asm:392-410). local left = (self.phase == "ask-shift" or self.phase == "ask-next-mon") - and 1 or 14 + and 1 or (14 + ox) Chrome.box(left, 7, 6, 5) Chrome.printThrough(Strings("YES"), left + 2, 8, Chrome.DEFAULT_BOX_PALETTE) @@ -4474,7 +4557,7 @@ function BattleState:drawPanel() end GbcPalette.setBgp(previousBgp) if self.phase == "stats-box" and self.statsBoxMon then - self:drawStatsBox(self.statsBoxMon) + self:drawStatsBox(self.statsBoxMon, math.floor(ox / 2)) end love.graphics.setColor(1, 1, 1, 1) end @@ -4487,7 +4570,8 @@ local STATS_BOX_ROWS = { } -- pokegold engine/battle/core.asm:7060-7066 (box at hlcoord 9,0, stats at 11,y). -function BattleState:drawStatsBox(mon) +function BattleState:drawStatsBox(mon, ox) + ox = ox or 0 if not mon then return end local stats = mon.stats local data = self.game and self.game.data @@ -4496,11 +4580,12 @@ function BattleState:drawStatsBox(mon) stats = Mon.stats(def.baseStats, mon.dvs, mon.level, mon.statExp) end if not stats then return end - Chrome.textbox(9, 0, 9, 10) + Chrome.textbox(9 + ox, 0, 9, 10) for i, row in ipairs(STATS_BOX_ROWS) do local ty = 1 + (i - 1) * 2 - Chrome.printThrough(Strings(row[1]), 11, ty, Chrome.DEFAULT_BOX_PALETTE) - Chrome.printRightThrough(("%d"):format(stats[row[2]] or 0), 19, ty + 1, + Chrome.printThrough(Strings(row[1]), 11 + ox, ty, + Chrome.DEFAULT_BOX_PALETTE) + Chrome.printRightThrough(("%d"):format(stats[row[2]] or 0), 19 + ox, ty + 1, Chrome.DEFAULT_BOX_PALETTE) end end @@ -4508,9 +4593,9 @@ end -- The BG layer, plus whatever the animation is doing to it, plus the OBJ -- layer on top. OBJs are not affected by SCX/SCY, which is why they are drawn -- after the scanline blit rather than into the canvas with everything else. -function BattleState:drawScene() +function BattleState:drawScene(bodyFn) local previousBgp = GbcPalette.setBgp(self:exitFadeBgp() or GbcPalette.bgp) - self:drawSceneBody() + if bodyFn then bodyFn() else self:drawSceneBody() end GbcPalette.setBgp(previousBgp) -- battle.overlay: shiny sparkles, custom HUD chrome, and so on. Draw-only, -- and the same name, the same payload (the battle screen) and the same place @@ -4560,8 +4645,8 @@ function BattleState:drawLiftedRows() G.draw(self.liftCanvas, 0, 0) end -function BattleState:drawSceneBody() - local panel = function() self:drawPanel() end +function BattleState:drawSceneBody(panelFn) + local panel = panelFn or function() self:drawPanel() end if self.animView and self.slideFrame < BattleAnimView.SLIDE_FRAMES then -- The back pic is lifted out of the sliding bands and drawn the way the -- cart's OAM copy is: one intact piece riding in from the right, so it @@ -4593,10 +4678,14 @@ function BattleState:drawSceneBody() end function BattleState:draw() + if self:wideLayout() then return end Chrome.withClip(function() self:drawScene() end) end function BattleState:drawWidescreen(winW, winH) + if self:wideLayout() then + return require("src.ui.gen2.WideBattle").draw(self, winW, winH) + end Chrome.withPanel(winW, winH, 1, 1, 1, function() self:drawScene() end, self:battlePanelScale(winW, winH)) end diff --git a/src/ui/gen2/Chrome.lua b/src/ui/gen2/Chrome.lua index b2139c60..2ec83d1d 100644 --- a/src/ui/gen2/Chrome.lua +++ b/src/ui/gen2/Chrome.lua @@ -83,19 +83,28 @@ local function playfieldRect(winW, winH) return 0, 0, winW or 0, winH or 0 end -function Chrome.fitScale(winW, winH) +function Chrome.fitScaleFor(winW, winH, tilesW, tilesH) local _, _, w, h = playfieldRect(winW, winH) - return math.max(1, math.floor(math.min(w / (Chrome.SCREEN_W * 8), - h / (Chrome.SCREEN_H * 8)))) + return math.max(1, math.floor(math.min(w / (tilesW * 8), h / (tilesH * 8)))) +end + +function Chrome.fitOriginFor(winW, winH, scale, tilesW, tilesH) + scale = scale or Chrome.fitScaleFor(winW, winH, tilesW, tilesH) + local x, y, w, h = playfieldRect(winW, winH) + return x + math.floor((w - tilesW * 8 * scale) / 2), + y + math.floor((h - tilesH * 8 * scale) / 2) + - Chrome.positionLift(winW, winH, scale) +end + +function Chrome.fitScale(winW, winH) + return Chrome.fitScaleFor(winW, winH, Chrome.SCREEN_W, Chrome.SCREEN_H) end -- The centred origin that goes with it, so a caller does not re-derive it. function Chrome.fitOrigin(winW, winH, scale) scale = scale or Chrome.fitScale(winW, winH) - local x, y, w, h = playfieldRect(winW, winH) - return x + math.floor((w - Chrome.SCREEN_W * 8 * scale) / 2), - y + math.floor((h - Chrome.SCREEN_H * 8 * scale) / 2) - - Chrome.positionLift(winW, winH, scale) + return Chrome.fitOriginFor(winW, winH, scale, Chrome.SCREEN_W, + Chrome.SCREEN_H) end function Chrome.positionLift(winW, winH, scale) @@ -113,6 +122,8 @@ local function clipTo(x, y, w, h) else G.setScissor(x, y, w, h) end end +Chrome.clipTo = clipTo + function Chrome.withPanel(winW, winH, r, g, b, drawFn, scale) local G = love.graphics Chrome.letterbox(winW, winH, r, g, b) diff --git a/src/ui/gen2/GenderSelect.lua b/src/ui/gen2/GenderSelect.lua index e8e03d1b..ee070d01 100644 --- a/src/ui/gen2/GenderSelect.lua +++ b/src/ui/gen2/GenderSelect.lua @@ -7,6 +7,7 @@ local Music = require("src.core.Music") local RomText = require("src.core.RomText") local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") +local Typer = require("src.ui.gen2.Typer") local GenderSelect = {} GenderSelect.__index = GenderSelect @@ -59,6 +60,9 @@ function GenderSelect.new(game, opts) self.exit = nil self.text = CommonText.plain(RomText(self.data, "_AreYouABoyOrAreYouAGirlText", FALLBACK)) + -- ../pokecrystal/home/print_text.asm:5, ../pokecrystal/engine/menus/init_gender.asm:30-34 + self.typer = Typer.new(game, {}) + self.typer:start(self.text) return self end @@ -83,6 +87,7 @@ function GenderSelect:choose(index) end function GenderSelect:update(_dt) + if self.typer then self.typer:tick() end if self.exit then self.exit = self.exit - 1 if self.exit > 0 then return end @@ -112,7 +117,8 @@ function GenderSelect:drawPanel() G.setColor(1, 1, 1, 1) Chrome.textbox(SAY_X, SAY_Y, SAY_W, SAY_H) -- ../pokecrystal/home/text.asm:473 - Chrome.printWrapped(self.text, SAY_TEXT_X, SAY_TEXT_Y, SAY_W, 2, 2) + Chrome.printWrapped(table.concat(Typer.text(self, {}), "\n"), + SAY_TEXT_X, SAY_TEXT_Y, SAY_W, 2, 2) Chrome.box(BOX_X, BOX_Y, BOX_W, BOX_H) for i, option in ipairs(GenderSelect.OPTIONS) do local row = TEXT_Y + (i - 1) * ROW_STEP @@ -122,19 +128,11 @@ function GenderSelect:drawPanel() end function GenderSelect:draw() - self:drawPanel() + Chrome.withClip(function() self:drawPanel() end) end function GenderSelect:drawWidescreen(winW, winH) - local G = love.graphics - Chrome.letterbox(winW, winH, 1, 1, 1) - local scale = Chrome.fitScale(winW, winH) - local ox, oy = Chrome.fitOrigin(winW, winH, scale) - G.push() - G.translate(ox, oy) - G.scale(scale, scale) - self:drawPanel() - G.pop() + Chrome.withPanel(winW, winH, 1, 1, 1, function() self:drawPanel() end) end return GenderSelect diff --git a/src/ui/gen2/InitClock.lua b/src/ui/gen2/InitClock.lua index f233444c..5b6d9a50 100644 --- a/src/ui/gen2/InitClock.lua +++ b/src/ui/gen2/InitClock.lua @@ -26,6 +26,7 @@ local Chrome = require("src.ui.gen2.Chrome") local Clock = require("src.core.gen2.Clock") local Strings = require("src.core.Strings") +local Typer = require("src.ui.gen2.Typer") local InitClock = {} InitClock.__index = InitClock @@ -60,6 +61,9 @@ InitClock.TEXT = TEXT -- same three and has always had them right. local MORN_HOUR, DAY_HOUR, NITE_HOUR = 4, 10, 18 +-- ../pokecrystal/engine/rtc/timeset.asm:24 +InitClock.GROUND = { 0, 0, 0 } + -- Clock.DAY_NAMES / Clock.weekdayName is the single translated home for this -- table: MainMenu's clock box and the Pokegear's clock card read the same -- weekday off the same save and must never disagree about what it is @@ -142,9 +146,18 @@ function InitClock.new(game, opts) -- (a \f here) on a button press like any other text box, and Oak's opening -- is three lines long over two of them. self.page = 1 + -- ../pokecrystal/home/print_text.asm:5 + self.typer = Typer.new(game, { + instant = self.autoConfirm or self.mode == "day", + }) + self:startText() return self end +function InitClock:startText() + if self.typer then self.typer:start(self:pageText()) end +end + -- The current question, split into its pages. function InitClock:pages() local question = self:question() @@ -249,6 +262,7 @@ function InitClock:accept() -- A on a page that has more behind it turns the page, the way `para` does. if self:morePages() then self.page = self.page + 1 + self:startText() return end self.page = 1 @@ -269,6 +283,7 @@ function InitClock:accept() elseif self.phase == "confirm-day" then self:finish() end + self:startText() end function InitClock:decline() @@ -279,6 +294,7 @@ function InitClock:decline() elseif self.phase == "confirm-day" then self.phase = "day" end + self:startText() end function InitClock:update(_dt) @@ -289,6 +305,13 @@ function InitClock:update(_dt) return end local input = self.game and self.game.input + -- ../pokecrystal/home/print_text.asm:5, ../pokecrystal/home/text.asm:473 + if self.typer and not self.typer:tick() then + if input and (input:wasPressed("a") or input:wasPressed("b")) then + self.typer.shown = self.typer.total + end + return + end if not input then return end if self:confirming() and not self:morePages() then -- YesNoBox: the cursor walks two rows and B is NO. @@ -345,8 +368,14 @@ local function arrow(tx, ty, up) end function InitClock:drawPanel() - -- ../pokecrystal/engine/rtc/timeset.asm:22 - if self.mode ~= "day" then Chrome.clear() end + -- ../pokecrystal/engine/rtc/timeset.asm:22-32 + if self.mode ~= "day" then + local G = love.graphics + local ground = InitClock.GROUND + G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1) + G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) + G.setColor(1, 1, 1, 1) + end local value = self:display() if value then local bx, by, bw, bh, arrowX, tx, ty = self:pickerBox() @@ -361,7 +390,7 @@ function InitClock:drawPanel() -- Gold prompt uses. Chrome.textbox(0, 12, 18, 4) -- ../pokecrystal/home/text.asm:473 - Chrome.printWrapped(self:pageText(), 1, 14, 18, 2, 2) + Chrome.printWrapped(table.concat(Typer.text(self, {}), "\n"), 1, 14, 18, 2, 2) if self:confirming() then -- ../pokecrystal/home/menu.asm:418 Chrome.box(14, 7, 6, 5) @@ -372,19 +401,13 @@ function InitClock:drawPanel() end function InitClock:draw() - self:drawPanel() + Chrome.withClip(function() self:drawPanel() end) end function InitClock:drawWidescreen(winW, winH) - local G = love.graphics - Chrome.letterbox(winW, winH, 1, 1, 1) - local scale = Chrome.fitScale(winW, winH) - local ox, oy = Chrome.fitOrigin(winW, winH, scale) - G.push() - G.translate(ox, oy) - G.scale(scale, scale) - self:drawPanel() - G.pop() + local ground = InitClock.GROUND + Chrome.withPanel(winW, winH, ground[1] / 255, ground[2] / 255, + ground[3] / 255, function() self:drawPanel() end) end return InitClock diff --git a/src/ui/gen2/OakSpeech.lua b/src/ui/gen2/OakSpeech.lua index 63a9cc27..620ac688 100644 --- a/src/ui/gen2/OakSpeech.lua +++ b/src/ui/gen2/OakSpeech.lua @@ -714,20 +714,13 @@ function OakSpeech:drawPanel() end end +-- ../pokecrystal/engine/menus/intro_menu.asm:875 function OakSpeech:draw() - self:drawPanel() + Chrome.withClip(function() self:drawPanel() end) end function OakSpeech:drawWidescreen(winW, winH) - local G = love.graphics - Chrome.letterbox(winW, winH, 1, 1, 1) - local scale = Chrome.fitScale(winW, winH) - local ox, oy = Chrome.fitOrigin(winW, winH, scale) - G.push() - G.translate(ox, oy) - G.scale(scale, scale) - self:drawPanel() - G.pop() + Chrome.withPanel(winW, winH, 1, 1, 1, function() self:drawPanel() end) end return OakSpeech diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index ebb8dac5..856549db 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -419,6 +419,11 @@ local ROWS = { text = function(options) return Strings(require("src.core.LogicClock").label(options.logicClock)) end }, + { label = Strings.source("BATTLE LAYOUT"), key = "battleLayout", port = true, + values = { "og", "wide" }, + display = { + og = Strings.source("OG "), wide = Strings.source("WIDE"), + } }, { label = Strings.source("BATTLE SIZE"), key = "battleFit", port = true, values = { "fixed", "fill" }, display = { @@ -454,7 +459,8 @@ local GROUPS = { { id = "group.audio", label = Strings.source("AUDIO"), members = { "sound", "musicVol", "sfxVol", "musicFilter" } }, { id = "group.battle", label = Strings.source("BATTLE OPTIONS"), - members = { "battleScene", "battleStyle", "battleFit", "battleBg" } }, + members = { "battleScene", "battleStyle", "battleLayout", "battleFit", + "battleBg" } }, { id = "group.extras", label = Strings.source("EXTRAS"), members = { "zoom", "voidFill", "tilt" } }, } diff --git a/src/ui/gen2/PartyMenu.lua b/src/ui/gen2/PartyMenu.lua index d7180f27..d4748242 100644 --- a/src/ui/gen2/PartyMenu.lua +++ b/src/ui/gen2/PartyMenu.lua @@ -648,7 +648,7 @@ function PartyMenu:showItemResult(slot, opts) shown = opts.fromHp, target = opts.toHp, text = opts.text, - delay = PartyMenu.ACTION_TEXT_DELAY, + delay = opts.delay or PartyMenu.ACTION_TEXT_DELAY, onDone = opts.onDone, auto = opts.auto, holdSlot = opts.holdSlot, @@ -657,6 +657,15 @@ function PartyMenu:showItemResult(slot, opts) if opts.sfx then self:playSfx(opts.sfx) end end +-- ../pokecrystal/engine/battle/core.asm:5156 +-- ../pokecrystal/home/text.asm:124 +function PartyMenu:refuse(text) + self:closeSubmenu() + local lines = Chrome.wrap(text, 18) + for i = #lines, 3, -1 do lines[i] = nil end + self:showItemResult(nil, { text = table.concat(lines, "\n"), delay = 0 }) +end + function PartyMenu:itemResultClimbing() local r = self.itemResult return r ~= nil and r.shown ~= nil and r.target ~= nil and r.shown ~= r.target diff --git a/src/ui/gen2/WideBattle.lua b/src/ui/gen2/WideBattle.lua new file mode 100644 index 00000000..05a36fec --- /dev/null +++ b/src/ui/gen2/WideBattle.lua @@ -0,0 +1,64 @@ + +local Chrome = require("src.ui.gen2.Chrome") +local Strings = require("src.core.Strings") + +local WideBattle = { + WIDTH = 304, + HEIGHT = 144, + TILES_W = 38, + TILES_H = 18, + FIELD_X = 72, + EXTRA_TILES = 18, +} + +function WideBattle.fillScale(winW, winH) + local w, h = winW or 0, winH or 0 + local ok, Playfield = pcall(require, "src.render.Playfield") + if ok and Playfield.rect then + local okv, _, _, pw, ph = pcall(Playfield.rect, winW, winH) + if okv and pw and pw >= 1 and ph and ph >= 1 then + w, h = pw, ph + end + end + return math.max(1, math.min(w / WideBattle.WIDTH, h / WideBattle.HEIGHT)) +end + +function WideBattle.drawSurface(battle) + local G = love.graphics + Chrome.paletteFill(0, 0, WideBattle.WIDTH, WideBattle.HEIGHT) + if not battle:hasBattleSides() then + Chrome.printThrough(Strings("NO BATTLE"), 1, 1, Chrome.DEFAULT_BOX_PALETTE) + return + end + G.push() + G.translate(WideBattle.FIELD_X, 0) + battle:drawSceneBody(function() + Chrome.clear() + battle:drawPics() + end) + G.pop() + -- engine/battle/core.asm:4730 + battle:drawEnemyHud() + -- engine/battle/core.asm:4592 + G.push() + G.translate(WideBattle.EXTRA_TILES * 8, 0) + battle:drawPlayerHud() + G.pop() + battle:drawBottom(WideBattle.EXTRA_TILES) +end + +function WideBattle.draw(battle, winW, winH) + local G = love.graphics + Chrome.letterbox(winW, winH, 1, 1, 1) + local scale = battle:battlePanelScale(winW, winH) + local ox, oy = Chrome.fitOriginFor(winW, winH, scale, WideBattle.TILES_W, + WideBattle.TILES_H) + G.push("all") + Chrome.clipTo(ox, oy, WideBattle.WIDTH * scale, WideBattle.HEIGHT * scale) + G.translate(ox, oy) + G.scale(scale, scale) + battle:drawScene(function() WideBattle.drawSurface(battle) end) + G.pop() +end + +return WideBattle diff --git a/src/world/FieldDefaults.lua b/src/world/FieldDefaults.lua index 4a8ede3c..c174e8f1 100644 --- a/src/world/FieldDefaults.lua +++ b/src/world/FieldDefaults.lua @@ -134,6 +134,12 @@ FieldDefaults.FIELD = { lastMapRewrites = LAST_MAP_REWRITES, -- CheckIfInOutsideMap: what counts as "outside" for the wLastMap memory outsideTilesets = { "OVERWORLD", "PLATEAU" }, + -- PlayMapChangeSound (home/overworld.asm:689-703) + -- of the cell being left; pokeyellow home/overworld.asm:667-671 + mapChangeSound = { + doorTile = 0x0B, + exemptTilesets = { "FACILITY", "CEMETERY" }, + }, -- the Route 16/18 gate scripts `res BIT_ALWAYS_ON_BIKE` every frame forcedMovement = { clearMaps = { "ROUTE_16_GATE_1F", "ROUTE_18_GATE_1F" } }, -- the one-shot flag the gate's pass text is gated on; a gate a mod adds diff --git a/src/world/Map.lua b/src/world/Map.lua index c28ba8c8..d0852b7f 100644 --- a/src/world/Map.lua +++ b/src/world/Map.lua @@ -143,15 +143,12 @@ end -- ------- map record properties (authored maps set them; vanilla falls back) --- town/route surface: door SFX, the walk-out step, the Fly menu and the --- town map all mean this one function Map.isOutdoor(def) if def.outdoor ~= nil then return def.outdoor end return def.tileset == "OVERWORLD" end -- CheckIfInOutsideMap, a strictly wider set: Route 23 / Indigo Plateau are --- outside for the wLastMap memory without being outdoor for the door SFX function Map.isOutside(def, tilesets) if Map.isOutdoor(def) then return true end for _, ts in ipairs(tilesets or OUTSIDE_TILESETS) do diff --git a/src/world/NPC.lua b/src/world/NPC.lua index f8e41663..ab950caf 100644 --- a/src/world/NPC.lua +++ b/src/world/NPC.lua @@ -1,6 +1,4 @@ -- Map object (NPC/item) built from a generated object_event entry. --- STAY objects keep their facing; WALK objects wander randomly within the --- roam constraint (ANY_DIR / UP_DOWN / LEFT_RIGHT), like the original. local Collision = require("src.world.Collision") local SpriteRenderer = require("src.render.SpriteRenderer") @@ -14,8 +12,10 @@ local FACING_FROM_RANGE = { DOWN = "down", UP = "up", LEFT = "left", RIGHT = "right", } +-- .randomMovement -- pokered engine/overworld/movement.asm:190 local ROAM_DIRS = { ANY_DIR = { "up", "down", "left", "right" }, + NONE = { "up", "down", "left", "right" }, UP_DOWN = { "up", "down" }, LEFT_RIGHT = { "left", "right" }, } @@ -36,8 +36,11 @@ function NPC.new(data, mapId, objDef) self.animClock = 0 self.stepFlip = false self.frozen = false -- scripts freeze NPCs while talking - self.wanders = objDef.movement == "WALK" - self.roamDirs = ROAM_DIRS[objDef.range] or ROAM_DIRS.ANY_DIR + -- pokered engine/overworld/movement.asm:611 + local turnDirs = ROAM_DIRS[objDef.range or "NONE"] + self.steps = objDef.movement == "WALK" + self.wanders = (self.steps or objDef.movement == "STAY") and turnDirs ~= nil + self.roamDirs = turnDirs or ROAM_DIRS.ANY_DIR self.timer = love.math.random(30, 120) return self end @@ -111,6 +114,8 @@ function NPC:update(map, entities) self.timer = love.math.random(30, 180) local dir = self.roamDirs[love.math.random(#self.roamDirs)] self.facing = dir + -- pokered engine/overworld/movement.asm:262 + if not self.steps then return end if love.math.random() < 0.5 then return end -- sometimes just turn -- never wander onto warps, so NPCs don't walk out of the map local tx, ty = Collision.target(self.cellX, self.cellY, dir) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 8dc7c5ca..49c5f2ad 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -10,6 +10,7 @@ local Encounter = require("src.world.Encounter") local FieldDefaults = require("src.world.FieldDefaults") local Flags = require("src.script.Flags") local GameVersion = require("src.core.GameVersion") +local GbcPalette = require("src.render.GbcPalette") local Logger = require("src.core.Logger") local Map = require("src.world.Map") local MapLoader = require("src.world.MapLoader") @@ -73,6 +74,24 @@ local FLY_PATH_IN = { { 0x39, 0x58 }, { 0x3B, 0x50 }, { 0x3C, 0x48 }, { 0x3C, 0x40 }, } +-- engine/overworld/player_animations.asm:126 +local TELEPORT_OUT_HOLDS = {} +for d = 15, 0, -1 do TELEPORT_OUT_HOLDS[#TELEPORT_OUT_HOLDS + 1] = d end +for i = 1, 6 do TELEPORT_OUT_HOLDS[#TELEPORT_OUT_HOLDS + 1] = i < 6 and 3 or 0 end +local TELEPORT_OUT_INPLACE = 16 +local TELEPORT_OUT_FRAMES = 135 +local TELEPORT_OUT_SFX = { + [4] = "Teleport_Exit2", [8] = "Teleport_Exit2", [12] = "Teleport_Exit2", + [TELEPORT_OUT_INPLACE] = "Teleport_Exit1", +} +-- counting 0 up to 8 -- engine/overworld/player_animations.asm:20 +local TELEPORT_IN_HOLDS = { 3, 3, 3, 3, 3, 0, 1, 2, 3, 4, 5, 6, 7, 0 } +local TELEPORT_IN_FRAMES = 43 +local SPIN_DOWN_STEPS = 6 +-- engine/overworld/player_animations.asm:41-45 +local HOLE_IN_HOLDS = { 3, 3, 3, 3, 3, 0 } +local HOLE_IN_FRAMES = 15 + -- healing machine ball screen positions (PokeCenterOAMData dbsprite -- rows are raw shadow-OAM bytes, so the hardware's -8/-16 OAM origin -- applies: screen = tile*8 + pixel offset - 8/16); [3] = OAM_XFLIP @@ -498,6 +517,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts) else self.player = Player.new(Game.data, x, y, facing) end + self.player.slopeMap = self:onSlopeMap(mapId) -- boot only: the original persists the surf state. wWalkBikeSurfState -- (ram/wram.asm) lives inside wMainDataStart..wMainDataEnd, which -- engine/menus/save.asm block-copies into sMainData on save and back out @@ -810,11 +830,7 @@ function OverworldState:sgbPalettes() return PaletteFX.wholeNamed(Game.data, mapName) end --- World-pass palette zones in world-canvas pixels: each visible map --- area keeps its own SGB palette (a deliberate step past the original, --- which recolored the whole screen per map -- see the survey zoom --- entry in docs/known-differences.md). Border fill inherits the --- current map's palette. +-- `ld de, BlkPacket_WholeScreen` (engine/gfx/palettes.asm:178) -- -- RED++ true overworld coloring does NOT go through this zone/shader -- system at all: TileRenderer bakes real per-tile GBC colors straight into @@ -829,6 +845,11 @@ end -- would re-run the DMG shade-remap over already-true-color pixels using -- an unrelated 4-color palette -- exactly the "colors are wrong" bug this -- fixes. +-- engine/gfx/palettes.asm:178 +function OverworldState.perMapWorldPalettes() + return (Zoom.offset or 0) < 0 +end + function OverworldState:sgbWorldZones() local PaletteFX = require("src.render.PaletteFX") if PaletteFX.usesGbcPack() and self.map.renderer and self.map.renderer.gbcAtlas then @@ -839,6 +860,7 @@ function OverworldState:sgbWorldZones() local vw, vh = Game.renderer:worldViewSize() local cam = self.camera local zones = { { colors = base, x = 0, y = 0, w = vw, h = vh } } + if not OverworldState.perMapWorldPalettes() then return zones end for _, nb in ipairs(self.neighbors) do local colors = PaletteFX.pal(Game.data, self:paletteNameFor(nb.map)) if colors then @@ -1153,6 +1175,7 @@ function OverworldState:update(dt) -- keep the player sprite in sync with the bike state (the drawer -- picks the red_bike sheet while riding) self.player.onBike = Game.save.onBike + self.player.slopeMap = self:onSlopeMap() -- the rendered neighbor set depends on the view size; zooming out (or -- resizing) past what setMap computed re-runs the walk in place if self.map and (self.neighborViewW or 0) > 0 then @@ -1295,7 +1318,8 @@ function OverworldState:update(dt) -- EnterMapAnim's .done tail re-enables the companion once the swoop or the -- spin-down has landed (player_animations.asm:40) if self.pikachuWarpHidden and not (self.flyAnim or self.flyArrive - or self.teleportOut or self.transitioning or self.player.spinning) then + or self.teleportOut or self.transitioning or self.player.spinning + or self.holeFall or self.holeArrive) then self:showPikachuAfterWarp() end @@ -1307,6 +1331,13 @@ function OverworldState:update(dt) -- player OUTSIDE the last Pokemon Center door (#196). player.spinFrames -- decrements in lockstep in Player:update, so the rising spin ends here too. if self.teleportOut then + -- engine/overworld/player_animations.asm:136 + local step = self.player.spinStep or 0 + if step ~= self.teleportOut.step then + self.teleportOut.step = step + local key = TELEPORT_OUT_SFX[step] + if key then require("src.core.Sound").play(Game.data, key) end + end self.teleportOut.frames = self.teleportOut.frames - 1 if self.teleportOut.frames <= 0 then local onDone = self.teleportOut.onDone @@ -1314,6 +1345,9 @@ function OverworldState:update(dt) self.player.spinning = false self.player.spinFrames = nil self.player.spinRise = nil + self.player.spinHolds = nil + self.player.spinStep = nil + self.player.spinRiseFrom = nil self.player.inputLocked = false -- keep the sprite hidden through the warp fade-out (#916): the spin is -- over but the arrival spin-drop is not armed until startWarpTo's @@ -1324,6 +1358,39 @@ function OverworldState:update(dt) end end + -- fades to white -- engine/overworld/player_animations.asm:204-228 + if self.holeFall then + self.holeFall.frames = self.holeFall.frames - 1 + if self.holeFall.frames <= 0 then + local f = self.holeFall + self.holeFall = nil + self.player.holeSink = nil + self.playerHidden = true + self.arriveWarp = "hole" + self:startWarpTo(f.map, f.x, f.y, f.facing) + return + end + end + + -- offscreen, then spins him down -- player_animations.asm:41-45 + if self.holeArrive then + self.holeArrive.frames = self.holeArrive.frames - 1 + if self.holeArrive.frames <= 0 then + self.holeArrive = nil + self.playerHidden = false + self.player.spinning = true + self.player.spinTimer = 0 + self.player.spinFrames = HOLE_IN_FRAMES + self.player.spinTotal = HOLE_IN_FRAMES + self.player.spinDrop = true + self.player.spinHolds = HOLE_IN_HOLDS + self.player.spinStep = 0 + self.player.spinHold = HOLE_IN_HOLDS[1] + self.player.spinDropSteps = SPIN_DOWN_STEPS + self.spinArrive = true + end + end + -- delayed one-shot SFX (the teleport-in spin's second note) if self.delaySfx then self.delaySfx.frames = self.delaySfx.frames - 1 @@ -1371,6 +1438,8 @@ function OverworldState:update(dt) or (self.hopLand or 0) > 0 or self.engaging or self.emote or self.teleportOut or self.flyAnim or self.flyArrive or self.spinArrive + or self.holeFall or self.holeArrive + or self.holeFall or self.holeArrive if not scripted and not self.transitioning then self:checkTrainerSight() -- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and @@ -1381,6 +1450,7 @@ function OverworldState:update(dt) or (self.hopLand or 0) > 0 or self.engaging or self.emote or self.teleportOut or self.flyAnim or self.flyArrive or self.spinArrive + or self.holeFall or self.holeArrive end -- a scriptMove's onDone can push a text box on the frame it retires, and -- DisplayTextID owns the loop from there (home/text_script.asm:3) @@ -1570,19 +1640,34 @@ function OverworldState:handleInput() -- promises ("Press the A or B Button to stay in place") and what the -- edge-only wasPressed("a") above can never deliver, since a press -- stalls the roll for one frame only (issue #255). - local fm = Game.data.field.forcedMovement local braking = input:isDown("a") or input:isDown("b") - if fm and Game.save.onBike and not braking and not self.player.moving then - for _, m in ipairs(fm.slopeMaps or {}) do - if m == self.map.id then - self.player.facing = "down" - self.player:tryMove("down", self.map, self.entities) - return - end + if Game.save.onBike and not braking and not self.player.moving + and self:onSlopeMap() then + -- does -- home/overworld.asm:1817 + self.player.facing = "down" + if self:checkEdgeExit("down") then return end + if self:checkLedgeHop("down") then return end + if self:checkBoulderPush("down") then return end + local result = self.player:tryMove("down", self.map, self.entities) + if result == "blocked" and self:canCollisionWarp() then + local w = Warp.onCollision(self.map, Game.data.field.warpCarpets, + self.player.cellX, self.player.cellY, "down") + if w then self:takeWarp(w.def) end end + return end end +-- JoypadOverworld both make -- home/overworld.asm:382 +function OverworldState:onSlopeMap(mapId) + local fm = Game.data.field.forcedMovement + mapId = mapId or (self.map and self.map.id) + for _, m in ipairs((fm and fm.slopeMaps) or {}) do + if m == mapId then return true end + end + return false +end + -- Strength boulders (engine/overworld/push_boulder.asm TryPushingBoulder): -- walking into one with STRENGTH in the party pushes it one cell, but -- only on the second consecutive push attempt (BIT_TRIED_PUSH_BOULDER); @@ -1892,7 +1977,7 @@ function OverworldState:crossConnection(dir, conn) -- fresh walk-cycle clock so the seam step always shows leg frames -- (mid-cycle stand phase would otherwise look like a slide) p.animClock = 0 - p.stepFramesCur = p:stepLength() + p.stepFramesCur = p:stepLength(dir) require("src.core.FixedStep"):discardCatchup() return true end @@ -2216,7 +2301,8 @@ function OverworldState:beginTeleportOut(onDone) -- StopMusic sits above both _LeaveMapAnim branches -- engine/overworld/player_animations.asm:123 require("src.core.Music").fadeOut(FLY_FADE_CONTROL) - require("src.core.Sound").play(Game.data, "Teleport_Exit1") + -- engine/overworld/player_animations.asm:136 + require("src.core.Sound").play(Game.data, "Teleport_Exit2") self.player.surfing = false self:syncSurfingPikachu() self:hidePikachuForWarp() @@ -2225,10 +2311,14 @@ function OverworldState:beginTeleportOut(onDone) -- spinRise lifts the sprite (Player:pose) while spinFrames counts down self.player.spinning = true self.player.spinTimer = 0 - self.player.spinFrames = 48 - self.player.spinTotal = 48 + self.player.spinFrames = TELEPORT_OUT_FRAMES + self.player.spinTotal = TELEPORT_OUT_FRAMES self.player.spinRise = true - self.teleportOut = { frames = 48, onDone = onDone } + self.player.spinHolds = TELEPORT_OUT_HOLDS + self.player.spinStep = 0 + self.player.spinHold = TELEPORT_OUT_HOLDS[1] + self.player.spinRiseFrom = TELEPORT_OUT_INPLACE - 1 + self.teleportOut = { frames = TELEPORT_OUT_FRAMES, onDone = onDone, step = 0 } end function OverworldState:npcAtCell(cx, cy) @@ -4972,16 +5062,22 @@ function OverworldState:takeWarp(warpDef) self:startWarpTo(destMap, x, y, facing) return elseif pad == "hole" then - -- falling through a hole: Faint_Fall plays while the player drops, - -- matching the boulder-hole Faint_Thud at line 3618 (#694) - require("src.core.Sound").play(Game.data, "Faint_Fall") - self:startWarpTo(destMap, x, y, facing) + self:fallThroughHole(destMap, x, y, facing) return end self.doorWarp = true -- door SFX + PlayerStepOutFromDoor walk-out self:startWarpTo(destMap, x, y, facing) end +-- (engine/overworld/player_animations.asm:204-228) +function OverworldState:fallThroughHole(mapId, x, y, facing) + self.player.inputLocked = true + self:hidePikachuForWarp() + self.player.holeSink = true + self.holeFall = { frames = 2, map = mapId, x = x, y = y, + facing = facing or self.player.facing } +end + -- Remember the outdoor side for LAST_MAP exits (pokered's wLastMap). function OverworldState:rememberOutdoor(id, x, y) self.lastOutdoor = { id = id, x = x, y = y } @@ -5056,20 +5152,35 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) -- PlayMapChangeSound (home/overworld.asm) plays before the tail-called -- GBFadeOutToBlack, so the SFX starts with the fade (#961) if doorWarp then - local dest = Game.data.maps[mapId] - local outdoor = dest and Map.isOutdoor(dest) - require("src.core.Sound").play(Game.data, - outdoor and "Go_Outside" or "Go_Inside") + -- the source map -- home/overworld.asm:689-703 + local rule = FieldDefaults.field(Game.data, "mapChangeSound") or {} + local exempt = false + -- pokeyellow home/overworld.asm:667-671 + if GameVersion.isYellow() then + for _, ts in ipairs(rule.exemptTilesets or {}) do + if ts == self.map.def.tileset then exempt = true end + end + end + local cue = "Go_Outside" + if not exempt + and self.map:tileAt(self.player.cellX * 2, self.player.cellY * 2) + == (rule.doorTile or 0x0B) then + cue = "Go_Inside" + end + require("src.core.Sound").play(Game.data, cue) end -- Fly/Teleport/Dig/Escape Rope: GBFadeOutToWhite / GBFadeInFromWhite -- (player_animations.asm). Door warps stay black with no fade-in (#1644). local Timing = require("src.core.Timing") local specialWarp = arriveWarp == "fly" or arriveWarp == "teleport" + or arriveWarp == "hole" local fadeOpts = specialWarp and { color = { 1, 1, 1 }, frames = Timing.FADE_OUT_TO_WHITE, framesIn = Timing.FADE_IN_FROM_WHITE, } or nil + -- home/overworld.asm:681 + if not specialWarp and self.dark then fadeOpts = { frames = 0 } end Game.stack:push(Transition.new(Game, function() self:setMap(mapId, x, y, facing or "down", opts) -- the departure-side hide from flyAnim/teleportOut ends here, on the new @@ -5100,18 +5211,28 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) self.flyArrive = { t = 0 } elseif arriveWarp == "teleport" then require("src.core.Sound").play(Game.data, "Teleport_Enter1") - -- ENTER_2 caps the spin-down a moment later - self.delaySfx = { frames = 40, key = "Teleport_Enter2" } + -- engine/overworld/player_animations.asm:83-85 + self.delaySfx = { frames = HOLE_IN_FRAMES, key = "Teleport_Enter2" } -- the sprite spins down into place (EnterMapAnim -- PlayerSpinWhileMovingDown), not just the SFX self.player.spinning = true self.player.spinTimer = 0 - self.player.spinFrames = 48 - self.player.spinTotal = 48 + self.player.spinFrames = TELEPORT_IN_FRAMES + self.player.spinTotal = TELEPORT_IN_FRAMES self.player.spinDrop = true + self.player.spinHolds = TELEPORT_IN_HOLDS + self.player.spinStep = 0 + self.player.spinHold = TELEPORT_IN_HOLDS[1] + self.player.spinDropSteps = SPIN_DOWN_STEPS -- engine/overworld/player_animations.asm:19 self.spinArrive = true self.player.inputLocked = true + elseif arriveWarp == "hole" then + -- engine/overworld/player_animations.asm:12 + require("src.core.Sound").play(Game.data, "Teleport_Enter1") + self.playerHidden = true + self.holeArrive = { frames = Timing.DUNGEON_WARP_ARRIVAL } + self.player.inputLocked = true end if doorWarp then -- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any @@ -5308,9 +5429,88 @@ end -- draw / save -- ------------------------------------------------------------------------- +-- home/fade.asm:58 +function OverworldState.warpFade() + local top = Game and Game.stack and Game.stack.top and Game.stack:top() + if top and top.bgp and top.shadeMapFor then return top end + return nil +end + +function OverworldState:bakedWorldColors() + return PaletteFX.usesGbcPack() and self.map and self.map.renderer + and self.map.renderer.gbcAtlas and true or false +end + +-- pokeyellow engine/gfx/palettes.asm:799 +-- pokeyellow home/fade.asm:75-77 +function OverworldState:warpFadeUniforms(byte) + local pack = PaletteFX.worldPack() + if not pack then return nil end + local bg, seen = {}, {} + local function addMap(map) + local tileset = map and map.def and map.def.tileset + if not tileset or seen[tileset] then return end + seen[tileset] = true + local groups = PaletteFX.worldGroupColors(Game.data, tileset, map.id, + self.player and self.player.cellY) + for _, colors in ipairs(groups or {}) do bg[#bg + 1] = colors end + end + addMap(self.map) + if not bg[1] then return nil end + for group = 0, 7 do + local colors = (pack.spritePalettes or {})[group] + if colors then bg[#bg + 1] = PaletteFX.darkObp(colors, group) end + end + for _, nb in ipairs(self.neighbors or {}) do addMap(nb.map) end + return GbcPalette.remapUniforms(bg, byte) +end + +-- home/fade.asm:58 +function OverworldState:drawWorldFaded() + local fade = OverworldState.warpFade() + if not (fade and self:bakedWorldColors()) then return false end + local byte = fade:bgp() + if not Transition.shadeMapFor(byte) then return false end + if Tilt.active() or Pipelines.worldPipeline() then return false end + if GbcPalette.mode ~= "gbc" or GbcPalette.customRamp then return false end + if not GbcPalette.remapShader() then return false end + local target = Game.renderer and Game.renderer.worldCanvas + if not target then return false end + local w, h = target:getWidth(), target:getHeight() + local scratch = self.fadeCanvas + if scratch and (scratch:getWidth() ~= w or scratch:getHeight() ~= h) then + if scratch.release then scratch:release() end + scratch, self.fadeCanvas = nil, nil + end + if not scratch then + local ok, made = pcall(love.graphics.newCanvas, w, h) + if not ok or not made then return false end + made:setFilter("nearest", "nearest") + scratch = made + self.fadeCanvas = scratch + end + local uniforms = self:warpFadeUniforms(byte) + if not uniforms then return false end + local G = love.graphics + local previous = G.getCanvas() + G.push() + G.origin() + G.setCanvas(scratch) + G.clear(1, 1, 1, 1) + self:drawWorld() + G.setCanvas(previous) + G.pop() + if not GbcPalette.useRemapUniforms(uniforms) then return false end + G.setColor(1, 1, 1, 1) + G.draw(scratch, 0, 0) + GbcPalette.clear() + fade.paletteStepped = true + return true +end + function OverworldState:draw() Game.renderer:beginWorldPass() - self:drawWorld() + if not self:drawWorldFaded() then self:drawWorld() end Game.renderer:endWorldPass() self:drawUI() end @@ -5434,8 +5634,17 @@ function OverworldState:drawWorld() local battleOverWorld = Game and Game.stack and Game.worldBgBattleInStack(Game.stack) -- home/fade.asm:66 - PaletteFX.setShadeMap((self.dark and not battleOverWorld) - and PaletteFX.DARK_BGP or self:poisonShadeMap()) + -- home/overworld.asm:684 + local fade = OverworldState.warpFade() + local fadeBgp = fade and fade:bgp() + local fadeMap = fadeBgp and Transition.shadeMapFor(fadeBgp) + if fadeBgp and not self:bakedWorldColors() then + PaletteFX.setShadeMap(fadeMap) + fade.paletteStepped = true + else + PaletteFX.setShadeMap((self.dark and not battleOverWorld) + and PaletteFX.DARK_BGP or self:poisonShadeMap()) + end -- advance the water/flower tile animation (runs under dialogs too). -- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate -- does not speed or slow the cycle (issue #4). diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index 03f0e472..b6070c7d 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -157,7 +157,9 @@ local function makeFollower(game, ow, x, y, facing) movement = "STAY", range = "NONE", x = x, y = y, }) npc.pikachuFollower = true - npc.passable = true -- never blocks a step (Collision.occupied) + npc.wanders = false -- scripted facing only, never the STAY idle roll (#2117) + -- pokeyellow engine/pikachu/pikachu_follow.asm:26 + npc.passable = true npc.facing = facing or "down" -- the idle animations below pose the walk cycle with no step under it, -- which NPC:walkPhase (moving-only) cannot express. An instance field @@ -681,10 +683,16 @@ end -- MapSpecificPikachuExpression + TalkToPikachu's selection order local function selectEmotion(game, ow, save) local mapId = ow.map.id - -- Fan Club / Pewter Center map beats (the Bill's-house event variant - -- is owned by that map's script) if mapId == "POKEMON_FAN_CLUB" then return 30 end if mapId == "PEWTER_POKECENTER" then return 26 end + -- BillsHouse_CheckPikachuEmotion -- scripts/BillsHouse_2.asm:88 + if mapId == "BILLS_HOUSE" then + if ow.pikachuBillsScene + and not save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR then + return 23 + end + return save.flags.EVENT_MET_BILL_2 and 31 or 32 + end local starter = PikachuFollower.starterInParty(save) if starter then if starter.status == "SLP" then return 11 end @@ -707,6 +715,8 @@ local function bubbleIndex(game, name) return nil end +local playEmotion + function PikachuFollower.talk(game, ow, npc, done) -- pikachu_follow.asm steps the follower on the player's own walk clock, -- so it is never mid-tile while the player stands and can always be @@ -733,6 +743,13 @@ function PikachuFollower.talk(game, ow, npc, done) if finish then finish() end end end + return playEmotion(game, ow, npc, emotion, { skippable = true, onDone = done }) +end + +-- engine/pikachu/pikachu_emotions.asm:14 +function playEmotion(game, ow, npc, emotion, opts) + opts = opts or {} + local done = opts.onDone local e = EMOTIONS[emotion] or EMOTIONS[1] if e.turnAway then npc.facing = ow.player.facing -- pikaemotion_9: back to the player @@ -766,8 +783,10 @@ function PikachuFollower.talk(game, ow, npc, done) local hold = anim.dur * PIKAPIC_TICK ow.emote = { npc = npc, frames = hold, bubble = bi or false, pikaPic = pic, - pikaSeq = anim.seq, pikaTotal = hold, skippable = true, onDone = done, + pikaSeq = anim.seq, pikaTotal = hold, skippable = opts.skippable, + onDone = done, } + return ow.emote end -- Where the framed pic sits this frame. The overlay a pikaframe run draws @@ -795,17 +814,26 @@ end -- the cell separator, then reacts when Bill reappears. Keep it at the -- machine until this map instance is discarded, just like the cartridge's -- disabled following state. -local function billsHouseEmotion(game, ow, npc, bubble) - local Sprites = require("src.pokemon.Sprites") +-- (engine/overworld/emotion_bubbles.asm:60) +-- engine/pikachu/pikachu_emotions.asm:14 +local function billsHouseEmotion(game, ow, npc, bubble, emotion, done, beforeAnim) + local function anim() + if beforeAnim then beforeAnim() end + playEmotion(game, ow, npc, emotion, { onDone = done }) + end + if not bubble then return anim() end ow.emote = { - npc = npc, frames = 50, bubble = bubbleIndex(game, bubble) or false, - pikaPic = Sprites.path(game.data, "PIKACHU", "front", - { kind = "overworld" }), + npc = npc, frames = 60, bubble = bubbleIndex(game, bubble) or false, + onDone = anim, } end +-- pokeyellow engine/pikachu/pikachu_movement.asm:208-229 +local PIKA_STEP_FRAMES = 16 + local function movePikachu(ow, npc, steps, onDone) npc.goalX, npc.goalY = nil, nil + npc.stepFrames = PIKA_STEP_FRAMES idleReset(npc) local function nextStep(i) local step = steps[i] @@ -836,7 +864,7 @@ function PikachuFollower.onFanClubEntered(game, ow) break end end - billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE") + billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE", 30) movePikachu(ow, npc, { { "up", 1 }, { "right", 3 }, { "up", 1 } }, function() npc.facing = "up" end) @@ -866,7 +894,11 @@ function PikachuFollower.onBillsHouseEnter(game, ow) if not npc then return end ow.pikachuBillsScene = true movePikachu(ow, npc, { { "right", 3 }, { "up", 1 } }, function() - billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE") + -- BillsHouse_CheckPikachuEmotion SCRIPT0 -- scripts/BillsHouse_2.asm:88 + billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE", 23, nil, function() + -- scripts/BillsHouse_2.asm:121, home/overworld.asm:1238-1240 + npc.passable = false + end) end) end @@ -928,9 +960,9 @@ function PikachuFollower.onBillEnteredMachine(game, ow) and { { "up", 1 }, { "left", 1 }, { "up", 2 }, { "right", 1 } } or { { "up", 3 } } movePikachu(ow, npc, steps, function() - -- PIKAMOVEMENT_LOOK_UP closes the detour table before the bubble + -- no EmotionBubble predef -- scripts/BillsHouse.asm:100 npc.facing = "up" - billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE") + billsHouseEmotion(game, ow, npc, nil, 32) end) end @@ -940,7 +972,8 @@ function PikachuFollower.onBillExitedMachine(game, ow) if not npc then return end idleReset(npc) npc.facing = "left" - billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE") + -- BillsHouse_CheckPikachuEmotion SCRIPT5 -- scripts/BillsHouse_2.asm:88 + billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE", 27) end -- OaksLabPikachuMovementScript (pokeyellow scripts/OaksLab_2.asm): the diff --git a/src/world/Player.lua b/src/world/Player.lua index 95c7bbd1..00b25efb 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -113,14 +113,20 @@ end -- the bicycle doubles walking speed (8 frames per step); movement.speed -- lets a mod multiply or replace that (running shoes, dash, etc.) -- DoBikeSpeedup is skipped mid-hop -- home/overworld.asm:283 -function Player:stepLength() +function Player:stepLength(dir) local Game = require("src.core.Game") local save = Game.save local onBike = (save and save.onBike and not self.ledgeHop) or false local frames = onBike and self.bikeStepFrames or self.stepFrames or STEP_FRAMES + -- held, so those three cost a walking step -- home/overworld.asm:377 + dir = dir or self.facing + local slope = onBike and self.slopeMap and dir ~= "down" or false + if slope then frames = self.stepFrames or STEP_FRAMES end if Runtime.wantsHook("movement.speed") then frames = Runtime.call("movement.speed", function(f) return f end, frames, { onBike = onBike, + slope = slope and true or false, + dir = dir, surfing = self.surfing and true or false, player = self, input = Game.input, @@ -165,7 +171,7 @@ function Player:tryMove(dir, map, entities) self.moving = true self.bumpFrames = nil -- a real step supersedes any in-place bonk self.progress = 0 - self.stepFramesCur = self:stepLength() + self.stepFramesCur = self:stepLength(dir) return "moved" end @@ -184,6 +190,14 @@ function Player:update() end if self.spinning then self.spinTimer = (self.spinTimer or 0) + 1 + -- engine/overworld/player_animations.asm:298 + if self.spinHolds then + self.spinHold = (self.spinHold or 0) - 1 + while self.spinHold <= 0 and (self.spinStep or 0) < #self.spinHolds - 1 do + self.spinStep = (self.spinStep or 0) + 1 + self.spinHold = self.spinHolds[self.spinStep + 1] + end + end end if self.spinFrames then self.spinFrames = self.spinFrames - 1 @@ -192,6 +206,11 @@ function Player:update() self.spinDrop = nil self.spinRise = nil -- teleport-out departure lift (#196) self.spinning = false + self.spinHolds = nil + self.spinStep = nil + self.spinHold = nil + self.spinRiseFrom = nil + self.spinDropSteps = nil end end -- wall-bonk walk-in-place (issue #230): while pushing into a wall the @@ -287,7 +306,19 @@ function Player:pose() -- fixed-rate animation clock so the bike's shorter steps don't double -- the leg cadence local flip = math.floor((self.animClock or 0) / 16) % 2 == 1 - if self.spinning then + if self.spinning and self.spinHolds then + -- engine/overworld/player_animations.asm:279 + local step = self.spinStep or 0 + facing = SPIN_ORDER[step % 4 + 1] + phase, flip = 0, false + -- engine/overworld/player_animations.asm:319 + if self.spinRiseFrom and step > self.spinRiseFrom then + py = py - (step - self.spinRiseFrom) * 16 + elseif self.spinDropSteps then + local left = self.spinDropSteps - step + if left > 0 then py = py - left * 16 end + end + elseif self.spinning then -- spinners.asm:1-11, home/overworld.asm:41-44, :268-272 facing = SPIN_ORDER[math.floor((self.spinTimer or 0) / 2) % 4 + 1] phase, flip = 0, false @@ -304,6 +335,8 @@ function Player:pose() py = py - math.floor((total - self.spinFrames) * 24 / total) end end + -- engine/overworld/player_animations.asm:204 + if self.holeSink then py = py + 8 end -- RodResponse (engine/items/item_effects.asm) zeroes wWalkBikeSurfState -- across FishingAnim, so casting from the water shows the on-foot sheet local sprite = (self.fishing and self.sprite) diff --git a/src/world/gen2/Player.lua b/src/world/gen2/Player.lua index e967b628..f0049a51 100644 --- a/src/world/gen2/Player.lua +++ b/src/world/gen2/Player.lua @@ -134,6 +134,11 @@ function Player:tryMove(dir, map, entities) return "moved" end +-- engine/overworld/player_movement.asm:807 +function Player:stopForEvent() + self.bumpFrames = nil +end + -- Cutscene step: ignores collision so Elm walk-up / after-pick paths play. function Player:scriptFace(dir) if dir then self.facing = dir end @@ -175,9 +180,11 @@ end local SPIN_FACINGS = { "down", "right", "up", "left" } local SPIN_START = { down = 0, right = 1, up = 2, left = 3 } -function Player:scriptSpin(frames) +-- map_objects.asm:1481-1493, map_object_action.asm:133 +function Player:scriptSpin(frames, flicker) if not frames or frames <= 0 then return end self.spinFrames = frames + self.spinFlicker = flicker or nil self.spinTimer = (SPIN_START[self.facing] or 0) * 4 end @@ -216,7 +223,10 @@ function Player:update() if self.spinFrames then self.spinTimer = (self.spinTimer or 0) + 1 self.spinFrames = self.spinFrames - 1 - if self.spinFrames <= 0 then self.spinFrames = nil end + if self.spinFrames <= 0 then + self.spinFrames = nil + self.spinFlicker = nil + end end if not self.moving then -- map_objects.asm:1517-1525 @@ -305,6 +315,11 @@ function Player:draw(ox, oy, scale, oamRow) local flip = self:drawFlip() -- OBJECT_ACTION_SPIN (map_object_action.asm:96-152), for step_dig. if self.spinFrames then + -- map_objects.asm:1481-1493 + if self.spinFlicker and self.spinFrames % 2 == 1 then + G.pop() + return + end facing = SPIN_FACINGS[math.floor(self.spinTimer / 4) % 4 + 1] phase = 0 end diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 55f1210c..d1e4e35e 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -88,6 +88,8 @@ local SFX = { FLASH = 169, ENTER_DOOR = 31, WARP_TO = 19, + WARP_FROM = 20, + KINESIS = 47, EXIT_BUILDING = 35, JUMP_OVER_LEDGE = 0x16, BUMP = 0x24, @@ -159,8 +161,16 @@ local BATTLETYPE = { local COLL = { DOOR = 0x71, WARP_PANEL = 0x7c, + -- CheckPitTile (home/map_objects.asm:162) + -- engine/overworld/events.asm:349-353 + PIT = 0x60, + PIT_68 = 0x68, } +local function isPitCollision(coll) + return coll == COLL.PIT or coll == COLL.PIT_68 +end + local SPRITE = { VARS = 0xf0, DAY_CARE_MON_1 = 0xe0, @@ -190,6 +200,8 @@ local MAPSETUP = { local MAPSETUP_FADE_OUT = { [MAPSETUP.DOOR] = true, [MAPSETUP.FALL] = true, [MAPSETUP.TELEPORT] = true, } +-- data/maps/setup_scripts.asm:27-32 +local MAPSETUP_WARP_WINDOW = { [MAPSETUP.TELEPORT] = true } local MAPSETUP_FADE_IN = { [MAPSETUP.DOOR] = true, [MAPSETUP.FALL] = true, [MAPSETUP.TELEPORT] = true, [MAPSETUP.WARP] = true, [MAPSETUP.BADWARP] = true, [MAPSETUP.TRAIN] = true, @@ -233,8 +245,10 @@ local MAPSETUP_ROAM_JUMP = { [MAPSETUP.TELEPORT] = true } -- World:draw already holds for the fade specials. local FADE_STEPS = 4 local FADE_STEP_FRAMES = 2 --- data/maps/setup_scripts.asm:102-139 -local MAP_LOAD_WHITE_FRAMES = 4 +-- data/maps/setup_scripts.asm:102-125, home/init.asm:149-153 +local MAP_LOAD_WHITE_FRAMES = 11 +-- data/maps/setup_scripts.asm:32-55 +local WARP_LOAD_WHITE_FRAMES = 13 -- home/map.asm:1927-1940 over home/tilemap.asm:12-25 local MENU_EXIT_RELOAD_FRAMES = 9 @@ -1513,6 +1527,8 @@ function World:busy() -- FlyFromAnim and FlyToAnim are blocking `callasm`s inside .FlyScript -- (engine/events/overworld.asm:599, :605). or self.flyAnim ~= nil + -- engine/overworld/events.asm:1011 + or self.skyfall ~= nil end -- CheckMenuOW (engine/overworld/events.asm:802) is the tail of OWPlayerInput, @@ -2299,6 +2315,40 @@ function World:updateShake() shake.phase = (shake.left % 2 == 0) and shake.amplitude or -shake.amplitude end +-- :1008-1021. StepFunction_Skyfall (engine/overworld/map_objects.asm:1368) +local SKYFALL_BEAT_FRAMES = 16 +local PITFALL_EARTHQUAKE = 0x10 + +function World:startSkyfall() + self:playSfxNamed("Sfx_Kinesis", SFX.KINESIS) + self.skyfall = { phase = "hidden", timer = SKYFALL_BEAT_FRAMES, height = 0 } + self.playerMasked = true +end + +function World:updateSkyfall() + local st = self.skyfall + if not st then return end + if st.phase == "hidden" then + st.timer = st.timer - 1 + if st.timer <= 0 then + st.phase = "fall" + st.timer = SKYFALL_BEAT_FRAMES + self.playerMasked = nil + end + return + end + st.height = st.height + 1 + if self.player then + self.player.spriteYOffset = Movement.teleportYOffset(st.height) + end + st.timer = st.timer - 1 + if st.timer > 0 then return end + if self.player then self.player.spriteYOffset = 0 end + self.skyfall = nil + self:playSfxNamed("Sfx_Strength", SFX.STRENGTH) + self:earthquake(PITFALL_EARTHQUAKE) +end + -- engine/events/poisonstep_pals.asm:9 function World:poisonBGFlash() self.poisonFlash = 4 @@ -3501,10 +3551,14 @@ function World:screenFade(kind) -- kind: "outWhite" | "outBlack" | "inWhite" | "inBlack" if kind == "inWhite" or kind == "inBlack" then self.fade, self.fadeLevel = nil, nil + self.fadeHold, self.fadeWhiten = nil, nil return end self.fade = (kind == "outWhite") and "white" or "black" self.fadeLevel = 1 + self.fadeHold = nil + -- engine/tilesets/timeofday_pals.asm:122-128 + self.fadeWhiten = (kind == "outWhite") or nil end -- RunMapSetupScript (engine/overworld/map_setup.asm): every map entry runs one @@ -3540,12 +3594,19 @@ function World:runMapSetup(method, load, fly) -- only the way back in is a fade. local ok = wrapped() self.fade, self.fadeLevel = "white", 1 + self.fadeWhiten = nil + self.fadeHold = WARP_LOAD_WHITE_FRAMES self.mapSetup = { phase = "in", step = FADE_STEPS, - wait = MAP_LOAD_WHITE_FRAMES + FADE_STEP_FRAMES } + wait = WARP_LOAD_WHITE_FRAMES + FADE_STEP_FRAMES } return ok end + -- engine/tilesets/timeofday_pals.asm:122-128 + self.fadeWhiten = true + self.fadeHold = nil self.mapSetup = { phase = "out", step = 0, wait = FADE_STEP_FRAMES, load = wrapped, + white = MAPSETUP_WARP_WINDOW[method] and WARP_LOAD_WHITE_FRAMES + or MAP_LOAD_WHITE_FRAMES, } return true end @@ -3553,6 +3614,8 @@ end -- ramp with no load between the halves (home/map.asm:2281-2292). function World:exitMenusFade(whiteFrames) self.fade, self.fadeLevel = "white", 1 + self.fadeWhiten = nil + self.fadeHold = whiteFrames or MENU_EXIT_WHITE_FRAMES self.mapSetup = { phase = "in", step = FADE_STEPS, wait = whiteFrames or MENU_EXIT_WHITE_FRAMES, @@ -3579,6 +3642,7 @@ World.FADE_RAMP = { World.FADE_STEPS = FADE_STEPS World.FADE_STEP_FRAMES = FADE_STEP_FRAMES World.MAP_LOAD_WHITE_FRAMES = MAP_LOAD_WHITE_FRAMES +World.WARP_LOAD_WHITE_FRAMES = WARP_LOAD_WHITE_FRAMES function World.fadeRampRow(fade, level) local ramp = fade and World.FADE_RAMP[fade] @@ -3597,8 +3661,26 @@ end function World:battleReturnFade() if self.mapSetup then return end self.fade, self.fadeLevel = "white", 1 + self.fadeWhiten = nil + self.fadeHold = WARP_LOAD_WHITE_FRAMES self.mapSetup = { phase = "in", step = FADE_STEPS, - wait = MAP_LOAD_WHITE_FRAMES + FADE_STEP_FRAMES } + wait = WARP_LOAD_WHITE_FRAMES + FADE_STEP_FRAMES } +end + +-- engine/tilesets/timeofday_pals.asm:160-187 +function World:fadeBgSet() + local def = self.map and self.map.def + local bg = def and Palettes.bgSet(self.palettes, def, self.daytime) + if not (bg and self.fadeWhiten) then return bg end + local out = {} + for i, colors in ipairs(bg) do + if i >= 2 and i <= 7 and bg[1] and bg[1][1] then + out[i] = { bg[1][1], colors[2], colors[3], colors[4] } + else + out[i] = colors + end + end + return out end -- --------------------------------------------------------------------------- @@ -3667,27 +3749,36 @@ end function World:updateMapSetup() local ms = self.mapSetup + if self.fadeHold then + self.fadeHold = self.fadeHold - 1 + if self.fadeHold <= 0 then self.fadeHold = nil end + end ms.wait = ms.wait - 1 if ms.wait > 0 then return end ms.wait = FADE_STEP_FRAMES if ms.phase == "out" then ms.step = ms.step + 1 - self.fade, self.fadeLevel = "white", ms.step / FADE_STEPS - if ms.step >= FADE_STEPS then - -- setMap clears self.fade (a map load repaints everything), so the sheet - -- has to be re-armed at full strength on the far side for the fade in to - -- take back down. - ms.load() - ms.phase = "in" - self.fade, self.fadeLevel = "white", 1 - -- engine/tilesets/timeofday_pals.asm:277-299 - ms.wait = FADE_STEP_FRAMES + MAP_LOAD_WHITE_FRAMES + FADE_STEP_FRAMES + -- engine/tilesets/timeofday_pals.asm:122-128 + if ms.step <= FADE_STEPS then + self.fade, self.fadeLevel = "white", ms.step / FADE_STEPS + return end + ms.load() + ms.phase = "in" + ms.step = FADE_STEPS + self.fade, self.fadeLevel = "white", 1 + self.fadeWhiten = nil + local white = ms.white or MAP_LOAD_WHITE_FRAMES + -- home/lcd.asm:35-72 + self.fadeHold = white + -- engine/tilesets/timeofday_pals.asm:277-299 + ms.wait = white + FADE_STEP_FRAMES return end ms.step = ms.step - 1 if ms.step <= 0 then self.fade, self.fadeLevel = nil, nil + self.fadeHold, self.fadeWhiten = nil, nil self.mapSetup = nil -- `callasm FlyToAnim` is the command straight after `newloadmap -- MAPSETUP_TELEPORT` (engine/events/overworld.asm:604-605). @@ -3696,6 +3787,10 @@ function World:updateMapSetup() local function respawn() self.flyHidden = nil end if not self:startFlyAnim("to", ms.flyIn, respawn) then respawn() end end + -- engine/events/overworld.asm:869-870 + if ms.digIn then self:digReturn() end + -- engine/overworld/events.asm:1008-1021 + if ms.fallIn then self:startSkyfall() end return end self.fadeLevel = ms.step / FADE_STEPS @@ -4062,6 +4157,14 @@ function World:updateMovement() st.sleep = duration return end + -- engine/overworld/movement.asm:142-160, map_objects.asm:1481-1493 + if b == Movement.RETURN_DIG then + local duration = st.bytes[st.i] or 0 + st.i = st.i + 1 + if ent.scriptSpin then ent:scriptSpin(duration, true) end + st.sleep = duration + return + end -- engine/overworld/movement.asm:163 if b == 0x57 then local duration = st.bytes[st.i] or 0 @@ -4135,6 +4238,13 @@ function World:updateMovement() -- (engine/overworld/map_objects.asm:284-294), so the object steps -- without turning. ContinueReadingMovement again, so no frame here. ent.fixedFacing = act.fixed + elseif act.kind == "visible" then + -- macros/scripts/movement.asm:99-106 + if st.objectId == 0 then + self.playerHidden = (not act.on) or nil + else + ent.hiddenByMovement = (not act.on) or nil + end elseif act.kind == "treeshake" then -- Movement_tree_shake: 24 frames of STEP_TYPE_SLEEP with OBJECT_ACTION -- set to OBJECT_ACTION_WEIRD_TREE (engine/overworld/movement.asm:334). @@ -4764,20 +4874,32 @@ function World:escapeRopeTarget() return backup.map, destWarp end --- The shared tail of .UsedEscapeRopeScript / .UsedDigScript: SFX.WARP_TO, --- `loadvar VAR.MOVEMENT, PLAYER_NORMAL`, then `newloadmap MAPSETUP.DOOR` with --- the triple already in wNextWarp -- EnterMapWarp and GetWarpDestCoords land --- the player on the destination warp's own tile. The dig-spin sprite work is --- not ported, the same standing decision World:flyTo records for the two fly --- animations. +-- .UsedDigScript -- engine/events/overworld.asm:851-872 +-- engine/overworld/events.asm:1034 function World:runEscapeWarp(destMapId, destWarp) self:playSfxNamed("Sfx_WarpTo", SFX.WARP_TO) - self:applyPlayerState(FieldMoves.PLAYER_NORMAL) - return self:runMapSetup(MAPSETUP.DOOR, function() - local ok = self:setMap(destMapId, destWarp.x, destWarp.y, "down") - if ok then self:spawnFacing() end + local function load() + self:applyPlayerState(FieldMoves.PLAYER_NORMAL) + local ok = self:runMapSetup(MAPSETUP.DOOR, function() + local loaded = self:setMap(destMapId, destWarp.x, destWarp.y, "down") + if loaded then self:spawnFacing() end + return loaded + end) + if self.mapSetup then + self.mapSetup.digIn = true + else + self:digReturn() + end return ok - end) + end + self:beginMovement(0, Movement.digOutBytes(), load) + return true +end + +-- The half after `newloadmap`: engine/events/overworld.asm:869-870 +function World:digReturn() + self:playSfxNamed("Sfx_WarpFrom", SFX.WARP_FROM) + self:beginMovement(0, Movement.digReturnBytes()) end -- EscapeRopeEffect (engine/items/item_effects.asm): EscapeRopeFunction, and @@ -6073,7 +6195,7 @@ end -- which is exactly what World:warpToSpawn resolves (blackoutmod override -- first, then the SPAWN_* table). PLAYER_NORMAL first, so a teleport off a -- bike arrives on foot the way `loadvar VAR.MOVEMENT, PLAYER_NORMAL` leaves --- it. The teleport spin, like the dig spin, is sprite work and not ported. +-- engine/events/overworld.asm:940-955 function World:runTeleport(result) self:setNickname(result.mon) self:showText(Strings(result.text), function() @@ -6808,8 +6930,13 @@ function World:startBattle(opts, onDone) self.wildCooldown = 5 self.battleActive = nil game.stack:pop() + -- engine/overworld/scripting.asm:1178-1184, engine/events/whiteout.asm:1-21 + local whiteout = outcome == "lose" + and opts.battleType ~= BATTLETYPE.CANLOSE -- engine/overworld/events.asm:1158-1162, data/maps/setup_scripts.asm:124 - if not self:scriptRunning() then self:battleReturnFade() end + if not whiteout and not self:scriptRunning() then + self:battleReturnFade() + end -- wBattleResult (constants/battle_constants.asm): WIN 0, LOSE 1, DRAW 2. -- The port never forfeits or draws a battle, so "lose" is the only -- other outcome startBattle's onDone hands back; VAR.BATTLERESULT @@ -6871,7 +6998,7 @@ function World:startBattle(opts, onDone) -- .FinishRival is what heals the party, not a whiteout. Warping here -- moved the loser to the spawn point and then ran that walk-off over -- whatever stood there. - if outcome == "lose" and opts.battleType ~= BATTLETYPE.CANLOSE then + if whiteout then self:healParty() if not BugContest.isActive(game.save) then CallAsm.run(self, "HalveMoney") @@ -6884,7 +7011,11 @@ function World:startBattle(opts, onDone) Runtime.emit("world.blacked_out", { save = game.save, healTarget = self:healPoint() }) end - self:warpToSpawn() + -- engine/events/whiteout.asm:19-20 + self:runMapSetup(MAPSETUP.WARP, function() + self:warpToSpawn() + return true + end) end -- RestartMapMusic: the map theme comes back with the overworld, over -- whatever the battle left playing (the victory jingle loops until @@ -8382,6 +8513,9 @@ function World:interactBody() if self:busy() or not self.player or not self.vm then return false end local p = self.player if p.moving then return false end + -- engine/overworld/events.asm:505-510 + if p.stopForEvent then p:stopForEvent() end + self.turningDirection = nil local d = Map.DELTA[p.facing] local fx, fy = p.cellX + d[1], p.cellY + d[2] local npc = self:npcAt(self:facingObjectCell()) @@ -9736,7 +9870,10 @@ function World:setMap(mapId, cx, cy, facing, opts) -- over from the script that warped cannot survive it -- LoadMapPalettes and -- DeleteMapObject are what end both on the cart. self.fade = nil + self.fadeHold, self.fadeWhiten = nil, nil self.shake = nil + self.skyfall = nil + if self.player then self.player.spriteYOffset = nil end self.map = Map.new(def, tileset) self:rebuildAttrGrid() -- A follow pairing points at two live objects, and a map load rebuilds them @@ -9996,7 +10133,11 @@ function World:takeWarp(warpDef) return false end end - self:warpSound() + -- loads under MAPSETUP_FALL -- engine/overworld/events.asm:349-353 + local p = self.player + local falling = p and self.map + and isPitCollision(self.map:cellCollision(p.cellX, p.cellY)) or false + if not falling then self:warpSound() end -- wBackupMapGroup / wBackupMapNumber: the map being LEFT. The elevator's -- .FindCurrentFloor is the only thing that reads it, and it is what makes -- "Now on:" say the floor you got in from. @@ -10012,15 +10153,20 @@ function World:takeWarp(warpDef) Runtime.emit("player.warped", { fromMap = prevMapId, toMap = destMapId, x = destX, y = destY, warp = warpDef, toWarp = destWarpNumber }) - return self:runMapSetup(MAPSETUP.DOOR, function() - local ok = self:setMap(destMapId, destX, destY, - (self.player and self.player.facing) or "down") - if ok then - self:spawnFacing() - self:recordWarpBackup(prevMapId, prevWarpIndex, destWarp, destMapId) - end - return ok - end) + local taken = self:runMapSetup(falling and MAPSETUP.FALL or MAPSETUP.DOOR, + function() + local ok = self:setMap(destMapId, destX, destY, + (self.player and self.player.facing) or "down") + if ok then + self:spawnFacing() + self:recordWarpBackup(prevMapId, prevWarpIndex, destWarp, destMapId) + end + return ok + end) + if falling then + if self.mapSetup then self.mapSetup.fallIn = true else self:startSkyfall() end + end + return taken end -- RefreshPlayerSprite (engine/overworld/map_objects.asm) is the whole rule for @@ -10582,7 +10728,11 @@ function World:whiteOut() Runtime.emit("world.blacked_out", { save = self.game and self.game.save, healTarget = self:healPoint() }) end - self:warpToSpawn() + -- engine/events/whiteout.asm:19-20 + self:runMapSetup(MAPSETUP.WARP, function() + self:warpToSpawn() + return true + end) end) end @@ -10799,6 +10949,7 @@ function World:stepBody() -- parked on the earthquake's own waitFrames while the screen is still -- rattling), so both tick above the busy() gate rather than below it. if self.shake then self:updateShake() end + if self.skyfall then self:updateSkyfall() end -- The map setup chain ticks above the busy() gate for the same reason: it IS -- what closes that gate, so nothing below can be allowed to advance it. if self.mapSetup then @@ -10905,6 +11056,9 @@ function World:stepBody() self.player.inGrass = self:grassAt(self.player.cellX, self.player.cellY) end + elseif self.player and self.player.stopForEvent then + -- engine/overworld/map_objects.asm:1851-1860 + self.player:stopForEvent() end self:updatePeople() return @@ -11124,11 +11278,11 @@ function World:drawPeople(s, billboard) local filter = self.spriteFilter local drawList = {} if not hideAll then - if not hidePlayer and not self.playerMasked then + if not hidePlayer and not self.playerMasked and not self.playerHidden then drawList[1] = { kind = "player", py = p.py, ox = 0, oy = 0 } end for _, npc in ipairs(self.npcs) do - if not filter or filter(npc) then + if (not filter or filter(npc)) and not npc.hiddenByMovement then drawList[#drawList + 1] = { kind = "npc", npc = npc, ox = 0, oy = 0, py = npc.py, } @@ -11390,11 +11544,12 @@ function World:drawFadeRemap(s, w, h) local def = self.map and self.map.def if not (def and self.palettes) then return false end local cache = self.fadeRemapCache + local whiten = self.fadeWhiten or false if not (cache and cache.byte == byte and cache.def == def and cache.daytime == self.daytime and cache.palettes == self.palettes - and cache.mode == GbcPalette.mode + and cache.mode == GbcPalette.mode and cache.whiten == whiten and cache.custom == GbcPalette.customRamp) then - local bg = Palettes.bgSet(self.palettes, def, self.daytime) + local bg = self:fadeBgSet() if not bg then return false end -- home/fade.asm:32-38 local pals = {} @@ -11406,7 +11561,7 @@ function World:drawFadeRemap(s, w, h) local uniforms = GbcPalette.remapUniforms(pals, byte) if not uniforms then return false end cache = { byte = byte, def = def, daytime = self.daytime, - palettes = self.palettes, mode = GbcPalette.mode, + palettes = self.palettes, mode = GbcPalette.mode, whiten = whiten, custom = GbcPalette.customRamp, uniforms = uniforms } self.fadeRemapCache = cache end @@ -11512,7 +11667,8 @@ function World:draw() elseif tilt then self:drawTilted(w, h, s, gw, gh) else - if self.fade and (self.fadeLevel or 1) < 1 then + -- engine/tilesets/timeofday_pals.asm:65-91, home/lcd.asm:35-72 + if self.fade and not self.fadeHold then fadeStepped = self:drawFadeRemap(s, w, h) end if not fadeStepped then self:drawWorldBody(s) end diff --git a/tests/drivers/battle_hud_after_cry_bug2118_test.lua b/tests/drivers/battle_hud_after_cry_bug2118_test.lua new file mode 100644 index 00000000..ee239780 --- /dev/null +++ b/tests/drivers/battle_hud_after_cry_bug2118_test.lua @@ -0,0 +1,99 @@ +-- (EnemySendOutFirstMon, engine/battle/core.asm:1432-1435) +-- WaitForSoundToFinish, home/pokemon.asm:145-149 +-- POKEPORT_DRIVER=tests/drivers/battle_hud_after_cry_bug2118_test.lua POKEPORT_IDENTITY=bug2118 SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local Font = require("src.render.Font") + + local LOG = DIR .. "/bug2118.log" + os.execute('mkdir -p "' .. DIR .. '" 2>/dev/null') + local function note(...) + U.log(...) + local f = io.open(LOG, "a") + if not f then return end + f:write(table.concat({ ... }, " "), "\n") + f:close() + end + + local function check(label, ok) + note(ok and "PASS" or "FAIL", label) + return ok + end + + local function hudStrings(battle) + local strings = {} + local realFont, realDraw = Font.draw, love.graphics.draw + Font.draw = function(text) strings[#strings + 1] = tostring(text) end + love.graphics.draw = function() end + pcall(battle.drawHUDs, battle, 0) + Font.draw, love.graphics.draw = realFont, realDraw + return strings + end + + local function drew(strings, want) + for _, s in ipairs(strings) do + if s:find(want, 1, true) then return true end + end + return false + end + + game.save.party = { + Pokemon.new(game.data, "BULBASAUR", 12), + Pokemon.new(game.data, "PIDGEY", 9), + } + U.teleport(game, "ROUTE_3", 13, 6, "left") + U.wait(10) + local ow = game.overworld + check("the overworld is up", ow ~= nil) + + local battle = BattleState.newTrainer(game, "OPP_BUG_CATCHER", 4) + battle.onFinish = function() end + if ow then ow:pushBattle(battle) end + for _ = 1, 600 do + if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end + U.wait(1) + end + check("the trainer battle reached the screen", game.stack:top() == battle) + + local grew = false + for _ = 1, 900 do + if battle.growIn then grew = true break end + U.tap(game, "a") + U.wait(4) + end + check("AnimateSendingOutMon started", grew) + + for _ = 1, 240 do + if not battle.growIn then break end + U.wait(1) + end + check("the grow-in finished", battle.growIn == nil) + check("the cry is still outstanding (enemyHudPending)", + battle.enemyHudPending == true) + check("the enemy name is NOT on screen yet", + not drew(hudStrings(battle), battle.enemy.name)) + U.shot(game, DIR .. "/bug2118_1_sprite_no_hud.png") + + for _ = 1, 900 do + if not battle.enemyHudPending then break end + U.wait(1) + end + check("the cry finished and the HUD was released", + battle.enemyHudPending == nil) + U.wait(2) + check("the enemy name and HP bar are on screen now", + drew(hudStrings(battle), battle.enemy.name)) + U.shot(game, DIR .. "/bug2118_2_hud_after_cry.png") + + U.log("Correct: the foe's pic grows out of its ball with the top-left of the") + U.log("screen still blank, the cry plays in full, and only then do the name,") + U.log(" and HP bar appear together (#2118).") + U.log("Screenshots: " .. DIR .. "/bug2118_*.png") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/bill_face_rotation_bug2117_test.lua b/tests/drivers/bill_face_rotation_bug2117_test.lua new file mode 100644 index 00000000..ea168559 --- /dev/null +++ b/tests/drivers/bill_face_rotation_bug2117_test.lua @@ -0,0 +1,71 @@ +-- (engine/overworld/movement.asm:151) +-- SHOT_DIR=/tmp/shots POKEPORT_VERSION=red \ +-- POKEPORT_DRIVER=tests/drivers/bill_face_rotation_bug2117_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function idle() + while true do coroutine.yield() end + end + + local MAP, STAND = "BILLS_HOUSE", { x = 1, y = 5, facing = "up" } + + game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) } + game.save.player.name = "bryan" + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil + game.save.flags.EVENT_MET_BILL = nil + game.save.flags.EVENT_MET_BILL_2 = nil + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(20) + + local ow = game.overworld + if not check("Bill's House loaded", ow ~= nil and ow.map + and ow.map.id == MAP) then + idle() + end + + local monster + for _, npc in ipairs(ow.npcs or {}) do + if npc.def and npc.def.sprite == "SPRITE_MONSTER" then monster = npc end + end + if not check("the monster form is on the map", monster ~= nil) then idle() end + check("it is a STAY, NONE object", + monster.def.movement == "STAY" and monster.def.range == "NONE") + check("it rolls a facing but never steps", + monster.wanders == true and monster.steps == false) + + local seen, order = {}, {} + local startX, startY = monster.cellX, monster.cellY + local moved = false + for frame = 0, 600 do + if not seen[monster.facing] then + seen[monster.facing] = true + order[#order + 1] = monster.facing + end + if monster.cellX ~= startX or monster.cellY ~= startY then moved = true end + if frame % 60 == 0 then + U.shot(game, SHOT_DIR .. "/bill_face_" .. frame .. ".png") + end + U.wait(1) + end + + check("Bill takes more than one facing over 600 frames: " + .. table.concat(order, ","), #order >= 2) + check("and never leaves his cell", not moved) + U.log("shots in", SHOT_DIR) + + idle() +end diff --git a/tests/drivers/bills_pc_changebox_bug2124.lua b/tests/drivers/bills_pc_changebox_bug2124.lua new file mode 100644 index 00000000..435a663a --- /dev/null +++ b/tests/drivers/bills_pc_changebox_bug2124.lua @@ -0,0 +1,131 @@ +-- (bills_pc.asm:176) +-- " BOX." (data/text/text_3.asm:30) +-- with balls.2bpp tile 0 (bills_pc.asm:118) +-- SHOT_DIR=/tmp/bug2124 POKEPORT_DRIVER=tests/drivers/bills_pc_changebox_bug2124.lua \ +-- POKEPORT_IDENTITY= POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Boxes = require("src.pokemon.Boxes") + local BoxMenu = require("src.ui.BoxMenu") + local Menu = require("src.ui.Menu") + local TextBox = require("src.render.TextBox") + local Theme = require("src.ui.Theme") + local Font = require("src.render.Font") + local Pokemon = require("src.pokemon.Pokemon") + + local pass = true + local function check(msg, cond) + if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end + end + + local function capture() + local drawn = {} + local realCode, realDraw = Font.drawCode, Font.draw + local realImage = love.graphics.draw + Font.drawCode = function(code, x, y) + drawn[#drawn + 1] = { kind = "code", code = code, x = x, y = y } + return realCode(code, x, y) + end + Font.draw = function(s, x, y, ...) + drawn[#drawn + 1] = { kind = "text", text = s, x = x, y = y } + return realDraw(s, x, y, ...) + end + love.graphics.draw = function(img, ...) + drawn[#drawn + 1] = { kind = "image", + path = type(img) == "userdata" and "?" or (type(img) == "table" and img.path) } + return realImage(img, ...) + end + game.stack:draw() + Font.drawCode, Font.draw, love.graphics.draw = realCode, realDraw, realImage + return drawn + end + local function sawCode(drawn, code) + for _, d in ipairs(drawn) do + if d.kind == "code" and d.code == code then return d end + end + end + local function sawText(drawn, want) + for _, d in ipairs(drawn) do + if d.kind == "text" and d.text == want then return d end + end + end + + U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up") + U.wait(6) + + local boxes = Boxes.ensure(game.save) + boxes[1] = { Pokemon.new(game.data, "PIDGEY", 12) } + boxes[3] = { Pokemon.new(game.data, "MAGIKARP", 5) } + game.save.currentBox = 1 + game.save.party = { Pokemon.new(game.data, "CHARMANDER", 14) } + game.stack:push(BoxMenu.new(game)) + U.wait(4) + local bills = game.stack:top() + check("Bill's PC menu is up", getmetatable(bills) == Menu) + + local changeIdx + for i, item in ipairs(bills.items) do + if tostring(item.label):find("CHANGE BOX", 1, true) then changeIdx = i end + end + check("CHANGE BOX is on the menu", changeIdx ~= nil) + for _ = 2, changeIdx or 1 do U.tap(game, "down"); U.wait(2) end + U.tap(game, "a") + U.wait(20) + + check("CHANGE BOX asks first", getmetatable(game.stack:top()) == TextBox) + check("the parent cursor is marked hollow under the ask", + bills.hollowIndex == bills.index) + do + local drawn = capture() + check("and the unfilled arrow is what gets drawn", + sawCode(drawn, Theme.cursorHollow) ~= nil + and sawCode(drawn, Theme.cursor) == nil) + end + U.shot(game, DIR .. "/bug2124_1_hollow_cursor.png") + + local function isPicker(s) + return getmetatable(s) == Menu and s.kind == "pc_box_change" + end + for _ = 1, 60 do + if isPicker(game.stack:top()) then break end + U.tap(game, "a") + U.wait(20) + end + check("YES opens the box picker", isPicker(game.stack:top())) + U.wait(4) + do + local drawn = capture() + check("the prompt is the two-tile , not the word POKeMON", + sawText(drawn, " BOX.") ~= nil + and sawText(drawn, "POKéMON BOX.") == nil) + check("no {DONE} marker leaks onto the screen", + sawText(drawn, " BOX.{DONE}") == nil) + local balls = 0 + for _, d in ipairs(drawn) do + if d.kind == "image" then balls = balls + 1 end + end + check("the box markers are drawn tiles, not vectors", balls >= 2) + end + U.shot(game, DIR .. "/bug2124_2_change_box_list.png") + + U.tap(game, "b") + U.wait(12) + check("B returns to Bill's PC menu", game.stack:top() == bills) + check("and the cursor is filled again", bills.hollowIndex == nil) + do + local drawn = capture() + check("the filled arrow is back", sawCode(drawn, Theme.cursor) ~= nil) + end + U.shot(game, DIR .. "/bug2124_3_back_on_pc_menu.png") + + U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE") + U.log("Look at bug2124_1_hollow_cursor.png: the arrow beside CHANGE BOX is") + U.log("the unfilled outline, not the solid triangle. In") + U.log("bug2124_2_change_box_list.png the second prompt line reads with the") + U.log("two-tile PK MN ligature and the markers beside BOX 1 and BOX 3 are") + U.log("the cart's flat pokeball ring -- no white belt, no centre pip -- and") + U.log("pick up colour in ADVANCED mode.") + U.log("Shots are in " .. DIR) +end diff --git a/tests/drivers/bills_pikachu_bug2165_test.lua b/tests/drivers/bills_pikachu_bug2165_test.lua new file mode 100644 index 00000000..11051376 --- /dev/null +++ b/tests/drivers/bills_pikachu_bug2165_test.lua @@ -0,0 +1,101 @@ +-- engine/pikachu/pikachu_movement.asm:88 +-- EmotionBubble (engine/overworld/emotion_bubbles.asm:60) +-- (engine/pikachu/pikachu_emotions.asm:14) +-- BillsHouseScript0 actually runs. No POKEPORT_SPEED: it scales the logic +-- POKEPORT_DRIVER=tests/drivers/bills_pikachu_bug2165_test.lua POKEPORT_IDENTITY=bug2165 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local GameVersion = require("src.core.GameVersion") + local Pokemon = require("src.pokemon.Pokemon") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function idle() + while true do coroutine.yield() end + end + + if not check("running the Yellow cache (POKEPORT_VERSION=yellow)", + GameVersion.isYellow()) then + U.log("Re-run with POKEPORT_VERSION=yellow.") + idle() + end + + local MAP, STAND = "BILLS_HOUSE", { x = 3, y = 6, facing = "up" } + + game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) } + game.save.player.name = "bryan" + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true + game.save.flags.EVENT_MET_BILL = nil + game.save.flags.EVENT_MET_BILL_2 = nil + game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil + game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = nil + game.save.pikachuMapScriptActive = nil + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(20) + + local ow = game.overworld + if not check("Bill's House loaded", ow ~= nil and ow.map + and ow.map.id == MAP) then + idle() + end + + local function follower() + for _, n in ipairs(ow.npcs or {}) do + if n.pikachuFollower then return n end + end + return nil + end + + local npc = follower() + if not check("the follower spawned", npc ~= nil) then idle() end + check("the confused beat started", ow.pikachuBillsScene == true) + check("the follower never rolls its own facing (#2117 pin)", + npc.wanders == false) + check("the scripted walk uses the 16-frame Pikachu step", + npc.stepFrames == 16) + + for frame = 0, 64, 16 do + U.shot(game, SHOT_DIR .. "/bug2165_walk_" .. frame .. ".png") + U.wait(15) + end + + local sawBubble = false + for _ = 1, 240 do + if ow.emote and ow.emote.bubble and not ow.emote.pikaPic then + sawBubble = true + break + end + U.wait(1) + end + check("the silent 60-frame question bubble comes up first", sawBubble) + if sawBubble then + U.shot(game, SHOT_DIR .. "/bug2165_bubble.png") + end + + local sawPic = false + for _ = 1, 240 do + if ow.emote and ow.emote.pikaPic then sawPic = true break end + U.wait(1) + end + check("the emotion animation follows it", sawPic) + if sawPic then + check("and it is the emotion's own pikapic, not the battle front pic", + ow.emote.pikaPic:find("pikachu/pikapic_", 1, true) ~= nil) + check("a scripted emotion cannot be mashed away", + ow.emote.skippable ~= true) + U.shot(game, SHOT_DIR .. "/bug2165_pikapic.png") + end + U.log("listen for PikachuCry19 under the box; shots in", SHOT_DIR) + + idle() +end diff --git a/tests/drivers/bills_pikachu_into_bill.lua b/tests/drivers/bills_pikachu_into_bill.lua new file mode 100644 index 00000000..ee48df6e --- /dev/null +++ b/tests/drivers/bills_pikachu_into_bill.lua @@ -0,0 +1,148 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local GameVersion = require("src.core.GameVersion") + local Pokemon = require("src.pokemon.Pokemon") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local PHASE = os.getenv("PHASE") or "all" + local TICKS = tonumber(os.getenv("TICKS") or "") or 22 + + local function idle() + while true do coroutine.yield() end + end + + if not GameVersion.isYellow() then + U.log("FAIL not the yellow cache") + idle() + end + + local function stage() + game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) } + game.save.player.name = "bryan" + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true + game.save.flags.EVENT_MET_BILL = true + game.save.flags.EVENT_MET_BILL_2 = nil + game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil + game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = nil + game.save.pikachuMapScriptActive = nil + end + + local function findBill(ow) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == "BILLSHOUSE_BILL_POKEMON" then return n end + end + return nil + end + + local function findFollower(ow) + for _, n in ipairs(ow.npcs or {}) do + if n.pikachuFollower then return n end + end + return nil + end + + local function inList(list, e) + for _, v in ipairs(list or {}) do if v == e then return true end end + return false + end + + local overlap = 0 + + local function dump(tag, ow) + local pk = findFollower(ow) + local bill = findBill(ow) + local p = ow.player + if pk and bill and pk.cellX == bill.cellX and pk.cellY == bill.cellY then + overlap = overlap + 1 + end + U.log(string.format( + "%s f=%d P=(%d,%d)%s pika=%s bill=%s emote=%s", + tag, U.frame(), p.cellX, p.cellY, p.facing, + pk and string.format("(%d,%d)px%d,%d mv=%s pass=%s npcs=%s ents=%s", + pk.cellX, pk.cellY, pk.px, pk.py, tostring(pk.moving), + tostring(pk.passable), tostring(inList(ow.npcs, pk)), + tostring(inList(ow.entities, pk))) or "GONE", + bill and string.format("(%d,%d)px%d,%d", bill.cellX, bill.cellY, + bill.px, bill.py) or "HIDDEN", + ow.emote and "yes" or "no")) + end + + -- a scripted-walk run from an arbitrary start cell + local function run(label, sx, sy, ticks) + stage() + U.teleport(game, "BILLS_HOUSE", sx, sy, "up") + U.wait(4) + local ow = game.overworld + overlap = 0 + U.log("=== " .. label .. " start=(" .. sx .. "," .. sy .. ") scene=" + .. tostring(ow.pikachuBillsScene) .. " ===") + local shot = false + for _ = 1, ticks do + dump(label, ow) + if overlap > 0 and not shot then + shot = true + U.log("!!! " .. label .. " OVERLAP pikachu on Bill's cell") + U.shot(game, SHOT_DIR .. "/" .. label .. "_overlap.png") + end + U.wait(4) + end + U.shot(game, SHOT_DIR .. "/" .. label .. "_end.png") + U.log("=== " .. label .. " overlapTicks=" .. overlap .. " ===") + return ow + end + + if PHASE == "all" or PHASE == "A" then + run("A_door37", 3, 7, TICKS) + run("A_door27", 2, 7, TICKS) + run("B_mid36", 3, 6, TICKS) + end + + if PHASE == "all" or PHASE == "D" then + -- the real user path: walk in the door, let the confused beat settle, + -- then walk up column 6 onto the parked follower at (6,6) -- the only + -- southern approach to Bill at (6,5). + stage() + U.teleport(game, "BILLS_HOUSE", 3, 7, "up") + local ow = game.overworld + for _ = 1, 600 do + if ow.emote == nil and #(ow.scriptMoves or {}) == 0 then break end + U.wait(1) + end + U.wait(20) + overlap = 0 + dump("D_settled", ow) + U.shot(game, SHOT_DIR .. "/D_settled.png") + local function step(dir) + local sx, sy = ow.player.cellX, ow.player.cellY + for _ = 1, 90 do + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + coroutine.yield() + if (ow.player.cellX ~= sx or ow.player.cellY ~= sy) + and not ow.player.moving then break end + end + game.input.state[dir] = false + U.wait(4) + dump("D_step_" .. dir, ow) + end + step("right"); step("right"); step("right") + step("up") + U.shot(game, SHOT_DIR .. "/D_on_follower.png") + local pk = findFollower(ow) + U.log("D player=(" .. ow.player.cellX .. "," .. ow.player.cellY + .. ") pika=" .. (pk and ("(" .. pk.cellX .. "," .. pk.cellY .. ")") + or "GONE")) + if pk and pk.cellX == ow.player.cellX and pk.cellY == ow.player.cellY then + U.log("!!! D PLAYER STANDS ON THE FOLLOWER (passable, same cell)") + end + end + + U.log("DONE") + U.wait(2) + love.event.quit() + idle() +end diff --git a/tests/drivers/crystal_battle_exit_fade_2141.lua b/tests/drivers/crystal_battle_exit_fade_2141.lua index e07592c1..6daaa301 100644 --- a/tests/drivers/crystal_battle_exit_fade_2141.lua +++ b/tests/drivers/crystal_battle_exit_fade_2141.lua @@ -1,8 +1,11 @@ -- ../pokecrystal/data/maps/setup_scripts.asm:127-140 -- ../pokecrystal/engine/overworld/map_setup.asm:181-189 -- ../pokecrystal/home/fade.asm:35-62 +-- ../pokecrystal/engine/tilesets/timeofday_pals.asm:160-187 +-- ../pokecrystal/engine/events/whiteout.asm:1-21 local U = require("tests.drivers.util") local Mon = require("src.battle.gen2.Mon") +local World = require("src.world.gen2.World") return function(game) local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-exit-fade-2141" @@ -24,6 +27,12 @@ return function(game) assert(world and world.map, "the crystal world did not boot") local save, data = game.save, game.data save.party = { Mon.new(data, "CYNDAQUIL", 12) } + -- POKEPORT_FADE_NIGHT=1 pins the clock so the colour-0 plane that brackets + local night = os.getenv("POKEPORT_FADE_NIGHT") == "1" + if night then + world.daytime = "NITE" + world:applyPalettes() + end world.mapScenes = world.mapScenes or {} world.mapScenes.NEW_BARK_TOWN = 1 @@ -37,13 +46,16 @@ return function(game) U.hold(game, "up", 24) local whiteTicks, doorTicks, entered = 0, 0, false + local doorSheet, doorWhiten = 0, 0 local lastDoorLevel - for _ = 1, 120 do + for _ = 1, 200 do U.wait(1) if world.mapSetup then doorTicks = doorTicks + 1 if world.fadeLevel == 1 then whiteTicks = whiteTicks + 1 end - if world.fadeLevel ~= lastDoorLevel then + if world.fadeHold then doorSheet = doorSheet + 1 end + if world.fadeWhiten then doorWhiten = doorWhiten + 1 end + if world.fadeLevel ~= lastDoorLevel or world.fadeHold then lastDoorLevel = world.fadeLevel shot("door_in") end @@ -53,10 +65,19 @@ return function(game) break end end + local doorHold = World.MAP_LOAD_WHITE_FRAMES ok(entered, "the door warp finished") - ok(whiteTicks == 2 + 4 + 2, - ("door: white held %d ticks (want 8: last out row, load, first in row)"):format(whiteTicks)) - ok(doorTicks >= 20, ("door: the chain ran %d ticks"):format(doorTicks)) + ok(whiteTicks == 2 + doorHold + 2, + ("door: level 1 held %d ticks (want %d: last out row, load, first in row)") + :format(whiteTicks, 4 + doorHold)) + ok(doorSheet == doorHold, + ("door: pure white held %d ticks (want %d, the LCD-off window alone)") + :format(doorSheet, doorHold)) + ok(doorWhiten == 10, + ("door: FillWhiteBGColor held %d ticks over the way out (want 10)") + :format(doorWhiten)) + ok(doorTicks == 10 + doorHold + 8, + ("door: the chain ran %d ticks (want %d)"):format(doorTicks, 18 + doorHold)) local wild = Mon.new(data, "SENTRET", 3) ok(world:startBattle({ wild = wild }), "a wild battle starts") @@ -81,7 +102,7 @@ return function(game) U.tap(game, "a") local fadeTicks, rows, popped = 0, {}, false - local returnWhite, returnTicks = 0, 0 + local returnWhite, returnTicks, returnSheet = 0, 0, 0 local lastReturnLevel for _ = 1, 900 do if game.stack:top() == screen then @@ -100,7 +121,8 @@ return function(game) if world.mapSetup then returnTicks = returnTicks + 1 if world.fadeLevel == 1 then returnWhite = returnWhite + 1 end - if world.fadeLevel ~= lastReturnLevel then + if world.fadeHold then returnSheet = returnSheet + 1 end + if world.fadeLevel ~= lastReturnLevel or world.fadeHold then lastReturnLevel = world.fadeLevel shot("battle_return") end @@ -116,13 +138,66 @@ return function(game) for _, row in ipairs(rows) do rowText[#rowText + 1] = ("%02x"):format(row) end ok(table.concat(rowText, ",") == "90,40,00", "rows 2100 / 1000 / 0000 in order (" .. table.concat(rowText, ",") .. ")") - ok(returnWhite == 4 + 2, - ("return: white held %d ticks (want 6: load, first in row)"):format(returnWhite)) - ok(returnTicks == 12, ("return: the fade in ran %d ticks (want 12)"):format(returnTicks)) + local warpHold = World.WARP_LOAD_WHITE_FRAMES + ok(returnWhite == warpHold + 2, + ("return: level 1 held %d ticks (want %d: load, first in row)") + :format(returnWhite, warpHold + 2)) + ok(returnSheet == warpHold, + ("return: pure white held %d ticks (want %d)"):format(returnSheet, warpHold)) + ok(returnTicks == warpHold + 2 + 6, + ("return: the fade in ran %d ticks (want %d)") + :format(returnTicks, warpHold + 8)) ok(world.mapSetup == nil and world.fade == nil, "the return fade cleared") U.wait(5) shot("done") + -- (engine/overworld/scripting.asm:1174-1184, engine/events/whiteout.asm:19) + local lossMap = world.map.id + save.party = { Mon.new(data, "CYNDAQUIL", 2) } + save.party[1].hp = 1 + local loser = Mon.new(data, "SENTRET", 20) + ok(world:startBattle({ wild = loser }), "a losing battle starts") + local lossScreen + for _ = 1, 600 do + U.wait(1) + local top = game.stack:top() + if top and top.battle then lossScreen = top break end + end + ok(lossScreen ~= nil, "the losing battle screen is up") + local lossWhite, lossSheet, lossPopped = 0, 0, false + local lossPhase, lossFade, lossHold + for _ = 1, 1800 do + if game.stack:top() == lossScreen then + U.tap(game, "a") + else + if not lossPopped then + lossPopped = true + lossPhase = world.mapSetup and world.mapSetup.phase + lossFade, lossHold = world.fade, world.fadeHold + end + if world.mapSetup then + if world.fadeLevel == 1 then lossWhite = lossWhite + 1 end + if world.fadeHold then lossSheet = lossSheet + 1 end + shot("whiteout_in") + elseif lossWhite > 0 then + break + end + end + U.wait(1) + end + ok(lossPopped, "the lost battle popped") + ok(lossFade == "white" and lossPhase == "in", + ("whiteout: the spawn map fades in (fade=%s phase=%s)") + :format(tostring(lossFade), tostring(lossPhase))) + ok(lossHold == warpHold, + ("whiteout: pure white armed for %s ticks (want %d)") + :format(tostring(lossHold), warpHold)) + ok(lossSheet == warpHold and lossWhite == warpHold + 2, + ("whiteout: held %d pure / %d level-1 ticks"):format(lossSheet, lossWhite)) + ok(world.map.id ~= lossMap, "and the player is at the spawn point") + U.wait(5) + shot("whiteout_done") + print("[exit-fade-2141] " .. (fails == 0 and "PASS all claims" or (fails .. " claims failed")) .. " -- " .. shotN .. " shots in " .. out) love.event.quit(fails == 0 and 0 or 1) diff --git a/tests/drivers/crystal_battle_ui_2173_2174_2175.lua b/tests/drivers/crystal_battle_ui_2173_2174_2175.lua new file mode 100644 index 00000000..bad67f01 --- /dev/null +++ b/tests/drivers/crystal_battle_ui_2173_2174_2175.lua @@ -0,0 +1,136 @@ +-- ../pokecrystal/engine/battle/core.asm:3208 +-- ../pokecrystal/engine/battle/core.asm:5156 +-- ../pokecrystal/home/text.asm:630 +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-battle-ui-2173" + os.execute("mkdir -p '" .. out .. "'") + local shotN = 0 + local function shot(tag) + shotN = shotN + 1 + game.capturePath = string.format("%s/%03d_%s.png", out, shotN, tag) + U.wait(2) + end + local fails = 0 + local function ok(cond, msg) + if not cond then fails = fails + 1 end + print("[battle-ui-2173/2174/2175] " .. (cond and "PASS " or "FAIL ") .. msg) + return cond + end + + U.wait(60) + local world, data, save = game.world, game.data, game.save + assert(world and world.map, "the crystal world did not boot") + save.party = { + Mon.new(data, "CYNDAQUIL", 20), + Mon.new(data, "TOTODILE", 20), + } + save.inventory = { POKE_BALL = 5 } + world:warpToMapId("NEW_BARK_TOWN", 13, 7, "down") + for _ = 1, 120 do + if not world:busy() then break end + U.wait(1) + end + + local function screenUp() + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then return top end + U.wait(1) + end + end + + -- ShowSetEnemyMonAndSendOutAnimation runs (core.asm:3221) + local trainer = { + class = 1, classId = "YOUNGSTER", memberId = "JOEY", + name = "YOUNGSTER JOEY", trainerName = "JOEY", className = "YOUNGSTER", + party = { Mon.new(data, "RATTATA", 4) }, + baseMoney = 12, + } + assert(world:startBattle({ trainer = trainer }), "trainer battle failed") + local screen = screenUp() + ok(screen ~= nil, "the trainer battle screen is up") + + local sawTrainer, sawEmpty, popped = false, false, false + local shotEmpty = false + for _ = 1, 2400 do + if screen.showEnemyTrainer then sawTrainer = true end + if sawTrainer and not screen.showEnemyTrainer and not screen.afterSendOut + and not screen.anim and not screen.showEnemyHud then + if screen:picBoxCleared("enemy") then + sawEmpty = true + if not shotEmpty then shotEmpty = true shot("2173_box_empty") end + else + popped = true + end + end + if screen.showEnemyHud then break end + if (screen.messageTimer or 0) > 0 then U.tap(game, "a") end + U.wait(1) + end + ok(sawTrainer, "the trainer frontpic stood in the enemy box") + ok(sawEmpty, "the box was empty across SlideBattlePicOut -> the send-out") + ok(not popped, "the mon never popped in before ANIM_SEND_OUT_MON") + + local held = false + for _ = 1, 1800 do + if (screen.messageTimer or 0) > 0 and screen.typer and screen.typer:done() + then held = true break end + U.wait(1) + end + ok(held, "a battle line is holding for PromptButton") + if held then + screen.arrowBlink = 0 + ok(screen:messageArrowVisible(), "the down arrow is on at blink phase 0") + shot("2174_arrow_on") + screen.arrowBlink = 16 + ok(not screen:messageArrowVisible(), + "UnloadBlinkingCursor blanks it at phase 16") + shot("2174_arrow_off") + screen.arrowBlink = 0 + end + + for _ = 1, 2400 do + if screen.phase == "menu" then break end + if (screen.messageTimer or 0) > 0 or screen.phase == "stats-box" then + U.tap(game, "a") + end + U.wait(1) + end + ok(screen.phase == "menu", "reached the battle menu") + + -- accepted switch calls CloseWindow (core.asm:5162) + U.tap(game, "right") + U.wait(4) + U.tap(game, "a") + local list + for _ = 1, 300 do + local top = game.stack:top() + if top and top ~= screen then list = top break end + U.wait(1) + end + ok(list ~= nil, "the party list opened") + U.wait(6) + U.tap(game, "a") + U.wait(6) + U.tap(game, "a") + U.wait(8) + local result = list and list.itemResult + ok(game.stack:top() == list, "the party list is still on screen") + ok(list and list.submenu == nil, "the SWITCH/STATS/CANCEL box is gone") + ok(result and (result.text or ""):find("already out"), + "BattleText_MonIsAlreadyOut printed over the list") + ok(screen.phase == "submenu", "the battle screen never took the box back") + shot("2175_already_out") + U.tap(game, "a") + U.wait(8) + ok(list and list.itemResult == nil and game.stack:top() == list, + "A dismisses the line back to a live list") + shot("2175_list_back") + + print("[battle-ui-2173/2174/2175] " .. (fails == 0 and "PASS all claims" or + (fails .. " claims failed")) .. " -- " .. shotN .. " shots in " .. out) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_dig_anim_bug2178.lua b/tests/drivers/crystal_dig_anim_bug2178.lua new file mode 100644 index 00000000..25b91024 --- /dev/null +++ b/tests/drivers/crystal_dig_anim_bug2178.lua @@ -0,0 +1,74 @@ +local U = require("tests.drivers.util") + +-- ../pokecrystal/engine/events/overworld.asm:755-846 +local MAP = "DARK_CAVE_VIOLET_ENTRANCE" +local BACK = "ROUTE_31" + +return function(game) + local fails = 0 + local function say(line) print("[dig2178] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + if not (world and world.map) then + say("FAIL the crystal world did not boot") + love.event.quit(1) + return + end + + local mouth = world.maps and world.maps[MAP] and world.maps[MAP].warps + and world.maps[MAP].warps[1] + ok(mouth ~= nil, MAP .. " carries its Route 31 mouth") + if not mouth then love.event.quit(1) return end + + world:warpToMapId(MAP, mouth.x, mouth.y + 1, "down") + U.wait(60) + ok(world.map and world.map.id == MAP, "arrived in the cave") + world.noWildEncounters = true + world.backupWarp = { map = BACK, warp = 3 } + + local destMapId, destWarp = world:escapeRopeTarget() + ok(destMapId == BACK, "the rope resolves back to " .. BACK) + if not destWarp then love.event.quit(1) return end + + U.shot(game, "/tmp/pokeport-shots/dig2178_before.png") + ok(world:runEscapeWarp(destMapId, destWarp), "the dig warp runs") + ok(world.moveState ~= nil, ".DigOut armed a movement stream") + for i = 1, 4 do + U.wait(8) + U.shot(game, ("/tmp/pokeport-shots/dig2178_spin%d.png"):format(i)) + end + U.wait(8) + ok(world.playerHidden == true, "hide_object hid the player for the fade") + + for _ = 1, 300 do + if world.map and world.map.id == BACK then break end + U.wait(1) + end + ok(world.map and world.map.id == BACK, "landed on " .. BACK) + + for _ = 1, 240 do + if not world.playerHidden then break end + U.wait(1) + end + ok(world.moveState ~= nil, ".DigReturn armed the return stream") + ok(not world.playerHidden, "show_object brought the player back") + for i = 1, 6 do + U.shot(game, ("/tmp/pokeport-shots/dig2178_return%d.png"):format(i)) + U.wait(1) + end + local walkable = false + for _ = 1, 120 do + if not world:busy() then walkable = true break end + U.wait(1) + end + ok(walkable, "and the world is walkable again") + U.shot(game, "/tmp/pokeport-shots/dig2178_after.png") + + say(fails == 0 and "ALL PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_gender_shots.lua b/tests/drivers/crystal_gender_shots.lua index 41db7f19..9ed4f291 100644 --- a/tests/drivers/crystal_gender_shots.lua +++ b/tests/drivers/crystal_gender_shots.lua @@ -132,7 +132,11 @@ return function(game) "wPlayerGender after the answer") eq(game.save.player.name, want == "girl" and "KRIS" or "CHRIS", "NamePlayer's InitName default") + -- ../pokecrystal/engine/rtc/timeset.asm:22-32 + U.wait(20) + U.shot(game, out .. "/02b-initclock.png") + local shotDemo = false for _ = 1, 200 do if isA(OakSpeech) then break end tap("a", 2) @@ -144,6 +148,12 @@ return function(game) local shotPic = false for _ = 1, 500 do if isA(NamePick) then break end + -- ../pokecrystal/engine/menus/intro_menu.asm:875 + if not shotDemo and isA(OakSpeech) and top().pic == top().marillPic then + U.wait(6) + U.shot(game, out .. "/02c-demowipe.png") + shotDemo = true + end if not shotPic and isA(OakSpeech) then local at = top().steps and top().steps[top().step] if at and at.id == "ask_player_name" then diff --git a/tests/drivers/crystal_hole_fall_bug2176.lua b/tests/drivers/crystal_hole_fall_bug2176.lua new file mode 100644 index 00000000..b6619ea9 --- /dev/null +++ b/tests/drivers/crystal_hole_fall_bug2176.lua @@ -0,0 +1,77 @@ +local U = require("tests.drivers.util") +local Permissions = require("src.world.gen2.Permissions") + +-- ../pokecrystal/maps/IcePathB1F.asm:82-85 +local MAP = "ICE_PATH_B1F" +local BELOW = "ICE_PATH_B2F_MAHOGANY_SIDE" +local HOLES = { { 11, 2 }, { 4, 7 }, { 5, 12 }, { 12, 13 } } +local STEP = { + down = { 0, -1, "down" }, up = { 0, 1, "up" }, + left = { 1, 0, "left" }, right = { -1, 0, "right" }, +} + +return function(game) + local fails = 0 + local function say(line) print("[hole2176] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + if not (world and world.map) then + say("FAIL the crystal world did not boot") + love.event.quit(1) + return + end + + world:warpToMapId(MAP, HOLES[1][1], HOLES[1][2], "down") + U.wait(60) + ok(world.map and world.map.id == MAP, "arrived at " .. MAP) + world.noWildEncounters = true + + local hx, hy, dir + for _, hole in ipairs(HOLES) do + for name, d in pairs(STEP) do + local nx, ny = hole[1] + d[1], hole[2] + d[2] + if Permissions.isWalkable(world.map:cellCollision(nx, ny)) then + hx, hy, dir = nx, ny, name + break + end + end + if dir then break end + end + ok(dir ~= nil, "found a walkable cell beside a COLL_PIT") + if not dir then love.event.quit(1) return end + + world:warpToMapId(MAP, hx, hy, dir) + U.wait(60) + U.shot(game, "/tmp/pokeport-shots/hole2176_before.png") + + U.tap(game, dir) + U.wait(30) + for _ = 1, 240 do + if world.skyfall then break end + U.wait(1) + end + ok(world.skyfall ~= nil, "the fall animation armed after the load") + ok(world.map and world.map.id == BELOW, "landed on " .. BELOW) + ok(world:busy(), "and the applymovement holds the overworld") + U.shot(game, "/tmp/pokeport-shots/hole2176_hidden.png") + U.wait(20) + U.shot(game, "/tmp/pokeport-shots/hole2176_falling.png") + + for _ = 1, 120 do + if not world.skyfall then break end + U.wait(1) + end + ok(world.shake ~= nil, "earthquake 16 fires on the landing") + ok(world.shake and world.shake.amplitude == 1, "one pixel of it") + U.shot(game, "/tmp/pokeport-shots/hole2176_landed.png") + U.wait(30) + ok((world.player.spriteYOffset or 0) == 0, "the sprite is back on its tile") + + say(fails == 0 and "ALL PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/cycling_road_end_bug2115_test.lua b/tests/drivers/cycling_road_end_bug2115_test.lua new file mode 100644 index 00000000..0e085fff --- /dev/null +++ b/tests/drivers/cycling_road_end_bug2115_test.lua @@ -0,0 +1,42 @@ +-- home/overworld.asm:1817 +-- POKEPORT_DRIVER=tests/drivers/cycling_road_end_bug2115_test.lua \ +-- POKEPORT_IDENTITY=bug2115 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \ +-- SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + game.save.onBike = true + U.teleport(game, "ROUTE_17", 7, 135, "down") + U.wait(15) + local ow = game.overworld + check("standing near the bottom of Cycling Road on the bike", + ow.map.id == "ROUTE_17" and game.save.onBike == true) + check("the last row of ROUTE_17 is the impassable ledge tile", + not ow.map:isWalkableCell(7, 143)) + U.shot(game, SHOT_DIR .. "/bug2115_before.png") + + for _ = 1, 900 do + if game.overworld.map.id ~= "ROUTE_17" then break end + coroutine.yield() + end + ow = game.overworld + check(("the roll hopped the ledge and landed on %s"):format(tostring(ow.map.id)), + ow.map.id == "ROUTE_18") + U.wait(30) + U.shot(game, SHOT_DIR .. "/bug2115_route18.png") + + U.log("Hands off the pad on Cycling Road: the roll should carry you all the") + U.log("way down and hop the ledge onto Route 18 with no input at all.") + U.log("#2115 was the roll pedalling in place on the last row.") + U.log("Shots in " .. SHOT_DIR .. "/bug2115_*.png") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/cycling_road_speed_bug2114_test.lua b/tests/drivers/cycling_road_speed_bug2114_test.lua new file mode 100644 index 00000000..8ab22435 --- /dev/null +++ b/tests/drivers/cycling_road_speed_bug2114_test.lua @@ -0,0 +1,66 @@ +-- (#2114). DoBikeSpeedup (pokered home/overworld.asm:377) +-- POKEPORT_DRIVER=tests/drivers/cycling_road_speed_bug2114_test.lua \ +-- POKEPORT_IDENTITY=bug2114 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \ +-- SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local MAP, START_X, START_Y = "ROUTE_17", 2, 20 + + game.save.onBike = true + U.teleport(game, MAP, START_X, START_Y, "down") + U.wait(15) + local ow = game.overworld + check("standing on Cycling Road on the bike", + ow.map.id == MAP and game.save.onBike == true) + check("the player is flagged as on a slope map", ow.player.slopeMap == true) + + local y0 = ow.player.cellY + U.wait(120) + local down = ow.player.cellY - y0 + check(("DOWN travelled %d cells in 120 frames"):format(down), down > 0) + U.shot(game, SHOT_DIR .. "/bug2114_down.png") + + table.insert(game.input.pressQueue, "up") + game.input.state.up = true + U.wait(12) + local y1 = ow.player.cellY + U.wait(120) + local up = y1 - ow.player.cellY + game.input.state.up = false + check(("UP travelled %d cells in 120 frames"):format(up), up > 0) + check(("UP is about half of DOWN (%d vs %d)"):format(up, down), up * 2 <= down + 2) + U.shot(game, SHOT_DIR .. "/bug2114_up.png") + + U.teleport(game, "ROUTE_16", 11, 4, "up") + U.wait(15) + ow = game.overworld + game.save.onBike = true + check("Route 16 is not a slope map", ow.player.slopeMap ~= true) + table.insert(game.input.pressQueue, "up") + game.input.state.up = true + U.wait(20) + check(("UP off the slope is a bike step (%s frames)") + :format(tostring(ow.player.stepFramesCur)), + ow.player.stepFramesCur == ow.player.bikeStepFrames) + game.input.state.up = false + + U.teleport(game, MAP, START_X, START_Y, "down") + U.wait(10) + game.save.onBike = true + + U.log("On the bike on Cycling Road, rolling south.") + U.log("Hold UP, LEFT or RIGHT: those three should feel like WALKING pace.") + U.log("Let go and roll south: twice as fast. Shots in " + .. SHOT_DIR .. "/bug2114_*.png") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/dig_bug196_test.lua b/tests/drivers/dig_bug196_test.lua index 5bad0d6e..8e2bfb7f 100644 --- a/tests/drivers/dig_bug196_test.lua +++ b/tests/drivers/dig_bug196_test.lua @@ -83,8 +83,24 @@ return function(game) local sawDepartureSpin = false local spinShotTaken = false local leftCave = false - for _ = 1, 240 do + local facingRuns, lastFacing, lastTick, maxTick = {}, nil, -1, 0 + local function sampleSpin() + if not ow.player.spinning then return end + local tick = ow.player.spinTimer or 0 + if tick == lastTick then return end + lastTick = tick + if tick > maxTick then maxTick = tick end + local _, _, _, facing = ow.player:pose() + if facing ~= lastFacing then + lastFacing = facing + facingRuns[#facingRuns + 1] = 1 + else + facingRuns[#facingRuns] = facingRuns[#facingRuns] + 1 + end + end + for _ = 1, 400 do local stillCave = ow.map and ow.map.id == "MT_MOON_1F" + if stillCave then sampleSpin() end -- departure-only markers: ow.teleportOut is the new pre-warp spin state, -- and player.spinRise is the rising spin (the arrival uses spinDrop), so -- neither can be confused with the interior arrival spin-down @@ -123,6 +139,30 @@ return function(game) check(sawDepartureSpin, "DIG: departure spin appears in the cave before the fade (FIX A)") + + do + local runs = {} + for i, n in ipairs(facingRuns) do runs[i] = n end + U.log("departure fixed steps:", tostring(maxTick), + "facing runs:", table.concat(runs, ",")) + check(maxTick >= 130, + "DIG: the departure spin runs the cart's 135 fixed steps -- got " + .. tostring(maxTick)) + check((runs[2] or 0) >= 12, + "DIG: the spin starts slow (>=12 frames a facing) -- got " + .. tostring(runs[2])) + local monotonic, prev = true, math.huge + for i = 2, math.min(#runs, 15) do + if runs[i] > prev then monotonic = false end + prev = runs[i] + end + check(monotonic, + "DIG: PlayerSpinInPlace accelerates (run lengths never grow)") + local sawRise = false + for i = 12, #runs do if runs[i] == 3 then sawRise = true end end + check(sawRise, + "DIG: the rise runs at PlayerSpinWhileMovingUp's 3 frames a step") + end check(landedMap == "VIRIDIAN_CITY", "DIG: lands OUTSIDE at VIRIDIAN_CITY, not the interior (FIX B) -- got " .. tostring(landedMap)) @@ -151,7 +191,7 @@ return function(game) U.wait(2) local ropeSpin = false - for _ = 1, 240 do + for _ = 1, 400 do local stillCave = ow.map and ow.map.id == "MT_MOON_1F" if stillCave and (ow.teleportOut ~= nil or ow.player.spinRise) then ropeSpin = true diff --git a/tests/drivers/gen2_talk_cancels_bump_bug2170_test.lua b/tests/drivers/gen2_talk_cancels_bump_bug2170_test.lua new file mode 100644 index 00000000..0b136a6c --- /dev/null +++ b/tests/drivers/gen2_talk_cancels_bump_bug2170_test.lua @@ -0,0 +1,83 @@ +-- engine/overworld/events.asm:494-510 +-- engine/overworld/player_movement.asm:807-816 +local U = require("tests.drivers.util") + +local Permissions = require("src.world.gen2.Permissions") + +return function(game) + local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") + or "/tmp/talk-bump-2170" + local fails = 0 + local function say(line) print("[2170] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + if not (world and world.map) then + say("FAIL the gen2 world did not boot") + love.event.quit(1) + return + end + + world:warpToMapId("CHERRYGROVE_CITY", 20, 10, "down") + U.wait(45) + + local DELTA = { + left = { -1, 0 }, right = { 1, 0 }, up = { 0, -1 }, down = { 0, 1 }, + } + local sx, sy, dir + for _, npc in ipairs(world.npcs or {}) do + for d, v in pairs(DELTA) do + local cx, cy = npc.cellX - v[1], npc.cellY - v[2] + if Permissions.isWalkable(world.map:cellCollision(cx, cy)) then + sx, sy, dir = cx, cy, d + break + end + end + if sx then break end + end + if not sx then + say("FAIL no reachable NPC on this map") + love.event.quit(1) + return + end + + world:warpToMapId("CHERRYGROVE_CITY", sx, sy, dir) + U.wait(45) + local p = world.player + say("bumping " .. dir .. " into an NPC from " .. sx .. "," .. sy) + + local sawWalk = false + for _ = 1, 40 do + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + coroutine.yield() + if p:walkPhase() == 1 then sawWalk = true end + end + ok(not p.moving, "the NPC never let the step start") + ok(sawWalk, "the walk-in-place cycle was running before the talk") + + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + U.tap(game, "a") + U.wait(2) + game.input.state[dir] = false + + ok(world:busy(), "the A press started the conversation") + ok(p:walkPhase() == 0, "the talk snapped the player to the standing frame") + ok((p.bumpFrames or 0) == 0, "with the queued bump cancelled") + U.shot(game, DIR .. "/2170-talking.png") + + local stayed = true + for _ = 1, 60 do + coroutine.yield() + if world:busy() and p:walkPhase() ~= 0 then stayed = false end + end + ok(stayed, "and it stays standing for the whole box") + + say(fails == 0 and "ALL PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gold_battle_layout_wide_bug1709_test.lua b/tests/drivers/gold_battle_layout_wide_bug1709_test.lua new file mode 100644 index 00000000..5bb6dbea --- /dev/null +++ b/tests/drivers/gold_battle_layout_wide_bug1709_test.lua @@ -0,0 +1,225 @@ +-- maps/Route29.asm:432 +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_battle_layout_wide_bug1709_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-battle-wide love . +local U = require("tests.drivers.util") + +local Chrome = require("src.ui.gen2.Chrome") +local Mon = require("src.battle.gen2.Mon") +local OptionsMenu = require("src.ui.gen2.OptionsMenu") +local Permissions = require("src.world.gen2.Permissions") +local WideBattle = require("src.ui.gen2.WideBattle") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-battle-wide" + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[wide] ok " .. label) + else + failures = failures + 1 + print("[wide] FAIL " .. label .. " " .. tostring(detail)) + end + end + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 6) + end + + local function shot(path) + if not U.shot(game, path) then failures = failures + 1 end + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + local layoutRow + for _, row in ipairs(OptionsMenu.ROWS) do + if row.label == "BATTLE LAYOUT" then layoutRow = row end + end + ok("OPTION carries a BATTLE LAYOUT row", layoutRow ~= nil, layoutRow) + if layoutRow then + ok("and WIDE is on its ladder", layoutRow.display.wide == "WIDE", + layoutRow.display.wide) + end + ok("the wide surface is 304x144", + WideBattle.WIDTH == 304 and WideBattle.HEIGHT == 144, WideBattle.WIDTH) + + if love.window and love.window.setMode then + love.window.setMode(1280, 840, { resizable = true }) + U.wait(6) + end + local winW, winH = love.graphics.getDimensions() + local wideScale = Chrome.fitScaleFor(winW, winH, WideBattle.TILES_W, + WideBattle.TILES_H) + local ox, oy = Chrome.fitOriginFor(winW, winH, wideScale, + WideBattle.TILES_W, WideBattle.TILES_H) + ok(("the wide panel fits (%dx%d, at %d,%d x%d)") + :format(winW, winH, ox, oy, wideScale), ox >= 0 and oy >= 0, + ox .. "," .. oy) + + local player = Mon.new(game.data, "CYNDAQUIL", 12) + local wild = Mon.new(game.data, "PIDGEY", 4) + ok("CYNDAQUIL builds from the extracted tables", + player ~= nil and #player.moves > 0, player and #player.moves) + game.save.party = { player } + game.save.inventory = { POKE_BALL = 5, POTION = 3 } + + assert(world:setMap("ROUTE_29", 15, 11, "down"), "setMap ROUTE_29 failed") + U.wait(8) + if not Permissions.isWalkable(world:playerCollision()) then + for _, step in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 }, + { 2, 0 }, { -2, 0 } }) do + if world:setMap("ROUTE_29", 15 + step[1], 11 + step[2], "down") + and Permissions.isWalkable(world:playerCollision()) then + break + end + end + U.wait(8) + end + ok("the player is standing on floor, not in a wall", + Permissions.isWalkable(world:playerCollision()), + tostring(world:playerCollision())) + + print(failures == 0 + and "[wide] preflight PASS -- the shots below are worth looking at" + or ("[wide] preflight FAIL (%d) -- fix these before judging a pixel") + :format(failures)) + + game.options.battleLayout = "wide" + U.wait(6) + + assert(world:startBattle({ wild = wild }), "startBattle failed") + local battle + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then battle = top break end + U.wait(1) + end + ok("the battle screen came up after the transition", battle ~= nil, battle) + if not battle then + print(("[wide] FAIL no battle to shoot (%d)"):format(failures)) + while true do coroutine.yield() end + end + ok("and it reports the wide layout", battle:wideLayout() == true, + battle:wideLayout()) + local pw, ph = battle:panelSize() + ok("composing on 304x144", pw == 304 and ph == 144, pw .. "x" .. ph) + + for _ = 1, 150 do + if battle.phase == "menu" then break end + tap("a", 2) + end + ok("the battle reached the FIGHT menu", battle.phase == "menu", battle.phase) + U.wait(10) + + U.log("00: the command menu on WIDE. the field (both pics, the grass, the") + U.log("bases) is the cart's 160x144 picture, centred, untouched. the foe's") + U.log("HP bar sits out in the LEFT margin at its cart tiles, yours out in") + U.log("the RIGHT one. the message box and the FIGHT/PKMN/PACK/RUN window") + U.log("run the full 38 tiles across the bottom, and the four labels are") + U.log("hard against the right edge. anything clipped at 160 is the") + U.log("surface push being wrong.") + shot(out .. "/00-wide-menu.png") + + local function pointAt(index) + tap("left", 3) + tap("up", 3) + if index == 2 or index == 4 then tap("right", 3) end + if index == 3 or index == 4 then tap("down", 3) end + return battle.menuIndex == index + end + + ok("the cursor is on FIGHT", pointAt(1), battle.menuIndex) + tap("a", 14) + ok("the move list opened", battle.phase == "moves", battle.phase) + U.log("01: the FIGHT list on WIDE. the name list stretches to the right") + U.log("edge; MoveInfoBox (TYPE/ and the PP) stays where the cart puts it,") + U.log("bottom left, unmoved.") + shot(out .. "/01-wide-moves.png") + tap("a", 30) + + U.log("02: a move going off. the FIELD shakes and flashes; the two HUD") + U.log("blocks out in the margins must hold perfectly still, and the ▼") + U.log("prompt must sit in the bottom-right corner of the WIDE message box,") + U.log("not 18 tiles in.") + shot(out .. "/02-wide-anim.png") + for _ = 1, 200 do + if battle.phase == "menu" then break end + tap("a", 3) + end + + ok("back at the menu", battle.phase == "menu", battle.phase) + ok("the cursor is on PKMN", pointAt(2), battle.menuIndex) + tap("a", 12) + U.log("03: the party list over the wide battle. it is a 160-wide screen and") + U.log("must land CENTRED in the 304 surface, on the same pixel grid -- not") + U.log("hard left, not at a different scale.") + shot(out .. "/03-party-over-wide.png") + tap("b", 12) + for _ = 1, 20 do + if battle.phase == "menu" then break end + tap("b", 4) + end + + game.options.battleFit = "fill" + U.wait(8) + U.log("04: WIDE + BATTLE SIZE on FILL. the 304 surface stretches to the") + U.log("window, still whole-grid, nothing cut off either side.") + shot(out .. "/04-wide-fill.png") + game.options.battleFit = "fixed" + U.wait(8) + + game.options.battleBg = "black" + U.wait(8) + U.log("05: WIDE + BATTLE BG on BLACK. the bars must hug the 304 rect, not") + U.log("the 160 one -- black creeping over the margins where the HUDs live") + U.log("is the surround reading the wrong panel size.") + shot(out .. "/05-wide-black.png") + + ok("the cursor is on PKMN under BLACK", pointAt(2), battle.menuIndex) + tap("a", 12) + U.log("05b: the party list over a WIDE + BLACK battle. the black must still") + U.log("hug the 304 rect -- side bands intact, no white void left on any") + U.log("edge. the list itself lands centred on the 160 grid.") + shot(out .. "/05b-party-over-wide-black.png") + tap("b", 12) + for _ = 1, 20 do + if battle.phase == "menu" then break end + tap("b", 4) + end + + game.options.battleBg = "world" + U.wait(8) + U.log("06: WIDE + WORLD. Route 29 dimmed around the whole wide rect. note") + U.log("the known cosmetic: during a per-scanline BGP effect the margins") + U.log("stay paper while the 160 field flashes.") + shot(out .. "/06-wide-world.png") + game.options.battleBg = "white" + U.wait(8) + + game.options.battleLayout = "og" + U.wait(10) + ok("OG reads og again", battle:wideLayout() == false, battle:wideLayout()) + local ogW = select(1, battle:panelSize()) + ok("and composes on 160 again", ogW == 160, ogW) + U.log("07: the same battle back on OG. this must be pixel-for-pixel the") + U.log("screen the port shipped before this change -- HUDs at their cart") + U.log("tiles, 20-tile message box, ▼ at (18,17).") + shot(out .. "/07-og-unchanged.png") + + game.options.battleLayout = "wide" + U.wait(8) + + print(failures == 0 and "[wide] PASS gold_battle_layout_wide_bug1709" + or ("[wide] FAIL gold_battle_layout_wide_bug1709 (%d)"):format(failures)) + U.log("the battle is still up on WIDE and the controls are yours.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/menu_sfx_bug960_bug961_bug1044_bug1045_test.lua b/tests/drivers/menu_sfx_bug960_bug961_bug1044_bug1045_test.lua index 71e60f48..b7404af2 100644 --- a/tests/drivers/menu_sfx_bug960_bug961_bug1044_bug1045_test.lua +++ b/tests/drivers/menu_sfx_bug960_bug961_bug1044_bug1045_test.lua @@ -260,8 +260,8 @@ return function(game) if houseStairs then local sx, sy = cellBehind(houseStairs.x, houseStairs.y, "up") stand("REDS_HOUSE_1F", sx, sy, "up") - -- the destination decides the cue here, not the tile underfoot, so an - takeDoor("up", "Go_Inside", "taking the stairs up") + -- home/overworld.asm:689-703 + takeDoor("up", "Go_Outside", "taking the stairs up") end -- ---- the bedroom PC (#960) --------------------------------------------- diff --git a/tests/drivers/party_heal_bug252_test.lua b/tests/drivers/party_heal_bug252_test.lua index fd7c9529..0dcc0153 100644 --- a/tests/drivers/party_heal_bug252_test.lua +++ b/tests/drivers/party_heal_bug252_test.lua @@ -24,6 +24,10 @@ return function(game) return s ~= nil and (s.screenId == "PartyMenu" or getmetatable(s) == PartyMenu) end local function isBox(s) return getmetatable(s) == TextBox end + -- engine/menus/start_sub_menus.asm:414-416 + local function isFlash(s) + return type(s) == "table" and s.t ~= nil and s.frames ~= nil + end local function inStack(pred) for _, s in ipairs(game.stack.states or {}) do if pred(s) then return true end @@ -240,6 +244,13 @@ return function(game) U.wait(8) end check("the picker is gone once the message is dismissed", not inStack(isPicker)) + local flashed = isFlash(top()) + check("the return to the bag whites out (#2125)", flashed) + if flashed then U.shot(game, DIR .. "/bug2125_white.png") end + for _ = 1, 40 do + if not isFlash(top()) then break end + U.wait(1) + end local back = top() check("and we are back on the ITEM list", back ~= nil and back.screenId == "BagMenu") U.shot(game, DIR .. "/bug252_back_on_bag.png") diff --git a/tests/drivers/party_swap_anim_bug2059_test.lua b/tests/drivers/party_swap_anim_bug2059_test.lua index 768939f7..17184d6d 100644 --- a/tests/drivers/party_swap_anim_bug2059_test.lua +++ b/tests/drivers/party_swap_anim_bug2059_test.lua @@ -52,12 +52,19 @@ return function(game) pm.swapAnim ~= nil and pm.swapAnim.blank[1] == true) U.shot(game, DIR .. "/bug2059_blank.png") + -- the second SwitchPartyMon_ClearGfx (start_sub_menus.asm:664-666) + U.wait(1) + check("the destination row blanks too (#2126)", + pm.swapAnim ~= nil and pm.swapAnim.blank[3] == true) + U.shot(game, DIR .. "/bug2126_both_blank.png") + U.wait(40) check("the row is back after the second SFX_SWAP", pm.swapAnim == nil) U.shot(game, DIR .. "/bug2059_redrawn.png") U.log("bug2059_blank.png should show row 1 EMPTY (no icon, no name, no HP") - U.log("bar, no cursor) while the swap sound plays; bug2059_redrawn.png") + U.log("bar, no cursor) while the swap sound plays; bug2126_both_blank.png") + U.log("should show rows 1 AND 3 empty at the same time; bug2059_redrawn.png") U.log("should show the full list with MAGIKARP first and RATTATA third.") U.log("Listen for TWO swap beeps, not one. Input is yours now: press A") U.log("twice on the same slot -- the row should still blank and beep.") diff --git a/tests/drivers/pp_restore_menu_2155_2158.lua b/tests/drivers/pp_restore_menu_2155_2158.lua new file mode 100644 index 00000000..ab123869 --- /dev/null +++ b/tests/drivers/pp_restore_menu_2155_2158.lua @@ -0,0 +1,254 @@ +-- engine/items/item_effects.asm:1954 +-- engine/battle/core.asm:2520 +-- Not under POKEPORT_SPEED: SFX_HEAL_AILMENT rides the real-time audio clock. +-- POKEPORT_DRIVER=tests/drivers/pp_restore_menu_2155_2158.lua \ +-- POKEPORT_IDENTITY=pp2155 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \ +-- SHOT_DIR=/tmp/shots2155 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local Bag = require("src.inventory.Bag") + local ItemEffects = require("src.inventory.ItemEffects") + local PartyMenu = require("src.ui.PartyMenu") + local MoveSelectMenu = require("src.ui.MoveSelectMenu") + local TextBox = require("src.render.TextBox") + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + local function top() return game.stack:top() end + local function isPicker(s) + return s ~= nil and (s.screenId == "PartyMenu" or getmetatable(s) == PartyMenu) + end + local function isMoveMenu(s) return getmetatable(s) == MoveSelectMenu end + local function isBox(s) return getmetatable(s) == TextBox end + local function inStack(pred) + for _, s in ipairs(game.stack.states or {}) do + if pred(s) then return true end + end + return false + end + + U.log("======== #2155/#2158 PP restore: machine checks ========") + for _, id in ipairs({ "ELIXER", "MAX_ELIXER", "ETHER", "MAX_ETHER", "PP_UP" }) do + check(id .. " keeps the party menu drawn", + ItemEffects.keepsPartyMenuOpen(id) == true) + check(id .. " resolves in the item table", game.data.items[id] ~= nil) + end + check("MoveSelectMenu draws", type(MoveSelectMenu.draw) == "function") + + local lead = Pokemon.new(game.data, "CHARIZARD", 50) + local topped = Pokemon.new(game.data, "SNORLAX", 40) + for _, mv in ipairs(lead.moves) do mv.pp = 1 end + game.save.party = { lead, topped } + game.save.player.name = "RED" + for _, id in ipairs({ "ELIXER", "MAX_ELIXER", "ETHER", "MAX_ETHER" }) do + Bag.add(game.save, id, 5) + end + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(10) + + local function cursorTo(menu, want) + for _ = 1, 40 do + if not menu or menu.index == want then return menu and menu.index == want end + U.tap(game, menu.index < want and "down" or "up") + U.wait(3) + end + return menu.index == want + end + + local function openPickerFor(id) + U.tap(game, "start") + U.wait(10) + local menu = top() + if not (menu and menu.screenId == "StartMenu") then + return nil, "start menu never opened" + end + local itemRow + for i, it in ipairs(menu.items or {}) do + if it.label == "ITEM" then itemRow = i break end + end + if not itemRow or not cursorTo(menu, itemRow) then return nil, "no ITEM row" end + U.tap(game, "a") + U.wait(10) + local bag = top() + if not (bag and bag.screenId == "BagMenu") then return nil, "bag never opened" end + local bagRow + for i, r in ipairs(bag.items or {}) do + if r.value == id then bagRow = i break end + end + if not bagRow or not cursorTo(bag, bagRow) then return nil, id .. " not in bag" end + U.tap(game, "a") + U.wait(10) + local ut = top() + if ut and ut.items and ut.items[1] and ut.items[1].label == "USE" then + if not cursorTo(ut, 1) then return nil, "USE row unreachable" end + U.tap(game, "a") + U.wait(10) + end + local picker = top() + if not isPicker(picker) then return nil, "party picker never opened" end + return picker + end + + local function backToOverworld() + for _ = 1, 30 do + if top() == game.overworld then return true end + U.tap(game, "b") + U.wait(6) + end + return top() == game.overworld + end + + local function dismiss() + for _ = 1, 30 do + if not inStack(isPicker) then break end + U.tap(game, "a") + U.wait(8) + end + end + + local function waitForBag() + for _ = 1, 60 do + local t = top() + if t ~= nil and t.screenId == "BagMenu" then break end + U.wait(1) + end + end + + U.log("======== #2155 ELIXER: SFX_HEAL_AILMENT over a live party menu ========") + local picker, why = openPickerFor("ELIXER") + check("party picker opened for ELIXER" .. (why and (" (" .. why .. ")") or ""), + picker ~= nil) + if picker then + check("keepOpen is set for the ELIXERs", picker.keepOpen == true) + cursorTo(picker, 1) + U.tap(game, "a") + U.wait(6) + check("the party menu is still on the stack after the pick", inStack(isPicker)) + -- engine/items/item_effects.asm:2022 + check("the cursor is still drawn", picker.cursorsErased ~= true) + for _ = 1, 60 do + if isBox(top()) then break end + U.wait(1) + end + check("the PP restored box opened", isBox(top())) + check("...over the STILL-drawn party menu", inStack(isPicker)) + check("every slot came back up", lead.moves[1].pp > 1) + U.log("LISTEN", "SFX_HEAL_AILMENT should have played as the box opened") + U.wait(60) + U.shot(game, DIR .. "/pp2155_elixir_message_over_party.png") + dismiss() + check("the picker is gone once the message is dismissed", not inStack(isPicker)) + -- pokered engine/items/item_effects.asm:1244 + waitForBag() + check("and we are back on the ITEM list", + top() ~= nil and top().screenId == "BagMenu") + end + backToOverworld() + + U.log("======== #2155 MAX ELIXER refusal prints over the party menu ========") + local refuse = openPickerFor("MAX_ELIXER") + if check("party picker opened for MAX ELIXER", refuse ~= nil) then + cursorTo(refuse, 2) -- the untouched SNORLAX + U.tap(game, "a") + U.wait(6) + for _ = 1, 60 do + if isBox(top()) then break end + U.wait(1) + end + check("the no-effect box opened", isBox(top())) + check("...over the STILL-drawn party menu", inStack(isPicker)) + U.wait(60) + U.shot(game, DIR .. "/pp2155_max_elixir_no_effect.png") + dismiss() + end + backToOverworld() + + U.log("======== #2158 ETHER: wMoveMenuType 2 window over the party menu ========") + for _, mv in ipairs(lead.moves) do mv.pp = 1 end + local ether = openPickerFor("ETHER") + if check("party picker opened for ETHER", ether ~= nil) then + check("keepOpen is set for the ETHERs", ether.keepOpen == true) + cursorTo(ether, 1) + U.tap(game, "a") + U.wait(8) + check("the move window is the top state", isMoveMenu(top())) + check("...with the party menu still drawn underneath", inStack(isPicker)) + U.wait(20) + U.shot(game, DIR .. "/pp2158_ether_move_window.png") + + -- engine/items/item_effects.asm:1985 + U.tap(game, "b") + U.wait(8) + check("B goes back to the party menu", isPicker(top())) + U.tap(game, "a") + U.wait(8) + check("and the move window opens again", isMoveMenu(top())) + + local before = lead.moves[1].pp + U.tap(game, "a") + U.wait(6) + for _ = 1, 60 do + if isBox(top()) then break end + U.wait(1) + end + check("the PP restored box opened", isBox(top())) + check("...over the STILL-drawn party menu", inStack(isPicker)) + check("the picked move gained PP", lead.moves[1].pp > before) + U.log("LISTEN", "SFX_HEAL_AILMENT should have played for the ETHER too") + U.wait(60) + U.shot(game, DIR .. "/pp2158_ether_message_over_party.png") + dismiss() + -- pokered engine/items/item_effects.asm:1244 + waitForBag() + check("back on the ITEM list", top() ~= nil and top().screenId == "BagMenu") + end + backToOverworld() + + U.log("======== #2158 MAX ETHER refusal keeps both windows up ========") + local maxEther = openPickerFor("MAX_ETHER") + if check("party picker opened for MAX ETHER", maxEther ~= nil) then + cursorTo(maxEther, 2) -- the untouched SNORLAX + U.tap(game, "a") + U.wait(8) + check("the move window opened", isMoveMenu(top())) + U.tap(game, "a") + U.wait(6) + for _ = 1, 60 do + if isBox(top()) then break end + U.wait(1) + end + check("the no-effect box opened", isBox(top())) + check("...over the STILL-drawn party menu", inStack(isPicker)) + U.wait(60) + U.shot(game, DIR .. "/pp2158_max_ether_no_effect.png") + dismiss() + end + backToOverworld() + + U.log(("======== PP restore driver: %d passed, %d failed ========") + :format(pass, fail)) + U.log(fail == 0 and "DRIVER PASS" or "DRIVER FAIL") + + for _, mv in ipairs(lead.moves) do mv.pp = 1 end + local handoff = openPickerFor("ETHER") + if handoff then + cursorTo(handoff, 1) + U.tap(game, "a") + U.wait(8) + end + U.log("The ETHER move window is open over the party menu. Compare it with") + U.log("shots/issue_2158_1.png: a 16x6 box at tile 4,7, four names single") + U.log("spaced with '-' for empty slots, no PP column, cursor at column 5,") + U.log("and 'Restore PP of / which technique?' in the bottom box. B returns") + U.log("to the party menu; A restores PP with the Heal_Ailment jingle.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/rare_candy_statbox_hold_test.lua b/tests/drivers/rare_candy_statbox_hold_test.lua index f1889015..f94bc3a4 100644 --- a/tests/drivers/rare_candy_statbox_hold_test.lua +++ b/tests/drivers/rare_candy_statbox_hold_test.lua @@ -72,6 +72,19 @@ return function(game) end U.shot(game, SHOT_DIR .. "/candy_statbox_1_stats.png") + -- pokered engine/items/item_effects.asm:1392-1394 + local PartyMenu = require("src.ui.PartyMenu") + local picker + for _, s in ipairs(game.stack.states) do + if getmetatable(s) == PartyMenu then picker = s end + end + if check("the party menu is still under the stat window", picker ~= nil) then + local msg = picker:bottomMessage() or "" + check("its bottom box still holds the level line, not the item prompt " + .. "(#2161): " .. msg:gsub("\n", " "), + msg:find("Use item") == nil) + end + U.tap(game, "a") check("the stat window is still on the stack after its A press", findStat() ~= nil) diff --git a/tests/drivers/rival_walkoff_test.lua b/tests/drivers/rival_walkoff_test.lua index 58467f40..c6d39392 100644 --- a/tests/drivers/rival_walkoff_test.lua +++ b/tests/drivers/rival_walkoff_test.lua @@ -200,4 +200,64 @@ return function(game) U.shot(game, DIR .. "/cer_4_done.png") U.log("cer beat:", tostring(game.save.flags.EVENT_BEAT_CERULEAN_RIVAL), "billShot:", tostring(billShot), "exitShot:", tostring(exitShot)) + + Flags.clear(game.save, "EVENT_BEAT_CERULEAN_RIVAL") + game.save.party = { tank() } + + U.teleport(game, "CERULEAN_CITY", 21, 7, "up") + ow = game.overworld + U.shot(game, DIR .. "/cer21_0_before.png") + U.hold(game, "up", 20) + + local firstX, firstY, spawnShot = nil, nil, false + local path, lastCell = {}, nil + local keptSprite, wipeShot = nil, false + for _ = 1, 1200 do + local r = rivalNpc() + if r and r.cellX then + if not firstX then + firstX, firstY = r.cellX, r.cellY + U.log("cer21 rival first visible at", firstX, firstY) + end + local cell = r.cellX .. "," .. r.cellY + if cell ~= lastCell then + lastCell = cell + path[#path + 1] = cell + end + if not spawnShot then + spawnShot = true + U.shot(game, DIR .. "/cer21_1_spawn.png") + end + end + if ow.battleOamKeep ~= nil and not keptSprite then + keptSprite = ow.battleOamKeep + U.log("cer21 battleOamKeep:", + tostring(keptSprite and keptSprite.def and keptSprite.def.name)) + end + local wipe = game.renderer and game.renderer.battleWipe + if wipe and wipe.prog and wipe.prog > 0.2 and wipe.prog < 0.8 + and not wipeShot then + wipeShot = true + U.shot(game, DIR .. "/cer21_2_wipe.png") + end + local top = game.stack:top() + if top ~= ow then + if top.phase then break end + U.tap(game, "a") -- advance the pre-battle box + end + U.wait(1) + end + U.log("cer21 approach path:", table.concat(path, " -> ")) + U.log(firstX == 21 and "PASS #2149 rival spawns in the player's column" + or ("FAIL #2149 rival first seen at x=" .. tostring(firstX))) + U.log(#path <= 4 and "PASS #2149 approach is three straight steps" + or ("FAIL #2149 approach took " .. (#path - 1) .. " steps")) + U.log(keptSprite and keptSprite ~= false + and "PASS #2150 the rival's OAM block survives the wipe" + or "FAIL #2150 battleOamKeep was false, the rival is culled") + U.log(wipeShot and "wipe frame captured" or "no wipe frame captured") + + winBattle("cerulean21") + mashUntil(function() return idle(ow) end, "cerulean21 done", 2000) + U.shot(game, DIR .. "/cer21_3_done.png") end diff --git a/tests/drivers/route24_gift_jingles_2156_2159.lua b/tests/drivers/route24_gift_jingles_2156_2159.lua new file mode 100644 index 00000000..b015f369 --- /dev/null +++ b/tests/drivers/route24_gift_jingles_2156_2159.lua @@ -0,0 +1,29 @@ +-- ../pokeyellow/scripts/Route24.asm:149-158 +-- ../pokeyellow/engine/events/give_pokemon.asm:45-46 +-- POKEPORT_DRIVER=tests/drivers/route24_gift_jingles_2156_2159.lua \ +-- POKEPORT_IDENTITY=yellow-sep02 POKEPORT_VERSION=yellow POKEPORT_TOUCH=0 \ +-- SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + U.newGame(game) + local flags = game.save.flags + flags.EVENT_GOT_NUGGET = nil + flags.EVENT_BEAT_ROUTE24_ROCKET = nil + flags.EVENT_54F = nil + + U.teleport(game, "ROUTE_24", 10, 16, "up") + U.wait(30) + U.shot(game, DIR .. "/route24_jingles_2156_2159.png") + + U.log("PASS setup: ROUTE_24 (10,16), nugget and Charmander quests cleared") + U.log("#2156 step 1: press UP onto (10,15) -- the recruiter talks.") + U.log("#2156 expect: the fanfare plays at the END of the") + U.log(" 'You beat our 5 contest trainers!' box, before the arrow.") + U.log("#2156 expect: the NUGGET receipt box plays SFX_GET_KEY_ITEM on") + U.log(" Yellow, SFX_GET_ITEM_1 on Red/Blue.") + U.log("#2159 step 2: walk north to Damian and accept his CHARMANDER.") + U.log("#2159 expect: 'got CHARMANDER!' + SFX_GET_ITEM_1 BEFORE the") + U.log(" nickname prompt.") +end diff --git a/tests/drivers/seafoam_holes_bug599_test.lua b/tests/drivers/seafoam_holes_bug599_test.lua index b931f45d..b3668dcb 100644 --- a/tests/drivers/seafoam_holes_bug599_test.lua +++ b/tests/drivers/seafoam_holes_bug599_test.lua @@ -127,6 +127,14 @@ return function(game) if landed and not o.transitioning then break end U.wait(1) end + -- spins the player down -- player_animations.asm:41-45 + local hidden = false + for _ = 1, 200 do + local o = game.overworld + if o.holeArrive then hidden = hidden or o.playerHidden end + if not (o.holeArrive or o.player.spinning) then break end + U.wait(1) + end U.wait(20) watching = false @@ -152,6 +160,15 @@ return function(game) check("no door chime over the fall (heard: " .. (#cues > 0 and table.concat(cues, ", ") or "nothing") .. ")", #doors == 0) + -- player_animations.asm:12 + local faint, enter = false, false + for _, c in ipairs(cues) do + if c == "Faint_Fall" then faint = true end + if c == "Teleport_Enter1" then enter = true end + end + check("the departure is silent (no invented Faint_Fall)", not faint) + check("the arrival rings Teleport_Enter1", enter) + check("the player is parked offscreen through the 50-frame hold", hidden) U.shot(game, f.shot) U.log("captured", f.shot) end @@ -165,8 +182,10 @@ return function(game) U.wait(10) U.log("Both falls have already run; press up from where you are standing to") - U.log("do the 1F one again. It should be a plain fade to black and back with") - U.log("the player on B1F at (18,7) still facing up, no door chime, and no") + U.log("do the 1F one again. It should sink the sprite, fade to WHITE, hold") + U.log("about a second on an empty B1F (18,7) with the player offscreen, ring") + U.log("Teleport_Enter1 and spin him down into the cell still facing up -- no") + U.log("faint sound, no door chime, and no") U.log("step out of a doorway on the far side. The near miss to watch for is") U.log("landing one cell off, on the boulder's own spot from field.seafoam") U.log("landsAt, which strands you inside the rock; and dropping onto the B3F") diff --git a/tests/drivers/ss_anne_departure_bug360_test.lua b/tests/drivers/ss_anne_departure_bug360_test.lua index 11a1b49d..20d74c3b 100644 --- a/tests/drivers/ss_anne_departure_bug360_test.lua +++ b/tests/drivers/ss_anne_departure_bug360_test.lua @@ -49,7 +49,7 @@ return function(game) } run(ow) Music.play = realPlay - return rows + return rows, ow end -- ---------------------------------------------------------- rival exit @@ -63,9 +63,27 @@ return function(game) for _, side in ipairs({ { 37, 4 }, { 36, 6 } }) do local x, steps = side[1], side[2] - local rows = capture(function(ow) + local rows, capturedOw = capture(function(ow) story5[ANNE2F].onStep({ save = { flags = {} }, data = game.data }, ow, x, 8) end) + -- SSAnne2FSetFacingDirectionScript (scripts/SSAnne2F.asm:73) + check(("x=%d the player keeps his walking facing at trigger time"):format(x), + capturedOw.player.facing == "down") + local turnIndex, moveIndex, textIndex + for i, r in ipairs(rows) do + if r[1] == "face_player_dir" and not turnIndex then turnIndex = i end + if r[1] == "move_npc_to" and not moveIndex then moveIndex = i end + if r[1] == "show_text" and not textIndex then textIndex = i end + end + if x == 37 then + check("x=37 turns the player LEFT as a scripted row", + turnIndex ~= nil and rows[turnIndex][2] == "left") + check("x=37 turns him after the walk and before the dialogue", + turnIndex ~= nil and moveIndex ~= nil and textIndex ~= nil + and turnIndex > moveIndex and turnIndex < textIndex) + else + check("x=36 never writes the player's direction", turnIndex == nil) + end local said = rowsOfKind(rows, "show_text") check(("x=%d the goodbye is the last thing he says"):format(x), said[#said] ~= nil and said[#said][2] == CUT) diff --git a/tests/drivers/text_arrow_bug2116_test.lua b/tests/drivers/text_arrow_bug2116_test.lua new file mode 100644 index 00000000..26a74960 --- /dev/null +++ b/tests/drivers/text_arrow_bug2116_test.lua @@ -0,0 +1,72 @@ +-- (text/PalletTown.asm:44) +-- show, on the second text row (home/text.asm:214) +-- SHOT_DIR=/tmp/bug2116 POKEPORT_DRIVER=tests/drivers/text_arrow_bug2116_test.lua \ +-- POKEPORT_IDENTITY= POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local TextBox = require("src.render.TextBox") + + local pass = true + local function check(msg, cond) + if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end + end + local function box() + local top = game.stack:top() + return getmetatable(top) == TextBox and top or nil + end + + -- bg_event 7, 9 (data/maps/objects/PalletTown.asm:16) + U.teleport(game, "PALLET_TOWN", 7, 10, "up") + U.wait(8) + U.tap(game, "a") + U.wait(10) + + local tb = box() + check("the sign opened a text box", tb ~= nil) + if not tb then U.log("RESULT: SEE FAILURES ABOVE") return end + + U.log("_PalletTownSignText = " + .. string.format("%q", tostring(game.data.text._PalletTownSignText))) + check("the extracted line ends in a done marker", + (game.data.text._PalletTownSignText or ""):find("{DONE}", 1, true) ~= nil) + + -- the `cont` wait: home/text.asm:263 + local sawCont = false + for _ = 1, 400 do + if tb.waiting then sawCont = true break end + U.wait(1) + end + check("the cont page waits on the arrow", sawCont) + if sawCont then + local x, y = tb:arrowPos() + U.log(("arrowPos = %d,%d line2Y = %d"):format(x, y, tb.line2Y)) + check("the arrow is drawn at the cont wait", tb:arrowVisible()) + check("column 18 (x=144)", x == 144) + check("row 16 (y=128), the second text row", y == 128 and y == tb.line2Y) + tb.blink = 0 + U.shot(game, DIR .. "/bug2116_1_cont_arrow.png") + end + + for _ = 1, 120 do + if tb.done then break end + U.tap(game, "a") + U.wait(4) + end + check("the text finished", tb.done == true) + -- home/text_script.asm:96 -> home/joypad2.asm:71-72 + check("a `done` text still blinks the arrow", tb:arrowVisible()) + -- home/joypad2.asm:90-92 + check("and closes without SFX_PRESS_AB", tb.waitButton == true) + tb.blink = 0 + U.shot(game, DIR .. "/bug2116_2_done_arrow_no_beep.png") + + U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE") + U.log("bug2116_1_cont_arrow.png: the blinking triangle sits on the same") + U.log("row as the second line of text, not jammed on the bottom border.") + U.log("bug2116_2_done_arrow_no_beep.png: the final page of a `done` text") + U.log("still shows the triangle -- WaitForTextScrollButtonPress blinks it") + U.log("for every Gen 1 text; only the A-press beep is missing.") + U.log("Shots are in " .. DIR) +end diff --git a/tests/drivers/warp_fade_bug607_test.lua b/tests/drivers/warp_fade_bug607_test.lua index 4d81b4cd..03c1597d 100644 --- a/tests/drivers/warp_fade_bug607_test.lua +++ b/tests/drivers/warp_fade_bug607_test.lua @@ -54,7 +54,7 @@ return function(game) check(("sfx volume %d"):format(vol), vol > 0) -- the record and the staircase it drives, before any of it is on screen - local fade = Transition.new(game) + local fade = Transition.new(game, nil, nil, true) check("the warp fade runs 32 frames with no fade in", fade.frames == 32 and (fade.framesIn or 0) == 0) fade.phase = "out" @@ -111,6 +111,7 @@ return function(game) return getmetatable(top) == Transition and top or nil end local seen, order = {}, {} + local bytes = {} for _ = 1, 140 do table.insert(game.input.pressQueue, "down") game.input.state.down = true @@ -119,6 +120,8 @@ return function(game) local a = live:alpha() if not seen[a] then seen[a] = 0; order[#order + 1] = a end seen[a] = seen[a] + 1 + local b = live:bgp() + if bytes[#bytes] ~= b then bytes[#bytes + 1] = b end elseif #order > 0 then break end @@ -141,6 +144,15 @@ return function(game) check("each shade holds its eight frames", held) check("we are back outside on " .. MAP, ow.map.id == MAP) + -- staircase has to be FadePal4/3/2/1 (home/fade.asm:49) + local hex = {} + for _, b in ipairs(bytes) do hex[#hex + 1] = ("$%02X"):format(b) end + U.log("rBGP bytes written on the way out:", table.concat(hex, ", ")) + local want = { 0xE4, 0xF9, 0xFE, 0xFF } + local walked = #bytes == #want + for i, b in ipairs(want) do if bytes[i] ~= b then walked = false end end + check("the exit fade walks FadePal4 -> FadePal3 -> FadePal2 -> FadePal1", walked) + -- hand the doorstep back so this can be re-triggered by hand U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) U.log(failures == 0 and "checks clean" or (failures .. " check(s) failed above")) diff --git a/tests/drivers/yellow_map_seam_bug2152_test.lua b/tests/drivers/yellow_map_seam_bug2152_test.lua new file mode 100644 index 00000000..da0b74be --- /dev/null +++ b/tests/drivers/yellow_map_seam_bug2152_test.lua @@ -0,0 +1,76 @@ +-- (../pokeyellow/engine/gfx/palettes.asm:178) +-- POKEPORT_DRIVER=tests/drivers/yellow_map_seam_bug2152_test.lua \ +-- POKEPORT_IDENTITY=bsa2180 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow \ +-- SHOT_DIR=/tmp/shots2152 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Zoom = require("src.render.Zoom") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots2152" + + local failures = 0 + local function check(label, ok) + if not ok then failures = failures + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + U.newGame(game) + local ow = game.overworld + + U.teleport(game, "PALLET_TOWN", 10, 1, "up") + U.wait(20) + check("standing on the Pallet Town / Route 1 boundary", + ow.map.id == "PALLET_TOWN") + check("a neighbour map is resident", #ow.neighbors > 0) + + local zones = ow:sgbWorldZones() + U.log("world zones at FIT:", zones and #zones or "none") + check("FIT publishes one whole-view zone (BlkPacket_WholeScreen)", + zones == nil or #zones <= 1) + check("the boundary frame captured", + U.shot(game, DIR .. "/seam_1_fit.png")) + + Zoom.offset = -2 + U.wait(10) + local surveyed = ow:sgbWorldZones() + U.log("world zones at survey zoom:", surveyed and #surveyed or "none") + check("survey zoom keeps a zone per resident neighbour", + surveyed == nil or #surveyed > 1) + U.shot(game, DIR .. "/seam_2_survey.png") + Zoom.offset = 0 + U.wait(10) + + U.teleport(game, "ROUTE_1", 10, 1, "up") + U.wait(20) + local lane + for x = 0, 19 do + if ow.map:isWalkableCell(x, 0) and ow.map:isWalkableCell(x, 1) then + lane = x + break + end + end + check("Route 1 has a walkable lane to Viridian", lane ~= nil) + if lane then + U.teleport(game, "ROUTE_1", lane, 1, "up") + U.wait(20) + U.hold(game, "up", 120) + U.wait(20) + end + U.shot(game, DIR .. "/seam_3_crossed.png") + U.log("map after walking north:", ow.map.id) + check("walked onto VIRIDIAN_CITY", ow.map.id == "VIRIDIAN_CITY") + local after = ow:sgbWorldZones() + check("the new map is also a single whole-view zone at FIT", + after == nil or #after <= 1) + + U.log(failures == 0 and "checks clean" or (failures .. " check(s) failed above")) + U.log("seam_1_fit.png must show ONE palette across the whole screen, with no") + U.log("horizontal colour break above the player and no split down his sprite.") + U.log("seam_2_survey.png is the survey zoom and SHOULD still show each town") + U.log("and route in its own colours. seam_3_crossed.png is Viridian after") + U.log("the crossing, entirely in the new map's palette.") + + while true do + coroutine.yield() + end +end diff --git a/tests/engine/battle_transition_oam_bug1948.lua b/tests/engine/battle_transition_oam_bug1948.lua index 37a11474..61811f0d 100644 --- a/tests/engine/battle_transition_oam_bug1948.lua +++ b/tests/engine/battle_transition_oam_bug1948.lua @@ -53,6 +53,41 @@ do eq(got, nil, "no npc and no keep: the player only") end +-- home/text_script.asm:8 +do + local rival = { id = "rival" } + local other = { id = "other" } + local got + local ow = { + player = player, + pushBattle = function(_, _, keep) got = keep end, + npcByIndex = function(_, i) + if i == 1 then return rival elseif i == 2 then return other end + end, + } + local ctx = { overworld = ow, runner = {} } + Commands.pushBattle(ctx, { kind = "trainer" }) + eq(got, nil, "an unaddressed ambush still keeps nothing") + Commands.face_object(ctx, 1, "down") + Commands.pushBattle(ctx, { kind = "trainer" }) + eq(got, rival, "face_object stamps the sprite an ambush battle keeps") + Commands.place_npc(ctx, 2, 3, 4) + Commands.pushBattle(ctx, { kind = "trainer" }) + eq(got, other, "place_npc restamps, like hSpriteIndex being overwritten") + Commands.walk_npc(ctx, 1, {}) + Commands.pushBattle(ctx, { kind = "trainer" }) + eq(got, rival, "walk_npc stamps the object it moves") + Commands.walk_npc(ctx, "player", {}) + Commands.pushBattle(ctx, { kind = "trainer" }) + eq(got, rival, "walk_npc \"player\" leaves the stamp alone") + local talked = { id = "talked" } + ctx.npc = talked + Commands.pushBattle(ctx, { kind = "trainer" }) + eq(got, talked, "a talked-to npc still wins over the script stamp") + Commands.pushBattle(ctx, { kind = "trainer" }, other) + eq(got, other, "an explicit keep still wins over both") +end + do Renderer.wipeSprites = function() end Renderer.wipeWox, Renderer.wipeWoy = 1, 2 diff --git a/tests/engine/bills_pc_changebox_bug2124.lua b/tests/engine/bills_pc_changebox_bug2124.lua new file mode 100644 index 00000000..16df7d9f --- /dev/null +++ b/tests/engine/bills_pc_changebox_bug2124.lua @@ -0,0 +1,149 @@ +-- engine/pokemon/bills_pc.asm:176 +-- :118 PokeballTileGraphics; data/text/text_3.asm:30 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Data = T.fixtures.fresh() +local Font = require("src.render.Font") +Font.load(Data) +local Menu = require("src.ui.Menu") +local Theme = require("src.ui.Theme") +local BoxMenu = require("src.ui.BoxMenu") +local Boxes = require("src.pokemon.Boxes") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") + +local function stubGame() + local game = { data = Data, save = SaveData.newGame() } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + game.input = { + wasPressed = function() return false end, + isDown = function() return false end, + } + return game +end + +-- home/window.asm:198 +local cursors +local realDrawCode = Font.drawCode +Font.drawCode = function(code, x, y) + cursors[#cursors + 1] = { code = code, x = x, y = y } +end + +local function cursorCodes(menu) + cursors = {} + menu:draw() + local out = {} + for _, g in ipairs(cursors) do + if g.code == Theme.cursor or g.code == Theme.cursorHollow then + out[#out + 1] = g.code + end + end + return out +end + +do + local menu = Menu.new(stubGame(), { + { label = "ONE" }, { label = "TWO" }, + }) + menu.index = 2 + eq(cursorCodes(menu)[1], Theme.cursor, "a plain Menu draws the filled arrow") + menu.hollowIndex = 1 + eq(cursorCodes(menu)[1], Theme.cursor, + "a hollow mark on another row leaves the cursor filled") + menu.hollowIndex = 2 + eq(cursorCodes(menu)[1], Theme.cursorHollow, + "hollowIndex on the cursor row draws the unfilled arrow") +end + +-- bills_pc.asm:176 +do + local game = stubGame() + local menu = BoxMenu.new(game) + game.stack:push(menu) + local changeBox + for i, item in ipairs(menu.items) do + if item.keepOpen then changeBox = changeBox or i end + end + check(changeBox, "the PC menu has keepOpen items") + menu.index = changeBox + menu.items[changeBox].onSelect() + eq(menu.hollowIndex, changeBox, + "picking a keepOpen item marks the parent cursor hollow") + eq(cursorCodes(menu)[1], Theme.cursorHollow, "and it draws hollow") + while game.stack:top() ~= menu do game.stack:pop() end + menu:update(1 / 60) + eq(menu.hollowIndex, nil, "back on top, the filled arrow returns") + eq(cursorCodes(menu)[1], Theme.cursor, "and it draws filled") +end + +Font.drawCode = realDrawCode + +-- engine/menus/save.asm:437 +local function upvalue(fn, name) + for i = 1, 60 do + local n, v = debug.getupvalue(fn, i) + if not n then return nil end + if n == name then return v end + end +end + +do + local changeBoxMenu = upvalue(upvalue(BoxMenu.new, "changeBox"), "changeBoxMenu") + check(type(changeBoxMenu) == "function", "found changeBoxMenu") + + local game = stubGame() + Boxes.ensure(game.save) + game.save.boxes[2] = { Pokemon.new(Data, "FIXMON_A", 5) } + changeBoxMenu(game) + local menu = game.stack:top() + check(menu, "the CHANGE BOX menu went up") + + local lines, circles, images = {}, 0, {} + local realDraw, realCircle = Font.draw, love.graphics.circle + local realImageDraw = love.graphics.draw + Font.draw = function(s) lines[#lines + 1] = s end + love.graphics.circle = function() circles = circles + 1 end + love.graphics.draw = function(img, _, x, y) + images[#images + 1] = { path = type(img) == "table" and img.path, x = x, y = y } + end + menu:draw() + Font.draw, love.graphics.circle, love.graphics.draw = + realDraw, realCircle, realImageDraw + + local joined = table.concat(lines, "|") + check(joined:find(" BOX.", 1, true), + "the ROM-free prompt is the two-tile ligature, not POKeMON") + check(not joined:find("POKéMON BOX.", 1, true), "and never the ASCII word") + check(not joined:find("{DONE}", 1, true), "with no terminator marker") + + eq(circles, 0, "the box marker is no longer a vector pokeball") + local balls = 0 + for _, img in ipairs(images) do + if img.path == "assets/generated/battle/balls.png" then + balls = balls + 1 + -- engine/menus/save.asm:488 + eq(img.x, 144, "the ball sits on tile column 18") + eq(img.y, 16, "on box 2's row") + end + end + eq(balls, 1, "one balls.2bpp tile 0, for the one stocked box") +end + +do + local strip = function(s) + return (s:gsub("{DONE}%s*$", ""):gsub("{PROMPT}%s*$", "")) + end + eq(strip("Choose a\n BOX.{DONE}"), "Choose a\n BOX.", + "the draw strips the terminator the extractor now writes") +end + +T.finish("bills pc change box bug2124") diff --git a/tests/engine/enemy_hud_after_cry_bug2118.lua b/tests/engine/enemy_hud_after_cry_bug2118.lua new file mode 100644 index 00000000..58495637 --- /dev/null +++ b/tests/engine/enemy_hud_after_cry_bug2118.lua @@ -0,0 +1,88 @@ +-- EnemySendOutFirstMon (engine/battle/core.asm:1432-1435) +-- then PlayCry -- which ends in WaitForSoundToFinish (home/pokemon.asm:145-149) +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local function newGame() + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 30) } + return { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } +end + +local function step(battle) + local row = table.remove(battle.queue, 1) + if row and row.fn then + battle.nextInsert = 0 + row.fn() + end + return row +end + +do + local game = newGame() + local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + battle.rng = function() return 0 end + battle.enemyParty = { Pokemon.new(Data, "FIXMON_B", 30), + Pokemon.new(Data, "FIXMON_C", 30) } + battle.enemyIndex = 1 + battle.queue, battle.nextInsert = {}, 0 + battle.enemyHudPending = nil + battle:executeAction(battle.enemy, battle.player, + { special = "aiSwitch", index = 2 }) + + local grew = false + for _ = 1, 40 do + if not battle.queue[1] then break end + step(battle) + if battle.growIn then grew = true break end + end + T.check(grew, "the AI switch queues AnimateSendingOutMon") + T.eq(battle.enemyHudPending, true, + "the enemy HUD stays down across PlayCry (core.asm:1434-1435)") + + T.check(battle.queue[1] and battle.queue[1].wait ~= nil, + "the grow-in hold is the next row") + step(battle) + T.check(battle.queue[1] and battle.queue[1].fn ~= nil, + "the cry act follows the grow-in hold") + step(battle) + T.check(battle.queue[1] and battle.queue[1].waitSound ~= nil, + "PlayCry's WaitForSoundToFinish row is queued") + T.eq(battle.enemyHudPending, true, "the HUD is still down during the cry") + step(battle) + T.check(battle.queue[1] and battle.queue[1].fn ~= nil, + "DrawEnemyHUDAndHPBar follows the wait row") + step(battle) + T.eq(battle.enemyHudPending, nil, + "the HUD comes up once the cry has finished") +end + +do + local game = newGame() + local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + battle.growIn = nil + battle.enemyHudPending = true + T.check(battle:growInScale(battle.enemy) == nil, + "the grow-in is over") + T.check(battle.enemyHudPending and true or false, + "drawHUDs' enemy gate is closed while the cry is outstanding") +end + +-- _InitBattleCommon (core.asm:6755-6763) +do + local game = newGame() + local battle = BattleState.newWild(game, "FIXMON_C", 30) + T.eq(battle.enemyHudPending, nil, + "the wild intro never gates the enemy HUD on a cry") +end + +T.finish("enemy HUD waits for the send-out cry (#2118)") diff --git a/tests/engine/ether_move_menu_2158.lua b/tests/engine/ether_move_menu_2158.lua new file mode 100644 index 00000000..4eab0839 --- /dev/null +++ b/tests/engine/ether_move_menu_2158.lua @@ -0,0 +1,145 @@ +-- (engine/battle/core.asm:2520) +-- luajit tests/engine/ether_move_menu_2158.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("ETHER move select window #2158") +local check, eq = S.check, S.eq + +local ItemEffects = require("src.inventory.ItemEffects") +local SaveData = require("src.core.SaveData") + +-- engine/items/item_effects.asm:2016 +for _, id in ipairs({ "ETHER", "MAX_ETHER", "ELIXER", "MAX_ELIXER", "PP_UP" }) do + eq(ItemEffects.keepsPartyMenuOpen(id), true, id .. " keeps the party menu open") +end + +local src = assert(io.open("src/ui/BagMenu.lua")):read("*a") +eq(src:find("Which move?", 1, true), nil, "BagMenu no longer builds a Which move? list") +check(src:find("MoveSelectMenu", 1, true) ~= nil, "BagMenu pushes MoveSelectMenu") +check(src:find("_RestorePPWhichTechniqueText", 1, true) ~= nil, + "and prints RestorePPWhichTechniqueText") +check(src:find("_RaisePPWhichTechniqueText", 1, true) ~= nil, + "and RaisePPWhichTechniqueText for PP UP") + +local draws +local FontStub = { + drawBox = function(tx, ty, tw, th) draws[#draws + 1] = { "box", tx, ty, tw, th } end, + draw = function(text, x, y) draws[#draws + 1] = { "text", text, x, y } end, + drawCode = function(code, x, y) draws[#draws + 1] = { "code", code, x, y } end, +} +package.loaded["src.render.Font"] = FontStub +local MoveSelectMenu = require("src.ui.MoveSelectMenu") + +local pressed = {} +local stack = {} +local game = { + input = { wasPressed = function(_, b) return pressed[b] == true end }, + stack = { + push = function(_, s) stack[#stack + 1] = s end, + pop = function() return table.remove(stack) end, + top = function() return stack[#stack] end, + }, + data = { moves = { SCRATCH = { name = "SCRATCH" }, EMBER = { name = "EMBER" } } }, +} +local mon = { moves = { { id = "SCRATCH", pp = 5 }, { id = "EMBER", pp = 25 } } } + +local chosen, cancelled +local menu = MoveSelectMenu.new(game, mon, "Restore PP of\nwhich technique?", + function(i) chosen = i end, function() cancelled = true end) +stack[1] = menu + +draws = {} +menu:draw() +-- engine/battle/core.asm:2526 +eq(draws[1][1], "box", "the move window is a bordered box") +eq(draws[1][2], 4, "at tile column 4") +eq(draws[1][3], 7, "tile row 7") +eq(draws[1][4], 16, "16 tiles wide") +eq(draws[1][5], 6, "6 tiles tall") +-- engine/battle/core.asm:2530 +eq(draws[2][2], "SCRATCH", "slot 1 name") +eq(draws[2][3], 48, "names start at x 48") +eq(draws[2][4], 64, "slot 1 on row 8") +eq(draws[3][4], 72, "slot 2 on row 9, single spaced") +-- engine/battle/misc.asm:37 +eq(draws[4][2], "-", "empty slot 3 is a dash") +eq(draws[5][2], "-", "empty slot 4 is a dash") +check(not tostring(draws[2][2]):find("5"), "no PP column on the move rows") +-- engine/battle/core.asm:2535 +eq(draws[6][1], "code", "cursor glyph") +eq(draws[6][3], 40, "cursor at tile column 5") +eq(draws[6][4], 64, "cursor starts on the first move") +-- engine/items/item_effects.asm:1979 +eq(draws[7][2], 0, "prompt box at column 0") +eq(draws[7][3], 12, "row 12") +eq(draws[8][2], "Restore PP of", "prompt line 1") +eq(draws[8][4], 112, "on row 14") +eq(draws[9][2], "which technique?", "prompt line 2") +eq(draws[9][4], 128, "on row 16") + +local terminated = MoveSelectMenu.new(game, mon, + "Restore PP of\nwhich technique?{DONE}", function() end) +draws = {} +terminated:draw() +eq(draws[9][2], "which technique?", "the {DONE} terminator is not drawn") + +-- engine/battle/core.asm:2692 +pressed = { down = true } +menu:update() +eq(menu.index, 2, "down moves to slot 2") +menu:update() +eq(menu.index, 1, "and wraps past the dash slots back to slot 1") +pressed = { up = true } +menu:update() +eq(menu.index, 2, "up wraps to the last real move") + +-- engine/items/item_effects.asm:1985 +pressed = { b = true } +menu:update() +eq(#stack, 0, "B pops the move window") +eq(cancelled, true, "back to the party menu") + +stack[1] = menu +menu.index = 2 +pressed = { a = true } +menu:update() +eq(chosen, 2, "A hands back the picked slot") +eq(#stack, 0, "and pops itself first") + +local empty, escaped = { moves = {} }, 0 +local emptyMenu = MoveSelectMenu.new(game, empty, "Restore PP of\nwhich technique?", + function() escaped = -1 end, function() escaped = escaped + 1 end) +stack[1] = emptyMenu +pressed = { down = true } +emptyMenu:update() +eq(#stack, 1, "an empty list ignores DOWN") +eq(emptyMenu.index, 1, "and leaves the cursor on slot 1") +pressed = { b = true } +emptyMenu:update() +eq(#stack, 0, "B pops the empty move window") +eq(escaped, 1, "and runs onCancel") + +stack[1] = emptyMenu +escaped = 0 +pressed = { a = true } +emptyMenu:update() +eq(#stack, 0, "A on an empty list cancels too") +eq(escaped, 1, "never reporting a picked slot") + +-- engine/items/item_effects.asm:2040 +local Data = { + items = { ETHER = { name = "ETHER" } }, + moves = { SCRATCH = { name = "SCRATCH", pp = 35 }, EMBER = { name = "EMBER", pp = 25 } }, + pokemon = { CHARMANDER = { name = "CHARMANDER" } }, + text = {}, +} +local save = SaveData.newGame() +local t = { species = "CHARMANDER", hp = 20, stats = { hp = 20 }, + moves = { { id = "SCRATCH", pp = 30 }, { id = "EMBER", pp = 5 } } } +eq((ItemEffects.use(Data, save, "ETHER", t, nil, 2)), "consumed", "ETHER on slot 2") +eq(t.moves[1].pp, 30, "slot 1 untouched") +eq(t.moves[2].pp, 15, "slot 2 gets +10") + +S.finish() diff --git a/tests/engine/evo_stone_cancel_bug883_test.lua b/tests/engine/evo_stone_cancel_bug883_test.lua index 83eac84e..8f75fbad 100644 --- a/tests/engine/evo_stone_cancel_bug883_test.lua +++ b/tests/engine/evo_stone_cancel_bug883_test.lua @@ -102,6 +102,20 @@ end local function isPicker(s) return getmetatable(s) == PartyMenu end +-- engine/menus/start_sub_menus.asm:414-416 +local function drainFlash(game) + local top = game.stack:top() + if not (type(top) == "table" and top.t ~= nil and top.frames ~= nil) then + return false + end + local n = 0 + while game.stack:top() == top and n < 240 do + top:update(1 / 60) + n = n + 1 + end + return true +end + local function rowFor(list, id) for i, r in ipairs(list.items) do if r.value == id then return i end @@ -176,6 +190,8 @@ do pushed.onDone() check(game.stack:top() ~= picker and not isPicker(game.stack:top()), "and the picker comes down when the evolution finishes (#2070)") + check(drainFlash(game), + "with GBPalWhiteOutWithDelay3 over the seam (#2125)") eq(game.stack:top(), list, "landing on the ITEM list, the way .useItem_partyMenu returns to " .. "StartMenu_Item (engine/menus/start_sub_menus.asm:419)") @@ -205,6 +221,8 @@ do game.stack:pop() box.done() end + check(drainFlash(game), + "the refusal whites out on the way back too (#2125)") eq(game.stack:top(), list, "closing the box drops the picker back to the ITEM list") end diff --git a/tests/engine/gen1_text_terminator_bug2116.lua b/tests/engine/gen1_text_terminator_bug2116.lua new file mode 100644 index 00000000..9cd99950 --- /dev/null +++ b/tests/engine/gen1_text_terminator_bug2116.lua @@ -0,0 +1,140 @@ +-- home/text.asm:209 PromptText, :221 DoneText; constants/charmap.asm:12 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Json = require("src.link.Json") +local Extractor = require("src.import.RomExtractor") +local TextBox = require("src.render.TextBox") +local Sound = require("src.core.Sound") +local romText = require("src.core.RomText") + +local presses = 0 +local realPress, realPlay = Sound.playPress, Sound.play +Sound.playPress = function() presses = presses + 1 return nil end +Sound.play = function() return nil end + +local function manifest(path) + local file = assert(io.open(path, "r")) + local data = assert(Json.decode(file:read("*a"))) + file:close() + return data +end + +local charmap = manifest("tools/rom_manifest.json").charmap +eq(charmap["74"], "", + "the manifest maps $4A to the two-tile ligature, not POKeMON") +eq(manifest("tools/rom_manifest_blue.json").charmap["74"], "", + "Blue's manifest too") +eq(manifest("tools/rom_manifest_yellow.json").charmap["74"], "", + "and Yellow's") + +local function decode(bytes) + local rom = {} + function rom:byte(_, address) return bytes[address] or 0x50 end + local extractor = setmetatable({ rom = rom, manifest = { charmap = charmap } }, + Extractor) + return extractor:decodeTextCommands( + { bank = 0, address = 0, name = "_Test" }, {}) +end + +local H, i = 0x87, 0xa8 +eq(decode({ [0] = 0x00, H, i, 0x57 }), "Hi{DONE}", "a `done` text carries {DONE}") +eq(decode({ [0] = 0x00, H, i, 0x58 }), "Hi{PROMPT}", + "a `prompt` text carries {PROMPT}") +-- data/text/text_3.asm:32 +eq(decode({ [0] = 0x00, H, i, 0x50, 0x50 }), "Hi{DONE}", + "a text_end text is a done too") +eq(decode({ [0] = 0x00, H, i, 0x5F }), "Hi", + " stays unmarked for DexEntryMenu") +eq(decode({ [0] = 0x50 }), "", "an empty text stays empty") +eq(decode({ [0] = 0x00, 0x4A, 0x57 }), "{DONE}", + "$4A decodes to the ligature, whole, not a {PK> entry is still a token") + +eq(TextBox.ending("Hi{DONE}"), "done", "TextBox reads {DONE}") +eq(TextBox.ending("Hi{PROMPT}"), "prompt", "and {PROMPT}") +eq(TextBox.ending("Hi"), nil, "and nothing for an untagged text") + +local function newGame() + local game = { + save = { player = {}, options = { textSpeed = "FAST" }, generation = 1 }, + data = { text = {} }, + } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + game.input = { + queue = {}, + wasPressed = function(self, btn) return self.queue[btn] or false end, + isDown = function(self, btn) return self.queue[btn] or false end, + } + return game +end + +local function finished(text, opts) + local game = newGame() + local box = TextBox.new(game, text, nil, opts) + game.stack:push(box) + local frames = 0 + while not box.done and frames < 600 do + box:update(1 / 60) + frames = frames + 1 + end + presses = 0 + game.input.queue.a = true + box:update(1 / 60) + return box, presses +end + +-- text/Route25.asm:153 +do + local box, beeps = finished("SEA COTTAGE\nBILL lives here!{DONE}") + eq(box.waitButton, true, "an extracted `done` text is a WaitButton box") + -- home/text_script.asm:96 -> home/joypad2.asm:71-72 + check(box:arrowVisible(), "and still blinks the arrow WaitForTextScrollButtonPress draws") + eq(select(2, box:arrowPos()), 128, "at hlcoord 18,16") + -- SFX_PRESS_AB lives only in ManualTextScroll, home/joypad2.asm:90-92 + eq(beeps, 0, "and closes silently") + eq(box.pages[1][1], "SEA COTTAGE", "the marker never reaches the glyphs") + eq(box.pages[1][2], "BILL lives here!", "on either line") +end + +do + local box, beeps = finished("SEA COTTAGE\nBILL lives here!{PROMPT}") + eq(box.waitButton, false, "an extracted `prompt` text blinks") + check(box:arrowVisible(), "and shows the cursor") + eq(beeps, 1, "and beeps on the press") + eq(box.pages[1][2], "BILL lives here!", "with the marker stripped") +end + +do + local box = finished("Hi") + check(box:arrowVisible(), "an untagged text keeps the blinking default") +end + +Sound.playPress, Sound.play = realPress, realPlay + +do + local pages = TextBox.paginate("Choose a\n BOX.{DONE}", 18) + eq(pages[1][1], "Choose a", "paginate drops the terminator") + eq(pages[1][2], " BOX.", "and keeps the ligature") + eq(TextBox.paginate("Take your time.{PROMPT}", 18)[1][1], "Take your time.", + "for {PROMPT} as well") +end + +do + local data = { text = { _PotionText = "{RAM:wcf4b} recovered\nby {NUM}!{DONE}" } } + eq(romText(data, "_PotionText", "fallback", "POLIWHIRL", 4), + "POLIWHIRL recovered\nby 4!{DONE}", + "RomText does not count the terminator as a slot") +end + +T.finish("gen1 text terminator bug2116") diff --git a/tests/engine/gen2_bump_walk_in_place_bug1978.lua b/tests/engine/gen2_bump_walk_in_place_bug1978.lua index 660566ed..c05e6a75 100644 --- a/tests/engine/gen2_bump_walk_in_place_bug1978.lua +++ b/tests/engine/gen2_bump_walk_in_place_bug1978.lua @@ -95,6 +95,15 @@ p:update() eq(p:walkPhase(), 0, "and .Standing drops to the standing pose on release") eq(p:drawFlip(), p.stepFlip, "with the sprite unmirrored again") +p = newPlayer() +p.animClock = 16 +p:tryMove("right", MAP, nil) +-- engine/overworld/player_movement.asm:807 +p:stopForEvent() +eq(p.bumpFrames, nil, "an event cancels the queued bump") +eq(p:walkPhase(), 0, "and the talk starts on the standing pose") +eq(p:drawFlip(), p.stepFlip, "unmirrored, whatever the clock read") + p = newPlayer() p:tryMove("right", MAP, nil) eq(p:tryMove("right", OPEN, nil), "moved", "an open tile steps") diff --git a/tests/engine/gen2_talk_cancels_bump_bug2170.lua b/tests/engine/gen2_talk_cancels_bump_bug2170.lua new file mode 100644 index 00000000..22103e2f --- /dev/null +++ b/tests/engine/gen2_talk_cancels_bump_bug2170.lua @@ -0,0 +1,77 @@ +-- engine/overworld/player_movement.asm:807-816 +-- engine/overworld/events.asm:494-510 +-- engine/overworld/map_objects.asm:1851-1860 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +love = require("tests.love_stub") + +local Player = require("src.world.gen2.Player") +local World = require("src.world.gen2.World") + +local MAP = { + inBounds = function(_, x, y) return x >= 0 and y >= 0 and x < 20 and y < 20 end, + isWalkable = function(_, x, y) return x == 5 and y == 5 end, +} + +local function bumpedPlayer() + local p = Player.new(5, 5, "right", nil) + p.turnArmed = false + p.animClock = 16 + p:tryMove("right", MAP, nil) + return p +end + +do + local p = bumpedPlayer() + eq(p.bumpFrames, 1, "the wall armed the in-place bump") + eq(p:walkPhase(), 1, "and this clock draws the walking pose") + p:stopForEvent() + eq(p.bumpFrames, nil, "stopForEvent drops the queued bump") + eq(p:walkPhase(), 0, "which snaps back to the standing pose") + eq(p:drawFlip(), p.stepFlip, "with the sprite unmirrored") +end + +do + local peopleTicked = false + local w = setmetatable({ + map = MAP, + player = bumpedPlayer(), + busy = function() return true end, + pollTimeOfDay = function() end, + pollCaveFlicker = function() end, + pollTileAnim = function() end, + handleCmdQueue = function() return false end, + updatePeople = function() peopleTicked = true end, + }, { __index = World }) + + eq(w.player:walkPhase(), 1, "the bump pose is live when the script starts") + w:stepBody() + check(peopleTicked, "the busy arm still ticks the map objects") + eq(w.player.bumpFrames, nil, "the held world cleared the bump") + eq(w.player:walkPhase(), 0, "so the talk draws the standing frame") +end + +do + local w = setmetatable({ + map = MAP, + player = Player.new(5, 5, "right", nil), + busy = function() return true end, + pollTimeOfDay = function() end, + pollCaveFlicker = function() end, + pollTileAnim = function() end, + handleCmdQueue = function() return false end, + updatePeople = function() end, + playerStepGrass = function() end, + grassAt = function() return false end, + }, { __index = World }) + w.player:scriptStep("right") + local before = w.player.progress + w:stepBody() + check(w.player.progress > before, "a scripted step keeps advancing") +end + +T.finish("gen2_talk_cancels_bump_bug2170") diff --git a/tests/engine/gen2_text_terminator_bug2045.lua b/tests/engine/gen2_text_terminator_bug2045.lua index 59ec4252..b78402db 100644 --- a/tests/engine/gen2_text_terminator_bug2045.lua +++ b/tests/engine/gen2_text_terminator_bug2045.lua @@ -54,7 +54,7 @@ eq(TextBox.ending("Hi"), nil, "and nothing for an untagged text") local function newGame() local game = { - save = { player = {}, options = { textSpeed = "FAST" } }, + save = { player = {}, options = { textSpeed = "FAST" }, generation = 2 }, data = { text = {} }, } game.stack = { diff --git a/tests/engine/gen2_waitbutton_arrow_bug2045.lua b/tests/engine/gen2_waitbutton_arrow_bug2045.lua index 09dc3c18..30932246 100644 --- a/tests/engine/gen2_waitbutton_arrow_bug2045.lua +++ b/tests/engine/gen2_waitbutton_arrow_bug2045.lua @@ -18,7 +18,7 @@ Sound.play = function() return nil end local function newGame() local game = { - save = { player = {}, options = { textSpeed = "FAST" } }, + save = { player = {}, options = { textSpeed = "FAST" }, generation = 2 }, data = { text = {} }, } game.stack = { diff --git a/tests/engine/mart_bag_full_bug2162.lua b/tests/engine/mart_bag_full_bug2162.lua new file mode 100644 index 00000000..009b1e1c --- /dev/null +++ b/tests/engine/mart_bag_full_bug2162.lua @@ -0,0 +1,182 @@ +-- engine/events/pokemart.asm:212-219 +-- engine/events/pokemart.asm:199-206 +-- luajit tests/engine/mart_bag_full_bug2162.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local function glyphs(text) + local n = 0 + for _ in tostring(text):gmatch("[%z\1-\127\194-\244][\128-\191]*") do + n = n + 1 + end + return n +end + +local realFont = package.loaded["src.render.Font"] +package.loaded["src.render.Font"] = { + BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 }, + draw = function() end, + drawCode = function() end, + drawBox = function() end, + width = function(text) return glyphs(text) * 8 end, + split = function(text) + local out = {} + for s in tostring(text):gmatch("[%z\1-\127\194-\244][\128-\191]*") do + out[#out + 1] = s + end + return out + end, + spansFitting = function(spans, pixels) + return math.min(#spans, math.floor(pixels / 8)) + end, + encode = function(text) return { text } end, + advanceOf = function() return 8 end, +} + +local RELOAD = { + "src.ui.QuantityBox", "src.ui.ShopMenu", "src.ui.ListMenu", + "src.ui.ChoiceBox", "src.ui.Menu", "src.ui.Theme", "src.render.TextBox", +} +for _, m in ipairs(RELOAD) do package.loaded[m] = nil end + +local Bag = require("src.inventory.Bag") +local ShopMenu = require("src.ui.ShopMenu") +local TextBox = require("src.render.TextBox") + +local BAG_FULL = "You can't carry\nany more items." +local NO_MONEY = "You don't have\nenough money." +local ANYTHING = "Is there anything\nelse I can do?" + +local function boxText(box) + local out = {} + for _, page in ipairs(box.pages or {}) do + for _, line in ipairs(page) do out[#out + 1] = line end + end + return table.concat(out, " ") +end + +local function newGame(money, ballCount) + local events, stack = {}, {} + local game + game = { + data = { + items = { POKE_BALL = { name = "POKe BALL", price = 200 } }, + text = { + _PokemartItemBagFullText = BAG_FULL, + _PokemartNotEnoughMoneyText = NO_MONEY, + _PokemartAnythingElseText = ANYTHING, + }, + constants = {}, + }, + save = { money = money, inventory = {}, bagOrder = {} }, + } + if ballCount then + game.save.inventory.POKE_BALL = ballCount + game.save.bagOrder = { "POKE_BALL" } + end + game.stack = { + push = function(_, s) + events[#events + 1] = { "push", s } + stack[#stack + 1] = s + end, + pop = function() + local s = table.remove(stack) + events[#events + 1] = { "pop", s } + return s + end, + top = function() return stack[#stack] end, + } + return game, events, stack +end + +local function buyOne(game, stack) + local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end) + menu.items[1].onSelect() + local list = stack[#stack] + list.onChoose({ value = "POKE_BALL" }) + local qty = stack[#stack] + game.stack:pop() + qty.onDone(1) + local confirm = stack[#stack] + game.stack:pop() + confirm.onChoose(true) + return menu, list +end + +do + local game = newGame(3000, 99) + eq(Bag.add(game.save, "POKE_BALL", 1, game.data), false, + "AddItemToInventory refuses a 100th POKe BALL") + eq(game.save.inventory.POKE_BALL, 99, "and adds nothing") +end + +for _, case in ipairs({ + { name = "bag full", money = 3000, count = 99, text = BAG_FULL }, + { name = "not enough money", money = 100, count = nil, text = NO_MONEY }, +}) do + local game, events, stack = newGame(case.money, case.count) + local menu, list = buyOne(game, stack) + local box = stack[#stack] + if check(getmetatable(box) == TextBox, case.name .. ": a text box is pushed") then + check(boxText(box):find(case.text:gsub("\n", " "), 1, true) ~= nil, + case.name .. ": the clerk's refusal is the text") + end + eq(stack[#stack - 1], list, case.name .. ": the list is still under the box") + local popsBefore = 0 + for _, ev in ipairs(events) do + if ev[1] == "push" and ev[2] == box then break end + if ev[1] == "pop" and ev[2] == list then popsBefore = popsBefore + 1 end + end + eq(popsBefore, 0, + case.name .. ": PrintText runs before LoadScreenTilesFromBuffer1") + eq(game.save.money, case.money, case.name .. ": no money changes hands") + + game.stack:pop() + box.onDone() + eq(stack[#stack], nil, case.name .. ": A closes the list") + eq(menu.footer, ANYTHING, case.name .. ": the mart menu asks again") + eq(game.save.money, case.money, case.name .. ": money is still untouched") +end + +do + -- pokemart.asm:113 + local game, events, stack = newGame(3000) + game.data.items.BICYCLE = { name = "BICYCLE", price = 0, keyItem = true } + game.data.text._PokemartUnsellableItemText = "I can't put a\nprice on that." + game.save.inventory.BICYCLE = 1 + game.save.bagOrder = { "BICYCLE" } + local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end) + menu.items[2].onSelect() + local list = stack[#stack] + list.onChoose({ value = "BICYCLE" }) + local box = stack[#stack] + check(getmetatable(box) == TextBox, "unsellable: a text box is pushed") + eq(stack[#stack - 1], list, "unsellable: the sell list is still under it") + game.stack:pop() + box.onDone() + eq(stack[#stack], nil, "unsellable: A closes the sell list") + eq(menu.footer, ANYTHING, "unsellable: the mart menu asks again") +end + +do + -- the bag-empty refusal has no list of its own (pokemart.asm:50) + local game, _, stack = newGame(3000) + local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end) + game.data.text._PokemartItemBagEmptyText = "You don't have\nanything to sell." + menu.items[2].onSelect() + local box = stack[#stack] + check(getmetatable(box) == TextBox, "empty bag: only a text box is pushed") + game.stack:pop() + box.onDone() + eq(menu.footer, ANYTHING, "empty bag: the mart menu asks again") +end + +package.loaded["src.render.Font"] = realFont +for _, m in ipairs(RELOAD) do package.loaded[m] = nil end +require("src.ui.Screens").invalidate() + +T.finish() diff --git a/tests/engine/npc_stay_turns_bug2117.lua b/tests/engine/npc_stay_turns_bug2117.lua new file mode 100644 index 00000000..f6997620 --- /dev/null +++ b/tests/engine/npc_stay_turns_bug2117.lua @@ -0,0 +1,110 @@ +-- (engine/overworld/movement.asm:151) +-- is)" (engine/overworld/movement.asm:606 +-- luajit tests/engine/npc_stay_turns_bug2117.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +love = require("tests.love_stub") + +local NPC = require("src.world.NPC") + +local DATA = { + sprites = { + SPRITE_TEST_NPC = { image = "fixture_npc.png", frames = 6, walker = true }, + }, +} + +local MAP = { + def = { tileset = "HOUSE" }, + inBounds = function() return true end, + isWalkableCell = function() return true end, + isWaterCell = function() return false end, + cellTile = function() return 0 end, + warpAtCell = function() return nil end, +} + +local function newNpc(movement, range) + return NPC.new(DATA, "TEST_MAP", { + index = 1, x = 5, y = 5, sprite = "SPRITE_TEST_NPC", + movement = movement, range = range, + }) +end + +local function run(npc, ticks) + local seen, moved, everMoving = {}, false, false + for _ = 1, ticks do + npc:update(MAP, { npc }) + seen[npc.facing] = true + if npc.moving then everMoving = true end + if npc.cellX ~= 5 or npc.cellY ~= 5 then moved = true end + end + local n = 0 + for _ in pairs(seen) do n = n + 1 end + return seen, n, moved, everMoving +end + +do + math.randomseed(2117) + local npc = newNpc("STAY", "NONE") + check(npc.wanders, "STAY, NONE rolls a facing") + check(not npc.steps, "STAY, NONE never steps") + local _, n, moved, everMoving = run(npc, 4000) + check(n >= 2, "STAY, NONE takes at least two distinct facings") + check(not moved, "STAY, NONE never leaves its spawn cell") + check(not everMoving, "STAY, NONE never enters a step") +end + +-- ---- byte 2 DOWN pins the roll (.determineDirection, movement.asm:186) +do + math.randomseed(2117) + local npc = newNpc("STAY", "DOWN") + check(not npc.wanders, "STAY, DOWN is pinned") + eq(npc.facing, "down", "STAY, DOWN spawns facing down") + local _, n = run(npc, 2000) + eq(n, 1, "STAY, DOWN never turns") + eq(npc.facing, "down", "STAY, DOWN stays facing down") +end + +do + math.randomseed(2117) + local npc = newNpc("STAY", "UP_DOWN") + local seen, n = run(npc, 4000) + eq(n, 2, "STAY, UP_DOWN takes both axis facings") + check(seen.up and seen.down, "STAY, UP_DOWN only faces up or down") + check(not (seen.left or seen.right), "STAY, UP_DOWN never faces sideways") +end + +do + local npc = newNpc("STAY", "BOULDER_MOVEMENT_BYTE_2") + check(not npc.wanders, "Strength boulders keep their pin") +end + +do + math.randomseed(2117) + local npc = newNpc("WALK", "LEFT_RIGHT") + check(npc.steps and npc.wanders, "WALK, LEFT_RIGHT still wanders") + npc.facing = "left" -- FACING_FROM_RANGE spawns an axis object facing down + local seen, _, moved = run(npc, 4000) + check(moved, "WALK, LEFT_RIGHT still takes steps") + check(not (seen.up or seen.down), "WALK, LEFT_RIGHT stays on its axis") +end + +do + math.randomseed(2117) + local npc = newNpc("STAY", "NONE") + npc.wanders = false + local _, n = run(npc, 2000) + eq(n, 1, "npc.wanders = false still freezes the facing") +end + +do + math.randomseed(2117) + local npc = newNpc("STAY", "NONE") + npc.frozen = true + local _, n = run(npc, 2000) + eq(n, 1, "a frozen NPC still holds its facing") +end + +T.finish("npc stay turns bug2117") diff --git a/tests/engine/overworld_world_palette_seam_bug2152.lua b/tests/engine/overworld_world_palette_seam_bug2152.lua new file mode 100644 index 00000000..dd83584f --- /dev/null +++ b/tests/engine/overworld_world_palette_seam_bug2152.lua @@ -0,0 +1,33 @@ +-- (engine/gfx/palettes.asm:178) +-- luajit tests/engine/overworld_world_palette_seam_bug2152.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("world palette seam #2152") +local check, eq = S.check, S.eq + +local OW = require("src.world.OverworldController") +local Zoom = require("src.render.Zoom") + +local saved = Zoom.offset + +Zoom.offset = 0 +eq(OW.perMapWorldPalettes(), false, + "FIT publishes the whole-screen block the hardware does") + +Zoom.offset = 1 +eq(OW.perMapWorldPalettes(), false, "a close-up view is whole-screen too") + +Zoom.offset = -1 +eq(OW.perMapWorldPalettes(), true, "one step of survey zoom keeps per-area colour") + +Zoom.offset = -3 +eq(OW.perMapWorldPalettes(), true, "so does a far survey zoom") + +Zoom.offset = nil +eq(OW.perMapWorldPalettes(), false, "an unset zoom reads as FIT") + +Zoom.offset = saved + +S.finish() diff --git a/tests/engine/pokedex_entry_layout_bug1341.lua b/tests/engine/pokedex_entry_layout_bug1341.lua index f208bdb3..213b6809 100644 --- a/tests/engine/pokedex_entry_layout_bug1341.lua +++ b/tests/engine/pokedex_entry_layout_bug1341.lua @@ -66,8 +66,11 @@ ns:draw() check(hasText("BULBASAUR", 72, 16), "name at (72,16), not the port's old (72,8)") check(hasText("SEED POKEMON", 72, 32), "kind at (72,32)") check(hasText("No.001", 16, 64), "dex number under the pic, at (16,64)") -check(hasText("HT 2\226\128\178" .. "04\226\128\179", 72, 48), "HT at (72,48)") -check(hasText("WT 6.9lb", 72, 64), "WT at (72,64)") +check(hasText("HT", 72, 48), "HT label at (72,48)") +check(hasText(" 2", 96, 48), "feet in cols 12-13") +check(hasText("04", 120, 48), "inches in cols 15-16") +check(hasText("WT", 72, 64), "WT label at (72,64)") +check(hasText(" 6.9", 88, 64), "weight right-aligned into cols 11-16") check(hasText("A strange seed was", 8, 88), "page 1 line 1 at y=88 (row 11)") check(hasText("planted on its", 8, 104), "page 1 line 2 at y=104") check(hasText("back at birth", 8, 120), "page 1 line 3, unmodified: not the last page") diff --git a/tests/engine/pokedex_seen_placeholders_bug2163.lua b/tests/engine/pokedex_seen_placeholders_bug2163.lua new file mode 100644 index 00000000..6e41f163 --- /dev/null +++ b/tests/engine/pokedex_seen_placeholders_bug2163.lua @@ -0,0 +1,95 @@ +-- engine/menus/pokedex.asm:449 +-- luajit tests/engine/pokedex_seen_placeholders_bug2163.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +love = love or require("tests.love_stub") + +local calls = {} +local realFont = package.loaded["src.render.Font"] +package.loaded["src.render.Font"] = { + draw = function(text, x, y) calls[#calls + 1] = { text = text, x = x, y = y } end, + drawCode = function(code, x, y) calls[#calls + 1] = { code = code, x = x, y = y } end, +} + +package.loaded["src.ui.DexEntryMenu"] = nil +local DexEntryMenu = require("src.ui.DexEntryMenu") + +local function hasText(text, x, y) + for _, c in ipairs(calls) do + if c.text == text and c.x == x and c.y == y then return true end + end + return false +end +local function anyBelow(y) + for _, c in ipairs(calls) do + if c.y and c.y >= y then return true end + end + return false +end + +local def = { + id = "SNORLAX", + name = "SNORLAX", + dex = 143, + dexEntry = { + kind = "SLEEPING POKEMON", + heightFt = 6, heightIn = 11, weight = 10140, + text = "_SnorlaxDexEntry", + }, +} + +local game = { + data = { + pokemon = { SNORLAX = def }, + text = { _SnorlaxDexEntry = "Very lazy. Just\neats and sleeps" }, + constants = { dexDigits = 3 }, + }, + save = { pokedex = { owned = {}, seen = { SNORLAX = true } } }, +} + +local function skeleton(label) + check(hasText("HT", 72, 48), label .. ": HT label in col 9") + check(hasText("′", 112, 48), label .. ": foot mark in col 14") + check(hasText("″", 136, 48), label .. ": inch mark in col 17") + check(hasText("WT", 72, 64), label .. ": WT label in col 9") + check(hasText("lb", 136, 64), label .. ": lb unit in cols 17-18") +end + +calls = {} +DexEntryMenu.render(game, def, nil, false, false, 1, {}) +skeleton("seen") +check(hasText("?", 104, 48), "seen: the feet placeholder sits in col 13") +check(hasText("??", 120, 48), "seen: the inches placeholder sits in cols 15-16") +check(hasText("???", 112, 64), "seen: the weight placeholder sits in cols 14-16") +check(not hasText("Data unknown.", 8, 88), + "seen: no invented substitute message") +check(not anyBelow(88), "seen: nothing at all below the divider") + +calls = {} +DexEntryMenu.render(game, def, nil, false, false, 1, { crying = true }) +skeleton("crying") +check(hasText("?", 104, 48), "crying: the placeholders are already up") + +game.save.pokedex.owned.SNORLAX = true +calls = {} +DexEntryMenu.render(game, def, nil, false, false, 1, {}) +skeleton("owned") +check(hasText(" 6", 96, 48), "owned: feet in cols 12-13") +check(hasText("11", 120, 48), "owned: inches in cols 15-16") +check(hasText("1014.0", 88, 64), "owned: weight right-aligned into cols 11-16") +check(not hasText("?", 104, 48), "owned: no leftover placeholder digits") +check(hasText("Very lazy. Just", 8, 88), "owned: the description prints") + +game.save.pokedex.owned.SNORLAX = nil +calls = {} +DexEntryMenu.render(game, def, nil, true, false, 1, {}) +check(hasText(" 6", 96, 48), "forceOwned: feet still print") +check(not hasText("???", 112, 64), "forceOwned: no weight placeholder") + +package.loaded["src.render.Font"] = realFont +package.loaded["src.ui.DexEntryMenu"] = nil + +T.finish("pokedex seen placeholders bug 2163") diff --git a/tests/engine/pp_restore_jingle_menu_2155.lua b/tests/engine/pp_restore_jingle_menu_2155.lua new file mode 100644 index 00000000..fa66bcef --- /dev/null +++ b/tests/engine/pp_restore_jingle_menu_2155.lua @@ -0,0 +1,62 @@ +-- the still-drawn party menu (engine/items/item_effects.asm:2022-2039) +-- luajit tests/engine/pp_restore_jingle_menu_2155.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("ELIXER PP restore jingle + party menu #2155") +local check, eq = S.check, S.eq + +local ItemEffects = require("src.inventory.ItemEffects") +local SaveData = require("src.core.SaveData") + +local Data = { + items = { + ELIXER = { name = "ELIXER" }, MAX_ELIXER = { name = "MAX ELIXER" }, + ETHER = { name = "ETHER" }, MAX_ETHER = { name = "MAX ETHER" }, + }, + moves = { TACKLE = { name = "TACKLE", pp = 35 }, GROWL = { name = "GROWL", pp = 40 } }, + pokemon = { CHARMANDER = { name = "CHARMANDER" } }, + text = {}, +} + +local save = SaveData.newGame() + +local function mon(pp1, pp2) + return { + species = "CHARMANDER", hp = 20, stats = { hp = 20 }, + moves = { { id = "TACKLE", pp = pp1 }, { id = "GROWL", pp = pp2 } }, + } +end + +-- engine/items/item_effects.asm:2087 +local target = mon(5, 5) +local result, msgs, extra = ItemEffects.use(Data, save, "ELIXER", target) +eq(result, "consumed", "a partly-drained mon consumes the ELIXER") +eq(target.moves[1].pp, 15, "every slot gets +10") +eq(target.moves[2].pp, 15, "including slot 2") +-- engine/items/item_effects.asm:2035 +check(extra ~= nil and extra.useJingle == true, + "the PP-restored line carries the Heal_Ailment jingle") +check(msgs[1]:find("PP") ~= nil, "and prints the PP restored line") + +local single = mon(5, 40) +local eResult, _, eExtra = ItemEffects.use(Data, save, "ETHER", single, nil, 1) +eq(eResult, "consumed", "ETHER restores the picked move") +eq(single.moves[1].pp, 15, "slot 1 only") +eq(single.moves[2].pp, 40, "slot 2 untouched") +check(eExtra ~= nil and eExtra.useJingle == true, "ETHER carries the jingle too") + +-- engine/items/item_effects.asm:2022 +for _, id in ipairs({ "ELIXER", "MAX_ELIXER" }) do + eq(ItemEffects.keepsPartyMenuOpen(id), true, + id .. " keeps the party menu drawn through the message") + check(ItemEffects.healsHP(id) ~= true, id .. " does not animate the HP bar") +end + +-- engine/items/item_effects.asm:2120 +local full = mon(35, 40) +eq((ItemEffects.use(Data, save, "MAX_ELIXER", full)), "failed", + "a full-PP mon takes the .noEffect arm") + +S.finish() diff --git a/tests/engine/rare_candy_bag_open_bug796.lua b/tests/engine/rare_candy_bag_open_bug796.lua index 9510c237..f0641a16 100644 --- a/tests/engine/rare_candy_bag_open_bug796.lua +++ b/tests/engine/rare_candy_bag_open_bug796.lua @@ -37,6 +37,11 @@ package.loaded["src.core.Sound"] = { local jingles = {} package.loaded["src.render.TextBox"] = { new = function(_, text, done) return { textBox = true, text = text, done = done } end, + -- constants/charmap.asm:19-20 + strip = function(text) + if type(text) ~= "string" then return text end + return (text:gsub("{DONE}", ""):gsub("{PROMPT}", "")) + end, soundOpts = function(_, sound, opts) jingles[#jingles + 1] = sound opts = opts or {} diff --git a/tests/engine/rby_translation_runtime_test.lua b/tests/engine/rby_translation_runtime_test.lua index 332f5314..478a06e1 100644 --- a/tests/engine/rby_translation_runtime_test.lua +++ b/tests/engine/rby_translation_runtime_test.lua @@ -267,7 +267,7 @@ end local harvestCalls = { ["src/ui/BagMenu.lua"] = { - 'Strings.source("Which move?")', 'Strings.source("ITEMS")', + 'Strings.source("ITEMS")', }, ["src/ui/BoxMenu.lua"] = { 'Strings("WITHDRAW")', 'Strings("DEPOSIT")', diff --git a/tests/engine/route24_rocket_bug1550.lua b/tests/engine/route24_rocket_bug1550.lua index 25ed8034..c90adbde 100644 --- a/tests/engine/route24_rocket_bug1550.lua +++ b/tests/engine/route24_rocket_bug1550.lua @@ -84,4 +84,46 @@ engaged.onDone() T.eq(#pushed, 0, "a loss prints nothing") T.eq(doneCalls, 1, "and just unfreezes the player") -T.finish("Nugget Bridge Rocket battle text (#1550, #1551)") +-- the prize arm: scripts/Route24.asm:149-158 +local GameVersion = require("src.core.GameVersion") +local savedVersion = GameVersion.current + +local function runPrizeArm(version) + GameVersion.current = version + pushed, defeated, doneCalls = {}, false, 0 + game.save = SaveData.newGame() + game.save.flags.EVENT_GOT_NUGGET = nil + handler(game, ow, npc, function() doneCalls = doneCalls + 1 end) + return pushed +end + +local p = runPrizeArm("red") +T.eq(p[1].text, Data.text._Route24CooltrainerM1YouBeatOurContestText, + "the contest line is its own box") +T.eq(p[1].opts and p[1].opts.auto and p[1].opts.auto.sound, "Get_Item1", + "and it carries sound_get_item_1 (#2156)") +p[1].onDone() +T.eq(p[2].text, Data.text._Route24CooltrainerM1YouJustEarnedAPrizeText, + "the prize line is the second box") +T.eq(p[2].opts, nil, "with no jingle of its own (#2156)") +p[2].onDone() +T.eq(p[3].text, Data.text._Route24CooltrainerM1ReceivedNuggetText, + "then the receipt box") +T.eq(p[3].opts and p[3].opts.auto and p[3].opts.auto.sound, "Get_Item1", + "which plays sound_get_item_1 on Red") +p[3].onDone() +T.eq(p[4].text, Data.text._Route24CooltrainerM1JoinTeamRocketText, + "then the Team Rocket pitch") + +local y = runPrizeArm("yellow") +T.eq(y[1].opts and y[1].opts.auto and y[1].opts.auto.sound, "Get_Item1", + "Yellow arms the contest box the same way") +y[1].onDone() +T.eq(y[2].opts, nil, "and leaves the prize box silent") +y[2].onDone() +T.eq(y[3].opts and y[3].opts.auto and y[3].opts.auto.sound, "Get_Key_Item", + "the receipt box plays sound_get_key_item on Yellow (#2156)") + +GameVersion.current = savedVersion + +T.finish("Nugget Bridge Rocket battle text (#1550, #1551, #2156)") diff --git a/tests/engine/status_penalty_baked_bug2148.lua b/tests/engine/status_penalty_baked_bug2148.lua new file mode 100644 index 00000000..4b4c2ab1 --- /dev/null +++ b/tests/engine/status_penalty_baked_bug2148.lua @@ -0,0 +1,119 @@ +-- QuarterSpeedDueToParalysis (pokered engine/battle/core.asm:6283) +-- pokered engine/battle/effects.asm:414-415 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) +local MoveEffects = require("src.battle.MoveEffects") +local Status = require("src.battle.Status") +local StatusRegistry = require("src.battle.StatusRegistry") +local TurnOrder = require("src.battle.TurnOrder") +local TrainerAI = require("src.battle.TrainerAI") +local faithful = require("src.battle.rulesets.gen1_faithful") +local modern = require("src.battle.rulesets.modern_clean") + +local function mon(status) + return { + name = "MON", stages = {}, + curStats = { speed = 100, attack = 100, defense = 50, special = 50, hp = 100 }, + curTypes = { "NORMAL" }, + mon = { status = status, level = 50, stats = { hp = 100 } }, + } +end + +local function arena(ruleset, player, enemy) + return { data = Data, ruleset = ruleset, player = player, enemy = enemy } +end + +-- a fresh send-out carries exactly one application (core.asm:1658) +T.eq(TurnOrder.effectiveSpeed(mon("PAR")), 25, "paralysis quarters speed once") + +do + local p, e = mon("PAR"), mon(nil) + MoveEffects.primary.SPEED_UP2_EFFECT(arena(faithful, p, e), p, e) + T.eq(TurnOrder.effectiveSpeed(p), 200, "paralysis then AGILITY wipes the quarter") +end + +do + local p, e = mon(nil), mon(nil) + local B = arena(faithful, p, e) + MoveEffects.primary.SPEED_UP2_EFFECT(B, p, e) + StatusRegistry.inflict(B, p, "PAR", {}) + T.eq(TurnOrder.effectiveSpeed(p), 50, "AGILITY then paralysis quarters the boost") +end + +do + local p, e = mon(nil), mon("PAR") + MoveEffects.primary.ATTACK_DOWN1_EFFECT(arena(faithful, p, e), p, e) + T.eq(TurnOrder.effectiveSpeed(e), 6, "paralysis + GROWL quarters twice (100 -> 6)") + T.eq(Status.penaltyStacks(e, "speed"), 2, "two applications are baked in") +end + +do + local p, e = mon(nil), mon("PAR") + MoveEffects.primary.ACCURACY_DOWN1_EFFECT(arena(faithful, p, e), p, e) + T.eq(Status.penaltyStacks(e, "speed"), 2, + "an accuracy drop still re-applies the quarter to the non-user") +end + +do + local p, e = mon("PAR"), mon(nil) + local B = arena(faithful, p, e) + MoveEffects.primary.SPEED_UP2_EFFECT(B, p, e) + MoveEffects.primary.HAZE_EFFECT(B, p, e) + T.eq(TurnOrder.effectiveSpeed(p), 100, "Haze lifts the quarter") + MoveEffects.primary.ATTACK_UP1_EFFECT(B, p, e) + T.eq(TurnOrder.effectiveSpeed(p), 100, "and a later stage change does not re-arm it") +end + +do + local p, e = mon("PAR"), mon(nil) + MoveEffects.primary.SPEED_UP2_EFFECT(arena(modern, p, e), p, e) + T.eq(p.statusPenaltyStacks, nil, "modern_clean stays stateless") + T.eq(TurnOrder.effectiveSpeed(p), 50, "and quarters the boosted speed") +end + +-- (pokered engine/battle/move_effects/paralyze.asm:35) +do + local p, e = mon(nil), mon(nil) + local B = arena(faithful, p, e) + MoveEffects.primary.HAZE_EFFECT(B, p, e) + T.eq(p.statusPenaltyStacks, nil, "HAZE leaves an untouched mon stateless") + StatusRegistry.inflict(B, p, "PAR", {}) + T.eq(Status.penaltyStacks(p, "speed"), 1, "a post-HAZE status still bakes its penalty") + T.eq(TurnOrder.effectiveSpeed(p), 25, "and quarters the speed (100 -> 25)") +end + +-- an AI X ITEM goes through StatModifierUpEffect (trainer_ai.asm:719) +do + local p, e = mon("PAR"), mon(nil) + local B = arena(faithful, p, e) + B.trainer = { name = "LASS" } + Data.items.X_ATTACK = Data.items.X_ATTACK or { id = "X_ATTACK", name = "X ATTACK" } + TrainerAI.useItem(B, "X_ATTACK") + T.eq(Status.penaltyStacks(p, "speed"), 2, "the AI's stat change re-applies the quarter") + T.eq(TurnOrder.effectiveSpeed(p), 6, "player speed 100 -> 6") +end + +do + local bare = { name = "fix_unset" } + T.eq(Status.rulesetBakes(bare), true, "an unset statusPenaltyIsBaked bakes") + T.eq(Status.rulesetBakes(modern), false, "an explicit false does not") + T.eq(Status.rulesetBakes(nil), false, "and no ruleset at all stays stateless") + local p, e = mon(nil), mon(nil) + local B = arena(bare, p, e) + MoveEffects.primary.SPEED_UP2_EFFECT(B, p, e) + StatusRegistry.inflict(B, p, "PAR", {}) + T.eq(TurnOrder.effectiveSpeed(p), 50, "so a bare mod ruleset matches gen1_faithful") +end + +-- the burn half of the same seam (core.asm:6326, effects.asm:697-698) +do + local p, e = mon(nil), mon("BRN") + MoveEffects.primary.DEFENSE_DOWN2_EFFECT(arena(faithful, p, e), p, e) + T.eq(Status.penaltyStacks(e, "attack"), 2, "burn + SCREECH quarters the attack") +end + +T.finish("the status stat penalty is baked, not recomputed (#2148)") diff --git a/tests/engine/textbox_arrow_bug2084.lua b/tests/engine/textbox_arrow_bug2084.lua index 8c21611f..074725b4 100644 --- a/tests/engine/textbox_arrow_bug2084.lua +++ b/tests/engine/textbox_arrow_bug2084.lua @@ -47,7 +47,10 @@ red.shown = {} local g = arrowAt(red, 0) check(g, "gen1 arrow draws on the on phase") eq(g.x, 144, "gen1 arrow x") -eq(g.y, 132, "gen1 arrow keeps its straddled row") +-- home/text.asm:214 +eq(g.y, 128, "gen1 arrow sits on the second text row, tile row 16") +eq(g.y, red.line2Y, "gen1 arrow shares the second line's row") +eq(select(2, red:arrowPos()), 128, "arrowPos agrees with the draw") check(arrowAt(red, 29), "gen1 on through frame 29") eq(arrowAt(red, 30), nil, "gen1 off from frame 30") eq(arrowAt(red, 59), nil, "gen1 off through frame 59") @@ -62,6 +65,7 @@ g = arrowAt(gold, 0) check(g, "gold arrow draws on the on phase") eq(g.x, 144, "gold arrow x is tile column 18") eq(g.y, 136, "gold arrow sits on the border row, tile row 17") +eq(select(2, gold:arrowPos()), 136, "gold arrowPos is unchanged") -- pokegold home/joypad.asm:430 check(arrowAt(gold, 15), "gold on through frame 15") eq(arrowAt(gold, 16), nil, "gold off from frame 16") diff --git a/tests/engine/warp_fade_palette_2180.lua b/tests/engine/warp_fade_palette_2180.lua new file mode 100644 index 00000000..f64ce4ca --- /dev/null +++ b/tests/engine/warp_fade_palette_2180.lua @@ -0,0 +1,104 @@ +-- (pokeyellow home/fade.asm:74-81, engine/gfx/palettes.asm:799) +-- luajit tests/engine/warp_fade_palette_2180.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("warp fade palette #2180") +local check, eq = S.check, S.eq + +local Timing = require("src.core.Timing") +local Transition = require("src.render.Transition") +local PaletteFX = require("src.render.PaletteFX") + +local noData = { data = { transitions = {} } } + +-- home/fade.asm:49 +do + local fade = Transition.new(noData, nil, nil, true) + fade.phase = "out" + local want = { { 0, 0xE4 }, { 7, 0xE4 }, { 8, 0xF9 }, { 15, 0xF9 }, + { 16, 0xFE }, { 23, 0xFE }, { 24, 0xFF }, { 31, 0xFF } } + for _, w in ipairs(want) do + fade.t = w[1] + eq(fade:bgp(), w[2], + ("black fade out frame %d writes its FadePal byte"):format(w[1])) + end +end + +-- home/fade.asm:24 +do + local fade = Transition.new(noData, nil, nil, false, { framesIn = 32 }) + fade.phase = "in" + local want = { { 0, 0xFF }, { 8, 0xFE }, { 16, 0xF9 }, { 24, 0xE4 } } + for _, w in ipairs(want) do + fade.t = w[1] + eq(fade:bgp(), w[2], + ("black fade in frame %d writes its FadePal byte"):format(w[1])) + end +end + +-- pokeyellow home/fade.asm:29 +do + local out = Transition.new(noData, nil, nil, false, { + color = { 1, 1, 1 }, + frames = Timing.FADE_OUT_TO_WHITE, + framesIn = Timing.FADE_IN_FROM_WHITE, + }) + out.phase = "out" + local wantOut = { { 0, 0x90 }, { 8, 0x40 }, { 16, 0x00 }, { 23, 0x00 } } + for _, w in ipairs(wantOut) do + out.t = w[1] + eq(out:bgp(), w[2], + ("white fade out frame %d writes FadePal6..8"):format(w[1])) + end + out.phase = "in" + local wantIn = { { 0, 0x40 }, { 8, 0x90 }, { 16, 0xE4 } } + for _, w in ipairs(wantIn) do + out.t = w[1] + eq(out:bgp(), w[2], + ("white fade in frame %d writes FadePal7..5"):format(w[1])) + end +end + +do + eq(Transition.shadeMapFor(0xE4), nil, "FadePal4 is the identity, so no map") + local m = Transition.shadeMapFor(0xF9) + eq(m[0], 1, "FadePal3 shows colour 0 as colour 1") + eq(m[1], 2, "FadePal3 shows colour 1 as colour 2") + eq(m[2], 3, "FadePal3 shows colour 2 as colour 3") + eq(m[3], 3, "FadePal3 shows colour 3 as colour 3") + local m2 = Transition.shadeMapFor(0xFE) + eq(m2[0], 2, "FadePal2 shows colour 0 as colour 2") + eq(m2[3], 3, "FadePal2 shows colour 3 as colour 3") +end + +do + local PAL_ROUTE = { { 255, 255, 255 }, { 132, 255, 33 }, + { 90, 189, 255 }, { 24, 24, 24 } } + local stepped = PaletteFX.permute(PAL_ROUTE, Transition.shadeMapFor(0xF9)) + eq(stepped[1][1], 132, "FadePal3 turns the white sky lime green, not grey") + eq(stepped[1][2], 255, "FadePal3's sky keeps the palette's own green") + eq(stepped[2][3], 255, "FadePal3 turns the grass sky blue") + local stepped2 = PaletteFX.permute(PAL_ROUTE, Transition.shadeMapFor(0xFE)) + eq(stepped2[1][3], 255, "FadePal2 turns the sky the palette's blue") + eq(stepped2[4][1], 24, "FadePal1's floor is the palette's near-black") +end + +do + local painted = 0 + local savedRect = love.graphics.rectangle + love.graphics.rectangle = function() painted = painted + 1 end + local fade = Transition.new({ data = { transitions = {} } }, nil, nil, true) + fade.phase = "out" + fade.t = 16 + fade.paletteStepped = true + fade:draw() + eq(painted, 0, "a palette-stepped frame paints no veil") + eq(fade.paletteStepped, false, "the flag clears so the veil can come back") + fade:draw() + eq(painted, 1, "an unconsumed frame falls back to the veil") + love.graphics.rectangle = savedRect +end + +S.finish() diff --git a/tests/engine/yellow_gifts_gotmon_2159.lua b/tests/engine/yellow_gifts_gotmon_2159.lua new file mode 100644 index 00000000..d9b9cf88 --- /dev/null +++ b/tests/engine/yellow_gifts_gotmon_2159.lua @@ -0,0 +1,44 @@ +-- AskName: engine/events/give_pokemon.asm:45-46 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local scripts = dofile("data/scripts/yellow_gifts.lua") + +local function giveRow(rows) + for _, row in ipairs(rows) do + if row[1] == "give_pokemon" then return row end + end +end + +local function drive(handler, save) + local captured + local ow = { runner = { run = function(_, rows) captured = rows end } } + handler({ save = save }, ow, { id = "npc" }, function() end) + T.check(captured ~= nil, "the handler builds a row list") + return captured +end + +local function assertGift(row, species) + T.check(row ~= nil, species .. " is still given") + T.eq(row[2], species, "the gift is " .. species) + T.check(row[4] ~= true, + species .. " still asks for a nickname (add_mon.asm:52)") + T.eq(row[5], true, + species .. " prints GotMonText + the jingle (give_pokemon.asm:45-46)") +end + +assertGift(giveRow(scripts.ROUTE_24.talk.TEXT_ROUTE24_COOLTRAINER_M4), + "CHARMANDER") + +local melanie = drive( + scripts.CERULEAN_MELANIES_HOUSE.talk.TEXT_CERULEANMELANIESHOUSE_MELANIE, + { flags = {}, pikachuHappiness = 147 }) +assertGift(giveRow(melanie), "BULBASAUR") + +local jenny = drive( + scripts.VERMILION_CITY.talk.TEXT_VERMILIONCITY_OFFICER_JENNY, + { flags = {}, inventory = { THUNDERBADGE = 1 } }) +assertGift(giveRow(jenny), "SQUIRTLE") + +T.finish("Yellow starter gifts print GotMonText (#2159)") diff --git a/tests/engine/zero_damage_miss_ruleset_bug2153.lua b/tests/engine/zero_damage_miss_ruleset_bug2153.lua new file mode 100644 index 00000000..f9b803f0 --- /dev/null +++ b/tests/engine/zero_damage_miss_ruleset_bug2153.lua @@ -0,0 +1,57 @@ +-- AdjustDamageForMoveType (engine/battle/core.asm:5169-5176) +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) +table.insert(Data.type_chart.matchups, + { attacker = "FIRE", defender = "FIX_DEF_A", multiplier = 5 }) +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) +local BattleState = require("src.battle.BattleState") +local Damage = require("src.battle.Damage") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") + +local faithful = require("src.battle.rulesets.gen1_faithful") +local modern = require("src.battle.rulesets.modern_clean") + +Data.moves.FIX_WEAK_FIRE = { + id = "FIX_WEAK_FIRE", index = 95, name = "FIX WEAK", + type = "FIRE", power = 1, accuracy = 100, pp = 35, + effect = "NO_ADDITIONAL_EFFECT", +} +local move = Data.moves.FIX_WEAK_FIRE + +local save = SaveData.newGame() +save.party = { Pokemon.new(Data, "FIXMON_A", 5) } +local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } +local battle = BattleState.newWild(game, "FIXMON_C", 5) +local atk, dfn = battle.player, battle.enemy +dfn.curTypes = { "WATER", "FIX_DEF_A" } +local opts = { forceCrit = false, rng = function(_, hi) return hi end } + +T.eq(TypeChart.effectiveness("FIRE", dfn.curTypes), 2, + "the matchup is 0.25x (x10 scale)") + +local d, info = Damage.compute(faithful, atk, dfn, move, opts) +T.eq(d, 0, "gen1_faithful floors the hit to zero") +T.eq(info.missed, true, "and flags it as a miss (core.asm:5171)") + +local dm, im = Damage.compute(modern, atk, dfn, move, opts) +T.eq(dm, 1, "modern_clean deals the minimum 1 instead") +T.eq(im.missed, nil, "and never reports a miss") + +local unset = { name = "fix_unset", randMin = 217, randMax = 255, + critUsesBaseSpeed = true, critIgnoresStages = true } +T.eq(select(2, Damage.compute(unset, atk, dfn, move, opts)).missed, true, + "an unset flag defaults to the Gen 1 quirk") + +T.eq(Damage.accuracyThreshold(modern, { accuracy = 100 }, atk, dfn), 256, + "modern_clean still removes the 1/256 miss") +T.eq(Damage.accuracyThreshold(faithful, { accuracy = 100 }, atk, dfn), 255, + "gen1_faithful keeps it") + +Data.moves.FIX_WEAK_FIRE = nil +T.finish("zero-damage miss is ruleset-gated (#2153)") diff --git a/tests/gen2_audio_test.lua b/tests/gen2_audio_test.lua index 948eebc2..1b576e9a 100644 --- a/tests/gen2_audio_test.lua +++ b/tests/gen2_audio_test.lua @@ -96,6 +96,10 @@ if type(audio.sfx) == "table" and audio.sfx.Sfx_CaughtMon then check(audio.sfx.Sfx_CaughtMon.generation == 2, "Sfx_CaughtMon is Gen 2 header") check(audio.sfxOrder and audio.sfxOrder[3] == "Sfx_CaughtMon", "sfxOrder[3] is Sfx_CaughtMon (SFX id 2)") + check(audio.sfxOrder and audio.sfxOrder[48] == "Sfx_Kinesis", + "sfxOrder[48] is Sfx_Kinesis (SFX id 47)") + check(audio.sfxOrder and audio.sfxOrder[21] == "Sfx_WarpFrom", + "sfxOrder[21] is Sfx_WarpFrom (SFX id 20)") local sfxOk, sfxEng = pcall(ChipSynth.newEngine, data, audio.sfx.Sfx_CaughtMon, { sfx = true, allowLoops = false, }) diff --git a/tests/gen2_bank_of_mom_test.lua b/tests/gen2_bank_of_mom_test.lua index c4029232..9b751861 100644 --- a/tests/gen2_bank_of_mom_test.lua +++ b/tests/gen2_bank_of_mom_test.lua @@ -257,16 +257,25 @@ end do local World = require("src.world.gen2.World") local healed, warped = false, false - local fakeSelf = { + local fakeSelf = setmetatable({ game = { save = { player = { money = 5001 } } }, showText = function(_self, _text, onDone) onDone() end, healParty = function() healed = true end, - warpToSpawn = function() warped = true end, - } + warpToSpawn = function(self) + warped = true + self.fade, self.fadeHold = nil, nil + end, + }, { __index = World }) World.whiteOut(fakeSelf) eq(fakeSelf.game.save.player.money, 2500, "halved and floored") check(healed, "HealParty still runs") check(warped, "and WarpToSpawnPoint still runs") + -- back in is a fade -- engine/events/whiteout.asm:19-20 + eq(fakeSelf.fade, "white", "and the warp brings a FadeInFromWhite with it") + eq(fakeSelf.mapSetup and fakeSelf.mapSetup.phase, "in", + "armed AFTER the load, so the load cannot clear it") + eq(fakeSelf.fadeHold, World.WARP_LOAD_WHITE_FRAMES, + "holding the LCD-off window first") end S.finish() diff --git a/tests/gen2_battle_options_test.lua b/tests/gen2_battle_options_test.lua index 3062824b..52d91b61 100644 --- a/tests/gen2_battle_options_test.lua +++ b/tests/gen2_battle_options_test.lua @@ -246,7 +246,14 @@ local function paint(states, w, h) G.rectangle = function(_, x, y, rw, rh) rects[#rects + 1] = { x = x, y = y, w = rw, h = rh, pen = pen } end - local ok, err = pcall(painter, { stack = { states = states } }, w, h) + local stack = { states = states } + function stack:visibleBase() + for i = #self.states, 1, -1 do + if self.states[i].isOpaque then return i end + end + return 1 + end + local ok, err = pcall(painter, { stack = stack }, w, h) G.rectangle, G.setColor = realRect, realSetColor if not ok then check("paintBattleSurround ran: " .. tostring(err), false, true) @@ -464,6 +471,256 @@ do end check("and the flag is down by default", Chrome.worldSurround, false) + +local WideBattle = require("src.ui.gen2.WideBattle") + +check("the default layout is the cart's panel", + Save.DEFAULT_OPTIONS.battleLayout, "og") +check("and a fresh options table carries it", + Save.defaultOptions().battleLayout, "og") + +local layoutIndex, layoutRow = rowNamed("BATTLE LAYOUT") +check("OPTION has a BATTLE LAYOUT row", layoutRow ~= nil, true) +if layoutRow then + check("it edits battleLayout", layoutRow.key, "battleLayout") + check("it is a port row, not one of the cart's seven", layoutRow.port, true) + check("OG and WIDE are the whole ladder", #layoutRow.values, 2) + check("stored lowercase, shown OG, padded to the value column", + layoutRow.display.og, "OG ") + check("and WIDE", layoutRow.display.wide, "WIDE") + check("it sits straight before BATTLE SIZE", layoutIndex + 1, sizeIndex) +end + +local builtLayout, viewedLayout +for _, row in ipairs(menu.rows) do + if row.key == "battleLayout" then builtLayout = row end +end +for _, row in ipairs(menu.view or {}) do + if row.id == "battleLayout" then viewedLayout = row end +end +check("buildRows keeps BATTLE LAYOUT", builtLayout ~= nil, true) +check("and files it under the BATTLE OPTIONS group", viewedLayout, nil) +if builtLayout then + check("and gives it the mod-facing id battleLayout", builtLayout.id, + "battleLayout") + menu.options.battleLayout = "og" + menu:cycle(builtLayout, 1) + check("right stores wide, not the display string", + menu.options.battleLayout, "wide") + menu:cycle(builtLayout, 1) + check("right again wraps to og", menu.options.battleLayout, "og") + menu:cycle(builtLayout, -1) + check("left walks the ladder the other way", menu.options.battleLayout, + "wide") + menu:cycle(builtLayout, -1) + check("and back", menu.options.battleLayout, "og") +end + +local gearLayout +for _, section in ipairs(model.sections) do + for _, row in ipairs(section.rows) do + if row.label == "BATTLE LAYOUT" then gearLayout = row end + end +end +check("the gold gear offers BATTLE LAYOUT", gearLayout ~= nil, true) +if gearLayout then + model.opts.gold.battleLayout = nil + check("an options.lua with no battleLayout reads OG", gearLayout.value(), + "OG") + gearLayout.step(1) + check("stepping writes the gold block, not the flat Gen 1 one", + model.opts.gold.battleLayout, "wide") + check("and the row reads WIDE", gearLayout.value(), "WIDE") + gearLayout.step(1) + check("stepping again returns to og", model.opts.gold.battleLayout, "og") +end + +local wideLayout = BattleState.wideLayout +check("the Gold battle screen answers wideLayout", type(wideLayout), "function") +check("under the same name Gen 1 uses", + BattleState.isWideBattleLayout, wideLayout) +if type(wideLayout) == "function" then + check("WIDE asks for the wide surface", + wideLayout({ game = { options = { battleLayout = "wide" } } }), true) + check("OG does not", + wideLayout({ game = { options = { battleLayout = "og" } } }), false) + check("nor does an options table that predates the key", + wideLayout({ game = { options = {} } }), false) + check("nor a screen with no game at all", wideLayout({}), false) +end + +check("and mods are told wideLayout is backed on this side", + Gen2Compat.memberStatus("src.battle.BattleState", "wideLayout"), "backed") +check("as is isWideBattleLayout", + Gen2Compat.memberStatus("src.battle.BattleState", "isWideBattleLayout"), + "backed") + +check("the wide surface is 304 pixels across", WideBattle.WIDTH, 304) +check("and the cart's 144 tall", WideBattle.HEIGHT, 144) +check("the classic field sits 9 tiles in", WideBattle.FIELD_X, 72) +check("which leaves 18 tiles of extra width", WideBattle.EXTRA_TILES, 18) +check("38 tiles across", WideBattle.TILES_W, 38) + +local ogScreen = { game = { options = { battleLayout = "og" } } } +local wideScreen = { game = { options = { battleLayout = "wide" } } } +local pw, ph = BattleState.panelSize(ogScreen) +check("OG composes on the cart's panel", pw, 160) +check("and its height", ph, 144) +pw, ph = BattleState.panelSize(wideScreen) +check("WIDE composes on the wide one", pw, 304) +check("at the same height", ph, 144) + +check("WIDE takes the fit its own width allows", + BattleState.battlePanelScale(wideScreen, 640, 480), + Chrome.fitScaleFor(640, 480, 38, 18)) +check("which is a step under the classic fit here", + BattleState.battlePanelScale(wideScreen, 640, 480) + < Chrome.fitScale(640, 480), true) +check("OG is unchanged", BattleState.battlePanelScale(ogScreen, 640, 480), + Chrome.fitScale(640, 480)) +check("Chrome.fitScale is still the 20x18 fit", + Chrome.fitScale(640, 480), Chrome.fitScaleFor(640, 480, 20, 18)) +check("and fitOrigin still the 20x18 origin", + select(1, Chrome.fitOrigin(640, 480, 2)), + select(1, Chrome.fitOriginFor(640, 480, 2, 20, 18))) + +local wideFill = { + game = { options = { battleLayout = "wide", battleFit = "fill" } }, +} +check("WIDE + FILL measures against 304 too", + BattleState.battlePanelScale(wideFill, 1280, 840), + WideBattle.fillScale(1280, 840)) +check("and that is not the classic fill", + WideBattle.fillScale(1280, 840) ~= BattleState.fillScale(1280, 840), true) +check("WIDE + FILL never goes below one whole pixel", + WideBattle.fillScale(100, 90), 1) + +local function wideSafe(states, w, h, scale, label) + local ox, oy = Chrome.fitOriginFor(w, h, scale, 38, 18) + local pw, ph = 304 * scale, 144 * scale + local rects = paint(states, w, h) + local covered, overlap = 0, 0 + for _, r in ipairs(rects) do + covered = covered + r.w * r.h + local ix = math.max(0, math.min(r.x + r.w, ox + pw) - math.max(r.x, ox)) + local iy = math.max(0, math.min(r.y + r.h, oy + ph) - math.max(r.y, oy)) + overlap = overlap + ix * iy + end + local want = 0 + for _, on in ipairs({ oy > 0, oy + ph < h, ox > 0, ox + pw < w }) do + if on then want = want + 1 end + end + check(label .. ": every void edge gets a band", #rects, want) + check(label .. ": no band touches the wide panel", overlap, 0) + check(label .. ": the void is covered edge to edge", + math.abs(covered - (w * h - pw * ph)) < 1, true) +end + +for _, size in ipairs({ { 1280, 840 }, { 1366, 768 }, { 1920, 1080 } }) do + local w, h = size[1], size[2] + local fit = Chrome.fitScaleFor(w, h, 38, 18) + local wideBattleState = { + bgMode = function() return "black" end, + panelSize = function() return 304, 144 end, + battlePanelScale = function() return fit end, + } + local wideFillBattle = { + bgMode = function() return "black" end, + panelSize = function() return 304, 144 end, + battlePanelScale = function() return WideBattle.fillScale(w, h) end, + } + local listOverBattle = { + drawsWidescreen = function() return true end, + drawWidescreen = function() end, + } + wideSafe({ wideBattleState }, w, h, fit, ("WIDE %dx%d"):format(w, h)) + wideSafe({ wideBattleState, listOverBattle }, w, h, fit, + ("WIDE under a party list %dx%d"):format(w, h)) + wideSafe({ wideBattleState, plainMenu }, w, h, fit, + ("WIDE under a plain menu %dx%d"):format(w, h)) + wideSafe({ wideFillBattle, listOverBattle }, w, h, + WideBattle.fillScale(w, h), + ("WIDE + FILL under a party list %dx%d"):format(w, h)) + + local opaqueList = { + isOpaque = true, + drawsWidescreen = function() return true end, + drawWidescreen = function() end, + } + local rects = paint({ wideBattleState, opaqueList }, w, h) + local fit = Chrome.fitScale(w, h) + local bx, by = Chrome.fitOrigin(w, h, fit) + local bw, bh = 160 * fit, 144 * fit + local covered, overlap = 0, 0 + for _, r in ipairs(rects) do + covered = covered + r.w * r.h + local ix = math.max(0, math.min(r.x + r.w, bx + bw) - math.max(r.x, bx)) + local iy = math.max(0, math.min(r.y + r.h, by + bh) - math.max(r.y, by)) + overlap = overlap + ix * iy + end + check(("an opaque list over WIDE %dx%d: no band clips the list"):format(w, h), + overlap, 0) + check(("an opaque list over WIDE %dx%d: the void is covered"):format(w, h), + math.abs(covered - (w * h - bw * bh)) < 1, true) +end + +do + local clips, scenes = 0, 0 + local realClip = Chrome.withClip + Chrome.withClip = function(fn) clips = clips + 1; fn() end + local screen = { + wideLayout = function() return false end, + drawScene = function() scenes = scenes + 1 end, + } + BattleState.draw(screen) + check("OG still draws through Chrome.withClip", clips, 1) + check("and reaches the scene", scenes, 1) + screen.wideLayout = function() return true end + BattleState.draw(screen) + check("WIDE draws nothing a second time", clips, 1) + check("and never re-enters the scene", scenes, 1) + Chrome.withClip = realClip +end + +do + local drawn = {} + local screen = { + wideLayout = function() return false end, + drawScene = function() drawn[#drawn + 1] = "scene" end, + battlePanelScale = function() return 2 end, + } + local realPanel = Chrome.withPanel + Chrome.withPanel = function(_, _, _, _, _, fn) drawn[#drawn + 1] = "panel" + fn() end + local realWide = WideBattle.draw + WideBattle.draw = function() drawn[#drawn + 1] = "wide" end + BattleState.drawWidescreen(screen, 1280, 840) + check("OG goes through Chrome.withPanel", table.concat(drawn, ","), + "panel,scene") + screen.wideLayout = function() return true end + BattleState.drawWidescreen(screen, 1280, 840) + check("WIDE hands the frame to WideBattle", table.concat(drawn, ","), + "panel,scene,wide") + Chrome.withPanel, WideBattle.draw = realPanel, realWide +end + +do + local order = {} + local screen = { + drawEnemyHud = function() order[#order + 1] = "enemy" end, + drawPics = function() order[#order + 1] = "pics" end, + drawPlayerHud = function() order[#order + 1] = "player" end, + } + BattleState.drawHud(screen) + check("drawHud draws enemy HUD, pics, player HUD in that order", + table.concat(order, ","), "enemy,pics,player") +end + +check("drawBottom takes the extra tiles as an argument", + type(BattleState.drawBottom), "function") +check("and WIDE hands it the 18 the wide surface adds", + WideBattle.EXTRA_TILES, 38 - Chrome.SCREEN_W) + print(("gen2 battle options: %d checks, %d failures"):format(checks, failures)) if failures > 0 then error(("%d assertion(s) failed"):format(failures), 0) diff --git a/tests/gen2_battle_return_fade_test.lua b/tests/gen2_battle_return_fade_test.lua index dd9e369b..42c3a5f2 100644 --- a/tests/gen2_battle_return_fade_test.lua +++ b/tests/gen2_battle_return_fade_test.lua @@ -51,37 +51,181 @@ eq(armed.mapSetup.phase, "out", "a chain already running is left alone") eq(armed.fade, nil, "and its sheet untouched") -local ticks, white = 0, 0 +-- home/init.asm:149-153 +check(World.MAP_LOAD_WHITE_FRAMES >= 10, + "the door LCD-off window is held ten frames or more") +check(World.WARP_LOAD_WHITE_FRAMES >= World.MAP_LOAD_WHITE_FRAMES, + "a Warp/ReloadMap load runs longer still: LoadBlockData is under the LCD too") +eq(w.fadeHold, World.WARP_LOAD_WHITE_FRAMES, + "the battle return opens holding pure white for that window") +eq(w.fadeWhiten, nil, "with LoadMapPalettes' plain set already back") + +local ticks, white, sheet = 0, 0, 0 while w.mapSetup and ticks < 64 do if w.fadeLevel == 1 then white = white + 1 end + if w.fadeHold then sheet = sheet + 1 end World.updateMapSetup(w) ticks = ticks + 1 end -eq(World.MAP_LOAD_WHITE_FRAMES, 4, "the LCD-off map load is held as four white frames") -eq(white, World.MAP_LOAD_WHITE_FRAMES + World.FADE_STEP_FRAMES, - "white holds for the load plus FadeInFromWhite's first row") -eq(ticks, 8 + World.MAP_LOAD_WHITE_FRAMES, - "the fade in runs four steps of two frames after the hold") +eq(sheet, World.WARP_LOAD_WHITE_FRAMES, + "pure white is the LCD-off window and nothing else") +eq(white, World.WARP_LOAD_WHITE_FRAMES + World.FADE_STEP_FRAMES, + "the hold plus FadeInFromWhite's own first row, which is a palette row") +eq(ticks, 6 + World.WARP_LOAD_WHITE_FRAMES + World.FADE_STEP_FRAMES, + "then three more rows of two frames") eq(w.mapSetup, nil, "then the chain ends") eq(w.fade, nil, "with nothing left over the world") +eq(w.fadeHold, nil, "and no hold left behind") check(not World.busy(w), "and control back") -- data/maps/setup_scripts.asm:102-125 local door = { mapSetup = { phase = "out", step = 0, wait = 2, - load = function() return true end } } -local levels, whiteRun = {}, 0 + load = function() return true end }, fadeWhiten = true } +local levels, whiteRun, doorSheet, whitenRun = {}, 0, 0, 0 ticks = 0 while door.mapSetup and ticks < 64 do + if door.fadeLevel == 1 then whiteRun = whiteRun + 1 end + if door.fadeHold then doorSheet = doorSheet + 1 end + if door.fadeWhiten then whitenRun = whitenRun + 1 end World.updateMapSetup(door) ticks = ticks + 1 levels[#levels + 1] = door.fadeLevel or 0 - if door.mapSetup and door.fadeLevel == 1 then whiteRun = whiteRun + 1 end end +-- engine/tilesets/timeofday_pals.asm:160-187 +eq(doorSheet, World.MAP_LOAD_WHITE_FRAMES, + "a door is pure white for the load alone") eq(whiteRun, World.FADE_STEP_FRAMES + World.MAP_LOAD_WHITE_FRAMES - + World.FADE_STEP_FRAMES, "a door holds white: last out row, load, first in row") -eq(ticks, 8 + (2 * World.FADE_STEP_FRAMES + World.MAP_LOAD_WHITE_FRAMES) - + 3 * World.FADE_STEP_FRAMES, "out ramp, hold, in ramp") + + World.FADE_STEP_FRAMES, + "the colour-0 plane brackets it: last out row, load, first in row") +eq(whitenRun, (World.FADE_STEPS + 1) * World.FADE_STEP_FRAMES, + "FillWhiteBGColor holds over the whole way out and no further") +eq(door.fadeWhiten, nil, "the load puts the plain palettes back") +eq(ticks, (World.FADE_STEPS + 1) * World.FADE_STEP_FRAMES + + World.MAP_LOAD_WHITE_FRAMES + World.FADE_STEPS * World.FADE_STEP_FRAMES, + "out ramp, hold, in ramp") eq(levels[World.FADE_STEP_FRAMES], 0.25, "the out ramp opens on the identity row") eq(levels[#levels], 0, "and the in ramp closes on nothing") +-- data/maps/setup_scripts.asm:27-32 +do + local MAPSETUP_TELEPORT, MAPSETUP_DOOR = 0xf4, 0xf5 + local function armed(method) + local wm = { roamMonsBeforeLoad = function() end, + roamMonsAfterLoad = function() end } + World.runMapSetup(wm, method, function() return true end) + local hold + while wm.mapSetup and wm.mapSetup.phase == "out" do + World.updateMapSetup(wm) + hold = wm.fadeHold or hold + end + return hold + end + eq(armed(MAPSETUP_TELEPORT), World.WARP_LOAD_WHITE_FRAMES, + "a teleport falls into _Fly falls into _Warp, so it holds the Warp window") + eq(armed(MAPSETUP_DOOR), World.MAP_LOAD_WHITE_FRAMES, + "a door falls into _Train and keeps the shorter one") +end + +-- engine/tilesets/timeofday_pals.asm:122-128 +do + local wf = {} + World.screenFade(wf, "outWhite") + eq(wf.fadeLevel, 1, "a bare FadeOutToWhite lands on the solid row") + eq(wf.fadeHold, nil, "with no LCD-off hold of its own") + eq(wf.fadeWhiten, true, "and FillWhiteBGColor's pals 1-6 in place") + World.screenFade(wf, "outBlack") + eq(wf.fadeWhiten, nil, "FadeOutToBlack does not call FillWhiteBGColor") +end + +-- night, never white -- into pals 1 to 6 (timeofday_pals.asm:160-187) +do + local pal = function(c0) + return { c0, { 20, 20, 20 }, { 10, 10, 10 }, { 0, 0, 0 } } + end + local set = { pal({ 15, 14, 24 }), pal({ 30, 30, 11 }), pal({ 1, 1, 1 }), + pal({ 2, 2, 2 }), pal({ 3, 3, 3 }), pal({ 4, 4, 4 }), pal({ 5, 5, 5 }), + pal({ 6, 6, 6 }) } + local w2 = { + palettes = { bg = { set[1] }, environments = { TOWN = { DAY = {} } }, + specialTilesets = { TILESET_TEST = set } }, + map = { def = { tileset = "TILESET_TEST", environment = "INDOOR" } }, + daytime = "NITE", + } + local plain = World.fadeBgSet(w2) + eq(plain[2][1][1], 30, "with no whitening the pals are the map's own") + w2.fadeWhiten = true + local whitened = World.fadeBgSet(w2) + eq(whitened[1][1][1], 15, "pal 0 keeps its own colour 0") + eq(whitened[2][1][1], 15, "and pals 1 to 6 take it") + eq(whitened[2][1][3], 24, "the night plane, not white") + eq(whitened[7][1][1], 15, "up to pal 6") + eq(whitened[8][1][1], 6, "pal 7 is left alone, as the `ld c, 6` loop leaves it") + eq(whitened[2][2][1], 20, "and colours 1 to 3 are untouched") +end + +-- MAPSETUP_WARP` at the end of Script_Whiteout (engine/events/whiteout.asm:19) +local Screens = require("src.ui.Screens") + +local DATA = { + pokemon = { + RATTATA = { id = "RATTATA", name = "RATTATA", index = 19, baseExp = 57, + growthRate = "MEDIUM_FAST", stats = { hp = 30, attack = 56, + defense = 35, speed = 72, specialAttack = 25, specialDefense = 35 }, + types = { "NORMAL" } }, + }, + moves = {}, +} + +local function battleWorld() + local captured = {} + Screens.invalidate() + local game = { + data = { pokemon = DATA.pokemon, moves = DATA.moves, + screens = { Gen2BattleState = function(_, opts) + captured.opts = opts + return { screenId = "Gen2BattleState" } + end } }, + save = { player = { name = "GOLD", money = 3000 }, + mom = { savedMoney = 0 }, party = {} }, + stack = { push = function() end, pop = function() end }, + } + local world = World.new(game) + game.world = world + world.map = { def = { id = "TEST_MAP" } } + world.maps = { TEST_MAP = world.map.def } + world.playBattleMusic = function() end + world.battleMusicContext = function() return nil end + world.pushBattleTransition = function() return nil end + world.restoreMapMusic = function() end + world.forceMapMusic = function() end + world.healParty = function() end + world.warpToSpawn = function(self) self.fade, self.fadeHold = nil, nil end + return world, captured +end + +do + local world, captured = battleWorld() + world:startBattle({ trainer = { name = "FALKNER", baseMoney = 25, + party = { { species = "RATTATA", level = 5, hp = 0, maxHp = 20, + moves = {}, stats = {}, dvs = {}, statExp = {} } } } }) + check(captured.opts ~= nil, "startBattle pushes the battle screen") + captured.opts.onDone("win") + eq(world.fade, "white", "a win comes back through MAPSETUP_RELOADMAP") + eq(world.mapSetup and world.mapSetup.phase, "in", "which is a fade in") +end + +do + local world, captured = battleWorld() + world:startBattle({ trainer = { name = "FALKNER", baseMoney = 25, + party = { { species = "RATTATA", level = 5, hp = 0, maxHp = 20, + moves = {}, stats = {}, dvs = {}, statExp = {} } } } }) + captured.opts.onDone("lose") + eq(world.fade, "white", + "a loss fades in from the whiteout's own warp, not from the reload") + eq(world.mapSetup and world.mapSetup.phase, "in", + "armed AFTER the warp loaded, so the load cannot clear it") + eq(world.fadeHold, World.WARP_LOAD_WHITE_FRAMES, + "with the LCD-off window held first") +end + S.finish() diff --git a/tests/gen2_battle_ui_test.lua b/tests/gen2_battle_ui_test.lua index 9e789436..cf6e257c 100644 --- a/tests/gen2_battle_ui_test.lua +++ b/tests/gen2_battle_ui_test.lua @@ -24,6 +24,7 @@ local Mon = require("src.battle.gen2.Mon") local PackMenu = require("src.ui.gen2.PackMenu") local PartyMenu = require("src.ui.gen2.PartyMenu") local Sound = require("src.core.Sound") +local Typer = require("src.ui.gen2.Typer") -- ---------------------------------------------------------------- fixtures @@ -878,10 +879,20 @@ do list3.index = 2 list3:openSubmenu() list3:updateSubmenu({ wasPressed = function(_, b) return b == "a" end }) - eq(screen.phase, "refuse-switch", "the mon already out is refused") - check((screen.message or ""):find("is already out"), - "with BattleText_MonIsAlreadyOut") + eq(screen.phase, "submenu", "the mon already out is refused in the list") + eq(pushed[#pushed], list3, "with the party list still up") + eq(list3.submenu, nil, "and the SWITCH/STATS/CANCEL box gone") + local refused = (list3.itemResult or {}).text or "" + check(refused:find("already"), "with BattleText_MonIsAlreadyOut") + -- SpeechTextbox's 18-tile interior (home/text.asm:124) + for line in refused:gmatch("[^\n]+") do + check(#line <= 18, "the refusal wraps inside the textbox") + end eq(battle.turn, turn0, "and no turn is spent on it") + list3:updateItemResult({ wasPressed = function(_, b) return b == "a" end }) + eq(list3.itemResult, nil, "A dismisses it back to a live list") + eq(pushed[#pushed], list3, "which is still the top of the stack") + list3.onCancel() -- The forced list is PickPartyMonInBattle: WhichPKMNString, no submenu. check(screen:openParty(true), "the forced list opens") @@ -2044,4 +2055,72 @@ do "without scripts the mon and the HUD come up together") end +-- SlideBattlePicOut + EmptyBattleTextbox (core.asm:3221) +do + local screen, battle = newScreen() + check(runToMenu(screen), "the intro drains") + screen.showEnemyTrainer = true + screen.picHidden.enemy = false + screen.trainerSlide = 0 + for _ = 1, 400 do + run(screen, 1) + if not screen.trainerSlide then break end + end + eq(screen.showEnemyTrainer, false, "the trainer pic slides off") + eq(screen.picHidden.enemy, true, "and the box it emptied stays empty") + check(screen:picBoxCleared("enemy"), "so drawPic paints nothing there") + + screen:startSendOut("enemy", battle.enemy) + for _ = 1, 400 do + if not screen.afterSendOut then break end + run(screen, 1) + end + check(not screen:picBoxCleared("enemy"), + "the send-out animation is the only thing that brings the mon back") +end + +-- home/text.asm:630 +-- (home/joypad.asm:428) +do + local screen = newScreen() + local held = false + for _ = 1, 600 do + run(screen, 1) + if (screen.messageTimer or 0) > 0 and not Typer.typing(screen) then + held = true + break + end + end + check(held, "WildPokemonAppearedText holds for PromptButton") + + screen.arrowBlink = 0 + check(screen:messageArrowVisible(), "the cursor is on at phase 0") + screen.arrowBlink = 15 + check(screen:messageArrowVisible(), "and through phase 15") + screen.arrowBlink = 16 + check(not screen:messageArrowVisible(), "UnloadBlinkingCursor at phase 16") + screen.arrowBlink = 31 + check(not screen:messageArrowVisible(), "and through phase 31") + + -- byte (home/text.asm:887-902) + screen.arrowBlink = 0 + screen.waitSfx = "SFX_TACKLE" + check(not screen:messageArrowVisible(), "no cursor under a held SFX") + screen.waitSfx = nil + screen.messageDelay = 10 + check(not screen:messageArrowVisible(), "none through a text_pause") + screen.messageDelay = 0 + check(screen:messageArrowVisible(), "back on once the pause is done") + + -- DoneText / text_end lines never load it (home/text.asm:566) + screen.messageTimer = 0 + check(not screen:messageArrowVisible(), "and never on a `done` line") + + local sliding = newScreen() + local before = sliding.arrowBlink + run(sliding, 1) + check(sliding.slideFrame > 0 and sliding.arrowBlink ~= before, + "the blink phase advances through update's early returns") +end + S.finish() diff --git a/tests/gen2_clock_test.lua b/tests/gen2_clock_test.lua index 313ca0d7..66737228 100644 --- a/tests/gen2_clock_test.lua +++ b/tests/gen2_clock_test.lua @@ -35,6 +35,20 @@ local function fakeInput() } end +-- (../pokecrystal/home/print_text.asm:5) +local function settle(screen) + for _ = 1, 900 do + if not screen.typer or screen.typer:done() then break end + screen:update(0) + end +end + +local function press(screen, input, button) + settle(screen) + input:press(button) + screen:update(0) +end + -- --------------------------------------------------------------- the offsets do local save = {} @@ -92,73 +106,57 @@ do -- OakTimeWokeUpText is two `para` pages, so A turns the page before it takes -- the box down. eq(#screen:pages(), 2, "Oak's opening is two pages") - input:press("a") - screen:update(0) + press(screen, input, "a") eq(screen.phase, "intro", "the first A turns the page") check(screen:pageText():find("clock", 1, true) ~= nil, "onto the one that asks about the clock") - input:press("a") - screen:update(0) + press(screen, input, "a") eq(screen.phase, "hour", "and the second opens the hour picker") -- DisplayHourOClock is PrintHour + String_oclock, so the word rides along eq(screen:display(), "DAY 10 o'clock", "which shows the hour DisplayHourOClock does") - input:press("up") - screen:update(0) + press(screen, input, "up") eq(screen.hour, 11, "up walks the hour forward") - input:press("down") - screen:update(0) - input:press("down") - screen:update(0) + press(screen, input, "down") + press(screen, input, "down") eq(screen.hour, 9, "down walks it back") -- .DecreaseThroughMidnight / .AdvanceThroughMidnight: both ends wrap. screen.hour = 0 - input:press("down") - screen:update(0) + press(screen, input, "down") eq(screen.hour, 23, "and midnight wraps to 11 PM") - input:press("up") - screen:update(0) + press(screen, input, "up") eq(screen.hour, 0, "and back again") screen.hour = 7 - input:press("a") - screen:update(0) + press(screen, input, "a") eq(screen.phase, "confirm-hour", "A confirms the hour") check(screen:question():find("o'clock", 1, true) ~= nil, "and the question reads it back") -- NO drops back to the picker (`jr c, .loop`). screen.yesNo = 2 - input:press("a") - screen:update(0) + press(screen, input, "a") eq(screen.phase, "hour", "NO goes back to the picker") - input:press("a") - screen:update(0) - input:press("a") - screen:update(0) + press(screen, input, "a") + press(screen, input, "a") eq(screen.phase, "minute", "YES moves on to the minutes") - input:press("up") - screen:update(0) + press(screen, input, "up") eq(screen.minute, 1, "up walks the minutes") screen.minute = 0 - input:press("down") - screen:update(0) + press(screen, input, "down") eq(screen.minute, 59, "and they wrap at the hour") screen.minute = 30 eq(screen:display(), "30 min.", "DisplayMinutesWithMinString's own string") - input:press("a") - screen:update(0) + press(screen, input, "a") eq(screen.phase, "confirm-minute", "A confirms them") - input:press("a") - screen:update(0) + press(screen, input, "a") eq(screen.phase, "response", "and Oak answers with the time") -- "MORN 7:30", not "7 AM:30": OakText_ResponseToSetTime is PrintHour (the -- time-of-day word then the 1-12 hour) then ':' then two-digit minutes, so -- the meridiem never appears and cannot land between the hour and them. check(screen:question():find("MORN 7:30", 1, true) ~= nil, "which is the pair the player just set") - input:press("a") - screen:update(0) + press(screen, input, "a") eq(done.hour, 7, "the screen hands the hour back") eq(done.minute, 30, "and the minutes") eq(Clock.hour(save), 7, "and the save now reads that hour") @@ -200,27 +198,19 @@ do eq(screen.day, 0, "`xor a / ld [wTempDayOfWeek], a`: SUNDAY") eq(screen:display(), "SUNDAY", "which is what the box shows") for _ = 1, 2 do - input:press("up") - screen:update(0) + press(screen, input, "up") end eq(screen:display(), "TUESDAY", "up walks the wheel forward") - input:press("down") - screen:update(0) - input:press("down") - screen:update(0) - input:press("down") - screen:update(0) + press(screen, input, "down") + press(screen, input, "down") + press(screen, input, "down") eq(screen:display(), "SATURDAY", "and it wraps past SUNDAY") - input:press("a") - screen:update(0) + press(screen, input, "a") eq(screen.phase, "confirm-day", "A confirms it") - input:press("b") - screen:update(0) + press(screen, input, "b") eq(screen.phase, "day", "B is NO and drops back to the wheel") - input:press("a") - screen:update(0) - input:press("a") - screen:update(0) + press(screen, input, "a") + press(screen, input, "a") eq(picked, 6, "YES hands the day back") eq(Clock.weekday(save), 6, "and the save reads it") end @@ -262,6 +252,7 @@ do local rows = {} Chrome.print = function(text, _tx, ty) rows[text] = ty end local screen = InitClock.new({}, { save = {} }) + settle(screen) screen:drawPanel() Chrome.print = priorPrint eq(rows["Zzz... Hm? Wha...?"], 14, "Oak's first line prints on row 14") @@ -278,13 +269,70 @@ do local clock = InitClock.new({}, { mode = "clock", save = {} }) eq(clock.isOpaque, true, "InitClock still runs on a cleared page") eq(clock:drawsWidescreen(), true, "with its own widescreen surround") - local priorClear, cleared = Chrome.clear, 0 - Chrome.clear = function() cleared = cleared + 1 end - day:drawPanel() - eq(cleared, 0, "the day wheel never clears the tilemap") + -- ../pokecrystal/engine/rtc/timeset.asm:22-32 + local G = love.graphics + local priorColor, priorRect = G.setColor, G.rectangle + local held, fills = nil, {} + G.setColor = function(r, g, b, a) + held = { r, g, b } + priorColor(r, g, b, a) + end + G.rectangle = function(mode, x, y, w, h) + fills[#fills + 1] = { color = held, x = x, y = y, w = w, h = h } + priorRect(mode, x, y, w, h) + end + local function groundFill(list) + local first = list[1] + if not first then return nil end + if first.x ~= 0 or first.y ~= 0 or first.w ~= 160 or first.h ~= 144 then + return nil + end + return first.color + end clock:drawPanel() - eq(cleared, 1, "the clock does") - Chrome.clear = priorClear + local ground = groundFill(fills) + check(ground ~= nil, "the clock screen fills the whole tilemap first") + eq(ground[1], 0, "with black") + eq(ground[2], 0, "and black") + eq(ground[3], 0, "and black") + fills = {} + day:drawPanel() + check(groundFill(fills) == nil, "the day wheel never clears the tilemap") + G.setColor, G.rectangle = priorColor, priorRect + eq(InitClock.GROUND[1] + InitClock.GROUND[2] + InitClock.GROUND[3], 0, + "gfx/diploma/diploma.pal colour 3") +end + +-- ../pokecrystal/home/print_text.asm:5 +do + local slow = InitClock.new( + { input = fakeInput(), save = { options = { textSpeed = "SLOW" } } }, + { save = {} }) + check(slow.typer ~= nil, "the clock screen types its text") + check(not slow.typer:done(), "and opens with nothing shown") + for _ = 1, 25 do slow:update(0) end + eq(slow.typer.shown, 5, "SLOW is one character every five frames") + + local input = fakeInput() + local fast = InitClock.new( + { input = input, save = { options = { textSpeed = "FAST" } } }, + { save = {} }) + for _ = 1, 25 do fast:update(0) end + eq(fast.typer.shown, 25, "FAST is one a frame") + check(not fast.typer:done(), "the page is still going") + input:press("a") + fast:update(0) + eq(fast.page, 1, "A mid-line does not turn the page") + check(fast.typer:done(), "it finishes the line instead") + input:press("a") + fast:update(0) + eq(fast.page, 2, "the next A turns it") + check(not fast.typer:done(), "and the new page starts from nothing") + + local driven = InitClock.new({}, { save = {}, autoConfirm = true }) + check(driven.typer.instant, "autoConfirm takes the whole line at once") + local wheel = InitClock.new({}, { mode = "day", save = {} }) + check(wheel.typer.instant, "and so does SetDayOfWeek") end -- ../pokecrystal/home/text.asm:502 diff --git a/tests/gen2_crystal_gender_test.lua b/tests/gen2_crystal_gender_test.lua index 5001164e..5f544e4f 100644 --- a/tests/gen2_crystal_gender_test.lua +++ b/tests/gen2_crystal_gender_test.lua @@ -16,6 +16,7 @@ love = require("tests.love_stub") local FieldMoves = require("src.world.gen2.FieldMoves") local GameVersion = require("src.core.GameVersion") local GenderSelect = require("src.ui.gen2.GenderSelect") +local InitClock = require("src.ui.gen2.InitClock") local NamePick = require("src.ui.gen2.NamePick") local NamingScreen = require("src.ui.gen2.NamingScreen") local OakSpeech = require("src.ui.gen2.OakSpeech") @@ -204,6 +205,10 @@ do } } }) eq(done.text, "Are you a boy?\nOr are you a girl?", "the {DONE} terminator is stripped from the prompt") + for _ = 1, 300 do + if done.typer:done() then break end + done:update(0) + end done:drawPanel() check(not table.concat(printed, "|"):find("DONE", 1, true), "and never reaches the tile grid") @@ -214,6 +219,26 @@ do _AreYouABoyOrAreYouAGirlText = "Are you a boy?{PROMPT}", } } }) eq(prompt.text, "Are you a boy?", "nor is {PROMPT}") + -- ../pokecrystal/home/print_text.asm:5 + local slow = GenderSelect.new({ + save = { options = { textSpeed = "SLOW" } }, + data = { text = { _AreYouABoyOrAreYouAGirlText = "Are you a boy?" } }, + }) + check(slow.typer ~= nil and not slow.typer:done(), + "the prompt opens with nothing shown") + for _ = 1, 15 do slow:update(0) end + eq(slow.typer.shown, 3, "SLOW is one character every five frames") + printed = {} + slow:drawPanel() + check(table.concat(printed, "|"):find("Are", 1, true) ~= nil + and not table.concat(printed, "|"):find("boy", 1, true), + "and only the typed part reaches the tile grid") + local fast = GenderSelect.new({ + save = { options = { textSpeed = "FAST" } }, + data = { text = { _AreYouABoyOrAreYouAGirlText = "Are you a boy?" } }, + }) + for _ = 1, 15 do fast:update(0) end + eq(fast.typer.shown, 14, "FAST is one a frame, and the line is 14 long") Chrome.print = priorPrint end @@ -238,6 +263,27 @@ eq(OakSpeech.defaultSteps()[1].id, "init_clock", "a bare call (no speech) is the Gold list") eq(#goldSteps + 1, #crystalSteps, "the Crystal list is the Gold list plus one") +-- ../pokecrystal/engine/menus/intro_menu.asm:875 +do + local G = love.graphics + local priorScissor, clips = G.setScissor, {} + G.setScissor = function(x, y, w, h) clips[#clips + 1] = { x, y, w, h } end + for _, screen in ipairs({ + setmetatable({}, OakSpeech), + GenderSelect.new({ data = {} }), + InitClock.new({}, { save = {} }), + }) do + clips = {} + screen:draw() + eq(#clips, 1, "draw scissors once") + eq(clips[1][1], 0, "at the panel origin") + eq(clips[1][2], 0, "at the panel origin") + eq(clips[1][3], 160, "over the LCD's own width") + eq(clips[1][4], 144, "and its height") + end + G.setScissor = priorScissor +end + -- ------------------------------------------------- the trainer card local CARD_GFX = { diff --git a/tests/gen2_dig_warp_test.lua b/tests/gen2_dig_warp_test.lua index 0bf86568..be9b0c26 100644 --- a/tests/gen2_dig_warp_test.lua +++ b/tests/gen2_dig_warp_test.lua @@ -71,12 +71,10 @@ local function world(mapId) return w end --- MAPSETUP_DOOR fades out before the load, so the take is parked in --- world.mapSetup; this drains it the way World:step does. local function pump(w) - for _ = 1, 64 do - if not w.mapSetup then return end - w:updateMapSetup() + for _ = 1, 256 do + if not (w.mapSetup or w.moveState) then return end + if w.mapSetup then w:updateMapSetup() else w:updateMovement() end end end diff --git a/tests/gen2_field_anim_test.lua b/tests/gen2_field_anim_test.lua index 7483805e..65cf0798 100644 --- a/tests/gen2_field_anim_test.lua +++ b/tests/gen2_field_anim_test.lua @@ -57,7 +57,38 @@ check("three sixteen-frame beats", to.frames, 48) check("$47 is still step_end", Movement.decodeByte(0x47).kind, "end") check("$46 is still the last step_sleep", Movement.decodeByte(0x46).kind, "sleep") -check("$4e skyfall is still unmodelled", Movement.decodeByte(0x4e).kind, "nop") + +-- written out of (engine/events/overworld.asm:864-872) +-- engine/overworld/events.asm:1008-1021 +local sky = Movement.decodeByte(0x4e) +check("$4e decodes as a skyfall", sky.kind, "skyfall") +check("sixteen hidden frames then sixteen falling", sky.frames, 32) +check("$59 is the skyfall_top half", Movement.decodeByte(0x59).kind, + "skyfalltop") +check("one beat only", Movement.decodeByte(0x59).frames, 16) +local shown = Movement.decodeByte(0x3c) +check("$3c show_object is a visibility byte", shown.kind, "visible") +check("and it shows", shown.on, true) +local hidden = Movement.decodeByte(0x3d) +check("$3d hide_object is the same byte", hidden.kind, "visible") +check("and it hides", hidden.on, false) +check("$58 return_dig has its own kind", Movement.decodeByte(0x58).kind, + "returndig") + +local out = Movement.digOutBytes() +check(".DigOut opens on step_dig", out[1], 0x4f) +check("spinning for 32 frames, not forced movement's 16", out[2], 32) +check("then hide_object", out[3], 0x3d) +check("and step_end", out[4], 0x47) +local back = Movement.digReturnBytes() +check(".DigReturn opens on show_object", back[1], 0x3c) +check("then return_dig", back[2], 0x58) +check("for the same 32 frames", back[3], 32) +check("and step_end", back[4], 0x47) + +check("the fall starts off the top of the screen", + Movement.teleportYOffset(1), -87) +check("and lands flush on the tile", Movement.teleportYOffset(16), 0) -- ---------------------------------------------------------- the jump families -- diff --git a/tests/gen2_field_items_test.lua b/tests/gen2_field_items_test.lua index c28c8358..d737afaf 100644 --- a/tests/gen2_field_items_test.lua +++ b/tests/gen2_field_items_test.lua @@ -813,10 +813,11 @@ local function world(mapId, x, y, save) return w, game end +-- ../pokecrystal/engine/events/overworld.asm:851-872 local function pump(w) - for _ = 1, 64 do - if not w.mapSetup then return end - w:updateMapSetup() + for _ = 1, 256 do + if not (w.mapSetup or w.moveState) then return end + if w.mapSetup then w:updateMapSetup() else w:updateMovement() end end end diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua index 0bf3409d..282ce5d8 100644 --- a/tests/gen2_menus_test.lua +++ b/tests/gen2_menus_test.lua @@ -366,7 +366,7 @@ local options = OptionsMenu.new(optionsGame, { }) -- The cart's seven rows, then the port's: CONTROLS, audio, PERFORMANCE, -- speed, display, SHADER FX + SHADER FX 2 (the second slot added alongside -check("thirty-two rows", #OptionsMenu.ROWS, 32) +check("thirty-three rows", #OptionsMenu.ROWS, 33) check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame") check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls") check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol") diff --git a/tests/gen2_world_test.lua b/tests/gen2_world_test.lua index c75b03eb..588dd0bb 100644 --- a/tests/gen2_world_test.lua +++ b/tests/gen2_world_test.lua @@ -188,6 +188,71 @@ local function fakePlayer(x, y, facing) } end +-- ../pokecrystal/engine/overworld/events.asm:349-353 +do + local Movement = require("src.script.gen2.Movement") + local w = World.new({ data = { audio = { sfxOrder = {} } }, + save = { player = {} } }) + w.player = { cellX = 0, cellY = 0, facing = "down" } + local sfx = {} + w.playSfxNamed = function(_, name) sfx[#sfx + 1] = name end + + w:startSkyfall() + eq(sfx[1], "Sfx_Kinesis", "the fall opens on SFX_KINESIS") + check(w.playerMasked, "OBJECT_ACTION_00 is SetFacingStanding: nothing drawn") + check(w:busy(), "and the applymovement holds the overworld") + for _ = 1, 16 do w:updateSkyfall() end + check(not w.playerMasked, "the sprite comes back for the descent") + eq(w.player.spriteYOffset, nil, "and has not moved yet") + + w:updateSkyfall() + eq(w.player.spriteYOffset, Movement.teleportYOffset(1), + "the first falling frame is off the top of the screen") + local last = w.player.spriteYOffset + for _ = 1, 14 do + w:updateSkyfall() + check(w.player.spriteYOffset > last, "the drop eases down every frame") + last = w.player.spriteYOffset + end + w:updateSkyfall() + eq(w.player.spriteYOffset, 0, "landing flush on the tile") + check(w.skyfall == nil, "and the state is done") + check(not w:busy(), "so the world is walkable again") + eq(sfx[2], "Sfx_Strength", "SFX_STRENGTH on the landing") + eq(#sfx, 2, "and no warpsound anywhere in the fall") + eq(w.shake and w.shake.amplitude, 1, "earthquake 16 is a one-pixel shake") + eq(w.shake and w.shake.left, 16, "for sixteen frames") +end + +-- engine/events/overworld.asm:864-872 +do + local Movement = require("src.script.gen2.Movement") + local w = World.new({ data = { audio = { sfxOrder = {} } }, + save = { player = {} } }) + local spins = {} + w.player = { + cellX = 0, cellY = 0, facing = "down", moving = false, + scriptSpin = function(_, frames, flicker) + spins[#spins + 1] = { frames = frames, flicker = flicker } + end, + } + w:beginMovement(0, Movement.digOutBytes()) + w:updateMovement() + eq(spins[1] and spins[1].frames, 32, "step_dig 32 spins for 32 frames") + check(not spins[1].flicker, "and the out-spin does not strobe") + check(not w.playerHidden, "hide_object has not been read yet") + for _ = 1, 33 do w:updateMovement() end + check(w.playerHidden, "hide_object hides the player for the fade") + check(w.moveState == nil, "and the stream ends on step_end") + + w:beginMovement(0, Movement.digReturnBytes()) + w:updateMovement() + check(not w.playerHidden, "show_object brings them back on the far side") + eq(spins[2] and spins[2].frames, 32, "return_dig 32 spins for 32 too") + eq(spins[2] and spins[2].flicker, true, + "and StepFunction_DigTo strobes it on the odd frames") +end + -- A world with the render half replaced: no love here, so the seams that would -- push a TextBox, a ChoiceBox or the battle screen record instead. `log` is -- the script trace the assertions read. @@ -424,7 +489,6 @@ end -- that differs between the three items, wRepelEffect already set refuses -- without touching either the counter or the bag, and only the success arm -- goes through UseDisposableItem. --- -- Scoped in its own `do` block: the file is already brushing Lua's 200-local -- ceiling, and this test needs its own world/game/pack rather than reusing -- the rod fixtures above. @@ -1760,13 +1824,11 @@ else end -- ------------------------------------------------------- the script VM hooks --- -- The 81 opcodes the interpreter grew are inert without these: an `appear` that -- reaches no World never spawns the object, a `changeblock` never opens the -- door, and a `warpcheck` never drops the player through the hole. Every hook -- the VM guards with `if self.xFn then` is asserted here against real World -- behaviour rather than against the closure that forwards to it. --- -- The world under test is built with World.new and then poked directly: the -- constructor is love-free, and each method below is the WORLD half of one -- transcribed command, so a stub map and a stub save are the whole rig. @@ -1852,7 +1914,6 @@ eq(hookWorld({ version = "silver" }):gsVersion(), 1, "and a Silver save is 1") -- reload comes back with every flag the player set and with each map still on -- the scene it had reached. World:load runs this before the first setMap; -- these are the halves of it on their own. --- -- The seed is InitializeEventsScript's setevent list, which the cache carries -- as data/generated/initial_events.lua. Three of its ids stand in for it here: -- EVENT_ILEX_FOREST_APPRENTICE 1794, EVENT_EARLS_ACADEMY_EARL 1739, and @@ -1912,7 +1973,6 @@ end -- it rode the save, a reload walked the player off the BICYCLE and off the -- water: everything the state decides follows from this one field, so all -- three of the things it decides are checked here. --- -- Wrapped in a function for the same reason bikeChecks is, and called on the -- spot rather than through a name of its own: block locals count against the -- main chunk's 200-local ceiling and this file has none left. @@ -2363,21 +2423,36 @@ check(doorWorld:busy(), "the setup script is a blocking call, so the world is busy for it") -- engine/tilesets/timeofday_pals.asm:115-128 do - local doorOut = World.FADE_STEPS * World.FADE_STEP_FRAMES - local doorHold = World.FADE_STEP_FRAMES + World.MAP_LOAD_WHITE_FRAMES - + World.FADE_STEP_FRAMES + -- (engine/tilesets/timeofday_pals.asm:160-187) + -- follows it is pure white (home/lcd.asm:35-72) + local doorOut = (World.FADE_STEPS + 1) * World.FADE_STEP_FRAMES + local doorHold = World.MAP_LOAD_WHITE_FRAMES + World.FADE_STEP_FRAMES local doorIn = (World.FADE_STEPS - 1) * World.FADE_STEP_FRAMES local doorChain = doorOut + doorHold + doorIn for _ = 1, doorOut - 1 do doorWorld:updateMapSetup() end check(doorLoad == nil, "one frame short of the fade out, it is still running") eq(doorWorld.fade, "white", "and the sheet it fades to is FillWhiteBGColor's") + eq(doorWorld.fadeLevel, 1, "whose last row is the colour-0 plane") + check(doorWorld.fadeWhiten, + "drawn through the remap with pals 1-6 whitened, not as a white sheet") + eq(doorWorld.fadeHold, nil, "so nothing holds the flat sheet yet") doorWorld:updateMapSetup() - check(doorLoad ~= nil, "the last fade-out frame is where the load lands") + check(doorLoad ~= nil, "the load lands after that last row, under the LCD") eq(doorLoad.id, "OTHER_MAP", "on the destination map") eq(doorLoad.x, 1, "at the destination WARP's own cell") eq(doorWorld.fadeLevel, 1, "with the sheet re-armed, because the load cleared it") - for _ = 1, doorHold + doorIn - 1 do doorWorld:updateMapSetup() end + eq(doorWorld.fadeHold, World.MAP_LOAD_WHITE_FRAMES, + "and the pure white held for the LCD-off window alone") + eq(doorWorld.fadeWhiten, nil, + "LoadMapPalettes put the plain time-of-day set back") + for _ = 1, World.MAP_LOAD_WHITE_FRAMES do doorWorld:updateMapSetup() end + eq(doorWorld.fadeHold, nil, "the LCD comes back on") + eq(doorWorld.fadeLevel, 1, + "on FadeInFromWhite's own first row, a palette row again") + for _ = 1, doorHold + doorIn - 1 - World.MAP_LOAD_WHITE_FRAMES do + doorWorld:updateMapSetup() + end check(doorWorld.mapSetup ~= nil, "the fade in runs out the remaining rows") doorWorld:updateMapSetup() check(doorWorld.mapSetup == nil, @@ -2569,11 +2644,9 @@ phoneWorld:setSpecialCall(0) eq(phoneWorld:specialCall(), 0, "and SPECIALCALL_NONE clears it") -- ---- roaming legendaries -------------------------------------------------- --- -- engine/overworld/wildmons.asm InitRoamMons / CheckEncounterRoamMon / -- UpdateRoamMons / JumpRoamMons, and data/wild/roammon_maps.asm. All of it is -- pure state, so none of these need a map loaded. --- -- Wrapped in a function because Lua 5.1 caps a chunk at 200 active locals and -- this suite is already close to it; the alternative is renaming everything -- above, which would make the diff lie about what changed. @@ -2795,7 +2868,6 @@ check(Roamers.afterWildBattle(driftSave, "ROUTE_30", seeded({ 16, 2, 1, 1 })), "and the sixteenth moves them") -- ---- swarms --------------------------------------------------------------- --- -- engine/events/specials.asm StoreSwarmMapIndices / SetSwarmFlag / -- CheckSwarmFlag, and _SwarmWildmonCheck's place in front of the normal table. @@ -3109,10 +3181,8 @@ check(selGame.save.registeredItem == nil, end -- ---- the BICYCLE ---------------------------------------------------------- --- -- BikeFunction (engine/events/overworld.asm) end to end: where the bike may be -- got on, what the queued script does, and the Cycling Road's two flags. --- -- Wrapped in a function rather than a `do` block: block locals still count -- against the main chunk's 200-local ceiling and this file is already near it. local function bikeChecks() diff --git a/tests/goldens/fixture_fingerprint.txt b/tests/goldens/fixture_fingerprint.txt index a8d8ae3b..590c14a4 100644 --- a/tests/goldens/fixture_fingerprint.txt +++ b/tests/goldens/fixture_fingerprint.txt @@ -1 +1 @@ -6e6c62281edfe2ac +d5491d0a4fa2860e diff --git a/tests/mod_battle_tests.lua b/tests/mod_battle_tests.lua index e4e6db37..6bc8ab47 100644 --- a/tests/mod_battle_tests.lua +++ b/tests/mod_battle_tests.lua @@ -633,21 +633,41 @@ do local game = uiGame({ mon }) Bag.add(game.save, "RARE_CANDY", 1) game.stack:push(BagMenu.new(game)) - for _ = 1, 800 do + local pickerMessages = {} + for _ = 1, 1000 do local top = game.stack:top() if not top then break end + for _, s in ipairs(game.stack.states) do + if s.bottomMessage then + pickerMessages[s:bottomMessage()] = true + end + end pressed = { a = true } top:update(1) pressed = {} end check(game.stack:top() == nil, "the candy flow runs to completion") - return mon + return mon, pickerMessages end - local fed = candyFlow() + local fed, seenMessages = candyFlow() check(fed.level == 16, "the candy levels the mon") check(fed.species == "CHARMELEON", "the level evolution fires afterwards") + -- engine/items/item_effects.asm:1392-1394 + local sawLevelText, sawMarker = false, false + for msg in pairs(seenMessages) do + if msg:find("grew", 1, true) and msg:find("level 16", 1, true) then + sawLevelText = true + end + if msg:find("{DONE}", 1, true) or msg:find("{PROMPT}", 1, true) then + sawMarker = true + end + end + check(sawLevelText, "the party picker's bottom box holds the level text") + check(not sawMarker, + "and no bottom message carries a {DONE}/{PROMPT} terminator") + local unsub = hooks:wrap("evolution.check", function() return false end) local blocked = candyFlow() check(blocked.level == 16, "the cancel hook leaves the level gain alone") diff --git a/tests/parity_E.lua b/tests/parity_E.lua index 6bd52abf..56571ab5 100644 --- a/tests/parity_E.lua +++ b/tests/parity_E.lua @@ -129,7 +129,7 @@ do check(not game.save.flags.EVENT_GAVE_FOSSIL_TO_LAB, "no fossil: GAVE_FOSSIL_TO_LAB not set") check(not game.save.flags.EVENT_LAB_STILL_REVIVING_FOSSIL, "no fossil: STILL_REVIVING not set") eq(#game.save.party, 0, "no fossil: party unchanged") - eq(shownTexts[#shownTexts], "No! Is too bad!", "no fossil shows NoFossilsText") + eq(shownTexts[#shownTexts], "No! Is too bad!{DONE}", "no fossil shows NoFossilsText") end -- === 2)-5) full deposit -> pending -> re-entry -> grant cycle === @@ -163,7 +163,7 @@ do check(game.save.flags.EVENT_LAB_STILL_REVIVING_FOSSIL == true, "same-visit re-talk: STILL_REVIVING still set") eq(#game.save.party, 0, "same-visit re-talk: still no mon granted") - eq(shownTexts[#shownTexts], "I take a little\ntime!\fYou go for walk a\nlittle while!", + eq(shownTexts[#shownTexts], "I take a little\ntime!\fYou go for walk a\nlittle while!{DONE}", "same-visit re-talk shows GoForAWalkText") -- 4) leaving and re-entering CINNABAR_ISLAND (its onEnter) clears @@ -219,7 +219,7 @@ do game.save.inventory.DOME_FOSSIL = 1 menuPick = "cancel" check(talk(game), "menu-cancel talk completes") - eq(shownTexts[#shownTexts], "Aiyah! You come\nagain!", "menu B-out shows ComeAgainText") + eq(shownTexts[#shownTexts], "Aiyah! You come\nagain!{DONE}", "menu B-out shows ComeAgainText") eq(game.save.inventory.DOME_FOSSIL, 1, "fossil kept after menu cancel") check(not game.save.flags.EVENT_GAVE_FOSSIL_TO_LAB, "menu cancel sets no quest flags") eq(game.save.labFossilMon, nil, "menu cancel leaves no pending species") @@ -231,7 +231,7 @@ do game.save.inventory.DOME_FOSSIL = 1 menuPick, choiceAnswer = 1, false check(talk(game), "confirm-NO talk completes") - eq(shownTexts[#shownTexts], "Aiyah! You come\nagain!", "NO on the confirm shows ComeAgainText") + eq(shownTexts[#shownTexts], "Aiyah! You come\nagain!{DONE}", "NO on the confirm shows ComeAgainText") eq(game.save.inventory.DOME_FOSSIL, 1, "fossil kept after NO") check(not game.save.flags.EVENT_GAVE_FOSSIL_TO_LAB, "NO sets no quest flags") eq(game.save.labFossilMon, nil, "NO leaves no pending species") diff --git a/tests/parity_L.lua b/tests/parity_L.lua index 11c8ae37..4171ac49 100644 --- a/tests/parity_L.lua +++ b/tests/parity_L.lua @@ -89,7 +89,7 @@ eq(u.mon.status, "PSN", "user's own major status is kept (still poisoned)") -- Target's major status is cured; its SLP forfeits the move this turn. eq(t.mon.status, nil, "target's major status is cured") check(t.skipMove == true, "curing target's sleep forfeits its move (skipMove)") -eq(msg[1], "All STATUS changes\nare eliminated!", "Haze prints the elimination text") +eq(msg[1], "All STATUS changes\nare eliminated!{PROMPT}", "Haze prints the elimination text") -- FRZ target also forfeits its move. local frz = battler{ mon = { status = "FRZ" }, name = "FROZEN" } @@ -147,27 +147,67 @@ local dBurnedHaze = Damage.compute(ruleset, hazedAtk, defender, move, opts) check(dBurnedRaw < dHealthy, "burn halves a burned mon's physical damage (sanity)") eq(dBurnedHaze, dHealthy, "Haze lifts the burn Attack-halving (damage == unburned)") --- A stat-stage change re-bakes the penalty (effects.asm:505-506). Bump the --- attacker's DEFENSE (irrelevant to its own offense) so only hazeStatReset flips. -MoveEffects.primary.DEFENSE_UP1_EFFECT(B, hazedAtk, nil) -check(hazedAtk.hazeStatReset == nil, "a stat-stage change re-arms the burn penalty") +-- pokered engine/battle/effects.asm:414-415 +local faithful = require("src.battle.rulesets.gen1_faithful") +local BF = { data = Data, ruleset = faithful } +BF.player, BF.enemy = hazedAtk, defender +MoveEffects.primary.DEFENSE_UP1_EFFECT(BF, hazedAtk, nil) +check(hazedAtk.hazeStatReset == nil, "a stat-stage change clears the Haze flag") local dAfter = Damage.compute(ruleset, hazedAtk, defender, move, opts) -eq(dAfter, dBurnedRaw, "burn Attack-halving returns after the stage change") +eq(dAfter, dHealthy, "a self stat-up does not re-halve the user's own attack") + +local screeched = attacker("BRN", nil) +local BD = { data = Data, ruleset = faithful, player = defender, enemy = screeched } +MoveEffects.primary.DEFENSE_DOWN2_EFFECT(BD, defender, screeched) +local dScreech = Damage.compute(ruleset, screeched, defender, move, opts) +check(dScreech < dBurnedRaw, "burn + SCREECH compounds the Attack-halving") -- ===================================================================== --- (C) New quirk: Haze temporarily lifts the paralysis Speed-quartering. +-- pokered engine/battle/effects.asm:414-415 -- ===================================================================== -local para = battler{ - curStats = { speed = 100, attack = 50, defense = 50, special = 50, hp = 100 }, - mon = { status = "PAR", level = 50, stats = { hp = 100 } }, name = "PARA", -} +local function paralyzed() + return battler{ + curStats = { speed = 100, attack = 50, defense = 50, special = 50, hp = 100 }, + mon = { status = "PAR", level = 50, stats = { hp = 100 } }, name = "PARA", + } +end + +local para = paralyzed() eq(TurnOrder.effectiveSpeed(para), 25, "paralysis quarters speed before Haze (100 -> 25)") MoveEffects.primary.HAZE_EFFECT(B, para, battler{ mon = {} }) eq(TurnOrder.effectiveSpeed(para), 100, "Haze lifts paralysis Speed-quartering") --- Re-arm via an ATTACK stage change (irrelevant to the speed calc). -MoveEffects.primary.ATTACK_UP1_EFFECT(B, para, nil) -check(para.hazeStatReset == nil, "stage change re-arms the paralysis penalty") -eq(TurnOrder.effectiveSpeed(para), 25, "Speed-quartering resumes after the stage change") +local BP = { data = Data, ruleset = faithful, player = para, enemy = battler{ mon = {} } } +MoveEffects.primary.ATTACK_UP1_EFFECT(BP, para, nil) +check(para.hazeStatReset == nil, "the stage change clears the Haze flag") +eq(TurnOrder.effectiveSpeed(para), 100, + "the quarter does not come back after the stage change") + +local agile = paralyzed() +local BA = { data = Data, ruleset = faithful, player = agile, enemy = battler{ mon = {} } } +MoveEffects.primary.SPEED_UP2_EFFECT(BA, agile, nil) +eq(TurnOrder.effectiveSpeed(agile), 200, "paralysis then AGILITY: 100 -> 200, no quarter") + +-- top of the boosted stat (move_effects/paralyze.asm:35) +local boosted = battler{ + curStats = { speed = 100, attack = 50, defense = 50, special = 50, hp = 100 }, + mon = { level = 50, stats = { hp = 100 } }, name = "BOOST", +} +local BB = { data = Data, ruleset = faithful, player = boosted, enemy = battler{ mon = {} } } +MoveEffects.primary.SPEED_UP2_EFFECT(BB, boosted, nil) +require("src.battle.StatusRegistry").inflict(BB, boosted, "PAR", {}) +eq(TurnOrder.effectiveSpeed(boosted), 50, "AGILITY then paralysis: 200 -> 50") + +-- QuarterSpeedDueToParalysis (effects.asm:697-698) +local growled = paralyzed() +local BG = { data = Data, ruleset = faithful, player = battler{ mon = {} }, enemy = growled } +MoveEffects.primary.ATTACK_DOWN1_EFFECT(BG, BG.player, growled) +eq(TurnOrder.effectiveSpeed(growled), 6, "paralysis + GROWL quarters speed twice") + +local modern = require("src.battle.rulesets.modern_clean") +local mc = paralyzed() +local BM = { data = Data, ruleset = modern, player = mc, enemy = battler{ mon = {} } } +MoveEffects.primary.SPEED_UP2_EFFECT(BM, mc, nil) +eq(TurnOrder.effectiveSpeed(mc), 50, "modern_clean quarters the boosted speed") S.finish() diff --git a/tests/parity_cycling_road_brake.lua b/tests/parity_cycling_road_brake.lua index de8b9052..c45d4253 100644 --- a/tests/parity_cycling_road_brake.lua +++ b/tests/parity_cycling_road_brake.lua @@ -137,4 +137,98 @@ do Game.save.onBike = false end +-- DoBikeSpeedup (home/overworld.asm:377) +do + local o = freshRoad() + check(o.player.slopeMap, "the Cycling Road player is flagged as on a slope map") + o:handleInput() + check(o.player.moving, "the forced roll starts a step") + eq(o.player.stepFramesCur, o.player.bikeStepFrames, + "rolling DOWN the slope is a full-speed bike step") + check(o.player.bikeStepFrames < o.player.stepFrames, + "the bike step is shorter than the walking step") +end + +for _, d in ipairs({ "up", "left", "right" }) do + Input:reset() + Game.save.onBike = true + while Game.stack:top() do Game.stack:pop() end + Game.stack:push(OW, "ROUTE_17", 7, 20, "down") + local o = Game.stack:top() + o.player.moving = false + o.player.turnTimer = 0 + check(o.map:isWalkableCell(7 + (d == "left" and -1 or d == "right" and 1 or 0), + 20 + (d == "up" and -1 or 0)), + "the cell " .. d .. " of the test cell is open road") + Input.state[d] = true + Input.pressed = {} + o:handleInput() + o.player.turnTimer = 0 + o:handleInput() + check(o.player.moving, d .. " starts a step on the slope") + eq(o.player.facing, d, d .. " is the step direction") + eq(o.player.stepFramesCur, o.player.stepFrames, + d .. " across the slope costs a WALKING step") + Input:reset() +end + +do + Input:reset() + Game.save.onBike = true + while Game.stack:top() do Game.stack:pop() end + Game.stack:push(OW, "ROUTE_16", 11, 4, "up") + local o = Game.stack:top() + eq(o.map.id, "ROUTE_16", "standing on Route 16") + check(not o.player.slopeMap, "Route 16 is not a slope map") + check(o.map:isWalkableCell(11, 3), "the cell north is open") + Input.state.up = true + Input.pressed = {} + o:handleInput() + o.player.turnTimer = 0 + o:handleInput() + check(o.player.moving, "UP starts a step off the slope") + eq(o.player.stepFramesCur, o.player.bikeStepFrames, + "UP off the slope is still a full-speed bike step") + Input:reset() + Game.save.onBike = false +end + +-- The roll is an injected hJoyHeld bit (home/overworld.asm:1817) +do + Input:reset() + Game.save.onBike = true + while Game.stack:top() do Game.stack:pop() end + Game.stack:push(OW, "ROUTE_17", 7, 135, "down") + local o = Game.stack:top() + o.player.moving = false + o.player.turnTimer = 0 + check(o.map:isWalkableCell(7, 142), "the road runs down to cy 142") + check(not o.map:isWalkableCell(7, 143), + "the row below it is the impassable ledge tile") + for _ = 1, 900 do + if o.map.id ~= "ROUTE_17" then break end + o:update(1 / 60) + o = Game.stack:top() + end + eq(o.map.id, "ROUTE_18", "the roll hops the bottom ledge onto Route 18") + Input:reset() + Game.save.onBike = false +end + +do + Input:reset() + Game.save.onBike = true + while Game.stack:top() do Game.stack:pop() end + Game.stack:push(OW, "ROUTE_17", 9, 140, "down") + local o = Game.stack:top() + o.player.moving = false + o.player.turnTimer = 0 + check(not o.map:isWalkableCell(9, 141), "the cell below is a wall, not a ledge") + for _ = 1, 120 do o:update(1 / 60) end + eq(o.map.id, "ROUTE_17", "a blocked roll does not teleport anywhere") + eq(o.player.cellY, 140, "and the player has not moved") + Input:reset() + Game.save.onBike = false +end + S.finish() diff --git a/tests/parity_dungeon_warp_holes.lua b/tests/parity_dungeon_warp_holes.lua new file mode 100644 index 00000000..18efe0b5 --- /dev/null +++ b/tests/parity_dungeon_warp_holes.lua @@ -0,0 +1,109 @@ +-- engine/overworld/player_animations.asm:204 +-- door warp. home/overworld.asm:689-703 +-- SFX_GO_INSIDE only on $0B; pokeyellow home/overworld.asm:667-671 +-- Self-contained; run via `luajit tests/parity_dungeon_warp_holes.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local S = require("tests.harness").suite("parity dungeon warp holes") +local check, eq = S.check, S.eq + +local M = dofile("data/scripts/story6.lua") +local m3 = M.POKEMON_MANSION_3F +check(m3 ~= nil and m3.onStep ~= nil, "POKEMON_MANSION_3F has an onStep hole trigger") + +local function owRecording() + local warps = {} + return { + player = { facing = "down" }, + fallThroughHole = function(_, mapId, x, y, facing) + warps[#warps + 1] = { mapId = mapId, x = x, y = y, facing = facing } + end, + startWarpTo = function() + error("a hole fall must not take the plain door-warp path (#2182)") + end, + }, warps +end + +local CASES = { + { 16, 14, "POKEMON_MANSION_1F", 16, 14 }, + { 17, 14, "POKEMON_MANSION_1F", 16, 14 }, + { 19, 14, "POKEMON_MANSION_2F", 18, 14 }, +} + +for _, c in ipairs(CASES) do + local ow, warps = owRecording() + check(m3.onStep({}, ow, c[1], c[2]), + "stepping on (" .. c[1] .. "," .. c[2] .. ") is consumed") + eq(#warps, 1, "exactly one dungeon warp fires from " .. c[1] .. "," .. c[2]) + eq(warps[1].mapId, c[3], "falls to " .. c[3]) + eq(warps[1].x, c[4], "lands at x=" .. c[4]) + eq(warps[1].y, c[5], "lands at y=" .. c[5]) + eq(warps[1].facing, "down", "facing is preserved across the fall") +end + +do + local ow, warps = owRecording() + eq(m3.onStep({}, ow, 18, 14), false, "a neighbouring floor cell is ignored") + eq(#warps, 0, "no warp fires off the hole") +end + +-- engine/overworld/player_animations.asm:43 +local Timing = require("src.core.Timing") +eq(Timing.DUNGEON_WARP_ARRIVAL, 50, "the dungeon-warp arrival hold is 50 frames") + +-- home/overworld.asm:689-703 +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end +local MapLoader = require("src.world.MapLoader") +local FieldDefaults = require("src.world.FieldDefaults") + +local rule = FieldDefaults.field(Data, "mapChangeSound") +check(rule ~= nil, "field.mapChangeSound exists") +eq(rule.doorTile, 0x0B, "the inside cue is gated on tile $0B") +local exempt = {} +for _, ts in ipairs(rule.exemptTilesets or {}) do exempt[ts] = true end +check(exempt.FACILITY and exempt.CEMETERY, + "pokeyellow exempts FACILITY and CEMETERY") + +local function topLeft(mapId, cx, cy) + local map = MapLoader.load(Data, mapId) + return map:tileAt(cx * 2, cy * 2) +end + +local function findDoorCell(mapId) + local map = MapLoader.load(Data, mapId) + for cy = 0, map.heightCells - 1 do + for cx = 0, map.widthCells - 1 do + if map:isDoorTileCell(cx, cy) then return cx, cy end + end + end +end + +do + local cx, cy = findDoorCell("PALLET_TOWN") + check(cx ~= nil, "PALLET_TOWN has a door cell") + if cx then + eq(topLeft("PALLET_TOWN", cx, cy), 0x0B, + "an outdoor door cell is $0B-topped (Go_Inside)") + end +end + +local INSIDE_CELLS = { + { "REDS_HOUSE_1F", "stairs" }, + { "ROCK_TUNNEL_B1F", "cave ladder" }, + { "INDIGO_PLATEAU_LOBBY", "league lobby exit" }, +} +for _, c in ipairs(INSIDE_CELLS) do + local map = MapLoader.load(Data, c[1]) + local hit = false + for cy = 0, map.heightCells - 1 do + for cx = 0, map.widthCells - 1 do + if map:isWarpTileCell(cx, cy) and map:tileAt(cx * 2, cy * 2) == 0x0B then + hit = true + end + end + end + check(not hit, c[1] .. " has no $0B-topped warp cell (" .. c[2] .. ")") +end + +S.finish() diff --git a/tests/parity_ghost_marowak.lua b/tests/parity_ghost_marowak.lua index 76def148..d88457ae 100644 --- a/tests/parity_ghost_marowak.lua +++ b/tests/parity_ghost_marowak.lua @@ -38,7 +38,7 @@ do local b = RealBattleState.newWild(game, "MAROWAK", 30) b:makeGhost() eq(b.enemy.name, "GHOST", "the disguise renames the foe") - eq(b.introText, "GHOST\nappeared!", + eq(b.introText, "GHOST\nappeared!{PROMPT}", "_EnemyAppearedText has no article in front of the nick") end diff --git a/tests/parity_rare_candy_menu.lua b/tests/parity_rare_candy_menu.lua index f6f6294b..cf3f7e14 100644 --- a/tests/parity_rare_candy_menu.lua +++ b/tests/parity_rare_candy_menu.lua @@ -31,9 +31,11 @@ local realBag = package.loaded["src.ui.BagMenu"] local realParty = package.loaded["src.ui.PartyMenu"] -- soundOpts only builds an opts table, so the real one runs against the stub. local soundOpts = require("src.render.TextBox").soundOpts +local strip = require("src.render.TextBox").strip package.loaded["src.render.TextBox"] = { new = function(_, text, done) return { textBox = true, text = text, done = done } end, soundOpts = soundOpts, + strip = strip, } package.loaded["src.ui.BagMenu"] = nil package.loaded["src.ui.PartyMenu"] = nil @@ -118,6 +120,22 @@ local function useCandy(game, list) return game.stack:top() end +-- engine/menus/start_sub_menus.asm:414-416 +local function isFlash(s) + return type(s) == "table" and s.t ~= nil and s.frames ~= nil +end + +local function drainFlash(game) + local top = game.stack:top() + if not isFlash(top) then return false end + local n = 0 + while game.stack:top() == top and n < 240 do + top:update(1 / 60) + n = n + 1 + end + return true +end + -- Walk the rest of the level-up sequence: dismiss the level text, then A -- through the stat window (PrintStatsBox). local function finishLevelUp(game, box) @@ -129,6 +147,7 @@ local function finishLevelUp(game, box) top:update(1 / 60) game.input.pressed = nil end + return drainFlash(game) end -- ---- the report: one candy closed the whole menu ------------------------- @@ -149,8 +168,21 @@ do -- (item_effects.asm:1392-1418) #1594 check(isPicker(game.stack.states[2]), "the party picker is the backdrop for the level text (#1594)") - - finishLevelUp(game, box) + -- (pokered engine/items/item_effects.asm:1392-1394) + local picker = game.stack.states[2] + local levelLine = box.text:gsub("{DONE}%s*$", ""):gsub("{PROMPT}%s*$", "") + eq(picker:bottomMessage(), levelLine, + "the party menu's bottom box holds the level text, not the item prompt (#2161)") + dismiss(game.stack, box) + check(not isPicker(game.stack:top()), + "PrintStatsBox is up over the party menu") + eq(picker:bottomMessage(), levelLine, + "and it survives into PrintStatsBox (item_effects.asm:1403-1404)") + game.input.pressed = "a" + game.stack:top():update(1 / 60) + game.input.pressed = nil + check(drainFlash(game), + "the bag return whites out (start_sub_menus.asm:414-416) #2125") for _, s in ipairs(game.stack.states) do check(not isPicker(s), "the picker comes down when the sequence ends") end diff --git a/tests/parity_rival_walkoff.lua b/tests/parity_rival_walkoff.lua index 5a27469d..1124ddbc 100644 --- a/tests/parity_rival_walkoff.lua +++ b/tests/parity_rival_walkoff.lua @@ -37,6 +37,20 @@ local function findWalk(rows) end end +local function lastWalk(rows) + local found + for _, r in ipairs(rows) do + if r[1] == "walk_npc" then found = r end + end + return found +end + +local function findRow(rows, name) + for i, r in ipairs(rows) do + if r[1] == name then return r, i end + end +end + local function texts(rows) local out = {} for _, r in ipairs(rows) do @@ -114,15 +128,14 @@ do check(hasBill, "Cerulean post-battle includes Bill dialogue") local order = { "_CeruleanCityRivalPreBattleText", - "_CeruleanCityRivalDefeatedText", "_CeruleanCityRivalIWentToBillsText", } local pi = 1 for _, id in ipairs(t) do if pi <= #order and id == order[pi] then pi = pi + 1 end end - eq(pi, #order + 1, "Cerulean text order: pre, defeated, Bill") - check(dirsEqual(findWalk(rows)[3], CER_X20), + eq(pi, #order + 1, "Cerulean text order: pre, Bill") + check(dirsEqual(lastWalk(rows)[3], CER_X20), "Cerulean x=20 exit: right then into town") for _, r in ipairs(rows) do check(not (r[1] == "move_npc_to" and r[4] == 2), @@ -132,10 +145,101 @@ end do local rows = capture(story5.CERULEAN_CITY, { save = { flags = {} }, data = {} }, 21, 6) - check(dirsEqual(findWalk(rows)[3], CER_X21), + check(dirsEqual(lastWalk(rows)[3], CER_X21), "Cerulean x=21 exit: left then into town") end +-- #2149 scripts/CeruleanCity.asm:84-99 +for _, px in ipairs({ 20, 21 }) do + local rows = capture(story5.CERULEAN_CITY, { save = { flags = {} }, data = {} }, px, 6) + local place = findRow(rows, "place_npc") + check(place ~= nil, ("Cerulean x=%d places the rival before he walks"):format(px)) + eq(place[2], 1, "place_npc addresses CERULEANCITY_RIVAL (object 1)") + eq(place[3], px, ("the rival spawns in the player's column (x=%d)"):format(px)) + eq(place[4], 2, "the rival spawns on the object_event home row 2") + check(dirsEqual(findWalk(rows)[3], { "down", "down", "down" }), + ("Cerulean x=%d approach is CeruleanCityMovement1"):format(px)) + check(findRow(rows, "move_npc_to") == nil, + "the BFS walk is gone, so no fourth sideways step") +end + +-- #2151 scripts/CeruleanCity.asm:141 +local function assertEndBattleText(rows, what) + local save, si = findRow(rows, "save_end_battle_text") + check(save ~= nil, what .. " arms save_end_battle_text") + eq(save[2], "_CeruleanCityRivalDefeatedText", what .. " arms the loss line") + local _, bi = findRow(rows, "rival_battle") + eq(si, bi - 1, what .. " arms it on the row before rival_battle") + for _, r in ipairs(rows) do + check(not (r[1] == "show_text" and r[2] == "_CeruleanCityRivalDefeatedText"), + what .. " no longer prints the loss line from the script queue") + end +end + +do + local rows = capture(story5.CERULEAN_CITY, { save = { flags = {} }, data = {} }, 21, 6) + assertEndBattleText(rows, "the Cerulean ambush") + assertEndBattleText(story.CERULEAN_CITY.talk.TEXT_CERULEANCITY_RIVAL, + "the Cerulean talk path") +end + +do + local ScriptRunner = require("src.script.ScriptRunner") + local anyVerb = function() return true end + local scenes = { + ["Cerulean ambush x=20"] = + capture(story5.CERULEAN_CITY, { save = { flags = {} }, data = {} }, 20, 6), + ["Cerulean ambush x=21"] = + capture(story5.CERULEAN_CITY, { save = { flags = {} }, data = {} }, 21, 6), + ["Cerulean talk"] = story.CERULEAN_CITY.talk.TEXT_CERULEANCITY_RIVAL, + ["S.S. Anne 2F x=36"] = + capture(story5.SS_ANNE_2F, { save = { flags = {} }, data = {} }, 36, 8), + ["S.S. Anne 2F x=37"] = + capture(story5.SS_ANNE_2F, { save = { flags = {} }, data = {} }, 37, 8), + } + for name, rows in pairs(scenes) do + local problems = ScriptRunner.validate(rows, anyVerb) + eq(#problems, 0, name .. " jump targets resolve: " + .. table.concat(problems, "; ")) + end +end + +-- #2164 scripts/SSAnne2F.asm:73 +do + local game = { save = { flags = {} }, data = {} } + local kept + local function captureFacing(x) + local rows + local ow = { + runner = { + isRunning = function() return false end, + run = function(_, r) rows = r end, + }, + player = { facing = "right" }, + npcByIndex = function() return { def = { name = "X" } } end, + } + check(story5.SS_ANNE_2F.onStep(game, ow, x, 8), + ("SS Anne onStep fires at (%d,8)"):format(x)) + kept = ow.player.facing + return rows + end + + local right = captureFacing(37) + eq(kept, "right", "the player keeps his walking facing while the rival comes down") + local turn, ti = findRow(right, "face_player_dir") + check(turn ~= nil, "x=37 turns the player as a scripted row") + eq(turn[2], "left", "x=37 turns the player left") + local _, mi = findRow(right, "move_npc_to") + local _, xi = findRow(right, "show_text") + check(ti > mi, "the turn happens after the rival's walk") + check(ti < xi, "the turn happens before the dialogue box") + + local left = captureFacing(36) + eq(kept, "right", "x=36 leaves the player's facing alone") + check(findRow(left, "face_player_dir") == nil, + "x=36 never writes wPlayerMovingDirection") +end + do local talk = story.CERULEAN_CITY.talk.TEXT_CERULEANCITY_RIVAL check(talk ~= nil, "talk script for CERULEANCITY_RIVAL exists") diff --git a/tests/parity_seafoam_holes.lua b/tests/parity_seafoam_holes.lua index 8de99096..054ac95e 100644 --- a/tests/parity_seafoam_holes.lua +++ b/tests/parity_seafoam_holes.lua @@ -20,9 +20,13 @@ local function owRecording() local warps = {} return { player = { facing = "up" }, - startWarpTo = function(_, mapId, x, y, facing) + -- player_animations.asm:204 + fallThroughHole = function(_, mapId, x, y, facing) warps[#warps + 1] = { mapId = mapId, x = x, y = y, facing = facing } end, + startWarpTo = function() + error("a hole fall must not take the plain door-warp path (#2182)") + end, }, warps end diff --git a/tests/parity_ss_anne_departure.lua b/tests/parity_ss_anne_departure.lua index 41f42c24..500c06c8 100644 --- a/tests/parity_ss_anne_departure.lua +++ b/tests/parity_ss_anne_departure.lua @@ -67,7 +67,7 @@ local function ambush(x) check(story5.SS_ANNE_2F.onStep(game, ow, x, 8), ("SS Anne 2F ambush fires at (%d,8)"):format(x)) check(rows ~= nil, "the ambush queued rows") - return rows + return rows, ow end do @@ -78,7 +78,25 @@ do for _, case in ipairs({ { 37, RIGHT_OF_HIM }, { 36, AROUND_HIM } }) do local x, dirs = case[1], case[2] - local rows = ambush(x) + local rows, ow = ambush(x) + -- scripts/SSAnne2F.asm:93 + eq(ow.player.facing, "down", + ("x=%d the trigger leaves the player's facing alone"):format(x)) + local function rowIndex(kind) + for i, r in ipairs(rows) do if r[1] == kind then return i end end + end + local turns = rowsOfKind(rows, "face_player_dir") + if x == 37 then + -- scripts/SSAnne2F.asm:73-77 + eq(#turns, 1, "x=37 turns the player as a script row") + eq(turns[1][2], "left", "and it turns him LEFT") + local at, moved, spoke = + rowIndex("face_player_dir"), rowIndex("move_npc_to"), rowIndex("show_text") + check(moved and at and spoke and moved < at and at < spoke, + "the turn lands after the rival's walk and before the dialogue") + else + eq(#turns, 0, "x=36 never touches the player's facing") + end local said = {} for _, r in ipairs(rowsOfKind(rows, "show_text")) do said[#said + 1] = r[2] @@ -111,7 +129,17 @@ do end -- jump_if_false on a lost battle has to clear the whole tail local jump = rowsOfKind(rows, "jump_if_false")[1] - eq(jump and jump[2], #rows, "a lost battle jumps past the exit walk") + local target = jump and jump[2] + if type(target) == "string" then + for i, r in ipairs(rows) do + if r[1] == "label" and r[2] == target then target = i break end + end + end + while type(target) == "number" and rows[target] + and rows[target][1] == "label" do + target = target + 1 + end + eq(target, #rows, "a lost battle jumps past the exit walk") end end diff --git a/tests/parity_text_markers.lua b/tests/parity_text_markers.lua index 7edd5c92..1f3cd085 100644 --- a/tests/parity_text_markers.lua +++ b/tests/parity_text_markers.lua @@ -76,7 +76,7 @@ end -- underscore, and the extractor now keys on both spellings, so the port's -- literal has to keep agreeing with what the cart actually says. do - local desc = "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!" + local desc = "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!{DONE}" local extracted = Data.text.ViridianCityYoungster2CaterpieAndWeedleDescriptionText or Data.text._ViridianCityYoungster2CaterpieAndWeedleDescriptionText if extracted then eq(extracted, desc, "the literal still matches the cart") end diff --git a/tests/parity_text_scroll_bounds.lua b/tests/parity_text_scroll_bounds.lua index e6e2fc69..89746d93 100644 --- a/tests/parity_text_scroll_bounds.lua +++ b/tests/parity_text_scroll_bounds.lua @@ -46,12 +46,13 @@ local game = { local box = TextBox.new(game, "OAK: Er-hem!\nCongratulations\vCHAMP!") local floorY = box.line2Y --- The page-advance arrow is the one glyph that belongs on the border: the --- original prints ▼ into the bottom-right border tile (home/text.asm), and --- TextBox draws it at a fixed slot 4px into that row. Everything else is --- body text and must stay inside. -local arrowX = (box.boxTx + box.boxTw - 2) * 8 -local arrowY = (box.boxTy + box.boxTh - 1) * 8 - 4 +-- pokered home/text.asm:214 +local arrowX, arrowY = 144, 128 +do + local px, py = box:arrowPos() + eq(px, arrowX, "arrowPos x is hlcoord 18 (home/text.asm:214)") + eq(py, arrowY, "arrowPos y is hlcoord 16 (home/text.asm:214)") +end local sawScroll, sawArrow, worst = false, false, -math.huge for _ = 1, 600 do @@ -78,7 +79,8 @@ check(sawScroll, "the box really did scroll (otherwise this proves nothing)") check(worst > -math.huge, "body glyphs were drawn") check(worst <= floorY, ("no body glyph drawn below line2Y (worst y=%d, line2Y=%d)"):format(worst, floorY)) -check(sawArrow, "the page-advance arrow still prints in the border row") +check(sawArrow, "the page-advance arrow still prints on the second text row") +eq(arrowY, floorY, "and shares that row's y") eq(box.line1Y + 16, box.line2Y, "the two text rows stay two tiles apart") S.finish() diff --git a/tests/parity_victory_road_hole.lua b/tests/parity_victory_road_hole.lua index f2d6da60..c45de2f0 100644 --- a/tests/parity_victory_road_hole.lua +++ b/tests/parity_victory_road_hole.lua @@ -23,9 +23,13 @@ local function owRecording() local warps = {} return { player = { facing = "right" }, - startWarpTo = function(_, mapId, x, y, facing) + -- player_animations.asm:204 + fallThroughHole = function(_, mapId, x, y, facing) warps[#warps + 1] = { mapId = mapId, x = x, y = y, facing = facing } end, + startWarpTo = function() + error("a hole fall must not take the plain door-warp path (#2182)") + end, _warps = warps, }, warps end diff --git a/tests/parity_yellow_bills_pikachu.lua b/tests/parity_yellow_bills_pikachu.lua index 20e9420d..de477f6e 100644 --- a/tests/parity_yellow_bills_pikachu.lua +++ b/tests/parity_yellow_bills_pikachu.lua @@ -34,7 +34,14 @@ GameVersion.set("yellow") local npc = { pikachuFollower = true, cellX = 3, cellY = 8, px = 48, py = 128, facing = "up", + -- pokeyellow engine/pikachu/pikachu_follow.asm:26 + passable = true, } +local Sound = require("src.core.Sound") +local realPikaCry = Sound.playPikaCry +local cry +Sound.playPikaCry = function(_, index) cry = index return true end + local moves = {} local yellowGame = { save = { flags = {} }, @@ -65,8 +72,20 @@ moves[1].onDone() check(#moves == 2 and moves[2].dir == "up" and moves[2].tiles == 1, "Pikachu finishes its cartridge entry route beside Bill") moves[2].onDone() -check(ow.emote and ow.emote.bubble == 1, +check(ow.emote and ow.emote.bubble == 1 and ow.emote.frames == 60 + and not ow.emote.pikaPic, "Pikachu shows its confused reaction after reaching Bill") +-- emotion 23 -- engine/pikachu/pikachu_emotions.asm:14 +check(npc.stepFrames == 16, + "the scripted walk runs at the Pikachu movement step length") +check(npc.passable, "Pikachu is walked through while it is still following") +ow.emote.onDone() +check(ow.emote and ow.emote.pikaPic and ow.emote.frames > 0 + and not ow.emote.skippable, + "and the bubble hands off to the unskippable emotion animation") +-- scripts/BillsHouse_2.asm:121, home/overworld.asm:1238-1240 +check(not npc.passable, + "DisablePikachuFollowingPlayer makes the parked companion solid") -- BillsHouseScript3 seeds hl with ..._EnterCellSeparatorDown and only swaps -- in ..._EnterCellSeparatorNotDown on the facing-down fallthrough, so the @@ -87,8 +106,10 @@ moves[5].onDone() check(#moves == 6 and moves[6].dir == "right" and moves[6].tiles == 1, "the detour steps back in beside it") moves[6].onDone() -check(ow.emote and ow.emote.bubble == 1 and npc.facing == "up", - "Pikachu looks up and wonders at the cell separator") +-- InitializePikachuTextID -- scripts/BillsHouse.asm:100 +check(ow.emote and ow.emote.bubble == false and ow.emote.pikaPic + and npc.facing == "up", + "Pikachu looks up at the cell separator with no bubble, just the emotion") -- the other branch: any non-down facing takes the straight three-step route local before = #moves @@ -105,8 +126,12 @@ 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 == 2 and npc.facing == "left", +check(ow.emote and ow.emote.bubble == 2 and ow.emote.frames == 60 + and npc.facing == "left", "Pikachu reacts when Bill comes back out") +ow.emote.onDone() +check(ow.emote and ow.emote.pikaPic, + "and emotion 27 plays behind that bubble") -- BillsHousePikachuWatchPlayer (scripts/BillsHouse_2.asm:133-156) is the -- other side of BillsHouseScript2: it only runs while Pikachu still follows @@ -186,5 +211,28 @@ check(not ow.pikachuBillsScene and #moves == 0, "a statused starter keeps following instead of walking over to Bill") yellowGame.save.party = nil +-- of the starter-status and mood fallbacks -- scripts/BillsHouse_2.asm:88 +npc.facePlayer = function(self) self.facing = "up" end +ow.player.facing = "down" + +ow.pikachuBillsScene = true +PikachuFollower.talk(yellowGame, ow, npc) +check(cry == 19, "talking during the confused beat picks emotion 23") + +ow.pikachuBillsScene = nil +PikachuFollower.talk(yellowGame, ow, npc) +check(cry == 26, "before Bill is met again it is emotion 32") + +yellowGame.save.flags.EVENT_MET_BILL_2 = true +PikachuFollower.talk(yellowGame, ow, npc) +check(cry == 19, "and emotion 31 once EVENT_MET_BILL_2 is set") + +yellowGame.save.party = { { species = "PIKACHU", hp = 12, status = "SLP" } } +PikachuFollower.talk(yellowGame, ow, npc) +check(cry == 19, "the map check still wins over a statused starter") +yellowGame.save.party = nil +yellowGame.save.flags.EVENT_MET_BILL_2 = nil +Sound.playPikaCry = realPikaCry + GameVersion.set("red") S.finish() diff --git a/tests/rby_translation_harvest_test.py b/tests/rby_translation_harvest_test.py index 88f64fce..b8ac354e 100644 --- a/tests/rby_translation_harvest_test.py +++ b/tests/rby_translation_harvest_test.py @@ -17,7 +17,6 @@ class RbyTranslationHarvestTest(TestCase): modkit.harvest_engine_strings(str(REPO))} expected = { '"FLY TO?"', - '"Which move?"', '"ITEMS"', '"HEAL"', '"WITHDRAW"', diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 8ef7d247..078aa7ba 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -1100,6 +1100,11 @@ do end return false end + -- constants/charmap.asm:19-20 + local function plain(s) + if type(s) ~= "string" then return s end + return (s:gsub("{DONE}%s*$", ""):gsub("{PROMPT}%s*$", "")) + end local function hasDrain(b) for _, it in ipairs(b.queue) do if it.drain then return true end @@ -1211,7 +1216,7 @@ do for _, r in ipairs(mhk.queue) do if r.anim == "DOUBLE_KICK" then seq[#seq + 1] = "anim" elseif r.drain then seq[#seq + 1] = "drain" - elseif r.text == "It's super\neffective!" then seq[#seq + 1] = "se" + elseif plain(r.text) == "It's super\neffective!" then seq[#seq + 1] = "se" elseif r.text and r.text:find("times!", 1, true) then seq[#seq + 1] = "count" end end @@ -1234,8 +1239,8 @@ do for _, r in ipairs(mhc.queue) do if r.anim == "DOUBLE_KICK" then cseq[#cseq + 1] = "anim" elseif r.drain then cseq[#cseq + 1] = "drain" - elseif r.text == "Critical hit!" then cseq[#cseq + 1] = "crit" - elseif r.text == "It's super\neffective!" then cseq[#cseq + 1] = "se" + elseif plain(r.text) == "Critical hit!" then cseq[#cseq + 1] = "crit" + elseif plain(r.text) == "It's super\neffective!" then cseq[#cseq + 1] = "se" end end eq(table.concat(cseq, ","), "anim,drain,crit,se,anim,drain,se", @@ -1243,7 +1248,7 @@ do -- data/text/text_2.asm:1144 local function rowFor(b, s) for _, r in ipairs(b.queue) do - if r.text == s then return r end + if plain(r.text) == s then return r end end return nil end @@ -1297,7 +1302,7 @@ do do local countRow for _, r in ipairs(mh2.queue) do - if r.text == "Hit 5 times!" then countRow = r end + if plain(r.text) == "Hit 5 times!" then countRow = r end end check(countRow ~= nil and countRow.auto ~= true, "the hit-count line stays readable until the command menu redraws") @@ -1492,10 +1497,14 @@ do check(pm.swapFrom == nil, "the swap arrow clears on the confirming A") check(pm.swapAnim ~= nil and pm.swapAnim.blank[1] == true, "wSwappedMenuItem's row blanks while SFX_SWAP plays") + check(pm.swapAnim.blank[3] ~= true, + "wCurrentMenuItem's row is still up on the first ClearGfx") StateStack:update(1 / 60) check(pm.swapAnim ~= nil, "the blank outlives the frame it started on") + check(pm.swapAnim.blank[3] == true, + "the second ClearGfx blanks wCurrentMenuItem's row too (#2126)") for _ = 1, 12 do StateStack:update(1 / 60) end - check(pm.swapAnim == nil, "RedrawPartyMenu_ restores the row") + check(pm.swapAnim == nil, "RedrawPartyMenu_ restores both rows") -- .pickedMonsToSwap (start_sub_menus.asm:711) pm.swapFrom, pm.index = 2, 2 Input.pressed = { a = true } @@ -1504,6 +1513,9 @@ do eq(Game.save.party[2].species, "CHARMANDER", "self-swap leaves the party alone") check(pm.swapAnim ~= nil and pm.swapAnim.blank[2] == true, "self-swap still blanks its own row") + StateStack:update(1 / 60) + check(pm.swapAnim.blank[2] == true and pm.swapAnim.to == 2, + "self-swap's second ClearGfx hits the same row (#2126)") for _ = 1, 12 do StateStack:update(1 / 60) end StateStack:pop() end diff --git a/tools/build_rom_data.py b/tools/build_rom_data.py index 86f21b85..a546f158 100755 --- a/tools/build_rom_data.py +++ b/tools/build_rom_data.py @@ -12,6 +12,7 @@ import argparse import copy import json import os +import re import shutil import sys from collections import deque @@ -1673,7 +1674,8 @@ def _text_glyph(value, charmap): if value in TEXT_GLYPH_OVERRIDES: return TEXT_GLYPH_OVERRIDES[value] glyph = charmap.get(str(value), f"{{BYTE:{value:02X}}}") - if glyph.startswith("<") and glyph.endswith(">"): + # home/text.asm:186 + if re.match(r"^<[^<>]*>$", glyph): return "{" + glyph[1:-1] + "}" return glyph @@ -1689,6 +1691,9 @@ def _decode_text_commands(rom, symbol, charmap, substitutions): if pending: raise ValueError( f"{symbol.name}: unused dynamic text substitutions") + # home/text.asm:221 + if out: + out.append("{DONE}") return "".join(out) if command == 0: while True: @@ -1700,6 +1705,9 @@ def _decode_text_commands(rom, symbol, charmap, substitutions): if pending: raise ValueError( f"{symbol.name}: unused dynamic text substitutions") + # constants/charmap.asm:19-20 + if value != 0x5F and out: + out.append("{PROMPT}" if value == 0x58 else "{DONE}") return "".join(out) out.append(_text_glyph(value, charmap)) continue diff --git a/tools/rom_manifest.json b/tools/rom_manifest.json index 46f4e842..bf904a99 100644 --- a/tools/rom_manifest.json +++ b/tools/rom_manifest.json @@ -1832,7 +1832,7 @@ "71": "\u307a", "72": "\u307d", "73": "\f", - "74": "POK\u00e9MON", + "74": "", "75": "<_CONT>", "76": "", "78": "\n", diff --git a/tools/rom_manifest_blue.json b/tools/rom_manifest_blue.json index 210f43f4..2fafb155 100644 --- a/tools/rom_manifest_blue.json +++ b/tools/rom_manifest_blue.json @@ -1832,7 +1832,7 @@ "71": "\u307a", "72": "\u307d", "73": "\f", - "74": "POK\u00e9MON", + "74": "", "75": "<_CONT>", "76": "", "78": "\n", diff --git a/tools/rom_manifest_yellow.json b/tools/rom_manifest_yellow.json index 6444ea23..30a40c28 100644 --- a/tools/rom_manifest_yellow.json +++ b/tools/rom_manifest_yellow.json @@ -1859,7 +1859,7 @@ "71": "ぺ", "72": "ぽ", "73": "\f", - "74": "POKéMON", + "74": "", "75": "<_CONT>", "76": "", "78": "\n", From 77f8c0ca7e64b2b012b4fa1bcdf97a7ac3e40e36 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 4 Sep 2026 09:20:36 -0400 Subject: [PATCH 02/10] battle: a refused status effect keeps its animation cancelled --- src/battle/BattleState.lua | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index e56309a6..a3cb3f86 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -4199,13 +4199,14 @@ local function primaryEffectFailed(msgs) if not msgs or #msgs == 0 then return true end if msgs.failed then return true end -- the extracted lines keep the ROM's own trailing blank ("But, it - -- failed! "), so match with it trimmed or a refused status animates - local m = msgs[1]:gsub("%s+$", "") + -- failed! ") and its terminator, so strip both or a refused status animates + local m = require("src.render.TextBox").strip(msgs[1]):gsub("%s+$", "") if m == "But, it failed!" or m == "Nothing happened!" then return true end if m:find("didn't affect", 1, true) then return true end if m:find("is unaffected", 1, true) then return true end if m:find("protected by MIST", 1, true) then return true end - if m:find("Already", 1, true) then return true end + -- engine/battle/effects.asm:46-47 + if m:lower():find("already asleep", 1, true) then return true end return false end From 16d684297eaff430608f462b683ed03680813380 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 4 Sep 2026 09:25:34 -0400 Subject: [PATCH 03/10] battle: status moves print the cart's refusal lines --- src/battle/MoveEffects.lua | 13 +++++++++++-- tests/run_tests.lua | 7 ++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index be6ed2f0..4f77c1c2 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -104,10 +104,19 @@ end local function statusMove(status) return function(battle, user, target, move) if target.mon.status then - return { romText(battle.data, "_ButItFailedText", "But, it failed!") } + -- engine/battle/effects.asm:44-47 + if status == "SLP" and target.mon.status == "SLP" then + return { romText(battle.data, "_AlreadyAsleepText", + "%s's\nalready asleep!", displayName(target)) } + end + -- effects.asm:51, move_effects/paralyze.asm:12 + return { romText(battle.data, "_DidntAffectText", + "It didn't affect\n%s!", displayName(target)) } end if status == "PSN" and target.substituteHP then - return { romText(battle.data, "_ButItFailedText", "But, it failed!") } + -- effects.asm:88 .noEffect falls into .didntAffect for POISON_EFFECT + return { romText(battle.data, "_DidntAffectText", + "It didn't affect\n%s!", displayName(target)) } end local msgs = inflictStatus(battle, target, status, { toxic = move and move.id == "TOXIC", diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 078aa7ba..ee4531e1 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -1136,9 +1136,14 @@ do { rng = mkseq({}) }, pb.player, pb.enemy, Data.moves.THUNDER_WAVE) eq(parMsgs[1], "Enemy RATTATA's\nparalyzed! It may\nnot attack!", "_ParalyzedMayNotAttackText wording + prefix") + -- move_effects/paralyze.asm:12 local failMsgs = MoveFx.primary.PARALYZE_EFFECT( { rng = mkseq({}) }, pb.player, pb.enemy, Data.moves.THUNDER_WAVE) - eq(failMsgs[1], "But, it failed!", "_ButItFailedText has the comma") + eq(failMsgs[1], "It didn't affect\nEnemy RATTATA!", + "an already-statused target is DidntAffect, not ButItFailed") + local TextBox = require("src.render.TextBox") + eq(TextBox.strip(Data.text._ButItFailedText):gsub("%s+$", ""), + "But, it failed!", "_ButItFailedText has the comma") -- send-out shout buckets (PrintSendOutMonMessage thresholds) pb.enemy.mon.stats = { hp = 20 } From 8d62d80f1c169cfccad2d6ae810ba5257f4a7007 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 4 Sep 2026 09:41:20 -0400 Subject: [PATCH 04/10] yellow: a parked Pikachu blocks the player in every follow-disabled scene --- src/world/PikachuFollower.lua | 15 +++++++-------- tests/parity_yellow_bills_pikachu.lua | 22 ++++++++++++++++++---- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index b6070c7d..8f9dd65e 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -424,9 +424,12 @@ end -- (pikachu_follow.asm keeps it one walk step behind) function PikachuFollower.update(game, ow) if ow.pikaHop then return end -- the counter hop owns the follower (#417) - if ow.pikachuBillsScene or ow.pikachuFanClubScene - or ow.pikachuPewterSleepScene then return end local npc = findFollower(ow) + -- home/overworld.asm:1238-1240 + if npc then + npc.passable = not PikachuFollower.isFollowingDisabled(ow) + end + if PikachuFollower.isFollowingDisabled(ow) then return end if not npc then if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end return @@ -816,9 +819,8 @@ end -- disabled following state. -- (engine/overworld/emotion_bubbles.asm:60) -- engine/pikachu/pikachu_emotions.asm:14 -local function billsHouseEmotion(game, ow, npc, bubble, emotion, done, beforeAnim) +local function billsHouseEmotion(game, ow, npc, bubble, emotion, done) local function anim() - if beforeAnim then beforeAnim() end playEmotion(game, ow, npc, emotion, { onDone = done }) end if not bubble then return anim() end @@ -895,10 +897,7 @@ function PikachuFollower.onBillsHouseEnter(game, ow) ow.pikachuBillsScene = true movePikachu(ow, npc, { { "right", 3 }, { "up", 1 } }, function() -- BillsHouse_CheckPikachuEmotion SCRIPT0 -- scripts/BillsHouse_2.asm:88 - billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE", 23, nil, function() - -- scripts/BillsHouse_2.asm:121, home/overworld.asm:1238-1240 - npc.passable = false - end) + billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE", 23) end) end diff --git a/tests/parity_yellow_bills_pikachu.lua b/tests/parity_yellow_bills_pikachu.lua index de477f6e..f3694915 100644 --- a/tests/parity_yellow_bills_pikachu.lua +++ b/tests/parity_yellow_bills_pikachu.lua @@ -78,14 +78,10 @@ check(ow.emote and ow.emote.bubble == 1 and ow.emote.frames == 60 -- emotion 23 -- engine/pikachu/pikachu_emotions.asm:14 check(npc.stepFrames == 16, "the scripted walk runs at the Pikachu movement step length") -check(npc.passable, "Pikachu is walked through while it is still following") ow.emote.onDone() check(ow.emote and ow.emote.pikaPic and ow.emote.frames > 0 and not ow.emote.skippable, "and the bubble hands off to the unskippable emotion animation") --- scripts/BillsHouse_2.asm:121, home/overworld.asm:1238-1240 -check(not npc.passable, - "DisablePikachuFollowingPlayer makes the parked companion solid") -- BillsHouseScript3 seeds hl with ..._EnterCellSeparatorDown and only swaps -- in ..._EnterCellSeparatorNotDown on the facing-down fallthrough, so the @@ -234,5 +230,23 @@ yellowGame.save.party = nil yellowGame.save.flags.EVENT_MET_BILL_2 = nil Sound.playPikaCry = realPikaCry +-- CheckPikachuFollowingPlayer is one flag, and CollisionCheckOnLand blocks +-- the player outright while it is set (home/overworld.asm:1238-1240). All +-- three scenes that call DisablePikachuFollowingPlayer must park a solid +-- companion: scripts/BillsHouse_2.asm:121, PokemonFanClub.asm:60, +-- PewterPokecenter_2.asm:67. +for _, scene in ipairs({ "pikachuBillsScene", "pikachuFanClubScene", + "pikachuPewterSleepScene" }) do + -- update() drops the follower once shouldSpawn goes false, so re-seed it + ow.npcs, ow.entities = { npc }, { npc } + npc.passable = true + ow[scene] = true + PikachuFollower.update(yellowGame, ow) + check(not npc.passable, scene .. " parks a solid companion") + ow[scene] = nil + PikachuFollower.update(yellowGame, ow) + check(npc.passable, scene .. " ending lets the player walk through again") +end + GameVersion.set("red") S.finish() From aa997eeb518205c56b8245e43ea00348bb713129 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 4 Sep 2026 09:56:35 -0400 Subject: [PATCH 05/10] online fixerino --- src/online/ArenaData.lua | 1 - tests/drivers/online_relay_smoke.lua | 11 ++++++++--- tests/online_arena_data.lua | 6 ++++-- tests/online_panel.lua | 4 ++-- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/online/ArenaData.lua b/src/online/ArenaData.lua index 8a181ab3..622d1524 100644 --- a/src/online/ArenaData.lua +++ b/src/online/ArenaData.lua @@ -15,7 +15,6 @@ local speciesMemo = {} local FIELDS = { { key = "engine", text = "engine differs" }, - { key = "version", text = "game differs" }, { key = "engineVersion", text = "engine version differs" }, { key = "apiVersion", text = "mod api differs" }, { key = "kind", text = "arena kind differs" }, diff --git a/tests/drivers/online_relay_smoke.lua b/tests/drivers/online_relay_smoke.lua index 99e4d8cb..8b827253 100644 --- a/tests/drivers/online_relay_smoke.lua +++ b/tests/drivers/online_relay_smoke.lua @@ -119,8 +119,12 @@ return function(game) rulesetId = "gen1_faithful", kind = "vanilla", rule = { partySize = 1 }, } + local YELLOW = {} + for k, v in pairs(PROFILE) do YELLOW[k] = v end + YELLOW.version = "yellow" + Host.connect({ name = "RED", profiles = { PROFILE } }) - Guest.connect({ name = "BLUE", profiles = { PROFILE } }) + Guest.connect({ name = "BLUE", profiles = { YELLOW } }) if not waitFor(function() return Host.state() == "online" and Guest.state() == "online" end, 600, "both clients to come online") then @@ -141,9 +145,10 @@ return function(game) return finish() end - local joined = Guest.joinRoom(room.code, "player", PROFILE) + local joined = Guest.joinRoom(room.code, "player", YELLOW) waitFor(function() return joined.done end, 600, "room_join to answer") - check(joined.error == nil, "the guest joins the room: " .. tostring(joined.error)) + check(joined.error == nil, + "a yellow guest joins a red room: " .. tostring(joined.error)) waitFor(function() return Host.room() and #Host.room().players == 2 end, 600, "the host to see two players") diff --git a/tests/online_arena_data.lua b/tests/online_arena_data.lua index 7f0d11e4..66af801b 100644 --- a/tests/online_arena_data.lua +++ b/tests/online_arena_data.lua @@ -259,8 +259,10 @@ do "data differs", "and reports the dataset") T.eq(ArenaData.describeMismatch(a, profile({ engineVersion = "9.9.9" })), "engine version differs", "an engine release is named first") - T.eq(ArenaData.describeMismatch(a, profile({ version = "blue" })), - "game differs", "so is the game") + T.eq(ArenaData.equal(a, profile({ version = "blue" })), true, + "red and blue share a link surface, so they pair") + T.eq(ArenaData.equal(a, profile({ version = "yellow" })), true, + "and so does yellow (#511)") T.eq(ArenaData.describeMismatch(a, profile({ rulesetId = "gen1_fixed" })), "ruleset differs", "and the ruleset") T.eq(ArenaData.describeMismatch(a, profile({ engine = 2 })), diff --git a/tests/online_panel.lua b/tests/online_panel.lua index 48dba4b8..c8d7f4db 100644 --- a/tests/online_panel.lua +++ b/tests/online_panel.lua @@ -156,8 +156,8 @@ T.eq(OnlinePanel.joinReason({ profile = copy(base) }, copy(base)), nil, "matching profiles are joinable") T.eq(OnlinePanel.joinReason({ profile = copy(base, { fingerprint = "zzz" }) }, copy(base)), "data differs", "a fingerprint split names the data") -T.eq(OnlinePanel.joinReason({ profile = copy(base, { version = "blue" }) }, - copy(base)), "game differs", "a different cart names the game") +T.eq(OnlinePanel.joinReason({ profile = copy(base, { version = "yellow" }) }, + copy(base)), nil, "another gen 1 game with the same surface is joinable") T.eq(OnlinePanel.joinReason({ profile = copy(base) }, nil), "pick a game first", "with no profile of our own we cannot judge") T.check(ArenaData.equal(copy(base), copy(base, { rule = { partySize = 6 } })), From e9cf07ef47f48f7d5a3d417cdd54757b2080d169 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 4 Sep 2026 10:45:02 -0400 Subject: [PATCH 06/10] gsc: map-load white hold measured on hardware, CLOSES #2141 --- src/world/gen2/World.lua | 4 +-- .../drivers/crystal_battle_exit_fade_2141.lua | 34 +++++++++++++++++-- tests/gen2_battle_return_fade_test.lua | 8 ++--- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index d1e4e35e..207172ce 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -246,9 +246,9 @@ local MAPSETUP_ROAM_JUMP = { [MAPSETUP.TELEPORT] = true } local FADE_STEPS = 4 local FADE_STEP_FRAMES = 2 -- data/maps/setup_scripts.asm:102-125, home/init.asm:149-153 -local MAP_LOAD_WHITE_FRAMES = 11 +local MAP_LOAD_WHITE_FRAMES = 13 -- data/maps/setup_scripts.asm:32-55 -local WARP_LOAD_WHITE_FRAMES = 13 +local WARP_LOAD_WHITE_FRAMES = 15 -- home/map.asm:1927-1940 over home/tilemap.asm:12-25 local MENU_EXIT_RELOAD_FRAMES = 9 diff --git a/tests/drivers/crystal_battle_exit_fade_2141.lua b/tests/drivers/crystal_battle_exit_fade_2141.lua index 6daaa301..93203011 100644 --- a/tests/drivers/crystal_battle_exit_fade_2141.lua +++ b/tests/drivers/crystal_battle_exit_fade_2141.lua @@ -27,10 +27,10 @@ return function(game) assert(world and world.map, "the crystal world did not boot") local save, data = game.save, game.data save.party = { Mon.new(data, "CYNDAQUIL", 12) } - -- POKEPORT_FADE_NIGHT=1 pins the clock so the colour-0 plane that brackets + -- home/time.asm GetTimeOfDay, engine/tilesets/timeofday_pals.asm:160-187 local night = os.getenv("POKEPORT_FADE_NIGHT") == "1" if night then - world.daytime = "NITE" + world.clockHour = 23 world:applyPalettes() end @@ -198,6 +198,36 @@ return function(game) U.wait(5) shot("whiteout_done") + -- engine/tilesets/timeofday_pals.asm:160-187 on a PALETTE_AUTO map + if night then + world:warpToMapId("NEW_BARK_TOWN", 13, 7, "up") + for _ = 1, 200 do + if not world:busy() then break end + U.wait(1) + end + U.wait(10) + ok(world.map.id == "NEW_BARK_TOWN" and world.daytime == "NITE", + ("outdoors at night (map=%s daytime=%s)") + :format(tostring(world.map.id), tostring(world.daytime))) + world:battleReturnFade() + local nightHold, nightPlane = 0, 0 + for _ = 1, 200 do + if not world.mapSetup then break end + if world.fadeHold then + nightHold = nightHold + 1 + else + nightPlane = nightPlane + 1 + shot("night_outdoor_in") + end + U.wait(1) + end + ok(nightHold == warpHold, + ("night: pure white held %d ticks (want %d)"):format(nightHold, warpHold)) + ok(nightPlane == World.FADE_STEPS * World.FADE_STEP_FRAMES, + ("night: %d ramp ticks through the palette remap (want %d)") + :format(nightPlane, World.FADE_STEPS * World.FADE_STEP_FRAMES)) + end + print("[exit-fade-2141] " .. (fails == 0 and "PASS all claims" or (fails .. " claims failed")) .. " -- " .. shotN .. " shots in " .. out) love.event.quit(fails == 0 and 0 or 1) diff --git a/tests/gen2_battle_return_fade_test.lua b/tests/gen2_battle_return_fade_test.lua index 42c3a5f2..63320af6 100644 --- a/tests/gen2_battle_return_fade_test.lua +++ b/tests/gen2_battle_return_fade_test.lua @@ -52,10 +52,10 @@ eq(armed.mapSetup.phase, "out", eq(armed.fade, nil, "and its sheet untouched") -- home/init.asm:149-153 -check(World.MAP_LOAD_WHITE_FRAMES >= 10, - "the door LCD-off window is held ten frames or more") -check(World.WARP_LOAD_WHITE_FRAMES >= World.MAP_LOAD_WHITE_FRAMES, - "a Warp/ReloadMap load runs longer still: LoadBlockData is under the LCD too") +eq(World.MAP_LOAD_WHITE_FRAMES, 13, + "a door holds white for the 13 frames measured on the cart (11 LCD-off + 2)") +eq(World.WARP_LOAD_WHITE_FRAMES, 15, + "a Warp/Teleport/Fly holds 15: LoadBlockData and LoadMapObjects are under the LCD too") eq(w.fadeHold, World.WARP_LOAD_WHITE_FRAMES, "the battle return opens holding pure white for that window") eq(w.fadeWhiten, nil, "with LoadMapPalettes' plain set already back") From e6e3459dbe08ff3cea352d3737632ab84bfc6cc0 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 4 Sep 2026 10:45:02 -0400 Subject: [PATCH 07/10] yellow: Bill's house Pikachu timing, collision and spawn cell, CLOSES #2165 --- src/script/Commands.lua | 19 +- src/world/PikachuFollower.lua | 68 ++++- tests/drivers/bills_pikachu_refix2_2165.lua | 279 ++++++++++++++++++++ tests/parity_bills_pc.lua | 37 ++- tests/parity_yellow_bills_pikachu.lua | 99 +++++++ 5 files changed, 496 insertions(+), 6 deletions(-) create mode 100644 tests/drivers/bills_pikachu_refix2_2165.lua diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 1da34c8d..c46bfb21 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -620,6 +620,23 @@ function Commands.play_sound(ctx, soundId) require("src.core.Sound").play(ctx.game.data, soundId) end +-- wait_sound: WaitForSoundToFinish (home/delay.asm:15), the drain that +-- follows every PlaySound in the cell-separator chain +-- (engine/events/hidden_events/bills_house_pc.asm:22) +local WAIT_SOUND_CEILING = 600 + +function Commands.wait_sound(ctx) + local Sound = require("src.core.Sound") + if not Sound.sfxBusy() 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() + end + runner:yield() +end + -- text_sound : the jingle the ROM parks at the END of a string as -- a trailing text command (sound_get_item_1, sound_get_key_item -> -- home/text.asm TextCommand_SOUND). It arms the NEXT show_text the same @@ -1476,7 +1493,7 @@ end for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp", "open_mart", "trade", "push_screen", "record_hall_of_fame", "old_man_demo", "static_battle", "rival_battle", "give_item", - "give_pokemon", "wait", + "give_pokemon", "wait", "wait_sound", "wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc", "emote", "fade", "pan_camera", "play_once", "pikachu_make_way", "ss_anne_departs" }) do diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index 8f9dd65e..4c776d7b 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -420,15 +420,69 @@ function PikachuFollower.rebase(ow, dx, dy) if trail then trail.x, trail.y = trail.x + dx, trail.y + dy end end +local DIRS = { "up", "down", "left", "right" } + +-- wPikachuCollisionCounter: 8 on a direction change (home/overworld.asm:189), +-- cleared with no d-pad held (:130) and once a step commits (:242) +local function tickCollisionCounter(game, ow, npc) + local input = game.input + local p = ow.player + local dir + if input then + for _, d in ipairs(DIRS) do + if input:isDown(d) then dir = d break end + end + end + if not dir then + ow.pikachuCollisionCounter = 0 + if ow.pikachuMovingDir then + ow.pikachuLastStopDir = ow.pikachuMovingDir + ow.pikachuMovingDir = nil + end + ow.pikachuTurnArmed = true + return + end + if ow.pikachuTurnArmed and dir ~= ow.pikachuLastStopDir then + ow.pikachuCollisionCounter = 8 + ow.pikachuTurnArmed = false + ow.pikachuMovingDir = dir + return + end + ow.pikachuMovingDir = dir + if p.moving then + ow.pikachuCollisionCounter = 0 + return + end + local n = ow.pikachuCollisionCounter or 0 + if n <= 0 then return end + local tx, ty = Collision.target(p.cellX, p.cellY, dir) + if p.facing == dir and npc.cellX == tx and npc.cellY == ty then + ow.pikachuCollisionCounter = n - 1 + end +end + +-- CollisionCheckOnLand's Pikachu branch -- home/overworld.asm:1234-1252 +local function updatePassable(game, ow, npc) + if PikachuFollower.isFollowingDisabled(ow) then + npc.passable = false + ow.pikachuCollisionCounter = 0 + return + end + tickCollisionCounter(game, ow, npc) + if game.input and game.input:isDown("b") then + npc.passable = true + return + end + npc.passable = (ow.pikachuCollisionCounter or 0) <= 0 +end + -- one follow step per frame: chase the cell the player last vacated -- (pikachu_follow.asm keeps it one walk step behind) function PikachuFollower.update(game, ow) if ow.pikaHop then return end -- the counter hop owns the follower (#417) local npc = findFollower(ow) -- home/overworld.asm:1238-1240 - if npc then - npc.passable = not PikachuFollower.isFollowingDisabled(ow) - end + if npc then updatePassable(game, ow, npc) end if PikachuFollower.isFollowingDisabled(ow) then return end if not npc then if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end @@ -895,7 +949,13 @@ function PikachuFollower.onBillsHouseEnter(game, ow) local npc = findFollower(ow) if not npc then return end ow.pikachuBillsScene = true - movePikachu(ow, npc, { { "right", 3 }, { "up", 1 } }, function() + local steps = { { "right", 3 }, { "up", 1 } } + -- engine/pikachu/pikachu_follow.asm:59 + if npc.cellX == ow.player.cellX and npc.cellY == ow.player.cellY + and Collision.canMove(ow.map, ow.entities, npc, "right") then + table.insert(steps, 1, { "right", 1 }) + end + movePikachu(ow, npc, steps, function() -- BillsHouse_CheckPikachuEmotion SCRIPT0 -- scripts/BillsHouse_2.asm:88 billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE", 23) end) diff --git a/tests/drivers/bills_pikachu_refix2_2165.lua b/tests/drivers/bills_pikachu_refix2_2165.lua new file mode 100644 index 00000000..d0d85ad6 --- /dev/null +++ b/tests/drivers/bills_pikachu_refix2_2165.lua @@ -0,0 +1,279 @@ +-- #2165 refix 2: home/overworld.asm:1234-1252 (the B bypass and the +-- wPikachuCollisionCounter soft bump), engine/pikachu/pikachu_follow.asm:59 +-- (the companion never starts the confused walk on the player's cell) and +-- engine/events/hidden_events/bills_house_pc.asm:14-40 (268 delay frames plus +-- five WaitForSoundToFinish drains). Self-terminating: it quits LOVE with 0 +-- on all-PASS and 1 otherwise. No POKEPORT_SPEED -- it scales the logic +-- clock only and desyncs the cries against the walk. +-- +-- SHOT_DIR=/tmp/shots2165b POKEPORT_SHOT_DIR=/tmp/shots2165b \ +-- POKEPORT_DRIVER=tests/drivers/bills_pikachu_refix2_2165.lua \ +-- POKEPORT_IDENTITY=yellow-sep04 POKEPORT_VERSION=yellow POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local GameVersion = require("src.core.GameVersion") + local Pokemon = require("src.pokemon.Pokemon") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots2165b" + local failures = 0 + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + 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")) + U.log("shots in", SHOT_DIR) + love.event.quit(failures == 0 and 0 or 1) + while true do coroutine.yield() end + end + + if not check("running the Yellow cache (POKEPORT_VERSION=yellow)", + GameVersion.isYellow()) then + finish() + end + + local MAP = "BILLS_HOUSE" + + local function resetSave() + game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) } + game.save.player.name = "bryan" + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true + game.save.flags.EVENT_MET_BILL = nil + game.save.flags.EVENT_MET_BILL_2 = nil + game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil + game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = nil + game.save.pikachuMapScriptActive = nil + end + + local ow + local function follower() + for _, n in ipairs(ow and ow.npcs or {}) do + if n.pikachuFollower then return n end + end + return nil + end + + 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 + return nil + end + + local function press(dir, frames) + for _ = 1, frames do + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + coroutine.yield() + end + end + + local function release(...) + for _, b in ipairs({ ... }) do game.input.state[b] = false end + U.wait(1) + end + + -- walk the player exactly one cell: hold until the cell index changes, + -- release, then let the in-flight step land + local function step(dir) + local p0 = ow.player + local sx, sy = p0.cellX, p0.cellY + for _ = 1, 60 do + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + coroutine.yield() + if p0.cellX ~= sx or p0.cellY ~= sy then break end + end + game.input.state[dir] = false + for _ = 1, 40 do + if not p0.moving then break end + coroutine.yield() + end + U.wait(2) + end + + -- ===================================================================== + -- A. the soft bump and the B bypass, both of which only exist while + -- following is ENABLED -- home/overworld.asm:1240-1252 + -- ===================================================================== + resetSave() + game.save.flags.EVENT_MET_BILL_2 = true -- BillsHouseScript0 stays asleep + U.teleport(game, MAP, 3, 6, "up") + U.wait(30) + ow = game.overworld + if not check("Bill's House loaded", ow and ow.map and ow.map.id == MAP) then + finish() + end + check("the confused beat did not run with EVENT_MET_BILL_2 set", + ow.pikachuBillsScene ~= true) + + -- take a step so the companion trails onto the cell behind the player + step("up") + U.wait(20) + local npc = follower() + if not check("the follower spawned and trailed", npc ~= nil) then finish() end + check("a following companion is walk-through when nothing is pressed", + npc.passable == true and (ow.pikachuCollisionCounter or 0) == 0) + + local p = ow.player + local backDir = (npc.cellY > p.cellY and "down") + or (npc.cellY < p.cellY and "up") + or (npc.cellX > p.cellX and "right") or "left" + U.log(string.format("A player=(%d,%d)%s pika=(%d,%d) push=%s", + p.cellX, p.cellY, p.facing, npc.cellX, npc.cellY, backDir)) + + local startX, startY = p.cellX, p.cellY + local seeded, blockedTicks, movedAt = 0, 0, nil + for i = 1, 40 do + table.insert(game.input.pressQueue, backDir) + game.input.state[backDir] = true + coroutine.yield() + seeded = math.max(seeded, ow.pikachuCollisionCounter or 0) + if p.cellX ~= startX or p.cellY ~= startY or p.moving then + movedAt = i + break + end + if p.facing == backDir and npc.passable == false then + blockedTicks = blockedTicks + 1 + end + end + release(backDir) + U.log("A seeded=" .. seeded .. " blockedTicks=" .. blockedTicks + .. " movedAt=" .. tostring(movedAt)) + check("turning toward the companion seeds the 8-count bump", seeded == 8) + check("the bump holds the player for several frames, then yields", + blockedTicks >= 4 and movedAt ~= nil) + + U.wait(20) + npc = follower() + p = ow.player + backDir = (npc.cellY > p.cellY and "down") or (npc.cellY < p.cellY and "up") + or (npc.cellX > p.cellX and "right") or "left" + startX, startY = p.cellX, p.cellY + local bWalkedThrough = false + game.input.state.b = true + for _ = 1, 40 do + table.insert(game.input.pressQueue, "b") + table.insert(game.input.pressQueue, backDir) + game.input.state[backDir] = true + coroutine.yield() + if p.cellX ~= startX or p.cellY ~= startY or p.moving then + bWalkedThrough = true + break + end + end + release(backDir, "b") + check("holding B walks straight through the companion", bWalkedThrough) + + -- ===================================================================== + -- B. the confused walk lands directly below Bill + -- (scripts/BillsHouse_2.asm:125 from the emerged cell) + -- ===================================================================== + resetSave() + U.teleport(game, MAP, 2, 7, "up") + U.wait(20) + ow = game.overworld + npc = follower() + if not check("the follower spawned for the confused beat", npc ~= nil) then + finish() + end + check("the confused beat started", ow.pikachuBillsScene == true) + check("the scripted walk uses the 16-frame Pikachu step", + npc.stepFrames == 16) + + for _ = 1, 600 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 + U.wait(10) + + local bill = objectAt("BILLSHOUSE_BILL_POKEMON") + npc = follower() + U.log(string.format("B pika=(%d,%d) bill=(%s,%s)", + npc.cellX, npc.cellY, + bill and bill.cellX or "?", bill and bill.cellY or "?")) + check("the confused walk ends directly below Bill (6,6)", + npc.cellX == 6 and npc.cellY == 6) + check("which is Bill's own column, one row south", + bill ~= nil and npc.cellX == bill.cellX and npc.cellY == bill.cellY + 1) + U.shot(game, SHOT_DIR .. "/01_confused_walk_ends_below_bill.png") + + -- ===================================================================== + -- C. the parked companion is solid -- home/overworld.asm:1238-1240 + -- ===================================================================== + p = ow.player + for _ = 1, 4 do step("right") end + U.wait(10) + U.log(string.format("C player=(%d,%d)%s pika=(%d,%d) passable=%s", + p.cellX, p.cellY, p.facing, npc.cellX, npc.cellY, + tostring(npc.passable))) + check("the player reached the cell south of the companion", + p.cellX == 6 and p.cellY == 7) + check("and following is still disabled, so the companion is solid", + npc.passable == false) + startX, startY = p.cellX, p.cellY + press("up", 60) + release("up") + check("pushing north into the parked companion never moves the player", + p.cellX == startX and p.cellY == startY) + U.shot(game, SHOT_DIR .. "/02_solid_companion_blocks_player.png") + + -- ===================================================================== + -- D. the cell separator runs the cartridge's 268 delay frames plus five + -- WaitForSoundToFinish drains -- bills_house_pc.asm:14-40, :51-63 + -- ===================================================================== + game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true + game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = nil + U.teleport(game, MAP, 1, 5, "up") + U.wait(20) + ow = game.overworld + U.tap(game, "a") + U.wait(10) + for _ = 1, 200 do -- type + prompt-button the initiated text away + U.tap(game, "a") + U.wait(2) + if ow.runner and ow.runner:isRunning() then break end + if #(ow.pendingScripts or {}) > 0 then break end + end + + local chainFrames, shot = 0, false + for _ = 1, 1200 do + if game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then break end + chainFrames = chainFrames + 1 + U.wait(1) + if not shot and chainFrames == 150 then + shot = true + U.shot(game, SHOT_DIR .. "/03_cell_separator_running.png") + end + end + -- 268 asm delay frames less the handful the text-box drain eats before + -- this loop starts; the pre-fix chain was 222 and measures ~218 here + U.log("D chainFrames=" .. chainFrames .. " (asm budget 268 + five SFX)") + check("the separator chain runs the cartridge's ~268 delay frames", + chainFrames >= 255) + check("and it sets EVENT_USED_CELL_SEPARATOR_ON_BILL only at the end", + game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL == true) + + for _ = 1, 600 do + if objectAt("BILLSHOUSE_BILL1") then break end + U.wait(1) + end + U.wait(30) + check("Bill steps out of the machine when the chain ends", + objectAt("BILLSHOUSE_BILL1") ~= nil) + U.shot(game, SHOT_DIR .. "/04_bill_out_of_the_machine.png") + + finish() +end diff --git a/tests/parity_bills_pc.lua b/tests/parity_bills_pc.lua index b6986fe1..1f80994f 100644 --- a/tests/parity_bills_pc.lua +++ b/tests/parity_bills_pc.lua @@ -85,8 +85,9 @@ check(setUpvalue(OW.billsHousePokemonList, "TextBox", textBoxStub), local realMenu = package.loaded["src.ui.Menu"] package.loaded["src.ui.Menu"] = menuStub +local queued local fakeSelf = setmetatable({ - queueScript = function() end, + queueScript = function(_, script, extra) queued = { script, extra } end, billsHouseBillExits = function() end, }, { __index = OW }) @@ -127,11 +128,45 @@ package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end, } +queued = nil runPC() check(musicStopped, "cell separator stops map music") check(tostring(lastPush().text):find("Cell", 1, true) or tostring(lastPush().text):find("TELEPORTER", 1, true), "separator path prints initiated text") + +-- bills_house_pc.asm:14-40 plus BillsHouseInitiatedText's text_asm at :51-63: +-- 268 delay frames, and every PlaySound is followed by WaitForSoundToFinish +-- before the next delay starts. +local wantSeparator = { + { "wait", 16 }, + { "play_sound", "Switch" }, { "wait_sound" }, + { "wait", 60 }, + { "wait", 32 }, + { "play_sound", "Tink" }, { "wait_sound" }, + { "wait", 80 }, + { "play_sound", "Shrink" }, { "wait_sound" }, + { "wait", 48 }, + { "play_sound", "Tink" }, { "wait_sound" }, + { "wait", 32 }, + { "play_sound", "Get_Item1" }, { "wait_sound" }, +} +check(queued ~= nil, "the separator queues its cutscene script") +local gotRows, delayFrames = {}, 0 +for _, row in ipairs(queued and queued[1] or {}) do + gotRows[#gotRows + 1] = table.concat({ tostring(row[1]), tostring(row[2]) }, " ") + if row[1] == "wait" then delayFrames = delayFrames + row[2] end +end +local wantRows = {} +for _, row in ipairs(wantSeparator) do + wantRows[#wantRows + 1] = table.concat({ tostring(row[1]), tostring(row[2]) }, " ") +end +eq(table.concat(gotRows, " | "), table.concat(wantRows, " | "), + "the separator chain matches bills_house_pc.asm delay for delay") +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") +queued[2].onDone() check(fakeGame.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL, "separator path sets EVENT_USED_CELL_SEPARATOR_ON_BILL") diff --git a/tests/parity_yellow_bills_pikachu.lua b/tests/parity_yellow_bills_pikachu.lua index f3694915..99258718 100644 --- a/tests/parity_yellow_bills_pikachu.lua +++ b/tests/parity_yellow_bills_pikachu.lua @@ -248,5 +248,104 @@ for _, scene in ipairs({ "pikachuBillsScene", "pikachuFanClubScene", check(npc.passable, scene .. " ending lets the player walk through again") end +-- The other two states of CollisionCheckOnLand's Pikachu branch +-- (home/overworld.asm:1234-1252): B held walks straight through, and +-- wPikachuCollisionCounter is a soft 8-count bump seeded on a direction +-- change (:189) and cleared with no d-pad held (:130) or once a step +-- commits (:242). +local held = {} +yellowGame.input = { isDown = function(_, b) return held[b] == true end } +local cp = ow.player +cp.cellX, cp.cellY, cp.facing, cp.moving = 3, 7, "up", false +npc.cellX, npc.cellY = 3, 6 + +local function tick() + ow.npcs, ow.entities = { npc }, { npc } + PikachuFollower.update(yellowGame, ow) +end + +held = {} +tick() +check(npc.passable and ow.pikachuCollisionCounter == 0, + "no d-pad held clears the counter and the companion is walk-through") + +held = { up = true } +tick() +check(ow.pikachuCollisionCounter == 8 and not npc.passable, + "turning to a new direction seeds the 8-count bump") + +local blocked = 0 +for _ = 1, 8 do + tick() + if not npc.passable then blocked = blocked + 1 end +end +check(blocked == 7, + "the bump blocks seven pushes and then yields, like dec [hl] / jr nz") +check(npc.passable and ow.pikachuCollisionCounter == 0, + "the drained counter leaves the companion passable") + +held = {} +tick() +held = { up = true } +tick() +check(ow.pikachuCollisionCounter == 0, + "re-pressing the same direction after a release does not re-seed") + +held = {} +tick() +held = { left = true } +tick() +check(ow.pikachuCollisionCounter == 8, "a new direction seeds again") +held = { left = true, b = true } +tick() +check(npc.passable, "holding B walks straight through the bump") + +held = { left = true } +cp.moving = true +tick() +check(ow.pikachuCollisionCounter == 0 and npc.passable, + "a committed step clears the counter") +cp.moving = false +held = {} +tick() +yellowGame.input = nil + +-- CalculatePikachuPlacementCoords never leaves the companion on the +-- player's own cell (engine/pikachu/pikachu_follow.asm:59), so +-- BillsHousePikachuConfused's three STEP_RIGHTs start one cell east of the +-- door and land it directly below Bill. +local emergeMoves = {} +local emergeOw = { + map = { + id = "BILLS_HOUSE", + def = { tileset = "INTERIOR" }, + inBounds = function() return true end, + isWalkableCell = function() return true end, + isWaterCell = function() return false end, + cellTile = function() return 0 end, + }, + npcs = {}, entities = {}, + player = { cellX = 2, cellY = 7, facing = "up" }, + scriptMove = function(_, entity, dir, tiles, onDone) + emergeMoves[#emergeMoves + 1] = { dir = dir, tiles = tiles, onDone = onDone } + end, +} +local emergeNpc = { + pikachuFollower = true, cellX = 2, cellY = 7, px = 32, py = 112, + facing = "up", passable = true, +} +emergeOw.npcs[1], emergeOw.entities[1] = emergeNpc, emergeNpc +yellowGame.save.flags = {} +yellowGame.save.pikachuMapScriptActive = nil +PikachuFollower.onBillsHouseEnter(yellowGame, emergeOw) +check(#emergeMoves == 1 and emergeMoves[1].dir == "right" + and emergeMoves[1].tiles == 1, + "a companion still stacked on the player steps off before the walk") +emergeNpc.cellX = 3 +emergeMoves[1].onDone() +check(#emergeMoves == 2 and emergeMoves[2].dir == "right" + and emergeMoves[2].tiles == 3, + "then runs PikachuMovement_Confused's three STEP_RIGHTs") + GameVersion.set("red") S.finish() From 9ae53ac81f93998f618ba292008f9cad3391f879 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 4 Sep 2026 10:45:07 -0400 Subject: [PATCH 08/10] gsc: intro palette rotations, music fade and player icon, CLOSES #2171 --- src/ui/gen2/GenderSelect.lua | 25 ++- src/ui/gen2/InitClock.lua | 49 +++++- src/ui/gen2/IntroFade.lua | 117 +++++++++++++++ src/ui/gen2/OakSpeech.lua | 102 +++++++++++-- tests/drivers/crystal_gender_shots.lua | 8 +- tests/drivers/gen2_intro_fades_2171.lua | 179 ++++++++++++++++++++++ tests/engine/gen2_intro_fade_2171.lua | 192 ++++++++++++++++++++++++ 7 files changed, 648 insertions(+), 24 deletions(-) create mode 100644 src/ui/gen2/IntroFade.lua create mode 100644 tests/drivers/gen2_intro_fades_2171.lua create mode 100644 tests/engine/gen2_intro_fade_2171.lua diff --git a/src/ui/gen2/GenderSelect.lua b/src/ui/gen2/GenderSelect.lua index ee070d01..a24358bb 100644 --- a/src/ui/gen2/GenderSelect.lua +++ b/src/ui/gen2/GenderSelect.lua @@ -3,6 +3,7 @@ local Chrome = require("src.ui.gen2.Chrome") local CommonText = require("src.core.gen2.CommonText") +local IntroFade = require("src.ui.gen2.IntroFade") local Music = require("src.core.Music") local RomText = require("src.core.RomText") local Sound = require("src.core.Sound") @@ -58,6 +59,7 @@ function GenderSelect.new(game, opts) -- `db 1 ; default option`: the cursor opens on Boy. self.cursor = 1 self.exit = nil + self.fades = opts.fades and true or false self.text = CommonText.plain(RomText(self.data, "_AreYouABoyOrAreYouAGirlText", FALLBACK)) -- ../pokecrystal/home/print_text.asm:5, ../pokecrystal/engine/menus/init_gender.asm:30-34 @@ -86,13 +88,24 @@ function GenderSelect:choose(index) self.exit = EXIT_FRAMES end +function GenderSelect:leave() + local function done() + if self.onDone then self.onDone(self.chosen) end + end + -- ../pokecrystal/engine/rtc/timeset.asm:22 + if self.fades then return IntroFade.run(self, { "outBlack" }, done) end + done() +end + function GenderSelect:update(_dt) + -- ../pokecrystal/home/fade.asm:22-101 + if IntroFade.advance(self) then return end if self.typer then self.typer:tick() end if self.exit then self.exit = self.exit - 1 if self.exit > 0 then return end self.exit = nil - if self.onDone then self.onDone(self.chosen) end + self:leave() return end local input = self.game and self.game.input @@ -127,12 +140,18 @@ function GenderSelect:drawPanel() end end +function GenderSelect:drawBody() + self:drawPanel() + IntroFade.paint(self, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) +end + function GenderSelect:draw() - Chrome.withClip(function() self:drawPanel() end) + Chrome.withClip(function() self:drawBody() end) end function GenderSelect:drawWidescreen(winW, winH) - Chrome.withPanel(winW, winH, 1, 1, 1, function() self:drawPanel() end) + local r, g, b = IntroFade.surround(self, 1, 1, 1) + Chrome.withPanel(winW, winH, r, g, b, function() self:drawBody() end) end return GenderSelect diff --git a/src/ui/gen2/InitClock.lua b/src/ui/gen2/InitClock.lua index 5b6d9a50..d98fa943 100644 --- a/src/ui/gen2/InitClock.lua +++ b/src/ui/gen2/InitClock.lua @@ -25,6 +25,7 @@ local Chrome = require("src.ui.gen2.Chrome") local Clock = require("src.core.gen2.Clock") +local IntroFade = require("src.ui.gen2.IntroFade") local Strings = require("src.core.Strings") local Typer = require("src.ui.gen2.Typer") @@ -151,6 +152,20 @@ function InitClock.new(game, opts) instant = self.autoConfirm or self.mode == "day", }) self:startText() + self.fades = opts.fades and self.mode ~= "day" and not self.autoConfirm + if self.fades then + -- ../pokecrystal/engine/rtc/timeset.asm:22, :42 + if opts.faded then + IntroFade.run(self, { "inBlack" }) + else + -- ../pokecrystal/engine/menus/intro_menu.asm:42-49, :65 + self.blank = true + IntroFade.run(self, { "outBlack" }, function() + self.blank = false + IntroFade.run(self, { "inBlack" }) + end) + end + end return self end @@ -253,7 +268,12 @@ function InitClock:finish() return end Clock.setTime(self.save, self.hour, self.minute) - if self.onDone then self.onDone(self.hour, self.minute) end + local function done() + if self.onDone then self.onDone(self.hour, self.minute) end + end + -- ../pokecrystal/engine/menus/intro_menu.asm:628 + if self.fades then return IntroFade.run(self, { "outBlack" }, done) end + done() end -- A on a picker confirms it, YES on a confirmation takes it, NO drops back to @@ -277,11 +297,12 @@ function InitClock:accept() elseif self.phase == "confirm-minute" then self.phase = "response" elseif self.phase == "response" then - self:finish() + -- ../pokecrystal/engine/menus/intro_menu.asm:628 + return self:finish() elseif self.phase == "day" then self.phase = "confirm-day" elseif self.phase == "confirm-day" then - self:finish() + return self:finish() end self:startText() end @@ -298,6 +319,8 @@ function InitClock:decline() end function InitClock:update(_dt) + -- ../pokecrystal/home/fade.asm:22-101 + if IntroFade.advance(self) then return end -- The driver path: no screen this new may be allowed to stall a scripted -- run, so it walks itself to the end taking every default. if self.autoConfirm then @@ -368,6 +391,13 @@ local function arrow(tx, ty, up) end function InitClock:drawPanel() + -- ../pokecrystal/engine/menus/intro_menu.asm:42-49 + if self.blank then + local G = love.graphics + G.setColor(1, 1, 1, 1) + G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) + return + end -- ../pokecrystal/engine/rtc/timeset.asm:22-32 if self.mode ~= "day" then local G = love.graphics @@ -400,14 +430,21 @@ function InitClock:drawPanel() end end +function InitClock:drawBody() + self:drawPanel() + IntroFade.paint(self, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) +end + function InitClock:draw() - Chrome.withClip(function() self:drawPanel() end) + Chrome.withClip(function() self:drawBody() end) end function InitClock:drawWidescreen(winW, winH) local ground = InitClock.GROUND - Chrome.withPanel(winW, winH, ground[1] / 255, ground[2] / 255, - ground[3] / 255, function() self:drawPanel() end) + local r, g, b = ground[1] / 255, ground[2] / 255, ground[3] / 255 + if self.blank then r, g, b = 1, 1, 1 end + r, g, b = IntroFade.surround(self, r, g, b) + Chrome.withPanel(winW, winH, r, g, b, function() self:drawBody() end) end return InitClock diff --git a/src/ui/gen2/IntroFade.lua b/src/ui/gen2/IntroFade.lua new file mode 100644 index 00000000..8164a9ad --- /dev/null +++ b/src/ui/gen2/IntroFade.lua @@ -0,0 +1,117 @@ +-- ../pokecrystal/home/fade.asm:22-101 RotateFourPalettes* / RotateThreePalettes* +-- (src/ui/gen2/MenuFade.lua is the other ramp, home/map.asm:1910-1940.) + +local IntroFade = {} +IntroFade.__index = IntroFade + +-- ../pokecrystal/home/fade.asm:56-57, :99-100 +local STEP_FRAMES = 8 +IntroFade.STEP_FRAMES = STEP_FRAMES + +-- ../pokecrystal/home/fade.asm:106-113 +-- outBlack RotateFourPalettesLeft 03 02 01 00 +-- inBlack RotateFourPalettesRight 00 01 02 03 +-- outWhite RotateThreePalettesRight 05 06 07 +-- inWhite RotateThreePalettesLeft 06 05 04 +local LEVELS = { + outBlack = { 0, 1 / 3, 2 / 3, 1 }, + inBlack = { 1, 2 / 3, 1 / 3, 0 }, + outWhite = { 1 / 3, 2 / 3, 1 }, + inWhite = { 2 / 3, 1 / 3, 0 }, +} + +local BLACK, WHITE = { 0, 0, 0 }, { 1, 1, 1 } +local COLORS = { + outBlack = BLACK, inBlack = BLACK, outWhite = WHITE, inWhite = WHITE, +} + +IntroFade.FOUR_FRAMES = 4 * STEP_FRAMES +IntroFade.THREE_FRAMES = 3 * STEP_FRAMES + +function IntroFade.frames(kind) + local levels = LEVELS[kind] + return levels and #levels * STEP_FRAMES or 0 +end + +function IntroFade.new(kind) + local levels = LEVELS[kind] + if not levels then return nil end + return setmetatable({ + kind = kind, + levels = levels, + color = COLORS[kind], + frame = 0, + total = #levels * STEP_FRAMES, + }, IntroFade) +end + +function IntroFade:done() return self.frame >= self.total end + +function IntroFade:tick() + if self.frame < self.total then self.frame = self.frame + 1 end + return self:done() +end + +function IntroFade:level() + local k = math.floor(self.frame / STEP_FRAMES) + 1 + if k > #self.levels then k = #self.levels end + return self.levels[k] +end + +function IntroFade:draw(w, h) + local a = self:level() + if a <= 0 then return end + local G = love.graphics + local c = self.color + G.setColor(c[1], c[2], c[3], a) + G.rectangle("fill", 0, 0, w, h) + G.setColor(1, 1, 1, 1) +end + +function IntroFade:blend(r, g, b) + local a = self:level() + local c = self.color + return r + (c[1] - r) * a, g + (c[2] - g) * a, b + (c[3] - b) * a +end + +-- ---------------------------------------------------------- owner-side mixin + +function IntroFade.step(owner) + owner.fadeAt = (owner.fadeAt or 0) + 1 + local kind = owner.fadeQueue and owner.fadeQueue[owner.fadeAt] + if not kind then + owner.fade, owner.fadeQueue, owner.fadeAt = nil, nil, nil + local fn = owner.fadeDone + owner.fadeDone = nil + if fn then fn() end + return + end + owner.fade = IntroFade.new(kind) +end + +function IntroFade.run(owner, kinds, onDone) + owner.fadeQueue, owner.fadeAt, owner.fadeDone = kinds, 0, onDone + IntroFade.step(owner) +end + +function IntroFade.busy(owner) + return owner.fade ~= nil +end + +-- True when the fade owned this frame, so the caller returns before its input. +function IntroFade.advance(owner) + if not owner.fade then return false end + if owner.fade:tick() then IntroFade.step(owner) end + return true +end + +function IntroFade.paint(owner, w, h) + if owner.fade then owner.fade:draw(w, h) end +end + +function IntroFade.surround(owner, r, g, b) + if not owner.fade then return r, g, b end + return owner.fade:blend(r, g, b) +end + +return IntroFade diff --git a/src/ui/gen2/OakSpeech.lua b/src/ui/gen2/OakSpeech.lua index 620ac688..bb60c0c1 100644 --- a/src/ui/gen2/OakSpeech.lua +++ b/src/ui/gen2/OakSpeech.lua @@ -42,6 +42,7 @@ local FieldMoves = require("src.world.gen2.FieldMoves") local Font = require("src.render.Font") local Gen2Save = require("src.core.gen2.Save") local GbcPalette = require("src.render.GbcPalette") +local IntroFade = require("src.ui.gen2.IntroFade") local Logger = require("src.core.Logger") local Music = require("src.core.Music") local Palettes = require("src.world.gen2.Palettes") @@ -58,6 +59,21 @@ OakSpeech.isOpaque = true local FADE_FRAMES = 24 local WIPE_FRAMES = 32 +-- ../pokecrystal/engine/menus/intro_menu.asm:802-851 ShrinkPlayer +OakSpeech.SHRINK_MUSIC_FADE = 32 +local SHRINK_PIC1 = 8 +local SHRINK_PIC2 = 16 +local SHRINK_CLEAR = 24 +local SHRINK_ICON = 27 +local SHRINK_END = 77 +OakSpeech.SHRINK_PIC1 = SHRINK_PIC1 +OakSpeech.SHRINK_PIC2 = SHRINK_PIC2 +OakSpeech.SHRINK_CLEAR = SHRINK_CLEAR +OakSpeech.SHRINK_ICON = SHRINK_ICON +OakSpeech.SHRINK_END = SHRINK_END +-- ../pokecrystal/engine/menus/intro_menu.asm:945-948 +local ICON_X, ICON_Y = 64, 60 + local FALLBACKS = { _OakText1 = Strings("Hello! Sorry to\nkeep you waiting!\fWelcome to the\nworld of POKéMON!\fMy name is OAK.\fPeople call me the\nPOKéMON PROF."), _OakText2 = Strings("This world is in-\nhabited by crea-\vtures that we call\vPOKéMON."), @@ -369,14 +385,25 @@ function OakSpeech:openInitClock() local game = self.game if not (game and game.stack) then return self:advance() end self.busy = true + local faded = self.faded + self.faded = false local pushed = Screens.push(game, "Gen2InitClock", { mode = "clock", save = game.save, autoConfirm = self.autoConfirm, + fades = not self.autoConfirm, + faded = faded, onDone = function() game.stack:pop() - self.busy = false - self:advance() + -- ../pokecrystal/engine/menus/intro_menu.asm:629-635 + if self.autoConfirm then + self.busy = false + return self:advance() + end + IntroFade.run(self, { "inBlack", "outWhite" }, function() + self.busy = false + self:advance() + end) end, }) if not pushed then @@ -397,9 +424,12 @@ function OakSpeech:openGenderSelect(step) self.busy = true local pushed = Screens.push(game, "Gen2GenderSelect", { save = game.save, + fades = not self.autoConfirm, onDone = function(gender) game.stack:pop() self.busy = false + -- ../pokecrystal/engine/rtc/timeset.asm:22 + self.faded = not self.autoConfirm -- `ld hl, wPlayerName / ld de, .Chris|.Kris / call InitName`, the default -- NamePlayer lays down before the menu opens -- (../pokecrystal/engine/menus/intro_menu.asm:768-781). @@ -465,13 +495,28 @@ end function OakSpeech:startShrink(step) self.shrinkText = self:lastPageLines((step and step.textKey) or "_OakText7") self.shrink = { frame = 0 } + self.playerIcon = nil local data = self.game and self.game.data + -- ../pokecrystal/engine/menus/intro_menu.asm:806-812 + Music.fadeOut(OakSpeech.SHRINK_MUSIC_FADE) if data and data.audio and data.audio.sfx and data.audio.sfx.Sfx_EscapeRope then Sound.play(data, "Sfx_EscapeRope") end end +-- ../pokecrystal/engine/menus/intro_menu.asm:911-950 Intro_PlacePlayerSprite +function OakSpeech:overworldIcon() + local data = self.game and self.game.data + local sprites = data and data.gen2Sprites + local def = sprites and sprites[FieldMoves.playerSprite(self:gender())] + local image = def and tryImage(def.image) + if not image then return nil end + local colors = data.gen2Palettes + and Palettes.spritePalette(data.gen2Palettes, "DAY", def) or nil + return { image = image, colors = colors } +end + -- A beat that produced a value: the name menu, and any choice/yesno a build -- wrapper inserted. Same store and same event payload as Gen 1's, so a mod -- reading answers off intro.oak_speech.answered needs no second listener. @@ -637,6 +682,8 @@ function OakSpeech:finish() end function OakSpeech:update(_dt) + -- ../pokecrystal/home/fade.asm:22-101 + if IntroFade.advance(self) then return end local r = self.picReveal if r then r.t = r.t + 1 @@ -646,23 +693,26 @@ function OakSpeech:update(_dt) end return end - -- ShrinkPlayer timeline (intro_menu.asm ShrinkPlayer): pic1 → pic2 → - -- clear → chris sprite beat → fade music → overworld. + -- ../pokecrystal/engine/menus/intro_menu.asm:820-851 local s = self.shrink if not s then return end s.frame = s.frame + 1 - if s.frame == 8 then + if s.frame == SHRINK_PIC1 then self.pic = self.shrinkPic1 or self.pic - elseif s.frame == 16 then + elseif s.frame == SHRINK_PIC2 then self.pic = self.shrinkPic2 or self.pic - elseif s.frame == 24 then + elseif s.frame == SHRINK_CLEAR then self.pic = nil - elseif s.frame == 32 then - Music.fadeOut(10) - elseif s.frame >= 80 then + elseif s.frame == SHRINK_ICON then + self.playerIcon = self:overworldIcon() + elseif s.frame >= SHRINK_END then self.shrink = nil - self.shrinkText = nil - self:finish() + -- ../pokecrystal/engine/menus/intro_menu.asm:849-850 + IntroFade.run(self, { "outWhite" }, function() + self.shrinkText = nil + self.playerIcon = nil + self:finish() + end) end end @@ -700,11 +750,29 @@ function OakSpeech:drawPic() G.setColor(1, 1, 1, 1) end +-- ../pokecrystal/engine/menus/intro_menu.asm:911-950 +function OakSpeech:drawPlayerIcon() + local icon = self.playerIcon + if not icon or not icon.image then return end + local G = love.graphics + G.setColor(1, 1, 1, 1) + local w, h = icon.image:getDimensions() + local quad = love.graphics.newQuad(0, 0, math.min(16, w), math.min(16, h), + w, h) + if icon.colors and GbcPalette.available() then + GbcPalette.with(icon.colors, + function() G.draw(icon.image, quad, ICON_X, ICON_Y) end) + else + G.draw(icon.image, quad, ICON_X, ICON_Y) + end +end + function OakSpeech:drawPanel() local G = love.graphics G.setColor(1, 1, 1, 1) G.rectangle("fill", 0, 0, 160, 144) self:drawPic() + self:drawPlayerIcon() if self.shrinkText and self.fontOk then G.setColor(0, 0, 0, 1) for i, line in ipairs(self.shrinkText) do @@ -714,13 +782,19 @@ function OakSpeech:drawPanel() end end +function OakSpeech:drawBody() + self:drawPanel() + IntroFade.paint(self, 160, 144) +end + -- ../pokecrystal/engine/menus/intro_menu.asm:875 function OakSpeech:draw() - Chrome.withClip(function() self:drawPanel() end) + Chrome.withClip(function() self:drawBody() end) end function OakSpeech:drawWidescreen(winW, winH) - Chrome.withPanel(winW, winH, 1, 1, 1, function() self:drawPanel() end) + local r, g, b = IntroFade.surround(self, 1, 1, 1) + Chrome.withPanel(winW, winH, r, g, b, function() self:drawBody() end) end return OakSpeech diff --git a/tests/drivers/crystal_gender_shots.lua b/tests/drivers/crystal_gender_shots.lua index 9ed4f291..b6dedc9e 100644 --- a/tests/drivers/crystal_gender_shots.lua +++ b/tests/drivers/crystal_gender_shots.lua @@ -104,6 +104,9 @@ return function(game) end eq(speech.steps and speech.steps[1] and speech.steps[1].id, "init_clock", "the first beat") + -- ../pokecrystal/engine/menus/intro_menu.asm:629-635 + waitFor("the clock hand-off rotations", + function() return top().fade == nil end, 200) U.wait(40) U.shot(game, out .. "/01-oak.png") eq(game.save.player.gender, "male", "save.player.gender") @@ -132,7 +135,10 @@ return function(game) "wPlayerGender after the answer") eq(game.save.player.name, want == "girl" and "KRIS" or "CHRIS", "NamePlayer's InitName default") - -- ../pokecrystal/engine/rtc/timeset.asm:22-32 + -- ../pokecrystal/engine/rtc/timeset.asm:22-32, :42-43: PrintText only runs + -- once RotateFourPalettesRight is done, so wait the rotation out. + waitFor("the clock's fade in", + function() return isA(InitClock) and top().fade == nil end, 200) U.wait(20) U.shot(game, out .. "/02b-initclock.png") diff --git a/tests/drivers/gen2_intro_fades_2171.lua b/tests/drivers/gen2_intro_fades_2171.lua new file mode 100644 index 00000000..3dacc7c3 --- /dev/null +++ b/tests/drivers/gen2_intro_fades_2171.lua @@ -0,0 +1,179 @@ +-- #2171: the intro's palette rotations and ShrinkPlayer's player icon. +-- ../pokecrystal/home/fade.asm:22-101 +-- ../pokecrystal/engine/menus/intro_menu.asm:627-700, :802-950 +-- +-- POKEPORT_DRIVER=tests/drivers/gen2_intro_fades_2171.lua \ +-- POKEPORT_IDENTITY=crystal-sep04 POKEPORT_VERSION=crystal \ +-- POKEPORT_TOUCH=0 POKEPORT_SHOT_DIR= love . +local U = require("tests.drivers.util") + +local GenderSelect = require("src.ui.gen2.GenderSelect") +local InitClock = require("src.ui.gen2.InitClock") +local MainMenu = require("src.ui.gen2.MainMenu") +local NamePick = require("src.ui.gen2.NamePick") +local NamingScreen = require("src.ui.gen2.NamingScreen") +local OakSpeech = require("src.ui.gen2.OakSpeech") + +local GameVersion = require("src.core.GameVersion") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/2171-fades" + local log = io.open(out .. "/driver.log", "w") + + local function say(line) + print("[driver] " .. line) + if log then log:write(line .. "\n"); log:flush() end + end + + local function bail(reason) + say("FAIL " .. reason) + if log then log:close() end + love.event.quit(1) + error(reason, 0) + end + + local function top() return game.stack:top() end + local function isA(class) + local state = top() + return state ~= nil and getmetatable(state) == class + end + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + local function waitFor(label, predicate, frames) + for _ = 1, frames or 900 do + if predicate() then return end + U.wait(1) + end + bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top()))) + end + + -- The fades block input, so the frame counter is deterministic: wait for the + -- rotation to reach `at`, then shoot. U.shot costs a couple of logic ticks + -- of its own, which is why nothing here counts frames across a shot. + local function shootFade(class, kind, at, name, label) + waitFor(("%s %s frame %d"):format(label, kind, at), function() + if not isA(class) then return false end + local f = top().fade + return f ~= nil and f.kind == kind and f.frame >= at + end, 900) + if not U.shot(game, out .. "/" .. name) then bail("no shot " .. name) end + say("OK " .. label .. " -> " .. name) + end + + local version = tostring(GameVersion.get()) + say("version=" .. version .. " engine=" .. tostring(GameVersion.engine())) + + for _ = 1, 900 do + if isA(MainMenu) then break end + tap("start", 2) + end + if not isA(MainMenu) then + for _ = 1, 400 do + if isA(MainMenu) then break end + tap("a", 2) + end + end + waitFor("the intro menu", function() return isA(MainMenu) end) + local menu = top() + for i, item in ipairs(menu.list.items) do + if item.value == "new" then menu.list.index = i end + end + tap("a") + + -- --------------------------------------------------- the NEW GAME seam + U.wait(4) + if isA(GenderSelect) then + -- Crystal: InitGender answers, and InitClock's RotateFourPalettesLeft runs + -- with the gender screen still on the LCD. + waitFor("the gender prompt to finish typing", + function() return top().typer == nil or top().typer:done() end, 300) + tap("a") + shootFade(GenderSelect, "outBlack", 20, + "2171_04_gender_to_clock_mid.png", "gender -> clock, half faded out") + waitFor("the clock screen", function() return isA(InitClock) end, 300) + shootFade(InitClock, "inBlack", 22, + "2171_04_clock_fadein_mid.png", "the clock coming back from black") + else + -- Gold and Silver: NewGame_ClearTilemapEtc blanked the screen first, so + -- RotateFourPalettesLeft runs over a cleared tilemap and has nothing on it + -- to photograph; the rotation coming back is the judgeable half. + waitFor("the clock screen", function() return isA(InitClock) end, 300) + waitFor("RotateFourPalettesLeft over the cleared tilemap", function() + return isA(InitClock) and top().blank and top().fade + and top().fade.kind == "outBlack" + end, 300) + shootFade(InitClock, "inBlack", 22, + "2171_05_newgame_to_clock_mid.png", "NEW GAME -> clock, back from black") + end + + waitFor("the clock screen to settle", + function() return isA(InitClock) and top().fade == nil end, 300) + + -- --------------------------------------------------- the clock -> Oak seam + for _ = 1, 120 do + if not isA(InitClock) or top().fade then break end + tap("a", 3) + end + if not isA(InitClock) then bail("the clock left before its fade") end + shootFade(InitClock, "outBlack", 14, + "2171_03_clock_to_oak_mid.png", "clock -> Oak, half faded out") + + waitFor("the Oak speech", function() return isA(OakSpeech) end, 400) + say("OK the speech is up") + + -- --------------------------------------------------- the shrink + local shotShrink, shotWhite = false, false + for _ = 1, 900 do + if game.phase == "play" then break end + if isA(NamePick) and top().slide == nil then + top().cursor = 1 + tap("a") + waitFor("the naming screen", function() return isA(NamingScreen) end, 240) + local naming = top() + naming.row = naming:bottomRow() + naming.col = 6 + tap("a") + elseif isA(OakSpeech) then + local speech = top() + if not shotShrink and speech.shrink + and speech.shrink.frame >= OakSpeech.SHRINK_ICON + 12 then + if not U.shot(game, out .. "/2171_07_shrink_player_icon.png") then + bail("no shrink shot") + end + say("OK Intro_PlacePlayerSprite -> 2171_07_shrink_player_icon.png") + shotShrink = true + elseif shotShrink and not shotWhite and speech.fade + and speech.fade.kind == "outWhite" and speech.fade.frame >= 10 then + if not U.shot(game, out .. "/2171_08_post_shrink_fade.png") then + bail("no post-shrink shot") + end + say("OK RotateThreePalettesRight -> 2171_08_post_shrink_fade.png") + shotWhite = true + elseif speech.shrink == nil and speech.fade == nil then + tap("a", 2) + else + U.wait(1) + end + else + tap("a", 2) + end + end + + if not shotShrink then bail("the shrink never put the player icon up") end + if not shotWhite then bail("the shrink never faded out to white") end + waitFor("the overworld", function() + return game.phase == "play" and game.world and game.world.map + end, 600) + say("OK reached " .. tostring(game.world.map.id)) + + say("PASS gen2 intro fades (" .. version .. ") in " .. out) + if log then log:close() end + love.event.quit(0) +end diff --git a/tests/engine/gen2_intro_fade_2171.lua b/tests/engine/gen2_intro_fade_2171.lua new file mode 100644 index 00000000..a9773670 --- /dev/null +++ b/tests/engine/gen2_intro_fade_2171.lua @@ -0,0 +1,192 @@ +-- #2171 the intro's missing palette rotations and ShrinkPlayer's player icon. +-- ../pokecrystal/home/fade.asm:22-113 +-- ../pokecrystal/engine/rtc/timeset.asm:4-46 +-- ../pokecrystal/engine/menus/intro_menu.asm:627-700, :802-950 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = love or require("tests.love_stub") + +local T = require("tests.harness") +local eq, check = T.eq, T.check +local IntroFade = require("src.ui.gen2.IntroFade") +local InitClock = require("src.ui.gen2.InitClock") +local GenderSelect = require("src.ui.gen2.GenderSelect") +local OakSpeech = require("src.ui.gen2.OakSpeech") + +local function fakeInput() + return { + pressed = {}, + wasPressed = function(self, b) + local hit = self.pressed[b] + self.pressed[b] = nil + return hit and true or false + end, + press = function(self, b) self.pressed[b] = true end, + } +end + +-- --------------------------------------------------------------- the tables +do + eq(IntroFade.STEP_FRAMES, 8, "RotatePalettesRight: ld c, 8 / DelayFrames") + eq(IntroFade.frames("outBlack"), 32, + "RotateFourPalettesLeft: b = 4 steps x 8 frames") + eq(IntroFade.frames("inBlack"), 32, "RotateFourPalettesRight, the same") + eq(IntroFade.frames("outWhite"), 24, "RotateThreePalettesRight: b = 3") + eq(IntroFade.frames("inWhite"), 24, "RotateThreePalettesLeft: b = 3") + eq(IntroFade.new("nonesuch"), nil, "and nothing else is a rotation") + + -- IncGradGBPalTable_03 .. _00: the first step is still the loaded palette. + local out = IntroFade.new("outBlack") + eq(out:level(), 0, "outBlack step 1 is table 03, the normal palette") + for _ = 1, 8 do out:tick() end + eq(out:level(), 1 / 3, "step 2 is table 02") + for _ = 1, 24 do out:tick() end + check(out:done(), "32 frames is the whole rotation") + eq(out:level(), 1, "ending on table 00, every index black") + local r, g, b = out:blend(1, 1, 1) + eq(r + g + b, 0, "so the surround under it is black too") + + -- IncGradGBPalTable_00 .. _03: black first, loaded palette last. + local into = IntroFade.new("inBlack") + eq(into:level(), 1, "inBlack opens on table 00, fully black") + for _ = 1, 32 do into:tick() end + eq(into:level(), 0, "and lands back on the loaded palette") + + -- IncGradGBPalTable_05 .. _07. + local white = IntroFade.new("outWhite") + eq(white:level(), 1 / 3, "outWhite opens on table 05") + for _ = 1, 24 do white:tick() end + eq(white:level(), 1, "and ends on table 07, every index white") + local wr, wg, wb = white:blend(0, 0, 0) + eq(wr + wg + wb, 3, "with a white surround") +end + +-- ---------------------------------------------------- NEW GAME -> the clock +do + -- Gold and Silver: NewGame_ClearTilemapEtc has already blanked the screen, + -- so InitClock's RotateFourPalettesLeft fades a cleared tilemap out. + local clock = InitClock.new({ input = fakeInput() }, + { save = {}, fades = true }) + check(clock.blank, "the leading fade runs over a cleared tilemap") + check(IntroFade.busy(clock), "and it is a fade, not the clock screen") + eq(clock.fade.kind, "outBlack", "RotateFourPalettesLeft first") + for _ = 1, 32 do clock:update(0) end + eq(clock.fade and clock.fade.kind, "inBlack", + "then RotateFourPalettesRight into the clock") + check(not clock.blank, "which is the clock screen, not the cleared tilemap") + eq(clock.phase, "intro", "and no input reached it while it was fading") + for _ = 1, 32 do clock:update(0) end + check(not IntroFade.busy(clock), "64 frames covers both rotations") + + -- Crystal: the gender screen carried RotateFourPalettesLeft out with it. + local after = InitClock.new({ input = fakeInput() }, + { save = {}, fades = true, faded = true }) + eq(after.fade and after.fade.kind, "inBlack", + "so the clock opens straight on RotateFourPalettesRight") + check(not after.blank, "with the clock screen already under it") + + -- The day wheel and the driver path never fade. + local day = InitClock.new({}, { save = {}, mode = "day", fades = true }) + check(not IntroFade.busy(day), "SetDayOfWeek is not InitClock") + local driven = InitClock.new({}, { save = {}, autoConfirm = true, + fades = true }) + check(not IntroFade.busy(driven), "and autoConfirm keeps its frame counts") + local plain = InitClock.new({ input = fakeInput() }, { save = {} }) + check(not IntroFade.busy(plain), "a screen opened without fades has none") +end + +-- ------------------------------------------------- the clock's own way out +do + local input = fakeInput() + local done = false + local clock = InitClock.new({ input = input }, + { save = {}, fades = true, faded = true, + onDone = function() done = true end }) + for _ = 1, 32 do clock:update(0) end + clock.phase = "response" + clock.page = 1 + clock:startText() + clock.typer.shown = clock.typer.total + input:press("a") + clock:update(0) + check(not done, "A on the last page does not hand over on the same frame") + eq(clock.fade and clock.fade.kind, "outBlack", + "OakSpeech's RotateFourPalettesLeft runs on the clock screen") + eq(clock.typer.shown, clock.typer.total, + "with Oak's answer still printed, not reprinted into an empty box") + for _ = 1, 31 do clock:update(0) end + check(not done, "for the whole 32 frames") + clock:update(0) + check(done, "and only then is InitClock done") +end + +-- ------------------------------------------- the gender screen's way out +do + local chosen = nil + local gender = GenderSelect.new({}, { + save = { player = {} }, fades = true, + onDone = function(g) chosen = g end, + }) + gender:choose(2) + for _ = 1, 10 do gender:update(0) end + eq(chosen, nil, "the 10-frame DelayFrames does not hand over on its own") + eq(gender.fade and gender.fade.kind, "outBlack", + "InitClock's fade runs while the answered gender screen is still up") + for _ = 1, 31 do gender:update(0) end + eq(chosen, nil, "for the whole rotation") + gender:update(0) + eq(chosen, "female", "and the answer follows the black screen") + + local quick = nil + local plain = GenderSelect.new({}, { + save = { player = {} }, onDone = function(g) quick = g end }) + plain:choose(1) + for _ = 1, 10 do plain:update(0) end + eq(quick, "male", "a screen opened without fades keeps its old timing") +end + +-- ---------------------------------------------------- the shrink timeline +do + eq(OakSpeech.SHRINK_MUSIC_FADE, 32, + "ld a, 32 / ld [wMusicFade] -- and it is set before the SFX") + eq(OakSpeech.SHRINK_PIC1, 8, "DelayFrames 8, then Shrink1Pic") + eq(OakSpeech.SHRINK_PIC2, 16, "DelayFrames 8, then Shrink2Pic") + eq(OakSpeech.SHRINK_CLEAR, 24, "DelayFrames 8, then ClearBox 6,5 7x7") + eq(OakSpeech.SHRINK_ICON, 27, + "DelayFrames 3, then Intro_PlacePlayerSprite") + eq(OakSpeech.SHRINK_END, 77, "DelayFrames 50, then RotateThreePalettesRight") + + local speech = setmetatable({}, OakSpeech) + speech.shrinkPic1, speech.shrinkPic2 = "pic1", "pic2" + speech.shrink = { frame = 0 } + speech.shrinkText = { "I'll be seeing you" } + speech.answers = {} + local placed = 0 + speech.overworldIcon = function() + placed = placed + 1 + return { image = false } + end + local finished = false + speech.onDone = function() finished = true end + + for _ = 1, OakSpeech.SHRINK_CLEAR do speech:update(0) end + eq(speech.pic, nil, "ClearBox takes the shrink frame down at 24") + eq(speech.playerIcon, nil, "and nothing replaces it yet") + for _ = 1, OakSpeech.SHRINK_ICON - OakSpeech.SHRINK_CLEAR do + speech:update(0) + end + eq(placed, 1, "Intro_PlacePlayerSprite runs once, at frame 27") + check(speech.playerIcon ~= nil, "and the icon is what is on the screen") + + for _ = 1, OakSpeech.SHRINK_END - OakSpeech.SHRINK_ICON do speech:update(0) end + eq(speech.shrink, nil, "the 50-frame hold ends at 77") + eq(speech.fade and speech.fade.kind, "outWhite", + "on RotateThreePalettesRight, not on the world load") + check(speech.playerIcon ~= nil, "with the icon still up through the fade") + check(not finished, "and the speech is not over yet") + for _ = 1, 24 do speech:update(0) end + check(finished, "24 frames later it is") + eq(speech.playerIcon, nil, "ClearTilemap takes the icon with it") + eq(speech.shrinkText, nil, "and the line under it") +end From 1863af2bac3835883998ba3606ad4163f161e180 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 4 Sep 2026 10:45:08 -0400 Subject: [PATCH 09/10] gsc: the battle HUD frame fades with the rest of the screen --- src/render/GbcPalette.lua | 1 + src/ui/gen2/BattleHud.lua | 5 +- src/ui/gen2/BattleState.lua | 5 +- src/world/OverworldController.lua | 25 +++++--- tests/drivers/crystal_battle_hud_fade.lua | 75 +++++++++++++++++++++++ tests/drivers/gold_boot_smoke.lua | 5 ++ tests/gen2_battle_exit_fade_test.lua | 34 ++++++++++ 7 files changed, 138 insertions(+), 12 deletions(-) create mode 100644 tests/drivers/crystal_battle_hud_fade.lua diff --git a/src/render/GbcPalette.lua b/src/render/GbcPalette.lua index bfb7b24e..97c496ff 100644 --- a/src/render/GbcPalette.lua +++ b/src/render/GbcPalette.lua @@ -40,6 +40,7 @@ GbcPalette.CUSTOM_MODE = "custom" local DMG_SHADES = { { 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 }, } +GbcPalette.DMG_SHADES = DMG_SHADES -- #9BBC0F / #8BAC0F / #306230 / #0F380F, the same ramp Gen 1's CLASSIC uses. local CLASSIC_SHADES = { { 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 }, diff --git a/src/ui/gen2/BattleHud.lua b/src/ui/gen2/BattleHud.lua index 3c1de6ce..e51c8a7f 100644 --- a/src/ui/gen2/BattleHud.lua +++ b/src/ui/gen2/BattleHud.lua @@ -128,8 +128,9 @@ function BattleHud:drawTile(key, firstTile, tile, tx, ty, colors, mirror) G.draw(image, self:quad(image, index), tx * 8, ty * 8) end end - if colors and GbcPalette.available() then - GbcPalette.with(colors, body) + -- home/fade.asm:35 (RotateThreePalettesRight) + if GbcPalette.available() then + GbcPalette.with(colors or GbcPalette.DMG_SHADES, body) else G.setColor(0, 0, 0, 1) body() diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 16b7a450..95f4de7b 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -4140,7 +4140,9 @@ end -- above it, not inside a border. function BattleState:drawFrame(tx, ty, width, stubRight) local G = love.graphics - G.setColor(0, 0, 0, 1) + -- home/fade.asm:35 (RotateThreePalettesRight) + local ink = GbcPalette.color(GbcPalette.DMG_SHADES, 4) or { 0, 0, 0 } + G.setColor(ink[1] / 255, ink[2] / 255, ink[3] / 255, 1) -- The bottom rule ($76 repeated, capped by $74/$78 or $6f/$77) sits at the -- top of its own tile row, immediately under the bar above it. G.rectangle("fill", tx * 8, ty * 8, width * 8, 2) @@ -4148,6 +4150,7 @@ function BattleState:drawFrame(tx, ty, width, stubRight) -- climbs from the rule past the bar row. local stubX = stubRight and ((tx + width - 2) * 8 + 6) or (tx * 8) G.rectangle("fill", stubX, ty * 8 - 8, 2, 10) + G.setColor(1, 1, 1, 1) end -- LoadBattleFontsHPBar puts FontBattleExtra in the $60 slot for the whole diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 49c5f2ad..1a32dc22 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -2891,22 +2891,29 @@ function OverworldState:billsHousePC() or romText(Game.data, "_BillsHouseMonitorText", "TELEPORTER is\ndisplayed on the\nPC monitor."))) return end - require("src.core.Music").stop() Game.stack:push(TextBox.new(Game, t._BillsHouseInitiatedText or Strings("{PLAYER} initiated\nTELEPORTER's Cell\nSeparator!"), function() - flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true - require("src.core.Sound").play(Game.data, "Switch") + -- BillsHouseInitiatedText's text_asm stops the music only after the + -- prompt button -- bills_house_pc.asm:55 + require("src.core.Music").stop() self:queueScript({ + { "wait", 16 }, + { "play_sound", "Switch" }, { "wait_sound" }, + { "wait", 60 }, + -- .doCellSeparator -- bills_house_pc.asm:18 { "wait", 32 }, - { "play_sound", "Tink" }, + { "play_sound", "Tink" }, { "wait_sound" }, { "wait", 80 }, - { "play_sound", "Shrink" }, + { "play_sound", "Shrink" }, { "wait_sound" }, { "wait", 48 }, - { "play_sound", "Tink" }, + { "play_sound", "Tink" }, { "wait_sound" }, { "wait", 32 }, - { "play_sound", "Get_Item1" }, - { "wait", 30 }, - }, { onDone = function() self:billsHouseBillExits() end }) + { "play_sound", "Get_Item1" }, { "wait_sound" }, + }, { onDone = function() + -- bills_house_pc.asm:39 + flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true + self:billsHouseBillExits() + end }) end)) end diff --git a/tests/drivers/crystal_battle_hud_fade.lua b/tests/drivers/crystal_battle_hud_fade.lua new file mode 100644 index 00000000..fd966ad6 --- /dev/null +++ b/tests/drivers/crystal_battle_hud_fade.lua @@ -0,0 +1,75 @@ +-- ../pokecrystal/home/fade.asm:35-62 +-- ../pokecrystal/engine/battle/core.asm DrawEnemyHUDBorder / DrawPlayerHUDBorder +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-hud-fade" + os.execute("mkdir -p '" .. out .. "'") + local shotN = 0 + local function shot(tag) + shotN = shotN + 1 + game.capturePath = string.format("%s/%03d_%s.png", out, shotN, tag) + end + local fails = 0 + local function ok(cond, msg) + if not cond then fails = fails + 1 end + print("[hud-fade] " .. (cond and "PASS " or "FAIL ") .. msg) + return cond + end + + U.wait(60) + local world = game.world + assert(world and world.map, "the crystal world did not boot") + local save, data = game.save, game.data + save.party = { Mon.new(data, "CYNDAQUIL", 12) } + + local wild = Mon.new(data, "SENTRET", 3) + ok(world:startBattle({ wild = wild }), "a wild battle starts") + local screen + for _ = 1, 600 do + U.wait(1) + local top = game.stack:top() + if top and top.battle then screen = top break end + end + ok(screen ~= nil, "the battle screen is up") + for _ = 1, 900 do + if screen.phase == "menu" then break end + U.tap(game, "a") + U.wait(2) + end + ok(screen.phase == "menu", "reached the battle menu") + U.wait(20) + shot("hud_before_fade") + U.wait(6) + + U.tap(game, "down") + U.wait(4) + U.tap(game, "right") + U.wait(4) + U.tap(game, "a") + + local rows, taken = {}, {} + for _ = 1, 900 do + if game.stack:top() ~= screen then break end + if screen.phase == "fadeout" then + local row = screen:exitFadeBgp() + if row and not taken[row] then + taken[row] = true + rows[#rows + 1] = row + shot(("hud_fade_row_%02x"):format(row)) + end + elseif (screen.messageTimer or 0) > 0 then + U.tap(game, "a") + end + U.wait(1) + end + local rowText = {} + for _, row in ipairs(rows) do rowText[#rowText + 1] = ("%02x"):format(row) end + ok(table.concat(rowText, ",") == "90,40,00", + "shot every fade row (" .. table.concat(rowText, ",") .. ")") + + print("[hud-fade] " .. (fails == 0 and "PASS all claims" or + (fails .. " claims failed")) .. " -- " .. shotN .. " shots in " .. out) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gold_boot_smoke.lua b/tests/drivers/gold_boot_smoke.lua index 1d6378c7..3f8645e6 100644 --- a/tests/drivers/gold_boot_smoke.lua +++ b/tests/drivers/gold_boot_smoke.lua @@ -103,6 +103,11 @@ return function(game) -- `farcall InitClock` opens OakSpeech, so the clock screen is what NEW GAME -- lands on and Oak is underneath it. waitFor("the clock screen", function() return isA(InitClock) end, 300) + -- ../pokecrystal/engine/rtc/timeset.asm:22, :42: PrintText is on the far side + -- of RotateFourPalettesLeft and RotateFourPalettesRight. + waitFor("the clock's fade in", + function() return isA(InitClock) and top().fade == nil end, 200) + U.wait(20) U.shot(game, out .. "/06b-initclock.png") for _ = 1, 60 do if isA(OakSpeech) then break end diff --git a/tests/gen2_battle_exit_fade_test.lua b/tests/gen2_battle_exit_fade_test.lua index d7e09e0e..084de82d 100644 --- a/tests/gen2_battle_exit_fade_test.lua +++ b/tests/gen2_battle_exit_fade_test.lua @@ -170,4 +170,38 @@ do eq(screen:exitFadeLength(), 24, "over 24 frames") end +-- engine/battle/core.asm DrawEnemyHUDBorder / DrawPlayerHUDBorder: the frame is +-- BG tiles, so RotateThreePalettesRight washes it out with everything else. +do + local G = love.graphics + local sent = {} + G.newShader = function() + return { send = function(_, name, value) sent[name] = value end } + end + local BattleHud = require("src.ui.gen2.BattleHud") + local hud = BattleHud.new({ battleHud = { + enemyBorder = "enemy_border.png", playerBorder = "player_border.png", + } }, nil) + local sheet = { getDimensions = function() return 48, 8 end } + hud.images["enemy_border.png"] = sheet + hud.images["player_border.png"] = sheet + check(GbcPalette.available(), "the harness has a shader to remap through") + + local function frameInk(byte) + sent.pal3 = nil + local previous = GbcPalette.setBgp(byte) + hud:drawEnemyFrame() + hud:drawPlayerFrame() + GbcPalette.setBgp(previous) + return sent.pal3 + end + + local ink = frameInk(0xe4) + check(ink and ink[1] == 0 and ink[2] == 0 and ink[3] == 0, + "the HUD frame is black on the identity row") + ink = frameInk(0x00) + check(ink and ink[1] == 1 and ink[2] == 1 and ink[3] == 1, + "and white on the last fade row, not a black skeleton left standing") +end + S.finish() From 469ba593a63567e00e05a2f097414cd1de69838a Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 4 Sep 2026 10:48:56 -0400 Subject: [PATCH 10/10] textbox fix for intro fade out --- src/ui/gen2/OakSpeech.lua | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ui/gen2/OakSpeech.lua b/src/ui/gen2/OakSpeech.lua index bb60c0c1..bca8b998 100644 --- a/src/ui/gen2/OakSpeech.lua +++ b/src/ui/gen2/OakSpeech.lua @@ -774,9 +774,12 @@ function OakSpeech:drawPanel() self:drawPic() self:drawPlayerIcon() if self.shrinkText and self.fontOk then - G.setColor(0, 0, 0, 1) - for i, line in ipairs(self.shrinkText) do - Font.draw(line, 16, 104 + (i - 1) * 16) + -- home/text.asm:142 PrintText -> SetUpTextbox -> SpeechTextbox + Chrome.textbox(0, 12, 18, 4) + local row = 14 + for _, line in ipairs(self.shrinkText) do + if row <= 16 then Chrome.print(line, 1, row) end + row = row + 2 end G.setColor(1, 1, 1, 1) end