From a9ce423bdcbf885b29db7d22ae8bf5133aaea41d Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 28 Aug 2026 12:15:36 -0400 Subject: [PATCH] CLOSES #1275, CLOSES #1295, CLOSES #1384, CLOSES #1628, CLOSES #1677, CLOSES #1682, CLOSES #1691, CLOSES #1813, CLOSES #1822, CLOSES #1861, CLOSES #1867, CLOSES #1868, CLOSES #1869, CLOSES #1870, CLOSES #1871, CLOSES #1872, CLOSES #1874, CLOSES #1876, CLOSES #1878, CLOSES #1880, CLOSES #1881, CLOSES #1882, CLOSES #1884, CLOSES #1885, CLOSES #1886, CLOSES #1887, CLOSES #1888, CLOSES #1889, CLOSES #1890, CLOSES #1891, CLOSES #1892, CLOSES #1893, CLOSES #1894, CLOSES #1895, CLOSES #1896, CLOSES #1899, CLOSES #1900, CLOSES #1901 CLOSES #1275, CLOSES #1295, CLOSES #1384, CLOSES #1628, CLOSES #1677, CLOSES #1682, CLOSES #1691, CLOSES #1813, CLOSES #1822, CLOSES #1861, CLOSES #1867, CLOSES #1868, CLOSES #1869, CLOSES #1870, CLOSES #1871, CLOSES #1872, CLOSES #1874, CLOSES #1876, CLOSES #1878, CLOSES #1880, CLOSES #1881, CLOSES #1882, CLOSES #1884, CLOSES #1885, CLOSES #1886, CLOSES #1887, CLOSES #1888, CLOSES #1889, CLOSES #1890, CLOSES #1891, CLOSES #1892, CLOSES #1893, CLOSES #1894, CLOSES #1895, CLOSES #1896, CLOSES #1899, CLOSES #1900, CLOSES #1901 --- data/scripts/story3.lua | 93 ++++--- data/scripts/story4.lua | 94 +++++-- docs/new-features.md | 1 + .../java/org/love2d/android/GameActivity.java | 62 +++-- poison_flicker_bug1872.png | Bin 0 -> 10371 bytes src/battle/AnimPlayer.lua | 6 + src/battle/BattleState.lua | 8 + src/battle/gen2/AnimRunner.lua | 11 +- src/core/FilePicker.lua | 1 + src/core/Game2.lua | 60 ++++- src/core/HostShell.lua | 5 + src/core/Timing.lua | 2 + src/core/TouchControls.lua | 19 +- src/core/TouchSkin.lua | 5 + src/core/gen2/NpcTrade.lua | 5 + src/core/gen2/Save.lua | 24 +- src/import/CacheContract.lua | 13 + src/import/LauncherSettings.lua | 3 + src/import/LauncherView.lua | 30 ++- src/import/RomExtractor.lua | 20 +- src/import/RomExtractorGen2.lua | 22 ++ src/import/RomImporter.lua | 193 +++++++++++--- src/inventory/ItemEffects.lua | 3 +- src/link/CodeEntry.lua | 6 +- src/link/LinkState.lua | 74 +++--- src/mods/Gen2Compat.lua | 5 +- src/mods/LauncherMods.lua | 5 +- src/mods/ModIndex.lua | 36 +++ src/mods/Schemas.lua | 3 +- src/render/PaletteFX.lua | 3 + src/render/TextBox.lua | 19 +- src/save_convert/Gen2Layout.lua | 9 + src/save_convert/Gen2Save.lua | 81 +++++- src/save_convert/GenSave.lua | 28 +-- src/save_convert/MapContext.lua | 19 +- src/sync/SyncEngine.lua | 79 ++++-- src/ui/BagMenu.lua | 6 +- src/ui/ListMenu.lua | 45 ++-- src/ui/Menu.lua | 8 +- src/ui/MenuRepeat.lua | 43 ++++ src/ui/PartyMenu.lua | 11 +- src/ui/PokedexMenu.lua | 12 +- src/ui/PrizeCounter.lua | 66 +++++ src/ui/ShopMenu.lua | 71 ++++-- src/ui/TownMap.lua | 127 +++++++--- src/ui/gen2/BattleAnimView.lua | 51 ++-- src/ui/gen2/BattleState.lua | 69 ++++- src/ui/gen2/Chrome.lua | 28 +++ src/ui/gen2/Credits.lua | 11 +- src/ui/gen2/HallOfFame.lua | 11 +- src/ui/gen2/OptionsMenu.lua | 5 +- src/ui/gen2/PackMenu.lua | 62 ++++- src/ui/gen2/PokedexMenu.lua | 154 +++++++----- src/ui/gen2/Pokegear.lua | 68 +++-- src/ui/kit/Kit.lua | 6 +- src/ui/kit/VirtualKeyboard.lua | 4 +- src/world/OverworldController.lua | 22 +- src/world/gen2/Npc.lua | 7 +- src/world/gen2/Player.lua | 19 +- src/world/gen2/World.lua | 134 +++++++++- .../battle_scy_deform_bug1895_test.lua | 131 ++++++++++ .../caught_ball_palette_bug1896_test.lua | 155 ++++++++++++ tests/drivers/fly_music_bug1892_test.lua | 68 +++++ tests/drivers/game_corner_bug624_test.lua | 29 +-- .../drivers/gold_battle_size_bug1709_test.lua | 204 +++++++++++++++ .../drivers/gold_bike_mount_bug1481_test.lua | 69 +++++ tests/drivers/gold_dex_area_bug1891_test.lua | 94 +++++++ tests/drivers/gold_pack_sfx_bug1893_test.lua | 120 +++++++++ .../drivers/link_addr_entry_bug1295_test.lua | 39 +++ .../drivers/menu_hold_scroll_bug1898_test.lua | 72 ++++++ tests/drivers/party_bug147_message_test.lua | 25 +- tests/drivers/poison_flicker_bug1872_test.lua | 76 ++++++ .../prize_room_coincase_bug194_test.lua | 28 ++- tests/drivers/route.lua | 46 ++-- tests/drivers/snorlax_flute_bug1880_test.lua | 106 ++++++++ .../drivers/touch_skin_crop_bug1677_test.lua | 72 ++++++ .../drivers/vending_machine_bug1876_test.lua | 124 +++++++++ .../drivers/widescreen_clip_bug1869_test.lua | 166 +++++++++++++ .../amnesia_rest_share_anim_bug1881.lua | 35 +++ tests/engine/cart_import_cancel_bug1813.lua | 93 +++++++ tests/engine/cart_launcher.lua | 13 +- tests/engine/fly_map_arrows_bug1892.lua | 170 +++++++++++++ .../engine/gen2_anim_bake_scissor_bug1895.lua | 53 ++++ .../gen2_caught_ball_palette_bug1896.lua | 48 ++++ .../engine/gen2_caught_data_stamp_bug1886.lua | 220 ++++++++++++++++ tests/engine/gen2_fly_bubble_bug1890.lua | 55 ++++ tests/engine/gen2_fly_leaves_bug1889.lua | 84 +++++++ tests/engine/gen2_ledge_shadow_bug1888.lua | 86 +++++++ tests/engine/gen2_pack_sfx_bug1893.lua | 132 ++++++++++ .../gen2_pokedex_area_landmark_bug1267.lua | 134 +++++++--- tests/engine/gen2_pokegear_page_bug1884.lua | 81 ++++++ tests/engine/gen2_pokegear_paper_bug1885.lua | 85 +++++++ .../engine/gen2_radio_rocket_flag_bug1871.lua | 55 ++++ tests/engine/gen2_save_import.lua | 82 +++++- tests/engine/gen2_scy_deform_blit_bug1895.lua | 77 ++++++ tests/engine/gen2_widescreen_clip_bug1869.lua | 89 +++++++ tests/engine/launcher_find_details.lua | 110 ++++++++ .../launcher_game_popup_fill_bug1874.lua | 109 ++++++++ tests/engine/launcher_navigation_perf.lua | 38 +++ tests/engine/launcher_nx_pad_cursor_test.lua | 9 +- tests/engine/launcher_osk_touch_bug1861.lua | 80 ++++++ tests/engine/launcher_uwp_pad_nav_test.lua | 142 +++++++++++ tests/engine/link_addr_entry_bug1295.lua | 47 ++++ tests/engine/mart_list_box_bug1887.lua | 159 ++++++++++++ tests/engine/menu_hold_scroll_bug1898.lua | 235 ++++++++++++++++++ tests/engine/mod_update_badge.lua | 97 ++++++++ tests/engine/open_menu_bugs_949_1149.lua | 6 +- tests/engine/party_battle_prompt_bug1901.lua | 50 ++++ tests/engine/poison_flicker_bug1872.lua | 111 +++++++++ .../engine/poke_flute_battle_turn_bug1882.lua | 105 ++++++++ tests/engine/poke_flute_prompt_bug1880.lua | 101 ++++++++ tests/engine/pokedex_area_bug1878.lua | 133 ++++++++++ tests/engine/prize_window_bug1867.lua | 214 ++++++++++++++++ tests/engine/rom_pick_tv_cancel.lua | 83 +++++++ tests/engine/save_convert_extra_flags.lua | 1 + tests/engine/save_convert_toggle_objects.lua | 3 + .../engine/save_export_map_window_bug1691.lua | 88 +++++++ tests/engine/save_file_io_tests.lua | 9 + tests/engine/sync_engine_test.lua | 144 ++++++++++- tests/engine/timing_parity.lua | 42 ++++ tests/engine/touch_skin_test.lua | 20 ++ tests/engine/town_map_cursor_bug1868.lua | 90 +++++++ tests/engine/vending_machine_bug1876.lua | 214 ++++++++++++++++ tests/fixture_data/map_window.lua | 30 +++ tests/gen2_battle_options_test.lua | 108 +++++++- tests/gen2_menus_test.lua | 11 +- tests/gen2_ow_bounce_test.lua | 39 ++- tests/gen2_screen_layout_test.lua | 3 +- tests/parity_box_mon_stats.lua | 7 + tests/parity_save_fly_towns.lua | 8 + tests/parity_save_nicknames.lua | 8 + tests/save_convert_tests.lua | 3 + tests/save_convert_yellow_bug838_test.lua | 5 + tools/gen2_sram_offsets.py | 1 + tools/make_gold_manifest.py | 2 + tools/make_rom_manifest.py | 2 + tools/rom_manifest.json | 8 + tools/rom_manifest_blue.json | 8 + tools/rom_manifest_crystal.json | 12 + tools/rom_manifest_gold.json | 12 + tools/rom_manifest_silver.json | 12 + tools/rom_manifest_yellow.json | 8 + 142 files changed, 7348 insertions(+), 682 deletions(-) create mode 100644 poison_flicker_bug1872.png create mode 100644 src/ui/MenuRepeat.lua create mode 100644 src/ui/PrizeCounter.lua create mode 100644 tests/drivers/battle_scy_deform_bug1895_test.lua create mode 100644 tests/drivers/caught_ball_palette_bug1896_test.lua create mode 100644 tests/drivers/fly_music_bug1892_test.lua create mode 100644 tests/drivers/gold_battle_size_bug1709_test.lua create mode 100644 tests/drivers/gold_bike_mount_bug1481_test.lua create mode 100644 tests/drivers/gold_dex_area_bug1891_test.lua create mode 100644 tests/drivers/gold_pack_sfx_bug1893_test.lua create mode 100644 tests/drivers/link_addr_entry_bug1295_test.lua create mode 100644 tests/drivers/menu_hold_scroll_bug1898_test.lua create mode 100644 tests/drivers/poison_flicker_bug1872_test.lua create mode 100644 tests/drivers/snorlax_flute_bug1880_test.lua create mode 100644 tests/drivers/touch_skin_crop_bug1677_test.lua create mode 100644 tests/drivers/vending_machine_bug1876_test.lua create mode 100644 tests/drivers/widescreen_clip_bug1869_test.lua create mode 100644 tests/engine/amnesia_rest_share_anim_bug1881.lua create mode 100644 tests/engine/cart_import_cancel_bug1813.lua create mode 100644 tests/engine/fly_map_arrows_bug1892.lua create mode 100644 tests/engine/gen2_anim_bake_scissor_bug1895.lua create mode 100644 tests/engine/gen2_caught_ball_palette_bug1896.lua create mode 100644 tests/engine/gen2_caught_data_stamp_bug1886.lua create mode 100644 tests/engine/gen2_fly_bubble_bug1890.lua create mode 100644 tests/engine/gen2_fly_leaves_bug1889.lua create mode 100644 tests/engine/gen2_ledge_shadow_bug1888.lua create mode 100644 tests/engine/gen2_pack_sfx_bug1893.lua create mode 100644 tests/engine/gen2_pokegear_page_bug1884.lua create mode 100644 tests/engine/gen2_pokegear_paper_bug1885.lua create mode 100644 tests/engine/gen2_radio_rocket_flag_bug1871.lua create mode 100644 tests/engine/gen2_scy_deform_blit_bug1895.lua create mode 100644 tests/engine/gen2_widescreen_clip_bug1869.lua create mode 100644 tests/engine/launcher_find_details.lua create mode 100644 tests/engine/launcher_game_popup_fill_bug1874.lua create mode 100644 tests/engine/launcher_osk_touch_bug1861.lua create mode 100644 tests/engine/launcher_uwp_pad_nav_test.lua create mode 100644 tests/engine/link_addr_entry_bug1295.lua create mode 100644 tests/engine/mart_list_box_bug1887.lua create mode 100644 tests/engine/menu_hold_scroll_bug1898.lua create mode 100644 tests/engine/mod_update_badge.lua create mode 100644 tests/engine/party_battle_prompt_bug1901.lua create mode 100644 tests/engine/poison_flicker_bug1872.lua create mode 100644 tests/engine/poke_flute_battle_turn_bug1882.lua create mode 100644 tests/engine/poke_flute_prompt_bug1880.lua create mode 100644 tests/engine/pokedex_area_bug1878.lua create mode 100644 tests/engine/prize_window_bug1867.lua create mode 100644 tests/engine/rom_pick_tv_cancel.lua create mode 100644 tests/engine/save_export_map_window_bug1691.lua create mode 100644 tests/engine/town_map_cursor_bug1868.lua create mode 100644 tests/engine/vending_machine_bug1876.lua create mode 100644 tests/fixture_data/map_window.lua diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index c47a1ad4..2df735c2 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -782,7 +782,7 @@ end -- buying again means talking to the counter again (#623). local function prizeCounter(window) return function(game, ow, npc, done) - local ListMenu = require("src.ui.ListMenu") + local PrizeCounter = require("src.ui.PrizeCounter") local Commands = require("src.script.Commands") local TextBox = require("src.render.TextBox") local t = game.data.text @@ -793,28 +793,25 @@ local function prizeCounter(window) t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done)) return end - -- ExchangeCoinsForPrizesText plays before the prize window opens. game.stack:push(TextBox.new(game, t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.", function() - local items = {} + local rows = {} for _, p in ipairs(prizeWindow(window)) do - local label - if p.kind == "mon" then - label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level) - else - label = game.data.items[p.item].name - end - table.insert(items, - { label = label, right = tostring(p.cost), value = p }) + rows[#rows + 1] = { + name = (p.kind == "mon") + and game.data.pokemon[p.species].name + or game.data.items[p.item].name, + cost = p.cost, + prize = p, + } + end + local function close() + game.stack:pop() + game.stack:pop() end - -- NoThanksText (data/events/prizes.asm) sits under the three prizes - table.insert(items, { label = "NO THANKS" }) - local list - -- close the window first: every ending in HandlePrizeChoice leaves - -- the menu for good, and the closing line belongs over the map local function finish(msg) - list:close() + close() game.stack:push(TextBox.new(game, msg, done)) end local function buy(p) @@ -844,40 +841,38 @@ local function prizeCounter(window) game.save.coins = game.save.coins - p.cost -- no thank-you line: HereYouGoText is unreferenced in the asm, -- which just redraws the coin box (PrintPrizePrice) and returns - list:close() + close() done() end - list = ListMenu.new(game, "PRIZES (COINS)", items, { - footer = ("COINS %d"):format(game.save.coins or 0), - onChoose = function(item) - local p = item.value - if not p then -- NO THANKS is the B exit (cp 3 -> .noChoice) - list:close() - done() - return - end - local name = (p.kind == "mon") - and game.data.pokemon[p.species].name - or game.data.items[p.item].name - -- SoYouWantPrizeText names the prize out of wNameBuffer, which - -- is not one of TextBox's RAM tokens, so fill it in here - local ask = (t._SoYouWantPrizeText - or "So, you want\n{RAM:wNameBuffer}?") - :gsub("{RAM:wNameBuffer}", name) - game.stack:push(TextBox.new(game, ask, nil, { - choice = function(yes) - if not yes then - finish(t._OhFineThenText or "Oh, fine then.") - return - end - buy(p) - end, - })) - end, - onCancel = done, - }) - game.stack:push(list) - end)) + game.stack:push(TextBox.new(game, + t._WhichPrizeText or "Which prize do\nyou want?", nil, { + instant = true, + stay = { onShown = function() + game.stack:push(PrizeCounter.new(game, rows, { + onCancel = function() + close() + done() + end, + onPick = function(row) + local p = row.prize + local ask = (t._SoYouWantPrizeText + or "So, you want\n{RAM:wNameBuffer}?") + :gsub("{RAM:wNameBuffer}", row.name) + game.stack:push(TextBox.new(game, ask, nil, { + instant = true, + choice = function(yes) + if not yes then + finish(t._OhFineThenText or "Oh, fine then.") + return + end + buy(p) + end, + })) + end, + })) + end }, + })) + end, { instant = true })) end end diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 8069bf09..29192d9f 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -391,30 +391,84 @@ local DRINK_PRICES = { { id = "LEMONADE", price = 350 }, } +-- engine/events/vending_machine.asm:56-65 +local function deliveryRumble(game, onDone) + local left, wait = 60, 2 + return { + draw = function() end, + update = function(self) + wait = wait - 1 + if wait > 0 then return end + wait = 2 + require("src.core.Sound").play(game.data, "Push_Boulder") + left = left - 1 + if left <= 0 then + game.stack:pop() + onDone() + end + end, + } +end + +-- engine/events/vending_machine.asm local function vendingMachine(game, ow, npc, done) - local ListMenu = require("src.ui.ListMenu") + local t = text(game) + local Menu = require("src.ui.Menu") + local Font = require("src.render.Font") + local money = function() return game.save.money end + local function closeSession(msg, menuPopped) + if not menuPopped then game.stack:pop() end + game.stack:pop() + push(game, msg, done, { money = money }) + end + local function notThirsty() + closeSession(t._VendingMachineText7 or "Not thirsty!", true) + end + local function buy(d) + if game.save.money < d.price then + closeSession(t._VendingMachineText4 or "Oops, not enough\nmoney!") + return + end + if not require("src.inventory.Bag").add(game.save, d.id, 1, game.data) then + closeSession(t._VendingMachineText6 or "There's no more\nroom for stuff!") + return + end + game.stack:push(deliveryRumble(game, function() + game.save.money = game.save.money - d.price + closeSession(fill(t._VendingMachineText5 + or "{RAM:wStringBuffer}\npopped out!", + { ram = game.data.items[d.id].name })) + end)) + end local items = {} for _, d in ipairs(DRINK_PRICES) do - table.insert(items, { - value = d, label = ("%s ¥%d"):format(game.data.items[d.id].name, d.price), - }) + items[#items + 1] = { + label = game.data.items[d.id].name, + keepOpen = true, + onSelect = function() buy(d) end, + } end - game.stack:push(ListMenu.new(game, "VENDING MACHINE", items, { - onChoose = function(item, list) - local d = item.value - if game.save.money < d.price then - push(game, "Not enough\nmoney.") - return - end - if not require("src.inventory.Bag").add(game.save, d.id, 1) then - push(game, "You have no room\nfor it!") - return - end - game.save.money = game.save.money - d.price - push(game, ("%s\npopped out!"):format(game.data.items[d.id].name)) - end, - onCancel = done, - })) + items[#items + 1] = { label = "CANCEL", onSelect = notThirsty } + push(game, t._VendingMachineText1 or "A vending machine!\nHere's the menu!", + nil, { + money = money, + stay = { prompt = true, onShown = function() + local menu = Menu.new(game, items, { + tx = 0, ty = 3, tw = 14, th = 10, itemY = 2, + noWrap = true, + onCancel = notThirsty, + }) + menu.draw = function(self) + Menu.draw(self) + love.graphics.setColor(0, 0, 0, 1) + for i, d in ipairs(DRINK_PRICES) do + Font.draw(("¥%d"):format(d.price), 9 * 8, (6 + (i - 1) * 2) * 8) + end + love.graphics.setColor(1, 1, 1, 1) + end + game.stack:push(menu) + end }, + }) end -- drink -> TM (CeladonMartRoof.asm .gaveFreshWater/.gaveSodaPop/ diff --git a/docs/new-features.md b/docs/new-features.md index 9645ffcb..e34d7f08 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -26,3 +26,4 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Pokémon Crystal** as an importable, launcher-selectable version alongside Gold and Silver * **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check` * **Followers** for mods, plus Gen 2-only registries and hooks +* **Battle screen options** on Gold, Silver and Crystal: BATTLE SIZE (fixed or window-filling) and BATTLE BG (white or black surround) diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 8490fa50..f41baae0 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -45,6 +45,7 @@ import android.Manifest; import android.app.AlarmManager; import android.app.AlertDialog; import android.app.PendingIntent; +import android.app.UiModeManager; import android.content.Context; import android.content.ClipData; import android.content.DialogInterface; @@ -53,6 +54,7 @@ import android.content.SharedPreferences; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; import android.content.res.AssetManager; +import android.content.res.Configuration; import android.hardware.Sensor; import android.hardware.SensorEvent; import android.hardware.SensorEventListener; @@ -112,6 +114,7 @@ public class GameActivity extends SDLActivity { // basename as its body, so RomImporter:focus can say so in the launcher // instead of leaving the player on "No ROM imported" (issue #442). private static final String PICK_ERROR_FILENAME = "pick_error.flag"; + private static final String PICK_CANCELLED_PREFIX = "cancelled:"; // Written after a direct required-import copy has been fully published. private static final String PICK_COMPLETE_FILENAME = "pick_complete.flag"; // Step bridge (love.system.syncHealthSteps): pending-steps delivery @@ -566,19 +569,37 @@ public class GameActivity extends SDLActivity { * is. The picked file (if any) arrives later in onActivityResult, not * synchronously here. * - * API 21+ uses ACTION_OPEN_DOCUMENT; API 16-20 uses an ACTION_GET_CONTENT - * chooser instead. Below 19 OPEN_DOCUMENT does not exist, and on 19/20 - * the stock DocumentsUI is unreliable -- it launches and then hands back - * RESULT_CANCELED with no data, which onActivityResult cannot tell apart - * from the player cancelling (#584). GET_CONTENT lets any installed file - * manager serve the pick, and both intents return the same content:// or - * file:// URI shapes, so the result path in onActivityResult stays - * picker-agnostic and unchanged. + * API 21+ uses ACTION_OPEN_DOCUMENT; API 16-20 and television devices use + * an ACTION_GET_CONTENT chooser instead. Below 19 OPEN_DOCUMENT does not + * exist, and on 19/20 the stock DocumentsUI is unreliable -- it launches + * and then hands back RESULT_CANCELED with no data, which onActivityResult + * cannot tell apart from the player cancelling (#584); Android TV ships no + * DocumentsUI at all and behaves the same way (#1535). GET_CONTENT lets any + * installed file manager serve the pick, and both intents return the same + * content:// or file:// URI shapes, so the result path in onActivityResult + * stays picker-agnostic and unchanged. * * @param destFilename basename under the app save identity (e.g. * picked_rom.gb, picked_mod.zip, picked_save.sav, or * picked_required_import.bin) */ + private static boolean isTelevision(Context context) { + if (context == null) return false; + try { + UiModeManager modes = + (UiModeManager) context.getSystemService(Context.UI_MODE_SERVICE); + if (modes != null + && modes.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) { + return true; + } + } catch (Exception e) { + Log.d("GameActivity", "could not read ui mode: " + e.getMessage()); + } + PackageManager packages = context.getPackageManager(); + return packages != null + && packages.hasSystemFeature(PackageManager.FEATURE_LEANBACK); + } + private static boolean isDirectRequiredDestination(String relative) { if (relative == null || relative.length() == 0 || relative.startsWith("/")) return false; String normalized = relative.replace('\\', '/'); @@ -635,7 +656,7 @@ public class GameActivity extends SDLActivity { } self.pendingPickFilename = normalizedDest; - if (android.os.Build.VERSION.SDK_INT >= 21) { + if (android.os.Build.VERSION.SDK_INT >= 21 && !isTelevision(self)) { Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("*/*"); @@ -1499,6 +1520,16 @@ public class GameActivity extends SDLActivity { outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName); } + private static Uri pickedUri(int resultCode, Intent data) { + if (resultCode != RESULT_OK || data == null) return null; + if (data.getData() != null) return data.getData(); + ClipData clip = data.getClipData(); + if (clip != null && clip.getItemCount() > 0) { + return clip.getItemAt(0).getUri(); + } + return null; + } + @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); @@ -1523,19 +1554,22 @@ public class GameActivity extends SDLActivity { return; } if (requestCode != FILE_PICKER_REQUEST_CODE) return; - if (resultCode != RESULT_OK || data == null || data.getData() == null) { + final String destName = pendingPickFilename != null + ? pendingPickFilename : PICKED_ROM_FILENAME; + Uri uri = pickedUri(resultCode, data); + if (uri == null) { Log.d("GameActivity", "file picker returned no file (cancelled?)"); + if (isTelevision(this)) { + writeSaveDirFlag(PICK_ERROR_FILENAME, PICK_CANCELLED_PREFIX + destName); + } return; } - - Uri uri = data.getData(); File destDir = saveIdentityDir(); if (!destDir.exists() && !destDir.mkdirs()) { Log.d("GameActivity", "could not create " + destDir); + writeSaveDirFlag(PICK_ERROR_FILENAME, destName); return; } - final String destName = pendingPickFilename != null - ? pendingPickFilename : PICKED_ROM_FILENAME; final boolean directRequired = isDirectRequiredDestination(destName); final File destFile; try { diff --git a/poison_flicker_bug1872.png b/poison_flicker_bug1872.png new file mode 100644 index 0000000000000000000000000000000000000000..6c968f1e8483b5af8a66c75b32e730278669239b GIT binary patch literal 10371 zcmahvcU)7+)^ok;wQ}9Hqa^IQHf$(G&`7eYKG(J2su7fy)pb!(K@mt0lB=#|vA|jY zQOZ8C#R7q)1GiT16IsN31HLI5m zA7VNLAvApXk4x4f)E_=^)E|eRAI}bQfS<3|tX#kJR{f^&BSu^<(Vr+8uW!*8)K~pF zebroqrp;WwEdcV&iq<7Q`wu3{};<6r*Xu<|bL`uW?QLgzL6)mb=`uJ(D zvi=g^aqfQRz+PNdt*>I2_3Ymsty2q-T}1M$>beTua^bbrf`|0@Ilb4%n)RT^BZ)qs`)VqG&a!lr4us{^y-7|Gp{+xkVw^(a~@7)PHg1JZJx`aUSa`Ifs2joAtZGH5guk?5YIH$$;yOkONYsdF4c9X&tv!+&1#j^^4Wjn^VcT4Bnoty$Mm4Rpixpi}M!l z=VSDxMHlnGl4S2@?qEqjV>aM6W|*(8T<&a*`^JP9ziWs(h`_&)cAsC)v)T z>F8}Zx#;{B`ww>hJz+K282Qjs&3j&jSTBE-xp;JIUYiQ}%#!<0Ob^bb&wK26clwN1 zM~Zt_uFszFDn9^~?q$$;O#!d?)6?Z}ZO4KbU%F3sV%9bJx$DuoYyBDpPi1k zj+U@rtq>yl-cO&Y5!csje;Keue8TCn_hs3pQ+B5+9cua1Wv&g=F*~EoM;GG|Lrci* zx??qE&5KVF&YQRaHNM3GPyU)4Ejiw%vZB1~NjCb^|Jy8l=B+$?O|Vi8xHpII>WT76A}VMX`j91V6c|AAS~ZsuO#t~fF@+4jNh{0l&K_s>2v<*hDF z9d8$svbeoI76c1@#gJ~;%BFnnW!N?)N-6*8(Y=XEw1khmxG)^Tpazk=TYG(G5-g56 zT$HKkNfFDio!s7q@DzOH!fiz*brccz2zM3-5k9l}P}X6xO(uNwEsMG(207g;#f#`& z9r*oloM9f;SW>r(n~j%Q@F|YiB_fqe8!9+=Js+*$sy?54!-64AgV|r!r)GNJn6!*? z0Vk_DU6AR!@+e6NF`*=J36>i7#MjoMB0|T!<9CaJg3G zAE4AFc?1qLtd1e$+D2YeJrkk|SUuKCBhC7bP3;mY(Gxye9HW2F9`^uiQXL}9pW^*n zapj(GN3qachWYPIDk*C40CI$LL^)HMLrM#7el>()p4iBzMhK}b>#qLr2=Bk4IQnfz zhLFkTRDO4TjS{`#Q%FepM!z8k&}0sJPkbCp9X?3JW#hjq@ri{m7C*w9P7>p~&=tkW zN(Zn6?f9@@&`$r{|Mt7C{poHd7>yn*Cg^WK9XT}cnJaFd_w7ca0g^W0Pt9R*8shDA;Dwk8w747t={pX%<#U@Ykd z0kvNsl#7NkcsF3ZuxBu0No@t_=PJJGw(T-31YUdf^euR8KZKr-_aLyX% z&Jz5y@&=d#o`PKwbXe)-2v|Bh8$YHB(!veJI4dwe*oT?7V8o87geq59SFwv zmI`&Mg1o<2x-WK>wr{jmA%2gkX4-$8bz`ZZ{e2i&Q@bT&5@K<9(H_+SGHkVj?d|#g zJxyncqNg)nsdMfphn{Q%k=b8|-E9qNvsrlA!B$KIoHGE-f6-989V8a^ZZ)l ze(Ea!Y5B(BDyB5QR^$W_vq;MmOi@M4#-k0vTBMT9fMdgnaZjx@6~faduY0<&f5Ama znx`r(GZL5_T|lo_3Mn_s+k-Pm5R-6ja;EnO90YA^@y(VCU9|8y`zd;+IDogC zu%Wp-^8gKYgqx3p+QH&R8%57Q=*0@|B!*N9GwWCJ(MTL5)#g36GjlA6&XygI@yKy} z>ZEuu$=fW$Y{gSp)E@_kvTLTTXiW7`IL*dJhMgD&I>F7tc_M0Rr5Q3~~!+?lj79B$$z?HaPN%ag0dIUu4*n zbMLyXKRa?-(Emg7oeg;6l4hHQ5HeiyYEyU}O?6tk_LHm4CPjDX(L+R8d}B#x4{5Ie zA-AXRwmUeMyYW);x`1mm6M$!ZBK^oXx;iuAo`2p9`Wltp!WV_RZnbK@0v#0p7R)X@ zeW>b=DNFjB-*g7AJaQ{sk;~w0owa2H1Rj@I)Z@)8cn@CCWmK*! zA5KUnf0P+ah#yP3{$gutf3wee8X01x4Uap+v6hzFmhqh_G~K_0YI0KywCD zW-r^1Jz3vCIByrBpgB~exV|NgzXdd=Kh-jzXss7RS`rUmYu2q`NJ~lh8u5A%OL`L+ zBOMAiql-x3JQI)}SD`aPinV+%jME0b$FB4u z?&HM=S_UjBm9nH;GGkkH?1@k5y>IyFbz54{{NU%B9&F7iiPazmWlHj*s(iG%x$+i? z_2VH*^pb0HGR?DwWG{W5Rmy6F^xdlsng;f_P^Qn*G8tm7t+(-2*ovL3yl=LS@xWVx^ zZ{|hWzb@{)+d(qSGaLD6W8u-oi#*f1@3sv1(O|3gGkEbE-3z0xI8^R8I+Hskf~dmt z{)^nyAPZ;*i;5RWLgsHN@7_myUx=fW_JV>~&Bl+N{hrchI$DJ?E={?f*WituGsjw5 z1D?<3yrlI^Qgi7}8-?D~>vG)wIlT3>*`7g4Llkc}%~rch6B??9jw_y!9^8?KCpLxn zgbN+f*Iz$x>%nvTJiyH#1Ak9_b4#yi3OP)!loMfe^pReNIsZ9jc~p2>yC`E3iK3Yt zgUg8^rWt}`S49RoXYe5zDr5+XO)DEbGbe9TeA)l%M5o8;v%l@f3W8P?y&}y^fe8P6 zJbr2QSJz+a(9U|7gDFDP=XcCUcWyrPK=&@S|1K@s3pT+sl_h=9xbQ&RO5PuqlI|nng;x%~&Bv#=mIyluX{%K!nl$kiNu)w#uB9_1(VJU*B!V zPtdb6>>V9drNqU6oEw$;3=yd|D0QF;`5KklgTX5>Dz$6Q{93F&2+aarZhV11?*@}p zir0RKAw+0r1d8)J>j}wF96vv7TV+X zJK?O#fQgD4tp0fko$}d{mU9zQhYS?rN)#`kUixNS-md9q_%5;9Mt|KEphC6i&h59x z2u>r&Xt$yYykV~`Y3``lxk@n~;+$wgIRR~wV0={0qFQEg6boVU0--cR%U-1Smtoh` zXlNH!Up1MwI=ci&@rPv8zLu|P%2QalsWD{H+X5YTv{@!^9p2C!L;1G>syPK23$h@}2-S z3;W41_lrbPbKgWIJ<$Q_G`LqYCGrJ-L9nD34*+3v`IJ2lVGNb{_@0-9}EE1r~hgBtDNKX*X9fNta>|kVEE51$M zp3*bqAW>#Y%F;v4JBTuCGVVgD%LG2Ps}-#?Xs+dXFiE0lv}!=O11&h&P|;ya+uht zPLg%w_DtWL!WnTjnnVi797+vveq^v+1lWvSu0Eg;l^NF!fc=?wUH;;o&T_iV>%{!(yMU>&AiI%NZ0&ZP`C9 z`Eo+nUchsL=xWE`d1EsO19dsxZY|jJ@_U8^<3_6vnTdLr-+8$oTLJ3_xH)*QF5J^C zH25YRsR5H&)F<%QI3I0hNpsnWDo8;fD&gqerH|o0V@S*H;AR6waZoHdY`B#<_?(M= zAIn(O4v}H&Kj|;p5;$#vJQzGJhsV`mD?15ip*`de%Ngd8bX>BEVSbkW2z(A+d@K4tgQ?qE}+a2Z3xK~L~9(>e0^x%L!c{sMP472RA|c%DT93oa6g_oAX- z*?V0E`s_%a*DBuyFUCXc)rwL|;{B{VFHHsNz|^=bOl{v&@HGDsFkFrN!!Pcz1#R1W zV=~Fx)$qrQx4FXsfi0PsLX+vo;B5t6Kp$Ara$4*U6o(Ve6$XVVS0OK2g+|D+5?T|? z3vmNK!6q3XsEMS^Df1Q+YF?0N^TQ!?PXkX{sCtC2aWW%i8yx^Gc;BFWNbp^`>VTx# zEiQd?4@(OlL4*WQ^h~({s#JsBNonqS#vKe2e1cHU@{kj`$y;7W-#AK}5(o%VgQA({ z?28q`)@5L7q@Q=q6TbH3mJBke6N~cJEMRlOW1`gW*Ra82&tmYx9F|Yaqp`DAIc{_x zYQWBqXMe{U)uE7^vXSVaSiyR6_y7_26hF3aBO94?)OqzHNa5rg_l|(Knn?QSn;lSz za1LLhX(3cU-G z*qo;Jk3}qLO{(tM&HSma3ux~G&`xPl|&? z6f1pozbUU67Zz|0K)}CWZ+pyNE?(ON&vDL|e_V?d_^rJ$s>kpUJnQO~u|A_iwAeL> ztSloH%)*nKlGw=4rWRXoTSI4K18nNBvkh-(PwTQ~F@tk{w;_NYx+53Aw$tc#cRa`K z3@I6h>%0GT%Yf$^v`LBDiq8^0#`xP*g;vuD+KM9yA8SL!Qp}<*fe76;gxJqCT37(L z@WgL0_-ZCHa^pD>-^z{I>BF128M`^!=qIJ zY((Qo(RA9cE?}771(tJo5Pbd>YfM;vaKs( zxEnp5@fMxY1SA~ONU2IUFN}Mo;T#m(%BGM$k^$@KRB#1doe24E`6n&dLf!=y=WU|U zCGMRFY>9M=fV$%ld!hTG6vE}i}Yo^ai7+!EHx z8Y1j<1>=zMrYjs@Xnd|6p3bbg-FfoPX~P2W@mE1q%$uB^nMPQxeuE&MJbv(x}33FfVdCaGezP5btt4) zn|1nd%`!SthB#N;1%9G0IHfFvyS@aUaUmWRUjp1~A4)sE1lQuOVl7&0jA#GvEz@fH zwm?D8Xugd}JZGq(H$assUjBU=MC&u$pqunUk#odAa>TM{w;T`sJ^i22in`l-Ua~w) zHfRiwcvmg7kF6yV3&6rc_0vrfq54uOHH`jM3r!QGUvw0rzA|duh17ga?e@67(x~>K zM?nt@?NlG4E#1fZ=CzrBGNtp_w8&F0Suvf-Yo@ZL=Em_eR;J#$XfdSXSx{!Bi)NPR zbCRvReuo_U2+-Ak0~A8$g|cn^EZ$_3md_!Nf;> zvs$M8%S1?h4;5QTD-Q{JTi@ON!OeJ5%2Rw;y>c~+I?H59pYo}xObRD0^GF8Ig(dYd zX?cw2!s!sMjVpK$DYre_-~qGj9}XqWupG#Qesoeu}SI(tq~lgHZcc(r_sNXRHON(BkBF ze3}Bv!bgZ)wW>VgibY}jExPQSgDa<2i(9@~cTV2zZ+t`N(w5#0bJv;O>U1&IyLs8g z7o4IRw^eiUjb-ea=?;14t}G~@Uu1j{G~=}H=S=J3mzf8``*1*NR~`^{{#3n&o>^0a zh1~tcZa!^kAO+1NBZ8g^1OQy89 zFSb0gpj&9D!r$d1zuu3*)JBH%E;Iq4D8KdLui$?E`=Hl>Izm{|?FWeX+X`;gU@P-6 z+V!o;&cD((I7zH|61p-69|t`MFyL_^5vy|WNO~}a`hCy1t01mpDr@24H-PTabGvu5yY?!=d6GNH^^nysPV^bjS&^n=M+NZl+ zgPov5P3f0>fZDfm30>xc^4##xjlj?AvP<;W%7UEFbwKdhe3YeG2$Cw^;b8Lb@cgy3 zQP_5K#lP8r7;#kSzfF8b4k;M83py8Ce@?w;)k7#T+KR;*yMNghzJMW<%jNU7ZZ`9v z`)^syXBYG6CY!uYK+WlBx7qrTsdE*dzW#yRFqMz48hTLz*mSgY%8)wEXcHQKbN^(*E>GS%bsq{J!Fw_X%muDGbd&Kl)SLhySx5E zp`NHW_RHoJ;>rolTl1VB#pYyYuHZhj&}wezzHD_MmFjk@?b)4mDy^GE&FF?Xq8+x@ z#^xAJkiyW_%bc)5ZS^zUM6W%%=)9MGC$!suAgXb9kU+8UgR!mmmh5ZM!I98WtM(51 z$^XIGE9!th-RaI1-SI2TS0k~eE8u0olhk?c=l*j(_5Roibc68UFnxL|7q=V}oowiJ zi;vKv+jXp7w4}UYs{qwq?9Dvoy+Aa!0eWzKtZ*RXqC!R0rLC*vEuC$k9>(q^&7!E# zVE3StS zKE=i>JBvJi;v8coSTw4m!V_5Y`!)Ih-x;#$mr)`0}HW7ke5 zW!DU%MpJMWI*3kmMdv*>utmOXxXJ+o?>--T>^+)2haNg|py)N6RPx#Ey0 z+%=(EY+`+XXkVvvn)f0bKT_hJ?T1Tk!LjASO;`2?ev&p=b*185ub>n-c1b-ZTv%H& zx!%k!qrm$D6Jl61H7bvFw-Bf5GPRyQq~2-$;RclW z45EQ~q|#4*@bEcp`-M&>`*4E7H>1P9`O$T&viWe}eU>z0RGzL|Thf9we$6-C_ov5X z)xAN=eAv2xR0f$ubSeU#3B@jiZhb~&!iSNmNMdx9ntOVov&K(XTAjYX`Icx?&7#Oq zg7N@Ylq8^p!-h3giEj34d>0;iP^IfBIdqB9Ne3rTY4hVnNZiH_sw${1Eof}lReMB; zM*1WKz{Yq!s@hswajETCw|Sl+I2$A`*JArT6FbK6B0yrkV#unz z>q_gGiqclS>sYl!lHHun{?)s>RNDxcOszpP#V(ZM#|7QFf|{mJz7BvY==o5DS$B zMQKA?YU9fTPU3{_F$zs1ChNl}9L(G&Yk#MONwG;A9X$*pePS~_=DgR6te1+aHh3n* zh+eYr`*gJOJ27oSmCk1WyR06QUQNzIWl~JS{eJwuC5129MlMt;jt9tii$p?XM{B`B z;(lS#QL`@J_h#4jhjbbbSW^<5U=vKZ@9tD)R;bix=7@G9J0q6+?vK6A_@ZM9&pnmv z2$MotPLDPdq!j*q@HE1w%E?2cY1kLs$Y~8srC<60{0CuJeQW0mJBgKt`2314wT;w< zWiZ+NeEWW^(m_W@1)}ZKQ%HrJovScpBhb>k@O4KT#7`{Kh{Mb7a^wCHjiaPJdu=PW(rQ@NX?c>+-=Y&b7a-Z*E2Z zoV5AY;Da=wz5YK=oK2^(U;;AbiPeUKWZ2$j0&^N-ATw1baaGynKQ;`4KX%VgU2)08 zr5Ea27(>>4l0=t6`^{!R#x28C1p(+?=dra!@zp7Yi8S3eGFueyO`Tl%Qh#{&@9RUp zg4^~Zi~O3J=c4+oefo&@r6U`62lj_}32r}-%D0J|VX8%TjvlxH!vfoJsN}C11t%L9 z{~~$+ZsjRRl)mFrsh{Se+Cu3w+fVw_0lS0k^{(|d9j*Mv0(%Y;GP3nH9w*VM3$z+v zO;obOi&{4bv<5KZM1y?N2e>Bawffl+vG(a#&fg!pMvm#2yBV%21HDfyf`1jF1ppoZ literal 0 HcmV?d00001 diff --git a/src/battle/AnimPlayer.lua b/src/battle/AnimPlayer.lua index 051da07f..5a76e9a7 100644 --- a/src/battle/AnimPlayer.lua +++ b/src/battle/AnimPlayer.lua @@ -401,6 +401,12 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts) self.stepIndex, self.stepLeft = 1, 0 self.elapsed, self.eventCursor = 0, 1 + -- animations.asm:452-473 (#1881) + if not attackerIsPlayer then + if moveId == "AMNESIA" then moveId = "CONF_ANIM" + elseif moveId == "REST" then moveId = "SLP_ANIM" end + end + local anim = self.data and self.data.moveAnims and self.data.moveAnims[moveId] if not anim then self:warnOnce("move:" .. tostring(moveId), diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 468590ba..0754678e 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1426,6 +1426,14 @@ function BattleState:updateQueue() self.waitFrames = Timing.MOVE_ANIM_PRE return true end + -- animations.asm:431-437 + if item.anim and not item.animOffDelayed + and not self:animationsOn() and not BALL_ANIMS[item.anim] then + item.animOffDelayed = true + table.insert(self.queue, 1, item) + self.waitFrames = Timing.MOVE_ANIM_OFF + return true + end local mdef = item.anim and self.data.moves[item.anim] local anim = mdef and mdef.anim if item.anim == "POOF_ANIM" then diff --git a/src/battle/gen2/AnimRunner.lua b/src/battle/gen2/AnimRunner.lua index 599eccd5..99c15339 100644 --- a/src/battle/gen2/AnimRunner.lua +++ b/src/battle/gen2/AnimRunner.lua @@ -457,9 +457,14 @@ function Runner:step() if first and first.index ~= 0 then first.yOffset = self.bg.rolloutYOffset end end if self.stopped then - -- BattleAnim_ClearOAM: unless the script asked to keep them, every object - -- goes at the end. - if not self.keepSprites then self.objects.oam = {} end + -- engine/battle_anims/anim_commands.asm:213 + if not self.keepSprites then + self.objects.oam = {} + else + for _, obj in ipairs(self.objects.oam) do + obj.palette = "PAL_BATTLE_OB_ENEMY" + end + end return false end return true diff --git a/src/core/FilePicker.lua b/src/core/FilePicker.lua index d56cc116..ec087ced 100644 --- a/src/core/FilePicker.lua +++ b/src/core/FilePicker.lua @@ -24,6 +24,7 @@ local function commandOutput(command) if not pipe then return nil end local result = pipe:read("*a") HostShell.pclose(pipe) + HostShell.pumpHostEvents() result = trim(result) return result ~= "" and result or nil end diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 3ae86ffd..379e66ed 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -173,6 +173,7 @@ function Game2.new() options = Save.loadOptions(), }, Game2) self.save.options = self.options + self.sessionStartedAt = os.time() anchorNewGameClock(self.save) return self end @@ -244,6 +245,7 @@ end function Game2:newGame() self.save = Save.newGame({ playerName = self.save.player.name }) self.save.options = self.options + self.sessionStartedAt = os.time() -- InitClock re-anchors this the moment the player answers Oak; the default -- only has to hold for a run that skips the screen. anchorNewGameClock(self.save) @@ -281,6 +283,7 @@ function Game2:continueGame(save) SaveData.runMigrations(save, self.mods and self.mods.migrations, activeMods) local modsDiff = SaveData.modsDiff(save, activeMods) self.save = save + self.sessionStartedAt = os.time() self:adoptSave(save) -- Editor species swaps used to leave mon.name on the previous species. -- CONTINUE rewrites party, boxes, and Day-Care copies from the live record. @@ -882,14 +885,36 @@ function Game2:writeSave() return false end local save = self:snapshotSave() - -- The snapshot is complete, so this payload carries exactly the table the - -- file gets; mods stash runtime state into their own keys now. `meta` is - -- the Gen 1 key, absent rather than renamed: a Gold save stamps no meta - -- block yet (see save.loaded in continueGame). + save.meta = SaveData.buildMeta( + self.modStatus and self.modStatus.loaded, save.meta, self.sessionStartedAt) if ModRuntime.wants("save.writing") then ModRuntime.emit("save.writing", { save = save, meta = save.meta }) end - return Save.save(save) + local written, err = Save.save(save) + if written then + local eng = self:syncEngine() + if eng then pcall(eng.noteSaveWritten, eng) end + end + return written, err +end + +function Game2:syncEngine() + if self._syncOff then return nil end + local eng = self._syncEngineRef + if not eng then + local ok, SyncEngine = pcall(require, "src.sync.SyncEngine") + if not ok or type(SyncEngine) ~= "table" then + self._syncOff = true + return nil + end + eng = SyncEngine.shared() + if not eng then + self._syncOff = true + return nil + end + self._syncEngineRef = eng + end + return eng end function Game2:load(opts) @@ -1584,6 +1609,23 @@ function Game2:drawContained(w, h) if not ok then error(err, 0) end end +local function panelBlit(stack, w, h) + local states = stack and stack.states or {} + for i = #states, 1, -1 do + local state = states[i] + if state then + if state.battlePanelScale then + local scale = state:battlePanelScale(w, h) + if scale then return scale, Chrome.fitOrigin(w, h, scale) end + end + if state.drawsWidescreen and state:drawsWidescreen() then break end + end + end + local scale = Chrome.fitScale(w, h) + local ox, oy = Chrome.fitOrigin(w, h, scale) + return scale, ox, oy +end + -- BATTLE BG (#1709): the surround around the centred GB screen, taken from -- whichever state in the stack owns a battle. function Game2:paintBattleSurround(w, h) @@ -1595,8 +1637,7 @@ function Game2:paintBattleSurround(w, h) end if mode ~= "black" then return end local G = love.graphics - local scale = Chrome.fitScale(w, h) - local ox, oy = Chrome.fitOrigin(w, h, scale) + local scale, ox, oy = panelBlit(self.stack, w, h) local pw, ph = 160 * scale, 144 * scale G.setColor(0, 0, 0, 1) if oy > 0 then G.rectangle("fill", 0, 0, w, oy) end @@ -1678,10 +1719,7 @@ function Game2:drawScene(w, h) self:paintBattleSurround(w, h) self:letterbox(w, h, false) if wide ~= top then - -- The pushed box blits at the same integer fit the widescreen layer - -- used, or it lands on a different grid than the panel underneath it. - local scale = Chrome.fitScale(w, h) - local ox, oy = Chrome.fitOrigin(w, h, scale) + local scale, ox, oy = panelBlit(self.stack, w, h) G.push() G.translate(ox, oy) G.scale(scale, scale) diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index f7e5daff..4e6426c1 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -213,6 +213,11 @@ function HostShell.pclose(pipe) withPopenLock(function() pcall(function() pipe:close() end) end) end +function HostShell.pumpHostEvents() + if not (love and love.event and love.event.pump) then return end + pcall(love.event.pump) +end + -- Restart the whole app. The obvious love.event.quit("restart") re-runs LÖVE's -- boot in-process, which calls love.filesystem.init a second time -- and inside -- an AppImage physfs is already initialized, so that second init throws diff --git a/src/core/Timing.lua b/src/core/Timing.lua index 1a1e05bf..ad6c6f5a 100644 --- a/src/core/Timing.lua +++ b/src/core/Timing.lua @@ -93,6 +93,8 @@ Timing.TRAINER_INTRO_SFX_GAP = 20 Timing.BATTLE_START_SENDOUT = 40 -- engine/battle/core.asm:155-156 Timing.MOVE_ANIM_PRE = Timing.DELAY3 -- core.asm:6638 PlayMoveAnimation +-- animations.asm:431-433 (pokeyellow animations.asm:445-446) +Timing.MOVE_ANIM_OFF = 30 -- core.asm:3185-3186 (player) / :5587-5588 (enemy). Reached when the move -- has 0 BP (core.asm:3145 -- every status move) or missed (:3158), so this diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index 3df41e2c..7e4e7b86 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -779,19 +779,12 @@ local function drawIcon(img, zone, pressed, alphaMul) zone.cy - img:getHeight() * scale / 2, 0, scale, scale) end -local function drawCovered(img, x, y, w, h, alpha) +local function drawOverlayImage(img, x, y, w, h, alpha) if not img or alpha <= 0 then return end - local iw, ih = img:getWidth(), img:getHeight() - if iw <= 0 or ih <= 0 then return end - -- Cover the assigned box with one uniform scale and crop the excess. The - -- old independent X/Y scale made portrait art visibly squash on wide - -- displays (and vice versa). - local s = math.max(w / iw, h / ih) - local dw, dh = iw * s, ih * s + local sx, sy = TouchSkin.imageFit(img:getWidth(), img:getHeight(), w, h) + if not sx then return end love.graphics.setColor(1, 1, 1, math.min(1, alpha)) - love.graphics.setScissor(x, y, w, h) - love.graphics.draw(img, x + (w - dw) * 0.5, y + (h - dh) * 0.5, 0, s, s) - love.graphics.setScissor() + love.graphics.draw(img, x, y, 0, sx, sy) end function TouchControls:drawSkin(alphaMul) @@ -803,7 +796,7 @@ function TouchControls:drawSkin(alphaMul) love.graphics.push("all") love.graphics.origin() - drawCovered(page.image, bx, by, bw, bh, opacity) + drawOverlayImage(page.image, bx, by, bw, bh, opacity) local pressed = {} for _, touch in pairs(self.touches or {}) do @@ -818,7 +811,7 @@ function TouchControls:drawSkin(alphaMul) TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy) local alpha = opacity if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end - drawCovered(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha) + drawOverlayImage(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha) end end diff --git a/src/core/TouchSkin.lua b/src/core/TouchSkin.lua index e378880f..12bd8df9 100644 --- a/src/core/TouchSkin.lua +++ b/src/core/TouchSkin.lua @@ -1352,6 +1352,11 @@ function TouchSkin.pageBox(page, w, h, ox, oy) return bx + r.x * bw, by + r.y * bh, r.w * bw, r.h * bh end +function TouchSkin.imageFit(iw, ih, w, h) + if not iw or not ih or iw <= 0 or ih <= 0 then return nil end + return w / iw, h / ih +end + function TouchSkin.controlGeometry(page, ctl, w, h, ox, oy) local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy) local cx, cy = bx + ctl.x * bw, by + ctl.y * bh diff --git a/src/core/gen2/NpcTrade.lua b/src/core/gen2/NpcTrade.lua index 65c7eb30..905d3a86 100644 --- a/src/core/gen2/NpcTrade.lua +++ b/src/core/gen2/NpcTrade.lua @@ -152,6 +152,11 @@ function NpcTrade.perform(data, save, row, index) -- already disagree and a traded mon has to answer both. received.ot, received.otName = row.otName, row.otName received.otId = row.otId + -- engine/events/npc_trade.asm:189-197 + if Mon.hasCaughtData(save.version) then + Mon.setGiftCaughtData(received, + row.dialog == "TRADE_DIALOGSET_GIRL" and "girl" or "unknown") + end table.remove(party, index) -- RemoveMonFromPartyOrBox's "Mail time!" tail. NPCTrade itself has no mail -- check -- unlike the Day-Care and the PC, it will trade a mon holding a diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index 6bee7106..6341f8ac 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -312,6 +312,8 @@ Save.DEFAULT_OPTIONS = { palette = "", videoMode = "windowed", fpsCap = 60, + -- BATTLE SIZE (#1709): fixed | fill + battleFit = "fixed", -- BATTLE BG (#1709): white | black, the surround around the battle screen. battleBg = "white", -- VOID FILL: fade | water | trees | black. fade is each map header's own @@ -409,6 +411,22 @@ local function normalizePokerus(mons) end end +-- constants/pokemon_data_constants.asm:79, :90 +local function normalizeMoves(mons) + for _, mon in ipairs(mons or {}) do + if type(mon) == "table" and type(mon.moves) == "table" then + local rebuilt = false + for i, entry in ipairs(mon.moves) do + if type(entry) ~= "table" then + mon.moves[i] = { id = entry, pp = (mon.pp or {})[i] } + rebuilt = true + end + end + if rebuilt then mon.pp = nil end + end + end +end + -- ../pokecrystal/ram/sram.asm:140 sGSBallFlag. nil is the cleared byte. Save.GS_BALL_STATES = { have = true, given = true, used = true } @@ -558,8 +576,12 @@ function Save.normalize(save) table.remove(save.party) end normalizePokerus(save.party) + normalizeMoves(save.party) for _, box in pairs(save.boxes) do - if type(box) == "table" then normalizePokerus(box) end + if type(box) == "table" then + normalizePokerus(box) + normalizeMoves(box) + end end -- move_mon.asm:143-149: a mon the player owns carries wPlayerID; saves -- written before the stamp existed get it here. diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index 3e4c46b4..29224bbc 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -27,6 +27,9 @@ CacheContract.REQUIRED_FILES = { "assets/generated/battle/anims/move_anim_1.png", "assets/generated/audio/programs.bin", "assets/generated/trade/game_boy.png", + -- engine/items/town_map.asm:296, 150 + "assets/generated/townmap/nest.png", + "assets/generated/townmap/up_arrow.png", } CacheContract.VERSION_REQUIRED_FILES = { @@ -83,6 +86,11 @@ CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = { "assets/generated/pc/mail_item.png", -- engine/events/fishing_gfx.asm:23 "assets/generated/emotes/fishing.png", + -- data/sprites/emotes.asm:19, engine/events/field_moves.asm:390 + "assets/generated/emotes/jump_shadow.png", + "assets/generated/emotes/cut_grass.png", + -- engine/pokegear/pokegear.asm:2298 + "assets/generated/pokegear/nest_icon.png", }, crystal = { "data/generated/constants.lua", @@ -133,6 +141,11 @@ CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = { "assets/generated/battle/trainers/chris.png", -- ../pokecrystal/engine/events/fishing_gfx.asm:38-42 "assets/generated/emotes/fishing.png", + -- data/sprites/emotes.asm:19, engine/events/field_moves.asm:390 + "assets/generated/emotes/jump_shadow.png", + "assets/generated/emotes/cut_grass.png", + -- engine/pokegear/pokegear.asm:2298 + "assets/generated/pokegear/nest_icon.png", }, } CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.silver = diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 9c7d17ba..45f8a400 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -675,6 +675,9 @@ local function gen2Rows(opts, hooks, shared) end) end + add(Strings("BATTLE SIZE"), ladder(opts, "battleFit", + { { "fixed", "FIXED" }, { "fill", "FILL" } }, "fixed")) + -- BATTLE BG (#1709): the WHITE/BLACK pair Gold's battle screen honours. add(Strings("BATTLE BG"), ladder(opts, "battleBg", { { "white", "WHITE" }, { "black", "BLACK" } }, "white")) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 29a8ad6c..b85d9458 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -417,8 +417,9 @@ local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, ac end end if (Kit.press(x, y, w, h) or Kit._activateId == key) then + local osk = false if Kit.VirtualKeyboard then - Kit.VirtualKeyboard.open({ + osk = Kit.VirtualKeyboard.open({ text = text, targetId = key, title = placeholder or "Enter Text", @@ -435,14 +436,14 @@ local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, ac elseif imp._settingsText and key:find("settext") then imp._settingsText.text = newText elseif imp.tab == "find" then - imp._findQuery = newText + imp.findQuery = newText if imp._refreshFind then imp:_refreshFind() end end end end - }) + }) and true or false end - if action then + if action and not osk then queueAction(imp, key, action) end end @@ -1387,6 +1388,7 @@ local function headerChrome(imp) local g = currentGame(imp) if imp.tab == g.id then imp._gamePopup = true + Kit.setFocus("gamepop-" .. g.id) else imp:_switchTab(g.id) end @@ -1514,10 +1516,8 @@ local function buildHeader(imp, m) chrome0.game.active = imp.tab == game.id local gameHot = Kit.hover(tx, ty, dropW, tabH) local gameDown = gameHot and Kit.mouseDown - -- face "tab" inverts on hover as well as when active, so the caret has to - -- flip with it or it vanishes into the cartridge colour - local gameInvert = chrome0.game.active or gameHot - chrome0.game.ring = gameHot and not chrome0.game.active or nil + local gameInvert = chrome0.game.active + chrome0.game.ring = nil btn(imp, tx, ty, dropW, tabH, "tab-game", "", chrome0.game) do local cw = math.floor(7 * m.s) @@ -5892,9 +5892,15 @@ local function buildModals(imp, m) local ModUpdate = require("src.mods.ModUpdate") local d = imp._findDetails local body = ModUpdate.cleanBody(d.body or "", 0) - if body == "" then body = Strings("(No description.)") end + if body == "" then + body = d.loading and Strings("Loading description...") + or Strings("(No description.)") + end buildTextModal(imp, m, "find-details", d.title, body, - function() imp._findDetails = nil end) + function() + imp._findDetails = nil + if imp._cancelFindDetails then imp:_cancelFindDetails() end + end) return true end if imp._modVersions then buildVersionsModal(imp, m) return true end @@ -5953,10 +5959,8 @@ local function drawPadCursor(imp) or (Kit.VirtualKeyboard and Kit.VirtualKeyboard.active) then return end - -- Pixel-snap on NX: subpixel polygon edges shimmer on the 720p Switch - -- framebuffer when the stick advances by fractional pixels each frame. local x, y = imp._padCursor.x, imp._padCursor.y - if imp.isNX then + if imp._consolePointerHost and imp:_consolePointerHost() then x, y = math.floor(x + 0.5), math.floor(y + 0.5) end love.graphics.push("all") diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index 0ab57381..492826d6 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -1871,7 +1871,7 @@ end function RomExtractor:extractField() self:beginStage("Interface artwork") - local done, total = 0, 51 + local done, total = 0, 53 local function tick() done = done + 1 self:tick("Interface artwork", math.min(done, total), total) @@ -2199,8 +2199,26 @@ function RomExtractor:extractField() "townmap/tiles.png"); tick() self:raw1bpp("TownMapCursor", 16, 16, "townmap/cursor.png", true); tick() + -- engine/items/town_map.asm:296, 150 + local nestArt, upArrowArt + if self.symbols["MonNestIcon"] then + self:raw1bpp("MonNestIcon", 8, 8, "townmap/nest.png", true); tick() + nestArt = { + path = "assets/generated/townmap/nest.png", width = 8, height = 8, + } + end + if self.symbols["TownMapUpArrow"] then + self:raw1bpp("TownMapUpArrow", 8, 8, "townmap/up_arrow.png", true); tick() + upArrowArt = { + path = "assets/generated/townmap/up_arrow.png", width = 8, height = 8, + } + end local data = copy(self.manifest.field) + if type(data.townMap) == "table" then + data.townMap.nest = nestArt + data.townMap.upArrow = upArrowArt + end local adjacency = data.hiddenExtras.trashCans.adjacent local converted = {} for index, values in pairs(adjacency) do converted[tonumber(index)] = values end diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 4ab3ba84..f1224967 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -5875,6 +5875,20 @@ function RomExtractorGen2:extractMenuGfx() 8, 8, "emotes/grass_rustle.png", true) emotes.grassRustle = "assets/generated/emotes/grass_rustle.png" end + -- data/sprites/emotes.asm:19, engine/overworld/map_objects.asm:1995 + local jumpShadow = self.symbols["JumpShadowGFX"] + if jumpShadow then + self:write2bpp(self.rom:bytes(jumpShadow[1], jumpShadow[2], 16), + 8, 8, "emotes/jump_shadow.png", true) + emotes.jumpShadow = "assets/generated/emotes/jump_shadow.png" + end + -- engine/events/field_moves.asm:390-407 + local cutGrass = self.symbols["CutGrassGFX"] + if cutGrass then + self:write2bpp(self.rom:bytes(cutGrass[1], cutGrass[2], 4 * 16), + 32, 8, "emotes/cut_grass.png", true) + emotes.cutGrass = "assets/generated/emotes/cut_grass.png" + end -- LoadFishingGFX (engine/events/fishing_gfx.asm:1-21): three 16x8 pose rows -- over the standing frames' bottom tiles, then the rod tiles $fc/$fd (#1708). local fishing = self.symbols["FishingGFX"] @@ -6492,6 +6506,14 @@ function RomExtractorGen2:pokegearGfx() gear.sprites = "assets/generated/pokegear/sprites.png" gear.spritesWide = 2 + -- engine/pokegear/pokegear.asm:2298 + if self.symbols["PokedexNestIconGFX"] then + local nest = self:symbol("PokedexNestIconGFX") + self:write2bpp(self.rom:bytes(nest.bank, nest.address, 16), 8, 8, + "pokegear/nest_icon.png", true) + gear.nestIcon = "assets/generated/pokegear/nest_icon.png" + end + local cells = SCREEN_AREA gear.cards = { clock = self:readTilemapRLE("ClockTilemapRLE", cells), diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index f1876a64..451a8a70 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -438,6 +438,7 @@ local function commandOutput(command) -- lock can free a FILE while a worker thread's popen is walking the stream -- list, which deadlocks that thread for good (see HostShell). HostShell.pclose(pipe) + HostShell.pumpHostEvents() result = trim(result) return result ~= "" and result or nil end @@ -1620,9 +1621,15 @@ function RomImporter:focus(f) and love.filesystem.read("pick_error.flag") if pickError then love.filesystem.remove("pick_error.flag") - local text = "Could not read the picked file. Reopen the picker and choose " - .. "it with the Files (Documents) app, or copy it into: " - .. love.filesystem.getSaveDirectory() + local text + if pickError:find("cancelled:", 1, true) == 1 then + text = "The file manager did not return a file. Try a different file " + .. "manager, or copy it into: " .. love.filesystem.getSaveDirectory() + else + text = "Could not read the picked file. Reopen the picker and choose " + .. "it with the Files (Documents) app, or copy it into: " + .. love.filesystem.getSaveDirectory() + end local legacyRequiredPick = self.requiredImportLegacyRomPick and self.pickerPendingKind == "required_import" if self.pickerPendingKind == "required_import" @@ -2881,6 +2888,7 @@ function RomImporter:update(dt) -- nothing. They run whether or not the view is up, so a refresh started -- before a tab switch still completes. self:_pumpFindFetch() + self:_pumpFindDetails() self:_pumpModInfoFetch() self:_queueFindEnrichment() self:_pumpFindStats() @@ -3100,6 +3108,10 @@ local PAD_DEAD = 0.28 local PAD_SPEED = 560 -- px/s at full stick deflection local PAD_DPAD_SPEED = 420 +function RomImporter:_consolePointerHost() + return (self.isNX or Platform.isUWP()) and true or false +end + function RomImporter:_activatePadCursor() if self._padCursorActive then return end local ox, oy, w, h = SafeArea.rect() @@ -3116,7 +3128,7 @@ end -- and makes the virtual cursor lag. Expose the pad pointer through a getPosition -- shim instead; desktop keeps the setPosition path unchanged. function RomImporter:_ensureNxPointerBridge() - if not self.isNX or self._nxPointerBridge then return end + if not self:_consolePointerHost() or self._nxPointerBridge then return end if not (love and love.mouse and love.mouse.getPosition) then return end self._nxRealGetPosition = love.mouse.getPosition local importer = self @@ -3141,7 +3153,7 @@ end -- NX only: drop the getPosition shim + hide the virtual cursor before a host -- takes over input (embedded save editor). Desktop is a no-op. function RomImporter:parkNxPointerForHost() - if not self.isNX then return end + if not self:_consolePointerHost() then return end self._padCursorActive = false self:_restoreNxPointerBridge() end @@ -3188,6 +3200,48 @@ function RomImporter:_cycleTab(delta) self:_switchTab(order[((idx - 1 + delta) % #order) + 1]) end +local STICK_NAV_ON = 0.5 +local STICK_NAV_OFF = 0.3 +local STICK_NAV_DELAY = 0.28 +local STICK_NAV_REPEAT = 0.12 + +function RomImporter:_stickNavDir() + local nav = self._stickNav + local on = (nav and nav.dir) and STICK_NAV_OFF or STICK_NAV_ON + local ax = self._padAxis.leftx or 0 + local ay = self._padAxis.lefty or 0 + if math.abs(ax) >= math.abs(ay) then + if math.abs(ax) > on then return ax > 0 and "right" or "left" end + else + if math.abs(ay) > on then return ay > 0 and "down" or "up" end + end + if self._padDir.dpleft then return "left" end + if self._padDir.dpright then return "right" end + if self._padDir.dpup then return "up" end + if self._padDir.dpdown then return "down" end + return nil +end + +function RomImporter:_navigateWithStick(dt, Kit) + local nav = self._stickNav + if not nav then nav = {}; self._stickNav = nav end + local dir = self:_stickNavDir() + if not dir then + nav.dir, nav.t, nav.fired = nil, 0, false + return + end + if nav.dir ~= dir then + nav.dir, nav.t, nav.fired = dir, 0, false + if Kit then Kit.navigate(dir) end + return + end + nav.t = (nav.t or 0) + dt + if nav.t >= (nav.fired and STICK_NAV_REPEAT or STICK_NAV_DELAY) then + nav.t, nav.fired = 0, true + if Kit then Kit.navigate(dir) end + end +end + function RomImporter:_updatePadCursor(dt) local okKit, Kit = pcall(require, "src.ui.kit.Kit") if okKit then @@ -3197,7 +3251,7 @@ function RomImporter:_updatePadCursor(dt) end end - if self.isNX then + if self:_consolePointerHost() then self:_ensureNxPointerBridge() -- Cap dt so a hitch in the FlexLove immediate-mode frame does not fling -- the cursor; desktop keeps raw dt (setPosition path already smooth there). @@ -3208,7 +3262,7 @@ function RomImporter:_updatePadCursor(dt) -- pointer after bumping a stick once. On NX this must stay off: love-nx / -- SDL often drifts the system mouse with the stick (or touch), and axis -- events are not every frame, so yield+reactivate flickers the overlay. - if not self.isNX then + if not self:_consolePointerHost() then local mx, my = love.mouse.getPosition() if self._lastMouseX and self._padCursorActive then if math.abs(mx - self._lastMouseX) > 3 or math.abs(my - self._lastMouseY) > 3 then @@ -3218,6 +3272,17 @@ function RomImporter:_updatePadCursor(dt) self._lastMouseX, self._lastMouseY = mx, my end + if not self._padCursorActive and not self.isNX + and (Platform.isUWP() or self._padNavChosen) then + self:_navigateWithStick(dt, okKit and Kit or nil) + local scrollY = self._padAxis.righty or 0 + if math.abs(scrollY) > PAD_DEAD and self._flex then + require("src.import.LauncherView").wheelmoved(self, 0, -scrollY * 8 * dt) + end + return + end + self._stickNav = nil + local ax = self._padAxis.leftx or 0 local ay = self._padAxis.lefty or 0 local dx, dy = 0, 0 @@ -3246,7 +3311,7 @@ function RomImporter:_updatePadCursor(dt) if overY ~= 0 and self._flex then require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48) end - if not self.isNX and love.mouse.setPosition then + if not self:_consolePointerHost() and love.mouse.setPosition then pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y) self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y end @@ -3275,6 +3340,7 @@ function RomImporter:gamepadpressed(_, button) -- Y button toggle between Native Controller Navigation and Virtual Pointer Cursor: if action == "y" or button == "y" then self._padCursorActive = not self._padCursorActive + self._padNavChosen = not self._padCursorActive if okKit then Kit._ringShown = not self._padCursorActive end self._cursorModeToast = self._padCursorActive and "Cursor Navigation [Y]" or "Controller Menu Navigation [Y]" self._cursorModeToastTime = love.timer.getTime() @@ -3340,11 +3406,11 @@ function RomImporter:gamepadpressed(_, button) return elseif self.tab == "find" then Kit.VirtualKeyboard.open({ - text = self._findQuery or "", + text = self.findQuery or "", title = "Search Mods", onDone = function(newText, confirmed) if confirmed then - self._findQuery = newText + self.findQuery = newText if self._refreshFind then self:_refreshFind() end end end @@ -3546,7 +3612,6 @@ function RomImporter:joystickhat(joystick, hat, direction) })[direction] or {} for _, dir in ipairs(dirs) do self._padDir[dir] = true end self._rawHatDirs[hat] = dirs - if #dirs > 0 then self:_activatePadCursor() end end -- Player pressed Play on a game whose ROM is imported: hand off to boot. @@ -4430,6 +4495,13 @@ end -- notice line used. -- Desktop picks a .cart file; everywhere else CartStore's stray scan already -- adopts anything dropped in the folder, so we just point at it. +function RomImporter:_resyncPointerAfterDialog() + self._mouseAt = nil + self._clickPt = nil + self._prevMouseDown = (love and love.mouse and love.mouse.isDown + and love.mouse.isDown(1)) and true or false +end + function RomImporter:importCartFile(version) local CartStore = require("src.carts.CartStore") local FilePicker = require("src.core.FilePicker") @@ -4442,6 +4514,7 @@ function RomImporter:importCartFile(version) end local path = FilePicker.open("Choose a cart", { label = "Cart", exts = { CartStore.EXT:gsub("^%.", "") } }) + self:_resyncPointerAfterDialog() if not path then return false end local bytes = FilePicker.read(path) if not bytes then @@ -4609,6 +4682,7 @@ function RomImporter:keypressed(key) if key == "escape" then if self._findDetails then self._findDetails = nil + self:_cancelFindDetails() elseif self._modReleaseNotes then self._modReleaseNotes = nil elseif self._appPatchNotes then @@ -4777,7 +4851,7 @@ function RomImporter:_cartSealSlot(version) return nil, scope end -function RomImporter:cartPlan(version) +function RomImporter:cartPlan(version, listed) local id = self.activeCart and self.activeCart[version] or nil if not id then return nil, nil end local slot = self:_cartSealSlot(version) @@ -4788,7 +4862,7 @@ function RomImporter:cartPlan(version) if cached and cached.key == key then return cached.report, slot end local installed = {} pcall(function() - local rows = require("src.mods.LauncherMods").list(version) or {} + local rows = listed or require("src.mods.LauncherMods").list(version) or {} for _, row in ipairs(rows) do local manifest = type(row.manifest) == "table" and row.manifest or row if type(manifest.id) == "string" then @@ -5510,18 +5584,18 @@ function RomImporter:_refreshMods() end -- a pin is judged against the whole listing: the cart named it, so it is -- listed even where the game filter above would have dropped it - local cartId, report = self:modCartPlan() + local cartId, report = self:modCartPlan(listed) if cartId then self.mods = self:_cartPinRows(cartId, report, listed) end end -- The cart the MODS panel is answering for, with the plan that resolves its -- pins, or nil when the panel is on a base game. -function RomImporter:modCartPlan() +function RomImporter:modCartPlan(listed) local version = self.modScope if not version then return nil end local id = self.activeCart and self.activeCart[version] or nil if not id then return nil end - local report = self:cartPlan(version) + local report = self:cartPlan(version, listed) if type(report) ~= "table" or type(report.pins) ~= "table" then return nil end return id, report, version end @@ -5676,6 +5750,29 @@ function RomImporter:_modUpdateInfo(id) return self.modUpdateInfo and self.modUpdateInfo[id] or nil end +function RomImporter:_rejudgeModUpdate(id, fallbackVersion) + if type(id) ~= "string" then return end + local info = self:_modUpdateInfo(id) + local releases = info and info.releases + if type(releases) ~= "table" or #releases == 0 then return end + local installed + for _, m in ipairs(self.mods or {}) do + if m.id == id then installed = m.version break end + end + installed = installed or fallbackVersion + if type(installed) ~= "string" or installed == "" then return end + local ModUpdate = require("src.mods.ModUpdate") + local status, best = ModUpdate.statusFor(installed, releases) + info.status = status + info.latest = best and best.version or nil + info.best = best + info.err = nil + self._modUpdateRev = (self._modUpdateRev or 0) + 1 + for _, item in ipairs(self._modInfoFetch or {}) do + if item.mod and item.mod.id == id then item.mod.version = installed end + end +end + -- Flip one game's mod flag (persisted via LauncherMods.setEnabled) and relist -- so that game's checkbox and status chips reflect the new resolution. -- Enabling an experimental mod arms a confirmation for that same game. @@ -6092,6 +6189,7 @@ function RomImporter:_pumpModInstall() -- The installed list is what the Install / Installed labels read, so it has -- to be re-derived before the next paint or the card lies. pcall(self._refreshMods, self) + self:_rejudgeModUpdate(spec.modId, resErr or job.version) local shown = tostring(resErr or job.version or "") local text = ("%s %s %s"):format(spec.verb or "Installed", tostring(spec.name or spec.modId), shown) @@ -6692,27 +6790,17 @@ function RomImporter:_findRows() category = (not carts) and self.findCategory or nil, base = carts and self.findBase or nil, }) - if self.modScope then - if carts then - -- A cart plays as exactly one game, so the scope is a plain match on - -- the base rather than a ModTargets coverage question. - local kept = {} - for _, entry in ipairs(rows) do - if entry.base == self.modScope then kept[#kept + 1] = entry end + if self.modScope and not carts then + local ModTargets = require("src.mods.ModTargets") + local gen = GameVersion.generation(self.modScope) + local kept = {} + for _, entry in ipairs(rows) do + local versions = ModTargets.normalize(entry.games) + if #versions == 0 or ModTargets.covers(versions, gen) then + kept[#kept + 1] = entry end - rows = kept - else - local ModTargets = require("src.mods.ModTargets") - local gen = GameVersion.generation(self.modScope) - local kept = {} - for _, entry in ipairs(rows) do - local versions = ModTargets.normalize(entry.games) - if #versions == 0 or ModTargets.covers(versions, gen) then - kept[#kept + 1] = entry - end - end - rows = kept end + rows = kept end self._findRowsCache = { src = all, query = self.findQuery, category = self.findCategory, base = self.findBase, @@ -7025,16 +7113,39 @@ end function RomImporter:_findShowDetails(entry) local ModIndex = require("src.mods.ModIndex") local url = ModIndex.joinUrl(entry._base, entry.description_url) - local body = entry.summary or "" - if url then - local ok, text = pcall(ModIndex.fetchText, url) - if ok and type(text) == "string" and text ~= "" then body = text end - end + self:_cancelFindDetails() self._findDetails = { title = entry.title or entry.id, - body = body, + body = entry.summary or "", + loading = url ~= nil, scroll = 0, } + if url then + self._findDetailsFetch = ModIndex.beginFetchText(url) + end +end + +function RomImporter:_cancelFindDetails() + local h = self._findDetailsFetch + if not h then return end + self._findDetailsFetch = nil + pcall(require("src.mods.ModIndex").cancelFetchText, h) +end + +function RomImporter:_pumpFindDetails() + local h = self._findDetailsFetch + if not h then return end + if not self._findDetails then + self:_cancelFindDetails() + return + end + local ok, done, text = pcall(require("src.mods.ModIndex").pumpFetchText, h) + if ok and not done then return end + self._findDetailsFetch = nil + local d = self._findDetails + if not d then return end + d.loading = false + if ok and type(text) == "string" and text ~= "" then d.body = text end end -- Arm the install confirm. The compatibility list is the whole point of the diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 8fd8dbb2..87f3bc9e 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -281,7 +281,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) -- WakeUpEntireParty runs on the enemy's bench too for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end if not woke then - return "failed", { romText(data, "_PlayedFluteNoEffectText", + -- engine/items/item_effects.asm:1728, engine/battle/core.asm:2257 (#1882) + return "kept", { romText(data, "_PlayedFluteNoEffectText", "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") } end return "flute", { romText(data, "_PlayedFluteHadEffectText", diff --git a/src/link/CodeEntry.lua b/src/link/CodeEntry.lua index 23c74241..88890853 100644 --- a/src/link/CodeEntry.lua +++ b/src/link/CodeEntry.lua @@ -1,5 +1,5 @@ --- Shared slot-scrub entry widget: the digit-scrub interaction LinkState's --- own `ipDigits`/`addrPos` already uses for IP entry, over the Crockford-32 +-- Shared slot-scrub entry widget: the digit-scrub interaction LinkState +-- uses for LAN address entry, over the Crockford-32 -- style alphabet pokeserver room/tournament codes are drawn from -- (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read aloud or -- handwritten never has to be checked twice). @@ -30,7 +30,7 @@ function CodeEntry.new(opts) end -- Seed the slots from an existing string: prefilling the LAN address means --- the player scrubs the last octet instead of all twelve digits. Anything +-- the player scrubs the last octet instead of the whole address. Anything -- not in the charset lands on slot 1's character. -- Slots past the end of the seed -- and any character the charset does not -- carry -- land on the charset's blank where it has one, so seeding a diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index 9274914d..860b6c6d 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -2,6 +2,7 @@ -- the other joins by typing that address in. Direct peer-to-peer over -- lua-enet (bundled with LÖVE), no relay server. +local CodeEntry = require("src.link.CodeEntry") local DiscordPresence = require("src.core.DiscordPresence") local Font = require("src.render.Font") local Handshake = require("src.link.Handshake") @@ -53,20 +54,28 @@ local PRE_CONNECT_STAGES = { menu = true } -- the handshake (a pre-mod guest sends nothing until it hears the mode) local HELLO_GRACE = 2 --- the joiner edits an IPv4 address as 12 digits (three per octet), --- prefilled with our own LAN IP so usually only the tail needs changing -local function ipDigits(ip) - local digits = {} - local a, b, c, d = (ip or ""):match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$") - local octets = { tonumber(a) or 192, tonumber(b) or 168, - tonumber(c) or 0, tonumber(d) or 1 } +LinkState.ADDR_LENGTH = 15 +LinkState.ADDR_CHARSET = "0123456789. " + +local ADDR_OPTS = { length = LinkState.ADDR_LENGTH, + charset = LinkState.ADDR_CHARSET } + +function LinkState.addrEntry(ip) + local seed = ip or "192.168.0.1" + if not seed:match("^%d+%.%d+%.%d+%.%d+$") then seed = "192.168.0.1" end + local state = CodeEntry.fromText(seed, ADDR_OPTS) + state.pos = math.max(1, math.min(ADDR_OPTS.length, #seed)) + return state +end + +function LinkState.addrText(state) + local text = (CodeEntry.text(state):gsub(" ", "")) + local octets = { text:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$") } + if #octets ~= 4 then return nil end for _, o in ipairs(octets) do - o = math.min(255, o) - table.insert(digits, math.floor(o / 100)) - table.insert(digits, math.floor(o / 10) % 10) - table.insert(digits, o % 10) + if #o > 3 or tonumber(o) > 255 then return nil end end - return digits + return text end local function openSession(role, connect) @@ -87,8 +96,7 @@ function LinkState.new(game) game.linkSession = true self.stage = "menu" self.index = 1 - self.addr = ipDigits(Net.lanIP()) - self.addrPos = 12 -- the last octet is what usually differs + self.addr = LinkState.addrEntry(Net.lanIP()) self.status = "" return self end @@ -249,22 +257,20 @@ function LinkState:update(dt) elseif self.stage == "addrEntry" then if input:wasPressed("b") then self:exitWith(nil) return end if input:wasPressed("up") then - self.addr[self.addrPos] = (self.addr[self.addrPos] + 1) % 10 + CodeEntry.up(self.addr) elseif input:wasPressed("down") then - self.addr[self.addrPos] = (self.addr[self.addrPos] - 1) % 10 + CodeEntry.down(self.addr) elseif input:wasPressed("left") then - self.addrPos = math.max(1, self.addrPos - 1) + CodeEntry.left(self.addr) elseif input:wasPressed("right") then - self.addrPos = math.min(12, self.addrPos + 1) + CodeEntry.right(self.addr) elseif input:wasPressed("a") then - local octets = {} - for i = 1, 4 do - local base = (i - 1) * 3 - octets[i] = math.min(255, self.addr[base + 1] * 100 - + self.addr[base + 2] * 10 - + self.addr[base + 3]) + local address = LinkState.addrText(self.addr) + if not address then + self.status = Strings("Not an IP address.") + return end - local address = table.concat(octets, ".") + self.status = "" local session, detail = openSession("guest", function(transport) return transport:join(address) end) @@ -597,18 +603,16 @@ function LinkState:draw() elseif self.stage == "addrEntry" then drawTitle("ENTER HOST ADDRESS") - for i = 1, 12 do - local octet = math.floor((i - 1) / 3) -- 0..3 - local x = 16 + (i - 1) * 8 + octet * 8 -- gap for the dots - Font.draw(tostring(self.addr[i]), x, 64) - if i == self.addrPos then - Font.drawCode(0xEE, x, 76) -- ▼ under the active digit + for i = 1, LinkState.ADDR_LENGTH do + local x = 8 + (i - 1) * 8 + local ch = CodeEntry.charAt(self.addr, i) + if ch ~= " " then Font.draw(ch, x, 64) end + if i == self.addr.pos then + Font.drawCode(0xEE, x, 76) end end - for octet = 1, 3 do - Font.draw(".", 16 + octet * 32 - 8, 64) - end - Font.draw(Strings("Port: %s", Net.defaultPort()), 16, 96) + Font.draw(Strings("Port: %s", Net.defaultPort()), 8, 96) + if self.status ~= "" then Font.draw(self.status, 8, 112) end Font.draw(Strings("A: connect B: back"), 8, 128) elseif self.stage == "joining" then diff --git a/src/mods/Gen2Compat.lua b/src/mods/Gen2Compat.lua index 7bc4f150..b5c3ec08 100644 --- a/src/mods/Gen2Compat.lua +++ b/src/mods/Gen2Compat.lua @@ -1942,8 +1942,9 @@ COVERAGE["src.battle.BattleState"] = { .. "takes its native safari path when it finds one", openParty = "Gold's takes a `forced` argument Gen 1's does not; a wrap " .. "must forward ... faithfully rather than normalising it away", - wantsFillScale = "returns TRUE unconditionally on Gold, which reads as " - .. "\"the player chose FILL\" and is not a choice at all", + wantsFillScale = "reads OPTION -> BATTLE SIZE (#1709), same fixed/fill " + .. "key as Gen 1; Gold has no WIDE layout, so FILL is the scale of the " + .. "160x144 panel and nothing more", swapMoves = "no disabled-slot migration and no sfx on Gold", sides = "self.battle.sides is the same { index, battlers, screens, " .. "hazards, tokens } shape, with the same index-1-is-player rule", diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index 627f7ce2..f73e3573 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -1230,8 +1230,9 @@ local ModProfile = require("src.mods.ModProfile") function LauncherMods.getProfiles(options) options = options or SaveData.loadOptions() - local manifests = discover() - ModProfile.ensureFirst(options, manifests, options.modOptions) + if not options.modProfilesSeeded then + ModProfile.ensureFirst(options, discover(), options.modOptions) + end return options.modProfiles or {}, options.activeProfile or "PROFILE 1" end diff --git a/src/mods/ModIndex.lua b/src/mods/ModIndex.lua index 78618e49..ae7a9ed4 100644 --- a/src/mods/ModIndex.lua +++ b/src/mods/ModIndex.lua @@ -880,6 +880,42 @@ function ModIndex.fetchText(url) return ModIndex.httpGet(url) end +function ModIndex.beginFetchText(url) + local h = { url = url, stage = "start" } + if type(url) ~= "string" or url == "" then + h.stage, h.err = "done", "no description" + end + return h +end + +function ModIndex.pumpFetchText(h) + if not h then return true, nil, "no handle" end + if h.stage == "done" then return true, h.body, h.err end + local Fetch = require("src.net.Fetch") + if h.stage == "start" then + h.job = Fetch.get(h.url, { userAgent = "gen1recomp-mod-index" }) + h.stage = "fetching" + return false + end + local st = Fetch.poll(h.job) + if st.status == "pending" then return false end + Fetch.release(h.job) + h.job, h.stage = nil, "done" + if st.status == "ok" and type(st.body) == "string" and st.body ~= "" then + h.body = st.body + return true, h.body + end + h.err = st.err or "description fetch failed" + return true, nil, h.err +end + +function ModIndex.cancelFetchText(h) + if not h or not h.job then return end + local Fetch = require("src.net.Fetch") + pcall(Fetch.cancel, h.job) + h.job, h.stage = nil, "done" +end + -- Download a thumbnail into the save directory and return the love.filesystem -- relative path. Reuses ModUpdate.downloadZip, which is a plain curl -o with -- a non-empty-file check -- nothing in it is zip-specific. diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 1861aae7..858083e9 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -2045,7 +2045,8 @@ R.field = { cursorOrder = f.opt(f.list(f.str)), locations = f.opt(f.map(f.str, f.rec{ x = f.int(0), y = f.int(0), name = f.opt(f.str) })), - nest = f.opt(f.any) }, + nest = f.opt(f.any), + upArrow = f.opt(f.any) }, flyOrder = f.list(f.str), -- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle -- back pic, the catch tutorial's old man, Yellow's PROF.OAK variant of diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 7cf4c752..5e9ba51c 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -787,6 +787,9 @@ end -- leaves the original. PaletteFX.DARK_BGP = { [0] = 2, [1] = 3, [2] = 3, [3] = 3 } +-- engine/gfx/screen_effects.asm:1-12 +PaletteFX.POISON_BGP = { [0] = 2, [1] = 1, [2] = 2, [3] = 3 } + local shadeMap = nil function PaletteFX.setShadeMap(map) diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 56722092..99c1175f 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -394,6 +394,13 @@ function TextBox:update(dt) return end if self.auto then + -- engine/items/item_effects.asm:1794 (#1880) + if self.auto.promptFirst and not self.autoPrompted then + if not (input:wasPressed("a") or input:wasPressed("b")) then return end + require("src.core.Sound").play(self.game.data, "Press_AB") + self.autoPrompted = true + return + end if not self.autoStarted then self.autoStarted = true self.autoSrc = self.auto.sound and self.auto.sound() or nil @@ -608,7 +615,15 @@ function TextBox:draw() Chrome.paletteBox(11, 0, 9, 3, Chrome.DEFAULT_BOX_PALETTE) else Font.drawBox(11, 0, 9, 3) + -- data/text_boxes.asm:35 + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 13 * 8, 0, 5 * 8, 8) love.graphics.setColor(0, 0, 0, 1) + local cap = 13 * 8 + for _, code in ipairs(Font.encode("MONEY")) do + drawGlyph(code, cap, 0) + cap = cap + Font.advanceOf(code) + end end local money = ("¥%d"):format(self.money() or 0) local pen = 152 - Font.width(money) @@ -617,7 +632,9 @@ function TextBox:draw() pen = pen + Font.advanceOf(code) end end - if (self.waiting or (self.done and not self.choice and not self.auto + if (self.waiting or (self.done and not self.choice + and (not self.auto + or (self.auto.promptFirst and not self.autoPrompted)) and (not self.stay or (self.stay.prompt and not self.stayShown)))) and self.blink < 30 then diff --git a/src/save_convert/Gen2Layout.lua b/src/save_convert/Gen2Layout.lua index d60511e1..b01f5f2e 100644 --- a/src/save_convert/Gen2Layout.lua +++ b/src/save_convert/Gen2Layout.lua @@ -39,6 +39,9 @@ Gen2Layout.goldSilver = { wYCoord = 0x286A, wEventFlags = 0x261F, wPlayerState = 0x24EA, + wStatusFlags = 0x23D9, + wStatusFlags2 = 0x23DA, + wPokegearFlags = 0x24E4, wGameTimeHours = 0x2053, wGameTimeMinutes = 0x2055, -- The 14 archived boxes, listed rather than strided (see BOX_COUNT). @@ -81,6 +84,9 @@ Gen2Layout.crystal = { wYCoord = 0x2845, wEventFlags = 0x2600, wPlayerState = 0x24EB, + wStatusFlags = 0x23DA, + wStatusFlags2 = 0x23DB, + wPokegearFlags = 0x24E5, wGameTimeHours = 0x2052, wGameTimeMinutes = 0x2054, -- The 14 archived boxes, listed rather than strided (see BOX_COUNT). @@ -123,6 +129,9 @@ Gen2Layout.crystal = { wYCoord = 0x1A45, wEventFlags = 0x1800, wPlayerState = 0x16EB, + wStatusFlags = 0x15DA, + wStatusFlags2 = 0x15DB, + wPokegearFlags = 0x16E5, wGameTimeHours = 0x1252, wGameTimeMinutes = 0x1254, boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 }, diff --git a/src/save_convert/Gen2Save.lua b/src/save_convert/Gen2Save.lua index 63369452..c6f3a43b 100644 --- a/src/save_convert/Gen2Save.lua +++ b/src/save_convert/Gen2Save.lua @@ -95,6 +95,7 @@ function Gen2Save.crosswalks(data) itemIndex = toIndex(data.items), mapIds = mapIds, itemDefs = data.items or {}, + moveDefs = data.moves or {}, } end @@ -147,18 +148,36 @@ local function decodeStatExp(bytes, o) } end +-- constants/pokemon_data_constants.asm:216, :218 +local function decodePPByte(b) return b % 64, math.floor(b / 64) % 4 end +local function encodePPByte(pp, ppUps) + return ((ppUps or 0) % 4) * 64 + ((pp or 0) % 64) +end + +-- engine/items/item_effects.asm:2736 +local function maxPPOf(base, ppUps) + if not base then return nil end + return base + (ppUps or 0) * math.min(math.floor(base / 5), 7) +end + -- The first 32 bytes, which a box mon and a party mon share. local function decodeSharedMon(bytes, o, x) - local moves, pp = {}, {} + local moves = {} for i = 0, 3 do local id = named(x.moves, u8(bytes, o + 2 + i)) - if id then moves[#moves + 1] = id end + if id then + local pp, ppUps = decodePPByte(u8(bytes, o + 0x17 + i)) + local def = x.moveDefs[id] + local base = type(def) == "table" and def.pp or nil + moves[#moves + 1] = { + id = id, pp = pp, ppUps = ppUps, maxPp = maxPPOf(base, ppUps), + } + end end - for i = 0, 3 do pp[i + 1] = u8(bytes, o + 0x17 + i) % 64 end return { species = named(x.pokemon, u8(bytes, o)), item = named(x.items, u8(bytes, o + 1)), - moves = moves, pp = pp, + moves = moves, otId = be(bytes, o + 6, 2), experience = be(bytes, o + 8, 3), statExp = decodeStatExp(bytes, o + 0x0B), @@ -299,6 +318,29 @@ local function decodeBadges(byte, order) return out end +-- data/events/engine_flags.asm:11-40 +local ENGINE_FLAG_BITS = { + wPokegearFlags = { { 1, 0 }, { 0, 1 }, { 2, 2 }, { 3, 3 }, { 4, 7 } }, + wStatusFlags = { { 11, 0 }, { 12, 1 }, { 13, 3 }, { 14, 4 }, { 15, 6 } }, + wStatusFlags2 = { + { 18, 0 }, { 17, 1 }, { 19, 4 }, { 20, 5 }, { 21, 6 }, { 22, 7 }, + }, +} + +local function decodeEngineFlags(bytes, L) + local out = {} + for field, rows in pairs(ENGINE_FLAG_BITS) do + local at = L[field] + if at then + local byte = u8(bytes, at) + for _, row in ipairs(rows) do + if math.floor(byte / 2 ^ row[2]) % 2 == 1 then out[row[1]] = true end + end + end + end + return out +end + Gen2Save.NUM_SPECIES = 251 Gen2Save.EVENT_BYTES = 256 @@ -369,6 +411,7 @@ function Gen2Save.decode(bytes, gameVersion, data) seen = decodeDex(bytes, L.wPokedexSeen, x), }, events = decodeFlagBytes(bytes, L.wEventFlags, Gen2Save.EVENT_BYTES), + engineFlags = decodeEngineFlags(bytes, L), -- Save.summary does `save.position.map or save.spawn`. position = { map = x.maps[u8(bytes, L.wMapGroup) * 256 + u8(bytes, L.wMapNumber)], @@ -479,7 +522,9 @@ local function putSharedMon(t, o, mon, x) putU8(t, o, indexOf(x.pokemonIndex, mon.species)) putU8(t, o + 1, indexOf(x.itemIndex, mon.item)) for i = 0, 3 do - putU8(t, o + 2 + i, indexOf(x.moveIndex, (mon.moves or {})[i + 1])) + local mv = (mon.moves or {})[i + 1] + putU8(t, o + 2 + i, + indexOf(x.moveIndex, type(mv) == "table" and mv.id or mv)) end putBE(t, o + 6, mon.otId or 0, 2) putBE(t, o + 8, mon.experience or 0, 3) @@ -491,7 +536,12 @@ local function putSharedMon(t, o, mon, x) putU8(t, o + 0x15, (d.attack or 0) * 16 + (d.defense or 0)) putU8(t, o + 0x16, (d.speed or 0) * 16 + (d.special or 0)) for i = 0, 3 do - putU8(t, o + 0x17 + i, (mon.ppRaw or {})[i + 1] or (mon.pp or {})[i + 1] or 0) + local mv = (mon.moves or {})[i + 1] + if type(mv) == "table" then + putU8(t, o + 0x17 + i, encodePPByte(mv.pp, mv.ppUps)) + else + putU8(t, o + 0x17 + i, (mon.ppRaw or {})[i + 1] or (mon.pp or {})[i + 1] or 0) + end end putU8(t, o + 0x1B, mon.happiness or 0) -- 0x1C-0x1E belong to the mon, not to the slot: leaving them to the template @@ -551,6 +601,22 @@ local function putBag(t, L, inventory, x) return overflow end +local function putEngineFlags(t, L, flags) + for field, rows in pairs(ENGINE_FLAG_BITS) do + local at = L[field] + if at then + local byte = t[at] or 0 + for _, row in ipairs(rows) do + local mask = 2 ^ row[2] + local on = math.floor(byte / mask) % 2 == 1 + local want = flags[row[1]] == true + if on ~= want then byte = want and (byte + mask) or (byte - mask) end + end + t[at] = byte + end + end +end + local function putFlagSet(t, at, set, count, indexFor) for i = 0, count - 1 do local byteAt = at + math.floor(i / 8) @@ -657,6 +723,9 @@ function Gen2Save.encode(save, gameVersion, template, data) local byte = (save.events or {})[i] if type(byte) == "number" then putU8(t, L.wEventFlags + i, byte) end end + if type(save.engineFlags) == "table" then + putEngineFlags(t, L, save.engineFlags) + end local pos = save.position if pos then diff --git a/src/save_convert/GenSave.lua b/src/save_convert/GenSave.lua index d7319953..c0232d1d 100644 --- a/src/save_convert/GenSave.lua +++ b/src/save_convert/GenSave.lua @@ -23,8 +23,7 @@ -- ORIGINAL imported bytes as a template when available (GenSave.decode -- stashes them) so that scratch state round-trips untouched instead of -- being invented; with no template (a save that originated in this --- project) those bytes stay zero-filled, which is safe because the real --- game regenerates all of it from wCurMap on the next map load anyway. +-- project) src/save_convert/MapContext.lua rebuilds it (home/overworld.asm:2016). local bit = require("bit") local MapContext = require("src.save_convert.MapContext") @@ -1069,20 +1068,21 @@ function GenSave.encode(save, data, template) rebuild = index == nil or u8(src, O.curMap) ~= bit.band(index, 0xFF) end if rebuild then - local ctx = MapContext.build(data, mapId, + local ctx, why = MapContext.build(data, mapId, (save.player and save.player.x) or 0, (save.player and save.player.y) or 0) - if ctx then - for offset, values in pairs(ctx.writes) do - for i, value in ipairs(values) do - setByte(buf, O.mainData + offset + i - 1, value) - end - end - for i, value in ipairs(ctx.spriteData) do - setByte(buf, O.spriteData + i - 1, value) - end - -- sTileAnimations, the byte between sCurBoxData and the checksum - setByte(buf, O.checksumEnd - 1, ctx.tileAnimations) + -- home/overworld.asm:2016 (#1691) + if not ctx then + error(("this save cannot be exported: %s"):format(tostring(why)), 0) end + for offset, values in pairs(ctx.writes) do + for i, value in ipairs(values) do + setByte(buf, O.mainData + offset + i - 1, value) + end + end + for i, value in ipairs(ctx.spriteData) do + setByte(buf, O.spriteData + i - 1, value) + end + setByte(buf, O.checksumEnd - 1, ctx.tileAnimations) end end diff --git a/src/save_convert/MapContext.lua b/src/save_convert/MapContext.lua index 0443a992..7289dbe2 100644 --- a/src/save_convert/MapContext.lua +++ b/src/save_convert/MapContext.lua @@ -207,13 +207,13 @@ function MapContext.build(data, mapId, x, y) -- The tileset header, as predef LoadTilesetHeader would have copied it. local tilesets = data.tilesets or {} local tilesetDef = tilesets[map.tileset] - local tileAnimations = 0 - if tilesetDef and tilesetDef.header then - local row = {} - for i = 1, 11 do row[i] = tilesetDef.header[i] end - writes[O.tilesetHeader] = row - tileAnimations = tilesetDef.header[12] or 0 + if not (tilesetDef and tilesetDef.header) then + return nil, ("no tileset bytes for %s (re-import the ROM)"):format(tostring(mapId)) end + local row = {} + for i = 1, 11 do row[i] = tilesetDef.header[i] end + writes[O.tilesetHeader] = row + local tileAnimations = tilesetDef.header[12] or 0 -- MapSongBanks: without it the game continues with sound id 0 and audio -- bank 0, which is what actually hangs a Continue on a white screen. @@ -221,10 +221,11 @@ function MapContext.build(data, mapId, x, y) local songLabel = audio and audio.mapSongs and audio.mapSongs[mapId] local song = songLabel and audio.songs and audio.songs[songLabel] local id = song and soundId(song.address) - if id and song.bank then - writes[O.mapMusicSoundID] = { id } - writes[O.mapMusicROMBank] = { song.bank % 256 } + if not (id and song.bank) then + return nil, ("no map music for %s (re-import the ROM)"):format(tostring(mapId)) end + writes[O.mapMusicSoundID] = { id } + writes[O.mapMusicROMBank] = { song.bank % 256 } -- Player position within its block, and the upper-left corner of the view. -- The pointer is the same expression the warp_to tables are assembled with diff --git a/src/sync/SyncEngine.lua b/src/sync/SyncEngine.lua index d2d4313a..bc7142a5 100644 --- a/src/sync/SyncEngine.lua +++ b/src/sync/SyncEngine.lua @@ -13,6 +13,16 @@ SyncEngine.MAX_STEPS_PER_UPDATE = 8 local IDLE_STATUS = "Ready" local UNLINKED_STATUS = "Not set up" +local function unixSeconds(v) + local n = tonumber(v) + if not n or n ~= n or n <= 0 or n == math.huge or n == -math.huge then + return nil + end + return n +end + +SyncEngine.unixSeconds = unixSeconds + local function saveApi() return require("src.core.SaveData") end @@ -56,8 +66,9 @@ function SyncEngine.defaultSaves() playthroughId = id, blob = source, meta = { - savedAt = tonumber(meta.savedAt), - sessionStart = tonumber(meta.sessionStart), + savedAt = unixSeconds(meta.savedAt) + or unixSeconds(save.savedAt), + sessionStart = unixSeconds(meta.sessionStart), playthroughId = id, format = meta.format, engine = meta.engine, @@ -110,8 +121,8 @@ end function SyncEngine.overlaps(a, b) if type(a) ~= "table" or type(b) ~= "table" then return false end - local aStart, aEnd = tonumber(a.sessionStart), tonumber(a.savedAt) - local bStart, bEnd = tonumber(b.sessionStart), tonumber(b.savedAt) + local aStart, aEnd = unixSeconds(a.sessionStart), unixSeconds(a.savedAt) + local bStart, bEnd = unixSeconds(b.sessionStart), unixSeconds(b.savedAt) if not (aStart and aEnd and bStart and bEnd) then return false end return aStart <= bEnd and bStart <= aEnd end @@ -135,7 +146,7 @@ local function playedMinutes(meta) if hours then return tonumber(hours) * 60 + tonumber(minutes) end end local seconds = tonumber(meta.playTime) - if seconds then return math.floor(seconds / 60) end + if seconds and seconds > 0 then return math.floor(seconds / 60) end return nil end @@ -146,6 +157,26 @@ function SyncEngine.samePlaytime(a, b) return left ~= nil and left == right end +function SyncEngine.sameProgress(a, b) + if SyncEngine.samePlaytime(a, b) then return true end + if type(a) ~= "table" or type(b) ~= "table" then return false end + local left = type(a.summary) == "table" and a.summary or nil + local right = type(b.summary) == "table" and b.summary or nil + if not (left and right) then return false end + if type(left.name) ~= "string" or left.name == "" then return false end + return left.name == right.name and left.badges == right.badges + and left.timeText == right.timeText and left.dexCount == right.dexCount +end + +function SyncEngine.displayMeta(meta) + if type(meta) ~= "table" then return meta end + local out = {} + for k, v in pairs(meta) do out[k] = v end + out.savedAt = unixSeconds(meta.savedAt) + out.sessionStart = unixSeconds(meta.sessionStart) + return out +end + function SyncEngine.new(opts) opts = opts or {} local eng = setmetatable({}, SyncEngine) @@ -383,19 +414,20 @@ function SyncEngine:unlink() self:_forgetLocal() return true end - if self:busy() then return false, "sync is busy" end + if self:busy() then self:cancel() end self.phase = "checking" self.status = "Unlinking this device..." self.error = nil local handle, err = self.client:unlink(self.state.deviceId) + if not handle then + self:_forgetLocal() + return true + end return self:_request(handle, err, function(eng) eng:_forgetLocal() - end, function(eng, res) - if res.code == 401 or res.code == 404 then - eng:_forgetLocal() - return true - end - return false + end, function(eng) + eng:_forgetLocal() + return true end) end @@ -479,15 +511,20 @@ function SyncEngine:_planFrom(remoteState) seen[key] = true local row = remote[key] local knownRev = SyncState.rev(self.state, key) - local stamp = SyncState.stamp(self.state, key) - local localChanged = stamp == nil - or tonumber(entry.meta and entry.meta.savedAt) ~= stamp + local stamp = unixSeconds(SyncState.stamp(self.state, key)) + local liveStamp = unixSeconds(entry.meta and entry.meta.savedAt) + local localChanged + if liveStamp == nil and stamp == nil then + localChanged = knownRev == nil + else + localChanged = liveStamp ~= stamp + end local remoteRev = row and tonumber(row.rev) local remoteChanged = row ~= nil and remoteRev ~= knownRev if not row then self:_queueUpload(entry, key, false) elseif localChanged and remoteChanged then - if SyncEngine.samePlaytime(entry.meta, SyncEngine.metaOf(row)) then + if SyncEngine.sameProgress(entry.meta, SyncEngine.metaOf(row)) then self:_queueUpload(entry, key, true) else self:_addConflict(entry, key, row) @@ -511,14 +548,14 @@ function SyncEngine:_planFrom(remoteState) end function SyncEngine:_addConflict(entry, key, row) - local remoteMeta = SyncEngine.metaOf(row) + local remoteMeta = SyncEngine.displayMeta(SyncEngine.metaOf(row)) self.conflicts[#self.conflicts + 1] = { key = key, version = entry.version, playthroughId = entry.playthroughId, slot = entry.slot, entry = entry, - localMeta = entry.meta, + localMeta = SyncEngine.displayMeta(entry.meta), remoteMeta = remoteMeta, remoteRev = tonumber(row.rev), overlap = SyncEngine.overlaps(entry.meta, remoteMeta), @@ -551,7 +588,7 @@ function SyncEngine:_queueUpload(entry, key, force) eng:_request(handle, err, function(e, res) local data = res.data or {} SyncState.setRev(e.state, key, tonumber(data.rev), - entry.meta and entry.meta.savedAt) + unixSeconds(entry.meta and entry.meta.savedAt)) e:_persist() if not e:busy() then e:_finish() end end, function(e, res) @@ -560,7 +597,7 @@ function SyncEngine:_queueUpload(entry, key, force) -- Retried with force only once: a forced write that still 409s is a -- real refusal, and retrying it would spin. if not force - and SyncEngine.samePlaytime(entry.meta, SyncEngine.metaOf(row)) then + and SyncEngine.sameProgress(entry.meta, SyncEngine.metaOf(row)) then e:_queueUpload(entry, key, true) else e:_addConflict(entry, key, row) @@ -592,7 +629,7 @@ function SyncEngine:_queueDownload(key, version, playthroughId, mode, knownRev) if mode ~= "new" then local meta = type(data.meta) == "table" and data.meta or {} SyncState.setRev(e.state, key, tonumber(data.rev) or knownRev, - tonumber(meta.savedAt)) + unixSeconds(meta.savedAt)) end e:_persist() if not e:busy() then e:_finish() end diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index d810ce0c..a952f80c 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -116,14 +116,16 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) -- then the woke-up/battle sequence (data/scripts/story.lua snorlaxWake) if result == "flute_wake" then closeBag() - require("src.core.Sound").play(game.data, "Pokeflute") + -- engine/items/item_effects.asm:1794 (#1880) + local opts = TextBox.soundOpts(game, "Pokeflute", + { auto = { wait = false, delay = 0, promptFirst = true } }) showMessages(game, payload, function() local ow = game.overworld local mod = ow and require("data.scripts.init").get(extra.mapId) if ow and mod and mod.snorlaxWake then ow.runner:run(mod.snorlaxWake.script, { npc = extra.npc }) end - end) + end, opts) return end diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index 41f16fd6..fcc85d71 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -5,6 +5,7 @@ local Font = require("src.render.Font") local Runtime = require("src.mods.Runtime") local Theme = require("src.ui.Theme") +local MenuRepeat = require("src.ui.MenuRepeat") local Strings = require("src.core.Strings") local ListMenu = {} @@ -54,14 +55,18 @@ local ITEM_CURSOR_X = 40 local ITEM_QTY_X, ITEM_QTY_END = 112, 136 local ITEM_MORE_X, ITEM_MORE_Y = 144, 88 -- frames to wait before key-repeat kicks in, then between repeats -local REPEAT_DELAY = 16 -local REPEAT_RATE = 4 +local REPEAT_DELAY = MenuRepeat.GEN1_DELAY +local REPEAT_RATE = MenuRepeat.GEN1_RATE +local UPDOWN_DIRS = { "up", "down" } +local NAV_DIRS = { "up", "down", "left", "right" } -- ui.list_menu identity: unhooked opts pass through unchanged local function sameOpts(opts) return opts end function ListMenu.new(game, title, items, opts) opts = opts or {} + -- home/list_menu.asm:8 + opts.keyRepeat = opts.keyRepeat ~= false -- bag / shop / dex / generic: mods may enable wrap, pageJump, keyRepeat if Runtime.wantsHook("ui.list_menu") then local hooked = Runtime.call("ui.list_menu", sameOpts, { @@ -99,8 +104,7 @@ function ListMenu.new(game, title, items, opts) self.keyRepeat = opts.keyRepeat -- hold Up/Down (and pageJump L/R) to scroll self.repeatDelay = opts.repeatDelay or REPEAT_DELAY self.repeatRate = opts.repeatRate or REPEAT_RATE - self.holdDir = nil - self.holdFrames = 0 + self.hold = MenuRepeat.new(self.repeatDelay, self.repeatRate, self.keyRepeat) self.onSelectKey = opts.onSelectKey -- SELECT pressed on an item -- scripted mode (the old man tutorial): update() runs the script -- every frame INSTEAD of reading input -- DisplayListMenuID's old-man @@ -201,18 +205,10 @@ function ListMenu:update(dt) end local moved = false - if input:wasPressed("up") then - moved = navPressed(self, "up") - self.holdDir, self.holdFrames = "up", 0 - elseif input:wasPressed("down") then - moved = navPressed(self, "down") - self.holdDir, self.holdFrames = "down", 0 - elseif self.pageJump and input:wasPressed("left") then - moved = navPressed(self, "left") - self.holdDir, self.holdFrames = "left", 0 - elseif self.pageJump and input:wasPressed("right") then - moved = navPressed(self, "right") - self.holdDir, self.holdFrames = "right", 0 + local dir = MenuRepeat.direction(self.hold, input, + self.pageJump and NAV_DIRS or UPDOWN_DIRS) + if dir then + moved = navPressed(self, dir) elseif self.onSelectKey and input:wasPressed("select") then self.onSelectKey(self.items[self.index], self) elseif input:wasPressed("b") then @@ -229,20 +225,6 @@ function ListMenu:update(dt) return end - -- hold-to-scroll (opt-in via ui.list_menu keyRepeat) - if self.keyRepeat then - local dir = self.holdDir - if dir and input:isDown(dir) then - self.holdFrames = self.holdFrames + 1 - local afterDelay = self.holdFrames - self.repeatDelay - if afterDelay >= 0 and afterDelay % self.repeatRate == 0 then - navPressed(self, dir) - end - else - self.holdDir, self.holdFrames = nil, 0 - end - end - if not moved then syncScroll(self) end end @@ -306,6 +288,9 @@ function ListMenu:drawItemBox() if item.sub then -- PrintLevel, one row down and 8 columns right (home/list_menu.asm:459-461) Font.draw(item.sub, ITEM_QTY_X, y + 8) + elseif item.price then + -- home/list_menu.asm:410-424 + Font.draw(item.price, ITEM_QTY_END - Font.width(item.price), y + 8) elseif item.right then -- '×' at column 14, PrintNumber's two right-aligned digits after it -- (home/list_menu.asm:479-490) diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 00079f18..3ba64d19 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -34,6 +34,8 @@ function Menu.new(game, items, opts) if self.tx + self.tw > 20 then self.tx = math.max(0, 20 - self.tw) end end self.rowStep = opts.rowStep or 2 + -- home/window.asm:56-83 + self.noWrap = opts.noWrap or false -- engine/movie/oak_speech/oak_speech2.asm:162 (DisplayIntroNameTextBox) self.title = opts.title self.itemY = opts.itemY @@ -82,9 +84,11 @@ end function Menu:update(dt) local input = self.game.input if input:wasPressed("up") then - self.index = self.index > 1 and self.index - 1 or #self.items + self.index = self.index > 1 and self.index - 1 + or (self.noWrap and 1 or #self.items) elseif input:wasPressed("down") then - self.index = self.index < #self.items and self.index + 1 or 1 + self.index = self.index < #self.items and self.index + 1 + or (self.noWrap and #self.items or 1) elseif input:wasPressed("a") then -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press if not self.noSound then diff --git a/src/ui/MenuRepeat.lua b/src/ui/MenuRepeat.lua new file mode 100644 index 00000000..d58dde3b --- /dev/null +++ b/src/ui/MenuRepeat.lua @@ -0,0 +1,43 @@ +-- home/joypad2.asm:16-53 JoypadLowSensitivity (hJoy7 = 1, 30 then 5) +-- pokegold home/joypad.asm:313-340 JoyTextDelay (hInMenu, 15 then 5) + +local MenuRepeat = {} + +MenuRepeat.GEN1_DELAY, MenuRepeat.GEN1_RATE = 30, 5 +MenuRepeat.GEN2_DELAY, MenuRepeat.GEN2_RATE = 15, 5 + +local ALL_DIRS = { "up", "down", "left", "right" } + +function MenuRepeat.new(delay, rate, enabled) + return { + delay = tonumber(delay) or MenuRepeat.GEN1_DELAY, + rate = math.max(1, tonumber(rate) or MenuRepeat.GEN1_RATE), + enabled = enabled ~= false, + dir = nil, + frames = 0, + } +end + +function MenuRepeat.reset(state) + state.dir, state.frames = nil, 0 +end + +function MenuRepeat.direction(state, input, dirs) + for _, dir in ipairs(dirs or ALL_DIRS) do + if input:wasPressed(dir) then + state.dir, state.frames = dir, 0 + return dir, true + end + end + local dir = state.dir + if not (dir and state.enabled and input.isDown and input:isDown(dir)) then + MenuRepeat.reset(state) + return nil, false + end + state.frames = state.frames + 1 + local afterDelay = state.frames - state.delay + if afterDelay >= 0 and afterDelay % state.rate == 0 then return dir, false end + return nil, false +end + +return MenuRepeat diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 34b1a700..f9719d54 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -731,14 +731,7 @@ function PartyMenu:update(dt) end end --- The bottom-of-screen context message for the current menu state --- (pokered engine/menus/party_menu.asm PartyMenuMessage / RedrawPartyMenu_): --- the party menu always prints a message in the bottom text box. With the --- normal message id that is PartyMenuBattleText ("Bring out which POKéMON?") --- when IsInBattle else PartyMenuNormalText ("Choose a POKéMON."); the swap / --- item / TM-HM ids print their own strings, and EVO_STONE shares --- PartyMenuItemUseText (party_menu.asm:229 PartyMenuMessagePointers). --- Pure (no side effects) so drivers can assert it. #147 #1610 +-- engine/menus/party_menu.asm:229 (#147 #1610 #1901) function PartyMenu:bottomMessage() if self.swapFrom then return self.game.data.text._PartyMenuSwapMonText @@ -749,7 +742,7 @@ function PartyMenu:bottomMessage() elseif self.softboiledFrom or self.itemUse then return self.game.data.text._PartyMenuItemUseText or Strings("Use item on which\nPOKéMON?") - elseif self.battle then + elseif self.forceSwitch then return self.game.data.text._PartyMenuBattleText or Strings("Bring out which\nPOKéMON?") else diff --git a/src/ui/PokedexMenu.lua b/src/ui/PokedexMenu.lua index 821eb49e..e59f5769 100644 --- a/src/ui/PokedexMenu.lua +++ b/src/ui/PokedexMenu.lua @@ -4,6 +4,7 @@ local Font = require("src.render.Font") local Strings = require("src.core.Strings") local Theme = require("src.ui.Theme") +local MenuRepeat = require("src.ui.MenuRepeat") local PokedexMenu = {} PokedexMenu.__index = PokedexMenu @@ -46,6 +47,8 @@ function PokedexMenu.new(game, opts) self.onCancel = opts.onCancel -- B returns to the start menu when opened from it self.index = 1 self.scroll = 0 + -- engine/menus/pokedex.asm:13 + self.hold = MenuRepeat.new(MenuRepeat.GEN1_DELAY, MenuRepeat.GEN1_RATE) self.rowsAt = require("src.core.GameVersion").isYellow() and YELLOW_ROWS or RED_ROWS local dex = game.save.pokedex or { seen = {}, owned = {} } @@ -153,13 +156,14 @@ function PokedexMenu:update(dt) end return end - if input:wasPressed("up") then + local dir = MenuRepeat.direction(self.hold, input) + if dir == "up" then self.index = self.index - 1 - elseif input:wasPressed("down") then + elseif dir == "down" then self.index = self.index + 1 - elseif input:wasPressed("left") then + elseif dir == "left" then self:pageScroll(-1) - elseif input:wasPressed("right") then + elseif dir == "right" then self:pageScroll(1) elseif input:wasPressed("b") then beep(self) diff --git a/src/ui/PrizeCounter.lua b/src/ui/PrizeCounter.lua new file mode 100644 index 00000000..9ac6c78b --- /dev/null +++ b/src/ui/PrizeCounter.lua @@ -0,0 +1,66 @@ +-- engine/events/prize_menu.asm CeladonPrizeMenu + +local Font = require("src.render.Font") +local Strings = require("src.core.Strings") +local Theme = require("src.ui.Theme") + +local PrizeCounter = {} +PrizeCounter.__index = PrizeCounter + +local NAME_X, TOP_Y, ROW_STEP = 16, 32, 16 +local CURSOR_X = 8 +local FIELD_END = 136 +local ROWS = 4 + +function PrizeCounter.new(game, prizes, opts) + opts = opts or {} + return setmetatable({ + game = game, + prizes = prizes, + index = 1, + onPick = opts.onPick, + onCancel = opts.onCancel, + }, PrizeCounter) +end + +function PrizeCounter:update(dt) + local input = self.game.input + -- home/window.asm:56-83 + if input:wasPressed("up") then + if self.index > 1 then self.index = self.index - 1 end + elseif input:wasPressed("down") then + if self.index < ROWS then self.index = self.index + 1 end + elseif input:wasPressed("a") or input:wasPressed("b") then + require("src.core.Sound").play(self.game.data, "Press_AB") + if input:wasPressed("b") or self.index >= ROWS then + if self.onCancel then self.onCancel() end + elseif self.onPick then + self.onPick(self.prizes[self.index], self.index) + end + end +end + +function PrizeCounter:draw() + local coins = tostring((self.game.save and self.game.save.coins) or 0) + love.graphics.setColor(1, 1, 1, 1) + Font.drawBox(11, 0, 9, 3) + love.graphics.rectangle("fill", 12 * 8, 0, 4 * 8, 8) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(Strings("COIN"), 12 * 8, 0) + Font.draw(coins, FIELD_END - Font.width(coins), 8) + love.graphics.setColor(1, 1, 1, 1) + Font.drawBox(0, 2, 18, 10) + love.graphics.setColor(0, 0, 0, 1) + for i, p in ipairs(self.prizes) do + local y = TOP_Y + (i - 1) * ROW_STEP + Font.draw(p.name, NAME_X, y) + local cost = tostring(p.cost) + Font.draw(cost, FIELD_END - Font.width(cost), y + 8) + end + -- data/events/prizes.asm + Font.draw(Strings("NO THANKS"), NAME_X, TOP_Y + 3 * ROW_STEP) + Font.drawCode(Theme.cursor, CURSOR_X, TOP_Y + (self.index - 1) * ROW_STEP) + love.graphics.setColor(1, 1, 1, 1) +end + +return PrizeCounter diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua index 7d8b22d0..ccbb113b 100644 --- a/src/ui/ShopMenu.lua +++ b/src/ui/ShopMenu.lua @@ -45,22 +45,31 @@ local function buy(game, stock, menu) table.insert(items, { value = id, label = def.name, - right = ("¥%d"):format(def.price), + price = ("¥%d"):format(def.price), }) end end + items[#items + 1] = { cancel = true, label = Strings("CANCEL") } local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.") local notEnough = txt(game, "_PokemartNotEnoughMoneyText", Strings("You don't have\nenough money.")) local bagFull = txt(game, "_PokemartItemBagFullText", Strings("You can't carry\nany more items.")) local list - list = ListMenu.new(game, "BUY", items, { + list = ListMenu.new(game, nil, items, { dialogue = true, + -- home/list_menu.asm:29-31 + itemBox = true, money = function() return game.save.money end, footer = greet, onCancel = function() menu.footer = anythingElse(game) end, onChoose = function(item) + -- home/list_menu.asm:105-110, 523-528 + if item.cancel then + list:close() + menu.footer = anythingElse(game) + return + end local def = game.data.items[item.value] if game.save.money < def.price then list.footer = notEnough @@ -109,24 +118,32 @@ local function buy(game, stock, menu) game.stack:push(list) end +-- home/list_menu.asm:472-477 +local function sellItems(game) + local items = {} + for _, id in ipairs(Bag.order(game.save)) do + local def = game.data.items[id] + local keyed = (def and def.keyItem) or id:find("^HM_") ~= nil + table.insert(items, { + value = id, + label = def and def.name or id, + right = (not keyed) and ("x" .. game.save.inventory[id]) or nil, + }) + end + items[#items + 1] = { cancel = true, label = Strings("CANCEL") } + return items +end + local function sell(game, menu) -- Sell list is ITEMLISTMENU with wPrintItemPrices cleared -- (pokemart.asm .sellMenuLoop): name + quantity only. Price shows -- in the quantity chooser. Stuffing "xN" into the label next to a -- right-aligned ¥ price made long names overlap (issue #116). - local items = {} - for _, id in ipairs(Bag.order(game.save)) do - local def = game.data.items[id] - table.insert(items, { - value = id, - label = def and def.name or id, - right = "x" .. game.save.inventory[id], - }) - end + local items = sellItems(game) -- engine/events/pokemart.asm:50 local greet = txt(game, "_PokemonSellingGreetingText", Strings("What would you\nlike to sell?")) - if #items == 0 then + if #Bag.order(game.save) == 0 then refuse(game, menu, nil, txt(game, "_PokemartItemBagEmptyText", Strings("You don't have\nanything to sell."))) return @@ -134,13 +151,16 @@ local function sell(game, menu) local unsellable = txt(game, "_PokemartUnsellableItemText", Strings("I can't put a\nprice on that.")) local list - list = ListMenu.new(game, "SELL", items, { + list = ListMenu.new(game, nil, items, { dialogue = true, + -- home/list_menu.asm:29-31 + itemBox = true, money = function() return game.save.money end, footer = greet, onCancel = function() menu.footer = anythingElse(game) end, onSelectKey = function(item, l) - if not item then return end + -- swap_items.asm:19-22 + if not item or item.cancel then return end if not l.swapIndex then l.swapIndex = l.index return @@ -149,18 +169,15 @@ local function sell(game, menu) order[l.swapIndex], order[l.index] = order[l.index], order[l.swapIndex] l.swapIndex = nil require("src.core.Sound").play(game.data, "Swap") - local rebuilt = {} - for _, id in ipairs(order) do - local def = game.data.items[id] - rebuilt[#rebuilt + 1] = { - value = id, - label = def and def.name or id, - right = "x" .. game.save.inventory[id], - } - end - l.items = rebuilt + l.items = sellItems(game) end, onChoose = function(item) + -- home/list_menu.asm:105-110, 523-528 + if item.cancel then + list:close() + menu.footer = anythingElse(game) + return + end local def = game.data.items[item.value] -- only key items and HMs are unsellable (pokemart.asm IsKeyItem / -- IsItemHM); zero-price items like ETHER sell for ¥0. An unknown id @@ -214,7 +231,10 @@ local function drawClerk(menu) local game = menu.game love.graphics.setColor(1, 1, 1, 1) Font.drawBox(11, 0, 9, 3) + -- data/text_boxes.asm:35 + love.graphics.rectangle("fill", 13 * 8, 0, 5 * 8, 8) love.graphics.setColor(0, 0, 0, 1) + Font.draw(Strings("MONEY"), 13 * 8, 0) local money = ("¥%d"):format((game.save and game.save.money) or 0) Font.draw(money, 152 - Font.width(money), 8) love.graphics.setColor(1, 1, 1, 1) @@ -247,7 +267,8 @@ function ShopMenu.new(game, stock, onQuit) { label = Strings("BUY"), keepOpen = true, onSelect = function() buy(game, stock, menu) end }, { label = Strings("SELL"), keepOpen = true, onSelect = function() sell(game, menu) end }, { label = Strings("QUIT"), onSelect = farewell }, - }, { tx = 0, ty = 0, tw = 8, th = 8 }) + -- data/text_boxes.asm:34 + }, { tx = 0, ty = 0, tw = 11, th = 7 }) menu.onCancel = farewell menu.footer = txt(game, "_PokemartGreetingText", Strings("Hi there!\nMay I help you?")) diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index a0a57460..ca4a3f00 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -18,11 +18,15 @@ local GameVersion = require("src.core.GameVersion") local PaletteFX = require("src.render.PaletteFX") local Sound = require("src.core.Sound") local SpriteRenderer = require("src.render.SpriteRenderer") +local Theme = require("src.ui.Theme") local TownMap = {} TownMap.__index = TownMap TownMap.isOpaque = true +-- engine/items/town_map.asm:183 +local ARROW_DELAY = 15 + -- SGB: PalPacket_TownMap, whole screen function TownMap:sgbPalettes(game) return require("src.render.PaletteFX").wholeNamed(game.data, "TOWNMAP") @@ -47,12 +51,27 @@ local function isRoute(loc) return loc.name:find("ROUTE", 1, true) ~= nil end +-- data/maps/town_map_order.asm:1 +local function orderByCursorOrder(byMap, order) + if type(order) ~= "table" then return nil end + local out, seen = {}, {} + for _, mapId in ipairs(order) do + local loc = byMap[mapId] + if loc and not seen[loc] then + seen[loc] = true + out[#out + 1] = loc + end + end + return #out >= 2 and out or nil +end + -- Build the ordered location list. Grid mode dedupes shared entries -- (interior maps point at their town's square); list mode falls back to -- the fly towns so the screen still works without townMap data. local function buildLocations(game) local field = game.data.field or {} local townMap = field.townMap + local cursorOrder = type(townMap) == "table" and townMap.cursorOrder or nil -- the extractor nests the per-map entries under .locations if type(townMap) == "table" and type(townMap.locations) == "table" then townMap = townMap.locations @@ -80,7 +99,7 @@ local function buildLocations(game) if a.x ~= b.x then return a.x < b.x end return a.name < b.name end) - return locs, byMap, "grid" + return orderByCursorOrder(byMap, cursorOrder) or locs, byMap, "grid", locs end end -- fallback: towns from the fly order (deduped, outdoor maps only) @@ -193,7 +212,7 @@ function TownMap.new(game, opts) local self = setmetatable({}, TownMap) self.game = game self.bg = loadBackground(game) - self.locs, self.byMap, self.mode = buildLocations(game) + self.locs, self.byMap, self.mode, self.allLocs = buildLocations(game) if opts.nestSpecies then self.nestSpecies = opts.nestSpecies self.nests = {} @@ -207,7 +226,8 @@ function TownMap.new(game, opts) if found then break end end local loc = found and self.byMap[mapId] - if loc and not seen[loc] then + -- engine/items/town_map.asm:388 + if loc and not seen[loc] and not (loc.x == 9 and loc.y == 1) then seen[loc] = true table.insert(self.nests, loc) end @@ -255,41 +275,34 @@ function TownMap.new(game, opts) self.birdSheet, self.birdQuad = markerSheet(sprites[playerSprites.fly or "SPRITE_BIRD"] or sprites.SPRITE_BIRD, "bird") + -- engine/items/town_map.asm:150 + local art = ((game.data.field or {}).townMap or {}).upArrow + local okArrow, arrow = pcall(love.graphics.newImage, + (art and art.path) + or "assets/generated/townmap/up_arrow.png") + self.upArrow = okArrow and arrow or nil + -- engine/items/town_map.asm:170, 183 + self.arrowHide, self.arrowDelay = "up", ARROW_DELAY end self.sel = 1 -- LoadTownMap_Fly always opens with hl on wFlyLocationsList[0], the FIRST -- fly destination (PALLET_TOWN), never the player's current town (#795). -- Only the plain viewer snaps the cursor to where the player stands. if not self.fly then + local found = false for i, loc in ipairs(self.locs) do - if loc == self.playerLoc then self.sel = i break end + if loc == self.playerLoc then self.sel = i found = true break end + end + -- engine/items/town_map.asm:29 + if self.playerLoc and not found then + table.insert(self.locs, self.playerLoc) + self.sel = #self.locs end end self.blink = 0 return self end --- snap the cursor to the nearest location in the pressed direction -function TownMap:moveGrid(dx, dy) - local cur = self.locs[self.sel] - local best, bestScore - for i, loc in ipairs(self.locs) do - if i ~= self.sel then - local ddx, ddy = loc.x - cur.x, loc.y - cur.y - local fwd = ddx * dx + ddy * dy -- progress along the d-pad axis - local side = math.abs(ddx * dy) + math.abs(ddy * dx) - if fwd > 0 then - local score = fwd + side * 3 -- prefer staying on-axis - if not best or score < bestScore then best, bestScore = i, score end - end - end - end - if best then - self.sel = best - Sound.play(self.game.data, "Tink") - end -end - function TownMap:moveList(step) local n = #self.locs if n < 2 then return end @@ -300,6 +313,10 @@ end function TownMap:update(dt) local cycle = GameVersion.generation() == 2 and 32 or 50 self.blink = (self.blink + 1) % cycle + if self.arrowDelay and self.arrowDelay > 0 then + self.arrowDelay = self.arrowDelay - 1 + if self.arrowDelay == 0 then self.arrowHide = nil end + end local input = self.game.input if input:wasPressed("b") then Sound.play(self.game.data, "Press_AB") @@ -318,8 +335,12 @@ function TownMap:update(dt) self.game.stack:pop() if mapId and self.onFly then self.onFly(mapId) end return - elseif input:wasPressed("up") then self:moveList(1) - elseif input:wasPressed("down") then self:moveList(-1) + elseif input:wasPressed("up") then + self:moveList(1) + self.arrowHide, self.arrowDelay = "up", ARROW_DELAY + elseif input:wasPressed("down") then + self:moveList(-1) + self.arrowHide, self.arrowDelay = "down", ARROW_DELAY end elseif self.nestSpecies then if input:wasPressed("a") then @@ -327,10 +348,9 @@ function TownMap:update(dt) self.game.stack:pop() end elseif self.mode == "grid" then - if input:wasPressed("up") then self:moveGrid(0, -1) - elseif input:wasPressed("down") then self:moveGrid(0, 1) - elseif input:wasPressed("left") then self:moveGrid(-1, 0) - elseif input:wasPressed("right") then self:moveGrid(1, 0) + -- engine/items/town_map.asm:74 + if input:wasPressed("up") then self:moveList(1) + elseif input:wasPressed("down") then self:moveList(-1) end else if input:wasPressed("up") then self:moveList(-1) @@ -345,6 +365,22 @@ function TownMap:markPlayerRedraw(x, y) PaletteFX.markUiSpriteRedraw(self.playerSheet, self.playerQuad, x, y) end +-- engine/items/town_map.asm:170, 185 +function TownMap:drawFlyArrows() + if self.arrowHide ~= "up" then + if self.upArrow then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(self.upArrow, 144, 0) + love.graphics.setColor(0, 0, 0, 1) + else + love.graphics.polygon("fill", 148, 1, 152, 7, 144, 7) + end + end + if self.arrowHide ~= "down" then + Font.drawCode(Theme.moreArrow, 152, 0) + end +end + local function drawSquare(loc) if isRoute(loc) then love.graphics.setColor(0.62, 0.62, 0.62, 1) -- routes lighter @@ -385,12 +421,26 @@ function TownMap:draw() end end end + if #self.nests > 0 then + -- engine/items/town_map.asm:399 + if self.playerLoc and self.playerSheet then + local x, y = markerXY(self.playerLoc) + love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3) + self:markPlayerRedraw(x - 4, y - 3) + end + else + -- engine/items/town_map.asm:403 + Font.drawBox(1, 7, 17, 4) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(" AREA UNKNOWN", 16, 72) + love.graphics.setColor(1, 1, 1, 1) + end love.graphics.rectangle("fill", 0, 0, 160, 8) love.graphics.setColor(0, 0, 0, 1) local def = self.game.data.pokemon[self.nestSpecies] local name = def and def.name or self.nestSpecies - Font.draw(#self.nests > 0 and (name .. "'s NEST") - or (name .. " AREA UNKNOWN"), 8, 0) + -- engine/items/town_map.asm:124 + Font.draw(name .. "'s NEST", 8, 0) love.graphics.setColor(1, 1, 1, 1) return end @@ -437,7 +487,14 @@ function TownMap:draw() -- the name strip on row 0 (DisplayTownMap: ClearScreenArea + name) love.graphics.rectangle("fill", 0, 0, 160, 8) love.graphics.setColor(0, 0, 0, 1) - if selected then Font.draw(self:bannerText(selected), 8, 0) end + if self.fly then + -- engine/items/town_map.asm:167, 176, 185 + Font.draw("To", 0, 0) + if selected then Font.draw(selected.name, 24, 0) end + self:drawFlyArrows() + elseif selected then + Font.draw(self:bannerText(selected), 8, 0) + end love.graphics.setColor(1, 1, 1, 1) return end @@ -446,7 +503,7 @@ function TownMap:draw() Font.drawBox(0, 0, 20, 18) if self.mode == "grid" then -- stale assets (no background art): the old abstract squares - for _, loc in ipairs(self.locs) do + for _, loc in ipairs(self.allLocs or self.locs) do drawSquare(loc) end -- player marker is static in both Gen 1 and 2 diff --git a/src/ui/gen2/BattleAnimView.lua b/src/ui/gen2/BattleAnimView.lua index 7a34938b..53ddf7ec 100644 --- a/src/ui/gen2/BattleAnimView.lua +++ b/src/ui/gen2/BattleAnimView.lua @@ -172,7 +172,7 @@ end -- One reusable quad, re-aimed per scanline. A row shifted by `dx` is drawn -- CLIPPED to the 160-pixel screen rather than allowed to hang over the edge: -- the cart's BG map wraps, so a scrolled scanline never spills past the LCD. -function BattleAnimView:blitRow(row, dx, dy) +function BattleAnimView:blitRowAt(srcRow, destRow, dx) local canvas = self.canvas if not self.blitQuad then self.blitQuad = love.graphics.newQuad(0, 0, SCREEN_W, 1, SCREEN_W, SCREEN_H) @@ -184,8 +184,12 @@ function BattleAnimView:blitRow(row, dx, dy) srcX, width, destX = -dx, SCREEN_W + dx, 0 end if width <= 0 then return end - self.blitQuad:setViewport(srcX, row, width, 1, SCREEN_W, SCREEN_H) - love.graphics.draw(canvas, self.blitQuad, destX, row + dy) + self.blitQuad:setViewport(srcX, srcRow, width, 1, SCREEN_W, SCREEN_H) + love.graphics.draw(canvas, self.blitQuad, destX, destRow) +end + +function BattleAnimView:blitRow(row, dx, dy) + self:blitRowAt(row, row + dy, dx) end -- Draw the battle panel into the blit canvas, optionally with an rBGP byte @@ -207,7 +211,10 @@ function BattleAnimView:bake(drawBg, palByte) end local previousCanvas = G.getCanvas() local previousBgp = GbcPalette.setBgp(palByte) + local sx, sy, sw, sh + if G.getScissor then sx, sy, sw, sh = G.getScissor() end G.setCanvas(self.canvas) + G.setScissor() G.clear(0, 0, 0, 0) -- A love canvas does NOT reset the transform: without this the panel is -- drawn at whatever scale and offset the caller was already under, and then @@ -217,6 +224,7 @@ function BattleAnimView:bake(drawBg, palByte) local ok, err = pcall(drawBg) G.pop() G.setCanvas(previousCanvas) + if sx then G.setScissor(sx, sy, sw, sh) end GbcPalette.setBgp(previousBgp) if not ok then error(err, 0) end end @@ -286,6 +294,26 @@ function BattleAnimView:panelPalettes(battle) return list end +-- engine/battle_anims/bg_effects.asm:2638 +function BattleAnimView.scanlines(bg) + local lines = {} + local scy = signed(bg.scy) + for row = 0, SCREEN_H - 1 do + local dx, src = 0, row + scy + local inWindow = bg.lcdc and bg.lcdc ~= "BGP" + and row >= bg.lyStart and row < bg.lyEnd + local byte = bg.lyBackup[row] or 0 + if inWindow then + local value = signed(byte) + if bg.lcdc == "SCX" then dx = -value else src = src + value end + end + if (not inWindow or byte ~= 0x90) and src >= 0 and src < SCREEN_H then + lines[#lines + 1] = { src = src, dest = row, dx = dx } + end + end + return lines +end + -- Runs `drawBg` (the battle panel) and puts it on screen through the -- animation's BG registers; skips the canvas when nothing needs one. function BattleAnimView:present(runner, drawBg, battle) @@ -332,20 +360,9 @@ function BattleAnimView:present(runner, drawBg, battle) G.setColor(1, 1, 1, 1) -- hSCX / hSCY move the whole background; the per-scanline overrides only -- apply inside the effect's own window. - local baseX, baseY = -signed(bg.scx), -signed(bg.scy) - for row = 0, SCREEN_H - 1 do - local dx, dy = baseX, baseY - local inWindow = bg.lcdc and row >= bg.lyStart and row < bg.lyEnd - if inWindow and bg.lcdc ~= "BGP" then - local value = signed(bg.lyBackup[row] or 0) - if bg.lcdc == "SCX" then dx = -value else dy = -value end - end - -- A row scrolled to $90 is showing a blank part of the map: skip it, which - -- is what makes Withdraw and Dig look like the mon sinking out of sight. - if (bg.lyBackup[row] or 0) ~= 0x90 or not bg.lcdc or bg.lcdc == "BGP" - or not inWindow then - self:blitRow(row, dx, dy) - end + local baseX = -signed(bg.scx) + for _, line in ipairs(BattleAnimView.scanlines(bg)) do + self:blitRowAt(line.src, line.dest, baseX + line.dx) end if remapped then GbcPalette.clear() end -- Shaderless boot: the panel is raw grayscale, so there are no palettes to diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index cdd1fcf2..3fa93a62 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -206,7 +206,33 @@ local function paginate(text) return pages end -function BattleState:wantsFillScale() return true end +function BattleState.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 / (Chrome.SCREEN_W * 8), + h / (Chrome.SCREEN_H * 8))) +end + +function BattleState.panelScale(winW, winH, fill) + if not fill then return Chrome.fitScale(winW, winH) end + return BattleState.fillScale(winW, winH) +end + +function BattleState:wantsFillScale() + local options = self.game and self.game.options + return (options and options.battleFit) == "fill" +end + +function BattleState:battlePanelScale(winW, winH) + return BattleState.panelScale(winW, winH, self:wantsFillScale()) +end + function BattleState:drawsWidescreen() return true end -- BATTLE BG (#1709): WHITE is the cart's paper surround, BLACK plain bars. @@ -2861,6 +2887,26 @@ function BattleState:hasPokedex() or save.pokedexReceived == true end +-- caught_data.asm:168-199 +function BattleState:stampCaughtData(mon, bugContest) + local save = self.save + local world = self.game and self.game.world + local map = world and world.map + local battle = self.battle + Catching.stampCaughtData(mon, { + version = save and save.version, + save = save, + data = self.game and self.game.data, + bugContest = bugContest, + timeOfDay = (battle and battle.timeOfDay) + or (world and world.timeOfDayId and world:timeOfDayId()), + map = map and map.def, + backupMap = world and world.backupMapId and world.maps + and world.maps[world.backupMapId], + playerGender = save and save.player and save.player.gender, + }) +end + -- PokeBallEffect's caught tail, in the cart's order (item_effects.asm:514-676): -- Text_GotchaMonWasCaught, CheckCaughtMon / SetSeenAndCaughtMon, the new-entry -- line and NewPokedexEntry, the party add or .SendToPC, then @@ -2912,6 +2958,8 @@ function BattleState:pushCaught(enemy, itemId) return self:pushPayDay() end save.party = save.party or {} + -- item_effects.asm:556-558, :612-614 + self:stampCaughtData(enemy) local toPc = #save.party >= Boxes.PARTY_SIZE if toPc then -- `.SendToPC` / `predef SendMonIntoBox` (item_effects.asm:548-550, 604): @@ -3190,6 +3238,8 @@ end -- already in stock the player is shown the comparison and asked, and the NO arm -- -- which is also what B does -- keeps the mon they already had. function BattleState:contestCatch(mon) + -- engine/pokemon/caught_data.asm:72-81 + self:stampCaughtData(mon, true) local kind, stock, fresh = BugContest.catch(self.save, mon) if kind ~= BugContest.ASK_SWITCH then self:push({ kind = "message", text = "Caught " .. self:name(mon) .. "!" }) @@ -3936,7 +3986,10 @@ function BattleState:drawLiftedRows() self.liftCanvas:setFilter("nearest", "nearest") end local previous = G.getCanvas() + local sx, sy, sw, sh + if G.getScissor then sx, sy, sw, sh = G.getScissor() end G.setCanvas(self.liftCanvas) + G.setScissor() G.clear(0, 0, 0, 0) G.push() G.origin() @@ -3946,6 +3999,7 @@ function BattleState:drawLiftedRows() self.liftedPass = nil G.pop() G.setCanvas(previous) + if sx then G.setScissor(sx, sy, sw, sh) end G.setColor(1, 1, 1, 1) G.draw(self.liftCanvas, 0, 0) end @@ -3979,19 +4033,12 @@ function BattleState:drawSceneBody() end function BattleState:draw() - self:drawScene() + Chrome.withClip(function() self:drawScene() end) end function BattleState: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:drawScene() - G.pop() + Chrome.withPanel(winW, winH, 1, 1, 1, function() self:drawScene() end, + self:battlePanelScale(winW, winH)) end BattleState.MENU = MENU diff --git a/src/ui/gen2/Chrome.lua b/src/ui/gen2/Chrome.lua index 58141124..419aa8e5 100644 --- a/src/ui/gen2/Chrome.lua +++ b/src/ui/gen2/Chrome.lua @@ -103,6 +103,34 @@ function Chrome.positionLift(winW, winH, scale) or Chrome.fitScale(winW, winH)), ScreenPosition.safeTop()) end +-- pokegold engine/battle/core.asm:8646, engine/events/halloffame.asm:270 +local function clipTo(x, y, w, h) + local G = love.graphics + if G.intersectScissor then G.intersectScissor(x, y, w, h) + else G.setScissor(x, y, w, h) end +end + +function Chrome.withPanel(winW, winH, r, g, b, drawFn, scale) + local G = love.graphics + Chrome.letterbox(winW, winH, r, g, b) + scale = scale or Chrome.fitScale(winW, winH) + local ox, oy = Chrome.fitOrigin(winW, winH, scale) + G.push("all") + clipTo(ox, oy, Chrome.SCREEN_W * 8 * scale, Chrome.SCREEN_H * 8 * scale) + G.translate(ox, oy) + G.scale(scale, scale) + drawFn() + G.pop() +end + +function Chrome.withClip(drawFn) + local G = love.graphics + G.push("all") + clipTo(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) + drawFn() + G.pop() +end + Chrome.DEFAULT_BOX_PALETTE = { { 255, 255, 255 }, { 255, 255, 255 }, { 255, 255, 255 }, { 0, 0, 0 }, } diff --git a/src/ui/gen2/Credits.lua b/src/ui/gen2/Credits.lua index 11405ca9..13dc2ad6 100644 --- a/src/ui/gen2/Credits.lua +++ b/src/ui/gen2/Credits.lua @@ -836,18 +836,11 @@ function Credits:drawPanel() end function Credits:draw() - self:drawPanel() + Chrome.withClip(function() self:drawPanel() end) end function Credits:drawWidescreen(winW, winH) - local G = love.graphics - Chrome.letterbox(winW, winH, 0, 0, 0) - local scale = Chrome.fitScale(winW, winH) - G.push() - G.translate(Chrome.fitOrigin(winW, winH, scale)) - G.scale(scale, scale) - self:drawPanel() - G.pop() + Chrome.withPanel(winW, winH, 0, 0, 0, function() self:drawPanel() end) end Credits.TILES_W = TILES_W diff --git a/src/ui/gen2/HallOfFame.lua b/src/ui/gen2/HallOfFame.lua index 761e9dea..fa6ae2fe 100644 --- a/src/ui/gen2/HallOfFame.lua +++ b/src/ui/gen2/HallOfFame.lua @@ -663,18 +663,11 @@ function HallOfFame:drawPanel() end function HallOfFame:draw() - self:drawPanel() + Chrome.withClip(function() self:drawPanel() end) end function HallOfFame:drawWidescreen(winW, winH) - local G = love.graphics - Chrome.letterbox(winW, winH, 0, 0, 0) - local scale = Chrome.fitScale(winW, winH) - G.push() - G.translate(Chrome.fitOrigin(winW, winH, scale)) - G.scale(scale, scale) - self:drawPanel() - G.pop() + Chrome.withPanel(winW, winH, 0, 0, 0, function() self:drawPanel() end) end HallOfFame.WHITE = WHITE diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index c2a9b269..ed9b2d7f 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -336,6 +336,9 @@ local ROWS = { text = function(options) return require("src.core.FrameCap").label(options.fpsCap) end }, + { label = Strings.source("BATTLE SIZE"), key = "battleFit", port = true, + values = { "fixed", "fill" }, + display = { fixed = "FIXED", fill = "FILL " } }, -- BATTLE BG (#1709): the void around the battle screen. Gold has no WIDE -- layout and no WORLD backdrop, so the ladder is the WHITE/BLACK pair only. { label = Strings.source("BATTLE BG"), key = "battleBg", port = true, @@ -364,7 +367,7 @@ 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", "battleBg" } }, + members = { "battleScene", "battleStyle", "battleFit", "battleBg" } }, { id = "group.extras", label = Strings.source("EXTRAS"), members = { "zoom", "voidFill", "tilt" } }, } diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index 4c17968c..62e442b2 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -15,11 +15,15 @@ local Chrome = require("src.ui.gen2.Chrome") local Gen2Save = require("src.core.gen2.Save") local PackGfx = require("src.ui.gen2.PackGfx") local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") +local MenuRepeat = require("src.ui.MenuRepeat") -- constants/sfx_constants.asm:3, :28 local SFX_DEX_FANFARE_50_79, SFX_WRONG = 0, 25 +local LIST_DIRS = { "up", "down" } + local PackMenu = {} PackMenu.__index = PackMenu PackMenu.isOpaque = true @@ -161,6 +165,8 @@ function PackMenu.new(game, opts) self.give = opts.give and true or false self.battle = opts.battle and true or false self.cursorStore = cursorStore(game) + -- engine/menus/scrolling_menu.asm:6 + self.hold = MenuRepeat.new(MenuRepeat.GEN2_DELAY, MenuRepeat.GEN2_RATE) self.pocketIndex = 1 -- wLastPocket, unless the caller names one: DepositSellInitPackBuffers writes -- ITEM_POCKET over it, so an explicit pocket still wins. @@ -301,6 +307,13 @@ function PackMenu:isCancel() return self.index > #self.rows end +-- home/menu.asm:746, :758 +function PackMenu:playSfx(name) + local data = self.game and self.game.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end +end + function PackMenu:ensureVisible() if self.index <= self.scroll then self.scroll = self.index - 1 @@ -319,6 +332,8 @@ function PackMenu:switchPocket(delta) self:restoreCursor() self:rebuild() self:storeCursor() + -- engine/items/pack.asm:1268 + self:playSfx("Sfx_SwitchPockets") end -- The player name OakThisIsntTheTimeText addresses, same fallback the SAVE @@ -759,25 +774,26 @@ function PackMenu:update(_dt) self:updateSubmenu(input) return end + local dir, edge = MenuRepeat.direction(self.hold, input, LIST_DIRS) if input:wasPressed("left") then self:switchPocket(-1) return elseif input:wasPressed("right") then self:switchPocket(1) return - elseif input:wasPressed("up") then - self.index = self.index > 1 and self.index - 1 or self:total() - self:ensureVisible() + elseif dir == "up" then + self:stepCursor(-1, edge) return - elseif input:wasPressed("down") then - self.index = self.index < self:total() and self.index + 1 or 1 - self:ensureVisible() + elseif dir == "down" then + self:stepCursor(1, edge) return elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") self:storeCursor() if self.onClose then self.onClose() end return elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") if self:isCancel() then self:storeCursor() if self.onClose then self.onClose() end @@ -795,6 +811,19 @@ function PackMenu:update(_dt) end end +-- engine/menus/scrolling_menu.asm +function PackMenu:stepCursor(delta, edge) + local total = self:total() + local next = self.index + delta + if next < 1 then + next = edge and total or 1 + elseif next > total then + next = edge and 1 or total + end + self.index = next + self:ensureVisible() +end + -- engine/items/pack.asm:1290 Pack_InterpretJoypad .select -- engine/items/tmhm.asm:207 -- the TM/HM pocket's joypad filter drops SELECT. function PackMenu:armSwitch() @@ -808,12 +837,11 @@ end -- `.switching_item` (engine/items/pack.asm:1297): A or SELECT places, B backs -- out, and left/right cannot leave the pocket mid-move. function PackMenu:updateSwitch(input) - if input:wasPressed("up") then - self.index = self.index > 1 and self.index - 1 or self:total() - self:ensureVisible() - elseif input:wasPressed("down") then - self.index = self.index < self:total() and self.index + 1 or 1 - self:ensureVisible() + local dir, edge = MenuRepeat.direction(self.hold, input, LIST_DIRS) + if dir == "up" then + self:stepCursor(-1, edge) + elseif dir == "down" then + self:stepCursor(1, edge) elseif input:wasPressed("a") or input:wasPressed("select") then self:placeSwitch() elseif input:wasPressed("b") then @@ -830,6 +858,8 @@ function PackMenu:placeSwitch() self:rebuild() self:storeCursor() end + -- engine/items/pack.asm:1309 + self:playSfx("Sfx_SwitchPokemon") self:endSwitch() end @@ -848,8 +878,10 @@ function PackMenu:updateSubmenu(input) elseif input:wasPressed("down") then menu.index = menu.index < total and menu.index + 1 or 1 elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") self:chooseSubmenu() elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") self:closeSubmenu() end end @@ -867,9 +899,11 @@ function PackMenu:updateQuantity(input) elseif input:wasPressed("left") then state.qty = qtyStep(state.qty, state.max, -10) elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") self.message = nil self:confirmToss() elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") self.qtyState = nil self.message = nil end @@ -881,9 +915,11 @@ function PackMenu:updateConfirm(input) if input:wasPressed("up") or input:wasPressed("down") then confirm.choice = confirm.choice == 1 and 2 or 1 elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") self.confirm = nil if confirm.onNo then confirm.onNo() end elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") local yes = confirm.choice == 1 self.confirm = nil if yes then @@ -908,6 +944,8 @@ function PackMenu:registerSelected() local world = not self:inBattle() and self.world or nil local ok = world and world.registerItem and world:registerItem(row.id) if ok then + -- engine/items/pack.asm:551 + self:playSfx("Sfx_FullHeal") -- RegisteredItemText: "Registered the\n." self.message = { Strings("Registered the"), row.name .. "." } else diff --git a/src/ui/gen2/PokedexMenu.lua b/src/ui/gen2/PokedexMenu.lua index 9a1b3b70..17d6483a 100644 --- a/src/ui/gen2/PokedexMenu.lua +++ b/src/ui/gen2/PokedexMenu.lua @@ -32,12 +32,14 @@ local Assets = require("src.render.Assets") local Chrome = require("src.ui.gen2.Chrome") local GbcPalette = require("src.render.GbcPalette") +local HallOfFame = require("src.core.gen2.HallOfFame") local Palettes = require("src.world.gen2.Palettes") local TileSheet = require("src.ui.gen2.TileSheet") local Nests = require("src.core.gen2.Nests") local Sound = require("src.core.Sound") local Unown = require("src.core.gen2.Unown") local Strings = require("src.core.Strings") +local MenuRepeat = require("src.ui.MenuRepeat") -- `db $3b, " OPTION ", $3c` / `db $3b, " SEARCH ", $3c"`: the panel titles -- drawn by drawOption/drawSearch below, declared here (rather than inline) @@ -45,6 +47,8 @@ local Strings = require("src.core.Strings") local OPTION_LABEL = Strings.source(" OPTION ") local SEARCH_LABEL = Strings.source(" SEARCH ") +local LIST_DIRS = { "up", "down" } + local PokedexMenu = {} PokedexMenu.__index = PokedexMenu PokedexMenu.isOpaque = true @@ -164,6 +168,8 @@ function PokedexMenu.new(game, opts) end self.index = 1 self.scroll = 0 + -- engine/pokedex/pokedex.asm:36-39 + self.hold = MenuRepeat.new(MenuRepeat.GEN2_DELAY, MenuRepeat.GEN2_RATE) self.view = "list" -- list | entry | area | option | search | results | unown self.page = 1 self.entryAction = 1 @@ -384,6 +390,7 @@ function PokedexMenu:update(_dt) if self.view == "option" then return self:updateOption(input) end if self.view == "search" then return self:updateSearch(input) end if self.view == "unown" then return self:updateUnown(input) end + local dir, edge = MenuRepeat.direction(self.hold, input, LIST_DIRS) if input:wasPressed("b") then self:close() return @@ -399,12 +406,21 @@ function PokedexMenu:update(_dt) self.searchType = self.searchType or { 1, 0 } self.searchResults = nil return - elseif input:wasPressed("up") then - self.index = self.index > 1 and self.index - 1 or #self.rows + elseif dir == "up" then + -- pokedex.asm:982-1011 + if self.index > 1 then + self.index = self.index - 1 + elseif edge then + self.index = #self.rows + end self:ensureVisible() return - elseif input:wasPressed("down") then - self.index = self.index < #self.rows and self.index + 1 or 1 + elseif dir == "down" then + if self.index < #self.rows then + self.index = self.index + 1 + elseif edge then + self.index = 1 + end self:ensureVisible() return elseif input:wasPressed("a") then @@ -807,22 +823,9 @@ end -- ------------------------------------------------------------------- AREA -- --- Pokedex_GetArea (engine/pokegear/pokegear.asm) borrows the Pokegear's town --- map and overlays FindNest's landmarks. The region shown starts as the one the --- player is standing in; LEFT/RIGHT swap it, which is how you see a Kanto mon's --- nests from Johto. B goes back to the entry. +-- engine/pokegear/pokegear.asm:2285, :2322 function PokedexMenu:areaRegionName() - if self.areaRegion then return self.areaRegion end - local landmark = self:playerLandmark() - return Nests.regionOf(landmark) or "johto" -end - --- The player's landmark index, which is what decides the starting region. -function PokedexMenu:playerLandmark() - local save = self.game and self.game.save - local mapId = save and save.position and save.position.map - local def = mapId and self.data and self.data.gen2Maps and self.data.gen2Maps[mapId] - return def and def.landmark + return self.areaRegion or "johto" end -- The Pokegear's own tilemap blit: a flat list of tile ids, row-major over the @@ -849,11 +852,70 @@ function PokedexMenu:updateArea(input) self.view = "entry" return end - if input:wasPressed("left") or input:wasPressed("right") then - self.areaRegion = (self:areaRegionName() == "johto") and "kanto" or "johto" + if input:wasPressed("left") then + self.areaRegion = "johto" + elseif input:wasPressed("right") then + -- pokegear.asm:2373 + if HallOfFame.hasEntered(self.game and self.game.save) then + self.areaRegion = "kanto" + end end end +-- engine/pokegear/pokegear.asm:2451 +function PokedexMenu:nestIconColors() + local set = self.palettes and Palettes.objectSet(self.palettes, "DAY") + return (set and set[1]) + or (self.mapGfx and self.mapGfx.palettes and self.mapGfx.palettes[1]) +end + +-- engine/pokegear/pokegear.asm:2298 +function PokedexMenu:drawNestIcon(x, y) + if self.nestIcon == nil then + self.nestIcon = false + local path = self.mapGfx and self.mapGfx.nestIcon + if path then + local ok, image = pcall(Assets.image, path) + if ok and image then self.nestIcon = image end + end + end + local G = love.graphics + G.setColor(1, 1, 1, 1) + if not self.nestIcon then + local ink = self:nestIconColors() + local dark = ink and GbcPalette.color(ink, 4) or { 0, 0, 0 } + G.setColor(dark[1] / 255, dark[2] / 255, dark[3] / 255, 1) + G.rectangle("fill", x + 1, y + 1, 6, 6) + G.setColor(1, 1, 1, 1) + return + end + local function body() G.draw(self.nestIcon, x, y) end + local colors = self:nestIconColors() + if colors and GbcPalette.available() then + GbcPalette.withRaw(colors, body) + else + body() + end +end + +-- engine/pokegear/pokegear.asm:2403 +function PokedexMenu:drawAreaHeader(title) + local pals = self.mapGfx and self.mapGfx.palettes + local pal = pals and pals[1] + local paper = pal and GbcPalette.color(pal, 1) or { 255, 255, 255 } + local G = love.graphics + G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, 8) + G.setColor(1, 1, 1, 1) + local sheet = self.mapSheet + if sheet then + sheet:draw(0x06, 0, 1) + for x = 1, Chrome.SCREEN_W - 2 do sheet:draw(0x07, x, 1) end + sheet:draw(0x17, Chrome.SCREEN_W - 1, 1) + end + Chrome.printThrough(title, 2, 0, pal, false, true) +end + function PokedexMenu:drawArea() local row = self:current() if not row then return end @@ -861,54 +923,22 @@ function PokedexMenu:drawArea() local save = self.game and self.game.save local nests = Nests.find(self.data, row.species, region, save) - self:fill(TILE_BG, 0, 0, Chrome.SCREEN_W + 1, Chrome.SCREEN_H) - - -- The map itself is the Pokegear's, drawn through the same gfx the MAP card - -- uses. Without it (a cache imported before the town map was extracted) the - -- page still lists the landmark NAMES, which is the information the screen - -- exists to convey. local maps = self.mapGfx and self.mapGfx.maps local cells = maps and maps[region] if cells then self:drawTilemap(cells) end - self:blank(0, 0, Chrome.SCREEN_W, 2) - self:text(self:monName(row.species) .. "'S NEST", 1, 0) - self:text(region == "kanto" and "KANTO" or "JOHTO", 1, 1) + self:drawAreaHeader(self:monName(row.species) .. "'S NEST") - local G = love.graphics - - if #nests == 0 then - -- A species with no grass, water or roamer entry in this region. The cart - -- simply shows the map with nothing blinking on it. - self:text("AREA UNKNOWN", 4, 16) - return - end - - -- engine/pokegear/pokegear.asm:2427 - local on = ((self.areaBlink or 0) % 32) < 20 - if cells and on then - for _, index in ipairs(nests) do - local mark = Nests.landmark(self.data, index) - if mark and mark.x and mark.y then - G.setColor(0, 0, 0, 1) - G.rectangle("fill", mark.x - 2, mark.y - 2, 5, 5) - G.setColor(1, 1, 1, 1) - G.rectangle("fill", mark.x - 1, mark.y - 1, 3, 3) - end - end - end - - -- Name the first one in words as well as on the map: the flashing dot is - -- unreadable at this size on a modern display, and the landmark name is what - -- a player actually wants off this screen. - local first = Nests.landmark(self.data, nests[1]) - if first and first.name then - local name = tostring(first.name):gsub("\n", " ") - self:text(name, 1, 16) - if #nests > 1 then - self:text(("+%d"):format(#nests - 1), 17, 16) + -- engine/pokegear/pokegear.asm:2385 + local on = ((self.areaBlink or 0) % 32) < 16 + if not on then return end + for _, index in ipairs(nests) do + local mark = Nests.landmark(self.data, index) + if mark and mark.x and mark.y then + -- engine/pokegear/pokegear.asm:2444 + self:drawNestIcon(mark.x - 4, mark.y - 4) end end end diff --git a/src/ui/gen2/Pokegear.lua b/src/ui/gen2/Pokegear.lua index e5ac4eca..bdce385e 100644 --- a/src/ui/gen2/Pokegear.lua +++ b/src/ui/gen2/Pokegear.lua @@ -22,6 +22,7 @@ local Chrome = require("src.ui.gen2.Chrome") local FieldMoves = require("src.world.gen2.FieldMoves") +local FlagNames = require("src.core.gen2.FlagNames") local GbcPalette = require("src.render.GbcPalette") local Gen2Save = require("src.core.gen2.Save") local Clock = require("src.core.gen2.Clock") @@ -1004,6 +1005,11 @@ function Pokegear:text(str, tx, ty) return Chrome.printThrough(str, tx, ty, pals and pals[1], false, true) end +function Pokegear:cursor(tx, ty) + local pals = self:pals() + return Chrome.cursorThrough(tx, ty, pals and pals[1], false, false, true) +end + -- wPokegearFlags' four card bits are ENGINE flags: EngineFlags rows 0-3 are -- POKEGEAR_RADIO/MAP/PHONE/EXPN_CARD_F (pokegold data/events/engine_flags.asm, -- constants/engine_flags.asm const order), so the scripts' `setflag` -- the @@ -1014,6 +1020,14 @@ end -- a world. local CARD_ENGINE_FLAGS = { radio = 0, map = 1, phone = 2, expn = 3 } +-- ../pokecrystal/constants/engine_flags.asm:25 +function Pokegear:engineFlag(name, goldId) + local world = self.game and self.game.world + local id = goldId + if world and world.engineFlagId then id = world:engineFlagId(name, goldId) end + return ((self.save or {}).engineFlags or {})[id] == true +end + function Pokegear:flags() local save = self.save or {} local flags = {} @@ -1164,7 +1178,18 @@ function Pokegear:update(_dt) self.phoneSubmenu = nil return end + -- engine/pokegear/pokegear.asm:454 + if card and card.id == "clock" then + if input:wasPressed("right") then self:switchCard("map", "phone", "radio") end + return + end if card and card.id == "radio" then + -- engine/pokegear/pokegear.asm:740 + if input:wasPressed("left") then + self:stopRadio() + self:switchCard("phone", "map", "clock") + return + end self:ensureTuned() -- AnimateTuningKnob.TuningKnob: up winds the knob towards 80 and down -- back towards 0, and it stops dead at either end rather than wrapping. @@ -1220,7 +1245,8 @@ function Pokegear:radioContext() -- POKEGEAR_EXPN_CARD_F, the Kanto radio upgrade. expnCard = flags.expn or false, -- STATUSFLAGS_ROCKET_SIGNAL_F, set while Team Rocket holds Mahogany. - rocketSignal = (save.flags or {}).ROCKET_SIGNAL or false, + rocketSignal = self:engineFlag("ENGINE_ROCKET_SIGNAL_ON_CH20", + FlagNames.engine.ENGINE_ROCKET_SIGNAL_ON_CH20), } end @@ -1386,7 +1412,10 @@ function Pokegear:radioData() -- Number Man in Radio Tower has never rolled one, so 00000 is the honest -- reading, not a stand-in for unfinished work. out.luckyNumber = save.luckyNumber or 0 - out.rocketsInRadioTower = (save.flags or {}).ROCKETS_IN_RADIO_TOWER or false + -- ../pokegold/engine/events/std_scripts.asm:255 + out.rocketsInRadioTower = + self:engineFlag("ENGINE_ROCKETS_IN_RADIO_TOWER", + FlagNames.engine.ENGINE_ROCKETS_IN_RADIO_TOWER) self.radioDataCache = out return out end @@ -1949,8 +1978,8 @@ function Pokegear:drawClock() local hour, minute, weekday = self:clockParts() self:drawTilemap(self.gfx and self.gfx.cards and self.gfx.cards.clock) self:drawStrip() - self:text("SWITCH", 13, 1) - Chrome.cursor(19, 1) + self:text(" SWITCH", 12, 1) + self:cursor(19, 1) -- Pokegear_UpdateClock: ClearBox(3,5) 5x14, the day at (6,6) and -- PrintHoursMins at (6,8) -- two digits, ':', two more, then AM/PM at @@ -1981,7 +2010,7 @@ end function Pokegear:printBoxText(text) local lines = Chrome.wrap(text, 18) for i = 1, math.min(#lines, 2) do - Chrome.print(lines[i], 1, 14 + (i - 1) * 2) + self:text(lines[i], 1, 14 + (i - 1) * 2) end end @@ -2056,17 +2085,19 @@ end -- TownMapBubble: the plate the fly screen wears instead of the card strip. -- Three rows from (1,0) to (18,2), "Where?" at (2,0), the flypoint's landmark --- name at (2,1) and the up/down scroller at (18,1). The four rounded corners --- come from FlyMapLabelBorderGFX, a six-tile 1bpp set loaded over vTiles2 tile --- $30 for this screen only -- the extractor carries the town map's own $30-$33 --- instead, so the plate is drawn square rather than with the wrong art in its --- corners. +-- name at (2,1) and the up/down scroller at (18,1). function Pokegear:drawFlyBubble() - self:drawPlate(1, 0, 18, 3) + self:tile(0x30, 1, 0) + for x = 2, 17 do self:tile(SPACE_TILE, x, 0) end + self:tile(0x31, 18, 0) + for x = 1, 18 do self:tile(SPACE_TILE, x, 1) end + self:tile(0x32, 1, 2) + for x = 2, 17 do self:tile(SPACE_TILE, x, 2) end + self:tile(0x33, 18, 2) self:text("Where?", 2, 0) local row = self:flyRow() self:text(flatName(row and row.name), 2, 1) - Chrome.cursor(18, 1) + self:tile(0x34, 18, 1) end -- _TownMap.InitTilemap (../pokecrystal/engine/pokegear/pokegear.asm:1891-1918): @@ -2273,8 +2304,8 @@ function Pokegear:drawRadio() self:textbox(0, 12, 18, 4) local radio = self.radio if not (station and station.station and radio) then return end - if radio.top ~= "" then Chrome.print(radio.top, 1, 14) end - if radio.bottom ~= "" then Chrome.print(radio.bottom, 1, 16) end + if radio.top ~= "" then self:text(radio.top, 1, 14) end + if radio.bottom ~= "" then self:text(radio.bottom, 1, 16) end end function Pokegear:drawPhone() @@ -2294,8 +2325,9 @@ function Pokegear:drawPhone() -- A call in progress replaces the prompt with what the caller is saying; -- otherwise the box holds PokegearAskWhoCallText the whole time. if self.call then - Chrome.printWrapped(self.call.text or self:phoneText("GearEllipse"), - 1, 14, 18, 3) + local lines = Chrome.wrap(self.call.text + or self:phoneText("GearEllipse"), 18) + for i = 1, math.min(#lines, 3) do self:text(lines[i], 1, 13 + i) end else self:printBoxText(self:phoneText("AskWhoCall")) end @@ -2312,7 +2344,7 @@ function Pokegear:drawPhone() if className then self:text(className, 5, ty + 1) end end -- PokegearPhone_UpdateCursor draws the cursor at (1, 4 + 2 * cursor). - Chrome.cursor(1, 4 + self.phoneCursor * 2) + self:cursor(1, 4 + self.phoneCursor * 2) self:drawPhoneSubmenu() end @@ -2328,7 +2360,7 @@ function Pokegear:drawPhoneSubmenu() local ty = menu.textY + (index - 1) * 2 self:text(label, menu.textX, ty) end - Chrome.cursor(menu.textX - 1, menu.textY + self.phoneSubmenuCursor * 2) + self:cursor(menu.textX - 1, menu.textY + self.phoneSubmenuCursor * 2) end -- ------------------------------------------------------------------ fallback diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua index 85ca06ec..406bdc43 100644 --- a/src/ui/kit/Kit.lua +++ b/src/ui/kit/Kit.lua @@ -720,14 +720,16 @@ function Kit.button(x, y, w, h, label, opts) strokeA = invert and Theme.A.focus or Theme.A.hairline doRing = focused and not hot elseif face == "tab" then - invert = active or focused or hot + invert = active and true or false local tint = opts.color or opts.fill or PAL.ink fill = invert and tint or PAL.surface ink = invert and PAL.inverse or (opts.color or PAL.text) if not invert then stroke = tint - strokeA = opts.color and Theme.A.hover or Theme.A.hairline + strokeA = (focused or hot) and Theme.A.focus + or (opts.color and Theme.A.hover or Theme.A.hairline) end + doRing = focused or hot elseif face == "chip" then local c = opts.color or PAL.line invert = active and true or false diff --git a/src/ui/kit/VirtualKeyboard.lua b/src/ui/kit/VirtualKeyboard.lua index 63f65f77..8b820bc4 100755 --- a/src/ui/kit/VirtualKeyboard.lua +++ b/src/ui/kit/VirtualKeyboard.lua @@ -203,6 +203,7 @@ function VirtualKeyboard.draw(m) -- Scrim background Theme.fill(0, 0, W, H, PAL.bg, 0.92) + local shielded = Kit.blockClicks Kit.blockClicks = true local pad = math.floor(12 * s) @@ -212,6 +213,7 @@ function VirtualKeyboard.draw(m) local py = math.floor((H - modalH) / 2) Kit.card(px, py, modalW, modalH, true) + Kit.blockClicks = shielded local cy = py + pad -- Title & hint @@ -276,7 +278,7 @@ function VirtualKeyboard.draw(m) cy = cy + rowH + gap end - Kit.blockClicks = false + Kit.blockClicks = shielded end return VirtualKeyboard diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 6bd33408..b2ffba7b 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -528,6 +528,8 @@ function OverworldState:setMap(mapId, x, y, facing, opts) -- one-shot set by play_music opts.keep. local keepMusic = (opts and opts.keepMusic) or self.keepMusicOnce self.keepMusicOnce = nil + -- home/overworld.asm ln 2340, player_animations.asm ln 64 + if opts and opts.via == "fly" then keepMusic = true end if not keepMusic then -- ..(home/overworld.asm ln 2346) local Music = require("src.core.Music") @@ -1245,6 +1247,9 @@ function OverworldState:update(dt) if self.flyArrive.t >= #FLY_PATH_IN * 3 then self.flyArrive = nil self.player.inputLocked = false + -- engine/overworld/player_animations.asm ln 64 + require("src.core.Music").playMap( + Game.data, self.map.id, Game.save.onBike, self.player.surfing, nil) end end if self.spinArrive and not self.player.spinFrames then @@ -4026,7 +4031,8 @@ function OverworldState:applyFieldPoison() end if not anyPoisoned then return false end require("src.core.Sound").play(Game.data, "Poisoned") - self.poisonFlash = 12 + -- engine/gfx/screen_effects.asm:7-8 + self.poisonFlash = 4 local queue = {} for _, mon in ipairs(fainted) do local name = mon.nickname or Game.data.pokemon[mon.species].name @@ -5777,12 +5783,18 @@ function OverworldState:drawUI() end end - -- poison step flicker (ChangeBGPalColor0_4Frames: dark for two - -- 4-frame pulses) + -- engine/gfx/screen_effects.asm:1-12 if self.poisonFlash and self.poisonFlash > 0 then self.poisonFlash = self.poisonFlash - 1 - local pulse = math.floor(self.poisonFlash / 4) % 2 == 1 - if pulse then + local r = Game and Game.renderer + if PaletteFX.shader() then + -- home/fade.asm:66 + if not PaletteFX.shadeMap() then + PaletteFX.setShadeMap(PaletteFX.POISON_BGP) + end + elseif r then + r.screenVeil = { 0, 0.45 } + else love.graphics.setColor(0, 0, 0, 0.45) love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.setColor(1, 1, 1, 1) diff --git a/src/world/gen2/Npc.lua b/src/world/gen2/Npc.lua index 3ad40933..7f7ec7e9 100644 --- a/src/world/gen2/Npc.lua +++ b/src/world/gen2/Npc.lua @@ -125,10 +125,9 @@ local SPIN_NEXT = { -- each quarter, no Random anywhere in the loop. local SPIN_TURN_FRAMES = 16 --- SetFacingBounce steps OBJECT_STEP_FRAME once a frame and reads bit 3, so a --- mon object holds each icon frame for eight -- map_object_action.asm:184-201 -local BOUNCE_PERIOD = 16 -local BOUNCE_HALF = 8 +-- map_object_action.asm:184-201, events.asm:175-189 +local BOUNCE_PERIOD = 32 +local BOUNCE_HALF = 16 local function rand(a, b) if love and love.math and love.math.random then diff --git a/src/world/gen2/Player.lua b/src/world/gen2/Player.lua index 5a0734e4..94bc9057 100644 --- a/src/world/gen2/Player.lua +++ b/src/world/gen2/Player.lua @@ -63,10 +63,12 @@ function Player.new(cx, cy, facing, spriteDef) end function Player:setSprite(spriteDef) - if spriteDef then - self.spriteDef = spriteDef - self.sprite = SpriteRenderer.new(spriteDef, "player") - end + if not spriteDef then return end + -- pokegold engine/overworld/overworld.asm:55-64 + local ok, sprite = pcall(SpriteRenderer.new, spriteDef, "player") + if not (ok and sprite) then return end + self.spriteDef = spriteDef + self.sprite = sprite end -- the movement.collision chain sees the boolean; a wrapper that flips it @@ -265,15 +267,6 @@ function Player:draw(ox, oy, scale) -- it moves the sprite without moving the player off the tile they are -- standing on. StepFunction_GotBite's `xor 1` rod bob rides this one byte. local yOffset = self.spriteYOffset or 0 - if self.jumping then - -- engine/overworld/map_objects.asm:1995 - local gx = ox + self.px * scale - local gy = oy + self.py * scale - local s = 16 * scale - G.setColor(0, 0, 0, 0.4) - G.ellipse("fill", gx + s * 0.5, gy + s * 0.85, s * 0.35, s * 0.12) - G.setColor(1, 1, 1, 1) - end if self.sprite then G.push() G.translate(ox, oy) diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index a20a321a..5c9bf69d 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -20,6 +20,7 @@ local CallAsm = require("src.script.gen2.CallAsm") local Camera = require("src.render.Camera") local Clock = require("src.core.gen2.Clock") local CatchTutorial = require("src.core.gen2.CatchTutorial") +local Catching = require("src.battle.gen2.Catching") local Decorations = require("src.core.gen2.Decorations") local MomShopping = require("src.core.gen2.MomShopping") local CmdQueue = require("src.world.gen2.CmdQueue") @@ -812,6 +813,19 @@ function World:load() local okImg, img = pcall(Assets.image, emotes.grassRustle) if okImg then self.grassRustleImage = img end end + -- data/sprites/emotes.asm:19 + if emotes.jumpShadow then + local okImg, img = pcall(Assets.image, emotes.jumpShadow) + if okImg then self.jumpShadowImage = img end + end + -- engine/events/field_moves.asm:390-407 + if emotes.cutGrass then + local okImg, img = pcall(Assets.image, emotes.cutGrass) + if okImg then + self.cutGrassImage = img + self.cutGrassQuad = love.graphics.newQuad(0, 0, 8, 8, 32, 8) + end + end -- LoadFishingGFX's two sheets -- (../pokecrystal/engine/events/fishing_gfx.asm:7-12) if emotes.fishing and pcall(Assets.image, emotes.fishing) then @@ -1053,6 +1067,14 @@ function World:load() end -- GivePoke -> TryAddMonToParty -> AddPartyMon (move_mon.asm:44-56, :143-149). Mon.stampOT(save, mon) + -- move_mon.asm:1734, :1761 + if Mon.hasCaughtData(save.version) then + if opts and opts.otName then + Mon.setGiftCaughtData(mon, opts.caughtBy or "unknown") + else + Catching.stampCaughtData(mon, self:caughtDataOpts()) + end + end Party.add(save.party, mon) -- GivePoke ends in SetSeenAndCaughtMon, which is why the STARTER is -- already ticked off in the #DEX before the first battle. @@ -1518,6 +1540,22 @@ function World:timeOfDayId() return TIME_OF_DAY_ID[self.tod or self.daytime or "DAY"] or 1 end +-- caught_data.asm:168-199 +function World:caughtDataOpts() + local save = self.game and self.game.save + local opts = { + version = save and save.version, + save = save, + data = self.game and self.game.data, + timeOfDay = self:timeOfDayId(), + map = self.map and self.map.def, + backupMap = self.backupMapId and self.maps and self.maps[self.backupMapId], + playerGender = save and save.player and save.player.gender, + } + opts.landmark = Catching.caughtLandmark(opts) + return opts +end + -- GetWeekday -> wCurDay, which the RTC counts SUNDAY 0 .. SATURDAY 6 -- the -- same numbering os.date("%w") answers, so no remap. `clockDay` overrides the -- host clock the way `clockHour` overrides the hour, so a driver can stand on @@ -4925,6 +4963,8 @@ function World:playBikeMusic() local audio = data and data.audio if not (audio and audio.runtime) then return false end if not (audio.songs and audio.songs[Bike.MUSIC_BICYCLE]) then return false end + -- engine/events/overworld.asm:1621-1630 + Music.stop() Music.play(data, Bike.MUSIC_BICYCLE, true, { reason = "bike" }) return true end @@ -5997,6 +6037,10 @@ end local FLY = { FROM_FRAMES = 128, TO_FRAMES = 64, HOVER = 0x40, AMP_MAX = 0x40, TO_AMP = 11 * 8, RISE = 84, + -- engine/sprite_anims/functions.asm:1389-1416 + LEAF_DEATH_X = 184, LEAF_AMP = 0x40, + -- constants/sprite_anim_constants.asm:20 + LEAF_MAX = 9, } -- FlyFunction_InitGFX's GetSpeciesIcon (engine/events/field_moves.asm:390): @@ -6036,6 +6080,7 @@ function World:startFlyAnim(phase, mon, onDone) self.flyAnim = { phase = phase, icon = icon, onDone = onDone, t = 0, px = p.px, py = p.py, xoff = 0, wave = 0, + leaves = {}, left = landing and FLY.TO_FRAMES or FLY.FROM_FRAMES, hover = landing and 0 or FLY.HOVER, amp = landing and FLY.TO_AMP or 0, @@ -6056,6 +6101,7 @@ function World:stepFlyAnim() if done then done() end return end + self:spawnFlyLeaves(fa) fa.left = left - 1 if left >= 0x40 and left % 8 == 0 then self:playSfxNamed("Sfx_Fly", SFX.FLY) @@ -6079,6 +6125,59 @@ function World:stepFlyAnim() fa.wave = fa.wave + 1 end +-- engine/events/field_moves.asm:429-446 +-- engine/sprite_anims/functions.asm:1389-1416 +function World:spawnFlyLeaves(fa) + local leaves = fa.leaves + if not leaves then + leaves = {} + fa.leaves = leaves + end + local counter = self.flyLeafCounter or 0 + self.flyLeafCounter = (counter + 1) % 256 + if counter % 8 == 0 and #leaves < FLY.LEAF_MAX then + local row = math.floor(self.flyLeafCounter / 8) % 4 + leaves[#leaves + 1] = { x = 0, y = row * 16 + 0x40, wave = 0, xoff = 0 } + end + for i = #leaves, 1, -1 do + local leaf = leaves[i] + if leaf.x >= FLY.LEAF_DEATH_X then + table.remove(leaves, i) + else + leaf.x = leaf.x + 2 + leaf.y = leaf.y - 1 + leaf.xoff = math.floor(FLY.LEAF_AMP + * math.cos((leaf.wave % 64) * math.pi / 32)) + leaf.wave = leaf.wave + 1 + end + end +end + +-- data/sprite_anims/oam.asm:485-487 +function World:drawFlyLeaves(s) + local fa = self.flyAnim + local sheet = self.cutGrassImage + if not (fa and fa.leaves and sheet and self.cutGrassQuad) then return end + local G = love.graphics + local colors = Palettes.spritePalette(self.palettes, + self.daytime or Palettes.daytimeFor(self.map and self.map.def, + self:hour(), self.flashUsed), + { paletteId = 6 }) + local function blit() + G.setColor(1, 1, 1, 1) + for _, leaf in ipairs(fa.leaves) do + G.draw(sheet, self.cutGrassQuad, + math.floor((leaf.x + (leaf.xoff or 0) - 4) * s), + math.floor((leaf.y - 4) * s), 0, s, s) + end + end + if colors and GbcPalette.available() then + GbcPalette.with(colors, blit) + else + blit() + end +end + -- .Frameset_RedWalk is two 8-frame icon beats, the fourth mirrored -- -- data/sprite_anims/framesets.asm:81-86 function World:drawFlyAnim(s, billboard) @@ -6104,6 +6203,7 @@ function World:drawFlyAnim(s, billboard) else body() end + self:drawFlyLeaves(s) end -- .FlyScript: FlyFromAnim, WarpToSpawnPoint, `newloadmap MAPSETUP_TELEPORT`, @@ -6548,6 +6648,11 @@ function World:startScriptedBattle(record, wild, onDone) end end if not (opts.trainer and #opts.trainer.party > 0) and not opts.wild then + if record then + require("src.core.Logger").warn( + "trainer %s (class %s, member %s) built no party; no battle ran", + tostring(record.name), tostring(record.class), tostring(record.member)) + end if onDone then onDone("win") end return false end @@ -8399,6 +8504,30 @@ function World:drawGrassShake(entity, ox, oy, s) end end +-- engine/overworld/map_objects.asm:1995, :879-893, facings.asm:161-164 +function World:drawJumpShadow(entity, ox, oy, s) + local sheet = self.jumpShadowImage + if not (sheet and entity and entity.jumping) then return end + local G = love.graphics + local facing = entity.facing + local dy = (facing == "left" or facing == "right") and 8 or 10 + local colors = Palettes.spritePalette(self.palettes, + self.daytime or Palettes.daytimeFor(self.map and self.map.def, + self:hour(), self.flashUsed), + { paletteId = 5 }) + local function blit() + G.setColor(1, 1, 1, 1) + local y = math.floor(oy + (entity.py + dy) * s) + G.draw(sheet, math.floor(ox + entity.px * s), y, 0, s, s) + G.draw(sheet, math.floor(ox + (entity.px + 16) * s), y, 0, -s, s) + end + if colors and GbcPalette.available() then + GbcPalette.with(colors, blit) + else + blit() + end +end + -- This frame's AnimateWaterTile graphic for a map's border block, or nil when -- the block holds no water at all (engine/tilesets/tileset_anims.asm:167). function World:borderWaterFrame(def, tileset) @@ -9823,7 +9952,8 @@ function World:hatchEggs() return end self:showText("Huh?", function() - local hatched, effects = Breeding.hatch(data, save, index) + local hatched, effects = + Breeding.hatch(data, save, index, nil, self:caughtDataOpts()) if not hatched then return nextEgg() end -- Breeding.hatch already ran SetSeenAndCaughtMon; the Togepi flag is the -- one side effect it hands back rather than setting, because wEventFlags @@ -10321,6 +10451,8 @@ function World:drawPeople(s, billboard) local oy = math.floor((entry.oy - cam.y) * s) local entity = entry.kind == "player" and p or entry.npc local function body() + -- map_objects.asm:221-227 + self:drawJumpShadow(entity, ox, oy, s) if entry.kind == "player" then self.player:draw(ox, oy, s) else diff --git a/tests/drivers/battle_scy_deform_bug1895_test.lua b/tests/drivers/battle_scy_deform_bug1895_test.lua new file mode 100644 index 00000000..4fb1f2b8 --- /dev/null +++ b/tests/drivers/battle_scy_deform_bug1895_test.lua @@ -0,0 +1,131 @@ +-- engine/battle_anims/bg_effects.asm:2638 (#1895) +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/battle_scy_deform_bug1895_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/scy-deform love . +-- No POKEPORT_SPEED: the shots land on counted animation frames. +local U = require("tests.drivers.util") + +local BattleAnimView = require("src.ui.gen2.BattleAnimView") +local Mon = require("src.battle.gen2.Mon") +local Permissions = require("src.world.gen2.Permissions") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/scy-deform" + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[scy] ok " .. label) + else + failures = failures + 1 + print("[scy] FAIL " .. label .. " " .. tostring(detail)) + end + 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") + + game.save.party = { Mon.new(game.data, "TOTODILE", 20) } + game.save.inventory = { 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 } }) 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 + + assert(world:startBattle({ wild = Mon.new(game.data, "GYARADOS", 20) }), + "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", battle ~= nil, battle) + if not battle then + print(("[scy] FAIL no battle to shoot (%d)"):format(failures)) + while true do coroutine.yield() end + end + for _ = 1, 150 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(2) + end + ok("and it reached the FIGHT menu", battle.phase == "menu", battle.phase) + U.wait(10) + shot(out .. "/00-battle.png") + + local function sampleWhile(label, frames) + local scanned, worst = 0, nil + for _ = 1, frames do + local anim = battle.anim + local bg = anim and anim.bg + if bg and (bg.lcdc or bg.scy ~= 0) then + local lines = BattleAnimView.scanlines(bg) + local seen, dup = {}, false + for _, line in ipairs(lines) do + if seen[line.dest] then dup = true end + seen[line.dest] = true + if line.src < 0 or line.src >= BattleAnimView.SCREEN_H then + worst = "source row " .. line.src .. " is off the panel" + end + end + if dup then worst = "two source rows landed on one scanline" end + scanned = scanned + 1 + end + U.wait(1) + end + ok(label .. ": " .. scanned .. " deformed frames, each scanline drawn once", + scanned > 0 and worst == nil, worst or "no deformed frame seen") + end + + local shots = { + { "WATER_GUN", "01-water-gun", 10, + "TOTODILE's WATER GUN: GYARADOS ripples in a gentle vertical wave, a", + "couple of pixels deep, and the enemy HUD above it breathes with it.", + "Wrong is the pic sliced into strips with blank rows between them." }, + { "CONFUSION", "02-confusion", 10, + "CONFUSION: the same window, amplitude 2, so an even softer ripple.", + "Nothing should tear, and no white gaps should open in the pic." }, + { "PSYCHIC_M", "03-psychic", 10, + "PSYCHIC is the rSCX effect, the control: the whole screen shears", + "SIDEWAYS. If this one stopped moving, the SCX path regressed." }, + { "WITHDRAW", "04-withdraw", 20, + "WITHDRAW parks rows on $90: TOTODILE sinks out of sight row by row.", + "Wrong is the pic staying put, or the whole panel jumping." }, + } + + for _, entry in ipairs(shots) do + local move, name, delay = entry[1], entry[2], entry[3] + battle.anim = nil + local started = battle:animForMove(move, move == "WITHDRAW" + and "player" or "enemy", 0, 10) + ok(move .. " has an extracted animation script", started, started) + if started then + U.wait(delay) + for i = 4, #entry do U.log(entry[i]) end + shot(out .. "/" .. name .. ".png") + sampleWhile(move, 30) + end + end + + battle.anim = nil + U.wait(6) + + print(failures == 0 and "[scy] PASS battle_scy_deform_bug1895" + or ("[scy] FAIL battle_scy_deform_bug1895 (%d)"):format(failures)) + U.log("the battle menu is yours; pick a move to watch a full animation.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/caught_ball_palette_bug1896_test.lua b/tests/drivers/caught_ball_palette_bug1896_test.lua new file mode 100644 index 00000000..8c0dad31 --- /dev/null +++ b/tests/drivers/caught_ball_palette_bug1896_test.lua @@ -0,0 +1,155 @@ +-- pokegold engine/battle_anims/anim_commands.asm:213 (#1896) +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/caught_ball_palette_bug1896_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/caught-ball love . +-- +-- No POKEPORT_SPEED: the shot has to land on the click frame. +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/caught-ball" + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[ball] PASS " .. label) + else + failures = failures + 1 + print("[ball] FAIL " .. label .. " " .. tostring(detail)) + end + end + + local function finish() + print(failures == 0 and "[ball] PASS caught_ball_palette_bug1896" + or ("[ball] FAIL caught_ball_palette_bug1896 (%d)"):format(failures)) + while true do coroutine.yield() end + end + + local function battleScreen() + local top = game.stack:top() + return (top and top.battle) and top or nil + end + + local function waitFor(predicate, frames) + for _ = 1, frames or 600 do + if predicate() then return true end + U.wait(1) + end + return predicate() and true or false + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + local save = game.save + save.party = { Mon.new(game.data, "CYNDAQUIL", 12) } + save.inventory = { POTION = 2, POKE_BALL = 10 } + + local wild = Mon.new(game.data, "WOOPER", 5) + assert(wild, "the cache carries no WOOPER") + assert(world:startBattle({ wild = wild }), "the wild battle refused to start") + + local screen + assert(waitFor(function() + screen = battleScreen() + return screen ~= nil + end, 900), "the battle screen never came up") + + for _ = 1, 400 do + if screen.phase == "menu" then break end + U.tap(game, "a") + U.wait(2) + end + ok("the battle reached the BattleMenu", screen.phase == "menu", screen.phase) + if screen.phase ~= "menu" then finish() end + + screen.battle.random = function() return 0 end + + if screen.menuIndex % 2 == 0 then + U.tap(game, "left") + U.wait(3) + end + if screen.menuIndex <= 2 then + U.tap(game, "down") + U.wait(3) + end + ok("the cursor sat on the PACK row", screen.menuIndex == 3, screen.menuIndex) + U.tap(game, "a") + U.wait(6) + + local pack = game.stack:top() + ok("the battle PACK opened", pack ~= nil and pack.rows ~= nil, pack) + if not (pack and pack.rows) then finish() end + + for _ = 1, 4 do + if pack:pocket().id == "BALL" then break end + U.tap(game, "right") + U.wait(4) + end + ok("and it crossed to the POKe BALLS pocket", pack:pocket().id == "BALL", + pack:pocket().id) + + local ballRow + for index, row in ipairs(pack.rows) do + if row.id == "POKE_BALL" then ballRow = index end + end + ok("the POKE BALL is on the list", ballRow ~= nil, #pack.rows) + if not ballRow then finish() end + + for _ = 1, 20 do + if pack.index == ballRow then break end + U.tap(game, pack.index < ballRow and "down" or "up") + U.wait(3) + end + ok("the cursor reached it", pack.index == ballRow, pack.index) + U.tap(game, "a") + U.wait(6) + + ok("the throw was rolled as a catch", + screen.ballThrow ~= nil and screen.ballThrow.caught == true, + screen.ballThrow and screen.ballThrow.caught) + ok("ANIM_THROW_POKE_BALL is running", screen.anim ~= nil, screen.anim) + if not screen.anim then finish() end + + local clicked = waitFor(function() + local anim = screen.anim + return anim ~= nil and anim:done() and anim.keepSprites + end, 1200) + ok("the wobble loop ended on .Click and kept its sprites", clicked, + screen.anim and screen.anim.keepSprites) + if not clicked then finish() end + + local kept, wrong = 0, nil + for _, obj in ipairs(screen.anim:oam()) do + kept = kept + 1 + if obj.palette ~= "PAL_BATTLE_OB_ENEMY" then wrong = obj.palette end + end + ok("the ball's OBJs outlived the script", kept > 0, kept) + ok(("all %d kept OBJs are on the enemy palette slot"):format(kept), + kept > 0 and wrong == nil, wrong) + + local gotcha = waitFor(function() + local text = screen.message + return type(text) == "string" and text:find("Gotcha") ~= nil + end, 600) + ok("the Gotcha line is on screen", gotcha, screen.message) + + local stillThere = screen.anim ~= nil and #screen.anim:oam() > 0 + ok("and the ball is still parked under it", stillThere, screen.anim) + + U.log("shot 01: the ball sits on the ground where WOOPER was, with the") + U.log("Gotcha line under it. Right looks like a ball wearing WOOPER's own") + U.log("blues, darker and muted; wrong is the bright red and white ball that") + U.log("was thrown, unchanged from the frame before the click.") + if not U.shot(game, out .. "/01-gotcha-ball.png") then failures = failures + 1 end + + print(failures == 0 and "[ball] PASS caught_ball_palette_bug1896" + or ("[ball] FAIL caught_ball_palette_bug1896 (%d)"):format(failures)) + U.log("the battle is left standing on the Gotcha line; press A in the") + U.log("window to carry on through the catch.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/fly_music_bug1892_test.lua b/tests/drivers/fly_music_bug1892_test.lua new file mode 100644 index 00000000..1d6b3429 --- /dev/null +++ b/tests/drivers/fly_music_bug1892_test.lua @@ -0,0 +1,68 @@ +-- home/overworld.asm (#1892) +-- POKEPORT_DRIVER=tests/drivers/fly_music_bug1892_test.lua \ +-- POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Music = require("src.core.Music") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function waitFor(pred, limit) + local guard = 0 + while not pred() and guard < (limit or 900) do + guard = guard + 1 + coroutine.yield() + end + return pred() + end + + U.teleport(game, "ROUTE_17", 4, 10, "down") + local ow = game.stack:top() + local destSong = game.data.audio.mapSongs.PALLET_TOWN + U.log("Pallet Town theme is " .. tostring(destSong)) + + local Screens = require("src.ui.Screens") + Screens.push(game, "TownMap", { fly = true, onFly = function() end }) + for _ = 1, 20 do coroutine.yield() end + U.shot(game, DIR .. "/fly_music_1_picker.png") + U.log("Picker: two small black triangles sit in the top-right of the" + .. " name strip, one pointing up, one pointing down") + game.stack:pop() + for _ = 1, 4 do coroutine.yield() end + + ow:flyTo("PALLET_TOWN") + + if not waitFor(function() return ow.map.id == "PALLET_TOWN" end) then + U.log("FAIL never reached PALLET_TOWN") + while true do coroutine.yield() end + end + + local earlyStart = nil + local frames = 0 + while ow.flyArrive and frames < 300 do + if Music.current() == destSong then + earlyStart = earlyStart or frames + end + frames = frames + 1 + coroutine.yield() + end + U.shot(game, DIR .. "/fly_music_2_swoop.png") + + if earlyStart then + U.log(("FAIL the Pallet theme started %d frames into the landing swoop") + :format(earlyStart)) + else + U.log("PASS the destination theme stayed silent through the swoop") + end + + for _ = 1, 8 do coroutine.yield() end + if Music.current() == destSong then + U.log("PASS the Pallet theme started once the bird landed") + else + U.log("FAIL after landing the current song is " + .. tostring(Music.current())) + end + U.shot(game, DIR .. "/fly_music_3_landed.png") + + U.log("Screenshots are under " .. DIR) + while true do coroutine.yield() end +end diff --git a/tests/drivers/game_corner_bug624_test.lua b/tests/drivers/game_corner_bug624_test.lua index 7254cb94..95daf5ad 100644 --- a/tests/drivers/game_corner_bug624_test.lua +++ b/tests/drivers/game_corner_bug624_test.lua @@ -9,7 +9,7 @@ return function(game) local U = dofile("tests/drivers/util.lua") local ChoiceBox = require("src.ui.ChoiceBox") - local ListMenu = require("src.ui.ListMenu") + local PrizeCounter = require("src.ui.PrizeCounter") local TextBox = require("src.render.TextBox") local mapScripts = require("data.scripts.init") @@ -197,7 +197,7 @@ return function(game) U.tap(game, "a") U.wait(20) local function listUp() - return getmetatable(game.stack:top()) == ListMenu + return getmetatable(game.stack:top()) == PrizeCounter end mashUntil(listUp, 600) local list = listUp() and game.stack:top() or nil @@ -206,20 +206,17 @@ return function(game) if list then -- wMaxMenuItem is 3: this counter's three prizes and NO THANKS, never -- the whole catalogue (#623) - local rows = list.items or {} + local rows = list.prizes or {} local names = {} - for _, item in ipairs(rows) do - names[#names + 1] = item.label .. - (item.right and (" " .. item.right) or "") + for _, row in ipairs(rows) do + names[#names + 1] = row.name .. " " .. tostring(row.cost) end U.log("counter 1 offers:", table.concat(names, ", ")) - check("four rows: three prizes and NO THANKS", #rows == 4) - check("the last row is the NO THANKS exit", - rows[4] ~= nil and rows[4].label == "NO THANKS" - and rows[4].value == nil) + check("three prizes, with NO THANKS as the fourth cursor row", #rows == 3) local priced = true - for i = 1, math.min(3, #rows) do - if not (rows[i].value and tonumber(rows[i].right)) then priced = false end + for i = 1, #rows do + if not (rows[i].prize and tonumber(rows[i].cost)) then priced = false end + if tostring(rows[i].name):find("L%d") then priced = false end end check("each prize names a real species or TM and a coin price", priced) check("captured the prize list", @@ -240,8 +237,7 @@ return function(game) U.log("it asks:", (said:gsub("^%s+", ""))) check("the question names the prize, not {RAM:wNameBuffer}", said:find("wNameBuffer", 1, true) == nil - and rows[1] ~= nil and said:find(rows[1].label:match("^%S+"), 1, true) - ~= nil) + and rows[1] ~= nil and said:find(rows[1].name, 1, true) ~= nil) check("captured the confirmation", U.shot(game, SHOT_DIR .. "/bug623_prize_confirm.png")) end @@ -258,8 +254,9 @@ return function(game) U.log("out under the YES/NO, MONEY ¥3000 and COIN 500 in the top-right") U.log("window. the near-miss to look for is that window going stale, still") U.log("reading ¥3000 after the 50 coins are bought.") - U.log("the prize list is still full-screen and the YES/NO lands on top of") - U.log("the third row. the small windowed menu half of #623 was left alone.") + U.log("the prize window is the cart's two boxes over the prize room: COIN") + U.log("top-right, names with their coin prices on the row below, and the") + U.log("floor still visible to the right of it (#1867).") while true do coroutine.yield() diff --git a/tests/drivers/gold_battle_size_bug1709_test.lua b/tests/drivers/gold_battle_size_bug1709_test.lua new file mode 100644 index 00000000..56606b49 --- /dev/null +++ b/tests/drivers/gold_battle_size_bug1709_test.lua @@ -0,0 +1,204 @@ +-- maps/Route29.asm:432 (#1709) +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_battle_size_bug1709_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-battle-size love . +-- No POKEPORT_SPEED: the shots land on counted frames. +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 Save = require("src.core.gen2.Save") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-battle-size" + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[battlesize] ok " .. label) + else + failures = failures + 1 + print("[battlesize] 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 BattleState = require("src.ui.gen2.BattleState") + + ok("battleFit has a default in the Gold options table", + Save.DEFAULT_OPTIONS.battleFit == "fixed", Save.DEFAULT_OPTIONS.battleFit) + + local sizeRow + for i, row in ipairs(OptionsMenu.ROWS) do + if row.label == "BATTLE SIZE" then sizeRow = i end + end + ok("OPTION carries a BATTLE SIZE row", sizeRow ~= nil, sizeRow) + if sizeRow then + ok("and BATTLE BG follows it", + OptionsMenu.ROWS[sizeRow + 1] + and OptionsMenu.ROWS[sizeRow + 1].key == "battleBg", sizeRow) + end + ok("the battle screen reads the option rather than answering true", + BattleState.wantsFillScale({ game = { options = { battleFit = "fixed" } } }) + == false, "stub") + + 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 fixed = Chrome.fitScale(winW, winH) + local fill = BattleState.fillScale(winW, winH) + ok(("the window makes FILL fractional (%dx%d, fixed x%d, fill x%.3f)") + :format(winW, winH, fixed, fill), fill > fixed and fill % 1 > 0.01, + fixed .. " / " .. fill) + + 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 "[battlesize] preflight PASS -- the shots below are worth looking at" + or ("[battlesize] preflight FAIL (%d) -- fix these before judging a pixel") + :format(failures)) + + game.options.battleFit = "fixed" + game.options.battleBg = "white" + + 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(("[battlesize] FAIL no battle to shoot (%d)"):format(failures)) + while true do coroutine.yield() end + end + 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) + ok("and it reports FIXED", battle:wantsFillScale() == false, + battle:wantsFillScale()) + U.wait(10) + + U.log("01: the battle on FIXED. this is the reference shot: every box border") + U.log("is one even line, the HUD rules are even, and the glyphs are whole.") + shot(out .. "/01-battle-fixed.png") + + game.options.battleFit = "fill" + U.wait(8) + ok("the battle now reports FILL", battle:wantsFillScale() == true, + battle:wantsFillScale()) + U.log("02: the same battle on FILL. the panel is bigger and reaches the top") + U.log("and bottom edges. what to judge: the message box border, the HUD") + U.log("rules under both names, and the level and HP digits. some stepping is") + U.log("inherent at a fractional scale; a border that breaks into visible") + U.log("stairs, or a glyph missing a pixel row, is too much to ship.") + shot(out .. "/02-battle-fill.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", 12) + U.log("03: the move list on FILL. the move names, TYPE/ and the PP figures") + U.log("are the smallest text in the game -- this is where a lost pixel row") + U.log("shows first.") + shot(out .. "/03-moves-fill.png") + tap("b", 12) + + ok("the cursor is on PKMN", pointAt(2), battle.menuIndex) + tap("a", 12) + U.log("04: the party list opened over the FILL battle. PARTY paints its own") + U.log("surround at the whole-pixel fit, so the panel steps back down to the") + U.log("FIXED size here and the void around it must be clean -- no paper ring") + U.log("left where the bigger battle panel was. carrying FILL into PARTY and") + U.log("PACK is still open work.") + shot(out .. "/04-party-over-fill.png") + tap("b", 12) + for _ = 1, 20 do + if battle.phase == "menu" then break end + tap("b", 4) + end + + game.options.battleBg = "black" + U.wait(8) + U.log("05: FILL with BATTLE BG on BLACK. the black hugs the bigger panel:") + U.log("no white strip left on an edge, and no black creeping over the HUD.") + shot(out .. "/05-fill-black-bg.png") + game.options.battleBg = "white" + + for _, mode in ipairs({ "upper", "top" }) do + game.options.screenPos = mode + game:applyOptions() + U.wait(8) + U.log(("06-%s: SCREEN POS %s at FILL. the panel moves as a whole; it must") + :format(mode, mode)) + U.log("not shear or leave a sliver at the edge it was lifted from.") + shot(out .. ("/06-screenpos-%s-fill.png"):format(mode)) + end + game.options.screenPos = "center" + game:applyOptions() + U.wait(8) + + game.options.battleFit = "fixed" + U.wait(8) + ok("and back to FIXED", battle:wantsFillScale() == false, + battle:wantsFillScale()) + U.log("07: back on FIXED, for the same comparison as 01. nothing about the") + U.log("panel should have changed while FILL was on and off again.") + shot(out .. "/07-battle-fixed-again.png") + + print(failures == 0 and "[battlesize] PASS gold_battle_size_bug1709" + or ("[battlesize] FAIL gold_battle_size_bug1709 (%d)"):format(failures)) + U.log("the battle is still up on FIXED and the controls are yours. OPTION ->") + U.log("BATTLE SIZE is the row to flip by hand if you want another look.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_bike_mount_bug1481_test.lua b/tests/drivers/gold_bike_mount_bug1481_test.lua new file mode 100644 index 00000000..11efc254 --- /dev/null +++ b/tests/drivers/gold_bike_mount_bug1481_test.lua @@ -0,0 +1,69 @@ +-- pokegold engine/events/overworld.asm:1616-1630, :1686 (#1481) +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bike_mount_bug1481_test.lua love . +-- No POKEPORT_SPEED: the song starts on the audio clock. +local U = require("tests.drivers.util") + +local Bag = require("src.inventory.Bag") +local PackMenu = require("src.ui.gen2.PackMenu") + +local HOME = { map = "NEW_BARK_TOWN", x = 13, y = 6 } +local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + +return function(game) + local failed = 0 + local function pass(ok, line) + if not ok then failed = failed + 1 end + U.log((ok and "PASS " or "FAIL ") .. line) + end + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + local world, save = game.world, game.save + + world:setMap(HOME.map, HOME.x, HOME.y, "down") + U.wait(12) + + local sprites = game.data and game.data.gen2Sprites or {} + pass(sprites.SPRITE_CHRIS_BIKE ~= nil, + "this cache carries SPRITE_CHRIS_BIKE, the sheet the mount swaps to") + local songs = game.data and game.data.audio and game.data.audio.songs or {} + pass(songs.Music_Bicycle ~= nil, + "and Music_Bicycle, the song .GetOnBike starts") + + save.inventory = { BICYCLE = 1 } + save.bagOrder = { "BICYCLE" } + Bag.order(save, { items = game.data.items }) + + game.packCursor = nil + -- data/items/attributes.asm + local pack = PackMenu.new(game, { save = save, world = world, + pocket = "KEY_ITEM", onClose = function() game.stack:pop() end }) + game.stack:push(pack) + U.wait(20) + + -- pokegold engine/items/pack.asm:243 + U.tap(game, "a") + U.wait(20) + U.tap(game, "a") + + for _ = 1, 150 do + if world.playerState == "bike" then break end + coroutine.yield() + end + pass(world.playerState == "bike", + "the queued Script_GetOnBike reached loadvar VAR_MOVEMENT, PLAYER_BIKE") + local def = world.player and world.player.spriteDef + pass(def ~= nil and def.id == "SPRITE_CHRIS_BIKE", + "and the player is on the rider sheet") + U.shot(game, SHOT_DIR .. "/bike_mount.png") + + U.tap(game, "a") + U.wait(40) + U.shot(game, SHOT_DIR .. "/bike_after_box.png") + U.log("the box above should read \"got on the BICYCLE!\" and the bike theme" + .. " should be playing; both are for a human to judge") + U.log(failed == 0 and "bike mount ran clean" + or (failed .. " machine-checkable parts failed")) + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_dex_area_bug1891_test.lua b/tests/drivers/gold_dex_area_bug1891_test.lua new file mode 100644 index 00000000..76d56252 --- /dev/null +++ b/tests/drivers/gold_dex_area_bug1891_test.lua @@ -0,0 +1,94 @@ +-- engine/pokegear/pokegear.asm:2285 (#1891) +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_dex_area_bug1891_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-dex-area love . +-- No POKEPORT_SPEED: the markers blink on a frame counter. +local U = require("tests.drivers.util") + +local HallOfFame = require("src.core.gen2.HallOfFame") +local Nests = require("src.core.gen2.Nests") +local PokedexMenu = require("src.ui.gen2.PokedexMenu") + +local SPECIES = "MILTANK" + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-dex-area" + local failed = 0 + + local function pass(ok, line) + if not ok then failed = failed + 1 end + U.log((ok and "PASS " or "FAIL ") .. line) + end + + local function shot(name) + U.wait(4) + U.shot(game, ("%s/%s.png"):format(out, name)) + end + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + local save = game.save + + save.pokedex = save.pokedex or {} + save.pokedex.seen = save.pokedex.seen or {} + save.pokedex.caught = save.pokedex.caught or {} + save.pokedex.seen[SPECIES] = true + save.pokedex.caught[SPECIES] = true + + local johto = Nests.find(game.data, SPECIES, "johto", save) + local kanto = Nests.find(game.data, SPECIES, "kanto", save) + pass(#johto == 2, ("%s has %d Johto nests; the cart's FindNest answers 2 " .. + "(Routes 38 and 39)"):format(SPECIES, #johto)) + pass(#kanto == 0, ("and %d in Kanto, where it answers none"):format(#kanto)) + + local gear = game.data and game.data.gen2MenuGfx and game.data.gen2MenuGfx.pokegear + pass(gear ~= nil and gear.maps ~= nil, "this cache carries the town maps") + pass(gear ~= nil and gear.nestIcon ~= nil, + "and PokedexNestIconGFX; without it the markers fall back to a plain " .. + "square and the cache wants a re-import") + + local dex = PokedexMenu.new(game, {}) + for i, row in ipairs(dex.rows) do + if row.species == SPECIES then dex.index = i break end + end + dex:ensureVisible() + dex.view = "area" + dex.areaRegion = nil + game.stack:push(dex) + U.wait(10) + + pass(dex:areaRegionName() == "johto", + "the page opens on Johto, whatever region the player is standing in") + + dex.areaBlink = 0 + U.log("00-johto-on: the Johto map, edge to edge, with no red or orange") + U.log("anywhere. row 0 is a cream strip reading \"MILTANK'S NEST\" in dark") + U.log("letters from the third column, row 1 the map's own rule. two markers") + U.log("blink over Routes 38 and 39, west of the middle. the word JOHTO is") + U.log("only the one baked into the map at the bottom right; no landmark") + U.log("name, no \"+1\", no second JOHTO up top.") + shot("00-johto-on") + + dex.areaBlink = 16 + U.log("01-johto-off: the same screen with both markers gone. sixteen frames") + U.log("on, sixteen off.") + shot("01-johto-off") + + dex.areaBlink = 0 + dex:updateArea({ wasPressed = function(_, b) return b == "right" end }) + pass(dex:areaRegionName() == "johto" or HallOfFame.hasEntered(save), + "right is refused until the Hall of Fame bit is set") + + HallOfFame.record(save).count = math.max(1, HallOfFame.count(save)) + dex:updateArea({ wasPressed = function(_, b) return b == "right" end }) + dex.areaBlink = 0 + pass(dex:areaRegionName() == "kanto", "and taken once it is") + U.log("02-kanto: the Kanto map with nothing blinking on it and the same") + U.log("cream caption on top. no \"AREA UNKNOWN\" printed over the baked-in") + U.log("KANTO at the bottom left.") + shot("02-kanto") + + U.log(("%d check(s) failed"):format(failed)) + + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_pack_sfx_bug1893_test.lua b/tests/drivers/gold_pack_sfx_bug1893_test.lua new file mode 100644 index 00000000..aa0599ff --- /dev/null +++ b/tests/drivers/gold_pack_sfx_bug1893_test.lua @@ -0,0 +1,120 @@ +-- engine/menus/scrolling_menu.asm:23 (#1893) +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_pack_sfx_bug1893_test.lua love . +-- No POKEPORT_SPEED: the cues are the thing under test. +local U = require("tests.drivers.util") + +local Bag = require("src.inventory.Bag") +local PackMenu = require("src.ui.gen2.PackMenu") +local Sound = require("src.core.Sound") + +local HOME = { map = "NEW_BARK_TOWN", x = 13, y = 6 } + +local SEED = { + { "POTION", 5 }, + { "SUPER_POTION", 3 }, + { "ESCAPE_ROPE", 2 }, + { "POKE_BALL", 7 }, +} + +local CUES = { "Sfx_ReadText2", "Sfx_SwitchPockets", "Sfx_SwitchPokemon" } + +return function(game) + local failed = 0 + + local function pass(ok, line) + if not ok then failed = failed + 1 end + U.log((ok and "PASS " or "FAIL ") .. line) + end + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + local world, save = game.world, game.save + + world:setMap(HOME.map, HOME.x, HOME.y, "down") + U.wait(12) + + local sfx = game.data and game.data.audio and game.data.audio.sfx or {} + for _, name in ipairs(CUES) do + pass(sfx[Sound.resolve(game.data, name)] ~= nil, + ("this cache carries %s, so the PACK has something to play"):format(name)) + end + + save.inventory = {} + save.bagOrder = {} + for _, entry in ipairs(SEED) do + if game.data.items and game.data.items[entry[1]] then + save.inventory[entry[1]] = entry[2] + table.insert(save.bagOrder, entry[1]) + else + pass(false, entry[1] .. " is not in this cache, so its row is missing") + end + end + Bag.order(save, { items = game.data.items }) + + local heard = {} + local realPlay = Sound.play + Sound.play = function(data, name) + heard[#heard + 1] = name + return realPlay(data, name) + end + + local function since(mark) + return heard[#heard] ~= nil and #heard > mark and heard[#heard] or "nothing" + end + + game.packCursor = nil + local pack = PackMenu.new(game, { save = save, world = world, + onClose = function() game.stack:pop() end }) + game.stack:push(pack) + U.wait(20) + + local function tap(button, frames) + U.tap(game, button) + U.wait(frames or 25) + end + + U.log("listen from here: five sounds, in this order.") + + local mark = #heard + tap("down") + tap("up") + pass(#heard == mark, "moving up and down the list plays nothing") + U.log("1. no sound at all while the arrow walks the list.") + + mark = #heard + tap("right") + pass(since(mark) == "Sfx_SwitchPockets", + ("right into POKe BALLS played %s"):format(since(mark))) + U.log("2. the pocket flip: a short two-note sweep as POKe BALLS comes up.") + + mark = #heard + tap("left") + pass(since(mark) == "Sfx_SwitchPockets", + ("left back to ITEMS played %s"):format(since(mark))) + U.log("3. the same sweep coming back to ITEMS.") + + mark = #heard + tap("a") + pass(since(mark) == "Sfx_ReadText2", + ("A on POTION played %s"):format(since(mark))) + U.log("4. the menu click as the USE/GIVE/TOSS box opens, and the same") + U.log(" click again on the B below.") + tap("b") + + mark = #heard + tap("select") + pass(#heard == mark, "SELECT arming the move is silent on the cart too") + tap("down") + tap("a") + pass(since(mark) == "Sfx_SwitchPokemon", + ("placing the item played %s"):format(since(mark))) + U.log("5. the party-swap chirp as POTION lands on the second row. the cart") + U.log(" plays it twice, one after the other; this port plays it once.") + + Sound.play = realPlay + U.log(("%d cue(s) missing"):format(failed)) + U.log("cues dispatched: " .. table.concat(heard, ", ")) + + while true do coroutine.yield() end +end diff --git a/tests/drivers/link_addr_entry_bug1295_test.lua b/tests/drivers/link_addr_entry_bug1295_test.lua new file mode 100644 index 00000000..050536be --- /dev/null +++ b/tests/drivers/link_addr_entry_bug1295_test.lua @@ -0,0 +1,39 @@ +-- POKEPORT_DRIVER=tests/drivers/link_addr_entry_bug1295_test.lua POKEPORT_IDENTITY=bug1295 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local LinkState = require("src.link.LinkState") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + check("10.0.0.1 round-trips unpadded", + LinkState.addrText(LinkState.addrEntry("10.0.0.1")) == "10.0.0.1") + check("192.168.1.40 does not gain trailing zeroes", + LinkState.addrText(LinkState.addrEntry("192.168.1.40")) == "192.168.1.40") + + U.newGame(game) + U.wait(10) + + local link = LinkState.new(game) + link.stage = "addrEntry" + link.addr = LinkState.addrEntry("10.42.0.1") + game.stack:push(link) + U.wait(5) + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + U.shot(game, SHOT_DIR .. "/bug1295_addr_entry.png") + U.log("captured", SHOT_DIR .. "/bug1295_addr_entry.png") + + U.log("ENTER HOST ADDRESS is on screen, seeded with 10.42.0.1 and reading") + U.log("exactly that, the way the HOSTING screen prints it. Before #1295 it") + U.log("was twelve fixed digits and the same address read 010.042.000.001.") + U.log("Left/right walks the slots, up/down cycles 0-9 then '.' then blank,") + U.log("so a shorter address just leaves the tail slots empty. A on") + U.log("something that is not a dotted quad says so instead of dialling.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/menu_hold_scroll_bug1898_test.lua b/tests/drivers/menu_hold_scroll_bug1898_test.lua new file mode 100644 index 00000000..8a3f9fb1 --- /dev/null +++ b/tests/drivers/menu_hold_scroll_bug1898_test.lua @@ -0,0 +1,72 @@ +-- home/list_menu.asm:8 (#1898) +-- POKEPORT_DRIVER=tests/drivers/menu_hold_scroll_bug1898_test.lua POKEPORT_IDENTITY=bug1898 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Screens = require("src.ui.Screens") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local STOCK = { + "POTION", "SUPER_POTION", "ANTIDOTE", "PARLYZ_HEAL", "AWAKENING", + "BURN_HEAL", "ICE_HEAL", "REPEL", "ESCAPE_ROPE", "POKE_BALL", + "GREAT_BALL", "ULTRA_BALL", + } + game.save.inventory = {} + game.save.bagOrder = {} + for i, id in ipairs(STOCK) do + game.save.inventory[id] = 1 + game.save.bagOrder[i] = id + end + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(10) + + Screens.push(game, "BagMenu", {}) + U.wait(10) + + local list = game.stack:top() + check("the bag list is on top of the stack", list ~= nil and list.items ~= nil) + if not (list and list.items) then + while true do coroutine.yield() end + end + check("hold-to-scroll is on with no mod loaded", list.keyRepeat == true) + check("at the cart's 30-frame delay", list.repeatDelay == 30) + check("and its 5-frame repeat", list.repeatRate == 5) + + table.insert(game.input.pressQueue, "down") + game.input.state.down = true + local marks = {} + for frame = 1, 90 do + coroutine.yield() + marks[frame] = list.index + end + game.input.state.down = false + + U.log("cursor row after the press:", marks[1]) + U.log("after 29 held frames:", marks[30]) + U.log("after 30:", marks[31], "after 35:", marks[36], "after 59:", marks[60]) + check("the press alone moves one row", marks[1] == 2) + check("a short hold moves nothing more", marks[30] == 2) + check("the 30th held frame repeats", marks[31] == 3) + check("and every fifth frame after it", marks[36] == 4) + check("a long hold reaches the CANCEL row", marks[90] == #list.items) + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + if U.shot(game, SHOT_DIR .. "/bug1898_bag_bottom.png") then + U.log("captured", SHOT_DIR .. "/bug1898_bag_bottom.png") + end + + list.index, list.scroll = 1, 0 + U.wait(5) + U.log("The bag is open at the top of a twelve-item list, input handed back.") + U.log("Hold Down: the cursor steps once, pauses about half a second, then") + U.log("runs to CANCEL at roughly twelve rows a second. Hold Up to come") + U.log("back. Before this fix the cursor moved one row and stopped there.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/party_bug147_message_test.lua b/tests/drivers/party_bug147_message_test.lua index cff18bbe..22f94a4f 100644 --- a/tests/drivers/party_bug147_message_test.lua +++ b/tests/drivers/party_bug147_message_test.lua @@ -1,7 +1,6 @@ -- Driver: party menu bottom context message (#147). -- Gen1 (pokered engine/menus/party_menu.asm PartyMenuMessage) always prints --- a message in the bottom text box: "Choose a POKéMON." (PartyMenuNormalText) --- in the field, "Bring out which POKéMON?" (PartyMenuBattleText) in battle. +-- the bottom text box message (core.asm:2315, #1901). -- The recomp handled only the swap / item / TM-HM ids and printed NOTHING for -- the default field and battle voluntary-switch cases -- reporter's "NO TEXT -- BOX". This driver opens the party menu in both contexts, screenshots each, @@ -48,12 +47,15 @@ return function(game) ow:pushBattle(battle) local function mashUntil(cond, max) - for _ = 1, max or 80 do + for _ = 1, max or 200 do if cond() then return true end U.tap(game, "a") - U.wait(4) + for _ = 1, 4 do + if cond() then return true end + U.wait(1) + end end - return false + return cond() end check("reached battle menu", mashUntil(function() return battle.phase == "menu" @@ -67,8 +69,17 @@ return function(game) local battleMsg = pm and pm.bottomMessage and pm:bottomMessage() U.log("battle party open (onSwitch set):", pm and pm.onSwitch ~= nil) U.log("battle bottomMessage:", tostring(battleMsg)) - check("battle message == 'Bring out which\\nPOKéMON?'", - battleMsg == "Bring out which\nPOKéMON?") + check("voluntary battle message == 'Choose a POKéMON.'", + battleMsg == "Choose a POKéMON.") + + local forced = require("src.ui.PartyMenu").new(game, { + battle = battle, party = game.save.party, forceSwitch = true, + onSwitch = function() end, + }) + local forcedMsg = forced:bottomMessage() + U.log("forced-switch bottomMessage:", tostring(forcedMsg)) + check("forced switch message == 'Bring out which\\nPOKéMON?'", + forcedMsg == "Bring out which\nPOKéMON?") U.log(("RESULT pass=%d fail=%d"):format(pass, fail)) end diff --git a/tests/drivers/poison_flicker_bug1872_test.lua b/tests/drivers/poison_flicker_bug1872_test.lua new file mode 100644 index 00000000..ffd3a112 --- /dev/null +++ b/tests/drivers/poison_flicker_bug1872_test.lua @@ -0,0 +1,76 @@ +-- pokered engine/gfx/screen_effects.asm:1-12 (#1872) +-- POKEPORT_DRIVER=tests/drivers/poison_flicker_bug1872_test.lua POKEPORT_IDENTITY=bug1872 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local PaletteFX = require("src.render.PaletteFX") + local Zoom = require("src.render.Zoom") + local Renderer = require("src.render.Renderer") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.save.party[1].status = "PSN" + game.save.player.name = "bryan" + + local lo = Zoom.offsetRange(Renderer:fitScale()) + game.save.options.zoom = lo + Zoom.applyOptions(game.save.options) + + U.teleport(game, "ROUTE_1", 5, 20, "down") + U.wait(20) + + local rects = {} + local realRect = love.graphics.rectangle + love.graphics.rectangle = function(mode, x, y, w, h, ...) + if mode == "fill" and x == 0 and y == 0 and w == 160 and h == 144 then + rects[#rects + 1] = true + end + return realRect(mode, x, y, w, h, ...) + end + + -- engine/events/poison.asm + local ow, armed, peak = game.overworld, false, 0 + for _ = 1, 160 do + local dir = (_ % 2 == 0) and "up" or "down" + for _ = 1, 2 do + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + coroutine.yield() + ow = game.overworld + local n = ow and ow.poisonFlash or 0 + if n > peak then peak = n end + if n > 0 then armed = true end + end + game.input.state[dir] = false + if armed then break end + end + check("a poison tick armed the flicker while walking", armed) + U.log("tinted frames", peak + 1) + if armed then + check("it arms four frames, like `ld c, 4 / call DelayFrames`", peak + 1 == 4) + end + + local sawMap = false + for _ = 1, 6 do + U.wait(1) + if PaletteFX.shadeMap() == PaletteFX.POISON_BGP then sawMap = true end + end + check("the flicker goes through the rBGP shade map, not a canvas overlay", + sawMap) + check("nothing filled the 160x144 UI canvas during the flash", #rects == 0) + love.graphics.rectangle = realRect + + U.shot(game, "poison_flicker_bug1872.png") + U.log("walk around with the poisoned CHARIZARD: every fourth step the WHOLE") + U.log("window should darken for four frames, survey map and letterbox bars") + U.log("included, not just the 160x144 box in the middle.") + U.log("white background pixels drop one shade; sprites and dark tiles stay put.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/prize_room_coincase_bug194_test.lua b/tests/drivers/prize_room_coincase_bug194_test.lua index 32a8b000..730594ad 100644 --- a/tests/drivers/prize_room_coincase_bug194_test.lua +++ b/tests/drivers/prize_room_coincase_bug194_test.lua @@ -3,12 +3,10 @@ -- COIN CASE: IsItemInBag COIN_CASE first, and with no case it prints -- RequireCoinCaseText and returns without ever opening a window; only with the -- case does it print ExchangeCoinsForPrizesText and then show the prize list. --- The port used to open "PRIZES (COINS)" unconditionally with no intro line. -- -- The three prize counters are bg-event signs at cells (2,2),(4,2),(6,2) in -- GAME_CORNER_PRIZE_ROOM (data/generated/maps.lua). Stand south of vendor 1 --- and press A: no-case -> require box and NO list; has-case -> exchange box, --- then the prize list; cancel returns to the overworld (onCancel == done). +-- and press A: no-case -> require box and NO list; has-case -> exchange box. -- -- SHOT_DIR=/tmp/prize194 POKEPORT_IDENTITY=bug194 POKEPORT_TOUCH=0 \ -- POKEPORT_DRIVER=tests/drivers/prize_room_coincase_bug194_test.lua love . @@ -20,7 +18,7 @@ return function(game) local Pokemon = require("src.pokemon.Pokemon") local TextBox = require("src.render.TextBox") - local ListMenu = require("src.ui.ListMenu") + local PrizeCounter = require("src.ui.PrizeCounter") -- a party so nothing else blocks overworld interaction game.save.party = { Pokemon.new(game.data, "BULBASAUR", 5) } @@ -102,19 +100,27 @@ return function(game) assert(sawExchange, "has-case: 'We exchange your coins for prizes.' never shown (#194)") - -- advance past the exchange line; the prize list must then open local sawList = false for _ = 1, 60 do - if topMeta() == ListMenu then sawList = true break end + if topMeta() == PrizeCounter then sawList = true break end U.tap(game, "a") U.wait(1) end - U.log("has-case: sawList", sawList, "title", - (sawList and game.stack:top().title) or "-") + U.log("has-case: sawList", sawList, "rows", + (sawList and #game.stack:top().prizes) or "-") U.shot(game, DIR .. "/prize_room_3_menu.png") - assert(sawList, "has-case: prize ListMenu never opened after exchange text") - assert(game.stack:top().title == "PRIZES (COINS)", - "has-case: opened list is not the prize window") + assert(sawList, "has-case: prize window never opened after exchange text") + local under = game.stack.states[#game.stack.states - 1] + assert(getmetatable(under) == TextBox, + "has-case: WhichPrizeText should stay in the box under the window") + U.log("has-case: names", + table.concat({ game.stack:top().prizes[1].name, + game.stack:top().prizes[2].name, + game.stack:top().prizes[3].name }, ", ")) + for _, row in ipairs(game.stack:top().prizes) do + assert(not tostring(row.name):find("L%d"), + "has-case: prize names carry no level (GetMonName only)") + end -- cancel returns to the overworld (onCancel == done) U.tap(game, "b") diff --git a/tests/drivers/route.lua b/tests/drivers/route.lua index 4cad673d..5432bb85 100644 --- a/tests/drivers/route.lua +++ b/tests/drivers/route.lua @@ -6504,10 +6504,7 @@ end -- to MR_FUJIS_HOUSE" however hard it searches. One FRESH_WATER opens every -- gate permanently. -- --- The machines are SIGNS (10,1) (11,1) (12,2), not clerks, so ops.shop --- cannot drive them -- they open a plain ListMenu (data/scripts/story4.lua --- vendingMachine) which stays up between purchases, showing a "popped out!" --- box over itself each time. +-- The machines are SIGNS (10,1) (11,1) (12,2), not clerks. -- -- Buys several: one goes to the guards, and the roof's thirsty girl trades -- the others for TMs. Nothing later depends on those TMs, so a short bag or @@ -6526,32 +6523,27 @@ function MANUAL.giveWater(where) note("giveWater: no vending machine here", where) return false end - -- a sign is read from the cell below it, facing up - if not ops.goto_({ x = sign.x, y = sign.y + 1 }) then - note("giveWater: cannot reach the vending machine", where) - return false - end - faceDir("up") - press("a") - U.wait(10) - if not waitFor(isList, 40) then - note("giveWater: the vending machine did not open", where) - say("giveWater: no vending menu appeared") - backOut() - return false - end local bought = 0 for _ = 1, VENDING_WANT do - if not isList() then break end - cursorTo("index", 1) -- FRESH_WATER, the cheapest at 200 - press("a") - U.wait(12) - -- the purchase (or "Not enough money") prints over the list; clear it - for _ = 1, 20 do - if isList() then break end - press("a") - U.wait(4) + if not ops.goto_({ x = sign.x, y = sign.y + 1 }) then + note("giveWater: cannot reach the vending machine", where) + break end + faceDir("up") + press("a") + U.wait(10) + if not pressUntil(isMenu, "a", 20) then + if bought == 0 then + note("giveWater: the vending machine did not open", where) + say("giveWater: no vending menu appeared") + end + backOut() + break + end + cursorTo("index", 1) -- FRESH WATER, the cheapest at 200 + press("a") + U.wait(140) + mashUntilIdle() bought = bought + 1 end backOut() diff --git a/tests/drivers/snorlax_flute_bug1880_test.lua b/tests/drivers/snorlax_flute_bug1880_test.lua new file mode 100644 index 00000000..22683eef --- /dev/null +++ b/tests/drivers/snorlax_flute_bug1880_test.lua @@ -0,0 +1,106 @@ +-- pokered engine/items/item_effects.asm:1794 (#1880) +-- POKEPORT_DRIVER=tests/drivers/snorlax_flute_bug1880_test.lua \ +-- POKEPORT_IDENTITY=bug1880 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +-- No POKEPORT_SPEED: the tune runs on the audio clock. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Screens = require("src.ui.Screens") + local Bag = require("src.inventory.Bag") + local Pokemon = require("src.pokemon.Pokemon") + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1; U.log("PASS", label) + else fail = fail + 1; U.log("FAIL", label) end + return ok + end + + local vol = game.save.options and game.save.options.sfxVol + if vol == 0 then + U.log("sfxVol is 0; turn it up in OPTION or the ear half of this check is moot") + end + + game.save.party = { Pokemon.new(game.data, "PIDGEOTTO", 30) } + Bag.add(game.save, "POKE_FLUTE", 1) + + U.teleport(game, "ROUTE_12", 10, 30, "down") + U.wait(20) + local ow = game.overworld + local snorlax + for _, npc in ipairs(ow.npcs or {}) do + if npc.def and tostring(npc.def.name or ""):find("SNORLAX") then snorlax = npc end + end + if not check("ROUTE_12 has its SNORLAX", snorlax ~= nil) then + U.log(("RESULT pass=%d fail=%d"):format(pass, fail)) + while true do coroutine.yield() end + end + U.teleport(game, "ROUTE_12", snorlax.cellX, snorlax.cellY - 1, "down") + U.wait(20) + ow = game.overworld + + local bag = Screens.push(game, "BagMenu") + U.wait(20) + local row + for i, item in ipairs(bag.items) do + if item.value == "POKE_FLUTE" then row = i end + end + if not check("the POKé FLUTE is in the bag", row ~= nil) then + U.log(("RESULT pass=%d fail=%d"):format(pass, fail)) + while true do coroutine.yield() end + end + bag.index = row + U.tap(game, "a") + U.wait(20) + if game.stack:top() ~= bag and not game.stack:top().isTextBox then + U.tap(game, "a") + U.wait(20) + end + + local box + for _ = 1, 240 do + local top = game.stack:top() + if top and top.isTextBox then box = top break end + U.wait(1) + end + if not check("the played-flute box opened", box ~= nil) then + U.log(("RESULT pass=%d fail=%d"):format(pass, fail)) + while true do coroutine.yield() end + end + + for _ = 1, 240 do + if box.done then break end + U.tap(game, "a") + end + check("the line typed out", box.done == true) + check("nothing has played yet", box.autoStarted ~= true and box.autoSrc == nil) + U.log("the box should read ' played the POKé FLUTE.' with a blinking") + U.log("arrow and NO tune yet; the map music is still going") + U.shot(game, "/tmp/shots/bug1880_prompt.png") + + U.tap(game, "a") + U.wait(2) + check("the prompt was answered", box.autoPrompted == true) + check("the tune started", box.autoSrc ~= nil) + U.log("the tune plays now, with the same box still on screen") + + local heldFrames, wokeEarly = 0, false + for _ = 1, 600 do + local playing = box.autoSrc and box.autoSrc.isPlaying and box.autoSrc:isPlaying() + if not playing then break end + heldFrames = heldFrames + 1 + if game.stack:top() ~= box then wokeEarly = true break end + U.tap(game, "a") + end + check("A never cut the tune short", not wokeEarly) + check("the tune held the box for a while", heldFrames > 30) + U.log(("the box stayed up for %d frames of tune"):format(heldFrames)) + + U.wait(30) + U.log("SNORLAX should be waking up only now, after the tune finished") + U.shot(game, "/tmp/shots/bug1880_after.png") + U.log(("RESULT pass=%d fail=%d"):format(pass, fail)) + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/touch_skin_crop_bug1677_test.lua b/tests/drivers/touch_skin_crop_bug1677_test.lua new file mode 100644 index 00000000..846804ac --- /dev/null +++ b/tests/drivers/touch_skin_crop_bug1677_test.lua @@ -0,0 +1,72 @@ +-- SHOT_DIR=/tmp/skin1677 POKEPORT_TOUCH=1 \ +-- POKEPORT_DRIVER=tests/drivers/touch_skin_crop_bug1677_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local TouchControls = require("src.core.TouchControls") + local TouchSkin = require("src.core.TouchSkin") + local Pokemon = require("src.pokemon.Pokemon") + + local dir = os.getenv("SHOT_DIR") or "/tmp/skin1677" + local skinId = os.getenv("SKIN") or "gb_anim" + local shotW = tonumber(os.getenv("SHOT_W")) or 1600 + local shotH = tonumber(os.getenv("SHOT_H")) or 720 + love.window.setMode(shotW, shotH, { resizable = true, highdpi = true }) + U.wait(2) + + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.save.player.name = "bryan" + game.save.options.touchControls = { enabled = true, skin = skinId } + game.save.options.tilt = 0 + game.save.options.zoom = 0 + game.save.options.pipelines = {} + game:applyOptions() + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(10) + + local ww, wh = love.graphics.getDimensions() + local page = TouchSkin.page() + U.log("skin:", tostring(TouchControls.skinId), "page:", page and page.name, + "window:", ww .. "x" .. wh) + if not page then + U.log("FAIL: no skin page active, cannot judge the overlay art") + while true do coroutine.yield() end + end + + local bx, by, bw, bh = TouchSkin.pageBox(page, ww, wh) + U.log("page box:", string.format("%.1f,%.1f %.1fx%.1f", bx, by, bw, bh)) + + local function report(label, img, w, h) + if not img then return true end + local iw, ih = img:getWidth(), img:getHeight() + local sx, sy = TouchSkin.imageFit(iw, ih, w, h) + if not sx then + U.log("FAIL:", label, "zero-sized image") + return false + end + local dw, dh = iw * sx, ih * sy + local fits = dw <= w + 0.01 and dh <= h + 0.01 + U.log(string.format("%-14s art %dx%d dest %.1fx%.1f drawn %.1fx%.1f%s", + label, iw, ih, w, h, dw, dh, fits and "" or " CROPPED")) + return fits + end + + local ok = report("page bezel", page.image, bw, bh) + for i, ctl in ipairs(page.controls) do + if ctl.image then + local _, _, halfW, halfH = TouchSkin.controlGeometry(page, ctl, ww, wh) + local label = (ctl.buttons and ctl.buttons[1]) or ("desc" .. (i - 1)) + if not report(label, ctl.image, halfW * 2, halfH * 2) then ok = false end + end + end + + U.log(ok and "PASS: every overlay bitmap draws whole inside its dest box" + or "FAIL: some overlay art is drawn larger than its dest box") + U.shot(game, dir .. "/skin_wide.png") + U.log("shot:", dir .. "/skin_wide.png") + U.log("right looks like: each button keeps its full outline ring, and the") + U.log("art may look stretched on a window that is not the overlay aspect,") + U.log("but no edge of any button graphic is sliced off.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/vending_machine_bug1876_test.lua b/tests/drivers/vending_machine_bug1876_test.lua new file mode 100644 index 00000000..5d918712 --- /dev/null +++ b/tests/drivers/vending_machine_bug1876_test.lua @@ -0,0 +1,124 @@ +-- engine/events/vending_machine.asm (#1876) +-- POKEPORT_DRIVER=tests/drivers/vending_machine_bug1876_test.lua POKEPORT_IDENTITY=bug1876 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + os.execute("mkdir -p " .. SHOT_DIR) + + local Pokemon = require("src.pokemon.Pokemon") + local TextBox = require("src.render.TextBox") + local Menu = require("src.ui.Menu") + + local ok = true + local function check(label, pass) + if not pass then ok = false end + U.log(pass and "PASS" or "FAIL", label) + return pass + end + + local function waitForArrow(box) + for _ = 1, 240 do + if box.waiting or box.done then return true end + U.wait(1) + end + return false + end + + game.save.party = { Pokemon.new(game.data, "BULBASAUR", 5) } + game.save.money = 3000 + + U.teleport(game, "CELADON_MART_ROOF", 10, 2, "up") + U.wait(20) + + local ow = game.overworld + local sign + for _, s in ipairs((ow.map.def and ow.map.def.signs) or {}) do + if tostring(s.text or s.id or ""):find("VENDING_MACHINE") then + sign = s + break + end + end + if not check("the roof has a vending machine sign", sign ~= nil) then + while true do coroutine.yield() end + end + U.teleport(game, "CELADON_MART_ROOF", sign.x, sign.y + 1, "up") + U.wait(20) + + U.tap(game, "a") + U.wait(40) + local intro = game.stack:top() + check("A on the machine opens a text box", getmetatable(intro) == TextBox) + local said = {} + for _, page in ipairs(intro.pages or {}) do + for _, line in ipairs(page) do said[#said + 1] = line end + end + U.log("it reads:", table.concat(said, " / ")) + check("it is VendingMachineText1", + table.concat(said, " "):find("vending machine", 1, true) ~= nil) + check("the money box rides along", intro.money ~= nil) + waitForArrow(intro) + U.shot(game, SHOT_DIR .. "/bug1876_1_intro.png") + + U.tap(game, "a") + U.wait(30) + local menu = game.stack:top() + check("the drink list opens over it", getmetatable(menu) == Menu) + if getmetatable(menu) ~= Menu then + while true do coroutine.yield() end + end + check("the greeting is still in the box underneath", + game.stack.states[#game.stack.states - 1] == intro) + check("the list is not a screen of its own", menu.isOpaque ~= true) + check("TextBoxBorder at hlcoord 0,3, 14x10", + menu.tx == 0 and menu.ty == 3 and menu.tw == 14 and menu.th == 10) + local labels = {} + for i, item in ipairs(menu.items) do labels[i] = item.label end + U.log("rows:", table.concat(labels, ", ")) + check("three drinks and CANCEL", #menu.items == 4 + and labels[4] == "CANCEL") + U.shot(game, SHOT_DIR .. "/bug1876_2_menu.png") + + U.tap(game, "up") + U.wait(10) + check("Up on FRESH WATER does not wrap onto CANCEL", menu.index == 1) + + local moneyBefore = game.save.money + local heldBefore = game.save.inventory.FRESH_WATER or 0 + U.tap(game, "a") + U.wait(150) -- 120 frames of rumble, then the popped-out line + local result = game.stack:top() + check("the delivery ends in a text box", getmetatable(result) == TextBox) + local out = {} + for _, page in ipairs((result.pages) or {}) do + for _, line in ipairs(page) do out[#out + 1] = line end + end + U.log("it reads:", table.concat(out, " / ")) + check("it is VendingMachineText5", + table.concat(out, " "):find("popped out", 1, true) ~= nil) + check("the drink is in the bag", + (game.save.inventory.FRESH_WATER or 0) == heldBefore + 1) + check("and it cost 200", game.save.money == moneyBefore - 200) + check("the drink list is gone by then", + game.stack.states[#game.stack.states - 1] == ow) + waitForArrow(result) + U.shot(game, SHOT_DIR .. "/bug1876_3_popped.png") + + U.tap(game, "a") + U.wait(30) + check("one A closes the whole conversation", game.stack:top() == ow) + + U.log(ok and "checks are green, the screen is worth looking at." + or "something above says FAIL, do not trust what is on screen.") + U.log("shots are in " .. SHOT_DIR .. ".") + U.log("") + U.log("the half a machine cannot judge: bug1876_2_menu.png should show the") + U.log("roof tiles to the right of column 14 and the greeting still in the") + U.log("bottom box, with MONEY top-right and the prices one row under each") + U.log("drink. talk to the machine again and press A on a drink: the rumble") + U.log("is SFX_PUSH_BOULDER restarted 60 times over two seconds, and the") + U.log("popped-out line lands while the last one is still decaying.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/widescreen_clip_bug1869_test.lua b/tests/drivers/widescreen_clip_bug1869_test.lua new file mode 100644 index 00000000..b9d7d1c1 --- /dev/null +++ b/tests/drivers/widescreen_clip_bug1869_test.lua @@ -0,0 +1,166 @@ +-- engine/events/halloffame.asm:270 (#1869) +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/widescreen_clip_bug1869_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/widescreen-clip love . +local U = require("tests.drivers.util") + +local Chrome = require("src.ui.gen2.Chrome") +local Mon = require("src.battle.gen2.Mon") +local Permissions = require("src.world.gen2.Permissions") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/widescreen-clip" + 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 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") + + 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 scale = Chrome.fitScale(winW, winH) + local ox, oy = Chrome.fitOrigin(winW, winH, scale) + ok(("the window leaves bars to look at (%dx%d, panel at %d,%d x%d)") + :format(winW, winH, ox, oy, scale), ox > 8 and oy > 8, ox .. "," .. oy) + + local G = love.graphics + local function panelClip(label, drawIt) + local rects = {} + local realSet, realIntersect = G.setScissor, G.intersectScissor + G.setScissor = function(...) rects[#rects + 1] = { ... } return realSet(...) end + if realIntersect then + G.intersectScissor = function(...) + rects[#rects + 1] = { ... } + return realIntersect(...) + end + end + local fine, err = pcall(drawIt) + G.setScissor, G.intersectScissor = realSet, realIntersect + if not fine then + ok(label .. " draws", false, err) + return + end + local matched = false + for _, r in ipairs(rects) do + if r[1] == ox and r[2] == oy and r[3] == Chrome.SCREEN_W * 8 * scale + and r[4] == Chrome.SCREEN_H * 8 * scale then + matched = true + end + end + ok(label .. " clips to the 160x144 panel before it draws", matched, + #rects .. " scissors, none matching " .. ox .. "," .. oy) + end + + game.save.party = { Mon.new(game.data, "CYNDAQUIL", 12) } + 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 } }) 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 + assert(world:startBattle({ wild = Mon.new(game.data, "PIDGEY", 4) }), + "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", battle ~= nil, battle) + if battle then + U.log("01: the first frames of the battle intro. The player's backpic") + U.log("slides in from the right EDGE OF THE PANEL. The white surround") + U.log("around it must be empty -- a trainer sprite parked out in the white") + U.log("void to the right of the screen is the bug.") + shot(out .. "/01-battle-intro.png") + U.wait(20) + shot(out .. "/02-battle-intro-mid.png") + for _ = 1, 150 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(2) + end + U.log("03: the FIGHT menu. Nothing at all outside the panel.") + shot(out .. "/03-battle-menu.png") + panelClip("BattleState:drawWidescreen", + function() battle:drawWidescreen(winW, winH) end) + game.stack:pop() + U.wait(10) + end + + world:hallOfFame(function() end) + local hof + for _ = 1, 600 do + local top = game.stack:top() + if top and top.phase then hof = top break end + U.wait(1) + end + ok("the Hall of Fame is up", hof ~= nil, hof) + if hof then + hof.phase = "backpic" + hof.scx = 0x50 + U.wait(2) + U.log("04: the Hall of Fame mid-slide. The bars left and right of the") + U.log("panel must be solid black. A whole backpic sitting in the left bar,") + U.log("or a scrap of one in the right bar, is the wrap copy leaking.") + shot(out .. "/04-hof-backpic.png") + panelClip("HallOfFame:drawWidescreen", + function() hof:drawWidescreen(winW, winH) end) + for _ = 1, 60 do + U.tap(game, "a") + U.wait(4) + if game.stack:top() ~= hof then break end + end + end + + local credits = game.stack:top() + if not (credits and credits.drawBorderStrips) then + game.stack:pop() + world:credits(function() end) + for _ = 1, 600 do + local top = game.stack:top() + if top and top.drawBorderStrips then credits = top break end + U.wait(1) + end + end + ok("the credits are rolling", credits ~= nil, credits) + if credits then + U.wait(120) + U.log("05: the credits. The two border strips run edge to edge INSIDE the") + U.log("panel only. Wavy white bands continuing through the black bars to") + U.log("the window edge is the strip loop drawing past x=0 and x=160.") + shot(out .. "/05-credits.png") + panelClip("Credits:drawWidescreen", + function() credits:drawWidescreen(winW, winH) end) + U.wait(240) + shot(out .. "/06-credits-later.png") + end + + print(failures == 0 and "[wide] PASS widescreen_clip_bug1869" + or ("[wide] FAIL widescreen_clip_bug1869 (%d)"):format(failures)) + U.log("the window is left at 1280x840; resize it and the bars must stay") + U.log("empty at every size.") + + while true do coroutine.yield() end +end diff --git a/tests/engine/amnesia_rest_share_anim_bug1881.lua b/tests/engine/amnesia_rest_share_anim_bug1881.lua new file mode 100644 index 00000000..f3789998 --- /dev/null +++ b/tests/engine/amnesia_rest_share_anim_bug1881.lua @@ -0,0 +1,35 @@ +-- engine/battle/animations.asm:452-473 (#1881) +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local AnimPlayer = require("src.battle.AnimPlayer") + +local function row(marker) + return { seq = { { effect = "SE_DARK_SCREEN_FLASH", sound = marker } } } +end + +local data = { moveAnims = { + AMNESIA = row("player_amnesia"), + REST = row("player_rest"), + CONF_ANIM = row("enemy_conf"), + SLP_ANIM = row("enemy_slp"), +} } + +local function firstSound(moveId, attackerIsPlayer) + local p = AnimPlayer.new(data) + p:start(moveId, attackerIsPlayer) + for _, ev in ipairs(p.events) do + if ev.sound then return ev.sound end + end +end + +T.eq(firstSound("AMNESIA", false), "enemy_conf", + "the foe's Amnesia plays CONF_ANIM, not a coord-flipped AmnesiaAnim") +T.eq(firstSound("REST", false), "enemy_slp", + "the foe's Rest plays SLP_ANIM") +T.eq(firstSound("AMNESIA", true), "player_amnesia", + "the player's Amnesia keeps its own row") +T.eq(firstSound("REST", true), "player_rest", + "the player's Rest keeps its own row") +T.eq(firstSound("CONF_ANIM", false), "enemy_conf", + "the status-check ids are untouched") diff --git a/tests/engine/cart_import_cancel_bug1813.lua b/tests/engine/cart_import_cancel_bug1813.lua new file mode 100644 index 00000000..43acb27a --- /dev/null +++ b/tests/engine/cart_import_cancel_bug1813.lua @@ -0,0 +1,93 @@ +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") + +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end +love.graphics.polygon = love.graphics.polygon or function() end + +local Kit = require("src.ui.kit.Kit") +local Platform = require("src.core.Platform") +local HostShell = require("src.core.HostShell") +local FilePicker = require("src.core.FilePicker") +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") + +love.graphics.getDimensions = function() return 1280, 720 end +love.graphics.getPixelDimensions = function() return 1280, 720 end + +local pumped = 0 +local realPump = HostShell.pumpHostEvents +HostShell.pumpHostEvents = function() pumped = pumped + 1 end + +local realPopen, realPclose = HostShell.popen, HostShell.pclose +HostShell.popen = function() + return { read = function() return "" end, close = function() end } +end +HostShell.pclose = function() end + +local realCanSpawn = Platform.canSpawnProcess +Platform.canSpawnProcess = function() return true end +local realGetOS = love.system.getOS +love.system.getOS = function() return "OS X" end + +local cancelled = FilePicker.open("Choose a cart", { label = "Cart", + exts = { "g1rcart" } }) +eq(cancelled, nil, "empty chooser output reads as a cancel") +eq(pumped, 1, "the chooser's button-up is pumped once the read unblocks") + +HostShell.popen, HostShell.pclose = realPopen, realPclose +HostShell.pumpHostEvents = realPump +love.system.getOS = realGetOS + +local realOpen = FilePicker.open +FilePicker.open = function() return nil end + +local imp = RomImporter.new(function() end, { launcher = true }) +imp.tab = "red" +imp.ready = { red = true, blue = true } +imp._cartPopup = "red" + +love.mouse.isDown = function() return false end +imp._prevMouseDown = true +imp._mouseAt = { x = 10, y = 10 } +imp._clickPt = { x = 10, y = 10 } + +eq(imp:importCartFile("red"), false, "a cancelled cart import commits nothing") +eq(imp._cartPopup, "red", "Custom Carts stays open after a cancel") +eq(imp._cartNotice, nil, "a cancel is not an error worth a notice") +eq(imp._mouseAt, nil, "the armed drag is dropped when the dialog returns") +eq(imp._clickPt, nil, "no stale click survives the dialog") +eq(imp._prevMouseDown, false, + "the polled click machine resyncs to the real button state") + +local ok, err = pcall(LauncherView.draw, imp) +check(ok, "Custom Carts draws after the cancel: " .. tostring(err)) + +local close = nil +for i = 1, (Kit._navPrevN or 0) do + local slot = Kit._nav[i] + if slot and slot.id == "cartpop-close" then close = slot end +end +check(close ~= nil, "the Close control is in the modal's nav graph") + +if close then + LauncherView.clickAt(imp, close.x + close.w / 2, close.y + close.h / 2) + ok, err = pcall(LauncherView.draw, imp) + check(ok, "the click frame draws: " .. tostring(err)) + LauncherView.update(imp, 1 / 60) + eq(imp._cartPopup, nil, "Close still closes Custom Carts after a cancel") +end + +FilePicker.open = realOpen +Platform.canSpawnProcess = realCanSpawn + +local src = io.open("src/core/FilePicker.lua", "r"):read("*a") +check(not src:match("io%.popen%("), + "FilePicker never reaches io.popen directly") +check(src:match("HostShell%.pumpHostEvents"), + "FilePicker's blocking read pumps host events afterwards") + +print("ok cart import cancel keeps the modal live") diff --git a/tests/engine/cart_launcher.lua b/tests/engine/cart_launcher.lua index 40c0adad..2616200b 100644 --- a/tests/engine/cart_launcher.lua +++ b/tests/engine/cart_launcher.lua @@ -1281,10 +1281,15 @@ check(filterText:find("Filter by category", 1, true) == nil, "not by a category no cart has") find.findBase = nil --- MODS-tab scope still applies: a cart plays as exactly one game -find.modScope = "silver" -eq(#find:_findRows(), 1, "a scoped launcher lists only that game's carts") -eq(find:_findRows()[1].id, "indexed_cart", "the one based on silver") +find.modScope = "blue" +eq(#find:_findRows(), 2, "an unfiltered cart list ignores the launcher's game") +find.findBase = "silver" +eq(#find:_findRows(), 1, "the Filter popup is what narrows it") +eq(find:_findRows()[1].id, "indexed_cart", "to that base game's carts") +find.findBase = "gold" +eq(#find:_findRows(), 1, "including a game the launcher is not scoped to") +eq(find:_findRows()[1].id, "gold_rush", "which is the point of Filter") +find.findBase = nil find.modScope = nil find:_setFindKind("mods") diff --git a/tests/engine/fly_map_arrows_bug1892.lua b/tests/engine/fly_map_arrows_bug1892.lua new file mode 100644 index 00000000..4df37b1b --- /dev/null +++ b/tests/engine/fly_map_arrows_bug1892.lua @@ -0,0 +1,170 @@ +-- engine/items/town_map.asm:150, 167, 176, 185 (#1892) + +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 Font = require("src.render.Font") +local Theme = require("src.ui.Theme") +local TownMap = require("src.ui.TownMap") + +local LOCATIONS = { + PALLET_TOWN = { x = 4, y = 12, name = "PALLET TOWN" }, + VIRIDIAN_CITY = { x = 4, y = 11, name = "VIRIDIAN CITY" }, + CINNABAR_ISLAND = { x = 2, y = 15, name = "CINNABAR ISLAND" }, +} + +local function newGame() + local pressed = {} + local game = { + data = { + field = { + townMap = { + locations = LOCATIONS, + background = { + map = { 1 }, + tiles = { path = "assets/generated/townmap/tiles.png" }, + }, + }, + playerSprites = { walk = "SPRITE_RED", fly = "SPRITE_BIRD" }, + flyOrder = { "PALLET_TOWN", "VIRIDIAN_CITY", "CINNABAR_ISLAND" }, + flyWarps = { + PALLET_TOWN = { x = 1, y = 1 }, + VIRIDIAN_CITY = { x = 1, y = 1 }, + CINNABAR_ISLAND = { x = 1, y = 1 }, + }, + }, + sprites = { + SPRITE_RED = { image = "assets/generated/sprites/red_walk.png" }, + SPRITE_BIRD = { image = "assets/generated/sprites/bird.png" }, + }, + maps = { + PALLET_TOWN = { id = "PALLET_TOWN", index = 0 }, + VIRIDIAN_CITY = { id = "VIRIDIAN_CITY", index = 1 }, + CINNABAR_ISLAND = { id = "CINNABAR_ISLAND", index = 2 }, + }, + }, + save = { + visited = { PALLET_TOWN = true, VIRIDIAN_CITY = true, + CINNABAR_ISLAND = true }, + }, + overworld = { map = { id = "PALLET_TOWN" } }, + input = { wasPressed = function(_, name) + local p = pressed[name] + pressed[name] = nil + return p + end }, + stack = { pop = function() end }, + } + return game, function(name) pressed[name] = true end +end + +local function capture(tm) + local texts, codes, draws, polys = {}, {}, {}, {} + local realDraw, realPoly = love.graphics.draw, love.graphics.polygon + local realText, realCode = Font.draw, Font.drawCode + love.graphics.draw = function(img, quadOrX, x, y) + draws[#draws + 1] = { img = img, quad = quadOrX, x = x, y = y } + end + love.graphics.polygon = function(mode, ...) + polys[#polys + 1] = { mode = mode, ... } + end + Font.draw = function(text, x, y) + texts[#texts + 1] = { text = text, x = x, y = y } + return 0 + end + Font.drawCode = function(code, x, y) + codes[#codes + 1] = { code = code, x = x, y = y } + end + tm:draw() + love.graphics.draw, love.graphics.polygon = realDraw, realPoly + Font.draw, Font.drawCode = realText, realCode + return texts, codes, draws, polys +end + +local function findText(texts, want) + for _, t in ipairs(texts) do + if t.text == want then return t end + end + return nil +end + +local function upArrowAt(tm, draws, polys) + if tm.upArrow then + for _, d in ipairs(draws) do + if d.img == tm.upArrow then return d.quad end + end + return nil + end + for _, p in ipairs(polys) do + if p.mode == "fill" then return p[1] end + end + return nil +end + +local function codeAt(codes, code, x, y) + for _, c in ipairs(codes) do + if c.code == code and c.x == x and c.y == y then return true end + end + return false +end + +local game, tap = newGame() +local tm = TownMap.new(game, { fly = true, onFly = function() end }) +check(tm.fly == true, "the picker opens in fly mode") +eq(tm.mode, "grid", "the picker draws the Kanto map, not the name list") + +local texts, codes, draws, polys = capture(tm) +local to = findText(texts, "To") +check(to ~= nil, "ToText is printed on its own") +if to then eq(to.x, 0, "ToText sits at hlcoord 0, 0") end +local name = findText(texts, "PALLET TOWN") +check(name ~= nil, "the destination name is printed on its own") +if name then eq(name.x, 24, "the name sits at hlcoord 3, 0") end +check(findText(texts, "To PALLET TOWN") == nil, + "the banner is no longer one concatenated string at column 1") + +check(codeAt(codes, Theme.moreArrow, 152, 0), + "the down arrow is drawn at hlcoord 19, 0") +for _, c in ipairs(codes) do + check(c.code ~= Theme.cursor, + "the up arrow is never the $ED right-pointing cursor glyph") +end + +check(upArrowAt(tm, draws, polys) == nil, + "the up arrow is blank for the opening 15 frames") +for _ = 1, 15 do tm:update(0) end +local _, _, draws3, polys3 = capture(tm) +eq(upArrowAt(tm, draws3, polys3), 144, + "after 15 frames the up arrow is drawn at hlcoord 18, 0") + +tap("down") tm:update(0) +local _, codes4, draws4, polys4 = capture(tm) +eq(upArrowAt(tm, draws4, polys4), 144, "the up arrow stays put on DOWN") +check(not codeAt(codes4, Theme.moreArrow, 152, 0), + "the down arrow blanks while DOWN is held") +for _ = 1, 15 do tm:update(0) end +local _, codes5 = capture(tm) +check(codeAt(codes5, Theme.moreArrow, 152, 0), + "both arrows are back 15 frames later") + +tap("up") tm:update(0) +local _, codes6, draws6, polys6 = capture(tm) +check(upArrowAt(tm, draws6, polys6) == nil, "the up arrow blanks on UP") +check(codeAt(codes6, Theme.moreArrow, 152, 0), + "the down arrow stays put on UP") + +local game7 = newGame() +local tm7 = TownMap.new(game7, { fly = true, onFly = function() end }) +tm7.sel = 3 +local texts7 = capture(tm7) +local long = findText(texts7, "CINNABAR ISLAND") +check(long ~= nil, "the longest fly name is printed") +if long then + check(long.x + #"CINNABAR ISLAND" * 8 <= 144, + "a 15-column name ends before the arrow columns") +end + +T.finish("fly map arrows bug 1892") diff --git a/tests/engine/gen2_anim_bake_scissor_bug1895.lua b/tests/engine/gen2_anim_bake_scissor_bug1895.lua new file mode 100644 index 00000000..82ea6860 --- /dev/null +++ b/tests/engine/gen2_anim_bake_scissor_bug1895.lua @@ -0,0 +1,53 @@ +-- engine/battle_anims/bg_effects.asm:2638 (#1895, #1869) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local G = love.graphics + +local scissor = { 112, 24, 800, 720 } +local seenInCanvas = {} +G.intersectScissor = nil +G.setScissor = function(x, y, w, h) + if x then scissor = { x, y, w, h } else scissor = nil end +end +G.getScissor = function() + if scissor then return scissor[1], scissor[2], scissor[3], scissor[4] end +end + +local function drawBody(label) + seenInCanvas[label] = scissor and "clipped" or "clear" +end + +do + local BattleAnimView = require("src.ui.gen2.BattleAnimView") + local view = setmetatable({}, { __index = BattleAnimView }) + view:bake(function() drawBody("bake") end) + T.eq(seenInCanvas.bake, "clear", "the panel bakes with no window scissor") + T.eq(scissor and scissor[1], 112, "and the caller's scissor x comes back") + T.eq(scissor and scissor[4], 720, "with its height") + + scissor = nil + view:bake(function() drawBody("bake2") end) + T.eq(seenInCanvas.bake2, "clear", "a bake with no scissor stays clear") + T.eq(scissor, nil, "and leaves none behind") +end + +do + local BattleState = require("src.ui.gen2.BattleState") + scissor = { 112, 24, 800, 720 } + local state = setmetatable({ + battle = { enemy = {}, player = {} }, + animPicState = function(_, side) + if side == "enemy" then return { lifted = { 0, 7 } } end + end, + drawPic = function() drawBody("lift") end, + }, { __index = BattleState }) + state:drawLiftedRows(state.battle) + T.eq(seenInCanvas.lift, "clear", "lifted rows bake with no window scissor") + T.eq(scissor and scissor[1], 112, "and the caller's scissor comes back") +end + +T.finish("gen2 anim bake scissor bug 1895") diff --git a/tests/engine/gen2_caught_ball_palette_bug1896.lua b/tests/engine/gen2_caught_ball_palette_bug1896.lua new file mode 100644 index 00000000..53ce6907 --- /dev/null +++ b/tests/engine/gen2_caught_ball_palette_bug1896.lua @@ -0,0 +1,48 @@ +-- engine/battle_anims/anim_commands.asm:213 (#1896) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local AnimRunner = require("src.battle.gen2.AnimRunner") + +local function runnerWith(rows, palettes) + local runner = AnimRunner.new({ + animId = "ANIM_THROW_POKE_BALL", + battleTurn = 0, + data = { scripts = { click = rows } }, + }) + runner:start("click") + runner.objects.playFrame = function(objects) + objects.oam = {} + for _, name in ipairs(palettes) do + objects.oam[#objects.oam + 1] = { x = 80, y = 80, tile = 0, attr = 0, + palette = name } + end + return false + end + return runner +end + +do + local runner = runnerWith({ { "keepsprites" }, { "ret" } }, + { "PAL_BATTLE_OB_RED", "PAL_BATTLE_OB_BLUE" }) + T.eq(runner:step(), false, "keepsprites + ret ends the script the same frame") + T.eq(runner.keepSprites, true, "and BATTLEANIM_KEEPSPRITES_F is set") + T.eq(#runner:oam(), 2, "the ball's OBJs stay on screen") + for _, obj in ipairs(runner:oam()) do + T.eq(obj.palette, "PAL_BATTLE_OB_ENEMY", + "every kept OBJ is remapped onto the wild mon's palette") + end +end + +do + local runner = runnerWith({ { "ret" } }, + { "PAL_BATTLE_OB_RED", "PAL_BATTLE_OB_BLUE" }) + T.eq(runner:step(), false, "a break-free ret still ends the script") + T.eq(runner.keepSprites, false, "with no keepsprites flag") + T.eq(#runner:oam(), 0, "so BattleAnim_ClearOAM deletes the sprites instead") +end + +T.finish("gen2 caught ball palette bug 1896") diff --git a/tests/engine/gen2_caught_data_stamp_bug1886.lua b/tests/engine/gen2_caught_data_stamp_bug1886.lua new file mode 100644 index 00000000..770dd523 --- /dev/null +++ b/tests/engine/gen2_caught_data_stamp_bug1886.lua @@ -0,0 +1,220 @@ +-- engine/pokemon/caught_data.asm:72-81, :163-233 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local Breeding = require("src.core.gen2.Breeding") +local Catching = require("src.battle.gen2.Catching") +local Mon = require("src.battle.gen2.Mon") +local NpcTrade = require("src.core.gen2.NpcTrade") +local UI = require("src.ui.gen2.BattleState") +local World = require("src.world.gen2.World") + +local NEW_BARK = 1 +local ROUTE_29 = 2 + +local POKEMON = { + growthRates = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, + }, + RATTATA = { + id = "RATTATA", index = 19, name = "RATTATA", + baseStats = { hp = 30, attack = 56, defense = 35, speed = 72, + specialAttack = 25, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 51, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + DODRIO = { + id = "DODRIO", index = 85, name = "DODRIO", + baseStats = { hp = 60, attack = 110, defense = 70, speed = 100, + specialAttack = 60, specialDefense = 60 }, + types = { "NORMAL", "FLYING" }, catchRate = 45, baseExp = 158, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, +} + +local DATA = { + pokemon = POKEMON, + moves = { TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, + type = "NORMAL", accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" } }, + type_chart = { types = {}, matchups = {} }, + items = {}, +} + +local function newSave(version, gender) + return { + version = version, party = {}, + player = { name = "KRIS", id = 4242, gender = gender or "female" }, + pokedex = { seen = {}, caught = {} }, + } +end + +local function newWorld() + return { + tod = "NITE", + maps = { + ROUTE_29 = { id = "ROUTE_29", landmark = ROUTE_29 }, + NEW_BARK_TOWN = { id = "NEW_BARK_TOWN", landmark = NEW_BARK }, + }, + map = { def = { id = "ROUTE_29", landmark = ROUTE_29 } }, + timeOfDayId = World.timeOfDayId, + } +end + +local function newState(save, world, opts) + opts = opts or {} + return setmetatable({ + save = save, queue = {}, + battle = { timeOfDay = opts.timeOfDay }, + contest = opts.contest, + game = { save = save, data = DATA, world = world }, + }, { __index = UI }) +end + +do + local save = newSave("crystal", "female") + local state = newState(save, newWorld(), { timeOfDay = 1 }) + local enemy = { species = "RATTATA", level = 4 } + state:pushCaught(enemy, "POKE_BALL") + T.eq(save.party[1], enemy, "the catch joins the party") + T.eq(enemy.caughtTime, 2, "wTimeOfDay DAY stores as 2") + T.eq(enemy.caughtLevel, 4, "the level rides the same byte") + T.eq(enemy.caughtLocation, ROUTE_29, "the map header's landmark") + T.eq(enemy.caughtByGender, "girl", "wPlayerGender bit 0 on bit 7") +end + +do + local save = newSave("crystal", "male") + for i = 1, 6 do save.party[i] = { species = "RATTATA", level = 5 } end + local state = newState(save, newWorld(), { timeOfDay = 0 }) + local enemy = { species = "RATTATA", level = 7 } + state:pushCaught(enemy, "POKE_BALL") + T.eq(#save.party, 6, "a full party sends the catch to the PC") + T.eq(enemy.caughtTime, 1, "MORN stores as 1, so 0 stays 'unknown'") + T.eq(enemy.caughtByGender, "boy", "a male player stamps CAUGHT_BY_BOY") + T.eq(enemy.caughtLocation, ROUTE_29, "and the same landmark") +end + +do + local save = newSave("crystal", "female") + local state = newState(save, newWorld()) + local enemy = { species = "RATTATA", level = 4 } + state:pushCaught(enemy, "POKE_BALL") + T.eq(enemy.caughtTime, 3, "NITE stores as 3") +end + +do + local save = newSave("gold", "male") + local state = newState(save, newWorld(), { timeOfDay = 1 }) + local enemy = { species = "RATTATA", level = 4 } + state:pushCaught(enemy, "POKE_BALL") + T.eq(enemy.caughtTime, nil, "Gold stamps no caught time") + T.eq(enemy.caughtByGender, nil, "and no OT gender") + T.eq(enemy.caughtLocation, nil, "and no landmark") +end + +do + local save = newSave("crystal", "female") + local state = newState(save, newWorld(), { timeOfDay = 1, contest = true }) + local enemy = { species = "RATTATA", level = 9 } + state:pushCaught(enemy, "PARK_BALL") + T.eq(save.bugContest.caught, enemy, "the contest only HOLDS the catch") + T.eq(enemy.caughtLocation, Catching.LANDMARK_NATIONAL_PARK, + "the location is rewritten to the park") + T.eq(enemy.caughtByGender, "girl", "and the gender bit survives the rewrite") + T.eq(enemy.caughtTime, 2, "the time byte is the plain SetCaughtData one") +end + +do + local world = newWorld() + world.map = { def = { id = "POKECENTER_2F", landmark = 0x7f } } + world.backupMapId = "NEW_BARK_TOWN" + local save = newSave("crystal", "male") + local state = newState(save, world, { timeOfDay = 1 }) + local enemy = { species = "RATTATA", level = 4 } + state:pushCaught(enemy, "POKE_BALL") + T.eq(enemy.caughtLocation, NEW_BARK, "the backup map's landmark is used") +end + +do + local world = newWorld() + local save = newSave("crystal", "female") + world.game = { save = save, data = DATA } + local opts = World.caughtDataOpts(world) + T.eq(opts.version, "crystal", "the save's version gates the stamp") + T.eq(opts.timeOfDay, 2, "NITE is wTimeOfDay 2") + T.eq(opts.landmark, ROUTE_29, "the landmark is resolved up front") + T.eq(opts.playerGender, "female", "and wPlayerGender comes along") + + world.map = { def = { id = "POKECENTER_2F", landmark = 0x7f } } + world.backupMapId = "NEW_BARK_TOWN" + T.eq(World.caughtDataOpts(world).landmark, NEW_BARK, + "with the POKECENTER_2F backup swap") +end + +do + local source = io.open("src/world/gen2/World.lua"):read("*a") + T.check(source:find("Mon.setGiftCaughtData(mon, opts.caughtBy", 1, true) + ~= nil, "the trainer arm stamps LANDMARK_GIFT") + T.check(source:find("Catching.stampCaughtData(mon, self:caughtDataOpts())", + 1, true) ~= nil, "the wild arm runs SetCaughtData") +end + +do + local save = newSave("crystal", "female") + local egg = Mon.new(DATA, "RATTATA", Breeding.EGG_LEVEL, {}) + egg.isEgg = true + egg.eggSteps = 0 + save.party[1] = egg + local world = newWorld() + world.game = { save = save, data = DATA } + local hatched = Breeding.hatch(DATA, save, 1, nil, World.caughtDataOpts(world)) + T.check(hatched ~= nil, "the egg hatches") + T.eq(hatched.caughtLevel, Mon.CAUGHT_EGG_LEVEL, "CAUGHT_EGG_LEVEL") + T.eq(hatched.caughtTime, 3, "NITE") + T.eq(hatched.caughtLocation, ROUTE_29, "the hatch site's landmark") + T.eq(hatched.caughtByGender, "girl", "and the player's gender") +end + +do + local save = newSave("crystal", "male") + save.party[1] = Mon.new(DATA, "RATTATA", 20, {}) + local row = { id = 3, dialog = "TRADE_DIALOGSET_GIRL", give = "RATTATA", + get = "DODRIO", nickname = "DORIS", dvs = { 0x77, 0x66 }, otName = "EMY", + otId = 283 } + local _, received = NpcTrade.perform(DATA, save, row, 1) + T.check(received ~= nil, "the trade completes") + T.eq(received.caughtTime, 0, "a gift has no caught time") + T.eq(received.caughtLevel, 0, "and no caught level") + T.eq(received.caughtLocation, Mon.LANDMARK_GIFT, "LANDMARK_GIFT") + T.eq(received.caughtByGender, "girl", "TRADE_DIALOGSET_GIRL is CAUGHT_BY_GIRL") +end + +do + local save = newSave("crystal", "male") + save.party[1] = Mon.new(DATA, "RATTATA", 20, {}) + local row = { id = 0, dialog = "TRADE_DIALOGSET_COLLECTOR", give = "RATTATA", + get = "DODRIO", nickname = "MUSCLE", dvs = { 0x37, 0x66 }, otName = "MIKE", + otId = 37460 } + local _, received = NpcTrade.perform(DATA, save, row, 1) + T.eq(received.caughtLocation, Mon.LANDMARK_GIFT, "still LANDMARK_GIFT") + T.eq(received.caughtByGender, "boy", + "CAUGHT_BY_UNKNOWN leaves the gender bit clear") +end + +do + local save = newSave("gold", "male") + save.party[1] = Mon.new(DATA, "RATTATA", 20, {}) + local row = { id = 3, dialog = "TRADE_DIALOGSET_GIRL", give = "RATTATA", + get = "DODRIO", nickname = "DORIS", dvs = { 0x77, 0x66 }, otName = "EMY", + otId = 283 } + local _, received = NpcTrade.perform(DATA, save, row, 1) + T.eq(received.caughtLocation, nil, "Gold trades stamp nothing") +end + +T.finish("gen2 caught data stamp bug 1886") diff --git a/tests/engine/gen2_fly_bubble_bug1890.lua b/tests/engine/gen2_fly_bubble_bug1890.lua new file mode 100644 index 00000000..c25fe408 --- /dev/null +++ b/tests/engine/gen2_fly_bubble_bug1890.lua @@ -0,0 +1,55 @@ +-- engine/pokegear/pokegear.asm:2088 (#1890) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +love = love or require("tests.love_stub") + +local Chrome = require("src.ui.gen2.Chrome") +local Pokegear = require("src.ui.gen2.Pokegear") + +local tiles, texts, plates, cursors = {}, {}, 0, 0 + +local gear = setmetatable({ + fly = { { name = "NEW BARK TOWN" }, { name = "CHERRYGROVE CITY" } }, + flyIndex = 1, +}, { __index = Pokegear }) +gear.tile = function(_, id, tx, ty) + tiles[#tiles + 1] = { id = id, tx = tx, ty = ty } +end +gear.text = function(_, str, tx, ty) + texts[#texts + 1] = { str = str, tx = tx, ty = ty } +end +gear.drawPlate = function() plates = plates + 1 end + +local realCursor = Chrome.cursor +Chrome.cursor = function() cursors = cursors + 1 end +gear:drawFlyBubble() +Chrome.cursor = realCursor + +local function tileAt(tx, ty) + local id + for _, cell in ipairs(tiles) do + if cell.tx == tx and cell.ty == ty then id = cell.id end + end + return id +end + +check(tileAt(1, 0) == 0x30, "the top-left corner is $30") +check(tileAt(18, 0) == 0x31, "the top-right corner is $31") +check(tileAt(1, 2) == 0x32, "the bottom-left corner is $32") +check(tileAt(18, 2) == 0x33, "the bottom-right corner is $33") +check(tileAt(18, 1) == 0x34, "and the scroller is $34, the POI-red arrows") +check(tileAt(2, 0) == 0x7f and tileAt(17, 0) == 0x7f, + "spaces run between the top corners") +check(tileAt(1, 1) == 0x7f, "row 1 is spaces under the name") +check(plates == 0, "no square plate is laid under it") +check(cursors == 0, "and no menu cursor stands in for the arrows") + +check(texts[1] and texts[1].str == "Where?" + and texts[1].tx == 2 and texts[1].ty == 0, "\"Where?\" sits at (2,0)") +check(texts[2] and texts[2].str == "NEW BARK TOWN" + and texts[2].tx == 2 and texts[2].ty == 1, "the flypoint's name at (2,1)") + +T.finish("gen2 fly bubble bug 1890") diff --git a/tests/engine/gen2_fly_leaves_bug1889.lua b/tests/engine/gen2_fly_leaves_bug1889.lua new file mode 100644 index 00000000..244bc9e8 --- /dev/null +++ b/tests/engine/gen2_fly_leaves_bug1889.lua @@ -0,0 +1,84 @@ +-- pokegold engine/events/field_moves.asm:429-446 (#1889) +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +local World = require("src.world.gen2.World") + +local function newWorld() + return setmetatable({}, { __index = World }) +end + +do + local world, fa = newWorld(), { leaves = {} } + world:spawnFlyLeaves(fa) + T.eq(#fa.leaves, 1, "the first FrameTimer tick spawns a leaf") + T.eq(fa.leaves[1].y, 0x40 - 1, + "on the $40 row, one pixel up after its own first step") + T.eq(fa.leaves[1].x, 2, "AnimSeq_FlyLeaf's `inc [hl] / inc [hl]` on XCOORD") +end + +do + local world, fa = newWorld(), { leaves = {} } + local rows = {} + for _ = 1, 32 do + local before = #fa.leaves + world:spawnFlyLeaves(fa) + if #fa.leaves > before then + rows[#rows + 1] = fa.leaves[#fa.leaves].y + 1 + end + end + T.eq(#rows, 4, "four leaves in thirty-two frames: one every eight") + T.eq(rows[1], 0x40, "`and $18 / sla a / add 8 * 8` row one is $40") + T.eq(rows[2], 0x50, "row two is $50") + T.eq(rows[3], 0x60, "row three is $60") + T.eq(rows[4], 0x70, "row four is $70, and then it wraps") +end + +do + local world, fa = newWorld(), { leaves = {} } + world:spawnFlyLeaves(fa) + T.eq(fa.leaves[1].xoff, 0x40, "VAR1 starts at zero, so XOFFSET is the full $40") + for _ = 1, 16 do world:spawnFlyLeaves(fa) end + T.check(fa.leaves[1].xoff < 0x40, + "and it swings back in as VAR1 climbs") +end + +do + local world, fa = newWorld(), { leaves = {} } + world:spawnFlyLeaves(fa) + local first = fa.leaves[1] + local gone = false + for _ = 1, 200 do + world:spawnFlyLeaves(fa) + local live = false + for _, leaf in ipairs(fa.leaves) do + if leaf == first then live = true end + end + if not live then gone = true break end + T.check(first.x < 184 + 2, "a live leaf is still short of 184") + end + T.check(gone, "past the right edge DeinitializeSprite takes it") +end + +do + local world, fa = newWorld(), { leaves = {} } + for _ = 1, 400 do world:spawnFlyLeaves(fa) end + T.check(#fa.leaves <= 9, + "InitSpriteAnimStruct runs out of slots at nine leaves") +end + +do + local world = newWorld() + local from = { leaves = {} } + for _ = 1, 8 do world:spawnFlyLeaves(from) end + T.eq(world.flyLeafCounter, 8, "the counter is World's, not the animation's") + local to = { leaves = {} } + world:spawnFlyLeaves(to) + T.eq(#to.leaves, 1, "so FlyTo's frame 8 spawns rather than waiting for 0") + T.eq(to.leaves[1].y + 0, 0x50 - 1, "and on the row after FlyFrom's last") +end + +T.finish("gen2_fly_leaves_bug1889") diff --git a/tests/engine/gen2_ledge_shadow_bug1888.lua b/tests/engine/gen2_ledge_shadow_bug1888.lua new file mode 100644 index 00000000..0cb0208c --- /dev/null +++ b/tests/engine/gen2_ledge_shadow_bug1888.lua @@ -0,0 +1,86 @@ +-- pokegold engine/overworld/map_objects.asm:1995, :879-893 (#1888) +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +local Player = require("src.world.gen2.Player") +local World = require("src.world.gen2.World") + +local SHEET = {} + +local function newWorld() + return setmetatable({ jumpShadowImage = SHEET, palettes = nil }, { + __index = World, + }) +end + +local function record(fn) + local G = love.graphics + local realDraw, realEllipse = G.draw, G.ellipse + local draws, ellipses = {}, 0 + G.draw = function(image, x, y, r, sx, sy) + draws[#draws + 1] = { image = image, x = x, y = y, sx = sx, sy = sy } + end + G.ellipse = function() ellipses = ellipses + 1 end + local ok, err = pcall(fn) + G.draw, G.ellipse = realDraw, realEllipse + if not ok then error(err, 0) end + return draws, ellipses +end + +do + local world = newWorld() + local jumper = { jumping = true, facing = "down", px = 32, py = 48 } + local draws = record(function() world:drawJumpShadow(jumper, 0, 0, 1) end) + T.eq(#draws, 2, "FacingShadow's `db 2` is two OAM entries, so two blits") + T.eq(draws[1].image, SHEET, "both of them the ripped JumpShadowGFX tile") + T.eq(draws[2].image, SHEET, "not an invented shape") + T.eq(draws[1].sx, 1, "the first entry draws unflipped") + T.eq(draws[2].sx, -1, "the second carries OAM_XFLIP") + T.eq(draws[1].x, 32, "at the jumper's own cell x") + T.eq(draws[2].x, 48, "and the flip hangs off the far edge of the 16px pair") + T.eq(draws[1].y, 58, "DOWN puts it 10 pixels under the ground cell's top") + T.eq(draws[2].y, 58, "both entries share the row") +end + +do + local world = newWorld() + for _, facing in ipairs({ "left", "right" }) do + local jumper = { jumping = true, facing = facing, px = 0, py = 0 } + local draws = record(function() world:drawJumpShadow(jumper, 0, 0, 1) end) + T.eq(draws[1].y, 8, + facing .. " takes MovementFunction_Shadow's 1 * TILE_WIDTH + 4 arm") + end +end + +do + local world = newWorld() + local standing = { jumping = false, facing = "down", px = 0, py = 0 } + local draws = record(function() world:drawJumpShadow(standing, 0, 0, 1) end) + T.eq(#draws, 0, "SpawnShadow only runs from JumpStep, so no hop no shadow") +end + +do + local world = newWorld() + world.jumpShadowImage = nil + local jumper = { jumping = true, facing = "down", px = 0, py = 0 } + local draws, ellipses = record(function() + world:drawJumpShadow(jumper, 0, 0, 1) + end) + T.eq(#draws, 0, "a cache from before the tile was extracted has no shadow") + T.eq(ellipses, 0, "and does not fall back to a drawn oval") +end + +do + local player = setmetatable({ + jumping = true, facing = "down", px = 0, py = 0, spriteYOffset = -12, + turnTimer = 0, moving = false, animClock = 0, + sprite = { draw = function() end }, + }, { __index = Player }) + local _, ellipses = record(function() player:draw(0, 0, 1) end) + T.eq(ellipses, 0, "the 40%-black love.graphics.ellipse placeholder is gone") +end + +T.finish("gen2_ledge_shadow_bug1888") diff --git a/tests/engine/gen2_pack_sfx_bug1893.lua b/tests/engine/gen2_pack_sfx_bug1893.lua new file mode 100644 index 00000000..a660f1f1 --- /dev/null +++ b/tests/engine/gen2_pack_sfx_bug1893.lua @@ -0,0 +1,132 @@ +-- engine/menus/scrolling_menu.asm:23 (#1893) + +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 PackMenu = require("src.ui.gen2.PackMenu") +local Save = require("src.core.gen2.Save") +local Sound = require("src.core.Sound") + +local ITEMS = { + POTION = { id = "POTION", name = "POTION", pocket = "ITEM", index = 17, + canToss = true, canSelect = false, fieldMenu = "ITEMMENU_PARTY" }, + ESCAPE_ROPE = { id = "ESCAPE_ROPE", name = "ESCAPE ROPE", pocket = "ITEM", + index = 29, canToss = true, canSelect = true, + fieldMenu = "ITEMMENU_CLOSE" }, +} + +local played = {} +local realPlay = Sound.play +Sound.play = function(_data, name) played[#played + 1] = name end + +local function newInput() + local input = { pressed = {} } + function input:press(...) + for _, button in ipairs({ ... }) do self.pressed[button] = true end + end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input +end + +local function openPack() + local save = Save.newGame() + save.inventory = { POTION = 5, ESCAPE_ROPE = 1 } + save.bagOrder = { "POTION", "ESCAPE_ROPE" } + local game = { + input = newInput(), + save = save, + options = save.options, + data = { items = ITEMS, moves = {}, pokemon = {}, audio = { sfx = { + Sfx_ReadText2 = true, + Sfx_SwitchPockets = true, + Sfx_SwitchPokemon = true, + } } }, + stack = { push = function() end, pop = function() end }, + } + local pack = PackMenu.new(game, { save = save, pocket = "ITEM", + onClose = function() end, + world = { useFieldItem = function() end } }) + pack.gfx = { available = function() return false end, draw = function() end, + colorsAt = function() return nil end } + played = {} + return pack, game +end + +local function last() + return played[#played] +end + +do + local pack, game = openPack() + game.input:press("right") + pack:update(0) + eq(pack:pocket().id, "BALL", "right moves on to the next pocket") + eq(last(), "Sfx_SwitchPockets", "and rings the pocket cue") +end + +do + local pack, game = openPack() + game.input:press("down") + pack:update(0) + eq(#played, 0, "down the list is silent") + game.input:press("up") + pack:update(0) + eq(#played, 0, "so is up") + + game.input:press("a") + pack:update(0) + eq(last(), "Sfx_ReadText2", "A on a row clicks") + check(pack.submenu ~= nil, "and opens the item submenu") + + game.input:press("b") + pack:update(0) + eq(last(), "Sfx_ReadText2", "B out of the submenu clicks too") +end + +do + local pack, game = openPack() + pack.index = 1 + game.input:press("select") + pack:update(0) + eq(#played, 0, "SELECT arming a move is silent") + eq(pack.switching, 1, "and the row is held") + + game.input:press("down") + pack:update(0) + game.input:press("a") + pack:update(0) + eq(last(), "Sfx_SwitchPokemon", "putting it down rings the swap cue") + eq(pack.switching, nil, "and the move ends") +end + +do + local pack, game = openPack() + pack.index = 1 + game.input:press("select") + pack:update(0) + game.input:press("a") + pack:update(0) + eq(last(), "Sfx_SwitchPokemon", "placing it back on its own row still rings") +end + +do + local pack, game = openPack() + game.data.audio.sfx = {} + game.input:press("right") + pack:update(0) + eq(#played, 0, "a cache without the cue simply makes no sound") +end + +Sound.play = realPlay + +T.finish("gen2 pack sfx bug 1893") diff --git a/tests/engine/gen2_pokedex_area_landmark_bug1267.lua b/tests/engine/gen2_pokedex_area_landmark_bug1267.lua index 8791c325..dd3e5fc9 100644 --- a/tests/engine/gen2_pokedex_area_landmark_bug1267.lua +++ b/tests/engine/gen2_pokedex_area_landmark_bug1267.lua @@ -1,7 +1,4 @@ --- Gold #DEX AREA page drew no nest markers or landmark name because --- PokedexMenu:drawArea read the non-existent self.data.landmarks instead of --- the gen2Landmarks table Nests already resolves through (#1267). --- engine/pokegear/pokegear.asm:2427 +-- engine/pokegear/pokegear.asm:2285 (#1267) -- luajit tests/engine/gen2_pokedex_area_landmark_bug1267.lua package.path = "./?.lua;./?/init.lua;" .. package.path @@ -10,6 +7,7 @@ local T = require("tests.harness") local check, eq = T.check, T.eq love = love or require("tests.love_stub") +local Chrome = require("src.ui.gen2.Chrome") local PokedexMenu = require("src.ui.gen2.PokedexMenu") local Nests = require("src.core.gen2.Nests") @@ -35,54 +33,118 @@ local data = { eq(Nests.landmark(data, 5) and Nests.landmark(data, 5).name, "ROUTE 30", "Nests.landmark resolves index 5 to the ROUTE 30 record") --- capture what drawArea actually paints, without needing a real tile sheet --- or font: fill/blank/current/monName are stubbed on the instance, which --- Lua resolves before the PokedexMenu metatable's own methods. -local function newSelf() - local texts = {} - local rects = {} +local function newSelf(species, region) + local seen = { texts = {}, inverted = {}, header = {}, icons = {} } local self = setmetatable({ - game = { save = {} }, + game = { save = { position = { map = "ROUTE_30" } } }, data = data, - mapGfx = { maps = { johto = { 1 } } }, -- non-nil `cells`, no real sheet - areaRegion = "johto", - areaBlink = 0, -- (0 % 32) < 20, so markers are in their "on" phase - current = function() return { species = "RATTATA" } end, - monName = function() return "RATTATA" end, + mapGfx = { maps = { johto = { 1 }, kanto = { 1 } } }, + areaRegion = region, + areaBlink = 0, + current = function() return { species = species or "RATTATA" } end, + monName = function() return species or "RATTATA" end, fill = function() end, blank = function() end, + drawTilemap = function() end, text = function(_, str, tx, ty) - texts[#texts + 1] = { str = str, tx = tx, ty = ty } + seen.texts[#seen.texts + 1] = { str = str, tx = tx, ty = ty } + end, + drawAreaHeader = function(_, title) + seen.header[#seen.header + 1] = title + end, + drawNestIcon = function(_, x, y) + seen.icons[#seen.icons + 1] = { x = x, y = y } end, }, { __index = PokedexMenu }) - return self, texts, rects + return self, seen +end + +local function drawText(seen) + local out = {} + for _, t in ipairs(seen.texts) do out[#out + 1] = t.str end + return table.concat(out, "|") end -local realRect = love.graphics.rectangle -local self, texts, rects do - self, texts, rects = newSelf() - love.graphics.rectangle = function(mode, x, y, w, h) - rects[#rects + 1] = { mode = mode, x = x, y = y, w = w, h = h } - end + local self, seen = newSelf("RATTATA", "johto") self:drawArea() - love.graphics.rectangle = realRect + eq(#seen.icons, 1, "the one Johto nest gets one marker") + local icon = seen.icons[1] + eq(icon and icon.x, 40 - 4, "the marker sits four pixels left of the landmark") + eq(icon and icon.y, 60 - 4, "and four pixels above it") + eq(seen.header[1], "RATTATA'S NEST", "the only string is the nest caption") + eq(drawText(seen), "", "nothing is printed in the dex's inverted font") + + local blinkOff = 0 + for frame = 0, 31 do + local one, s2 = newSelf("RATTATA", "johto") + one.areaBlink = frame + one:drawArea() + if #s2.icons == 0 then blinkOff = blinkOff + 1 end + end + eq(blinkOff, 16, "the marker is hidden for sixteen of every thirty-two frames") end -local function hasRect(x, y) - for _, r in ipairs(rects) do - if r.x == x and r.y == y then return true end - end - return false +do + local self, seen = newSelf("RATTATA", "kanto") + self:drawArea() + eq(#seen.icons, 0, "no Kanto nest, so nothing blinks") + eq(drawText(seen), "", "and no AREA UNKNOWN over the Kanto map") + eq(seen.header[1], "RATTATA'S NEST", "the caption stays either way") end -check(hasRect(40 - 2, 60 - 2), "the nest marker is drawn at the landmark's x-2,y-2") -local function hasText(str) - for _, t in ipairs(texts) do - if t.str == str then return true end +do + local writes = {} + local realThrough = Chrome.printThrough + Chrome.printThrough = function(str, tx, ty) + writes[#writes + 1] = { str = str, tx = tx, ty = ty } end - return false + local self = newSelf("RATTATA", "johto") + self.drawAreaHeader = nil + self:drawAreaHeader("RATTATA'S NEST") + Chrome.printThrough = realThrough + eq(#writes, 1, "the caption is placed once") + eq(writes[1] and writes[1].tx, 2, "at hlcoord 2") + eq(writes[1] and writes[1].ty, 0, "on row 0") +end + +local function newInput() + local input = { pressed = {} } + function input:press(button) self.pressed[button] = true end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + return input +end + +do + local self = newSelf("RATTATA", nil) + self.game.save.position.map = "ROUTE_30" + eq(self:areaRegionName(), "johto", + "the page opens on Johto whatever the player is standing in") + + local input = newInput() + input:press("right") + self:updateArea(input) + eq(self:areaRegionName(), "johto", + "right does nothing before the Hall of Fame") + + self.game.save.hallOfFame = { count = 1 } + input:press("right") + self:updateArea(input) + eq(self:areaRegionName(), "kanto", "and swaps to Kanto once it has been rung") + + input:press("left") + self:updateArea(input) + eq(self:areaRegionName(), "johto", "left always comes back") + + input:press("b") + self:updateArea(input) + eq(self.view, "entry", "B returns to the entry") end -check(hasText("ROUTE 30"), "the landmark name is printed on row 16") T.finish("gen2 pokedex area landmark bug 1267") diff --git a/tests/engine/gen2_pokegear_page_bug1884.lua b/tests/engine/gen2_pokegear_page_bug1884.lua new file mode 100644 index 00000000..8a3242eb --- /dev/null +++ b/tests/engine/gen2_pokegear_page_bug1884.lua @@ -0,0 +1,81 @@ +-- engine/pokegear/pokegear.asm:454, :740 (#1884) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +love = love or require("tests.love_stub") + +local Pokegear = require("src.ui.gen2.Pokegear") + +local function newInput() + local input = { pressed = {} } + function input:press(button) self.pressed[button] = true end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input +end + +local function newGear(ids) + local input = newInput() + local cards = {} + for index, id in ipairs(ids) do cards[index] = { id = id, label = id } end + local gear = setmetatable({ + cards = cards, cardIndex = 1, mode = "strip", station = 1, + game = { input = input }, + }, { __index = Pokegear }) + gear.updatePhone = function() end + gear.stepMapCursor = function() end + gear.ensureTuned = function() end + gear.tickRadio = function() end + gear.tuneRadio = function() end + gear.stopRadio = function() end + return gear, input +end + +local function press(gear, input, button) + input:press(button) + gear:update(0) +end + +local gear, input = newGear({ "clock", "phone" }) +check(gear:card().id == "clock", "the gear opens on the clock") +press(gear, input, "right") +check(gear:card().id == "phone", "right takes the phone") +gear:update(0) +check(gear.mode == "card", "which enters its card without an A") +press(gear, input, "left") +check(gear:card().id == "clock", "left goes back to the clock") +press(gear, input, "right") +check(gear:card().id == "phone", "and the clock still pages right") + +local full, fullInput = newGear({ "clock", "map", "phone", "radio" }) +full.cardIndex, full.mode = 3, "card" +press(full, fullInput, "left") +check(full:card().id == "map", "the phone's left takes the map first") +press(full, fullInput, "right") +check(full:card().id == "phone", "and the map pages back") +press(full, fullInput, "right") +check(full:card().id == "radio", "the phone's right takes the radio") +press(full, fullInput, "left") +check(full:card().id == "phone", "and the radio's left takes it back") + +local radioOnly, radioInput = newGear({ "clock", "radio" }) +radioOnly.mode = "card" +press(radioOnly, radioInput, "right") +check(radioOnly:card().id == "radio", "the clock falls through to the radio") +press(radioOnly, radioInput, "left") +check(radioOnly:card().id == "clock", "and the radio falls back to the clock") + +local left, leftInput = newGear({ "clock", "map" }) +left.mode = "card" +press(left, leftInput, "left") +check(left:card().id == "clock", "left does nothing on the clock") + +T.finish("gen2 pokegear paging bug 1884") diff --git a/tests/engine/gen2_pokegear_paper_bug1885.lua b/tests/engine/gen2_pokegear_paper_bug1885.lua new file mode 100644 index 00000000..c8507c40 --- /dev/null +++ b/tests/engine/gen2_pokegear_paper_bug1885.lua @@ -0,0 +1,85 @@ +-- engine/pokegear/pokegear.asm (#1885) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +love = love or require("tests.love_stub") + +local Chrome = require("src.ui.gen2.Chrome") +local Pokegear = require("src.ui.gen2.Pokegear") + +local CREAM = { 224, 248, 160 } +local PALS = { { CREAM, { 168, 168, 168 }, { 84, 84, 84 }, { 0, 0, 0 } } } + +local runs, cursors, plain + +local function spy(fn) + runs, cursors, plain = {}, {}, 0 + local print_, through, cursor, cursorThrough = + Chrome.print, Chrome.printThrough, Chrome.cursor, Chrome.cursorThrough + Chrome.print = function() plain = plain + 1 end + Chrome.printThrough = function(text, tx, ty, palette) + runs[#runs + 1] = { text = text, tx = tx, ty = ty, palette = palette } + end + Chrome.cursor = function() plain = plain + 1 end + Chrome.cursorThrough = function(tx, ty, palette) + cursors[#cursors + 1] = { tx = tx, ty = ty, palette = palette } + end + local ok, err = pcall(fn) + Chrome.print, Chrome.printThrough = print_, through + Chrome.cursor, Chrome.cursorThrough = cursor, cursorThrough + if not ok then error(err, 0) end +end + +local function newGear() + local gear = setmetatable({}, { __index = Pokegear }) + gear.pals = function() return PALS end + gear.paperColor = function() return CREAM end + return gear +end + +local gear = newGear() +spy(function() gear:printBoxText("Press any button to exit.") end) +check(#runs == 2, "the exit prompt is two runs") +check(runs[1] and runs[1].text == "Press any button", "the first line as printed") +check(runs[1] and runs[1].tx == 1 and runs[1].ty == 14, "at (1,14)") +check(runs[2] and runs[2].ty == 16, "and the second two rows under it") +check(runs[1] and runs[1].palette == PALS[1], "both on the gear's cream paper") +check(runs[2] and runs[2].palette == PALS[1], "not Chrome's white box palette") +check(plain == 0, "and nothing goes through the white printer") + +local clock = newGear() +clock.clockParts = function() return 15, 47, 5 end +clock.drawTilemap = function() end +clock.drawStrip = function() end +clock.textbox = function() end +clock.phoneText = function() return "Press any button to exit." end +spy(function() clock:drawClock() end) +check(runs[1] and runs[1].text == " SWITCH" and runs[1].tx == 12, + "SWITCH is placed at column 12 with its leading space") +check(#cursors == 1 and cursors[1].tx == 19 and cursors[1].ty == 1, + "its arrow sits at (19,1)") +check(cursors[1] and cursors[1].palette == PALS[1], + "and wears the cream paper too") +check(plain == 0, "no white cell anywhere on the clock card") + +local radio = newGear() +radio.ensureTuned = function() end +radio.drawTilemap = function() end +radio.drawStrip = function() end +radio.drawTuningKnob = function() end +radio.textbox = function() end +radio.currentStation = function() + return { name = "OAK's PKMN Talk", station = "OAKS_POKEMON_TALK" } +end +radio.radio = { top = "... ...Ahem, we are", bottom = "TEAM ROCKET!" } +spy(function() radio:drawRadio() end) +check(runs[2] and runs[2].text == "... ...Ahem, we are" and runs[2].ty == 14, + "the show's top line lands on row 14") +check(runs[3] and runs[3].text == "TEAM ROCKET!" and runs[3].ty == 16, + "and the bottom line on row 16") +check(runs[2] and runs[2].palette == PALS[1], "on cream, like the station name") +check(plain == 0, "with no white bar behind the speech") + +T.finish("gen2 pokegear paper bug 1885") diff --git a/tests/engine/gen2_radio_rocket_flag_bug1871.lua b/tests/engine/gen2_radio_rocket_flag_bug1871.lua new file mode 100644 index 00000000..094c2fa3 --- /dev/null +++ b/tests/engine/gen2_radio_rocket_flag_bug1871.lua @@ -0,0 +1,55 @@ +-- engine/pokegear/radio.asm:1 (#1871) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +love = love or require("tests.love_stub") + +local FlagNames = require("src.core.gen2.FlagNames") +local Pokegear = require("src.ui.gen2.Pokegear") + +local TOWER = FlagNames.engine.ENGINE_ROCKETS_IN_RADIO_TOWER +local SIGNAL = FlagNames.engine.ENGINE_ROCKET_SIGNAL_ON_CH20 + +local function newGear(save, world) + local gear = setmetatable({ + save = save, + game = { world = world }, + landmarks = { landmarks = {} }, + }, { __index = Pokegear }) + gear.region = function() return "johto" end + gear.hiddenPeople = function() return {} end + gear.radioWeekday = function() return 1 end + gear.timeOfDayIndex = function() return 1 end + return gear +end + +check(TOWER == 18, "ENGINE_ROCKETS_IN_RADIO_TOWER is gold id 18") +check(SIGNAL == 14, "ENGINE_ROCKET_SIGNAL_ON_CH20 is gold id 14") + +local armed = newGear({ engineFlags = { [TOWER] = true } }) +check(armed:radioData().rocketsInRadioTower == true, + "the takeover reads straight off save.engineFlags") + +local quiet = newGear({ engineFlags = {} }) +check(quiet:radioData().rocketsInRadioTower == false, + "and stays off while the tower is clear") + +local legacy = newGear({ flags = { ROCKETS_IN_RADIO_TOWER = true } }) +check(legacy:radioData().rocketsInRadioTower == false, + "save.flags is not the takeover's store") + +local crystal = newGear({ engineFlags = { [TOWER + 1] = true } }, { + engineFlagId = function(_, name) + return name == "ENGINE_ROCKETS_IN_RADIO_TOWER" and TOWER + 1 or nil + end, +}) +check(crystal:radioData().rocketsInRadioTower == true, + "and Crystal's shifted id resolves through the world") + +local rage = newGear({ engineFlags = { [SIGNAL] = true } }) +check(rage:radioContext().rocketSignal == true, + "the Lake of Rage signal reads the same store") + +T.finish("gen2 radio rocket flag bug 1871") diff --git a/tests/engine/gen2_save_import.lua b/tests/engine/gen2_save_import.lua index a53190ca..abfa88f9 100644 --- a/tests/engine/gen2_save_import.lua +++ b/tests/engine/gen2_save_import.lua @@ -65,8 +65,7 @@ local function pack(b) return table.concat(out) end --- A save with one known Pokemon in the party and one in box 3. -local function build(version) +local function build(version, bare) local L = Gen2Save.layoutFor(version) local b = blank() putName(b, L.wPlayerName, "ASH") @@ -81,6 +80,7 @@ local function build(version) put(b, mon, 155) -- species put(b, mon + 1, 0) -- no held item put(b, mon + 2, 33, 43, 0, 0) -- two moves + put(b, mon + 0x17, 0x63, 40, 0, 0) -- 35 PP with one PP Up, then 40 put(b, mon + 6, 0x12, 0x34) -- OT id put(b, mon + 0x15, 0x9F, 0x6A) -- DVs: a=9 d=15 s=6 sp=10 put(b, mon + 0x1B, 200) -- happiness @@ -92,6 +92,10 @@ local function build(version) putName(b, L.wPartyMonNicknames, "FLAME") putName(b, L.wPartyMonOTs, "ASH") put(b, L.wMapGroup, 21); put(b, L.wMapNumber, 14) + if not bare then + put(b, L.wStatusFlags, 0x01) -- STATUSFLAGS_POKEDEX_F + put(b, L.wPokegearFlags, 0x81) -- POKEGEAR_OBTAINED_F + map card + end put(b, L.wNumItems, 1); put(b, L.wItems, 20, 3); put(b, L.wItems + 2, 0xFF) put(b, L.wNumKeyItems, 1); put(b, L.wKeyItems, 7); put(b, L.wKeyItems + 1, 0xFF) put(b, L.wNumBalls, 1); put(b, L.wBalls, 5, 9); put(b, L.wBalls + 2, 0xFF) @@ -144,6 +148,15 @@ for _, version in ipairs({ "gold", "silver", "crystal" }) do eq(save.boxes[3][1].nickname, "SQUIRT", version .. ": box nicknames follow the OTs") eq(save.boxes[3][1].ot, "ASH", version .. ": box OT") eq(save.boxes[3][1].hp, nil, version .. ": a box mon carries no computed stats") + -- data/events/engine_flags.asm (#1900) + eq(save.engineFlags[11], true, version .. ": ENGINE_POKEDEX out of wStatusFlags") + eq(save.engineFlags[4], true, version .. ": ENGINE_POKEGEAR out of wPokegearFlags") + eq(save.engineFlags[1], true, version .. ": the map card is engine flag 1") + eq(save.engineFlags[0], nil, version .. ": the radio card bit is clear") + local bare = assert(Gen2Save.decode(build(version, true), version)) + eq(bare.engineFlags[11], nil, + version .. ": a save from before Mr. Pokemon's house has no dex flag") + eq(bare.engineFlags[4], nil, version .. ": nor a POKEGEAR") end end @@ -157,7 +170,8 @@ end local CROSSWALK = { pokemon = { CYNDAQUIL = { index = 155 }, SQUIRTLE = { index = 7 } }, - moves = { TACKLE = { index = 33 }, LEER = { index = 43 } }, + moves = { TACKLE = { index = 33, pp = 35, name = "TACKLE" }, + LEER = { index = 43, pp = 30, name = "LEER" } }, items = { POTION = { index = 20 }, BICYCLE = { index = 7 }, POKE_BALL = { index = 5 } }, maps = { GOLDENROD_CITY = { group = 21, map = 14 } }, @@ -167,8 +181,13 @@ do local save = assert(Gen2Save.decode(build("gold"), "gold", CROSSWALK)) local m = save.party[1] eq(m.species, "CYNDAQUIL", "species is the engine's id, not the cart's number") - eq(m.moves[1], "TACKLE", "and so are moves") - eq(m.moves[2], "LEER", "both of them") + eq(m.moves[1].id, "TACKLE", "and so are moves") + eq(m.moves[2].id, "LEER", "both of them") + -- engine/pokemon/mon_stats.asm (#1899) + eq(m.moves[1].pp, 35, "a move slot carries the PP the cart stored") + eq(m.moves[1].ppUps, 1, "and its PP Ups, out of the top two bits") + eq(m.moves[1].maxPp, 42, "max PP is the base plus one PP Up's bonus") + eq(m.pp, nil, "and there is no parallel PP array beside it") eq(save.boxes[3][1].species, "SQUIRTLE", "boxes translate too") -- One flat bag. Nothing in src reads save.keyItems or save.balls; PackMenu @@ -205,6 +224,9 @@ end do local save = assert(Gen2Save.decode(build("gold"), "gold")) eq(save.party[1].species, 155, "an unknown species keeps its cart number") + eq(save.party[1].moves[1].id, 33, "and an unnamed move keeps its own") + eq(save.party[1].moves[1].maxPp, nil, + "with no move table there is no base PP to grow a max out of") end -- ------------------------------------------------------------------ @@ -236,6 +258,8 @@ do save.party[1].pokerus = 0x34 save.party[1].caughtData = 0x1234 save.events[9] = 0xA5 + save.party[1].moves[1].pp = 12 + save.engineFlags[11] = nil local out = assert(Gen2Save.encode(save, "gold", cart, data)) eq(#out, #cart, "the image keeps its size") @@ -257,6 +281,54 @@ do eq(back.party[1].status, "slp", "status survives as a class") eq(back.party[1].statusTurns, 3, "with its turn count") eq(back.events[9], 0xA5, "event bytes are written") + + eq(back.party[1].moves[1].id, "TACKLE", "a move slot survives the round trip") + eq(back.party[1].moves[1].pp, 12, "with the PP it was spent down to") + eq(back.party[1].moves[1].ppUps, 1, "and its PP Ups still in the top bits") + -- engine/menus/save.asm + eq(back.engineFlags[11], nil, "a cleared engine flag clears its SRAM bit") + eq(back.engineFlags[4], true, "and the ones left alone stay set") +end + +-- engine/pokemon/stats_screen.asm (#1899) + +do + local SummaryMenu = require("src.ui.gen2.SummaryMenu") + local save = assert(Gen2Save.decode(build("gold"), "gold", CROSSWALK)) + local view = setmetatable( + { mon = save.party[1], moves = CROSSWALK.moves, items = CROSSWALK.items, + party = {}, page = SummaryMenu.GREEN_PAGE }, + { __index = SummaryMenu }) + local rows = {} + for _, row in ipairs(view:greenPlacements()) do + rows[#rows + 1] = row.text + end + local text = table.concat(rows, "|") + check(text:find("TACKLE", 1, true) ~= nil, + "the green page names the imported move -- got: " .. text) + check(text:find("35", 1, true) ~= nil and text:find("42", 1, true) ~= nil, + "and prints its PP over its max rather than 0/ 0 -- got: " .. text) +end + +-- engine/menus/start_menu.asm (#1900) +do + local StartMenu = require("src.ui.gen2.StartMenu") + local imported = assert(SaveConvert.importSav(build("gold"), "gold", "gold")) + local menu = setmetatable({ save = imported }, { __index = StartMenu }) + local rows = {} + for _, item in ipairs(menu:visibleItems()) do rows[#rows + 1] = item.value end + local list = table.concat(rows, ",") + check(list:find("pokedex", 1, true) ~= nil, + "an imported save shows #DEX on the START menu -- got: " .. list) + check(list:find("pokegear", 1, true) ~= nil, + "and the POKEGEAR row too -- got: " .. list) + + local before = assert(SaveConvert.importSav(build("gold", true), "gold", "gold")) + local early = setmetatable({ save = before }, { __index = StartMenu }) + local earlyRows = {} + for _, item in ipairs(early:visibleItems()) do earlyRows[#earlyRows + 1] = item.value end + check(table.concat(earlyRows, ","):find("pokedex", 1, true) == nil, + "and a save from before the dex was given still hides it") end -- A save with no cartridge image behind it is refused, not invented. diff --git a/tests/engine/gen2_scy_deform_blit_bug1895.lua b/tests/engine/gen2_scy_deform_blit_bug1895.lua new file mode 100644 index 00000000..5482f0f7 --- /dev/null +++ b/tests/engine/gen2_scy_deform_blit_bug1895.lua @@ -0,0 +1,77 @@ +-- engine/battle_anims/bg_effects.asm:2638 (#1895) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local BattleAnimView = require("src.ui.gen2.BattleAnimView") + +local SCREEN_H = BattleAnimView.SCREEN_H + +local function bg(fields) + local out = { scx = 0, scy = 0, lyStart = 0, lyEnd = 0, lyBackup = {} } + for k, v in pairs(fields) do out[k] = v end + return out +end + +local function byRow(lines) + local map = {} + for _, line in ipairs(lines) do map[line.dest] = line end + return map +end + +do + local lines = byRow(BattleAnimView.scanlines( + bg({ lcdc = "SCY", lyStart = 0, lyEnd = 0x36, lyBackup = { [10] = 2 } }))) + T.eq(lines[10] and lines[10].src, 12, "rSCY 2 on LY 10 samples BG row 12") + T.eq(lines[10] and lines[10].dest, 10, "and still draws on scanline 10") + T.eq(lines[10] and lines[10].dx, 0, "rSCY never moves the row sideways") +end + +do + local lines = byRow(BattleAnimView.scanlines( + bg({ lcdc = "SCX", lyStart = 0, lyEnd = 0x36, lyBackup = { [10] = 2 } }))) + T.eq(lines[10] and lines[10].src, 10, "rSCX leaves the sampled row alone") + T.eq(lines[10] and lines[10].dx, -2, "and shifts the destination x instead") +end + +do + local backup = {} + for row = 0, 0x35 do + backup[row] = ({ 0, 1, 2, 3, 2, 1, 0, 0xff, 0xfe, 0xfd, 0xfe, 0xff }) + [(row % 12) + 1] + end + local lines = BattleAnimView.scanlines( + bg({ lcdc = "SCY", lyStart = 0, lyEnd = 0x36, lyBackup = backup })) + local seen = {} + for _, line in ipairs(lines) do + T.check(not seen[line.dest], "scanline " .. line.dest .. " is drawn once") + seen[line.dest] = true + T.check(line.src >= 0 and line.src < SCREEN_H, + "scanline " .. line.dest .. " samples inside the panel") + end + local gaps = 0 + for row = 3, 0x32 do + if not seen[row] then gaps = gaps + 1 end + end + T.eq(gaps, 0, "a per-scanline rSCY sine leaves no blank rows in the window") +end + +do + local lines = byRow(BattleAnimView.scanlines( + bg({ lcdc = "SCY", lyStart = 0, lyEnd = 0x36, + lyBackup = { [10] = 0x90, [120] = 0x90 } }))) + T.eq(lines[10], nil, "a $90 row inside the window is skipped") + T.check(lines[120] ~= nil, "a $90 row outside the window still draws") +end + +do + local lines = byRow(BattleAnimView.scanlines(bg({ scy = 4 }))) + T.eq(lines[0] and lines[0].src, 4, "hSCY 4 puts BG row 4 on scanline 0") + T.eq(lines[0] and lines[0].dest, 0, "with the destination untouched") + T.eq(lines[SCREEN_H - 1], nil, + "and the rows that would sample past the panel are left blank") +end + +T.finish("gen2 scy deform blit bug 1895") diff --git a/tests/engine/gen2_widescreen_clip_bug1869.lua b/tests/engine/gen2_widescreen_clip_bug1869.lua new file mode 100644 index 00000000..ff3952b2 --- /dev/null +++ b/tests/engine/gen2_widescreen_clip_bug1869.lua @@ -0,0 +1,89 @@ +-- engine/events/halloffame.asm:270 (#1869) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local G = love.graphics + +local scissors = {} +G.intersectScissor = nil +G.setScissor = function(x, y, w, h) + scissors[#scissors + 1] = { x = x, y = y, w = w, h = h } +end + +local Chrome = require("src.ui.gen2.Chrome") + +local WIN_W, WIN_H = 1280, 840 +local PANEL_W, PANEL_H = Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8 + +local function only(label) + T.eq(#scissors, 1, label .. " sets exactly one scissor") + local rect = scissors[1] + scissors = {} + return rect or {} +end + +local function checkPanel(label, scale) + local rect = only(label) + local ox, oy = Chrome.fitOrigin(WIN_W, WIN_H, scale) + T.eq(rect.x, ox, label .. " clips from the panel's own origin x") + T.eq(rect.y, oy, label .. " clips from the panel's own origin y") + T.eq(rect.w, PANEL_W * scale, label .. " clips to the scaled panel width") + T.eq(rect.h, PANEL_H * scale, label .. " clips to the scaled panel height") +end + +do + local scale = Chrome.fitScale(WIN_W, WIN_H) + T.check(scale >= 1, "1280x840 fits at least one whole GB pixel per window pixel") + local drew = false + Chrome.withPanel(WIN_W, WIN_H, 0, 0, 0, function() drew = true end) + T.eq(drew, true, "Chrome.withPanel runs the panel body") + checkPanel("Chrome.withPanel", scale) + + Chrome.withPanel(WIN_W, WIN_H, 1, 1, 1, function() end, 3) + checkPanel("an explicit panel scale", 3) + + drew = false + Chrome.withClip(function() drew = true end) + T.eq(drew, true, "Chrome.withClip runs the panel body") + local rect = only("Chrome.withClip") + T.eq(rect.x, 0, "the 1:1 clip starts at the panel origin") + T.eq(rect.y, 0, "on both axes") + T.eq(rect.w, PANEL_W, "and is 160 wide") + T.eq(rect.h, PANEL_H, "by 144 tall") +end + +do + local HallOfFame = require("src.ui.gen2.HallOfFame") + local hof = setmetatable({ drawPanel = function() end }, + { __index = HallOfFame }) + hof:drawWidescreen(WIN_W, WIN_H) + checkPanel("HallOfFame:drawWidescreen", Chrome.fitScale(WIN_W, WIN_H)) + hof:draw() + T.eq(only("HallOfFame:draw").w, PANEL_W, "and its 1:1 draw clips to the LCD") +end + +do + local Credits = require("src.ui.gen2.Credits") + local credits = setmetatable({ drawPanel = function() end }, + { __index = Credits }) + credits:drawWidescreen(WIN_W, WIN_H) + checkPanel("Credits:drawWidescreen", Chrome.fitScale(WIN_W, WIN_H)) + credits:draw() + T.eq(only("Credits:draw").w, PANEL_W, "and its 1:1 draw clips to the LCD") +end + +do + local BattleState = require("src.ui.gen2.BattleState") + local battle = setmetatable({ drawScene = function() end }, + { __index = BattleState }) + local scale = battle:battlePanelScale(WIN_W, WIN_H) + battle:drawWidescreen(WIN_W, WIN_H) + checkPanel("BattleState:drawWidescreen", scale) + battle:draw() + T.eq(only("BattleState:draw").w, PANEL_W, "and its 1:1 draw clips to the LCD") +end + +T.finish("gen2 widescreen clip bug 1869") diff --git a/tests/engine/launcher_find_details.lua b/tests/engine/launcher_find_details.lua new file mode 100644 index 00000000..b2756be6 --- /dev/null +++ b/tests/engine/launcher_find_details.lua @@ -0,0 +1,110 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local Fetch = require("src.net.Fetch") +local ModIndex = require("src.mods.ModIndex") +local HostShell = require("src.core.HostShell") +local RomImporter = require("src.import.RomImporter") + +ModIndex.fetchText = function() error("Details fetched on the render thread") end +ModIndex.httpGet = function() error("Details fetched on the render thread") end +HostShell.httpGet = function() error("Details fetched on the render thread") end + +local requested, released, cancelled, answer +Fetch.get = function(url) + requested[#requested + 1] = url + return #requested +end +Fetch.poll = function() return answer end +Fetch.release = function(id) released[#released + 1] = id end +Fetch.cancel = function(id) cancelled[#cancelled + 1] = id end + +local function launcher() + requested, released, cancelled = {}, {}, {} + answer = { status = "pending" } + return setmetatable({ tab = "find" }, RomImporter) +end + +local ENTRY = { id = "rare_soda", title = "Rare Soda", + summary = "A soda, but rare.", + description_url = "mods/rare_soda.md", + _base = "https://example.invalid/" } + +do + local ri = launcher() + ri:_findShowDetails(ENTRY) + check(ri._findDetails ~= nil, "Details opens on the click that asked for it") + eq(ri._findDetails.title, "Rare Soda", "titled with the entry") + eq(ri._findDetails.body, "A soda, but rare.", + "showing the listing summary while the markdown is in flight") + check(ri._findDetails.loading, "and saying so") + + ri:_pumpFindDetails() + eq(#requested, 1, "the frame after the click is what goes to the network") + eq(requested[1], "https://example.invalid/mods/rare_soda.md", + "asking for the entry's description, resolved against its index") + check(ri._findDetails.loading, "and the modal is still waiting") + + ri:_pumpFindDetails() + check(ri._findDetails.loading, "a pending poll leaves the modal as it was") + eq(#released, 0, "and holds the job") + + answer = { status = "ok", body = "# Rare Soda\n\nIt is rare." } + ri:_pumpFindDetails() + eq(ri._findDetails.body, "# Rare Soda\n\nIt is rare.", + "the markdown replaces the summary when it lands") + eq(ri._findDetails.loading, false, "and the modal stops waiting") + eq(#released, 1, "the job is released") + eq(ri._findDetailsFetch, nil, "and the handle is dropped") + + ri:_pumpFindDetails() + eq(#requested, 1, "a settled modal asks for nothing more") +end + +do + local ri = launcher() + ri:_findShowDetails(ENTRY) + ri:_pumpFindDetails() + answer = { status = "error", err = "timeout" } + ri:_pumpFindDetails() + eq(ri._findDetails.body, "A soda, but rare.", + "a failed description keeps the summary") + eq(ri._findDetails.loading, false, "and the modal is done waiting") +end + +do + local ri = launcher() + ri:_findShowDetails(ENTRY) + ri:_pumpFindDetails() + ri._findDetails = nil + ri:_pumpFindDetails() + eq(#cancelled, 1, "closing the modal cancels the request behind it") + eq(ri._findDetailsFetch, nil, "and drops the handle") + answer = { status = "ok", body = "# Too late" } + ri:_pumpFindDetails() + eq(ri._findDetails, nil, "so a late answer does not reopen the modal") +end + +do + local ri = launcher() + ri:_findShowDetails(ENTRY) + ri:_pumpFindDetails() + ri:_findShowDetails(ENTRY) + eq(#cancelled, 1, "the first request is cancelled") + ri:_pumpFindDetails() + eq(#requested, 2, "and the second one is what the modal is waiting on") +end + +do + local ri = launcher() + ri:_findShowDetails({ id = "bare", title = "Bare", summary = "Nothing more." }) + check(ri._findDetails ~= nil, "the modal still opens") + eq(ri._findDetails.loading, false, "with nothing to wait for") + ri:_pumpFindDetails() + eq(#requested, 0, "and no request at all") +end + +T.finish("launcher find details") diff --git a/tests/engine/launcher_game_popup_fill_bug1874.lua b/tests/engine/launcher_game_popup_fill_bug1874.lua new file mode 100644 index 00000000..02b43f3a --- /dev/null +++ b/tests/engine/launcher_game_popup_fill_bug1874.lua @@ -0,0 +1,109 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +love = love or require("tests.love_stub") + +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end + +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local PAL = Theme.PAL +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") + +local realFill = Theme.fillRounded +local fills = nil + +local function sameColor(a, b) + if type(a) ~= "table" or type(b) ~= "table" then return false end + return a[1] == b[1] and a[2] == b[2] and a[3] == b[3] +end + +local function spyOn() + fills = {} + Theme.fillRounded = function(x, y, w, h, color, alpha, radius) + fills[#fills + 1] = { x = x, y = y, w = w, h = h, + color = color, alpha = alpha } + return realFill(x, y, w, h, color, alpha, radius) + end +end + +local function spyOff() + Theme.fillRounded = realFill +end + +local function filledWith(rect, color) + for _, f in ipairs(fills) do + if math.abs(f.x - rect.x) < 1 and math.abs(f.y - rect.y) < 1 + and math.abs(f.w - rect.w) < 1 and (f.alpha or 1) > 0.5 + and sameColor(f.color, color) then + return true + end + end + return false +end + +local function drawGamePopup(tab) + love.graphics.getDimensions = function() return 1280, 720 end + love.graphics.getPixelDimensions = function() return 1280, 720 end + local imp = RomImporter.new(function() end, { launcher = true }) + imp.tab = tab + imp.ready = { red = true, blue = true, yellow = true, gold = true } + imp._gamePopup = true + Kit.focusId = nil + local ok, err = pcall(LauncherView.draw, imp) + check(ok, "first Choose-game frame draws: " .. tostring(err)) + spyOn() + ok, err = pcall(LauncherView.draw, imp) + spyOff() + check(ok, "second Choose-game frame draws: " .. tostring(err)) + local rects = {} + for i = 1, (Kit._navPrevN or 0) do + local slot = Kit._nav[i] + if slot and slot.id then + rects[slot.id] = { x = slot.x, y = slot.y, w = slot.w, h = slot.h } + end + end + return rects +end + +local rects = drawGamePopup("blue") +check(rects["gamepop-blue"] and rects["gamepop-red"], + "the Choose-game list is in the nav graph") +check(filledWith(rects["gamepop-blue"], PAL.railBlue), + "Blue cartridge: only the Blue row carries a solid rail fill") +check(not filledWith(rects["gamepop-red"], PAL.railRed), + "Blue cartridge: Red is not filled even when the ring parks on it") + +Kit.setFocus("gamepop-blue") +rects = drawGamePopup("red") +check(filledWith(rects["gamepop-red"], PAL.railRed), + "Red cartridge: Red stays filled while the ring sits elsewhere") +check(not filledWith(rects["gamepop-blue"], PAL.railBlue), + "Red cartridge: the focused Blue row is ring-only") + +local function tabFace(opts) + Kit.blockClicks = false + Kit.beginFrame() + spyOn() + Kit.button(10, 10, 100, 20, "Red", opts) + spyOff() + Kit.endFrame() + return { x = 10, y = 10, w = 100, h = 20 } +end + +Kit.setFocus("tab-face") +local r = tabFace({ face = "tab", id = "tab-face", color = PAL.railRed, + active = false }) +check(not filledWith(r, PAL.railRed), + "tab face: the focused-but-inactive row is not filled with its cart colour") + +Kit.setFocus("tab-face") +r = tabFace({ face = "tab", id = "tab-face", color = PAL.railRed, + active = true }) +check(filledWith(r, PAL.railRed), + "tab face: the active row is filled with its cart colour") + +print("ok launcher choose-game fill vs focus") diff --git a/tests/engine/launcher_navigation_perf.lua b/tests/engine/launcher_navigation_perf.lua index 6e50d090..5f31cc3f 100644 --- a/tests/engine/launcher_navigation_perf.lua +++ b/tests/engine/launcher_navigation_perf.lua @@ -112,4 +112,42 @@ do LauncherMods.list = old end +do + local calls = 0 + local old = LauncherMods.list + LauncherMods.list = function() + calls = calls + 1 + return { { id = "scoped", name = "Scoped", targetsHere = true, + manifest = { id = "scoped", version = "1.0.0" } } } + end + local imp = setmetatable({ + modScope = "red", + modStraysChecked = true, + activeCart = { red = "somecart" }, + activeSlot = {}, slots = { ["cart:somecart"] = {} }, + }, RomImporter) + imp:_refreshMods() + eq(calls, 1, "a scoped relist reads the installed mods once, not per consumer") + local before = calls + imp:modCartPlan() + imp:modCartPlan() + eq(calls, before, "and the cart plan the panel asks for every frame reuses it") + LauncherMods.list = old +end + +do + local ModProfile = require("src.mods.ModProfile") + local seeded = 0 + local oldEnsure = ModProfile.ensureFirst + ModProfile.ensureFirst = function() seeded = seeded + 1 end + local options = { modProfilesSeeded = true, + modProfiles = { { name = "PROFILE 1" } }, + activeProfile = "PROFILE 1" } + local list, active = LauncherMods.getProfiles(options) + eq(active, "PROFILE 1", "the active profile is read straight off options") + eq(#list, 1, "with the profiles already stored there") + eq(seeded, 0, "and no first-run seeding pass behind it") + ModProfile.ensureFirst = oldEnsure +end + T.finish("launcher_navigation_perf") diff --git a/tests/engine/launcher_nx_pad_cursor_test.lua b/tests/engine/launcher_nx_pad_cursor_test.lua index 3d9a8289..3b4b87de 100644 --- a/tests/engine/launcher_nx_pad_cursor_test.lua +++ b/tests/engine/launcher_nx_pad_cursor_test.lua @@ -70,7 +70,6 @@ do local gx, gy = love.mouse.getPosition() eq(gx, imp._padCursor.x, "NX getPosition X matches pad cursor") eq(gy, imp._padCursor.y, "NX getPosition Y matches pad cursor") - -- Real (stored) mouse must stay where the stub left it — bridge only. eq(mouseX, 0, "NX does not warp the underlying mouse X") eq(mouseY, 0, "NX does not warp the underlying mouse Y") imp:_restoreNxPointerBridge() @@ -279,10 +278,10 @@ do "RomImporter exports prepareOverlayHandoff") check(impSrc:find("function RomImporter:resumeAfterOverlay", 1, true) ~= nil, "RomImporter exports resumeAfterOverlay") - check(impSrc:find("if not self.isNX then", 1, true) ~= nil, - "NX skips desktop mouse-yield path") - check(impSrc:find("if not self.isNX and love.mouse.setPosition", 1, true) ~= nil, - "RomImporter skips setPosition on NX") + check(impSrc:find("if not self:_consolePointerHost() then", 1, true) ~= nil, + "consoles skip the desktop mouse-yield path") + check(impSrc:find("if not self:_consolePointerHost() and love.mouse.setPosition", + 1, true) ~= nil, "RomImporter skips setPosition on consoles") check(impSrc:find("dt > 1 / 30", 1, true) ~= nil, "NX clamps pad cursor dt") diff --git a/tests/engine/launcher_osk_touch_bug1861.lua b/tests/engine/launcher_osk_touch_bug1861.lua new file mode 100644 index 00000000..fd995580 --- /dev/null +++ b/tests/engine/launcher_osk_touch_bug1861.lua @@ -0,0 +1,80 @@ +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") + +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end +love.keyboard.setTextInput = love.keyboard.setTextInput or function() end + +local Kit = require("src.ui.kit.Kit") +local VK = require("src.ui.kit.VirtualKeyboard") +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") + +local realGetenv = os.getenv +local handheld = false +os.getenv = function(name) + if handheld and name == "HANDHELD" then return "1" end + return realGetenv(name) +end + +love.graphics.getDimensions = function() return 1280, 720 end +love.graphics.getPixelDimensions = function() return 1280, 720 end + +local imp = RomImporter.new(function() end, { launcher = true }) +imp.tab = "find" +imp.ready = { red = true, blue = true } + +handheld = false +eq(VK.open({ title = "Search mods" }), false, + "off a Linux handheld the OSK refuses to open") +check(VK.active == false, "no OSK overlay on a touch-only host") + +handheld = true +check(VK.open({ text = "", title = "Search mods", + onDone = function(newText, confirmed) + if confirmed then imp.findQuery = newText end + end }) == true, "the OSK opens on a handheld") + +local pressRects = {} +local realPress = Kit.press +local recording = false +Kit.press = function(x, y, w, h) + if recording and not Kit.blockClicks then + pressRects[#pressRects + 1] = { x = x, y = y, w = w, h = h } + end + return realPress(x, y, w, h) +end + +local function frame(click) + imp._clickPt = click + pressRects = {} + recording = true + local ok, err = pcall(LauncherView.draw, imp) + recording = false + check(ok, "OSK frame draws: " .. tostring(err)) + return pressRects +end + +local rects = frame(nil) +check(#rects > 0, + "the OSK keys register with Kit.press instead of drawing behind a shield") + +local first = rects[1] +if first then + frame({ x = first.x + first.w / 2, y = first.y + first.h / 2 }) + eq(VK.text, "1", "tapping the top-left key types it") +end + +VK.text = "pikachu" +VK.close(true) +check(VK.active == false, "Done closes the OSK") +eq(imp.findQuery, "pikachu", "Done writes findQuery, not a dead _findQuery") +eq(imp._findQuery, nil, "nothing writes the dead _findQuery field") + +Kit.press = realPress +os.getenv = realGetenv + +print("ok launcher on-screen keyboard taps") diff --git a/tests/engine/launcher_uwp_pad_nav_test.lua b/tests/engine/launcher_uwp_pad_nav_test.lua new file mode 100644 index 00000000..eb59b3d8 --- /dev/null +++ b/tests/engine/launcher_uwp_pad_nav_test.lua @@ -0,0 +1,142 @@ +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 setPositionCalls = 0 +local mouseX, mouseY = 0, 0 +love.mouse.getPosition = function() return mouseX, mouseY end +love.mouse.setPosition = function(x, y) + setPositionCalls = setPositionCalls + 1 + mouseX, mouseY = x, y +end + +local Platform = require("src.core.Platform") +local Kit = require("src.ui.kit.Kit") +local RomImporter = require("src.import.RomImporter") + +local osName = "OS X" +love.system.getOS = function() return osName end + +local function setHost(name) + osName = name + Platform._resetForTests() +end + +local function freshImporter() + Kit.focusId = nil + Kit._navQueue = nil + return setmetatable({ + isNX = false, + _padCursor = { x = 100, y = 100 }, + _padCursorActive = false, + _padAxis = { leftx = 0, lefty = 0, righty = 0 }, + _padDir = {}, + _rawHatDirs = {}, + _padInited = true, + _flex = true, + tab = "red", + }, RomImporter) +end + +local navLog = {} +local realNavigate = Kit.navigate +Kit.navigate = function(dir) + navLog[#navLog + 1] = dir + Kit._navQueue = nil +end + +local function stick(imp, ax, ay, frames, dt) + dt = dt or (1 / 60) + imp._padAxis.leftx = ax + imp._padAxis.lefty = ay + for _ = 1, frames do imp:_updatePadCursor(dt) end +end + +setHost("UWP") +do + navLog = {} + setPositionCalls = 0 + local imp = freshImporter() + check(imp:_consolePointerHost(), "UWP counts as a console pointer host") + stick(imp, 1, 0, 10) + check(not imp._padCursorActive, + "UWP: a stick push does not arm the virtual mouse") + eq(#navLog, 1, "a held stick steps the ring once before the repeat delay") + eq(navLog[1], "right", "right on the stick is right on the ring") + eq(setPositionCalls, 0, "UWP never warps the system mouse") + eq(imp._padCursor.x, 100, "the pad cursor stays parked in menu-nav mode") + + stick(imp, 1, 0, 30) + check(#navLog > 1, "holding the stick repeats the step") + stick(imp, 0.35, 0, 5) + check(#navLog > 0, "0.35 is inside the release hysteresis, so the latch holds") + stick(imp, 0, 0, 2) + local released = #navLog + stick(imp, 0.4, 0, 5) + eq(#navLog, released, "0.4 is under the latch threshold, so nothing steps") + stick(imp, 0, 0, 2) + stick(imp, 0, -1, 1) + eq(navLog[#navLog], "up", "up on the stick is up on the ring") +end + +do + navLog = {} + local imp = freshImporter() + imp._padCursorActive = true + stick(imp, 1, 0, 10) + check(imp._padCursor.x > 100, + "UWP: cursor mode still moves the pad pointer") + eq(#navLog, 0, "cursor mode does not also walk the ring") + eq(setPositionCalls, 0, "UWP cursor mode never warps the system mouse") + check(imp._nxPointerBridge, "UWP installs the getPosition bridge") + imp:_restoreNxPointerBridge() + + mouseX, mouseY = 400, 400 + imp:_updatePadCursor(1 / 60) + check(imp._padCursorActive, + "UWP: system-mouse drift does not yield the pad cursor") +end + +do + navLog = {} + local imp = freshImporter() + imp._padCursorActive = true + imp:gamepadpressed(nil, "y") + check(not imp._padCursorActive, "Y drops back to Controller Menu Navigation") + eq(imp._cursorModeToast, "Controller Menu Navigation [Y]", + "the toast names the mode Y selected") + stick(imp, 1, 0, 10) + check(not imp._padCursorActive, + "a stick still off-centre does not undo the Y toggle") +end + +setHost("OS X") +do + navLog = {} + setPositionCalls = 0 + local imp = freshImporter() + check(not imp:_consolePointerHost(), "desktop is not a console pointer host") + stick(imp, 1, 0, 10) + check(imp._padCursorActive, "desktop: the stick still arms the virtual mouse") + check(setPositionCalls > 0, "desktop still warps the system mouse") + eq(#navLog, 0, "desktop stick does not walk the ring by default") +end + +do + navLog = {} + local imp = freshImporter() + imp._padCursorActive = true + imp:gamepadpressed(nil, "y") + check(imp._padNavChosen, "Y records that menu nav was the deliberate choice") + stick(imp, 1, 0, 10) + check(not imp._padCursorActive, + "desktop: after Y the stick keeps the ring instead of the mouse") + eq(#navLog, 1, "desktop menu nav steps the ring") +end + +Kit.navigate = realNavigate +setHost("OS X") + +print("ok launcher UWP pad menu navigation") diff --git a/tests/engine/link_addr_entry_bug1295.lua b/tests/engine/link_addr_entry_bug1295.lua new file mode 100644 index 00000000..8f4cee9b --- /dev/null +++ b/tests/engine/link_addr_entry_bug1295.lua @@ -0,0 +1,47 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local CodeEntry = require("src.link.CodeEntry") +local LinkState = require("src.link.LinkState") + +local function roundTrip(ip) + return LinkState.addrText(LinkState.addrEntry(ip)) +end + +do + T.eq(roundTrip("10.0.0.1"), "10.0.0.1", "a two-digit first octet survives") + T.eq(roundTrip("10.42.0.1"), "10.42.0.1", "hotspot addresses survive") + T.eq(roundTrip("192.168.1.40"), "192.168.1.40", "short tails do not zero-extend") + T.eq(roundTrip("255.255.255.255"), "255.255.255.255", "a full-width address fits") + T.eq(roundTrip(nil), "192.168.0.1", "no LAN IP falls back to a usable seed") + T.eq(roundTrip("not an address"), "192.168.0.1", "junk falls back to the seed") +end + +do + local state = LinkState.addrEntry("10.0.0.1") + T.eq(state.pos, 8, "the cursor starts on the last filled slot") + T.eq(state.length, LinkState.ADDR_LENGTH, "the widget is the address shape") + T.eq(CodeEntry.charAt(state, 9), " ", "unused slots are blank, not '0'") +end + +do + local function entryOf(text) + return CodeEntry.fromText(text, { length = LinkState.ADDR_LENGTH, + charset = LinkState.ADDR_CHARSET }) + end + T.eq(LinkState.addrText(entryOf("10.0.0")), nil, "three octets are rejected") + T.eq(LinkState.addrText(entryOf("10.0.0.256")), nil, "an octet over 255 is rejected") + T.eq(LinkState.addrText(entryOf("10.0.0.1.2")), nil, "five octets are rejected") + T.eq(LinkState.addrText(entryOf("")), nil, "an empty widget is rejected") +end + +do + local state = LinkState.addrEntry("10.0.0.1") + state.pos = 1 + CodeEntry.up(state) + T.eq(LinkState.addrText(state), "20.0.0.1", "scrubbing edits one slot") + for _ = 1, 11 do CodeEntry.up(state) end + T.eq(LinkState.addrText(state), "10.0.0.1", "a full cycle returns the digit") +end + +T.finish("link_addr_entry_bug1295") diff --git a/tests/engine/mart_list_box_bug1887.lua b/tests/engine/mart_list_box_bug1887.lua new file mode 100644 index 00000000..8e3cf7d9 --- /dev/null +++ b/tests/engine/mart_list_box_bug1887.lua @@ -0,0 +1,159 @@ +-- engine/events/pokemart.asm (#1887) + +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 realFont = package.loaded["src.render.Font"] +local calls = {} +local FontStub +FontStub = { + BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 }, + draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end, + drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end, + drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end, + width = function(text) return #tostring(text) * 8 end, + split = function(text) + local out = {} + for i = 1, #tostring(text) do out[i] = i end + return out + end, + encode = function() return {} end, + spansFitting = function(spans) return #spans end, + advanceOf = function() return 8 end, +} +package.loaded["src.render.Font"] = FontStub +for _, mod in ipairs({ "src.ui.ListMenu", "src.ui.Theme", "src.ui.Menu", + "src.ui.ShopMenu", "src.render.TextBox" }) do + package.loaded[mod] = nil +end +local ShopMenu = require("src.ui.ShopMenu") +local ListMenu = require("src.ui.ListMenu") + +local function found(kind, pred) + for _, c in ipairs(calls) do + if c[1] == kind and pred(c) then return c end + end + return nil +end + +local pressed +local game +game = { + data = { + text = {}, + items = { + POKE_BALL = { name = "POKe BALL", price = 200 }, + GREAT_BALL = { name = "GREAT BALL", price = 600 }, + TOWN_MAP = { name = "TOWN MAP", price = 0, keyItem = true }, + HM_CUT = { name = "HM01", price = 0 }, + POTION = { name = "POTION", price = 300 }, + }, + }, + save = { money = 3000, inventory = {}, bagOrder = {} }, + input = { + wasPressed = function(_, b) return pressed == b end, + isDown = function() return false end, + }, + stack = { + states = {}, + push = function(self, s) self.states[#self.states + 1] = s end, + pop = function(self) table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + }, +} + +local function openList(which) + game.stack.states = {} + local menu = ShopMenu.new(game, { "POKE_BALL", "GREAT_BALL" }, function() end) + game.stack:push(menu) + menu.index = which + pressed = "a" + menu:update(1 / 60) + pressed = nil + return menu, game.stack:top() +end + +do + local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end) + eq(menu.tx, 0, "BUY/SELL/QUIT sits at column 0") + eq(menu.ty, 0, "and row 0") + eq(menu.tw, 11, "11 tiles wide, flush with the money box at column 11") + eq(menu.th, 7, "7 tiles tall (data/text_boxes.asm:34)") + + calls = {} + menu:draw() + check(found("draw", function(c) + return c[2] == "BUY" and c[4] == 8 + end) ~= nil, "BUY prints on interior row 1 (wTopMenuItemY 1)") + -- text_boxes.asm:35 + check(found("box", function(c) + return c[2] == 11 and c[3] == 0 and c[4] == 9 and c[5] == 3 + end) ~= nil, "the money box is MONEY_BOX 11,0 - 19,2") + check(found("draw", function(c) + return c[2] == "MONEY" and c[3] == 104 and c[4] == 0 + end) ~= nil, "with MONEY captioned on its top border at column 13") +end + +do + local menu, list = openList(1) + check(getmetatable(list) == ListMenu, "BUY opens a list menu") + eq(list.itemBox, true, "PRICEDITEMLISTMENU draws LIST_MENU_BOX") + eq(list.isOpaque, false, "so the mart floor and clerk keep drawing") + eq(list.title, nil, "no BUY header row: the ROM list has no title") + eq(list.items[#list.items].cancel, true, "the terminator's CANCEL row") + eq(list.items[2].price, "¥600", "prices ride item.price, not the name row") + + calls = {} + list:draw() + check(found("box", function(c) + return c[2] == 4 and c[3] == 2 and c[4] == 16 and c[5] == 11 + end) ~= nil, "LIST_MENU_BOX 4,2 - 19,12") + check(found("draw", function(c) + return c[2] == "GREAT BALL" and c[3] == 48 and c[4] == 48 + end) ~= nil, "the second name sits at (48, 48)") + check(found("draw", function(c) + return c[2] == "¥600" and c[4] == 56 + end) ~= nil, "its price is one tile row below the name") + local price = found("draw", function(c) return c[2] == "¥600" end) + if price then + eq(price[3] + FontStub.width("¥600"), 136, + "right-aligned through the BCD field (home/list_menu.asm:410-424)") + end + check(found("draw", function(c) return c[2] == "CANCEL" end) ~= nil, + "CANCEL is a real row (home/list_menu.asm:523-528)") + + list.index = #list.items + pressed = "a" + list:update(1 / 60) + pressed = nil + eq(game.stack:top(), menu, "A on CANCEL returns to the mart menu") +end + +do + game.save.inventory = { POTION = 3, TOWN_MAP = 1, HM_CUT = 1 } + local _, list = openList(2) + check(getmetatable(list) == ListMenu, "SELL opens a list menu") + eq(list.itemBox, true, "ITEMLISTMENU draws the same box") + eq(list.title, nil, "and carries no SELL header") + local byId = {} + for _, it in ipairs(list.items) do + if it.value then byId[it.value] = it end + end + eq(byId.POTION.right, "x3", "ordinary stock keeps its quantity") + eq(byId.TOWN_MAP.right, nil, "IsKeyItem_ skips the quantity") + eq(byId.HM_CUT.right, nil, "and so do the HMs") + eq(list.items[#list.items].cancel, true, "CANCEL closes the sell list too") + game.save.inventory = {} +end + +package.loaded["src.render.Font"] = realFont +for _, mod in ipairs({ "src.ui.ListMenu", "src.ui.Theme", "src.ui.Menu", + "src.ui.ShopMenu", "src.render.TextBox" }) do + package.loaded[mod] = nil +end +require("src.ui.Screens").invalidate() + +T.finish() diff --git a/tests/engine/menu_hold_scroll_bug1898.lua b/tests/engine/menu_hold_scroll_bug1898.lua new file mode 100644 index 00000000..39d2799e --- /dev/null +++ b/tests/engine/menu_hold_scroll_bug1898.lua @@ -0,0 +1,235 @@ +-- home/joypad2.asm:16-53 (#1898) + +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") + +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 #tostring(text) * 8 end, + split = function(text) + local spans = {} + for i = 1, #tostring(text) do spans[i] = { from = i, to = i } end + return spans + end, + spansFitting = function(spans) return #spans end, +} + +local MenuRepeat = require("src.ui.MenuRepeat") +local ListMenu = require("src.ui.ListMenu") + +local function newInput() + local input = { edges = {}, held = {} } + function input:press(button) + self.edges[button] = true + self.held[button] = true + end + function input:release(button) + self.edges[button] = nil + self.held[button] = nil + end + function input:wasPressed(button) + if self.edges[button] then + self.edges[button] = nil + return true + end + return false + end + function input:isDown(button) return self.held[button] == true end + return input +end + +do + eq(MenuRepeat.GEN1_DELAY, 30, "half a second before the first repeat") + eq(MenuRepeat.GEN1_RATE, 5, "then 1/12 of a second between repeats") + eq(MenuRepeat.GEN2_DELAY, 15, "JoyTextDelay seeds wTextDelayFrames with 15") + eq(MenuRepeat.GEN2_RATE, 5, "and reseeds it with 5") + + local input = newInput() + local state = MenuRepeat.new(MenuRepeat.GEN1_DELAY, MenuRepeat.GEN1_RATE) + input:press("down") + local dir, edge = MenuRepeat.direction(state, input, { "up", "down" }) + eq(dir, "down", "the press itself reports immediately") + eq(edge, true, "and is flagged as the edge") + + local fired = {} + for frame = 1, 60 do + local d = MenuRepeat.direction(state, input, { "up", "down" }) + if d then fired[#fired + 1] = frame end + end + eq(fired[1], 30, "the hold is ignored for half a second") + eq(fired[2], 35, "then repeats every five frames") + eq(fired[3], 40, "and keeps repeating") + eq(#fired, 7, "seven repeats in the second after the press") + + input:release("down") + eq(MenuRepeat.direction(state, input, { "up", "down" }), nil, + "letting go stops it") + eq(state.frames, 0, "and clears the counter, so the next hold waits again") + + local off = MenuRepeat.new(MenuRepeat.GEN1_DELAY, MenuRepeat.GEN1_RATE, false) + input:press("down") + MenuRepeat.direction(off, input, { "up", "down" }) + local repeated = false + for _ = 1, 120 do + if MenuRepeat.direction(off, input, { "up", "down" }) then repeated = true end + end + check(not repeated, "a disabled state stays edge-only") + input:release("down") +end + +do + local items = {} + for i = 1, 10 do items[i] = { value = i, label = "ITEM " .. i } end + local input = newInput() + local game = { input = input, + stack = { push = function() end, pop = function() end, + top = function() end } } + local list = ListMenu.new(game, "ITEMS", items, { kind = "bag", itemBox = true }) + eq(list.keyRepeat, true, + "DisplayListMenuID runs with hJoy7 = 1, with no mod hook in sight") + eq(list.repeatDelay, 30, "at the cart's half-second delay") + eq(list.repeatRate, 5, "and the cart's repeat rate") + + input:press("down") + list:update(1 / 60) + eq(list.index, 2, "the press moves the cursor one row") + for _ = 1, 29 do list:update(1 / 60) end + eq(list.index, 2, "a short hold moves nothing more") + list:update(1 / 60) + eq(list.index, 3, "the 30th held frame is the first repeat") + for _ = 1, 5 do list:update(1 / 60) end + eq(list.index, 4, "and every fifth frame after that") + for _ = 1, 60 do list:update(1 / 60) end + eq(list.index, 10, "holding Down walks to the end of the list") + eq(list.scroll, 7, "with the window scrolled to the bottom") + + input:release("down") + input:press("up") + list:update(1 / 60) + for _ = 1, 200 do list:update(1 / 60) end + eq(list.index, 1, "holding Up walks back to the top") + eq(list.scroll, 0, "and the window comes with it") +end + +do + local data = { pokemon = {}, constants = { dexSize = 151, dexDigits = 3 } } + for n = 1, 151 do + local id = ("DEXMON_%03d"):format(n) + data.pokemon[id] = { id = id, name = ("MON%03d"):format(n), dex = n } + end + local save = { pokedex = { seen = {}, owned = {} } } + for n = 1, 20 do save.pokedex.seen[("DEXMON_%03d"):format(n)] = true end + local input = newInput() + local game = { data = data, save = save, input = input, + stack = { push = function() end, pop = function() end, + top = function() end } } + local PokedexMenu = require("src.ui.PokedexMenu") + local dex = PokedexMenu.new(game, {}) + eq(#dex.items, 20, "twenty seen species in the list") + + input:press("down") + dex:update(1 / 60) + eq(dex.index, 2, "ShowPokedexMenu's own list moves on the press") + for _ = 1, 29 do dex:update(1 / 60) end + eq(dex.index, 2, "and waits out the same half second") + dex:update(1 / 60) + eq(dex.index, 3, "before the first repeat") + for _ = 1, 200 do dex:update(1 / 60) end + eq(dex.index, 20, "a long hold reaches the last seen species") + + input:release("down") + input:press("right") + dex:update(1 / 60) + eq(dex.scroll, 13, "Right pages the scroll offset and clamps at the end") + for _ = 1, 60 do dex:update(1 / 60) end + eq(dex.scroll, 13, "holding Right at the bottom cannot page past it") + input:release("right") + + input:press("left") + dex:update(1 / 60) + eq(dex.scroll, 6, "Left pages back seven rows") + for _ = 1, 40 do dex:update(1 / 60) end + eq(dex.scroll, 0, "and a held Left runs the list back to the top") + input:release("left") +end + +do + local PackMenu = require("src.ui.gen2.PackMenu") + local Save = require("src.core.gen2.Save") + local items = {} + local order = {} + for i = 1, 8 do + local id = "FAKEITEM_" .. i + items[id] = { id = id, name = "ITEM " .. i, pocket = "ITEM", index = i, + canToss = true, canSelect = false, + fieldMenu = "ITEMMENU_CLOSE" } + order[i] = id + end + local save = Save.newGame() + save.inventory = {} + for _, id in ipairs(order) do save.inventory[id] = 1 end + save.bagOrder = order + local input = newInput() + local game = { input = input, save = save, options = save.options, + data = { items = items, moves = {}, pokemon = {}, + audio = { sfx = {} } }, + stack = { push = function() end, pop = function() end } } + local pack = PackMenu.new(game, { save = save, pocket = "ITEM", + onClose = function() end, world = { useFieldItem = function() end } }) + pack.gfx = { available = function() return false end, draw = function() end, + colorsAt = function() return nil end } + eq(pack.hold.delay, 15, "the pocket list runs at JoyTextDelay's cadence") + eq(pack.hold.rate, 5, "with the cart's five-frame repeat") + + local total = pack:total() + check(total >= 9, "eight items plus the CANCEL row") + input:press("down") + pack:update(1 / 60) + eq(pack.index, 2, "the press moves one row") + for _ = 1, 14 do pack:update(1 / 60) end + eq(pack.index, 2, "fifteen frames of delay first") + pack:update(1 / 60) + eq(pack.index, 3, "then the first repeat") + for _ = 1, 200 do pack:update(1 / 60) end + eq(pack.index, total, + "a held Down stops on the last row rather than wrapping round") + input:release("down") +end + +do + local Gen2Dex = require("src.ui.gen2.PokedexMenu") + local entries, seen = {}, {} + for n = 1, 30 do + local id = ("G2MON_%03d"):format(n) + entries[id] = { dex = n, species = id, name = ("MON%03d"):format(n) } + seen[id] = true + end + local input = newInput() + local save = { pokedex = { seen = seen, caught = {} } } + local game = { data = {}, save = save, input = input } + local dex = Gen2Dex.new(game, { save = save, + pokedex = { entries = entries }, + pokemon = {} }) + eq(dex.hold.delay, 15, "the listing runs at JoyTextDelay's cadence") + check(#dex.rows >= 30, "thirty seen species in the listing") + + input:press("down") + dex:update(1 / 60) + eq(dex.index, 2, "the press moves one row") + for _ = 1, 14 do dex:update(1 / 60) end + eq(dex.index, 2, "after the same fifteen-frame delay") + dex:update(1 / 60) + eq(dex.index, 3, "the hold starts repeating") + for _ = 1, 400 do dex:update(1 / 60) end + eq(dex.index, #dex.rows, + "Pokedex_ListingMoveCursorDown stops at the end of the listing") + input:release("down") +end + +T.finish("menu hold-to-scroll bug 1898") diff --git a/tests/engine/mod_update_badge.lua b/tests/engine/mod_update_badge.lua new file mode 100644 index 00000000..4837a9b1 --- /dev/null +++ b/tests/engine/mod_update_badge.lua @@ -0,0 +1,97 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local ModUpdate = require("src.mods.ModUpdate") +local LauncherMods = require("src.mods.LauncherMods") +local RomImporter = require("src.import.RomImporter") + +local RELEASES = { + { version = "1.1.10", zip = { url = "https://example.invalid/a.zip" } }, + { version = "1.0.0", zip = { url = "https://example.invalid/b.zip" } }, +} + +local oldPump = ModUpdate.pumpDownloadZip +local oldInstall = LauncherMods.installDownloadedZip +local oldDeps = LauncherMods.checkDependencies +local installedAs + +ModUpdate.pumpDownloadZip = function() return true, "install.zip" end +LauncherMods.installDownloadedZip = function() return true, installedAs end +LauncherMods.checkDependencies = function() return { hasIssues = false } end + +local function launcher(installed, wanted) + installedAs = wanted + local ri = setmetatable({ + mods = { { id = "gym", name = "Gym Challenge", version = installed, + github = "someone/gym" } }, + modUpdateInfo = { + gym = { status = "available", latest = "1.1.10", best = RELEASES[1], + releases = RELEASES, checkedAt = 1 }, + }, + _modInstall = { + h = {}, version = wanted, + spec = { modId = "gym", name = "Gym Challenge", verb = "Updated", + notice = "mod" }, + }, + }, RomImporter) + ri._refreshMods = function(self) self.mods[1].version = wanted end + return ri +end + +do + local ri = launcher("1.0.0", "1.1.10") + ri:_pumpModInstall() + eq(ri:_modUpdateInfo("gym").status, "current", + "the mod that just installed the latest release is up to date") + eq(ri:_modUpdateInfo("gym").latest, "1.1.10", + "and the release list it was judged against is still on the row") + check(ri.modNotice and ri.modNotice.ok, "the install still reports success") + check(ri.modNotice.text:find("1.1.10", 1, true) ~= nil, + "with the version it landed") + eq(ri._modInstall, nil, "and the job is done") +end + +do + local ri = launcher("0.9.0", "1.0.0") + ri:_pumpModInstall() + eq(ri:_modUpdateInfo("gym").status, "available", + "an older release is still behind the newest one") + eq(ri:_modUpdateInfo("gym").latest, "1.1.10", + "which is the release the badge names") +end + +do + local ri = launcher("1.0.0", "1.1.10") + local pending = { mod = ri.mods[1], h = {} } + ri._modInfoFetch = { pending } + ri:_pumpModInstall() + eq(pending.mod.version, "1.1.10", + "the in-flight check judges against the version now on disk") + + local oldFetch = ModUpdate.pumpFetchReleases + local oldCache = ModUpdate.readCache + ModUpdate.pumpFetchReleases = function() return true, RELEASES end + ModUpdate.readCache = function() return nil end + ri:_pumpModInfoFetch() + ModUpdate.pumpFetchReleases = oldFetch + ModUpdate.readCache = oldCache + eq(ri:_modUpdateInfo("gym").status, "current", + "so it lands on the same answer instead of bringing the chip back") +end + +do + local ri = launcher("1.0.0", "1.1.10") + ri.modUpdateInfo = {} + ri:_pumpModInstall() + eq(ri:_modUpdateInfo("gym"), nil, + "no release list means no verdict to invent") +end + +ModUpdate.pumpDownloadZip = oldPump +LauncherMods.installDownloadedZip = oldInstall +LauncherMods.checkDependencies = oldDeps + +T.finish("mod update badge") diff --git a/tests/engine/open_menu_bugs_949_1149.lua b/tests/engine/open_menu_bugs_949_1149.lua index c9af0053..7999bf8c 100644 --- a/tests/engine/open_menu_bugs_949_1149.lua +++ b/tests/engine/open_menu_bugs_949_1149.lua @@ -16,9 +16,13 @@ T.eq(romText(localized, "_MoveIsDisabledText", "%s's %s is disabled!", { GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")()) local data = { - pokemon = {}, moves = {}, items = {}, maps = {}, + pokemon = {}, moves = {}, items = {}, + -- home/overworld.asm:2016 (#1691) + maps = { PALLET_TOWN = { id = "PALLET_TOWN", index = 0, tileset = "OVERWORLD", + width = 10, height = 9 } }, hiddenItems = loadfile("src/save_convert/data/hidden_items.lua")(), } +loadfile("tests/fixture_data/map_window.lua")()(data, "PALLET_TOWN") local save = { player = { name = "RED", id = 1, map = "PALLET_TOWN", x = 0, y = 0 }, rival = { name = "BLUE" }, party = {}, boxes = {}, inventory = {}, diff --git a/tests/engine/party_battle_prompt_bug1901.lua b/tests/engine/party_battle_prompt_bug1901.lua new file mode 100644 index 00000000..888130c7 --- /dev/null +++ b/tests/engine/party_battle_prompt_bug1901.lua @@ -0,0 +1,50 @@ +-- pokered engine/battle/core.asm:2315 (#1901) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq +love = love or require("tests.love_stub") + +local PartyMenu = require("src.ui.PartyMenu") + +local game = { + data = { text = {}, pokemon = { LAPRAS = { name = "LAPRAS" } } }, + save = { + party = { { species = "LAPRAS", hp = 50, stats = { hp = 50 }, + level = 30, moves = {} } }, + inventory = {}, options = {}, flags = {}, + }, +} +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 } + +local battle = { playerParty = game.save.party } + +local function message(opts) + return PartyMenu.new(game, opts):bottomMessage() +end + +eq(message({}), "Choose a POKéMON.", + "field START -> POKéMON is NORMAL_PARTY_MENU") +eq(message({ battle = battle }), "Choose a POKéMON.", + "the voluntary PKMN option is NORMAL_PARTY_MENU too") +eq(message({ battle = battle, forceSwitch = true }), + "Bring out which\nPOKéMON?", + "ChooseNextMon / SHIFT is BATTLE_PARTY_MENU") +eq(message({ battle = battle, itemUse = true }), + "Use item on which\nPOKéMON?", + "in-battle medicine keeps USE_ITEM_PARTY_MENU") +eq(message({ tmhm = { move = "FIX_CUT", kind = "TM" } }), + "Use TM on which\nPOKéMON?", "TMHM_PARTY_MENU") + +game.data.text._PartyMenuNormalText = "CHOOSE A #MON." +game.data.text._PartyMenuBattleText = "BRING OUT WHICH #MON?" +eq(message({ battle = battle }), "CHOOSE A #MON.", + "the voluntary open prints PartyMenuNormalText") +eq(message({ battle = battle, forceSwitch = true }), "BRING OUT WHICH #MON?", + "the forced open prints PartyMenuBattleText") + +T.finish("party_battle_prompt_bug1901") diff --git a/tests/engine/poison_flicker_bug1872.lua b/tests/engine/poison_flicker_bug1872.lua new file mode 100644 index 00000000..2d18d9e9 --- /dev/null +++ b/tests/engine/poison_flicker_bug1872.lua @@ -0,0 +1,111 @@ +-- engine/gfx/screen_effects.asm:1-12 (#1872) + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.modkit") +local check, eq = T.check, T.eq + +local Data = T.fixtures.fresh() + +local SaveData = require("src.core.SaveData") +local Pokemon = require("src.pokemon.Pokemon") +local PaletteFX = require("src.render.PaletteFX") +local OW = require("src.world.OverworldController") + +local function setUpvalue(fn, name, val) + local i = 1 + while true do + local n = debug.getupvalue(fn, i) + if not n then return false end + if n == name then debug.setupvalue(fn, i, val); return true end + i = i + 1 + end +end + +do + local bgp = 0xE4 + local want = {} + for i = 0, 3 do + local shade = math.floor(bgp / (4 ^ i)) % 4 + want[i] = (i == 0) and (shade % 4 >= 2 and shade or shade + 2) or shade + end + for i = 0, 3 do + eq(PaletteFX.POISON_BGP[i], want[i], + ("POISON_BGP color %d matches `or $2` on rBGP"):format(i)) + end + + local colors = { { 1, 1, 1 }, { 2, 2, 2 }, { 3, 3, 3 }, { 4, 4, 4 } } + local out = PaletteFX.permute(colors, PaletteFX.POISON_BGP) + eq(out[1][1], 3, "poison shows DMG white as shade 2") + eq(out[2][1], 2, "poison leaves shade 1 alone") + eq(out[4][1], 4, "poison leaves shade 3 alone") +end + +do + local realSound = package.loaded["src.core.Sound"] + package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end } + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 20) } + save.party[1].status = "PSN" + save.party[1].hp = 20 + save.poisonSteps = 3 + local game = { data = Data, save = save, stack = { push = function() end } } + setUpvalue(OW.applyFieldPoison, "Game", game) + local self_ = setmetatable({}, { __index = OW }) + self_:applyFieldPoison() + eq(self_.poisonFlash, 4, "a poison tick arms four frames of flicker") + package.loaded["src.core.Sound"] = realSound +end + +do + local rects = {} + local savedRect = love.graphics.rectangle + love.graphics.rectangle = function(mode, x, y, w, h) + rects[#rects + 1] = { mode = mode, x = x, y = y, w = w, h = h } + end + local savedShader = PaletteFX.shader + PaletteFX.shader = function() return { send = function() end } end + + local renderer = {} + setUpvalue(OW.drawUI, "Game", { renderer = renderer }) + local self_ = setmetatable({ poisonFlash = 4 }, { __index = OW }) + + PaletteFX.setShadeMap(nil) + self_:drawUI() + eq(#rects, 0, "the poison flicker paints no rect on the UI canvas") + check(PaletteFX.shadeMap() == PaletteFX.POISON_BGP, + "the flicker arms the poison BGP map, which the world and UI blits both read") + eq(self_.poisonFlash, 3, "and ticks the counter down") + + for _ = 1, 3 do + PaletteFX.setShadeMap(nil) + self_:drawUI() + end + eq(self_.poisonFlash, 0, "four draws spend the flicker") + PaletteFX.setShadeMap(nil) + self_:drawUI() + check(PaletteFX.shadeMap() == nil, "a spent flicker arms nothing") + + -- home/fade.asm:66 + self_.poisonFlash = 4 + PaletteFX.setShadeMap(PaletteFX.DARK_BGP) + self_:drawUI() + check(PaletteFX.shadeMap() == PaletteFX.DARK_BGP, + "the flicker leaves a dark map's shade map in place") + eq(#rects, 0, "and still paints no rect") + + PaletteFX.shader = function() return nil end + PaletteFX.setShadeMap(nil) + self_.poisonFlash = 4 + renderer.screenVeil = nil + self_:drawUI() + check(renderer.screenVeil ~= nil and renderer.screenVeil[1] == 0 + and renderer.screenVeil[2] > 0, + "with no shader the flicker becomes a screen-space veil") + eq(#rects, 0, "and still not a 160x144 fill") + + PaletteFX.setShadeMap(nil) + PaletteFX.shader = savedShader + love.graphics.rectangle = savedRect +end diff --git a/tests/engine/poke_flute_battle_turn_bug1882.lua b/tests/engine/poke_flute_battle_turn_bug1882.lua new file mode 100644 index 00000000..a6a0f161 --- /dev/null +++ b/tests/engine/poke_flute_battle_turn_bug1882.lua @@ -0,0 +1,105 @@ +-- pokered engine/items/item_effects.asm:1-3 (#1882) + +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 ItemEffects = require("src.inventory.ItemEffects") + +local data = { + items = { POKE_FLUTE = { id = "POKE_FLUTE", index = 49, name = "POKé FLUTE" }, + POTION = { id = "POTION", index = 1, name = "POTION", price = 300 } }, + text = {}, +} + +local function newSave(status) + return { + party = { { species = "CHARMANDER", hp = 20, stats = { hp = 20 }, + level = 12, status = status } }, + player = { name = "RED" }, + inventory = { POKE_FLUTE = 1, POTION = 1 }, + options = {}, flags = {}, + } +end + +local function newBattle(save, enemyStatus) + return { + player = { mon = save.party[1] }, + enemy = { mon = { species = "PIDGEY", hp = 15, stats = { hp = 15 }, + level = 8, status = enemyStatus } }, + enemyParty = {}, + } +end + +do + local save = newSave(nil) + local battle = newBattle(save, nil) + local result, msgs = ItemEffects.use(data, save, "POKE_FLUTE", nil, battle) + eq(result, "kept", "nothing asleep still takes the turn") + check(msgs and msgs[1] and msgs[1]:find("catchy"), + "and prints PlayedFluteNoEffectText") + eq(save.inventory.POKE_FLUTE, 1, "the flute is a key item, never consumed") +end + +do + local save = newSave("SLP") + local battle = newBattle(save, nil) + local result = ItemEffects.use(data, save, "POKE_FLUTE", nil, battle) + eq(result, "flute", "a sleeping party mon is the had-effect arm") + eq(save.party[1].status, nil, "WakeUpEntireParty cleared the sleep") +end + +do + local save = newSave(nil) + local battle = newBattle(save, "SLP") + local result = ItemEffects.use(data, save, "POKE_FLUTE", nil, battle) + eq(result, "flute", "a sleeping active enemy is the had-effect arm too") +end + +do + local save = newSave(nil) + local battle = newBattle(save, nil) + local result = ItemEffects.use(data, save, "POTION", save.party[1], battle) + eq(result, "failed", "a POTION on a full-HP mon still costs no turn") +end + +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { textBox = true, text = text, done = done } end, + soundOpts = function(_, _, opts) return opts end, +} +package.loaded["src.ui.BagMenu"] = nil +local BagMenu = require("src.ui.BagMenu") + +do + local save = newSave(nil) + local battle = newBattle(save, nil) + local turns = 0 + battle.itemUsed = function() turns = turns + 1 end + local game = { data = data, save = save } + 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 } + local list = BagMenu.new(game, { battle = battle }) + game.stack:push(list) + local row + for i, item in ipairs(list.items) do + if item.value == "POKE_FLUTE" then row = i end + end + if check(row ~= nil, "the flute is in the battle bag") then + list.index = row + list.onChoose(list.items[row], list) + local box = game.stack:top() + if check(box and box.textBox, "the no-effect line is up") then + eq(turns, 0, "the turn waits on the message") + game.stack:pop() + box.done() + eq(turns, 1, "dismissing it spends the turn") + eq(game.stack:top(), nil, "and the bag is gone, not back on ITEMS") + end + end +end + +T.finish("poke_flute_battle_turn_bug1882") diff --git a/tests/engine/poke_flute_prompt_bug1880.lua b/tests/engine/poke_flute_prompt_bug1880.lua new file mode 100644 index 00000000..cceb0181 --- /dev/null +++ b/tests/engine/poke_flute_prompt_bug1880.lua @@ -0,0 +1,101 @@ +-- pokered engine/items/item_effects.asm:1794 (#1880) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local check, eq = T.check, T.eq +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) +local TextBox = require("src.render.TextBox") + +local plays = {} +local sources = {} +local function newSource(file) + local src = sources[file] + if src then return src end + src = { + playing = false, + setVolume = function() end, + setPitch = function() end, + stop = function() end, + isPlaying = function(self) return self.playing end, + play = function(self) + plays[#plays + 1] = file + self.playing = true + end, + } + sources[file] = src + return src +end +love.audio = { newSource = newSource } +Data.audio = { sfx = { Press_AB = "ab.wav", Pokeflute = "flute.wav" }, + fanfares = {} } + +local function tunes() + local n = 0 + for _, file in ipairs(plays) do + if file == "flute.wav" then n = n + 1 end + end + return n +end + +local stack = { states = {} } +function stack:push(s) self.states[#self.states + 1] = s end +function stack:pop() return table.remove(self.states) end +function stack:top() return self.states[#self.states] end + +local pressed = {} +local game = { + data = Data, + save = { player = { name = "RED" }, options = { textSpeed = 1 } }, + stack = stack, + input = { + wasPressed = function(_, key) return pressed[key] or false end, + isDown = function() return false end, + }, +} + +local function step(btn) + pressed = btn and { [btn] = true } or {} + local top = stack:top() + if top then top:update(1 / 60) end + pressed = {} +end + +local woke = 0 +local opts = TextBox.soundOpts(game, "Pokeflute", + { auto = { wait = false, delay = 0, promptFirst = true } }) +local box = TextBox.new(game, "RED PLAYED THE\nPOKE FLUTE", + function() woke = woke + 1 end, opts) +stack:push(box) + +for _ = 1, 2000 do + if box.done then break end + step(box.waiting and "a" or nil) +end +check(box.done, "the played-flute line typed out") +eq(tunes(), 0, "the tune has not started yet") + +step() +eq(tunes(), 0, "still silent while the prompt is up") +eq(stack:top(), box, "and the box is still on screen") +eq(woke, 0, "the woke-up script has not started") + +step("a") +eq(box.autoPrompted, true, "A answers the prompt") +step() +eq(tunes(), 1, "and the tune starts after it") +eq(box.autoSrc, sources["flute.wav"], "the box holds the flute source") +eq(stack:top(), box, "the played-flute line stays up under it") + +for _ = 1, 30 do step("a") end +eq(tunes(), 1, "A neither retriggers nor cuts the tune short") +eq(stack:top(), box, "and cannot dismiss the box") +eq(woke, 0, "so Snorlax does not wake mid-tune") + +sources["flute.wav"].playing = false +step() +eq(stack:top(), nil, "the box pops itself once the tune is over") +eq(woke, 1, "and the woke-up script runs, with no second button") + +T.finish("poke_flute_prompt_bug1880") diff --git a/tests/engine/pokedex_area_bug1878.lua b/tests/engine/pokedex_area_bug1878.lua new file mode 100644 index 00000000..41c9e657 --- /dev/null +++ b/tests/engine/pokedex_area_bug1878.lua @@ -0,0 +1,133 @@ +-- engine/items/town_map.asm:124, 403 (#1878) + +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 Font = require("src.render.Font") +local TownMap = require("src.ui.TownMap") + +local LOCATIONS = { + ROUTE_2 = { x = 2, y = 6, name = "ROUTE 2" }, + VIRIDIAN_FOREST = { x = 2, y = 4, name = "VIRIDIAN FOREST" }, + CERULEAN_CAVE_1F = { x = 9, y = 1, name = "CERULEAN CAVE" }, + PALLET_TOWN = { x = 1, y = 12, name = "PALLET TOWN" }, +} + +local function grass(species) + return { grass = { slots = { { species = species, level = 3 } } } } +end + +local function newGame(encounters) + return { + data = { + field = { + townMap = { + locations = LOCATIONS, + background = { + map = { 1 }, + tiles = { path = "assets/generated/townmap/tiles.png" }, + }, + }, + playerSprites = { walk = "SPRITE_RED" }, + }, + sprites = { SPRITE_RED = { image = "assets/generated/sprites/red_walk.png" } }, + pokemon = { + BULBASAUR = { name = "BULBASAUR" }, + CATERPIE = { name = "CATERPIE" }, + MEWTWO = { name = "MEWTWO" }, + }, + maps = {}, + encounters = encounters, + }, + save = {}, + overworld = { map = { id = "PALLET_TOWN" } }, + } +end + +local function capture(tm) + local texts, draws, boxes = {}, {}, {} + local realDraw = love.graphics.draw + local realText, realBox = Font.draw, Font.drawBox + love.graphics.draw = function(img, quadOrX, x, y) + draws[#draws + 1] = { img = img, quad = quadOrX, x = x, y = y } + end + Font.draw = function(text, x, y) + texts[#texts + 1] = { text = text, x = x, y = y } + return 0 + end + Font.drawBox = function(tx, ty, tw, th) + boxes[#boxes + 1] = { tx = tx, ty = ty, tw = tw, th = th } + end + tm.blink = 0 + tm:draw() + love.graphics.draw, Font.draw, Font.drawBox = realDraw, realText, realBox + return texts, draws, boxes +end + +local function findText(texts, want) + for _, t in ipairs(texts) do + if t.text == want then return t end + end + return nil +end + +local tm = TownMap.new(newGame({}), { nestSpecies = "BULBASAUR" }) +eq(#tm.nests, 0, "no encounters means no nests") +check(tm.playerLoc ~= nil, "the player's location still resolves in AREA mode") + +local texts, draws, boxes = capture(tm) +check(findText(texts, "BULBASAUR's NEST") ~= nil, + "row 0 always reads \"'s NEST\"") +check(findText(texts, "BULBASAUR AREA UNKNOWN") == nil, + "the cropped concatenated title is gone") +local unknown = findText(texts, " AREA UNKNOWN") +check(unknown ~= nil, "AREA UNKNOWN is printed in the middle of the map") +if unknown then + eq(unknown.x, 16, "AreaUnknownText sits at hlcoord 2, 9") + eq(unknown.y, 72, "AreaUnknownText sits at hlcoord 2, 9") +end +local box +for _, b in ipairs(boxes) do + if b.tx == 1 and b.ty == 7 then box = b end +end +check(box ~= nil, "TextBoxBorder at hlcoord 1, 7") +if box then + eq(box.tw, 17, "the border is 15 interior columns wide") + eq(box.th, 4, "the border is 2 interior rows tall") +end +local drewPlayer = false +for _, d in ipairs(draws) do + if d.img == tm.playerSheet then drewPlayer = true end +end +check(not drewPlayer, + "DrawPlayerOrBirdSprite is skipped when no nest OAM was written") + +local tm2 = TownMap.new( + newGame({ ROUTE_2 = grass("CATERPIE"), VIRIDIAN_FOREST = grass("CATERPIE") }), + { nestSpecies = "CATERPIE" }) +eq(#tm2.nests, 2, "ROUTE 2 and VIRIDIAN FOREST are separate nest squares") + +local texts2, draws2, boxes2 = capture(tm2) +check(findText(texts2, "CATERPIE's NEST") ~= nil, "the title is unchanged") +check(findText(texts2, " AREA UNKNOWN") == nil, + "no AREA UNKNOWN text once a nest was drawn") +eq(#boxes2, 0, "no mid-screen box once a nest was drawn") +local playerDraw +for _, d in ipairs(draws2) do + if d.img == tm2.playerSheet and d.quad == tm2.playerQuad then playerDraw = d end +end +check(playerDraw ~= nil, "the player's walk sprite is drawn on the AREA map") +if playerDraw then + -- town_map.asm:454 + eq(playerDraw.x, tm2.playerLoc.x * 8 + 16 - 4, "player x is markerXY - 4") + eq(playerDraw.y, tm2.playerLoc.y * 8 + 8 - 3, "player y is markerXY - 3") +end + +local tm3 = TownMap.new( + newGame({ CERULEAN_CAVE_1F = grass("MEWTWO") }), { nestSpecies = "MEWTWO" }) +eq(#tm3.nests, 0, "packed coords $19 (Cerulean Cave) are skipped") + +T.finish("pokedex area bug 1878") diff --git a/tests/engine/prize_window_bug1867.lua b/tests/engine/prize_window_bug1867.lua new file mode 100644 index 00000000..6149bcc0 --- /dev/null +++ b/tests/engine/prize_window_bug1867.lua @@ -0,0 +1,214 @@ +-- engine/events/prize_menu.asm (#1867) + +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 realFont = package.loaded["src.render.Font"] +local realTextBox = package.loaded["src.render.TextBox"] +local calls = {} +local FontStub +FontStub = { + BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 }, + draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end, + drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end, + drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end, + width = function(text) return #tostring(text) * 8 end, + split = function(text) + local out = {} + for i = 1, #tostring(text) do out[i] = i end + return out + end, +} +package.loaded["src.render.Font"] = FontStub +package.loaded["src.render.TextBox"] = { + new = function(_, text, onDone, opts) + return { text = text, onDone = onDone, opts = opts, isTextBox = true } + end, +} +package.loaded["src.core.Sound"] = { play = function() end } +local version = "red" +package.loaded["src.core.GameVersion"] = { + isBlue = function() return version == "blue" end, + isYellow = function() return version == "yellow" end, +} +package.loaded["src.ui.PrizeCounter"] = nil +package.loaded["src.ui.Theme"] = nil +local PrizeCounter = require("src.ui.PrizeCounter") + +local M = assert(loadfile("data/scripts/story3.lua"))() +local counters = M.GAME_CORNER_PRIZE_ROOM.talk + +local function found(kind, pred) + for _, c in ipairs(calls) do + if c[1] == kind and pred(c) then return c end + end + return nil +end + +local pressed, done +local function mkGame(coins) + done = false + local mons = {} + for _, n in ipairs({ "ABRA", "CLEFAIRY", "NIDORINA", "VULPIX", + "WIGGLYTUFF" }) do + mons[n] = { name = n } + end + return { + data = { + text = {}, + pokemon = mons, + items = { + TM_DRAGON_RAGE = { name = "TM23" }, + TM_HYPER_BEAM = { name = "TM15" }, + TM_SUBSTITUTE = { name = "TM35" }, + }, + }, + save = { coins = coins, inventory = { COIN_CASE = 1 } }, + input = { + wasPressed = function(_, b) return pressed == b end, + isDown = function() return false end, + }, + stack = { + states = {}, + push = function(self, s) self.states[#self.states + 1] = s end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + }, + } +end + +local function open(n, coins) + local game = mkGame(coins or 5000) + counters["TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_" .. n]( + game, nil, nil, function() done = true end) + local exchange = game.stack:pop() + exchange.onDone() + local which = game.stack:top() + which.opts.stay.onShown() + return game, which, game.stack:top() +end + +do + local game, which, window = open(1) + check(tostring(which.text):find("Which prize", 1, true) ~= nil, + "WhichPrizeText goes up before the window") + check(which.opts.stay ~= nil and which.opts.stay.prompt == nil, + "it ends in `done`: no prompt, and PrintText leaves it up") + check(getmetatable(window) == PrizeCounter, + "the prize window is the CeladonPrizeMenu overlay") + check(window.isOpaque ~= true, + "so the prize room keeps drawing around it") + + local names = {} + for i, row in ipairs(window.prizes) do names[i] = row.name end + eq(#window.prizes, 3, "one window is three prizes (#623)") + eq(table.concat(names, ","), "ABRA,CLEFAIRY,NIDORINA", + "Red's first window, by GetMonName") + for _, row in ipairs(window.prizes) do + check(tostring(row.name):find("L%d") == nil, + row.name .. " carries no level: GetPrizeMonLevel is not menu chrome") + end + eq(window.prizes[1].cost, 180, "with prizes.asm's coin prices") + eq(window.prizes[3].cost, 1200, "including the third row's") + + calls = {} + window:draw() + check(found("box", function(c) + return c[2] == 11 and c[3] == 0 and c[4] == 9 and c[5] == 3 + end) ~= nil, "PrintPrizePrice's coin box is 9x3 at hlcoord 11,0") + check(found("draw", function(c) + return c[2] == "COIN" and c[3] == 96 and c[4] == 0 + end) ~= nil, "with COIN on its top border at hlcoord 12,0") + local coins = found("draw", function(c) return c[2] == "5000" end) + if check(coins ~= nil, "and the balance on the row below") then + eq(coins[3] + FontStub.width("5000"), 136, + "right-aligned through the BCD field (hlcoord 13,1)") + eq(coins[4], 8, "on the box's middle row") + end + check(found("box", function(c) + return c[2] == 0 and c[3] == 2 and c[4] == 18 and c[5] == 10 + end) ~= nil, "the prize box is 18x10 at hlcoord 0,2, clear of columns 18-19") + for i, name in ipairs({ "ABRA", "CLEFAIRY", "NIDORINA" }) do + local y = 32 + (i - 1) * 16 + check(found("draw", function(c) + return c[2] == name and c[3] == 16 and c[4] == y + end) ~= nil, name .. " sits at (16, " .. y .. ")") + local cost = found("draw", function(c) + return c[2] == tostring(window.prizes[i].cost) + end) + if check(cost ~= nil, "its price is drawn") then + eq(cost[4], y + 8, "one tile row below the name (hlcoord 13, 5/7/9)") + eq(cost[3] + FontStub.width(cost[2]), 136, "right-aligned with the coins") + end + end + check(found("draw", function(c) + return c[2] == "NO THANKS" and c[3] == 16 and c[4] == 80 + end) ~= nil, "NoThanksText is the fourth row at hlcoord 2,10") + check(found("draw", function(c) return c[2] == "PRIZES (COINS)" end) == nil, + "and there is no full-screen page title") + + pressed = "up" + window:update(1 / 60) + eq(window.index, 1, "Up on the first prize does nothing") + pressed = "down" + for _ = 1, 6 do window:update(1 / 60) end + eq(window.index, 4, "Down stops on NO THANKS, the fourth row") + pressed = "a" + window:update(1 / 60) + pressed = nil + check(done, "A on NO THANKS ends the conversation (cp 3 -> .noChoice)") + eq(#game.stack.states, 0, + "with the window and WhichPrizeText both taken down") +end + +do + local game, _, window = open(3) + calls = {} + window:draw() + eq(window.prizes[1].name, "TM23", "vendor 3 is GetItemName, not GetMonName") + eq(window.prizes[3].cost, 7700, "PrizeMenuTMsEntries prices") + check(found("draw", function(c) + return c[2] == "3300" and c[4] == 40 + end) ~= nil, "TM prices sit on the row below their name too") +end + +do + version = "yellow" + local game, _, window = open(1) + local names = {} + for i, row in ipairs(window.prizes) do names[i] = row.name end + eq(table.concat(names, ","), "ABRA,VULPIX,WIGGLYTUFF", + "pokeyellow/data/events/prizes.asm restocks window 1") + eq(window.prizes[3].cost, 2680, "with Yellow's prices") + version = "red" +end + +do + local game, _, window = open(1, 5000) + window.index = 1 + pressed = "a" + window:update(1 / 60) + pressed = nil + local ask = game.stack:top() + check(ask.isTextBox and tostring(ask.text):find("ABRA", 1, true) ~= nil, + "SoYouWantPrizeText names the prize out of wNameBuffer") + check(tostring(ask.text):find("wNameBuffer", 1, true) == nil, + "with the token filled in") + check(ask.opts.choice ~= nil, "and a YES/NO over it") + eq(game.stack.states[#game.stack.states - 1], window, + "the window is still up underneath") + eq(game.save.coins, 5000, "no coins move before the answer") +end + +package.loaded["src.render.Font"] = realFont +package.loaded["src.render.TextBox"] = realTextBox +package.loaded["src.core.Sound"] = nil +package.loaded["src.core.GameVersion"] = nil +package.loaded["src.ui.PrizeCounter"] = nil +package.loaded["src.ui.Theme"] = nil +require("src.ui.Screens").invalidate() + +T.finish() diff --git a/tests/engine/rom_pick_tv_cancel.lua b/tests/engine/rom_pick_tv_cancel.lua new file mode 100644 index 00000000..ce6aec7e --- /dev/null +++ b/tests/engine/rom_pick_tv_cancel.lua @@ -0,0 +1,83 @@ +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 RomImporter = require("src.import.RomImporter") + +love.filesystem.getSaveDirectory = function() return "/sdcard/pokeport/save" end +love.system = { + getOS = function() return "Android" end, + pickFile = function() return true end, +} + +local function detail(ri) return tostring(ri.detail or "") end + +local function clearSaveDir() + for _, name in ipairs(love.filesystem.getDirectoryItems("")) do + love.filesystem.remove(name) + end +end + +local function freshImporter() + return setmetatable({ + android = true, + launcher = true, + workState = nil, + tab = "red", + ready = { red = false, blue = false, yellow = false }, + saveNotice = {}, + modNotice = nil, + notice = nil, + slotScroll = {}, + activeSlot = {}, + }, RomImporter) +end + +do + clearSaveDir() + love.filesystem.write("pick_error.flag", "cancelled:picked_rom.gb") + local ri = freshImporter() + ri:focus(true) + eq(ri.workState, "error", "an empty TV pick reports instead of staying silent") + check(detail(ri):find("did not return a file", 1, true), + "the notice blames the file manager, not an unreadable file") + check(not detail(ri):find("Files (Documents) app", 1, true), + "and does not send a TV player to an app the device does not have") + check(detail(ri):find("/sdcard/pokeport/save", 1, true), + "the save dir stays offered as the copy-it-yourself fallback") + eq(love.filesystem.getInfo("pick_error.flag"), nil, "the flag is consumed") +end + +do + clearSaveDir() + love.filesystem.write("pick_error.flag", "cancelled:picked_mod.zip") + local ri = freshImporter() + ri:focus(true) + check(ri.modNotice ~= nil and ri.modNotice.ok == false, + "the prefix still routes a mod pick to the mods panel") + eq(ri.workState, nil, "and leaves the ROM panel alone") +end + +do + clearSaveDir() + love.filesystem.write("pick_error.flag", "cancelled:picked_save.sav") + local ri = freshImporter() + ri.androidPendingVersion = "blue" + ri:focus(true) + check(ri.saveNotice.blue ~= nil and ri.saveNotice.blue.ok == false, + "and a save pick to the game it was picked for") +end + +do + clearSaveDir() + love.filesystem.write("pick_error.flag", "picked_rom.gb") + local ri = freshImporter() + ri:focus(true) + check(detail(ri):find("Could not read the picked file", 1, true), + "an unprefixed flag still reads as the #442 unreadable pick") +end + +clearSaveDir() +T.finish() diff --git a/tests/engine/save_convert_extra_flags.lua b/tests/engine/save_convert_extra_flags.lua index 24fd846f..1798b631 100644 --- a/tests/engine/save_convert_extra_flags.lua +++ b/tests/engine/save_convert_extra_flags.lua @@ -35,6 +35,7 @@ local data = { maps = loadfile("data/generated/maps.lua")(), eventFlags = events, } +loadfile("tests/fixture_data/map_window.lua")()(data, "REDS_HOUSE_2F") -- ------------------------------------------------------------------ -- offsets, walked forward from wTownVisitedFlag over ram/wram.asm's own diff --git a/tests/engine/save_convert_toggle_objects.lua b/tests/engine/save_convert_toggle_objects.lua index 219dc43b..4ab0ef2f 100644 --- a/tests/engine/save_convert_toggle_objects.lua +++ b/tests/engine/save_convert_toggle_objects.lua @@ -39,6 +39,8 @@ local data = { eventFlags = loadfile("src/save_convert/data/event_flags.lua")(), toggleObjects = toggles, } +local stampMapWindow = loadfile("tests/fixture_data/map_window.lua")() +stampMapWindow(data, "REDS_HOUSE_2F") -- ------------------------------------------------------------------ -- offset pins, independent of the codec's own arithmetic: wram.asm places @@ -175,6 +177,7 @@ local dataYellow = { eventFlags = loadfile("src/save_convert/data/event_flags_yellow.lua")(), gameVersion = "yellow", } +stampMapWindow(dataYellow, "REDS_HOUSE_2F") local ySave = seedSave() ySave.pikachuHappiness = 200 local yBytes = GenSave.encode(ySave, dataYellow, nil) diff --git a/tests/engine/save_export_map_window_bug1691.lua b/tests/engine/save_export_map_window_bug1691.lua new file mode 100644 index 00000000..a009dbbd --- /dev/null +++ b/tests/engine/save_export_map_window_bug1691.lua @@ -0,0 +1,88 @@ +-- home/overworld.asm:2016 (#1691) +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local MapContext = require("src.save_convert.MapContext") +local GenSave = require("src.save_convert.GenSave") + +local O = MapContext.OFFSETS +local SAVE = GenSave.OFFSETS +local stampMapWindow = loadfile("tests/fixture_data/map_window.lua")() + +GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")()) + +local function fixtureData() + local maps = {} + for id, map in pairs(dofile("tests/fixture_data/maps.lua")) do + local copy = {} + for k, v in pairs(map) do copy[k] = v end + maps[id] = copy + end + local data = { + maps = maps, + pokemon = dofile("tests/fixture_data/pokemon.lua"), + moves = dofile("tests/fixture_data/moves.lua"), + items = dofile("tests/fixture_data/items.lua"), + } + return stampMapWindow(data, "FIX_TOWN") +end + +local function newSave() + return { + player = { name = "RED", rival = "BLUE", map = "FIX_TOWN", x = 7, y = 4 }, + money = 3000, inventory = {}, pokedex = { seen = {}, owned = {} }, + flags = {}, party = {}, boxes = {}, + } +end + +do + local bytes = GenSave.encode(newSave(), fixtureData(), nil) + T.eq(#bytes, GenSave.SAVE_SIZE, "a complete cache still exports") + T.check(bytes:byte(SAVE.mainData + O.curMapHeader + 3) ~= 0, + "with a map width the game can walk") + T.check(bytes:byte(SAVE.mainData + O.tilesetHeader + 1) ~= 0, + "and a tileset bank that is not bank 0") +end + +local CASES = { + { + label = "no saved-map bytes", + want = "saved%-map bytes", + strip = function(data) data.maps.FIX_TOWN.sram = nil end, + }, + { + label = "no tileset header", + want = "tileset bytes", + strip = function(data) data.tilesets = {} end, + }, + { + label = "no map music", + want = "map music", + strip = function(data) data.audio = {} end, + }, +} + +for _, case in ipairs(CASES) do + local data = fixtureData() + case.strip(data) + local ok, err = pcall(GenSave.encode, newSave(), data, nil) + T.eq(ok, false, case.label .. ": the export is refused, not written") + T.check(type(err) == "string" and err:find(case.want) ~= nil, + case.label .. ": and says what is missing -- " .. tostring(err)) + T.check(type(err) == "string" and err:find("re%-import the ROM") ~= nil, + case.label .. ": and what to do about it -- " .. tostring(err)) +end + +do + local data = fixtureData() + local template = GenSave.encode(newSave(), data, nil) + local moved = template:sub(1, SAVE.curMap) .. string.char(0xFE) + .. template:sub(SAVE.curMap + 2) + data.maps.FIX_TOWN.sram = nil + local ok, err = pcall(GenSave.encode, newSave(), data, moved) + T.eq(ok, false, "a stale template is refused rather than exported as it is") + T.check(type(err) == "string" and err:find("saved%-map bytes") ~= nil, + "for the same missing bytes -- " .. tostring(err)) +end + +T.finish("save export map window bug1691") diff --git a/tests/engine/save_file_io_tests.lua b/tests/engine/save_file_io_tests.lua index d452c1f5..123c6e50 100644 --- a/tests/engine/save_file_io_tests.lua +++ b/tests/engine/save_file_io_tests.lua @@ -67,6 +67,14 @@ local data = { maps = loadfile("data/generated/maps.lua")(), eventFlags = loadfile("src/save_convert/data/event_flags.lua")(), } +-- home/overworld.asm:2016 (#1691) +local cacheHasMapWindow = (data.maps.REDS_HOUSE_2F or {}).sram ~= nil +if not cacheHasMapWindow then + print("save_file_io export cases skipped (this ROM cache predates the saved-map " + .. "bytes; re-import the ROM to run them)") +end +local stampMapWindow = loadfile("tests/fixture_data/map_window.lua")() +for mapId in pairs(data.maps) do stampMapWindow(data, mapId) end -- independent checksum re-derivation (complement of the additive byte sum) so -- the export sanity check does not trust the encoder that wrote it @@ -393,6 +401,7 @@ do local function assertLoadable(label, mapId, x, y) local def = data.maps[mapId] if not def then return end -- this data set lacks the map; skip silently + if not cacheHasMapWindow then return end local out = exportedThrough(mapId, x, y) check(out ~= nil and #out == GenSave.SAVE_SIZE, label .. ": exports a 32768-byte image through the launcher path") diff --git a/tests/engine/sync_engine_test.lua b/tests/engine/sync_engine_test.lua index 3ef6dab9..09fd6524 100644 --- a/tests/engine/sync_engine_test.lua +++ b/tests/engine/sync_engine_test.lua @@ -493,9 +493,49 @@ do }, {}, state) eng:unlink() pump(eng, 3) - T.eq(eng.phase, "error", "a failed revocation is surfaced") - T.eq(eng:linked(), true, - "and the device stays linked rather than lying about it") + T.eq(eng:linked(), false, + "a server that refuses the revocation still unlinks this device") + T.neq(eng.phase, "error", "so the player is not left stuck on a red status") +end + +do + local state = linkedState() + state.deviceId = "0a1b2c3d" + local transport = { + begin = function() return 1 end, + poll = function() + return { status = "error", + err = "fetch failed for http://sync.test/sync/unlink: " .. + "curl: (28) timed out" } + end, + release = function() end, + } + local eng = SyncEngine.new({ + baseUrl = "http://sync.test", transport = transport, state = state, + saves = fakeSaves({}), persist = false, + now = function() return 1700001000 end, + }) + eng:unlink() + pump(eng, 3) + T.eq(eng:linked(), false, + "an unreachable server does not trap the device as linked") + T.eq(eng.status, "Not set up", "and the modal says the device is unlinked") +end + +do + local state = linkedState() + state.deviceId = "0a1b2c3d" + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + ["POST /sync/unlink"] = { code = 200, body = '{"ok":true,"devices":1}' }, + }, {}, state) + eng.pending = { handle = 99 } + T.eq(eng:busy(), true, "a request is in flight") + eng:unlink() + T.eq(transport.sent[1].url, "http://sync.test/sync/unlink", + "unlink cancels it instead of refusing as busy") + pump(eng, 3) + T.eq(eng:linked(), false, "and the device is unlinked") end do @@ -710,4 +750,102 @@ do "an apply that never asked imports nothing: silence is not consent") end +do + T.eq(SyncEngine.unixSeconds(0), nil, "a zero stamp is no stamp") + T.eq(SyncEngine.unixSeconds(-5), nil, "and neither is a negative one") + T.eq(SyncEngine.unixSeconds(1700000000), 1700000000, "a real stamp survives") + T.eq(SyncEngine.displayMeta({ savedAt = 0, sessionStart = 0 }).savedAt, nil, + "the prompt is never handed a stamp it would date 1969") + T.eq(SyncEngine.overlaps({ sessionStart = 0, savedAt = 0 }, + { sessionStart = 0, savedAt = 0 }), false, + "two server-zeroed windows do not overlap in 1970") + T.eq(SyncEngine.samePlaytime({ playTime = 0 }, { playTime = 0 }), false, + "a server-zeroed playtime is unknown, not zero minutes") +end + +do + local SaveData = require("src.core.SaveData") + local raw = "return { player = { name = 'GOLD' }, savedAt = 1700000500 }" + local slots = { { id = "slot1", exists = true } } + local realList, realRead, realDecode, realSummary, realOptions = + SaveData.listSlots, SaveData.readSlotSource, SaveData.decode, + SaveData.slotSummary, SaveData.loadOptions + SaveData.listSlots = function(version) + return version == "gold" and slots or {} + end + SaveData.readSlotSource = function() return raw end + SaveData.decode = function() return { player = { name = "GOLD" }, + savedAt = 1700000500 } end + SaveData.slotSummary = function() + return "GOLD", { badges = 8, timeText = "3:46", dexCount = 9 } + end + SaveData.loadOptions = function() + return { playthroughIds = { gold = { slot1 = "xyz" } } } + end + local list = SyncEngine.defaultSaves().list() + SaveData.listSlots, SaveData.readSlotSource, SaveData.decode, + SaveData.slotSummary, SaveData.loadOptions = + realList, realRead, realDecode, realSummary, realOptions + T.eq(#list, 1, "the Gold slot is listed") + T.eq(list[1].meta.savedAt, 1700000500, + "with the stamp gen2/Save.lua actually writes") +end + +do + local state = linkedState() + local entry = saveEntry("gold", "xyz", 1700000500, nil) + entry.meta.summary = { name = "GOLD", badges = 8, timeText = "3:46", + dexCount = 9 } + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"gold/xyz":{"rev":4,"meta":{"savedAt":0,' .. + '"summary":{"name":"GOLD","badges":8,"timeText":"3:46",' .. + '"dexCount":9}}}}}' }, + ["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":5}' }, + }, { entry }, state) + + eng:syncNow() + pump(eng) + T.eq(#eng.conflicts, 0, + "the same playthrough with an epoch-dated server row is not a fork") + T.neq(eng.phase, "conflict", "so no duplicate-save prompt is raised") + T.eq(SyncState.stamp(eng.state, "gold/xyz"), 1700000500, + "and the upload leaves a stamp behind") + local put + for _, req in ipairs(transport.sent) do + if req.method == "PUT" then put = req end + end + T.check(put ~= nil, "this device's copy is pushed instead") +end + +do + local state = linkedState() + SyncState.setRev(state, "gold/xyz", 4, 1700000500) + local entry = saveEntry("gold", "xyz", 1700000500, nil) + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"gold/xyz":{"rev":4,"meta":{"savedAt":0}}}}' }, + }, { entry }, state) + eng:syncNow() + pump(eng) + T.eq(#transport.sent, 1, + "a Gold save at the rev it was uploaded at syncs no further") + T.eq(eng.phase, "idle", "and the second boot is idle") +end + +do + local state = linkedState() + SyncState.setRev(state, "gold/xyz", 4, nil) + local entry = saveEntry("gold", "xyz", nil, nil) + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"gold/xyz":{"rev":4,"meta":{"savedAt":0}}}}' }, + }, { entry }, state) + eng:syncNow() + pump(eng) + T.eq(#transport.sent, 1, + "an older Gold save with no stamp on either side is not dirty every boot") + T.eq(eng.phase, "idle", "so the launcher settles") +end + T.finish("sync_engine") diff --git a/tests/engine/timing_parity.lua b/tests/engine/timing_parity.lua index cddee324..85bb35fb 100644 --- a/tests/engine/timing_parity.lua +++ b/tests/engine/timing_parity.lua @@ -317,6 +317,48 @@ do "the anim row pays Delay3 before it plays") T.eq(#b.queue, 1, "and is put back on the queue to run after the hold") T.check(b.queue[1].animDelayed, "flagged so the hold is paid only once") + b.waitFrames = nil + b:updateQueue() + T.check(b.waitFrames ~= Timing.MOVE_ANIM_OFF, + "with animations on the disabled arm's 30 is never paid") +end + +-- animations.asm:431-437 +T.eq(Timing.MOVE_ANIM_OFF, 30, "the animations-off arm holds 30 frames") +do + local b = newBattle() + b.game.save.options.animations = false + b.queue, b.nextInsert, b.current = {}, 0, nil + b.waitFrames, b.waitingSound = nil, nil + b.draining, b.animPlaying, b.waitingUI = nil, nil, nil + b.queue[1] = { anim = "FIX_TACKLE", attackerIsPlayer = true, + hit = { animType = 4, blink = b.enemy } } + b:updateQueue() + T.eq(b.waitFrames, Timing.MOVE_ANIM_PRE, "Delay3 is paid whatever the option") + b.waitFrames = nil + b:updateQueue() + T.eq(b.waitFrames, Timing.MOVE_ANIM_OFF, "then the 30 stands in for the anim") + T.eq(#b.queue, 1, "and the row goes back for the applying-attack layer") + T.check(b.queue[1].animOffDelayed, "flagged so the hold is paid only once") + b.waitFrames = nil + b:updateQueue() + T.eq(b.waitFrames, Timing.BLINK_MON, + "the enemy blink still runs with animations off (#1384)") + T.check(b.fx and b.fx.blink ~= nil, "and it targets the enemy pic") +end + +-- animations.asm:415-419 +do + local b = newBattle() + b.game.save.options.animations = false + b.queue, b.nextInsert, b.current = {}, 0, nil + b.waitFrames, b.waitingSound = nil, nil + b.draining, b.animPlaying, b.waitingUI = nil, nil, nil + b.queue[1] = { anim = "POOF_ANIM", attackerIsPlayer = true } + b:updateQueue() + b.waitFrames = nil + b:updateQueue() + T.check(b.waitFrames ~= Timing.MOVE_ANIM_OFF, "a ball anim skips the 30") end -- StartBattle's 40-frame hold is unconditional -- the `call nz` gates only diff --git a/tests/engine/touch_skin_test.lua b/tests/engine/touch_skin_test.lua index 54c698c8..f7a5f8cd 100644 --- a/tests/engine/touch_skin_test.lua +++ b/tests/engine/touch_skin_test.lua @@ -412,6 +412,26 @@ local _, _, sHalfW, sHalfH = TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], 1920, 1080) check(math.abs(sHalfW - sHalfH) < 2, "A stays round on a 16:9 window") +TouchSkin.setSurface(0, 0, LW, LH) +local aCx, aCy, aHalfW, aHalfH = + TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], LW, LH) +local aW, aH = aHalfW * 2, aHalfH * 2 +local sx, sy = TouchSkin.imageFit(210, 210, aW, aH) +eq(sx, aW / 210, "desc art scales to the dest width") +eq(sy, aH / 210, "desc art scales to the dest height") +check(210 * sx <= aW + 1e-6 and 210 * sy <= aH + 1e-6, + "the whole bitmap lands inside its dest box") +check(sx >= math.min(aW / 210, aH / 210) - 1e-6, + "and it is not shrunk below a contain fit") +local lx, ly = TouchSkin.imageFit(327, 193, 218, 129) +check(327 * lx <= 218 + 1e-6 and 193 * ly <= 129 + 1e-6, + "a wide shoulder PNG is not cropped by its dest box") +check(TouchSkin.imageFit(0, 0, aW, aH) == nil, "a zero-sized image has no fit") +local wx, wy = TouchSkin.imageFit(100, 50, 200, 200) +eq(wx, 2, "independent X scale, not one uniform cover scale") +eq(wy, 4, "independent Y scale") +check(aCx > 0 and aCy > 0, "landscape A sits inside the page box") + local nativeOrient = TouchSkin.parseNative(TouchSkin.serialize(orient)) check(nativeOrient and nativeOrient.pages[2].aspectFromCfg, "native export keeps the aspect lock") diff --git a/tests/engine/town_map_cursor_bug1868.lua b/tests/engine/town_map_cursor_bug1868.lua new file mode 100644 index 00000000..32842a39 --- /dev/null +++ b/tests/engine/town_map_cursor_bug1868.lua @@ -0,0 +1,90 @@ +-- engine/items/town_map.asm:74 (#1868) + +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 TownMap = require("src.ui.TownMap") + +local LOCATIONS = { + PALLET_TOWN = { x = 4, y = 12, name = "PALLET TOWN" }, + ROUTE_1 = { x = 4, y = 9, name = "ROUTE 1" }, + VIRIDIAN_CITY = { x = 4, y = 11, name = "VIRIDIAN CITY" }, + SEA_COTTAGE = { x = 12, y = 0, name = "SEA COTTAGE" }, +} +local ORDER = { "PALLET_TOWN", "ROUTE_1", "VIRIDIAN_CITY" } + +local function newGame() + local pressed = {} + local game = { + data = { + field = { + townMap = { locations = LOCATIONS, cursorOrder = ORDER }, + playerSprites = { walk = "SPRITE_RED" }, + }, + sprites = {}, + maps = {}, + }, + save = {}, + overworld = { map = { id = "PALLET_TOWN" } }, + input = { wasPressed = function(_, name) + local p = pressed[name] + pressed[name] = nil + return p + end }, + stack = { pop = function() end }, + } + return game, function(name) pressed[name] = true end +end + +local game, tap = newGame() +local tm = TownMap.new(game, {}) + +eq(tm.mode, "grid", "located entries put the screen in grid mode") +eq(tm.locs[tm.sel].name, "PALLET TOWN", + "the viewer opens on the player's town") + +local reachable = {} +for _, loc in ipairs(tm.locs) do reachable[loc.name] = true end +check(not reachable["SEA COTTAGE"], + "a location outside cursorOrder is not a cursor stop") + +tap("left") tm:update(0) +eq(tm.locs[tm.sel].name, "PALLET TOWN", "LEFT does not move the cursor") +tap("right") tm:update(0) +eq(tm.locs[tm.sel].name, "PALLET TOWN", "RIGHT does not move the cursor") + +tap("up") tm:update(0) +eq(tm.locs[tm.sel].name, "ROUTE 1", + "UP steps forward through TownMapOrder, not to the nearest square") +tap("up") tm:update(0) +eq(tm.locs[tm.sel].name, "VIRIDIAN CITY", "UP again reaches VIRIDIAN CITY") +tap("down") tm:update(0) +eq(tm.locs[tm.sel].name, "ROUTE 1", "DOWN steps back") +tap("down") tm:update(0) +eq(tm.locs[tm.sel].name, "PALLET TOWN", "DOWN again returns to PALLET TOWN") +tap("down") tm:update(0) +eq(tm.locs[tm.sel].name, "VIRIDIAN CITY", + "DOWN off the top wraps to the last TownMapOrder entry") + +local game2, tap2 = newGame() +game2.data.field.townMap.cursorOrder = nil +local tm2 = TownMap.new(game2, {}) +eq(tm2.mode, "grid", "the screen still works without cursorOrder") +local before = tm2.locs[tm2.sel] +tap2("left") tm2:update(0) +tap2("right") tm2:update(0) +check(tm2.locs[tm2.sel] == before, + "LEFT/RIGHT stay inert on the cursorOrder-less fallback") +tap2("up") tm2:update(0) +check(tm2.locs[tm2.sel] ~= before, "UP still moves on the fallback") + +local game3 = newGame() +game3.overworld.map.id = "SEA_COTTAGE" +local tm3 = TownMap.new(game3, {}) +eq(tm3.locs[tm3.sel].name, "SEA COTTAGE", + "the viewer still opens on a map that is not a TownMapOrder stop") + +T.finish("town map cursor bug 1868") diff --git a/tests/engine/vending_machine_bug1876.lua b/tests/engine/vending_machine_bug1876.lua new file mode 100644 index 00000000..2536b152 --- /dev/null +++ b/tests/engine/vending_machine_bug1876.lua @@ -0,0 +1,214 @@ +-- engine/events/vending_machine.asm (#1876) + +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 realFont = package.loaded["src.render.Font"] +local realTextBox = package.loaded["src.render.TextBox"] +local calls = {} +package.loaded["src.render.Font"] = { + BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 }, + draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end, + drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end, + drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end, + width = function(text) return #tostring(text) * 8 end, + split = function(text) + local out = {} + for i = 1, #tostring(text) do out[i] = i end + return out + end, +} +package.loaded["src.render.TextBox"] = { + new = function(_, text, onDone, opts) + return { text = text, onDone = onDone, opts = opts, isTextBox = true } + end, +} +local sounds = {} +package.loaded["src.core.Sound"] = { + play = function(_, name) sounds[#sounds + 1] = name end, +} +local bagFull = false +local bagged = {} +package.loaded["src.inventory.Bag"] = { + add = function(_, id) + if bagFull then return false end + bagged[#bagged + 1] = id + return true + end, +} +package.loaded["src.ui.Menu"] = nil +package.loaded["src.ui.Theme"] = nil + +local M = assert(loadfile("data/scripts/story4.lua"))() +local machine = M.CELADON_MART_ROOF.talk.TEXT_CELADONMARTROOF_VENDING_MACHINE1 + +local function found(kind, pred) + for _, c in ipairs(calls) do + if c[1] == kind and pred(c) then return c end + end + return nil +end + +local pressed, done +local function mkGame(cash) + sounds, bagged, done = {}, {}, false + return { + data = { + text = { + _VendingMachineText1 = "A vending machine!\nHere's the menu!", + _VendingMachineText4 = "Oops, not enough\nmoney!", + _VendingMachineText5 = "{RAM:wStringBuffer}\npopped out!", + _VendingMachineText6 = "There's no more\nroom for stuff!", + _VendingMachineText7 = "Not thirsty!", + }, + items = { + FRESH_WATER = { name = "FRESH WATER" }, + SODA_POP = { name = "SODA POP" }, + LEMONADE = { name = "LEMONADE" }, + }, + }, + save = { money = cash, inventory = {} }, + input = { + wasPressed = function(_, b) return pressed == b end, + isDown = function() return false end, + }, + stack = { + states = {}, + push = function(self, s) self.states[#self.states + 1] = s end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + }, + } +end + +local function open(cash) + local game = mkGame(cash) + machine(game, nil, nil, function() done = true end) + local intro = game.stack:top() + return game, intro +end + +do + local game, intro = open(3000) + check(intro.isTextBox, "talking to the sign prints VendingMachineText1") + check(tostring(intro.text):find("vending machine", 1, true) ~= nil, + "with the machine's own greeting") + check(intro.opts.money ~= nil, "DisplayTextBoxID MONEY_BOX rides along") + check(intro.opts.stay ~= nil and intro.opts.stay.prompt, + "the greeting prompts, then stays up under the list") + + intro.opts.stay.onShown() + local menu = game.stack:top() + check(menu ~= intro and menu.items ~= nil, "the drink list opens over it") + eq(menu.tx, 0, "TextBoxBorder at hlcoord 0,3") + eq(menu.ty, 3, "row 3") + eq(menu.tw, 14, "14 tiles wide (b=8, c=12)") + eq(menu.th, 10, "10 tiles tall") + eq(#menu.items, 4, "wMaxMenuItem 3: three drinks and CANCEL") + eq(menu.items[4].label, "CANCEL", "CANCEL is the last row") + eq(menu.noWrap, true, + "wMenuWrappingEnabled is never set (home/window.asm:56-83)") + + calls = {} + menu:draw() + for i, name in ipairs({ "FRESH WATER", "SODA POP", "LEMONADE", "CANCEL" }) do + local y = (5 + (i - 1) * 2) * 8 + check(found("draw", function(c) + return c[2] == name and c[3] == 16 and c[4] == y + end) ~= nil, name .. " sits at (16, " .. y .. ")") + end + for i, price in ipairs({ "¥200", "¥300", "¥350" }) do + local y = (6 + (i - 1) * 2) * 8 + check(found("draw", function(c) + return c[2] == price and c[3] == 72 and c[4] == y + end) ~= nil, price .. " sits a row under its drink at (72, " .. y .. ")") + end +end + +do + local game, intro = open(3000) + intro.opts.stay.onShown() + local menu = game.stack:top() + menu.items[1].onSelect() + local waiter = game.stack:top() + check(waiter ~= menu and waiter.update ~= nil, + "the delivery rumble runs before the text") + eq(game.save.money, 3000, "money is still untouched during the rumble") + for _ = 1, 120 do + if game.stack:top() ~= waiter then break end + waiter:update(1 / 60) + end + eq(#sounds, 60, "SFX_PUSH_BOULDER restarts 60 times") + eq(sounds[1], "Push_Boulder", "and it is the boulder push, not Cut") + check(bagged[1] == "FRESH_WATER", "the drink lands in the bag") + eq(game.save.money, 2800, "SubBCDPredef takes the drink's price") + local result = game.stack:top() + check(result.isTextBox and tostring(result.text):find("popped out") ~= nil, + "then VendingMachineText5 names the drink") + check(tostring(result.text):find("FRESH WATER", 1, true) ~= nil, + "out of wStringBuffer") + check(result.opts ~= nil and result.opts.money ~= nil, + "the money box is redrawn with the new balance") + eq(#game.stack.states, 1, + "the drink list and the greeting are gone by then") + result.onDone() + check(done, "dismissing it hands control back to the script") +end + +do + local game, intro = open(3000) + intro.opts.stay.onShown() + local menu = game.stack:top() + menu.index = 4 + pressed = "a" + menu:update(1 / 60) + pressed = nil + local result = game.stack:top() + check(result.isTextBox and tostring(result.text):find("thirsty") ~= nil, + "CANCEL prints VendingMachineText7") + eq(#game.stack.states, 1, "with the list and the greeting closed") + eq(#bagged, 0, "and nothing bought") + + game, intro = open(3000) + intro.opts.stay.onShown() + menu = game.stack:top() + pressed = "b" + menu:update(1 / 60) + pressed = nil + check(tostring(game.stack:top().text):find("thirsty") ~= nil, + "B does the same (.notThirsty)") +end + +do + local game, intro = open(100) + intro.opts.stay.onShown() + game.stack:top().items[1].onSelect() + local result = game.stack:top() + check(tostring(result.text):find("enough", 1, true) ~= nil, + "no money prints VendingMachineText4") + eq(game.save.money, 100, "and spends nothing") + eq(#bagged, 0, "and hands over no drink") + + bagFull = true + game, intro = open(3000) + intro.opts.stay.onShown() + game.stack:top().items[1].onSelect() + result = game.stack:top() + check(tostring(result.text):find("room for stuff", 1, true) ~= nil, + "a full bag prints VendingMachineText6") + eq(game.save.money, 3000, "with the money untouched (.BagFull)") + bagFull = false +end + +package.loaded["src.render.Font"] = realFont +package.loaded["src.render.TextBox"] = realTextBox +package.loaded["src.core.Sound"] = nil +package.loaded["src.inventory.Bag"] = nil +package.loaded["src.ui.Menu"] = nil +package.loaded["src.ui.Theme"] = nil +require("src.ui.Screens").invalidate() + +T.finish() diff --git a/tests/fixture_data/map_window.lua b/tests/fixture_data/map_window.lua new file mode 100644 index 00000000..59d21281 --- /dev/null +++ b/tests/fixture_data/map_window.lua @@ -0,0 +1,30 @@ +-- home/overworld.asm:2016 +return function(data, mapId) + local map = data and data.maps and data.maps[mapId] + if not map then return data end + if not map.sram then + map.sram = { + header = { 0, map.height or 4, map.width or 5, + 0x00, 0xC0, 0x00, 0xC0, 0x00, 0xC0, 0x00 }, + connections = {}, + objects = { 0x0E, 0, 0, 0 }, + } + end + data.tilesets = data.tilesets or {} + if map.tileset then + local ts = data.tilesets[map.tileset] + if not ts then + ts = {} + data.tilesets[map.tileset] = ts + end + ts.header = ts.header or { 0x0C, 0x11, 0x40, 0x22, 0x40, + 0x33, 0x40, 0x44, 0x55, 0x66, 0x77, 0x02 } + end + data.audio = data.audio or {} + data.audio.mapSongs = data.audio.mapSongs or {} + data.audio.songs = data.audio.songs or {} + data.audio.mapSongs[mapId] = data.audio.mapSongs[mapId] or "Music_Fixture" + data.audio.songs.Music_Fixture = data.audio.songs.Music_Fixture + or { address = 0x4000 + 3 * 0xBD, bank = 2 } + return data +end diff --git a/tests/gen2_battle_options_test.lua b/tests/gen2_battle_options_test.lua index 51c59947..e66ce6c5 100644 --- a/tests/gen2_battle_options_test.lua +++ b/tests/gen2_battle_options_test.lua @@ -65,6 +65,11 @@ check("the default surround is the cart's paper white", check("and a fresh options table carries it", Save.defaultOptions().battleBg, "white") +check("the default battle size is the whole-pixel blit", + Save.DEFAULT_OPTIONS.battleFit, "fixed") +check("and a fresh options table carries that too", + Save.defaultOptions().battleFit, "fixed") + -- --------------------------------------------------------------- the row local function rowNamed(label) @@ -82,11 +87,26 @@ if bgRow then check("stored lowercase, shown WHITE", bgRow.display.white, "WHITE") check("and BLACK", bgRow.display.black, "BLACK") -- Appended at the tail, so nothing the other suites index by position moves. - check("it follows MAX FPS", OptionsMenu.ROWS[bgIndex - 1].label, "MAX FPS") + check("it follows BATTLE SIZE", + OptionsMenu.ROWS[bgIndex - 1].label, "BATTLE SIZE") check("and CANCEL still ends the list", OptionsMenu.ROWS[bgIndex + 1].cancel, true) check("CANCEL is last", bgIndex + 1, #OptionsMenu.ROWS) end + +local sizeIndex, sizeRow = rowNamed("BATTLE SIZE") +check("OPTION has a BATTLE SIZE row", sizeRow ~= nil, true) +if sizeRow then + check("it edits battleFit", sizeRow.key, "battleFit") + check("it is a port row, not one of the cart's seven", sizeRow.port, true) + check("FIXED and FILL are the whole ladder", #sizeRow.values, 2) + check("stored lowercase, shown FIXED", sizeRow.display.fixed, "FIXED") + check("and FILL, padded to the value column", + sizeRow.display.fill, "FILL ") + check("it follows MAX FPS", + OptionsMenu.ROWS[sizeIndex - 1].label, "MAX FPS") + check("BATTLE BG comes straight after it", sizeIndex + 1, bgIndex) +end check("the cart's rows are unmoved", OptionsMenu.ROWS[7].key, "frame") check("the rebind screen is unmoved", OptionsMenu.ROWS[8].id, "controls") check("the port's audio group is unmoved", OptionsMenu.ROWS[9].key, "musicVol") @@ -113,6 +133,25 @@ if built then check("and back", menu.options.battleBg, "white") end +local builtFit +for _, row in ipairs(menu.rows) do + if row.key == "battleFit" then builtFit = row end +end +check("buildRows keeps BATTLE SIZE", builtFit ~= nil, true) +if builtFit then + check("and gives it the mod-facing id battleFit", builtFit.id, "battleFit") + check("the menu starts on FIXED", menu.options.battleFit, "fixed") + menu:cycle(builtFit, 1) + check("right stores fill, not the display string", + menu.options.battleFit, "fill") + menu:cycle(builtFit, 1) + check("right again wraps to fixed", menu.options.battleFit, "fixed") + menu:cycle(builtFit, -1) + check("left walks the ladder the other way", menu.options.battleFit, "fill") + menu:cycle(builtFit, -1) + check("and back", menu.options.battleFit, "fixed") +end + -- ------------------------------------------------------ the launcher gear local model = LauncherSettings.open(nil, "gold") @@ -134,6 +173,25 @@ if gearRow then check("stepping again returns to white", model.opts.gold.battleBg, "white") end +local gearSize +for _, section in ipairs(model.sections) do + for _, row in ipairs(section.rows) do + if row.label == "BATTLE SIZE" then gearSize = row end + end +end +check("the gold gear offers BATTLE SIZE", gearSize ~= nil, true) +if gearSize then + model.opts.gold.battleFit = nil + check("an options.lua with no battleFit reads FIXED", gearSize.value(), + "FIXED") + gearSize.step(1) + check("stepping writes the gold block, not the flat Gen 1 one", + model.opts.gold.battleFit, "fill") + check("and the row reads FILL", gearSize.value(), "FILL") + gearSize.step(1) + check("stepping again returns to fixed", model.opts.gold.battleFit, "fixed") +end + -- ---------------------------------------------------------- the battle end local bgMode = BattleState.bgMode @@ -199,8 +257,8 @@ check("the overworld alone paints nothing", check("a party menu over the battle still finds the battle's mode", #paint({ overworld, blackBattle, plainMenu }, 1024, 768), 4) -local function panelSafe(states, w, h, label) - local scale = Chrome.fitScale(w, h) +local function panelSafe(states, w, h, label, scale) + scale = scale or Chrome.fitScale(w, h) local ox, oy = Chrome.fitOrigin(w, h, scale) local pw, ph = 160 * scale, 144 * scale local rects = paint(states, w, h) @@ -218,7 +276,8 @@ local function panelSafe(states, w, h, label) -- a black frame eating the HUD or the message box, and any shortfall is a -- strip of white left behind on one edge. check(label .. ": no band touches the battle panel", overlap, 0) - check(label .. ": the void is covered edge to edge", covered, w * h - pw * ph) + check(label .. ": the void is covered edge to edge", + math.abs(covered - (w * h - pw * ph)) < 1, true) check(label .. ": every band is black", offColour, 0) end @@ -240,6 +299,44 @@ ScreenPosition.setMode("center") -- not wrap around to the far edge on the negative origin. check("nothing to paint under 160x144", #paint({ blackBattle }, 100, 90), 0) +local wantsFill = BattleState.wantsFillScale +check("the Gold battle screen answers wantsFillScale", type(wantsFill), + "function") +if type(wantsFill) == "function" then + check("FILL asks for the fill scale", + wantsFill({ game = { options = { battleFit = "fill" } } }), true) + check("FIXED does not", + wantsFill({ game = { options = { battleFit = "fixed" } } }), false) + check("nor does an options table that predates the key", + wantsFill({ game = { options = {} } }), false) + check("nor a screen with no game at all", wantsFill({}), false) +end + +check("FIXED is the integer fit", BattleState.panelScale(1280, 840, false), + Chrome.fitScale(1280, 840)) +check("FILL is min(w/160, h/144)", BattleState.fillScale(1280, 840), 840 / 144) +check("and FILL is not the integer fit here", + BattleState.fillScale(1280, 840) ~= Chrome.fitScale(1280, 840), true) +check("FILL never goes below one whole pixel", + BattleState.fillScale(100, 90), 1) + +local fillBattle = { + bgMode = function() return "black" end, + battlePanelScale = function(_, w, h) return BattleState.fillScale(w, h) end, +} +for _, size in ipairs({ { 1280, 840 }, { 1366, 768 }, { 1024, 768 } }) do + panelSafe({ fillBattle }, size[1], size[2], + ("fill %dx%d"):format(size[1], size[2]), + BattleState.fillScale(size[1], size[2])) + panelSafe({ fillBattle, plainMenu }, size[1], size[2], + ("fill under a menu %dx%d"):format(size[1], size[2]), + BattleState.fillScale(size[1], size[2])) +end + +local widescreenMenu = { drawsWidescreen = function() return true end } +panelSafe({ fillBattle, widescreenMenu }, 1280, 840, + "a widescreen menu over a fill battle", Chrome.fitScale(1280, 840)) + -- ----------------------------------------------------- the call site -- -- Constructing a Game2 needs love, so where the paint is called from is read @@ -254,6 +351,9 @@ if source then check("drawScene repaints straight after the widescreen layer", source:find("wide:drawWidescreen%(w, h%)%s*self:paintBattleSurround%(w, h%)") ~= nil, true) + check("the overlay blit shares the battle's panel scale", + source:find("local scale, ox, oy = panelBlit%(self%.stack, w, h%)") + ~= nil, true) end print(("gen2 battle options: %d checks, %d failures"):format(checks, failures)) diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua index f82cf496..11d4fa6f 100644 --- a/tests/gen2_menus_test.lua +++ b/tests/gen2_menus_test.lua @@ -358,9 +358,8 @@ 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 --- the dual-shader feature), video mode, screen position, the mobile-gated --- touch three (buildRows), MAX FPS, BATTLE BG and CANCEL. -check("twenty-eight rows", #OptionsMenu.ROWS, 28) +-- the dual-shader feature), video mode and screen position. +check("twenty-nine rows", #OptionsMenu.ROWS, 29) 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") @@ -1499,10 +1498,10 @@ local expnGear = newGear({ landmark = "LANDMARK_PALLET_TOWN", clock = { hour = 14, minute = 0, weekday = 1 } }) check("and airs with it", expnGear:stations()[7].station, "POKE_FLUTE_RADIO") --- .EvolutionRadio wants STATUSFLAGS_ROCKET_SIGNAL_F and one of three --- landmarks around the Lake of Rage. +-- .EvolutionRadio wants STATUSFLAGS_ROCKET_SIGNAL_F. +-- pokegold constants/engine_flags.asm local rageGear = newGear({ landmark = "LANDMARK_LAKE_OF_RAGE", - save = { flags = { ROCKET_SIGNAL = true } }, + save = { engineFlags = { [14] = true } }, clock = { hour = 14, minute = 0, weekday = 1 } }) check("20.5 airs by the Lake of Rage", rageGear:stations()[8].station, "EVOLUTION_RADIO") diff --git a/tests/gen2_ow_bounce_test.lua b/tests/gen2_ow_bounce_test.lua index 211e56fc..edf8d25a 100644 --- a/tests/gen2_ow_bounce_test.lua +++ b/tests/gen2_ow_bounce_test.lua @@ -3,14 +3,12 @@ -- luajit tests/gen2_ow_bounce_test.lua (ROM-free; the cache section SKIPs -- without a gold cache) -- --- data/sprites/map_objects.asm:181-187 gives the row SPRITEMOVEFN_BOUNCE and --- OBJECT_ACTION_BOUNCE. SetFacingBounce increments OBJECT_STEP_FRAME once a --- frame, masks it to four bits and reads bit 3: set means FACING_STEP_UP_0, --- clear falls into SetFacingFreezeBounce and FACING_STEP_DOWN_0 +-- data/sprites/map_objects.asm:181-187 gives the row SPRITEMOVEFN_BOUNCE -- (engine/overworld/map_object_action.asm:184-202). Those are tiles $00..$03 -- and $04..$07 of the mon's menu icon (data/sprites/facings.asm:43-72), i.e. -- the two 16x16 halves of the 16x32 sheet extractIcons already writes. -- +-- events.asm:175-189 -- Both halves of #1748 are pinned here: the animation itself, and the sheet -- being two frames deep so the animation has a second pose to reach. package.path = "./?.lua;./?/init.lua;" .. package.path @@ -69,32 +67,27 @@ do "MovementFunction_Bouncing parks it on STEP_TYPE_STANDING: no walking") end --- ---- eight frames down, eight frames up ------------------------------------ --- `inc a / and %00001111 / ld [hl], a / and %00001000` is a sixteen-frame --- cycle with an eight-frame dwell on each pose, and the counter is stepped --- BEFORE the bit is read -- so the spawn frame is pose 0 and so are the seven --- after it. do local mon = build(SPRITEMOVEDATA_POKEMON) eq(mon.bounceStep, 0, "OBJECT_STEP_FRAME starts at zero") local seen, wrong = { mon:bounceFrame() }, nil - for i = 1, 31 do + for i = 1, 63 do mon:update() seen[i + 1] = mon:bounceFrame() end - for i = 0, 31 do - local want = math.floor(i / 8) % 2 + for i = 0, 63 do + local want = math.floor(i / 16) % 2 if seen[i + 1] ~= want and not wrong then wrong = ("frame %d drew pose %s, wanted %d") :format(i, tostring(seen[i + 1]), want) end end - check(wrong == nil, "two full cycles run 0,0..0,1,1..1 eight frames apiece" + check(wrong == nil, "two full cycles run sixteen fixed steps a pose" .. (wrong and (" -- " .. wrong) or "")) eq(seen[1], 0, "the spawn frame is FacingStepDown0, the icon's first half") - eq(seen[9], 1, "the ninth is FacingStepUp0, its second half") - eq(seen[17], 0, "and the seventeenth is back to the first") - eq(mon.bounceStep, 31 % 16, "the counter wrapped at sixteen, not at eight") + eq(seen[17], 1, "the seventeenth is FacingStepUp0, its second half") + eq(seen[33], 0, "and the thirty-third is back to the first") + eq(mon.bounceStep, 63 % 32, "the counter wrapped at thirty-two") end -- ---- the frozen column ----------------------------------------------------- @@ -103,9 +96,9 @@ end -- conversation pins its FIRST pose and resumes on the phase it left. do local mon = build(SPRITEMOVEDATA_POKEMON) - for _ = 1, 10 do mon:update() end - eq(mon:bounceFrame(), 1, "ten frames in, the mon is on the up pose") - eq(mon.bounceStep, 10, "with OBJECT_STEP_FRAME at 10") + for _ = 1, 20 do mon:update() end + eq(mon:bounceFrame(), 1, "twenty frames in, the mon is on the up pose") + eq(mon.bounceStep, 20, "with the step counter at 20") mon.frozen = true local pinned = true @@ -114,12 +107,12 @@ do if mon:bounceFrame() ~= 0 then pinned = false end end check(pinned, "frozen, it holds FacingStepDown0 for the whole conversation") - eq(mon.bounceStep, 10, + eq(mon.bounceStep, 20, "and SetFacingFreezeBounce leaves the step counter where it was") mon.frozen = false mon:update() - eq(mon.bounceStep, 11, "released, the counter carries on from 10") + eq(mon.bounceStep, 21, "released, the counter carries on from 20") eq(mon:bounceFrame(), 1, "so the bounce resumes on the phase it froze at") end @@ -166,9 +159,9 @@ do eq(seen.mon.override, 0, "the mon's first draw overrides to frame 0") check(seen.doll.override == nil, "and the doll's overrides to nothing") - for _ = 1, 8 do mon:update() end + for _ = 1, 16 do mon:update() end mon:draw(0, 0, 1) - eq(seen.mon.override, 1, "eight frames later it overrides to frame 1") + eq(seen.mon.override, 1, "sixteen frames later it overrides to frame 1") end -- ---- a one-frame sheet has nowhere to bounce to ---------------------------- diff --git a/tests/gen2_screen_layout_test.lua b/tests/gen2_screen_layout_test.lua index c9f78ebc..75a559ee 100644 --- a/tests/gen2_screen_layout_test.lua +++ b/tests/gen2_screen_layout_test.lua @@ -275,7 +275,8 @@ for _, path in ipairs(screens) do src:find("math%.min%(win[WH] */ *[%dSCREN_WH]+", 1, false) == nil, true) if src:find("function [%w_]+:drawWidescreen") then check(path .. " blits through Chrome.fitScale", - src:find("fitScale", 1, true) ~= nil, true) + src:find("fitScale", 1, true) ~= nil + or src:find("Chrome.withPanel", 1, true) ~= nil, true) end -- An opaque screen is one nothing under it is drawn for: its cart routine -- ran ClearBGPalettes / ClearTilemap, so the map is gone and the screen owns diff --git a/tests/parity_box_mon_stats.lua b/tests/parity_box_mon_stats.lua index 6bed01e5..e8f64973 100644 --- a/tests/parity_box_mon_stats.lua +++ b/tests/parity_box_mon_stats.lua @@ -24,6 +24,13 @@ local HudTiles = require("src.render.HudTiles") local DVS = { hp = 15, attack = 13, defense = 11, speed = 7, special = 5 } local STATEXP = { hp = 1200, attack = 900, defense = 400, speed = 0, special = 250 } +-- home/overworld.asm:2016 (#1691) +local stampMapWindow = loadfile("tests/fixture_data/map_window.lua")() +local function stampAll(d) + for mapId in pairs((d or {}).maps or {}) do stampMapWindow(d, mapId) end +end +stampAll(Data) + local function boxShapedMon(species, level, hp) return { species = species, level = level, exp = 1000, diff --git a/tests/parity_save_fly_towns.lua b/tests/parity_save_fly_towns.lua index 46efec9f..0d386ade 100644 --- a/tests/parity_save_fly_towns.lua +++ b/tests/parity_save_fly_towns.lua @@ -21,6 +21,14 @@ local FlyMenu = require("src.ui.FlyMenu") -- loadData() hands back the crosswalk set GenSave wants ({pokemon, moves, -- items, maps, eventFlags}) and installs the charmap as a side effect local cwData = assert(SaveConvert.loadData(), "save-convert crosswalk data") +-- home/overworld.asm:2016 (#1691) +local stampMapWindow = loadfile("tests/fixture_data/map_window.lua")() +local function stampAll(d) + for mapId in pairs((d or {}).maps or {}) do stampMapWindow(d, mapId) end +end +stampAll(cwData) +stampAll(Data) + local O = GenSave.OFFSETS -- ------------------------------------------------------------------ diff --git a/tests/parity_save_nicknames.lua b/tests/parity_save_nicknames.lua index 9bc30999..877cf4cb 100644 --- a/tests/parity_save_nicknames.lua +++ b/tests/parity_save_nicknames.lua @@ -19,6 +19,14 @@ local Pokemon = require("src.pokemon.Pokemon") local Evolution = require("src.pokemon.Evolution") local cwData = assert(SaveConvert.loadData(), "save-convert crosswalk data") +-- home/overworld.asm:2016 (#1691) +local stampMapWindow = loadfile("tests/fixture_data/map_window.lua")() +local function stampAll(d) + for mapId in pairs((d or {}).maps or {}) do stampMapWindow(d, mapId) end +end +stampAll(cwData) +stampAll(Data) + local O = GenSave.OFFSETS local NAME_LENGTH = 11 local charmap = assert(loadfile("src/save_convert/data/charmap.lua"))() diff --git a/tests/save_convert_tests.lua b/tests/save_convert_tests.lua index bd499eb2..bddfa59c 100644 --- a/tests/save_convert_tests.lua +++ b/tests/save_convert_tests.lua @@ -29,6 +29,9 @@ local data = { maps = loadfile("data/generated/maps.lua")(), eventFlags = loadfile("src/save_convert/data/event_flags.lua")(), } +-- home/overworld.asm:2016 (#1691) +local stampMapWindow = loadfile("tests/fixture_data/map_window.lua")() +for mapId in pairs(data.maps) do stampMapWindow(data, mapId) end -- Independent re-implementation of CalcCheckSum (complement of the additive -- byte sum) so the export scenarios below can verify all three SRAM checksums diff --git a/tests/save_convert_yellow_bug838_test.lua b/tests/save_convert_yellow_bug838_test.lua index 3e96eb1c..cbd9e640 100644 --- a/tests/save_convert_yellow_bug838_test.lua +++ b/tests/save_convert_yellow_bug838_test.lua @@ -49,6 +49,11 @@ local yellowData = { maps = shared.maps, eventFlags = yellowFlags, gameVersion = "yellow", } +-- home/overworld.asm:2016 (#1691) +local stampMapWindow = loadfile("tests/fixture_data/map_window.lua")() +for mapId in pairs(shared.maps) do stampMapWindow(redData, mapId) end +yellowData.tilesets, yellowData.audio = redData.tilesets, redData.audio + local OFF = GenSave.OFFSETS -- ------------------------------------------------------------------ diff --git a/tools/gen2_sram_offsets.py b/tools/gen2_sram_offsets.py index f3469ebd..39f00e96 100644 --- a/tools/gen2_sram_offsets.py +++ b/tools/gen2_sram_offsets.py @@ -28,6 +28,7 @@ FIELDS = [ "wKeyItems", "wNumBalls", "wBalls", "wTMsHMs", "wPokedexCaught", "wPokedexSeen", "wCurBox", "wBoxNames", "wMapGroup", "wMapNumber", "wXCoord", "wYCoord", "wEventFlags", "wPlayerState", + "wStatusFlags", "wStatusFlags2", "wPokegearFlags", "wGameTimeHours", "wGameTimeMinutes", ] GUARDS = ["sCheckValue1", "sCheckValue2", "sChecksum", "sGameData", "sGameDataEnd"] diff --git a/tools/make_gold_manifest.py b/tools/make_gold_manifest.py index 0df9587f..3a56cc88 100644 --- a/tools/make_gold_manifest.py +++ b/tools/make_gold_manifest.py @@ -818,6 +818,8 @@ REQUIRED_SYMBOLS = { "TownMapGFX", "PokegearGFX", "PokegearSpritesGFX", "ClockTilemapRLE", "PhoneTilemapRLE", "RadioTilemapRLE", "JohtoMap", "KantoMap", "TownMapPals.PalMap", "PokegearPals", + # engine/pokegear/pokegear.asm:2298 -- Pokedex_GetArea's nest marker. + "PokedexNestIconGFX", # Trainer card (engine/menus/trainer_card.asm): the player's portrait and # the card frame share one sheet at $00, the status/leader sheets both # load at $29, and the badges are OBJs with their own OAM template table. diff --git a/tools/make_rom_manifest.py b/tools/make_rom_manifest.py index 3b46e01b..3e29bb6d 100755 --- a/tools/make_rom_manifest.py +++ b/tools/make_rom_manifest.py @@ -615,6 +615,7 @@ FIELD_ASSET_SYMBOLS = { "HpBarAndStatusGraphics", "LedgeHoppingShadow", "LinkCableTiles", + "MonNestIcon", "MoveAnimationTiles1", "NintendoCopyrightLogoGraphics", "PlayerCharacterTitleGraphics", @@ -633,6 +634,7 @@ FIELD_ASSET_SYMBOLS = { "SlotMachineTiles2", "TheEndGfx", "TownMapCursor", + "TownMapUpArrow", "TradeBubbleIconGFX", "TradingAnimationGraphics", "TradingAnimationGraphics2", diff --git a/tools/rom_manifest.json b/tools/rom_manifest.json index 55a4e8fa..20132e26 100644 --- a/tools/rom_manifest.json +++ b/tools/rom_manifest.json @@ -20958,6 +20958,10 @@ 10, 31660 ], + "MonNestIcon": [ + 28, + 22206 + ], "MonPartyData": [ 28, 22797 @@ -22158,6 +22162,10 @@ 28, 20288 ], + "TownMapUpArrow": [ + 28, + 20627 + ], "TradeBubbleIconGFX": [ 28, 23129 diff --git a/tools/rom_manifest_blue.json b/tools/rom_manifest_blue.json index 555b1fa6..083e4ae2 100644 --- a/tools/rom_manifest_blue.json +++ b/tools/rom_manifest_blue.json @@ -20935,6 +20935,10 @@ 10, 31660 ], + "MonNestIcon": [ + 28, + 22206 + ], "MonPartyData": [ 28, 22797 @@ -22135,6 +22139,10 @@ 28, 20288 ], + "TownMapUpArrow": [ + 28, + 20627 + ], "TradeBubbleIconGFX": [ 28, 23129 diff --git a/tools/rom_manifest_crystal.json b/tools/rom_manifest_crystal.json index 8d45d7b8..dead47fd 100644 --- a/tools/rom_manifest_crystal.json +++ b/tools/rom_manifest_crystal.json @@ -15300,6 +15300,10 @@ 110, 20584 ], + "CutGrassGFX": [ + 35, + 18892 + ], "CyndaquilBackpic": [ 83, 21755 @@ -16764,6 +16768,10 @@ 115, 17035 ], + "JumpShadowGFX": [ + 65, + 17744 + ], "JumpluffBackpic": [ 85, 20059 @@ -18516,6 +18524,10 @@ 16, 21774 ], + "PokedexNestIconGFX": [ + 36, + 25297 + ], "PokedexQuestionMarkPalette": [ 2, 20410 diff --git a/tools/rom_manifest_gold.json b/tools/rom_manifest_gold.json index 597818e6..493227bd 100644 --- a/tools/rom_manifest_gold.json +++ b/tools/rom_manifest_gold.json @@ -13600,6 +13600,10 @@ 105, 20723 ], + "CutGrassGFX": [ + 35, + 19524 + ], "CyndaquilBackpic": [ 23, 22994 @@ -14844,6 +14848,10 @@ 106, 17046 ], + "JumpShadowGFX": [ + 5, + 17929 + ], "JumpluffBackpic": [ 25, 23516 @@ -16372,6 +16380,10 @@ 16, 21777 ], + "PokedexNestIconGFX": [ + 36, + 25124 + ], "PokedexQuestionMarkPalette": [ 2, 21849 diff --git a/tools/rom_manifest_silver.json b/tools/rom_manifest_silver.json index 1e4f86ed..1e8bfc5c 100644 --- a/tools/rom_manifest_silver.json +++ b/tools/rom_manifest_silver.json @@ -13600,6 +13600,10 @@ 105, 20725 ], + "CutGrassGFX": [ + 35, + 19524 + ], "CyndaquilBackpic": [ 23, 25508 @@ -14844,6 +14848,10 @@ 106, 17077 ], + "JumpShadowGFX": [ + 5, + 17929 + ], "JumpluffBackpic": [ 25, 23910 @@ -16372,6 +16380,10 @@ 16, 21777 ], + "PokedexNestIconGFX": [ + 36, + 25124 + ], "PokedexQuestionMarkPalette": [ 2, 21849 diff --git a/tools/rom_manifest_yellow.json b/tools/rom_manifest_yellow.json index e6da0bdb..d98a56d7 100644 --- a/tools/rom_manifest_yellow.json +++ b/tools/rom_manifest_yellow.json @@ -22525,6 +22525,10 @@ 10, 31430 ], + "MonNestIcon": [ + 28, + 22347 + ], "MonPartyData": [ 28, 22970 @@ -26485,6 +26489,10 @@ 28, 20420 ], + "TownMapUpArrow": [ + 28, + 20766 + ], "TradeBubbleIconGFX": [ 28, 23302