From 36ad59bdbb36727cb342b3f2afe2526f84b2831c Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Sat, 29 Aug 2026 06:59:38 -0400 Subject: [PATCH] CLOSES #1590, CLOSES #1592, CLOSES #1625, CLOSES #1657, CLOSES #1698, CLOSES #1699, CLOSES #1700, CLOSES #1843, CLOSES #1875, CLOSES #1898, CLOSES #1902, CLOSES #1905, CLOSES #1906, CLOSES #1907, CLOSES #1908, CLOSES #1909, CLOSES #1912, CLOSES #1915, CLOSES #1917, CLOSES #1919, CLOSES #1920, CLOSES #1921, CLOSES #1922, CLOSES #1923, CLOSES #1924, CLOSES #1925, CLOSES #1926, CLOSES #1927 --- README.md | 10 + data/scripts/story.lua | 3 +- data/scripts/story3.lua | 66 ++-- docs/new-features.md | 9 +- docs/updater.md | 8 + main.lua | 68 +++- poison_flicker_bug1872.png | Bin 10371 -> 10413 bytes src/battle/gen2/AnimObjects.lua | 25 +- src/battle/gen2/AnimRunner.lua | 2 + src/battle/gen2/BgEffects.lua | 3 +- src/core/FixedStep.lua | 45 ++- src/core/FrameCap.lua | 45 ++- src/core/Game.lua | 33 +- src/core/Game2.lua | 58 ++-- src/core/GamepadMap.lua | 5 + src/core/Input.lua | 20 +- src/core/LaunchOptions.lua | 27 +- src/core/Prelaunch.lua | 193 ++++++++++++ src/core/RefreshRate.lua | 116 +++++++ src/core/SaveData.lua | 1 + src/core/TouchControls.lua | 139 ++++++++- src/core/VSync.lua | 78 +++++ src/core/gen2/Nests.lua | 21 +- src/core/gen2/Save.lua | 1 - src/import/CacheContract.lua | 2 +- src/import/LauncherSettings.lua | 10 +- src/import/LauncherView.lua | 133 +++++++- src/import/RomExtractorGen2.lua | 37 +++ src/import/RomImporter.lua | 166 ++++++++-- src/mods/Gen2Compat.lua | 8 +- src/mods/ModIndex.lua | 35 ++- src/render/TextBox.lua | 18 +- src/save_convert/Gen2Layout.lua | 8 + src/save_convert/Gen2Save.lua | 80 ++++- src/save_convert/GenSave.lua | 50 +++ src/script/Commands.lua | 13 +- src/script/gen2/Vm.lua | 17 +- src/ui/BindingsMenu.lua | 54 ++-- src/ui/ListMenu.lua | 11 +- src/ui/OptionsMenu.lua | 27 +- src/ui/SkinStudio.lua | 118 ++++--- src/ui/TouchControlsEditor.lua | 3 + src/ui/gen2/BattleAnimView.lua | 4 + src/ui/gen2/BattleState.lua | 8 +- src/ui/gen2/Chrome.lua | 3 + src/ui/gen2/OptionsMenu.lua | 30 +- src/ui/gen2/PackMenu.lua | 3 + src/ui/gen2/PokedexMenu.lua | 5 +- src/ui/gen2/UnownPuzzle.lua | 2 + src/world/OverworldController.lua | 66 +++- src/world/gen2/Palettes.lua | 56 +++- src/world/gen2/World.lua | 108 ++++++- tests/crystal_import_test.lua | 27 ++ .../battle_scy_deform_bug1895_test.lua | 16 + .../drivers/bills_ss_ticket_bug1875_test.lua | 118 +++++++ .../crystal_house_palette_bug1924_test.lua | 56 ++++ tests/drivers/crystal_rock_smash_probe.lua | 78 +++++ tests/drivers/elevator_panel_bug395_test.lua | 12 +- .../gold_battle_bg_world_bug1709_test.lua | 188 +++++++++++ tests/drivers/gold_dex_area_bug1891_test.lua | 6 +- tests/drivers/gold_jingle_bug1860_test.lua | 6 + .../drivers/gold_unown_puzzle_jingle_test.lua | 77 +++++ tests/drivers/launcher_find_tags_shot.lua | 92 ++++++ .../drivers/menu_hold_scroll_bug1898_test.lua | 23 ++ tests/drivers/poison_flicker_bug1872_test.lua | 83 +++-- tests/drivers/refresh_cadence_test.lua | 63 ++++ tests/drivers/route.lua | 5 +- tests/drivers/touch_hotbar_1592.lua | 69 +++++ tests/engine/bag_cancel_row_bug1685.lua | 2 + tests/engine/cart_launcher.lua | 39 ++- tests/engine/frame_cap_display.lua | 122 ++++++++ tests/engine/gen2_fly_leaves_bug1889.lua | 12 + tests/engine/gen2_jingle_arrow_bug1926.lua | 104 +++++++ .../gen2_night_shade_top_row_bug1921.lua | 83 +++++ tests/engine/gen2_offmap_object_bug1923.lua | 84 +++++ tests/engine/gen2_save_import.lua | 76 ++++- tests/engine/gen2_scy_deform_blit_bug1895.lua | 28 +- .../engine/gen2_surf_ly_override_bug1920.lua | 87 ++++++ tests/engine/launch_options_flags_bug1657.lua | 91 ++++++ tests/engine/launch_prelaunch_bug1657.lua | 293 ++++++++++++++++++ tests/engine/launcher_update_all.lua | 282 +++++++++++++++++ tests/engine/menu_hold_scroll_bug1898.lua | 54 ++++ tests/engine/mod_index_tests.lua | 52 ++++ tests/engine/pad_speed_binding_bug1922.lua | 158 ++++++++++ tests/engine/poison_flicker_bug1872.lua | 93 +++++- tests/engine/save_convert_extra_flags.lua | 79 +++++ .../skin_studio_library_reach_bug1912.lua | 174 +++++++++++ .../engine/speed_shoulders_triggers_test.lua | 23 +- tests/engine/timing_parity.lua | 72 +++++ tests/engine/touch_hotbar_1592.lua | 145 +++++++++ tests/engine/vsync_option.lua | 55 ++++ tests/gen2_battle_anims_test.lua | 29 +- tests/gen2_battle_options_test.lua | 164 ++++++++-- tests/gen2_fly_map_test.lua | 54 ++++ tests/gen2_menus_test.lua | 3 +- tests/gen2_nests_test.lua | 12 + tests/gen2_palettes_test.lua | 51 +++ tests/gen2_rock_smash_test.lua | 55 ++++ tests/gen2_trainers_test.lua | 90 ++++++ tests/gen2_unown_test.lua | 43 +++ tests/gen2_world_test.lua | 32 ++ tests/mod_ui_tests.lua | 41 ++- tests/parity_C.lua | 22 +- tests/parity_bills_pc.lua | 74 +++++ tests/parity_elevator_panel.lua | 56 +++- tests/parity_save_fly_towns.lua | 4 +- tests/run_tests.lua | 7 +- tests/save_convert_tests.lua | 37 ++- tests/save_convert_yellow_bug838_test.lua | 23 ++ tests/save_editor_gen2_tests.lua | 63 +++- tests/save_editor_task6_tests.lua | 214 +++++++++++++ tools/gen2_sram_offsets.py | 14 +- tools/rom_manifest_crystal.json | 28 ++ tools/save-editor/Ops.lua | 174 ++++++++++- tools/save-editor/State.lua | 1 + tools/save-editor/panels/Items.lua | 114 +++++-- 116 files changed, 6157 insertions(+), 426 deletions(-) create mode 100644 src/core/Prelaunch.lua create mode 100644 src/core/RefreshRate.lua create mode 100644 src/core/VSync.lua create mode 100644 tests/drivers/bills_ss_ticket_bug1875_test.lua create mode 100644 tests/drivers/crystal_house_palette_bug1924_test.lua create mode 100644 tests/drivers/crystal_rock_smash_probe.lua create mode 100644 tests/drivers/gold_battle_bg_world_bug1709_test.lua create mode 100644 tests/drivers/gold_unown_puzzle_jingle_test.lua create mode 100644 tests/drivers/launcher_find_tags_shot.lua create mode 100644 tests/drivers/refresh_cadence_test.lua create mode 100644 tests/drivers/touch_hotbar_1592.lua create mode 100644 tests/engine/frame_cap_display.lua create mode 100644 tests/engine/gen2_jingle_arrow_bug1926.lua create mode 100644 tests/engine/gen2_night_shade_top_row_bug1921.lua create mode 100644 tests/engine/gen2_offmap_object_bug1923.lua create mode 100644 tests/engine/gen2_surf_ly_override_bug1920.lua create mode 100644 tests/engine/launch_options_flags_bug1657.lua create mode 100644 tests/engine/launch_prelaunch_bug1657.lua create mode 100644 tests/engine/launcher_update_all.lua create mode 100644 tests/engine/pad_speed_binding_bug1922.lua create mode 100644 tests/engine/skin_studio_library_reach_bug1912.lua create mode 100644 tests/engine/touch_hotbar_1592.lua create mode 100644 tests/engine/vsync_option.lua diff --git a/README.md b/README.md index 225ee79b..00307312 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,16 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend. | `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold`, `silver` and `crystal` too, or just `r` / `b` / `y` / `g` / `s` / `c`) | | `--slot=2` | load that save slot; takes a slot number or a slot id | | `--launcher` | open the launcher anyway, so you can edit a shortcut you already made | +| `--no-sync` | skip the save sync a linked device otherwise runs before the game boots (`POKEPORT_LAUNCH_SYNC=0`) | +| `--update` | check for a release first, and restart once into it if one is ready (`POKEPORT_LAUNCH_UPDATE=1`; `-update` works too) | + +If this device is linked for save sync, a shortcut now syncs before it boots +so CONTINUE never loads a save another device has already moved past. The +screen shows what it is doing and any button skips straight into the game; a +sync conflict opens the launcher so you can pick a copy rather than booting +over one. Android launcher intents take the same path, on the environment +defaults above since an intent carries no arguments, and never run the update +check. ## Linux desktop (AppImage / Flatpak) diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 0e4b8fbc..c040a352 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -236,7 +236,8 @@ M.BILLS_HOUSE = { -- the received text that reads it (scripts/BillsHouse.asm; the -- item id is S_S_TICKET in generated items.lua -- keyItem, so the -- sound_get_key_item jingle plays like BillsHouse.asm:196) - { "give_item", "S_S_TICKET", 1, false }, -- 5 + -- .bag_full (scripts/BillsHouse.asm:184-186) + { "give_item", "S_S_TICKET", 1, false, "_SSTicketNoRoomText" }, -- 5 { "show_text", "_SSTicketReceivedText" }, -- 6 { "set_flag", "EVENT_GOT_SS_TICKET" }, -- 7 -- The two Cerulean guards are a SWAP PAIR, not scenery diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index 2df735c2..311126d3 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -306,30 +306,50 @@ local function elevator(elevatorMapId, panelText, keyGate, preFrames) for _, f in ipairs(floors) do table.insert(items, { label = f.token, value = f }) end - local ListMenu = require("src.ui.ListMenu") - game.stack:push(ListMenu.new(game, "WHICH FLOOR?", items, { - onChoose = function(item, list) - list:close() - -- the whole ShakeElevator ride runs in place -- music stop, 100 - -- collision-thud scroll bounces, the PA chime -- and only then - -- does .UpdateWarp's rewrite land, with the player still stood - -- at the panel: they walk out to the car door themselves - local ElevatorShake = require("src.world.ElevatorShake") - game.stack:push(ElevatorShake.new(game, ow, { - preFrames = preFrames, - onDone = function() - elevatorSetExit(ow, item.value) + -- (home/list_menu.asm:105-110, 371-372, 524-525) + local Strings = require("src.core.Strings") + items[#items + 1] = { cancel = true, label = Strings("CANCEL") } + -- (engine/events/elevator.asm:2-3, data/text_boxes.asm:13) + local prompt + local function closePrompt() + if prompt and game.stack:top() == prompt then game.stack:pop() end + prompt = nil + end + local function openList() + local ListMenu = require("src.ui.ListMenu") + game.stack:push(ListMenu.new(game, nil, items, { + kind = "elevator_floors", + itemBox = true, + onChoose = function(item, list) + list:close() + closePrompt() + if item.cancel then done() - end, - })) - end, - onCancel = function() - -- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing - -- happens, the player just stays in the car (exit warps were - -- already seeded on entry) - done() - end, - })) + return + end + local ElevatorShake = require("src.world.ElevatorShake") + game.stack:push(ElevatorShake.new(game, ow, { + preFrames = preFrames, + onDone = function() + elevatorSetExit(ow, item.value) + done() + end, + })) + end, + onCancel = function() + closePrompt() + done() + end, + })) + end + local TextBox = require("src.render.TextBox") + prompt = TextBox.new(game, + game.data.text._WhichFloorText or "Which floor do\nyou want? ", nil, { + -- pokeyellow/engine/events/elevator.asm:1-8 sets BIT_NO_TEXT_DELAY + instant = require("src.core.GameVersion").isYellow(), + stay = { onShown = openList }, + }) + game.stack:push(prompt) end return { -- fromMapId: the floor the player just left (setMap passes it), so a diff --git a/docs/new-features.md b/docs/new-features.md index e34d7f08..61212a53 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -7,6 +7,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Multiple color modes**, including original, SGB, advanced GBC, monochrome, and classic green * **Optional GBC screen effects**, including pixel grids, shadows, glare, and transparency * **Performance presets** and configurable FPS limits +* **V-SYNC row and a DISPLAY frame cap**, for panels whose refresh is not a multiple of 60Hz * **Peer-to-peer LAN link play** for trades and battles between Red, Blue, and Yellow * **Online lobby** in the launcher for battles, spectating and tournaments * **Persistent custom options** stored separately from game saves @@ -19,6 +20,12 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Custom carts**, a named mod set saved from the mods tab and picked from a game's page, with its own shell colour, label art, save slots and export file * **Install required mods**, one press on a cart that will not start, fetching every pinned mod at the pinned version and refusing any archive whose hash is not the one the cart recorded * **Browse carts in Find mods**, a Mods / Carts switch on the same community index, searched and filtered by base game, installing the cart file straight into that game's cart list +* **Filter Find mods by game**, a generation or single-game filter of its own, with every listing showing the games and tags it declares +* **Update all mods** in one press from the MODS tab, installing every outdated mod in turn with a summary of what failed +* **Rebindable GAME SPEED shortcuts**, SPEED - / SPEED + rows in CONTROLS that move the shoulder hotkeys to any pad button or switch them off +* **Key bar on the touch pad**, a corner toggle that slides out SAVE, LOAD, SPEED, COLOR, TILT and ZOOM for phones with no keyboard +* **Save editor item verbs**, sorting the bag and PC by item number or name, filling one stack or every stack to x99, and a coin editor on every game +* **Shortcuts sync before they boot**, a `--game` launch syncing saves (and, with `--update`, taking a release) first, skippable with any button ## Gen 2 Specifics @@ -26,4 +33,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) +* **Battle screen options** on Gold, Silver and Crystal: BATTLE SIZE (fixed or window-filling) and BATTLE BG (white, black or the dimmed map as the surround) diff --git a/docs/updater.md b/docs/updater.md index d2a4c59e..82b85148 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -122,6 +122,14 @@ bundled game, in that case. 4. **Restart to apply**: a `ready` payload just sits in `updates/` until the player relaunches; the next launch's Boot step (1) is what actually mounts and runs it. There is no in-session hot-swap. + A shortcut launched with `--update` (`src/core/Prelaunch.lua`, #1657) is + the one path that does this without the launcher on screen: it runs steps + 2-3 before `bootGame`, and on `ready` writes the one-shot + `launch_update.txt` marker and restarts, so the fresh boot chainloads the + payload in step 1 and then boots the game. The marker is consumed on that + next launch, so a payload that will not apply cannot restart-loop the + shortcut. `needs_full` is never acted on unattended; it falls through and + boots the game. 5. **Native-package requirement**: when `minShell` or `payloadHost` is incompatible, the worker writes `full-update.json` and surfaces a persistent launcher control. Android downloads the release APK, verifies diff --git a/main.lua b/main.lua index 8f2ea95c..f41067b4 100644 --- a/main.lua +++ b/main.lua @@ -109,7 +109,7 @@ do love.errhand = love.errorhandler end -local Game, EditorApp, Importer, TouchEditor, Studio +local Game, EditorApp, Importer, TouchEditor, Studio, Prelaunch -- #887: quit-to-launcher state, shared by love.load and love.quit (both need -- it, so it is declared here rather than next to love.quit). @@ -650,10 +650,26 @@ function love.load(args) local launchGame, launchSlot = LaunchOptions.resolve(arg) if launchGame and not relaunched and not LaunchOptions.forceLauncher(arg) then if RomImporter.isReady(launchGame) then - if launchSlot then LaunchOptions.selectSlot(launchGame, launchSlot) end - -- No launcher behind this session: love.quit must exit, not restart. - launchedIntoGame = true - bootGame(launchGame) + local function bootShortcut() + if launchSlot then LaunchOptions.selectSlot(launchGame, launchSlot) end + launchedIntoGame = true + bootGame(launchGame) + end + Prelaunch = require("src.core.Prelaunch").new({ + version = launchGame, + tasks = LaunchOptions.tasks(args, arg), + done = function(outcome) + if outcome == "restart" then return end + Prelaunch = nil + if outcome == "launcher" then + LaunchOptions.pendingTab = launchGame + Importer = makeLauncher() + return + end + bootShortcut() + end, + }) + if not Prelaunch then bootShortcut() end return end -- Not importable yet: open the launcher already showing that game, so the @@ -679,6 +695,7 @@ function love.update(dt) if editorMode then return EditorApp.update(dt) end if TouchEditor then return TouchEditor.update(dt) end if Studio then return Studio.update(dt) end + if Prelaunch then return Prelaunch:update(dt) end local client = onlineClientModule() if client then pcall(client.update, dt) end if pendingLauncherReturn then @@ -750,6 +767,10 @@ function love.draw() HostDisplay.endFrame("skin_studio", Studio) return result end + if Prelaunch then + GameViewport.reset() + return Prelaunch:draw() + end if Importer then GameViewport.reset() HostDisplay.beginFrame("launcher", Importer) @@ -784,6 +805,7 @@ function love.keypressed(key, scancode, isrepeat) if editorMode then return EditorApp.keypressed(key) end if TouchEditor then return TouchEditor.keypressed(key) end if Studio then return Studio.keypressed(key) end + if Prelaunch then return Prelaunch:cancel() end if Importer then return Importer:keypressed(key) end if not Game then return end Game:keypressed(key) @@ -811,6 +833,7 @@ function love.gamepadpressed(joystick, button) return end if Studio then return Studio.gamepadpressed(joystick, button) end + if Prelaunch then return Prelaunch:cancel() end if Importer then return Importer:gamepadpressed(joystick, button) end if not Game then return end Game:gamepadpressed(joystick, button) @@ -1029,7 +1052,25 @@ function love.handlers.intent_game(version) returnToLauncher() end Importer = nil - bootGame(version) + if Prelaunch then return end + + local tasks = LaunchOptions.tasks(arg, arg) + tasks.update = false + Prelaunch = require("src.core.Prelaunch").new({ + version = version, + tasks = tasks, + done = function(outcome) + if outcome == "restart" then return end + Prelaunch = nil + if outcome == "launcher" then + LaunchOptions.pendingTab = version + Importer = makeLauncher() + return + end + bootGame(version) + end, + }) + if not Prelaunch then bootGame(version) end end function love.touchpressed(id, x, y, dx, dy, pressure) @@ -1050,6 +1091,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure) return TouchEditor.touchpressed(id, x, y) end if Studio then return Studio.touchpressed(id, x, y) end + if Prelaunch then return Prelaunch:cancel() end if Importer then -- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are -- polled inside the view; the istouch filter on mousepressed still drops @@ -1139,6 +1181,10 @@ function love.mousepressed(x, y, button, istouch) if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end return Studio.mousepressed(x, y, button) end + if Prelaunch then + if istouch then return end + return Prelaunch:cancel() + end if Importer then -- love.touchpressed already forwards the primary touch into FlexLove for -- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both @@ -1302,6 +1348,9 @@ function love.run() if love.timer then love.timer.step() end local FrameCap = require("src.core.FrameCap") + local RefreshRate = require("src.core.RefreshRate") + local FixedStep = require("src.core.FixedStep") + local VSync = require("src.core.VSync") local paced = pacingEnabled() -- The deadline the next present() should not beat. Carried forward one -- budget per frame so pacing stays even instead of drifting with the @@ -1349,6 +1398,9 @@ function love.run() -- update dt if love.timer then dt = love.timer.step() end idleFor = idleFor + dt + if RefreshRate.sample(dt) then + FixedStep.refreshPeriod = RefreshRate.period() + end checkEmergencyQuit(dt) @@ -1364,6 +1416,8 @@ function love.run() cap = 10 elseif Importer and (not focused or idleFor > 30) then cap = 15 + elseif cap == FrameCap.DISPLAY and not VSync.isOn() then + cap = FrameCap.DEFAULT end if visible and love.graphics and love.graphics.isActive() then @@ -1374,7 +1428,7 @@ function love.run() end if love.timer then - if paced then + if paced and cap ~= FrameCap.DISPLAY then -- Sleep out the remainder of the frame budget, measured from the -- carried deadline, in small chunks so the OS timer stays -- responsive. The pacer yields to vsync inside a 1ms dead band, so diff --git a/poison_flicker_bug1872.png b/poison_flicker_bug1872.png index 6c968f1e8483b5af8a66c75b32e730278669239b..b1fae8cec4de26d5bba430d555e7768f24bd9e13 100644 GIT binary patch literal 10413 zcmZ{Kc_37K^!UAVX^6Vov}s1~l~hQtgx6qt)sqnIvQD8wq9kL#MWs@CDan@CVkuii zL>MY6)d=BLGRQ9LSjU*(xifmp@B97!HFH0o^Ev0-v!C<1=gqenPMf-5DuN)>jJ9mr zfgn=w7ek~l_}OwzMjw7=nw#v}oc~p{di83NXgI$*rdpKR@>R4|c%O?Ps(Xw!ZP>+* z`5tQ(aVO`|S@f2lc1FjdS7~1MX7OIBH0GYP>jSsmph224l;tlrpBlYmxqe->bYk#t zJK7%06hD)Hu4CfYZm`~nCLE{_++g8e+1Y40y*2N+gOiZ@dTG9GlS`YiDBMxhHKSj& zLUv+rM&;bWzJ0)X>VEzf_((Zv!RhD%)d_iZ!d^F8 zXd~BYFcRvvL8v-XA?jK(dMhw0?v;tb0*X@EF+uggeU_pP*LLBD=lx#5-(!DAW8V(# z7g?;t8XD4ieBD*RKf6I|+H;o%W4DiMPAo4L33>`O9~Io%YIoQJsX6|oAj@>LtivGW zwDXdjDQ8yY@8*`@dsVC{Kebbk%>VhAT4}>dtJf7{_#?HDCgnGN?WsN`Nr|&RI;3>J z|H5xt#z~F2M84<;y{X~8vqvke2IXz;w_7RA4UPMpK)-0EJKgTDtr69M=6(l_MsGS6 z@NEN#pUf`Yc9uOlc1)0W{qbM*Ss4>6J7)(T?91=HH{6fSHmdVEKPcQa_J>Y{WH*RA z9#EKI)MD(WwXWl;D0$)O#{q5?SL8uDN9#YS8Fw0lRPfiN)SQVE3&4Aq_9oE66&g}{ zJg2^94^R&)9WK=>PyqqyZJhg2;S0YC%lPR3^ik$SkY-@jwuToszTQ97c2#8cS7V@P z?|;v_=ikfLDL6}>wXfRw$#nkFu|6eEs#3O_YXnG_phlV*&?p=%<^t9Hb`H{uBsQVf z#^=kUR&_UoUZ)PDp2P=6e%g*Ty3_LPHpwWHzyXI8Yzup&X2$B6!9J z|Gxs~vI*Cr<|0^7C0FMeAeWon3)52xYQ}65lLQo@5jO)EQUq0J4o(c9+ql^HO(n{c z+*N`e3eH+ag7N%-867&pK-LJi0Dl*$oKZN>cT#4<1`pj+={qXMCZ?mB({6}^M8EX7 ztmL6;(hNibMUrDB&~adL+e=KfXcS3o{$JkDT_50M;I)9QB|P-~OH4jbG$4!M%KPeN zw+s4rF|G%w9c@l>sUQ6LGS$MWMr10TwaV;ec>4p$I_2+v|$4>o^r$r-@4)BfS7h^$3< zdHkqp?iz;g&|vWaSJR;vPZp>69*r(wZ^LYhvSPX|x9FX3K&z`A%%1HQ4)6Ii{AHc+ zu#CH!p^YGoevHBS6xDTp*k|Y2H7B#%wPx|Gx2-JbG*rSfo~Za9;E%GUVjkuh89V}o zJX0e=eP%w#Qc{x#*%L{3yU#~-4r}DMP~9~W^M`B<8rejaRu*kHIPYxt^+{_K8#$^QE3L4@>;OsmuUVh{ zy*uL`RJas9joIT@!)^CQ3TUQS?3;EqBFyJkb)55YHqOzYG6;DZkt$0g9u#3R4QP(# z+?wM)cAI|Ji@i?eIbWc%Zro`>e(Pw@WFULzO2MhRVW0sPkmIvfN+;q@!MUC>UA;+c ziF4`)J$wL{p(5vD%nj+V?PAENMzpq>pw=2j4>do9hnxirLV<+Z2k*9p7 z93CFDF-UAhmE<|McY`JGXCQfUtJZ?i2lpX)7DR{ccHml*#=;Gl+ojG_h}V5b&m+UOtSP)YW(i2`bMD zss1Ux2aC*2?-@gJjy96An^{lQ^t7uV#Aq!0S^t36)>0z7JM#iKSl4)-1bvI-xi=9} zGa&j33OcC_ByeWNH!srbUtw=2X)Gi*Klsl9iwi5vvJpQNci%#bUuiasX<}?5s2@o` zK6gqlLTyGr^(*2KYA(OVPBg1$0=DUIu%!MAeH-?tAT(Y)gIM9#rvU#V8O*(3f1CAV z?V&V+yPE(?w?83%aW;4(yT9%*K-k2ce#F)L;yGZV^c$MnuQ({8^vLO~z{{`al|%56 zV0;KsQ3aN=%OO5WLMGJ|@*&sZ77L5$tLogSjW-)aoy30G@AiTLK=L zphkSUQF!+lOa6uq!1sNxCgJ7KUgQy*P%d~`q7bonw{4_t5Wu@GfLtTfgf{ZGM{WG{ z(bzoVn8CI}utAfZik#VI_q2!HgW5=IZf!>8AylSV44D;zP3W$|Ss{rPhH{(5PwF@; zbXkSn(JP0I=8@WsxlYx5xwHLospIzY9x#sgW!SvNWPWyO^P?qVbl~$QfUa~YTTOP% z-@k_I71Op$FtXKN;dX;4sJ|2?riVC-H1BJ>?i zyEV{{yi0}rht8|*3wFd2EmfYlc&2vmJ6clM5_2^ zE{@wOrqJ;rf&Ggfo3M9!WY|PzaF4!SKEP}xnaMeslSy4-zT&0(Q5_$|S^oXswhpIA z%Z`+m&GRBl*yVlb1lS zyLL`tzO<{*(L8IcbegJ@q@qrI5?ywCxH`Cb4(WJ~=q|q){jDBb@_ZnmXqs9gDmYWN zM&j1U{0pb&fTpfdxrb%4tGXYKCssE@ZvuH@y+{KvqHIDva4y6^DEJI>AlMLCFY?6* zf=*FX*VD7?vk+62_k}BR6u6p*8b}y|o-ssR=C6207P%KqMSHqRfuz19h-UQcH!v+W2?)4?yP@2MOCCD`eHYnrD% zF@g9s`lFB5x}q&jHtK{C2Ya>{*Nl`R7y0@Oj6ceGW?C*QJhjHW*NGo=y0on>W8+GiwJ1)%#~N z2rtt?hoe3__3zoaOgP)b22p#{lH=Z*y1l7s4on?w`e_QBsnqlSHNRb}B0(@1G5!(L zJWd)?3D)*2xA?F|WOt3#Kxxf9K;_zqp76)h|FYJs>&a@wS1o2EXw=M~gyTqgdK^gtGQ&+_H*8j`QD2R|-Q5>#spW%9F9khi<=$#1lQdK*cZ9toid=v&5krJh z%RTeYXwMBDUNa8n0ToxgYJZMx!p^=Yzu2}%JFuZJ@UW#Rn>p__Et*(FG{i8QMv+fs zWcT(a61f`t8vKYc+jPx`e0slR-N~C6cY0Q)mBc$088BCqtTc32;$enn-0?@_2S)Pbp1~Tcr*%D)kobBs-$YPf_3TrSDr-gTXKD6G4nnQ>?L%wR z42$>Z!LLiVqz*0rK;|M?t}^({uG0I^p~i_<)~duMC~i;>n?C$#X>g~0>8nBPN0#ar zKy|I|&~0v%H8D+(BbO z9H#MPq76YF+PV>UPBqg2^wp=NSjNB%2sr$>kjU;-{s;ZRpC6S3J+ugt>6JVifUZ73n?|DYhh9zHoA^ln59&i!3P?4DQY}7{X^+L=jCp`WOB>NWijdx# z(CW#bCm?SUy(@qd|4#2bGzxd9n#Cq+NJ}96p{D>5eQ+?7!ZnkNup>t#)ni53Qj^NH z(=Cl2tn&$|#)WLX%k#x)5Gve$hWzfJ7wvlFM*hflC2i>4_CoL0)1Z}?SToYMZ}9Dk z7mh=t<0*8XzB@VxL4~2z!}1>chIjh7pFr9$N3J~E?Ol9Ue8bS zPo9svHTZl@!5?C*kXWtnkJdHMJa@%+jD798bckDa>mgxpXrV!e<^_A`Ad1E1?*f+Tz#i2-byk9C?_-XRt>2bNfK_;dUGrX0>UcF}e@m+riA$MVDL8cVu9evSLHJ5M zU8y78pcoKMn|_%K&hzwu{ssC<=J38z)k?!(#%wsBfDKI=Z_p^FrEkOweL&7w93YT~ zm;v9yWd1w=`(!ef*jMiu z?UxQ&*dmXTi(6XIoMLT~;Q{1r*qi~8*lc-cUn%oYKbH#JT>HffTV$t3w&B{jzhgvz zqz%34aK(5n6ir~^yf@#EJ$-wI!r(k)BWVncB72R{vw$2bjwiA?Uk(KwBrO#N1Ha@~ zCrXPWkUhzN1A|j7&hOqxS}$470|O=v;tCspiwD6S6y9U*XF5sY$5L6*NF4e4(7HQ~ zT1!;GspyB+twx1h;-Pn4e#`YAJ<|+hgS2E_;!}i;r9O~}%0yf@s;R^Dz}?gm@<;#) z8|>mUz5K`cmOTQwX1Xx%Wyv7w0hgG@7d!dd_U)P5i zmuZipjlT$b6vU`Xix;GLBkM#qAj|y>B3A>-z6%3raF=mU36|9P%!kt9 zMO_1s?xvUO?hK%f&PTQ#7*YZel5%*mI2Uic8+Ys2G~_)s3`(>3?`1cxqe&*n&A6FB z4!Y+||4~NZw)eGU;3A*-fFm<1dWHva|GTPQR>dvwE|RUstqSh3L-S{E*x(d{D_dLL z!p#Fp`t)uN&3D+m$tjIg+FDwCxXwLA6viss7qo)Ps+0&1eWrrm(Q756fZP$)X~i6h zevV)Jo>ZRgJ6(Fb%;}H{9Xc}ya%Phs;j8!o4!){(NPpsQ#|fRx*T^3XRML>{N@w`l zaZ+%e6PRoW?pmSW z+VZJej>bDI4l4V5s9P4ug;^aEk4n6WyWV%U6jVt%H0IS^v+j0zCyd-aL*}x6#As#` zmGy_X84F`9bMqDhf~kk?;TCs(d_Q-lLeg16?Y2w$cj@tqD181GXj;L7G8dfjog(;cI+{vnnsM{R&bP(whU&^PrnIv# zZk=L$Riy_xq-jDcMo#A4Jr+;VI+b2evHNJA`~!2aXIV+Mg|xGbOi-fr;1A64YE3%+ z*H)AZMlQ9rKdk``^&-0%j;LxwOU>kPM4|7ChbU)sknAO4qOr-(LBZdj!!>wD7RBl? znDpQZn_B3Tqm}r^r>zsm20AC_G9m2lr-5I_e~eL9#QAp9wF}7@36l^a^iZ6j^j-R7 zl^ow_+Q|-=*1RXC45hGnpOpaR|DZb#!B7YTI~cVfa|)kIic_e{nJ?YCspaHYZ`)hU zq}1*E-xc;h=PUaWhw&20d6)#N0A5M7Cod-;JP3Vd#AAY2wKt}Kj#+a|_f1BB;=d)w zuV9%Db_@P}Wk8PISjIHgch7?52I5F_tS-Iv<2dqV9K;FrlczwZ-hB~ga&~K-?Q%Ps zeKdenEYg%4cwlBZ{tsj@v=WnhmAQ49Q+ebZ_YzezRd7j#xB;;k=D5y_?6MwLB=8d? z_}6ug+?KKZRD_q46z1Wq$CuMTJ*?CSAOa*rqCLm;rXJmzH-e6hif!abb>9Ylj^}@l za8Db?XI`YU!o)*l&cQ`?QXB5VLi{aQSWDv^SQN-zaARWUdI=V#OVqa1hJ`Qr*kQm4 zNp9W{#dqodv@qO+V|l#lBxP^GdtFS z)Im>uZ3)0cO!f6Msns>3le&VdHxr9mXa894JF(O`uAC2}qPG&2cnhs=SvlTT&nkG| zDjCF!JHWwtu8IF(r1|U9Hn`sYo~bd3a`evC+;f)BbvS1drIvVjFIKv8VcXx(|2N$LX40PJ6oAV2I$5goh+c6`44> zU)QG!P*TDWERxtZCNL?`72D~qan*dd!y|7>$Xf*ZJ?&nk8g4V7b`9&h^UZT(<=>!B z?6c2PGvdfb|0QPO_2tQ*%W0fkC9-HkT;y0h^hg=_Nhmd=`{@)K=Lu|pt&2Ch)H%JR z5Azws_p!PcSZxF*w%NP@?YYPo2OH}d(oI8+}gJvs|#Sxpx^mTRe2A2I|yDZIpL zhpb_P>k6w(zQ#gc0r#2j=L_v0Z=II z{4qJ?1WP=gl~RO-kwelQ;d(Rvf3)J55f1S_Nu~4vStYhK8j`R(cm5x+7j+>~Y;<;3 zBcqG>G>FduiyO&7bY;}C{5D@40bYJ&CWR^qOmbp&Gx{p7c3rS1*R?WG=;y}|a?!2J3iI)?q7hx^?XIg& zjJec%eC8X_i_8piBJQNYwB+*u;$SwOrhQDu-Ya^dvnh`rQ|9zi%rA53McXjfj59wk znr}~ot8($3m>b0K&c5-)tGUZ8)9QDR&A@pUmviCL(2WZl#~d%X*7U3phEUzr67v*y zm;rdXeP2()GPoBs9eYTJt7p&ec>!PJsPQF@YRL;5o%K#W;OEjgS%0tl9GDR>J|6ss zCwF(drvc%jo9;4y5CnyMob^$^!1c$f_`VR(hv_}6E6whHx=3?L*#^idm2U*t{>1hqJ(w+)R^bEn^5{{-2No zj{kxbqsdiM|1ZT!q%Ju6Kgdr~DF5D+eovOh*)2%BmEDR~O$aREI`KbgRQ+FC(K0`OIc~B3o!rovf3QLF8#&prIovmsNPUrAQz-EJG9A9C~87h0V zcfe+U97e4JNJR@6>B$!K#XcgbfP!Z%#FgJuphdnxRX7Ar({*RCzDW>(*pl^cUO!Uj z#(%J_)-9|qJoH7?#2A~=g7Ze4?i#r0uf0ia+lSOm z?eeK3i3<9Wiaij&foQ<2aAFb@&L)1pziFdUNU$*(oS}WaGqfP^m(LT`5XQEAL;HQU z>R$^Pkkagj-B;K*qoI4+tX@}PlGwjOAnfb=bC}ohbJ;R+ zQibZj^6$GCvzYZ0yu@rfvB?a7>a&mZYXuFU-hlXjx3$giwBn#_ZufA@@B< zBwXx7Y@cIsXMXvg)`lv6K6kD}OX9uLr=I)V#^;?$=(0295_#@D*U_52Xqb23R8Xel zeCw73H2x^(zA&KOexyEET3v|502hn_WYFgs{pytjVTY}ic34LdS20Gj;UY=#9ai-_ zZ_d>xqKUE5fOUIPJInbE$-10JlFVh~QWz+l?TRUSFS1;DlqQx3y9GTf%=YQN^?lf8 zeC+5pw^YwX7wWl+O8F{>k3os?)tlIG_;dQm?o7R$igzM6OZVHAtKNvSVpwTm$I#0A z%LR`gI@e8z;Jcr9?wVvqN-^Jz7)I$Jw(mQQDG2ZHN6Uk{)@Mdd`LfasPe?CrF}3&g2iMu#3x?^qpk z%~@WXA4pa}z~25>gZ|Gxm@yL9QDP-pshU`STv24dMyeM~1XLMX-FKG8s246GE#@UQ zJ~1kYd7)+9Q$u`TxnNm+iukU*7|UFwoxCt3;!4Yy3-5LGVa`jS{)&fw`k>phpx0>d zjGeQ=EoaU>lBWjrY66&PMnTHLjK8>vbi#3RwY6X9mR8c+W~prznJn}>E^ikF`jw|C z8{L;Ri}2FRapv?K!cGcC?@C)-gB=Cfq2!|%b5UQx=m3040Ppl0oA{JY#@V!j|1cDW_;x?P&6HX<)txGf(j`SZ=C#)pkZ-Nhf3{9*LO8>Y-bz$b7bTqGi zcoozctV5=~$Sf7VC#Y{>kBx+0eD-BGQ+6d${Wmc?Kx(DM8zAUfXNSE8^nIr3bcy6h zSruX9&eqxaQ~Yehr&;g=czH`eX^R=m+RP7G9p}ufXj&@yRwa7P=V%mbQuM{xiMmIN z(wa_n+%>ou=qXxCO0W?ff@xozH&Ty70`FlEc2GV9Q+t<+P1)9}5SqwS)ZP{1_M`yD zP={!oVGLS38JuSfWdaW!oAyYc#U^40m*ARh;dx{;Rg8^5pH5*D1zqG#HB1HDuKE7Y zjlZ)o2U}ZT<_jMf)b5q^1{n{IuC$Di4(V7)ChK5w)EFxzDFu9;W2xrT8ZN>G1uvG` zHg@}~w%1chZYRuWOs(Kmn18;>W?gE zFu$r*N7fZS5f$wn>}hIkbq)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 diff --git a/src/battle/gen2/AnimObjects.lua b/src/battle/gen2/AnimObjects.lua index 4e46d31d..717a3a4d 100644 --- a/src/battle/gen2/AnimObjects.lua +++ b/src/battle/gen2/AnimObjects.lua @@ -127,10 +127,9 @@ function AnimObjects.new(data, constants, env) for index, name in ipairs(self.framesetOrder) do self.framesetIds[name] = index - 1 end - -- Written by the two functions that reach past their own struct: Sky - -- Attack's OBP0 cycle and Surf's per-scanline window. The view reads them. + -- engine/battle_anims/functions.asm:1158 + self.hram = nil self.obp0 = nil - self.lyOverride = nil return self end @@ -139,7 +138,6 @@ function Pool:clear() self.lastIndex = 0 self.oam = {} self.obp0 = nil - self.lyOverride = nil end -- BattleAnimCmd_ClearObjs. The cart's loop clears $a0 bytes from @@ -850,20 +848,21 @@ F.BATTLE_ANIM_FUNC_BUBBLE = function(self, st) st.y, st.var2 = add16(st.y, st.var2, bit.band(st.param, 0xf0) - 0x100) end --- BattleAnimFunction_Surf: the wave is a per-scanline SCY override, so the --- port hands the view the same window the cart writes to hLYOverrideStart / --- hLYOverrideEnd rather than moving anything itself. +-- engine/battle_anims/functions.asm:1148 F.BATTLE_ANIM_FUNC_SURF = function(self, st) + local hram = self.hram local jt = st.jt if jt == 0 then incJt(st) - self.lyOverride = { register = "SCY", first = 0x58, last = 0x5e } + if hram then + hram.lcdc, hram.lyStart, hram.lyEnd = "SCY", 0x58, 0x5e + end return end if jt == 1 then if st.y < st.param then incJt(st) - if self.lyOverride then self.lyOverride.first = 0 end + if hram then hram.lyStart = 0 end return end st.y = u8(st.y - 1) @@ -872,21 +871,23 @@ F.BATTLE_ANIM_FUNC_SURF = function(self, st) -- `ret c`: the wave stops climbing entirely on the frames the subtraction -- underflows, offsets and all. if top < 0 then return end - if self.lyOverride then self.lyOverride.first = u8(top) end + if hram then hram.lyStart = u8(top) end st.xOffset = bit.band(st.xOffset + 1, 7) st.var1 = u8(st.var1 + 2) return end if jt == 3 then if st.y >= 0x70 then - self.lyOverride = nil + if hram then + hram.lcdc, hram.lyStart, hram.lyEnd = nil, 0, 0 + end deinit(st) return end st.y = u8(st.y + 2) local top = st.y - 0x10 if top < 0 then return end - if self.lyOverride then self.lyOverride.first = u8(top) end + if hram then hram.lyStart = u8(top) end return end if jt == 4 then deinit(st) end diff --git a/src/battle/gen2/AnimRunner.lua b/src/battle/gen2/AnimRunner.lua index 99c15339..ef7f4144 100644 --- a/src/battle/gen2/AnimRunner.lua +++ b/src/battle/gen2/AnimRunner.lua @@ -88,6 +88,8 @@ function AnimRunner.new(opts) } self.objects = AnimObjects.new(self.data, self.constants, self.env) self.bg = BgEffects.new(self.constants, self.env) + -- engine/battle_anims/functions.asm:1158 + self.objects.hram = self.bg self.animId = opts.animId -- wFXAnimID self.gfxOrder = self.constants.battleAnimGfxOrder or {} self.sfxOrder = opts.sfxOrder or {} diff --git a/src/battle/gen2/BgEffects.lua b/src/battle/gen2/BgEffects.lua index f4b6f1a2..0a5c38f1 100644 --- a/src/battle/gen2/BgEffects.lua +++ b/src/battle/gen2/BgEffects.lua @@ -948,8 +948,7 @@ end -- Surf. `.zero` lays a 2-amplitude wave into the ring and falls through to -- `.one` on the same frame (ASM fallthrough, not a jumptable branch), and --- `.one` declines to do anything at all until some OTHER effect has set --- hLCDCPointer -- START_WATER is what the Surf animation queues first. +-- `.one` waits for hLCDCPointer: engine/battle_anims/functions.asm:1158. E.BATTLE_BG_EFFECT_SURF = function(self, st) local jt = st.jt if jt == 0 then diff --git a/src/core/FixedStep.lua b/src/core/FixedStep.lua index 6a03da6a..4a40623e 100644 --- a/src/core/FixedStep.lua +++ b/src/core/FixedStep.lua @@ -22,11 +22,37 @@ local STEP_EPS = 1 / 60 * 0.02 -- warps go through Transition instead (issue #487). local RESEED_PHASE = 0.5 +FixedStep.refreshPeriod = nil + +local PHASE_PROBE = 600 + +local function phaseOffset(period, step) + local residuals, seen, accum = {}, {}, 0 + for _ = 1, PHASE_PROBE do + accum = accum + period + while accum >= step - STEP_EPS do accum = accum - step end + local key = math.floor(accum / step * 1e6 + 0.5) + if seen[key] then break end + seen[key] = true + residuals[#residuals + 1] = accum + end + if #residuals == 0 then return 0 end + table.sort(residuals) + local bestGap, bestCentre = -1, 0 + for i = 1, #residuals do + local a = residuals[i] + local b = (i < #residuals) and residuals[i + 1] or (residuals[1] + step) + if b - a > bestGap then bestGap, bestCentre = b - a, (a + b) * 0.5 end + end + return (step - bestCentre) % step +end + function FixedStep:init(callback) self.accum = 0 self.callback = callback self.suppressCatchup = false self.dtHistory, self.dtSum = nil, 0 + self.phasedFor = nil end -- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15 @@ -48,7 +74,19 @@ function FixedStep:update(dt) self.callback(self.STEP) return end - if dt > 0 and dt <= SMOOTH_MAX then + local period = self.refreshPeriod + local snapped = false + if period and dt > 0 then + local frames = math.floor(dt / period + 0.5) + if frames >= 1 and frames * period <= SMOOTH_MAX + and math.abs(frames * period - dt) < period * 0.25 then + dt = frames * period + snapped = true + end + end + if snapped then + self.dtHistory, self.dtSum = nil, 0 + elseif dt > 0 and dt <= SMOOTH_MAX then local hist = self.dtHistory if not hist then hist = {}; self.dtHistory = hist; self.dtSum = 0 end hist[#hist + 1] = dt @@ -61,6 +99,11 @@ function FixedStep:update(dt) else self.dtHistory, self.dtSum = nil, 0 end + if period and self.phasedFor ~= period then + self.phasedFor = period + local target = phaseOffset(period, self.STEP) + self.accum = self.accum - self.accum % self.STEP + target + end self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM) while self.accum >= self.STEP - STEP_EPS do self.accum = self.accum - self.STEP diff --git a/src/core/FrameCap.lua b/src/core/FrameCap.lua index 2e5b36fe..47f058d4 100644 --- a/src/core/FrameCap.lua +++ b/src/core/FrameCap.lua @@ -15,23 +15,27 @@ local FrameCap = {} -- Selectable steps: the normal framerate stops between the floor and the -- ceiling. STEPS[1] == MIN and STEPS[#STEPS] == MAX, so the nearest-step --- snap in normalize doubles as the clamp. Cycling past the last wraps. FrameCap.STEPS = { 30, 40, 50, 60, 75, 90, 100, 120, 144, 160 } FrameCap.MIN = 30 FrameCap.MAX = 160 FrameCap.DEFAULT = 60 +FrameCap.DISPLAY = 0 + +FrameCap.CYCLE = {} +for i, step in ipairs(FrameCap.STEPS) do FrameCap.CYCLE[i] = step end +FrameCap.CYCLE[#FrameCap.CYCLE + 1] = FrameCap.DISPLAY + -- The live cap the run loop paces to. Defaults so the launcher and the -- save editor are paced before any save applies its stored option. FrameCap.current = FrameCap.DEFAULT -- Nearest valid step for an arbitrary value (a hand-edited options.lua or -- an old save with no fpsCap key), so a bad number degrades to something --- sane; nil / non-numbers fall back to the default. A value below MIN or --- above MAX snaps to that end, since MIN/MAX are the first/last steps. function FrameCap.normalize(value) value = tonumber(value) if not value then return FrameCap.DEFAULT end + if value <= 0 then return FrameCap.DISPLAY end local best, bestDiff = FrameCap.DEFAULT, math.huge for _, step in ipairs(FrameCap.STEPS) do local diff = math.abs(step - value) @@ -40,21 +44,23 @@ function FrameCap.normalize(value) return best end --- plain numeric text for the options row (e.g. "60") function FrameCap.label(value) - return tostring(FrameCap.normalize(value)) + local cap = FrameCap.normalize(value) + local text = cap == FrameCap.DISPLAY and "DISPLAY" or tostring(cap) + local hz = require("src.core.RefreshRate").mismatch() + if hz then text = string.format("%s (%dHZ)", text, math.floor(hz + 0.5)) end + return text end --- cycle to the next/previous step, wrapping (the options row idiom) function FrameCap.cycle(value, dir) - local steps = FrameCap.STEPS + local ring = FrameCap.CYCLE local snapped = FrameCap.normalize(value) local cur = 1 - for i, step in ipairs(steps) do + for i, step in ipairs(ring) do if step == snapped then cur = i break end end - local nextIdx = (cur - 1 + (dir or 1)) % #steps + 1 - return steps[nextIdx] + local nextIdx = (cur - 1 + (dir or 1)) % #ring + 1 + return ring[nextIdx] end -- Store the chosen cap as the live value the run loop paces to. Never @@ -65,8 +71,25 @@ function FrameCap.apply(value) return FrameCap.current end +FrameCap.migrated = false + +FrameCap.MIGRATE_MAX_HZ = 120 + function FrameCap.applyOptions(opts) - FrameCap.apply(opts and opts.fpsCap) + local cap = opts and opts.fpsCap + if not FrameCap.migrated then + FrameCap.migrated = true + if opts and not opts.fpsCapMigrated + and FrameCap.normalize(cap) == FrameCap.DEFAULT then + local hz = require("src.core.RefreshRate").mismatch() + if hz and hz < FrameCap.MIGRATE_MAX_HZ then + cap = FrameCap.DISPLAY + opts.fpsCap = cap + opts.fpsCapMigrated = true + end + end + end + FrameCap.apply(cap) end return FrameCap diff --git a/src/core/Game.lua b/src/core/Game.lua index 6c743215..52403724 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -918,27 +918,21 @@ function Game:gamepadpressed(joystick, button) end) selectHeld = ok and down == true end - -- shoulder buttons and analog triggers cycle GAME SPEED (R1/RB or - -- R2/RT = faster, L1/LB or L2/LT = slower; same as keyboard hotkey - -- 1). LÖVE reports an analog trigger as gamepadpressed once it - -- crosses the press threshold, so a trigger pull lands here like any - -- other pad button. Skip while Select is held so Select+L can reach - -- displayChordDigit ("7"). - if not selectHeld then - if button == "rightshoulder" or button == "righttrigger" then - self:_cycleSpeed(1) - return - elseif button == "leftshoulder" or button == "lefttrigger" then - self:_cycleSpeed(-1) - return - end - end - -- BindingsMenu's pad capture rides the same top-state routing as keys local top = self.stack and self.stack:top() if top and top.onGamepadPressed then top:onGamepadPressed(button) return end + if not selectHeld then + local action = Input:padAction(button) + if action == "speedUp" then + self:_cycleSpeed(1) + return + elseif action == "speedDown" then + self:_cycleSpeed(-1) + return + end + end -- Select+face display chords → same digit path as Game:keypressed -- (COLORS/TILT/pipelines). Intercept before Input so face does not -- also fire GB A/B. Dual-path: raw already ignored when isGamepad(). @@ -1316,6 +1310,8 @@ function Game:applyOptions(opts) -- it has to be the last word on the window (it drops fullscreen to hold) require("src.core.FaithfulRes").applyOptions(opts) require("src.core.ScreenPosition").applyOptions(opts) + require("src.core.VSync").applyOptions(opts) + FixedStep.refreshPeriod = require("src.core.RefreshRate").period() -- normalizes a nil/garbage cap to the 60 default, so old saves with no -- fpsCap key pace at the standard rate (issue #88) require("src.core.FrameCap").applyOptions(opts) @@ -1334,7 +1330,10 @@ function Game:applyOptions(opts) if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end if caps.fpsMax then local FrameCap = require("src.core.FrameCap") - if FrameCap.current > caps.fpsMax then FrameCap.apply(caps.fpsMax) end + if FrameCap.current == FrameCap.DISPLAY + or FrameCap.current > caps.fpsMax then + FrameCap.apply(caps.fpsMax) + end end Input:applyBindings(opts.bindings) TouchControls:applyOptions(opts) diff --git a/src/core/Game2.lua b/src/core/Game2.lua index c59ac7ec..b4d3d095 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -1628,20 +1628,25 @@ local function panelBlit(stack, w, h) 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) - local states = self.stack and self.stack.states or {} - local mode +local function battleSurround(stack) + local states = stack and stack.states or {} for i = #states, 1, -1 do local state = states[i] - if state and state.bgMode then mode = state:bgMode() break end + if state and state.bgMode then + return state:bgMode(), state.BG_WORLD_DIM or 0.55 + end end - if mode ~= "black" then return end +end + +function Game2:paintBattleSurround(w, h) + local mode, dim = battleSurround(self.stack) + if mode ~= "black" and mode ~= "world" then return end + local alpha = mode == "world" and dim or 1 + if not alpha or alpha <= 0 then return end local G = love.graphics local scale, ox, oy = panelBlit(self.stack, w, h) local pw, ph = 160 * scale, 144 * scale - G.setColor(0, 0, 0, 1) + G.setColor(0, 0, 0, alpha) if oy > 0 then G.rectangle("fill", 0, 0, w, oy) end if oy + ph < h then G.rectangle("fill", 0, oy + ph, w, h - oy - ph) end if ox > 0 then G.rectangle("fill", 0, oy, ox, ph) end @@ -1654,6 +1659,7 @@ function Game2:drawScene(w, h) -- render.compose reads this after the scene is drawn; the plain overworld -- branch below is the only one where Gen 1 would call the world pass live. self.frameWorldActive = false + Chrome.worldSurround = false if self:inFillBoot() then local top = self.stack:top() @@ -1717,8 +1723,15 @@ function Game2:drawScene(w, h) or (base and base.drawsWidescreen and base:drawsWidescreen() and base.drawWidescreen and base) if wide then + if battleSurround(self.stack) == "world" then + self.frameWorldActive = true + self:letterbox(w, h, true) + self.world:draw() + Chrome.worldSurround = true + end wide:drawWidescreen(w, h) self:paintBattleSurround(w, h) + Chrome.worldSurround = false self:letterbox(w, h, false) if wide ~= top then local scale, ox, oy = panelBlit(self.stack, w, h) @@ -2113,9 +2126,13 @@ function Game2:applyOptions() TouchControls:applyOptions({ touchControls = options.touchControls, haptics = options.haptics, + hotbar = options.hotbar, }) require("src.core.VideoMode").applyOptions(options) require("src.core.ScreenPosition").applyOptions(options) + require("src.core.VSync").applyOptions(options) + require("src.core.FixedStep").refreshPeriod = + require("src.core.RefreshRate").period() require("src.core.FrameCap").applyOptions(options) require("src.world.gen2.BorderFill").applyOptions(options) -- returns true when a persisted preset name no longer resolves (deleted @@ -2134,7 +2151,10 @@ function Game2:applyOptions() if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end if caps.fpsMax then local FrameCap = require("src.core.FrameCap") - if FrameCap.current > caps.fpsMax then FrameCap.apply(caps.fpsMax) end + if FrameCap.current == FrameCap.DISPLAY + or FrameCap.current > caps.fpsMax then + FrameCap.apply(caps.fpsMax) + end end if shaderfxCleared and self.save then -- applyOptions returns true when it had to clear an unresolved preset. @@ -2170,21 +2190,21 @@ function Game2:gamepadpressed(joystick, button) end) selectHeld = ok and down == true end - -- shoulders and triggers cycle GAME SPEED, as in src/core/Game.lua:881 - if not selectHeld then - if button == "rightshoulder" or button == "righttrigger" then - self:_cycleSpeed(1) - return - elseif button == "leftshoulder" or button == "lefttrigger" then - self:_cycleSpeed(-1) - return - end - end local top = self.stack and self.stack:top() if top and top.onGamepadPressed then top:onGamepadPressed(button) return end + if not selectHeld then + local action = Input:padAction(button) + if action == "speedUp" then + self:_cycleSpeed(1) + return + elseif action == "speedDown" then + self:_cycleSpeed(-1) + return + end + end if selectHeld then local digit = GamepadMap.displayChordDigit(button) if digit then diff --git a/src/core/GamepadMap.lua b/src/core/GamepadMap.lua index a7a75604..2fceeb68 100644 --- a/src/core/GamepadMap.lua +++ b/src/core/GamepadMap.lua @@ -10,6 +10,11 @@ GamepadMap.DEFAULT_GAMEPAD_BINDINGS = { start = "start", back = "select", } +GamepadMap.DEFAULT_PAD_ACTIONS = { + rightshoulder = "speedUp", righttrigger = "speedUp", + leftshoulder = "speedDown", lefttrigger = "speedDown", +} + -- Switch: LÖVE/SDL labels south as "a" and east as "b", but Nintendo UX is -- physical A (east) = confirm (GB A), physical B (south) = cancel (GB B). GamepadMap.NX_GAMEPAD_BINDINGS = { diff --git a/src/core/Input.lua b/src/core/Input.lua index 3add0629..64633e23 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -52,6 +52,8 @@ local STICK_OFF = 0.3 -- "select". Both roads reach SELECT; the one road that did not was Gold's, -- where src/core/Game2.lua used to answer `back` with love.event.quit(). +Input.PAD_ACTIONS = { speedUp = true, speedDown = true } + local HAT_DIRECTIONS = { u = { "up" }, d = { "down" }, l = { "left" }, r = { "right" }, lu = { "left", "up" }, ru = { "right", "up" }, @@ -82,8 +84,19 @@ function Input:applyBindings(overlay) for index, action in pairs(GamepadMap.rawBindings()) do joys[index] = action end + local acts = {} + for button, action in pairs(GamepadMap.DEFAULT_PAD_ACTIONS) do + acts[button] = action + end for actionId, binding in pairs(overlay or {}) do - if type(binding) == "table" then + if Input.PAD_ACTIONS[actionId] then + for button, action in pairs(acts) do + if action == actionId then acts[button] = nil end + end + if type(binding) == "table" and binding.pad then + acts[binding.pad] = actionId + end + elseif type(binding) == "table" then if binding.key then keys[binding.key] = actionId end if binding.pad then pads[binding.pad] = actionId end elseif type(binding) == "string" then @@ -104,6 +117,11 @@ function Input:applyBindings(overlay) self.keyBindings = keys self.padBindings = pads self.joyBindings = joys + self.padActions = acts +end + +function Input:padAction(button) + return self.padActions and self.padActions[button] or nil end -- Purely event-driven state (press sets true, release sets false) has no diff --git a/src/core/LaunchOptions.lua b/src/core/LaunchOptions.lua index 18a692b2..9939d556 100644 --- a/src/core/LaunchOptions.lua +++ b/src/core/LaunchOptions.lua @@ -64,7 +64,8 @@ end local function argFlag(argv, name) if type(argv) ~= "table" then return false end for i = 1, #argv do - if argv[i] == "--" .. name then return true end + local a = argv[i] + if a == "--" .. name or a == "-" .. name then return true end end return false end @@ -100,6 +101,24 @@ function LaunchOptions.forceLauncher(argv) return argFlag(argv, "launcher") or os.getenv("POKEPORT_FORCE_LAUNCHER") == "1" end +local function taskFlag(argv, rawArgv, name, env) + if argFlag(argv, "no-" .. name) or argFlag(rawArgv, "no-" .. name) then + return false + end + if argFlag(argv, name) or argFlag(rawArgv, name) then return true end + local v = os.getenv(env) + if v == "1" then return true end + if v == "0" then return false end + return nil +end + +function LaunchOptions.tasks(argv, rawArgv) + return { + sync = taskFlag(argv, rawArgv, "sync", "POKEPORT_LAUNCH_SYNC"), + update = taskFlag(argv, rawArgv, "update", "POKEPORT_LAUNCH_UPDATE") == true, + } +end + -- Point a version at a save slot before it boots. Accepts either a slot id -- ("slot2") or a 1-based index ("2"), because a shortcut author should not -- have to know the internal id scheme. A slot that does not exist is @@ -127,9 +146,13 @@ end -- The shortcut command a player would use for this game, for the launcher to -- show and for docs to quote. -function LaunchOptions.commandFor(version, slot) +function LaunchOptions.commandFor(version, slot, tasks) local cmd = "--game " .. tostring(version) if slot then cmd = cmd .. " --slot " .. tostring(slot) end + if type(tasks) == "table" then + if tasks.update then cmd = cmd .. " --update" end + if tasks.sync == false then cmd = cmd .. " --no-sync" end + end return cmd end diff --git a/src/core/Prelaunch.lua b/src/core/Prelaunch.lua new file mode 100644 index 00000000..ddede212 --- /dev/null +++ b/src/core/Prelaunch.lua @@ -0,0 +1,193 @@ +local Prelaunch = {} +Prelaunch.__index = Prelaunch + +Prelaunch.UPDATE_BUDGET = 25 +Prelaunch.SYNC_BUDGET = 30 +Prelaunch.MARKER = "launch_update.txt" + +local function loveFs() + local l = rawget(_G, "love") + return l and l.filesystem or nil +end + +local function consumeMarker(fs) + if not (fs and fs.getInfo) then return false end + local ok, info = pcall(fs.getInfo, Prelaunch.MARKER) + if not ok or not info then return false end + pcall(fs.remove, Prelaunch.MARKER) + return true +end + +local function updateAllowed(opts, fs) + if opts.allowUpdate ~= nil then return opts.allowUpdate and true or false end + local ok, Version = pcall(require, "src.core.Version") + if ok and type(Version) == "table" and Version.isDev and Version.isDev() then + return false + end + local okp, Platform = pcall(require, "src.core.Platform") + if okp and type(Platform) == "table" and Platform.networkValidated + and not Platform.networkValidated() then + return false + end + if not (fs and fs.isFused and fs.isFused()) then return false end + return true +end + +local function syncSupported() + local ok, HostShell = pcall(require, "src.core.HostShell") + if not ok or type(HostShell) ~= "table" + or type(HostShell.canHttpRequest) ~= "function" then + return true + end + local asked, can = pcall(HostShell.canHttpRequest) + return (not asked) or (can and true or false) +end + +local function sharedEngine(opts) + if opts.engine ~= nil then return opts.engine or nil end + if not syncSupported() then return nil end + local ok, SyncEngine = pcall(require, "src.sync.SyncEngine") + if not ok or type(SyncEngine) ~= "table" then return nil end + local got, eng = pcall(SyncEngine.shared) + return (got and type(eng) == "table") and eng or nil +end + +local function engineWanted(eng) + if type(eng) ~= "table" then return false end + if not (eng.state and eng.state.enabled) then return false end + local ok, linked = pcall(eng.linked, eng) + return (ok and linked) and true or false +end + +function Prelaunch.new(opts) + opts = opts or {} + local self = setmetatable({}, Prelaunch) + self.version = opts.version + self.done = opts.done + self.fs = opts.fs + if self.fs == nil then self.fs = loveFs() end + if self.fs == false then self.fs = nil end + self.restart = opts.restart or function() + require("src.core.HostShell").restart() + end + + local tasks = opts.tasks or {} + local stages = {} + local resumed = consumeMarker(self.fs) + if tasks.update and not resumed and updateAllowed(opts, self.fs) then + local ok, check = pcall(function() + return opts.check or require("src.update.Check") + end) + if ok and type(check) == "table" then + self.check = check + stages[#stages + 1] = "update" + end + end + if tasks.sync ~= false then + local eng = sharedEngine(opts) + if engineWanted(eng) then + self.engine = eng + stages[#stages + 1] = "sync" + end + end + if #stages == 0 then return nil end + + self.stages = stages + self.index = 0 + self.elapsed = 0 + self.status = "" + self:_advance() + return self +end + +function Prelaunch:_finish(outcome) + if self.finished then return end + self.finished = true + self.outcome = outcome + if self.done then self.done(outcome) end +end + +function Prelaunch:_advance() + self.index = (self.index or 0) + 1 + self.elapsed = 0 + self.stage = self.stages[self.index] + if self.stage == nil then return self:_finish("boot") end + if self.stage == "update" then + self.status = "Checking for updates..." + pcall(self.check.start, true) + elseif self.stage == "sync" then + self.status = "Syncing saves..." + pcall(self.engine.syncNow, self.engine) + end +end + +function Prelaunch:_updateStage() + local ok, st = pcall(self.check.state) + st = (ok and type(st) == "table") and st or nil + local status = st and st.status or "error" + if status == "available" then + self.status = "Downloading update..." + pcall(self.check.download) + elseif status == "downloading" then + self.status = string.format("Updating %d%%", + math.floor((tonumber(st.progress) or 0) * 100)) + elseif status == "ready" then + if self.fs and self.fs.write then + pcall(self.fs.write, Prelaunch.MARKER, "1") + end + self.status = "Restarting..." + pcall(self.restart) + return self:_finish("restart") + elseif status ~= "checking" then + return self:_advance() + end + if self.elapsed >= Prelaunch.UPDATE_BUDGET then return self:_advance() end +end + +function Prelaunch:_syncStage(dt) + local eng = self.engine + pcall(eng.update, eng, dt) + if type(eng.status) == "string" and eng.status ~= "" then + self.status = eng.status + end + if eng.phase == "conflict" then return self:_finish("launcher") end + if eng.phase == "idle" or eng.phase == "error" then + return self:_advance() + end + if self.elapsed >= Prelaunch.SYNC_BUDGET then return self:_advance() end +end + +function Prelaunch:update(dt) + if self.finished then return end + dt = tonumber(dt) or 0 + self.elapsed = (self.elapsed or 0) + dt + if self.stage == "update" then + self:_updateStage() + elseif self.stage == "sync" then + self:_syncStage(dt) + end +end + +function Prelaunch:cancel() + self:_finish("boot") +end + +function Prelaunch:keypressed() + self:cancel() +end + +function Prelaunch:draw() + local l = rawget(_G, "love") + local g = l and l.graphics + if not (g and g.printf) then return end + g.clear(0, 0, 0, 1) + g.setColor(1, 1, 1, 1) + local w = g.getWidth and g.getWidth() or 0 + local h = g.getHeight and g.getHeight() or 0 + g.printf(tostring(self.status or ""), 0, math.floor(h / 2) - 12, w, "center") + g.setColor(1, 1, 1, 0.55) + g.printf("Press any button to skip", 0, math.floor(h / 2) + 8, w, "center") + g.setColor(1, 1, 1, 1) +end + +return Prelaunch diff --git a/src/core/RefreshRate.lua b/src/core/RefreshRate.lua new file mode 100644 index 00000000..ac795369 --- /dev/null +++ b/src/core/RefreshRate.lua @@ -0,0 +1,116 @@ +local RefreshRate = {} + +local DECLS = { + "typedef struct SDL_Window SDL_Window;", + "SDL_Window *SDL_GL_GetCurrentWindow(void);", + "typedef struct { uint32_t format; int w; int h; int refresh_rate;" .. + " void *driverdata; } SDL_DisplayMode;", + "int SDL_GetWindowDisplayIndex(SDL_Window *window);", + "int SDL_GetCurrentDisplayMode(int displayIndex, SDL_DisplayMode *mode);", +} + +local ffiState = nil + +local function sdlFfi() + if ffiState ~= nil then return ffiState or nil end + if not (love and love.window) then return nil end + local okFfi, ffi = pcall(require, "ffi") + if not okFfi then ffiState = false return nil end + for _, decl in ipairs(DECLS) do pcall(ffi.cdef, decl) end + local ok = pcall(ffi.new, "SDL_DisplayMode") + ffiState = ok and ffi or false + return ffiState or nil +end + +local function sdlHz() + local ffi = sdlFfi() + if not ffi then return nil end + local ok, hz = pcall(function() + local index = 0 + local window = ffi.C.SDL_GL_GetCurrentWindow() + if window ~= nil then + local got = ffi.C.SDL_GetWindowDisplayIndex(window) + if got >= 0 then index = got end + end + local mode = ffi.new("SDL_DisplayMode") + if ffi.C.SDL_GetCurrentDisplayMode(index, mode) ~= 0 then return nil end + return tonumber(mode.refresh_rate) + end) + if not ok or not hz or hz <= 0 then return nil end + return hz +end + +local SAMPLES = 31 +local ring, cursor, filled = {}, 0, 0 +local measured = nil + +local function quantize(hz) + if not hz or hz <= 0 then return nil end + local whole = math.floor(hz + 0.5) + local nearest = math.floor(whole / 60 + 0.5) * 60 + if nearest > 0 and math.abs(whole - nearest) <= 1 then return nearest end + return whole +end + +local function median(values) + local sorted = {} + for i = 1, #values do sorted[i] = values[i] end + table.sort(sorted) + local mid = sorted[math.floor(#sorted / 2) + 1] + local lo = sorted[math.floor(#sorted * 0.25) + 1] + local hi = sorted[math.floor(#sorted * 0.75) + 1] + if not mid or mid <= 0 or (hi - lo) > mid * 0.1 then return nil end + return mid +end + +function RefreshRate.sample(dt) + if type(dt) ~= "number" or dt <= 0 or dt > 0.2 then return false end + cursor = cursor % SAMPLES + 1 + ring[cursor] = dt + filled = math.min(filled + 1, SAMPLES) + if filled < SAMPLES or cursor ~= SAMPLES then return false end + local period = median(ring) + if not period then return false end + local hz = quantize(1 / period) + if not hz or measured == hz then return false end + measured = hz + return true +end + +local queried, queriedAt = nil, nil + +local function now() + if love and love.timer and love.timer.getTime then return love.timer.getTime() end + return nil +end + +function RefreshRate.hz() + local time = now() + if queried == nil or not time or not queriedAt or time - queriedAt > 1 then + queried = sdlHz() or false + queriedAt = time or 0 + end + local hz = quantize(queried or measured) + if not hz or hz <= 20 or hz >= 1000 then return nil end + return hz +end + +function RefreshRate.period() + local hz = RefreshRate.hz() + return hz and 1 / hz or nil +end + +function RefreshRate.mismatch() + local hz = RefreshRate.hz() + if not hz then return nil end + local nearest = math.floor(hz / 60 + 0.5) * 60 + if math.abs(hz - nearest) <= 1 then return nil end + return hz +end + +function RefreshRate.reset() + ffiState, queried, queriedAt, measured = nil, nil, nil, nil + ring, cursor, filled = {}, 0, 0 +end + +return RefreshRate diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index bc198403..61551f94 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -386,6 +386,7 @@ function SaveData.defaultOptions() -- Inert wherever the overlay never appears (desktop) or LOVE has no -- vibrator. haptics = "light", + hotbar = true, -- Shared UI/mod timestamp presentation. DEVICE follows the process time -- locale where the platform exposes one; otherwise DateTime falls back to -- DD-MM-YYYY and 24-hour time. Kept in options.lua so checkpoints never diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index 7e4e7b86..0595bef7 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -1,5 +1,3 @@ --- On-screen touch controls: a visible d-pad, A, B, START and SELECT drawn --- over the finished frame (art: Xelu's CC0 controller prompts, see -- assets/touch/README.md). Replaces the old touch gesture recognizer: -- every control is a real button under the thumb, so there is no -- tap-vs-swipe classification, no deferred-A double-tap window, and no @@ -59,9 +57,19 @@ local DPAD_DEAD = 0.16 -- multiplier on the control's half-width. START/SELECT get more because -- the glyphs are small. local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 } +local HOTBAR_SLOP = 1.4 local BUTTONS = { "a", "b", "start", "select" } -local CONTROLS = { "dpad", "a", "b", "start", "select" } +local CONTROLS = { "dpad", "a", "b", "start", "select", "hotbar" } + +local HOTBAR = { + { spec = "key:f1", label = "SAVE" }, + { spec = "key:f2", label = "LOAD" }, + { spec = "key:1", label = "SPEED" }, + { spec = "key:2", label = "COLOR" }, + { spec = "key:3", label = "TILT" }, + { spec = "key:4", label = "ZOOM" }, +} -- Per-orientation layout buckets (#633). Orientation comes from the safe -- rect, not the device: sw > sh is landscape, so a resized desktop window @@ -223,6 +231,7 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale) b = { cx = ox + ww - margin - abW * 1.60, cy = oy + wh - margin - abW * 0.55, w = abW }, start = { cx = ox + ww / 2 + ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW }, select = { cx = ox + ww / 2 - ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW }, + hotbar = { cx = ox + ww - margin - ssW * 0.70, cy = oy + margin + ssW * 0.70, w = ssW }, } end @@ -263,6 +272,9 @@ function TouchControls:init() self.skinId = nil self.skinError = nil self.hotkeysHeld = {} + self.hotbarEnabled = true + self.hotbarOpen = false + self.hotbarControls = nil TouchSkin.setActive(nil) self.img = nil -- Images load whenever the platform wants the overlay OR the launcher @@ -288,6 +300,8 @@ function TouchControls:applyOptions(opts) -- haptics is a plain top-level option, not part of the layout config the -- launcher editor round-trips through config() (#806) self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics) + self.hotbarEnabled = not (opts and opts.hotbar == false) + if not self.hotbarEnabled then self.hotbarOpen = false end TouchSkin.setOverlayLive(self.active) -- Off means off everywhere: do not leave a hidden selected skin behind to -- influence renderer placement on desktop or with a controller attached. @@ -503,6 +517,60 @@ local function inCircle(zone, x, y, slop) return dx * dx + dy * dy <= r * r end +function TouchControls:hotbarItems() + if self.hotbarControls then return self.hotbarControls end + local out = {} + for i, entry in ipairs(HOTBAR) do + local ctl = TouchSkin.newControl(entry.spec, 0, 0, 0, 0, "rect") + ctl.label = entry.label + out[i] = ctl + end + self.hotbarControls = out + return out +end + +function TouchControls:hotbarShown() + return self.hotbarEnabled ~= false and not TouchSkin.active +end + +function TouchControls:hotbarStrip() + local L = self:layout() + local zone = L and L.hotbar + local items = self:hotbarItems() + if not zone or #items == 0 then return nil end + local ox, oy, sw, sh = SafeArea.windowRect() + local h = zone.w * 0.95 + local pad = h * 0.14 + local cw = h * 1.9 + if self.labelFont then + for _, ctl in ipairs(items) do + cw = math.max(cw, self.labelFont:getWidth(ctl.label) + pad * 3) + end + end + local total = math.min(sw - pad * 2, cw * #items) + cw = total / #items + local x0 = math.max(ox + pad, + math.min(zone.cx - total, ox + sw - pad - total)) + local y0 = math.min(zone.cy + zone.w * 0.80, oy + sh - h) + local cells = {} + for i, ctl in ipairs(items) do + cells[i] = { ctl = ctl, label = ctl.label, h = h, + x = x0 + (i - 1) * cw, y = y0, w = cw - pad } + end + return cells +end + +function TouchControls:hotbarCellAt(x, y) + if not (self.hotbarOpen and self:hotbarShown()) then return nil end + for _, cell in ipairs(self:hotbarStrip() or {}) do + if x >= cell.x and x <= cell.x + cell.w + and y >= cell.y and y <= cell.y + cell.h then + return cell + end + end + return nil +end + -- Which control (if any) contains (x, y). Prefer face buttons over the -- d-pad when they overlap, matching touchpressed's order. function TouchControls:hitTest(x, y) @@ -516,6 +584,9 @@ function TouchControls:hitTest(x, y) return nil end local L = self:layout() + if self:hotbarShown() and inCircle(L.hotbar, x, y, HOTBAR_SLOP) then + return "hotbar" + end for _, btn in ipairs(BUTTONS) do if inCircle(L[btn], x, y, SLOP[btn]) then return btn end end @@ -676,6 +747,20 @@ function TouchControls:touchpressed(id, x, y) return true end local L = self:layout() + if self:hotbarShown() then + local cell = self:hotbarCellAt(x, y) + if cell then + self.touches[id] = { control = "hotkey", ctl = cell.ctl } + enterControl(self, cell.ctl) + return true + end + if inCircle(L.hotbar, x, y, HOTBAR_SLOP) then + self.hotbarOpen = not self.hotbarOpen + self.touches[id] = { control = "hotbarToggle" } + TouchControls.buzz(self.haptics) + return true + end + end for _, btn in ipairs(BUTTONS) do if inCircle(L[btn], x, y, SLOP[btn]) then self.touches[id] = { control = btn } @@ -717,6 +802,9 @@ function TouchControls:touchreleased(id, x, y) self.touches[id] = nil if touch.control == "skin" then applySkinSet(self, touch, nil) + elseif touch.control == "hotkey" then + if touch.ctl then exitControl(self, touch.ctl) end + elseif touch.control == "hotbarToggle" then elseif touch.control == "dpad" then setDpad(self, touch, nil) self.dpadTouch = nil @@ -733,6 +821,8 @@ function TouchControls:reset() for _, touch in pairs(self.touches or {}) do if touch.control == "skin" and touch.set then for ctl in pairs(touch.set) do exitControl(self, ctl) end + elseif touch.control == "hotkey" and touch.ctl then + exitControl(self, touch.ctl) end end for action in pairs(self.hotkeysHeld or {}) do @@ -745,6 +835,7 @@ function TouchControls:reset() self.held = {} self.touches = {} self.dpadTouch = nil + self.hotbarOpen = false end -- a gamepad is being used: hide the overlay (dropping anything it held) @@ -859,6 +950,48 @@ function TouchControls:draw() label("START", L.start) label("SELECT", L.select) + if self:hotbarShown() then + local z = L.hotbar + local r = z.w * 0.5 + love.graphics.setColor(0.15, 0.15, 0.15, 0.55 * alphaMul) + love.graphics.circle("fill", z.cx, z.cy, r * 1.16) + love.graphics.setColor(1, 1, 1, (ALPHA + 0.2) * alphaMul) + local dot = r * 0.16 + for i = -1, 1 do + love.graphics.circle("fill", z.cx + i * r * 0.5, z.cy, dot) + end + if self.hotbarOpen and not self.preview then + local pressed = {} + for _, touch in pairs(self.touches or {}) do + if touch.control == "hotkey" and touch.ctl then pressed[touch.ctl] = true end + end + local cells = self:hotbarStrip() or {} + local first, last = cells[1], cells[#cells] + if first and last then + local pad = first.h * 0.14 + love.graphics.setColor(0.15, 0.15, 0.15, 0.55 * alphaMul) + love.graphics.rectangle("fill", first.x - pad, first.y - pad, + last.x + last.w - first.x + pad * 2, + first.h + pad * 2, first.h * 0.3) + end + for _, cell in ipairs(cells) do + local down = pressed[cell.ctl] == true + love.graphics.setColor(0.3, 0.3, 0.3, + (down and 0.85 or 0.6) * alphaMul) + love.graphics.rectangle("fill", cell.x, cell.y, cell.w, cell.h, + cell.h * 0.25) + local tw = self.labelFont:getWidth(cell.label) + local ty = cell.y + (cell.h - self.labelFont:getHeight()) * 0.5 + love.graphics.setColor(0, 0, 0, 0.6 * alphaMul) + love.graphics.print(cell.label, + cell.x + (cell.w - tw) * 0.5 + 1, ty + 1) + love.graphics.setColor(1, 1, 1, + (down and ALPHA_PRESSED or (ALPHA + 0.2)) * alphaMul) + love.graphics.print(cell.label, cell.x + (cell.w - tw) * 0.5, ty) + end + end + end + love.graphics.pop() end diff --git a/src/core/VSync.lua b/src/core/VSync.lua new file mode 100644 index 00000000..834884ae --- /dev/null +++ b/src/core/VSync.lua @@ -0,0 +1,78 @@ +local VSync = {} + +VSync.MODES = { "on", "off", "adaptive" } + +local INTERVAL = { on = 1, off = 0, adaptive = -1 } +local LABELS = { on = "ON", off = "OFF", adaptive = "ADAPTIVE" } + +local boot = nil +local live = nil + +local function fromInterval(interval) + interval = tonumber(interval) + if not interval then return nil end + if interval < 0 then return "adaptive" end + if interval == 0 then return "off" end + return "on" +end + +local function query() + if not (love and love.window and love.window.getVSync) then return nil end + local ok, interval = pcall(love.window.getVSync) + if not ok then return nil end + return fromInterval(interval) +end + +function VSync.default() + if not boot then boot = query() or "on" end + return boot +end + +function VSync.normalize(mode) + for _, m in ipairs(VSync.MODES) do + if mode == m then return m end + end + return VSync.default() +end + +function VSync.label(mode) + return LABELS[VSync.normalize(mode)] or LABELS.on +end + +function VSync.cycle(mode, dir) + local cur = 1 + local normalized = VSync.normalize(mode) + for i, m in ipairs(VSync.MODES) do + if m == normalized then cur = i break end + end + return VSync.MODES[(cur - 1 + (dir or 1)) % #VSync.MODES + 1] +end + +function VSync.apply(mode) + mode = VSync.normalize(mode) + live = mode + if not (love and love.window and love.window.setVSync) then return mode end + local ok = pcall(love.window.setVSync, INTERVAL[mode]) + if not ok and mode == "adaptive" then + pcall(love.window.setVSync, INTERVAL.on) + live = "on" + end + local got = query() + if got then live = got end + return mode +end + +function VSync.applyOptions(opts) + VSync.apply(opts and opts.vsync) +end + +function VSync.isOn() + if not live then live = query() or VSync.default() end + return live ~= "off" +end + +function VSync.reset() + boot, live = nil, nil +end + +return VSync diff --git a/src/core/gen2/Nests.lua b/src/core/gen2/Nests.lua index 9b1399d7..eccd41e8 100644 --- a/src/core/gen2/Nests.lua +++ b/src/core/gen2/Nests.lua @@ -22,14 +22,25 @@ -- spelling. local Nests = {} --- constants/landmark_constants.asm. LANDMARK_SPECIAL is 0 and never a nest. +-- ../pokecrystal/constants/landmark_constants.asm:34 Nests.LANDMARK_PALLET_TOWN = 0x2e Nests.LANDMARK_FAST_SHIP = 0x5e -function Nests.regionOf(landmark) +local function landmarkIndexOf(data, id, fallback) + local landmarks = data and data.gen2Landmarks + local records = landmarks and landmarks.landmarks + local record = records and records["LANDMARK_" .. id] + local index = type(record) == "table" and tonumber(record.index) + return index or fallback +end + +function Nests.regionOf(landmark, data) if not landmark or landmark <= 0 then return nil end - if landmark >= Nests.LANDMARK_FAST_SHIP then return nil end - return (landmark < Nests.LANDMARK_PALLET_TOWN) and "johto" or "kanto" + if landmark >= landmarkIndexOf(data, "FAST_SHIP", Nests.LANDMARK_FAST_SHIP) then + return nil + end + local kanto = landmarkIndexOf(data, "PALLET_TOWN", Nests.LANDMARK_PALLET_TOWN) + return (landmark < kanto) and "johto" or "kanto" end -- ---------------------------------------------------------- the landmarks @@ -124,7 +135,7 @@ function Nests.find(data, species, region, save) local out, seen = {}, {} local function add(landmark) if not landmark or landmark <= 0 or seen[landmark] then return end - local where = Nests.regionOf(landmark) + local where = Nests.regionOf(landmark, data) if not where then return end if region and where ~= region then return end seen[landmark] = true diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index 6341f8ac..37c8ad61 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -314,7 +314,6 @@ Save.DEFAULT_OPTIONS = { 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 -- border block with the dissolve across a boundary (#1418). diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index 29224bbc..f6c75b9e 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -10,7 +10,7 @@ local CacheContract = {} CacheContract.FORMAT = "rom-cache-v10:" CacheContract.VERSION_FORMAT = { - crystal = "rom-cache-v10-crystal3:", + crystal = "rom-cache-v10-crystal4:", } CacheContract.MARKER_PATH = "rom-cache.complete" diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 45f8a400..28d3ebbf 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -106,6 +106,12 @@ local function addTouchRows(rows, add, opts, hooks) return true end) end + add(Strings("KEY BAR"), + function() return opts.hotbar == false and Strings("OFF") or Strings("ON") end, + function() + opts.hotbar = opts.hotbar == false + return true + end) end -- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a @@ -678,9 +684,9 @@ local function gen2Rows(opts, hooks, shared) 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")) + { { "white", "WHITE" }, { "black", "BLACK" }, { "world", "WORLD" } }, + "white")) addTouchRows(rows, add, shared, hooks) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index d46252dd..d7347498 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -2409,6 +2409,48 @@ local function modGameCheckbox(x, y, size, checked, game, id, enabled) return enabled and (Kit.press(x, y, size, size) or Kit._activateId == id) end +local function modsWithUpdates(imp) + local names = {} + for _, m in ipairs(imp.mods or {}) do + local info = imp._modUpdateInfo and imp:_modUpdateInfo(m.id) + if info and info.status == "available" and info.best then + names[#names + 1] = m.name or m.id + end + end + return names +end + +local function modsWithUpdatesCount(imp) + local mods = imp.mods or {} + local rev = imp._modUpdateRev or 0 + local cache = imp._modUpdateCountCache + if cache and cache.src == mods and cache.n == #mods and cache.rev == rev then + return cache.count + end + local n = 0 + for _, m in ipairs(mods) do + local info = imp._modUpdateInfo and imp:_modUpdateInfo(m.id) + if info and info.status == "available" and info.best then n = n + 1 end + end + imp._modUpdateCountCache = { src = mods, n = #mods, rev = rev, count = n } + return n +end + +local function askUpdateAllMods(imp) + local names = modsWithUpdates(imp) + if #names == 0 then + imp:pressUpdateAllMods() + return + end + local lines = { Strings("Update %d mods?", #names) } + for i = 1, math.min(3, #names) do lines[#lines + 1] = names[i] end + if #names > 3 then + lines[#lines + 1] = Strings("and %d more", #names - 3) + end + imp._modConfirm = { kind = "updateAll", title = Strings("Update all mods"), + yesLabel = Strings("Update all"), lines = lines } +end + local function buildModsPanel(imp, x, y, w, availH, m) imp:_ensureMods() local ModUpdate = require("src.mods.ModUpdate") @@ -2433,10 +2475,12 @@ local function buildModsPanel(imp, x, y, w, availH, m) local enableW = Kit.textWidth("small", Strings("Enable all")) + math.floor(20 * m.s) local checkFullW = Kit.textWidth("small", Strings("Check for updates")) + math.floor(20 * m.s) local checkShortW = Kit.textWidth("small", Strings("Updates")) + math.floor(20 * m.s) + local updateAllW = Kit.textWidth("small", Strings("Update all")) + math.floor(20 * m.s) local sortW = Kit.textWidth("small", Strings("Sort")) + math.floor(24 * m.s) local moreW = Kit.textWidth("small", Strings("More...")) + math.floor(20 * m.s) - local fullReq = importW + disableW + enableW + checkFullW + sortW + math.floor(30 * m.s) + local fullReq = importW + disableW + enableW + checkFullW + updateAllW + + sortW + math.floor(36 * m.s) local medReq = importW + checkShortW + sortW + moreW + math.floor(24 * m.s) local place = Layout.rightCluster(x, w, math.floor(6 * m.s)) @@ -2457,6 +2501,11 @@ local function buildModsPanel(imp, x, y, w, availH, m) btn(imp, place(checkFullW), cy, checkFullW, bh, "mods-check-updates", Strings("Check for updates"), { font = "small", action = function() imp:_syncModUpdateInfo(true) end }) + btn(imp, place(updateAllW), cy, updateAllW, bh, "mods-update-all", + Strings("Update all"), { + kind = (modsWithUpdatesCount(imp) > 0) and "warn" or "ghost", + font = "small", enabled = bulkOk and imp._updateAll == nil, + action = function() askUpdateAllMods(imp) end }) btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), { font = "small", action = function() imp._sortPopup = "mods" end }) @@ -2511,6 +2560,12 @@ local function buildModsPanel(imp, x, y, w, availH, m) end cy = cy + Kit.textWrapped("small", noticeText, x, cy, w, noticeCol, 2) + math.floor(8 * m.s) + if not safeMode and imp.modNotice then + for _, line in ipairs(imp.modNotice.failures or {}) do + cy = cy + Kit.textWrapped("small", line, x, cy, w, PAL.red, 2) + + math.floor(2 * m.s) + end + end cy = cy + buildModScopeRow(imp, x, cy, w, m) cy = cy + buildModCartRow(imp, x, cy, w, m, cartId, cartReport) @@ -3180,10 +3235,8 @@ local function buildFindPanel(imp, x, y, w, availH, m) btn(imp, place(sw), cy, sw, fieldH, "find-sort", Strings("Sort"), { font = "small", action = function() imp._sortPopup = "find" end }) - -- The Filter button carries its state: blue while a category (mods) or a - -- base game (carts) is active, so a filtered-down list never reads as "the - -- index shrank". - local activeFilter = carts and imp.findBase or imp.findCategory + local activeFilter = (carts and imp.findBase or imp.findCategory) + or imp.findGame local fw = Kit.textWidth("small", Strings("Filter")) + math.floor(20 * m.s) btn(imp, place(fw), cy, fw, fieldH, "find-filter", Strings("Filter"), { kind = activeFilter and "accent" or "ghost", font = "small", @@ -3318,8 +3371,16 @@ local function buildFindPanel(imp, x, y, w, availH, m) local bx = px + thumb + math.floor(10 * m.s) local bw = inner - thumb - math.floor(10 * m.s) - chipsW - Kit.text("button", Kit.ellipsize("button", entry.title or entry.id, bw), - bx, ly, PAL.heading) + local targets = (not carts) and ModIndex.targetLabel(entry) or nil + local targetsW = targets + and Kit.textWidth("micro", targets) + math.floor(12 * m.s) or 0 + local titleShown = Kit.ellipsize("button", entry.title or entry.id, + bw - targetsW - (targets and math.floor(8 * m.s) or 0)) + Kit.text("button", titleShown, bx, ly, PAL.heading) + if targets then + Kit.tag(bx + Kit.textWidth("button", titleShown) + math.floor(8 * m.s), + ly, targetsW, Kit.textHeight("button"), targets, PAL.blue) + end local by2 = ly + Kit.textHeight("button") + math.floor(4 * m.s) -- meta and stats on one line, the download count first (and green) -- because it is what the default Most-downloaded sort is ordering by: a @@ -3342,6 +3403,11 @@ local function buildFindPanel(imp, x, y, w, availH, m) elseif entry.categories and entry.categories[1] then rest[#rest + 1] = entry.categories[1] end + if not carts then + for i = 1, math.min(2, #(entry.tags or {})) do + rest[#rest + 1] = entry.tags[i] + end + end if dates then rest[#rest + 1] = dates elseif not hasCount and (entry.summary or "") ~= "" then @@ -3666,6 +3732,8 @@ local function buildConfirmModal(imp, m) imp:_installCartPins(c.version, c.id) elseif c.kind == "update" then imp:_confirmModUpdate(c.id, c.release) + elseif c.kind == "updateAll" then + imp:pressUpdateAllMods() elseif c.kind == "enableAll" then imp:_setAllMods(true, true) elseif c.kind == "importOversize" then @@ -3994,6 +4062,9 @@ local function buildModHeaderActionsModal(imp, m) local btns = { { label = Strings("Mod profiles..."), action = function() imp._profilesPopup = true end }, { label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end }, + { label = Strings("Update all mods"), kind = "warn", + enabled = bulkOk and imp._updateAll == nil, + action = function() askUpdateAllMods(imp) end }, { label = Strings("Enable all mods"), kind = "good", enabled = bulkOk, action = function() imp:_setAllMods(true) end }, { label = Strings("Disable all mods"), kind = "warn", enabled = bulkOk, @@ -4319,9 +4390,24 @@ local function buildCartSaveModal(imp, m) PAL.muted) end --- Category filter for FIND MODS, base-game filter for FIND CARTS (a cart has --- no categories). Two columns, because an index can list enough categories --- to overflow a single stacked column on a short window. +local function findGameOptions(imp) + local out = { { key = nil, label = Strings("All games") } } + local seen = {} + for _, version in ipairs(GameVersion.ORDER) do + local gen = GameVersion.generation(version) + if gen and not seen[gen] then + seen[gen] = true + out[#out + 1] = { key = "gen" .. gen, label = Strings("Gen %d", gen) } + end + end + for _, version in ipairs(GameVersion.ORDER) do + if imp.ready and imp.ready[version] then + out[#out + 1] = { key = version, label = gameLabel(version) } + end + end + return out +end + local function buildFilterModal(imp, m) local carts = imp.findKind == "carts" local keys = (imp.findIndex @@ -4330,17 +4416,38 @@ local function buildFilterModal(imp, m) for _, c in ipairs(keys) do items[#items + 1] = { key = c, label = carts and gameLabel(c) or c } end + local games = findGameOptions(imp) local pad = math.floor(18 * m.s) local w = math.floor(440 * m.s) local gap = math.floor(8 * m.s) local nrows = math.ceil(#items / 2) - local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) - + nrows * (m.btnH + gap) + m.btnH + pad + local grows = math.ceil(#games / 3) + local headH = Kit.textHeight("button") + math.floor(12 * m.s) + local h = pad + headH + grows * (m.btnH + gap) + math.floor(6 * m.s) + + headH + nrows * (m.btnH + gap) + m.btnH + pad local px, py, pw = modalPanel(m, w, h) local cy = py + pad + Kit.text("button", Strings("Filter by game"), px + pad, cy, PAL.heading) + cy = cy + headH + local gColW = math.floor((pw - 2 * pad - 2 * gap) / 3) + for i, it in ipairs(games) do + local bx = px + pad + ((i - 1) % 3) * (gColW + gap) + local by = cy + math.floor((i - 1) / 3) * (m.btnH + gap) + local key = it.key + btn(imp, bx, by, gColW, m.btnH, "filterpopgame-" .. (key or "all"), + it.label, { + kind = (imp.findGame == key) and "primary" or "ghost", + font = "small", + action = function() + imp:_setFindGame(key) + setPage(imp, "find", 1) + imp._filterPopup = nil + end }) + end + cy = cy + grows * (m.btnH + gap) + math.floor(6 * m.s) Kit.text("button", carts and Strings("Filter by base game") or Strings("Filter by category"), px + pad, cy, PAL.heading) - cy = cy + Kit.textHeight("button") + math.floor(12 * m.s) + cy = cy + headH local colW = math.floor((pw - 2 * pad - gap) / 2) local active = carts and imp.findBase or imp.findCategory for i, it in ipairs(items) do diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index f1224967..a4ea6c3c 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -40,6 +40,7 @@ local PAL_BG_NAMES = { "GRAY", "RED", "GREEN", "WATER", "YELLOW", "BROWN", "ROOF", "TEXT", } local PAL_BG_ROOF = 6 -- 0-based slot index +local PAL_BG_WATER, PAL_BG_YELLOW = 3, 4 -- Time-of-day palette sets, in wTimeOfDayPal order (MORN_F..DARKNESS_F). local DAYTIMES = { "MORN", "DAY", "NITE", "DARK" } -- gfx/tilesets/bg_tiles.pal: morn/day/nite/dark/indoor (8 each) plus the two @@ -614,6 +615,41 @@ function RomExtractorGen2:readPalMap(address) return out end +-- engine/tilesets/tileset_palettes.asm:1 +local SPECIAL_TILESET_PALETTES = { + TILESET_POKECOM_CENTER = "PokeComPalette", + TILESET_BATTLE_TOWER_INSIDE = "BattleTowerInsidePalette", + TILESET_ICE_PATH = "IcePathPalette", + TILESET_HOUSE = "HousePalette", + TILESET_RADIO_TOWER = "RadioTowerPalette", + TILESET_MANSION = "MansionPalette1", +} + +function RomExtractorGen2:specialTilesetPalettes() + if self.edition ~= "crystal" then return nil end + local out = nil + for tileset, label in pairs(SPECIAL_TILESET_PALETTES) do + local at = self.symbols[label] + if at then + local set = {} + for slot = 0, 7 do + set[slot + 1] = self:colors(at[1], at[2] + slot * 8, 4) + end + out = out or {} + out[tileset] = set + end + end + -- MansionPalette1's ninth palette -- engine/tilesets/tileset_palettes.asm:113 + local one, two = self.symbols["MansionPalette1"], self.symbols["MansionPalette2"] + local mansion = out and out.TILESET_MANSION + if mansion and one and two then + mansion[PAL_BG_YELLOW + 1] = self:colors(two[1], two[2], 4) + mansion[PAL_BG_WATER + 1] = self:colors(one[1], one[2] + 6 * 8, 4) + mansion[PAL_BG_ROOF + 1] = self:colors(one[1], one[2] + 8 * 8, 4) + end + return out +end + function RomExtractorGen2:extractPalettes() self:beginStage("Color palettes") local consts = self.manifest.constants @@ -723,6 +759,7 @@ function RomExtractorGen2:extractPalettes() roofSlot = PAL_BG_ROOF + 1, bg = bg, environments = environments, + specialTilesets = self:specialTilesetPalettes(), objects = objects, roofs = roofs, pokemon = pokemon, diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 451a8a70..6a736f35 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1423,6 +1423,7 @@ function RomImporter.new(onComplete, opts) -- Which half of the feed the panel is browsing. Mods by default: carts -- are the newer, much shorter list, and a feed may carry none at all. findKind = "mods", findBase = nil, + findGame = nil, skinUrl = "", _skinUrlFocus = false, -- Page scroll offset (px) for the column under the tab bar -- panel, updater -- banner and footer -- used only while that column is taller than the window @@ -2900,6 +2901,7 @@ function RomImporter:update(dt) self:_pumpModInstall() self:_pumpCartInstall() self:_pumpCartFill() + self:_pumpUpdateAll() self:_pumpExtract() -- Dev harness: POKEPORT_LAUNCHER_SHOT=/path.png resizes the window from -- POKEPORT_WIN=WxH, lets the view settle, then captures one frame and @@ -4981,7 +4983,8 @@ function RomImporter:_cartPinIndexRelease(row) end function RomImporter:pressInstallCartMods(version) - if self._cartFill or self._modInstall or self._cartInstall then return false end + if self._cartFill or self._modInstall or self._cartInstall + or self._updateAll then return false end local rows = self:cartFillRows(version) if #rows == 0 then return false end self.cartFillNotice = nil @@ -6104,9 +6107,11 @@ function RomImporter:_beginModInstall(spec) end function RomImporter:_modInstallFailed(spec, msg) - local notice = { ok = false, text = tostring(msg) } - if spec.notice == "find" then self.findNotice = notice - elseif spec.notice ~= "cart" then self.modNotice = notice end + if not spec.quiet then + local notice = { ok = false, text = tostring(msg) } + if spec.notice == "find" then self.findNotice = notice + elseif spec.notice ~= "cart" then self.modNotice = notice end + end self:_clearBusy() if spec.done then spec.done(false, tostring(msg)) end end @@ -6193,14 +6198,15 @@ function RomImporter:_pumpModInstall() 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) - if spec.notice == "find" then + if spec.quiet then + elseif spec.notice == "find" then self.findNotice = { ok = true, text = text } elseif spec.notice ~= "cart" then self.modNotice = { ok = true, text = text } end -- A cart pins its whole load order, so the dependency modal would only -- interrupt the queue that is already installing the rest of it. - if spec.notice ~= "cart" then + if spec.notice ~= "cart" and not spec.quiet then local depCheck = LauncherMods.checkDependencies({ id = spec.modId }) if depCheck and depCheck.hasIssues then self._modDepResolver = depCheck @@ -6215,7 +6221,7 @@ end -- never through the mod installer. Shares the mod job's single-in-flight -- rule: either entry point refuses while the other is running. function RomImporter:_beginCartInstall(entry) - if self._modInstall or self._cartInstall then return end + if self._modInstall or self._cartInstall or self._updateAll then return end local ModIndex = require("src.mods.ModIndex") local release, why = ModIndex.releaseFor(entry) if type(release) ~= "table" or not (release.zip and release.zip.url) then @@ -6500,6 +6506,125 @@ function RomImporter:_installModVersion(modId, release) end +function RomImporter:_updateAllRows() + if self.modCartPlan and self:modCartPlan() then return {} end + local rows = {} + for _, m in ipairs(self.mods or {}) do + local info = self:_modUpdateInfo(m.id) + if info and info.status == "available" and info.best + and info.best.zip and info.best.zip.url then + rows[#rows + 1] = { id = m.id, name = m.name, release = info.best, + from = m.version } + end + end + return rows +end + +function RomImporter:pressUpdateAllMods() + if self._updateAll or self._modInstall or self._cartInstall + or self._cartFill then return false end + if not Platform.canFetchRemote() then + self.modNotice = { ok = false, + text = "Remote mod download is unavailable on this platform. Install a mod .zip from storage instead." } + return false + end + self._updateAll = { stage = "check", index = 0, updated = 0, + updatedIds = {}, failures = {} } + self.modNotice = nil + self:_syncModUpdateInfo(true) + self:_setBusy(Strings("Checking for updates"), nil, + function() self:_cancelUpdateAll() end) + self:_pumpUpdateAll() + return true +end + +function RomImporter:_cancelUpdateAll() + local job = self._updateAll + if not job or job.cancelled then return end + job.cancelled = true + if self._modInstall then + self:_setBusy(Strings("Finishing the last mod")) + else + self:_finishUpdateAll(true) + end +end + +function RomImporter:_pumpUpdateAll() + local job = self._updateAll + if not job then return end + + if job.stage == "check" then + if self._modInfoFetch then return end + job.rows = self:_updateAllRows() + job.total = #job.rows + if job.cancelled or job.total == 0 then + return self:_finishUpdateAll(job.cancelled) + end + job.stage = "next" + return + end + + if job.stage ~= "next" then return end + if job.cancelled then return self:_finishUpdateAll(true) end + if self._modInstall or self._cartInstall then return end + job.index = job.index + 1 + local row = job.rows[job.index] + if not row then return self:_finishUpdateAll() end + job.stage = "installing" + self:_beginModInstall({ + modId = row.id, name = row.name, release = row.release, + verb = "Updated", notice = "mod", quiet = true, + done = function(ok, text) + if ok then + job.updated = job.updated + 1 + job.updatedIds[#job.updatedIds + 1] = row.id + else + job.failures[#job.failures + 1] = + ("%s: %s"):format(tostring(row.name or row.id), tostring(text)) + end + job.stage = "next" + end, + }) + if self._modInstall then + self:_setBusy(Strings("Updating %s (%d of %d)", + tostring(row.name or row.id), job.index, job.total), + "v" .. tostring(row.release.version or "?"), + function() self:_cancelUpdateAll() end) + elseif job.stage == "installing" then + job.stage = "next" + end +end + +function RomImporter:_finishUpdateAll(cancelled) + local job = self._updateAll + self._updateAll = nil + self:_clearBusy() + pcall(self._refreshMods, self) + if not job then return end + if cancelled then + self.modNotice = { ok = true, failures = job.failures, + text = Strings("Stopped after updating %d mods.", job.updated) } + elseif (job.total or 0) == 0 then + self.modNotice = { ok = true, text = Strings("All mods are up to date.") } + elseif #job.failures == 0 then + self.modNotice = { ok = true, + text = Strings("Updated %d mods.", job.updated) } + else + self.modNotice = { ok = false, failures = job.failures, + text = Strings("Updated %d of %d. %d failed:", job.updated, job.total, + #job.failures) } + end + local LauncherMods = require("src.mods.LauncherMods") + for _, id in ipairs(job.updatedIds or {}) do + local ok, depCheck = pcall(LauncherMods.checkDependencies, { id = id }) + if ok and depCheck and depCheck.hasIssues then + self._modDepResolver = depCheck + break + end + end +end + + -- NX / desktop / Android labels and inbox hints for the FlexLove view. function RomImporter:_modsImportButtonLabel() if self.isNX then return Strings("Scan again") end @@ -6781,7 +6906,7 @@ function RomImporter:_findRows() local c = self._findRowsCache if c and c.src == all and c.query == self.findQuery and c.category == self.findCategory and c.base == self.findBase - and c.scope == self.modScope then + and c.game == self.findGame then return c.rows end local ModIndex = require("src.mods.ModIndex") @@ -6789,25 +6914,24 @@ function RomImporter:_findRows() query = self.findQuery, category = (not carts) and self.findCategory or nil, base = carts and self.findBase or nil, + game = self.findGame, }) - 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 - end - rows = kept - end self._findRowsCache = { src = all, query = self.findQuery, category = self.findCategory, base = self.findBase, - scope = self.modScope, rows = rows } + game = self.findGame, rows = rows } return rows end +function RomImporter:_setFindGame(token) + if token == "all" then token = nil end + if self.findGame == token then return end + self.findGame = token + self._findRowsCache = nil + self._findSortCache = nil + if self._pages then self._pages["find"] = 1 end + self.findPage = 1 +end + function RomImporter:_findInstalledMap() if self:findingCarts() then return self:_findInstalledCarts() end if self.findInstalled then return self.findInstalled end diff --git a/src/mods/Gen2Compat.lua b/src/mods/Gen2Compat.lua index b5c3ec08..ed185d4e 100644 --- a/src/mods/Gen2Compat.lua +++ b/src/mods/Gen2Compat.lua @@ -1922,12 +1922,12 @@ COVERAGE["src.battle.BattleState"] = { backed = "update draw __index isOpaque openParty swapMoves " .. "lowHealthAlarmActive playVictoryMusic say sayAuto openItems " .. "openReplacementMenu finish askNicknameUI playEntranceCry stampOT " - .. "tryRun wantsFillScale bgMode", + .. "tryRun wantsFillScale bgMode BG_WORLD_DIM", warned = "tryRun askNicknameUI", absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn " .. "computeDamage catchAttempt runRoll enter exit sgbPalettes " .. "isWideBattleLayout wideLayout uiSize letterboxWhite " - .. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath " + .. "holdsUIAnchors trainerPalette trainerPicPath " .. "trainerTrueColor trainerSprite invalidate " .. "backPlacement frontPlacement StatBox drawClassic drawBallRow " .. "safariAction safariEnemyTurn throwBall storeCaughtMon field ruleset " @@ -1945,6 +1945,10 @@ COVERAGE["src.battle.BattleState"] = { 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", + bgMode = "reads OPTION -> BATTLE BG (#1709), the same white/black/world " + .. "keys as Gen 1; Game2:paintBattleSurround paints the result, and " + .. "Gold's battle stays OPAQUE under world -- its map is drawn by " + .. "Game2:drawScene, not by StateStack finding the overworld below", 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/ModIndex.lua b/src/mods/ModIndex.lua index ae7a9ed4..898d74ef 100644 --- a/src/mods/ModIndex.lua +++ b/src/mods/ModIndex.lua @@ -521,7 +521,18 @@ function ModIndex.matches(entry, query) return true end --- filter(entries, opts) -> a new array. opts = { query, category, base, tag }. +function ModIndex.targets(entry) + local ModTargets = require("src.mods.ModTargets") + return (ModTargets.normalize(type(entry) == "table" and entry.games or nil)) +end + +function ModIndex.targetLabel(entry) + local ModTargets = require("src.mods.ModTargets") + local ids = ModIndex.targets(entry) + if #ids == 0 then return nil end + return ModTargets.chip({ games = ids }) +end + -- Category, base and tag compare case-insensitively; feed order (already -- sorted by title) is preserved. `base` is the cart-side equivalent of a -- mod's category: a cart plays as exactly one game and has no categories. @@ -530,9 +541,31 @@ function ModIndex.filter(mods, opts) local want = opts.category and tostring(opts.category):lower() or nil local wantBase = opts.base and tostring(opts.base):lower() or nil local wantTag = opts.tag and tostring(opts.tag):lower() or nil + local wantGames = nil + if opts.game and opts.game ~= "all" then + local ModTargets = require("src.mods.ModTargets") + local ids = ModTargets.expand(opts.game) + if ids then + wantGames = {} + for _, id in ipairs(ids) do wantGames[id] = true end + end + end local out = {} for _, entry in ipairs(mods or {}) do local keep = ModIndex.matches(entry, opts.query) + if keep and wantGames then + if ModIndex.isCart(entry) then + keep = wantGames[tostring(entry.base or ""):lower()] == true + else + local ids = ModIndex.targets(entry) + if #ids > 0 then + keep = false + for _, id in ipairs(ids) do + if wantGames[id] then keep = true; break end + end + end + end + end if keep and want then keep = false for _, c in ipairs(entry.categories or {}) do diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 99c1175f..aeda01ee 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -338,6 +338,17 @@ function TextBox:sfxHeld() return false end +-- TextCommand_PROMPT_BUTTON's LoadBlinkingCursor (home/text.asm:749) +function TextBox:arrowVisible() + if self.sfxWait then return false end + if self.waiting then return true end + return not not (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))) +end + function TextBox:update(dt) local input = self.game.input self.blink = (self.blink + 1) % 60 @@ -632,12 +643,7 @@ function TextBox:draw() pen = pen + Font.advanceOf(code) end end - 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 + if self:arrowVisible() and self.blink < 30 then -- page-advance cursor: glyph $EE by default, the blinking down arrow -- the original prints via `ld a, "▼"` (home/text.asm) drawGlyph(Theme.moreArrow or 0xEE, diff --git a/src/save_convert/Gen2Layout.lua b/src/save_convert/Gen2Layout.lua index b01f5f2e..bc6c082e 100644 --- a/src/save_convert/Gen2Layout.lua +++ b/src/save_convert/Gen2Layout.lua @@ -42,6 +42,8 @@ Gen2Layout.goldSilver = { wStatusFlags = 0x23D9, wStatusFlags2 = 0x23DA, wPokegearFlags = 0x24E4, + wVisitedSpawns = 0x2856, + wVariableSprites = 0x23BD, wGameTimeHours = 0x2053, wGameTimeMinutes = 0x2055, -- The 14 archived boxes, listed rather than strided (see BOX_COUNT). @@ -87,8 +89,11 @@ Gen2Layout.crystal = { wStatusFlags = 0x23DA, wStatusFlags2 = 0x23DB, wPokegearFlags = 0x24E5, + wVisitedSpawns = 0x2833, + wVariableSprites = 0x23BC, wGameTimeHours = 0x2052, wGameTimeMinutes = 0x2054, + wPlayerGender = 0x3E3D, -- The 14 archived boxes, listed rather than strided (see BOX_COUNT). boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 }, -- The backup copy the game falls back to when the primary @@ -132,8 +137,11 @@ Gen2Layout.crystal = { wStatusFlags = 0x15DA, wStatusFlags2 = 0x15DB, wPokegearFlags = 0x16E5, + wVisitedSpawns = 0x1A33, + wVariableSprites = 0x15BC, wGameTimeHours = 0x1252, wGameTimeMinutes = 0x1254, + wPlayerGender = 0x3E3D, 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 c6f3a43b..276077b9 100644 --- a/src/save_convert/Gen2Save.lua +++ b/src/save_convert/Gen2Save.lua @@ -327,17 +327,61 @@ local ENGINE_FLAG_BITS = { }, } -local function decodeEngineFlags(bytes, L) +-- data/events/engine_flags.asm:74-101 over the spawn indexes at +-- constants/map_data_constants.asm:68-99 +local FLYPOINT_BITS = { + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + 18, 19, 20, 21, 22, 23, 24, 25, 26, 28, +} +local FLYPOINT_FIRST_ID = 50 + +-- pokecrystal constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM +local function engineId(gold, gameVersion) + if gameVersion == "crystal" and gold >= 16 then return gold + 1 end + return gold +end + +-- engine/pokegear/pokegear.asm:2189 HasVisitedSpawn +local function eachSpawnBit(L, fn) + if not L.wVisitedSpawns then return end + for i, bit in ipairs(FLYPOINT_BITS) do + fn(FLYPOINT_FIRST_ID + i - 1, L.wVisitedSpawns + math.floor(bit / 8), + 2 ^ (bit % 8)) + end +end + +local function decodeEngineFlags(bytes, L, gameVersion) 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 + if math.floor(byte / 2 ^ row[2]) % 2 == 1 then + out[engineId(row[1], gameVersion)] = true + end end end end + eachSpawnBit(L, function(id, at, mask) + if math.floor(u8(bytes, at) / mask) % 2 == 1 then + out[engineId(id, gameVersion)] = true + end + end) + return out +end + +-- constants/sprite_constants.asm:149 SPRITE_VARS; ram/sram.asm:96 +Gen2Save.VARIABLE_SPRITES = 16 + +-- engine/overworld/overworld.asm:326 GetMonSprite.Variable +local function decodeVariableSprites(bytes, at, count) + if not at then return nil end + local out = {} + for i = 0, count - 1 do + local b = u8(bytes, at + i) + if b and b ~= 0 then out[i] = b end + end return out end @@ -398,6 +442,9 @@ function Gen2Save.decode(bytes, gameVersion, data) coins = be(bytes, L.wCoins, 2), badges = decodeBadges(u8(bytes, L.wBadges), Gen2Save.JOHTO_BADGES), kantoBadges = decodeBadges(u8(bytes, L.wKantoBadges), Gen2Save.KANTO_BADGES), + -- constants/ram_constants.asm:177 PLAYERGENDER_FEMALE_F + gender = L.wPlayerGender + and (u8(bytes, L.wPlayerGender) % 2 == 1 and "female" or "male") or nil, }, rival = { name = text(bytes, L.wRivalName, Gen2Save.NAME_LENGTH) }, mom = { name = text(bytes, L.wMomsName, Gen2Save.NAME_LENGTH) }, @@ -411,7 +458,9 @@ function Gen2Save.decode(bytes, gameVersion, data) seen = decodeDex(bytes, L.wPokedexSeen, x), }, events = decodeFlagBytes(bytes, L.wEventFlags, Gen2Save.EVENT_BYTES), - engineFlags = decodeEngineFlags(bytes, L), + engineFlags = decodeEngineFlags(bytes, L, gameVersion), + variableSprites = decodeVariableSprites(bytes, L.wVariableSprites, + Gen2Save.VARIABLE_SPRITES), -- Save.summary does `save.position.map or save.spawn`. position = { map = x.maps[u8(bytes, L.wMapGroup) * 256 + u8(bytes, L.wMapNumber)], @@ -601,7 +650,7 @@ local function putBag(t, L, inventory, x) return overflow end -local function putEngineFlags(t, L, flags) +local function putEngineFlags(t, L, flags, gameVersion) for field, rows in pairs(ENGINE_FLAG_BITS) do local at = L[field] if at then @@ -609,12 +658,18 @@ local function putEngineFlags(t, L, flags) 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 + local want = flags[engineId(row[1], gameVersion)] == true if on ~= want then byte = want and (byte + mask) or (byte - mask) end end t[at] = byte end end + eachSpawnBit(L, function(id, at, mask) + local cur = t[at] or 0 + local on = math.floor(cur / mask) % 2 == 1 + local want = flags[engineId(id, gameVersion)] == true + if on ~= want then t[at] = want and (cur + mask) or (cur - mask) end + end) end local function putFlagSet(t, at, set, count, indexFor) @@ -658,6 +713,12 @@ function Gen2Save.encode(save, gameVersion, template, data) putBadges(t, L.wKantoBadges, p.kantoBadges, Gen2Save.KANTO_BADGES) putText(t, L.wRivalName, (save.rival or {}).name or "", Gen2Save.NAME_LENGTH) putText(t, L.wMomsName, (save.mom or {}).name or "", Gen2Save.NAME_LENGTH) + -- engine/menus/init_gender.asm:38 + if L.wPlayerGender then + local byte = t[L.wPlayerGender] or 0 + putU8(t, L.wPlayerGender, + byte - byte % 2 + (p.gender == "female" and 1 or 0)) + end local party = save.party or {} if #party > Gen2Save.PARTY_LENGTH then @@ -724,7 +785,14 @@ function Gen2Save.encode(save, gameVersion, template, data) if type(byte) == "number" then putU8(t, L.wEventFlags + i, byte) end end if type(save.engineFlags) == "table" then - putEngineFlags(t, L, save.engineFlags) + putEngineFlags(t, L, save.engineFlags, gameVersion) + end + -- engine/overworld/scripting.asm:869 Script_variablesprite + if L.wVariableSprites and type(save.variableSprites) == "table" then + for i = 0, Gen2Save.VARIABLE_SPRITES - 1 do + local b = save.variableSprites[i] + if type(b) == "number" then putU8(t, L.wVariableSprites + i, b) end + end end local pos = save.position diff --git a/src/save_convert/GenSave.lua b/src/save_convert/GenSave.lua index c0232d1d..48e948db 100644 --- a/src/save_convert/GenSave.lua +++ b/src/save_convert/GenSave.lua @@ -93,6 +93,10 @@ O.statusFlags1 = O.townVisited + 29 -- 1B O.statusFlags4 = O.townVisited + 35 -- 1B O.elite4Flags = O.townVisited + 41 -- 1B O.tradeFlags = O.townVisited + 44 -- 2B (flag_array NUM_NPC_TRADES) +-- +10 wRivalStarter / +12 wPlayerStarter from the same run (ram/wram.asm:2057-2078; +-- engine/debug/debug_party.asm:119 ASSERTs the spacing) (#1625) +O.rivalStarter = O.townVisited + 10 +O.playerStarter = O.townVisited + 12 -- wToggleableObjectFlags (ram/wram.asm, flag_array $100): the ShowObject/ -- HideObject persistence, one bit per data/maps/toggleable_objects.asm entry, -- set = hidden (engine/overworld/toggleable_objects.asm IsObjectHidden). @@ -380,6 +384,15 @@ local EXTRA_FLAG_BITS = { -- port-local name -> the wEventFlags name it means (#396) local FLAG_ALIAS = { EVENT_RECEIVED_BIKE_VOUCHER = "EVENT_GOT_BIKE_VOUCHER" } +-- scripts/OaksLab.asm:797-825 +local PLAYER_TO_RIVAL = { + CHARMANDER = "SQUIRTLE", SQUIRTLE = "BULBASAUR", BULBASAUR = "CHARMANDER", +} +local RIVAL_TO_PLAYER = {} +for player, rival in pairs(PLAYER_TO_RIVAL) do RIVAL_TO_PLAYER[rival] = player end +-- pokeyellow scripts/OaksLab.asm:1020 (wPlayerStarter) and :231/:381 +local YELLOW_STARTER = "PIKACHU" + -- STATUS_* bits (constants/battle_constants.asm): 0-2 sleep-turns-left, -- 3 PSN, 4 BRN, 5 FRZ, 6 PAR local STATUS_BIT = { PSN = 3, BRN = 4, FRZ = 5, PAR = 6 } @@ -751,6 +764,25 @@ function GenSave.decode(bytes, data, opts) if save.flags[vanillaName] then save.flags[portName] = true end end + -- scripts/OaksLab.asm:335, :900-901 + if save.flags.EVENT_GOT_STARTER then + local chosen = cw.pokemonByIndex[u8(bytes, O.playerStarter)] + if data.gameVersion == "yellow" then + if chosen ~= YELLOW_STARTER then chosen = nil end + elseif not PLAYER_TO_RIVAL[chosen or ""] then + chosen = RIVAL_TO_PLAYER[cw.pokemonByIndex[u8(bytes, O.rivalStarter)] or ""] + end + if chosen then + save.flags["EVENT_CHOSE_" .. chosen] = true + else + warn("no starter recorded in the save; rival parties will default") + end + end + if data.gameVersion == "yellow" then + local rival = u8(bytes, O.rivalStarter) + if rival >= 1 and rival <= 3 then save.rivalStarter = rival end + end + -- wToggleableObjectFlags -> save.objectToggles (bit set = hidden). A few -- of these are re-derived from flags on map entry (#106/#234 onEnter -- re-applies), but most ShowObject/HideObject state -- the Mt Moon @@ -927,6 +959,24 @@ function GenSave.encode(save, data, template) end end + -- scripts/OaksLab.asm:322-323, :900-901 + if data.gameVersion == "yellow" then + if save.flags and save.flags["EVENT_CHOSE_" .. YELLOW_STARTER] then + setByte(buf, O.playerStarter, cw.pokemonIndex[YELLOW_STARTER] or 0) + end + local rival = tonumber(save.rivalStarter) + if rival and rival >= 1 and rival <= 3 then + setByte(buf, O.rivalStarter, rival) + end + elseif save.flags then + for species, rival in pairs(PLAYER_TO_RIVAL) do + if save.flags["EVENT_CHOSE_" .. species] then + setByte(buf, O.playerStarter, cw.pokemonIndex[species] or 0) + setByte(buf, O.rivalStarter, cw.pokemonIndex[rival] or 0) + end + end + end + -- wToggleableObjectFlags, written both ways like the #396 extras: this -- port's save is the authority, and vanilla folds three stores this port -- keeps separate into these same bits -- script ShowObject/HideObject diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 627e701a..ccbd1d1d 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -214,22 +214,23 @@ function Commands.jump_if_false(ctx, target) if not ctx.lastCheck then return target end end --- give_item [count] [gotText]: adds to the bag, plays the gift --- jingle and shows the "got item!" box. pokered's GiveItem (home/ +-- give_item [count] [gotText] [fullText]: adds to the bag, plays +-- the gift jingle and shows the "got item!" box. pokered's GiveItem (home/ -- give.asm) copies the item name to wStringBuffer and every gift script -- then prints a text ending " got\n!" with -- sound_get_item_1/sound_get_key_item. gotText picks that per-script -- text (label or literal; {RAM:wStringBuffer} becomes the item name); --- pass false when the script shows its own received-text row. -function Commands.give_item(ctx, itemId, count, gotText) +-- pass false when the script shows its own received-text row. fullText +-- picks the refusal text (scripts/BillsHouse.asm:184-186). +function Commands.give_item(ctx, itemId, count, gotText, fullText) -- the bag can refuse at its configured capacity (20 in vanilla): halt -- the script, so later set_flag rows don't burn the gift -- make -- room and talk again, like the original (pokered's `jr nc, .bag_full` -- skips the received text entirely when AddItemToInventory refuses) if not require("src.inventory.Bag").add( ctx.save, itemId, count or 1, ctx.game.data) then - Commands.show_text(ctx, ctx.game.data.text - and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!")) + Commands.show_text(ctx, fullText or (ctx.game.data.text + and ctx.game.data.text._BagFullText) or Strings("You can't carry\nany more items!")) return math.huge end local def = ctx.game.data.items[itemId] diff --git a/src/script/gen2/Vm.lua b/src/script/gen2/Vm.lua index 19ffcfba..5451785b 100644 --- a/src/script/gen2/Vm.lua +++ b/src/script/gen2/Vm.lua @@ -1041,6 +1041,11 @@ local function runCmd(self, cmd, op) wild = self.wildMon }) self.wildMon = nil self.trainer = nil + -- engine/overworld/scripting.asm Script_startbattle + if outcome == nil then + self.aborted = true + return "end" + end self.justBattled = true self.battleOutcome = outcome self.scriptVar = BATTLE_RESULTS[outcome] or BATTLE_RESULTS.win @@ -2348,7 +2353,17 @@ end function Vm:lookupTrainer(class, member) if not (class and member) then return nil end if not self.lookupTrainerFn then return { class = class, member = member } end - return self.lookupTrainerFn(class, member) + local record = self.lookupTrainerFn(class, member) + if not record then + local key = tostring(class) .. "/" .. tostring(member) + self.missingTrainers = self.missingTrainers or {} + if not self.missingTrainers[key] then + self.missingTrainers[key] = true + Logger.warn("gen2 trainer class %s member %s is not in the roster", + tostring(class), tostring(member)) + end + end + return record end -- `special` handlers. The script command carries an index into diff --git a/src/ui/BindingsMenu.lua b/src/ui/BindingsMenu.lua index c0a4fc33..4a08270f 100644 --- a/src/ui/BindingsMenu.lua +++ b/src/ui/BindingsMenu.lua @@ -15,9 +15,7 @@ BindingsMenu.__index = BindingsMenu -- Input.lua's map, primary key first where several keys share a button. -- `pad` mirrors DEFAULT_GAMEPAD_BINDINGS in src/core/Input.lua row for --- row; keep the two in sync. Every row shows its key and pad so the --- controller side is discoverable (#73, #589), and the swap in --- storeBinding leans on each row holding a value in both slots. +-- row, so the controller side is discoverable (#73, #589). local BUTTONS = { { id = "up", label = "UP", key = "up", pad = "dpup" }, { id = "down", label = "DOWN", key = "down", pad = "dpdown" }, @@ -27,6 +25,8 @@ local BUTTONS = { { id = "b", label = "B", key = "x", pad = "b" }, { id = "start", label = "START", key = "escape", pad = "start" }, { id = "select", label = "SELECT", key = "tab", pad = "back" }, + { id = "speedDown", label = "SPEED -", pad = "leftshoulder", action = true }, + { id = "speedUp", label = "SPEED +", pad = "rightshoulder", action = true }, } -- a binding is a plain key string or { key, pad }; absent = the fixed @@ -41,6 +41,7 @@ end local function boundPad(overlay, def) local b = overlay and overlay[def.id] if type(b) == "table" and b.pad then return b.pad end + if b == false then return nil end return def.pad end @@ -67,8 +68,11 @@ end -- Right column for every row: effective key and pad together, so a -- controller player can read the whole map without a second legend (#589). local function boundRight(overlay, def) - local key = shortName(boundKey(overlay, def), KEY_SHORT) local pad = boundPad(overlay, def) + if def.action then + return pad and shortName(pad, PAD_SHORT) or Strings("OFF") + end + local key = shortName(boundKey(overlay, def), KEY_SHORT) if pad then return key .. "/" .. shortName(pad, PAD_SHORT) end return key end @@ -148,6 +152,9 @@ end -- which no rebind removes, so reserving it costs the player nothing. function BindingsMenu:captureKey(key) if key == "escape" or self.pending then return self:endCapture() end + if self.capture and self.capture.button.action then + return self:endCapture() + end self.pending = { slot = "key", value = key } end @@ -205,22 +212,26 @@ function BindingsMenu:storeBinding(slot, value) opts.bindings = opts.bindings or {} -- Swap, never steal (#589): when the captured input is another row's -- effective binding in this slot, that row inherits this row's previous - -- binding. Every BUTTONS row has a default in both slots, so `prev` - -- always exists: no row goes empty and no input serves two rows. - -- Default key ALIASES (W beside Up, Space beside Z; DEFAULT_BINDINGS in - -- Input.lua) are not effective bindings, so capturing one costs the - -- other row a spare alias, never its shown key. + -- binding. Default key ALIASES (W beside Up, Space beside Z; + -- DEFAULT_BINDINGS in Input.lua) are not effective bindings, so capturing + -- one costs the other row a spare alias, never its shown key. local effective = (slot == "key") and boundKey or boundPad local prev = effective(opts.bindings, item.button) + local handover = prev + if handover == nil and item.button.action then handover = item.button.pad end if value ~= prev then for _, other in ipairs(self.items) do if other ~= item and effective(opts.bindings, other.button) == value then - local ob = opts.bindings[other.button.id] - if type(ob) ~= "table" then - ob = { key = type(ob) == "string" and ob or nil } + if other.button.action then + opts.bindings[other.button.id] = false + else + local ob = opts.bindings[other.button.id] + if type(ob) ~= "table" then + ob = { key = type(ob) == "string" and ob or nil } + end + ob[slot] = handover + opts.bindings[other.button.id] = ob end - ob[slot] = prev - opts.bindings[other.button.id] = ob other.right = boundRight(opts.bindings, other.button) break end @@ -247,11 +258,18 @@ end function BindingsMenu:clearBinding(item) local game = self.game local opts = game and game.save and game.save.options - if not (opts and opts.bindings and opts.bindings[item.button.id]) then - return + local def = item.button + if def.action then + if not opts or (opts.bindings and opts.bindings[def.id] == false) then + return + end + opts.bindings = opts.bindings or {} + opts.bindings[def.id] = false + else + if not (opts and opts.bindings and opts.bindings[def.id]) then return end + opts.bindings[def.id] = nil end - opts.bindings[item.button.id] = nil - item.right = boundRight(opts.bindings, item.button) + item.right = boundRight(opts.bindings, def) if game.writeOptions then game:writeOptions() elseif game.persistOptions then diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index fcc85d71..5d7f2640 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -54,6 +54,8 @@ local ITEM_NAME_X, ITEM_TOP_Y = 48, 32 local ITEM_CURSOR_X = 40 local ITEM_QTY_X, ITEM_QTY_END = 112, 136 local ITEM_MORE_X, ITEM_MORE_Y = 144, 88 +-- Delay3 (home/list_menu.asm:61-64, 338-342, 47; home/window.asm:14-18) +local SCROLL_BLANK = 3 -- frames to wait before key-repeat kicks in, then between repeats local REPEAT_DELAY = MenuRepeat.GEN1_DELAY local REPEAT_RATE = MenuRepeat.GEN1_RATE @@ -96,6 +98,7 @@ function ListMenu.new(game, title, items, opts) self.items = items self.index = 1 self.scroll = 0 + self.cursorBlank = 0 self.onChoose = opts.onChoose self.onCancel = opts.onCancel self.footer = opts.footer @@ -174,6 +177,7 @@ end -- edge press or key-repeat tick for a held direction local function navPressed(self, dir) + local before = self.scroll if dir == "up" then moveIndex(self, -1) elseif dir == "down" then @@ -186,6 +190,10 @@ local function navPressed(self, dir) return false end syncScroll(self) + -- the loop reaches PlaceMenuCursor again (home/list_menu.asm:176-190) + if self.itemBox and self.scroll ~= before then + self.cursorBlank = SCROLL_BLANK + end return true end @@ -194,6 +202,7 @@ function ListMenu:update(dt) self.script(self) return end + if (self.cursorBlank or 0) > 0 then self.cursorBlank = self.cursorBlank - 1 end local input = self.game.input if #self.items == 0 then if input:wasPressed("a") or input:wasPressed("b") then @@ -298,7 +307,7 @@ function ListMenu:drawItemBox() Font.draw(item.right:sub(1, 1), ITEM_QTY_X, y + 8) Font.draw(count, ITEM_QTY_END - Font.width(count), y + 8) end - if i == self.index then + if i == self.index and (self.cursorBlank or 0) == 0 then Font.drawCode(self.hollowIndex == i and Theme.cursorHollow or Theme.cursor, ITEM_CURSOR_X, y) end diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index b095d866..ba66b942 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -25,6 +25,7 @@ local Orientation = require("src.core.Orientation") local FaithfulRes = require("src.core.FaithfulRes") local ScreenPosition = require("src.core.ScreenPosition") local FrameCap = require("src.core.FrameCap") +local VSync = require("src.core.VSync") local Performance = require("src.core.Performance") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") @@ -480,6 +481,16 @@ local function buildRows(game) FrameCap.apply(o.fpsCap) return true end }, + { id = "vsync", label = Strings("VSYNC"), + value = function(g) + return Strings(VSync.label(g.save.options.vsync)) + end, + step = function(g, dir) + local o = g.save.options + o.vsync = VSync.cycle(o.vsync, dir) + VSync.apply(o.vsync) + return true + end }, -- fast-forward the logic clock only; music and sfx keep their tempo -- (src/core/GameSpeed.lua), so this is safe to leave on. Per-category -- (RFC 0007): overworld walking, battle turns and menu navigation each @@ -584,6 +595,17 @@ local function buildRows(game) TC.buzz(o.haptics) return true end }, + { id = "hotbar", label = Strings("KEY BAR"), + value = function(g) + return g.save.options.hotbar == false and Strings("OFF") + or Strings("ON") + end, + step = function(g) + local o = g.save.options + o.hotbar = o.hotbar == false + require("src.core.TouchControls"):applyOptions(o) + return true + end }, } -- ORIENTATION only on the platforms Orientation.apply reaches (#1638). if not (Orientation.isAndroid() or Orientation.isIOS()) then @@ -605,7 +627,8 @@ local function buildRows(game) if not show then local filtered = {} for _, row in ipairs(rows) do - if row.id ~= "touchControls" and row.id ~= "haptics" then + if row.id ~= "touchControls" and row.id ~= "haptics" + and row.id ~= "hotbar" then filtered[#filtered + 1] = row end end @@ -657,7 +680,7 @@ local GROUPS = { members = { "musicVol", "sfxVol", "pikaVol", "musicFilter" } }, { id = "group.video", label = "VIDEO", members = { "uiLayout", "videoMode", "orientation", "faithfulRes", - "screenPos", "fpsCap" } }, + "screenPos", "fpsCap", "vsync" } }, { id = "group.speed", label = "SPEED", members = { "textSpeed", "speedOverworld", "speedBattle", "speedMenu" } }, { id = "group.graphics", label = "GRAPHICS", diff --git a/src/ui/SkinStudio.lua b/src/ui/SkinStudio.lua index 4787f13c..ff1d4b10 100644 --- a/src/ui/SkinStudio.lua +++ b/src/ui/SkinStudio.lua @@ -47,6 +47,7 @@ Studio.ORIENT_LABEL = { } local HANDLE = 7 +local TAP_SLOP2 = 16 * 16 local HANDLES = { { "nw", 0, 0 }, { "n", 0.5, 0 }, { "ne", 1, 0 }, { "w", 0, 0.5 }, { "e", 1, 0.5 }, @@ -508,7 +509,9 @@ function Studio.load(opts) -- first-class tasks, not controls buried inside the editor workspace. Studio.mode = "library" Studio.libraryThumbs = {} - Studio.libraryPage = 1 + Studio.libraryScroll, Studio.libraryScrollMax = 0, 0 + Studio.libraryPress, Studio.libraryRects = nil, {} + Studio.libraryFocusId = nil TouchControls:init() TouchControls.active = true @@ -606,6 +609,9 @@ function Studio.refreshAvailable() Studio.available = TouchSkin.list() Studio.availableMeta = {} Studio.libraryThumbs = {} + Studio.libraryScroll = Kit.scrollClamp(Studio.libraryScroll or 0, + Studio.libraryScrollMax or 0) + Studio.libraryPress = nil return Studio.available end @@ -2487,31 +2493,61 @@ local function drawLibrary() y = y + Kit.textHeight("small") + gap local entries = Studio.available or {} - local cols = W >= 520 * Kit.scale and 3 or 2 local cardGap = 10 * Kit.scale - local cardW = (W - pad * 2 - cardGap * (cols - 1)) / cols - local cardH = math.max(182 * Kit.scale, cardW * 1.42) + local listX, listTop = ox + pad, y + local listW = W - pad * 2 + local listH = math.max(0, (oy + H) - listTop - pad) + local gridW = listW - Kit.scrollGutter() + local cols = math.max(2, math.min(5, + math.floor((gridW + cardGap) / (250 * Kit.scale + cardGap)))) + local cardW = (gridW - cardGap * (cols - 1)) / cols + local textH = Kit.textHeight("mono") + Kit.textHeight("small") + local artH = math.min(cardW * 0.52, math.max(52 * Kit.scale, listH * 0.34)) + local cardH = artH + textH + btnH * 2 + 34 * Kit.scale local saved = SaveData.loadOptions() local tc = type(saved.touchControls) == "table" and saved.touchControls or {} local activeId = tc.enabled == false and nil or tc.skin - local total = #entries - local maxRows = math.max(1, math.floor((oy + H - y - pad - btnH - gap) - / (cardH + cardGap))) - local perPage = maxRows * cols - local pages = math.max(1, math.ceil(total / perPage)) - Studio.libraryPage = math.max(1, math.min(Studio.libraryPage or 1, pages)) - local first = (Studio.libraryPage - 1) * perPage + 1 - local last = math.min(total, first + perPage - 1) - for slot = first, last do - local index = slot - first - local cx = ox + pad + (index % cols) * (cardW + cardGap) - local cy = y + math.floor(index / cols) * (cardH + cardGap) - do + local rows = math.ceil(#entries / cols) + local contentH = rows * cardH + math.max(0, rows - 1) * cardGap + local maxScroll = Kit.scrollExtent(contentH, listH) + Studio.libraryScroll = Kit.scrollWheel(Studio.libraryScroll or 0, maxScroll, + listX, listTop, listW, listH) + Studio.libraryScroll = Kit.scrollClamp(Studio.libraryScroll, maxScroll) + local focusId = (Kit._ringShown and Kit.focusId) or nil + if focusId ~= Studio.libraryFocusId then + Studio.libraryFocusId = focusId + local focusSkin = focusId and tostring(focusId):match("^skin%-%a+%-(.+)$") + if focusSkin and maxScroll > 0 then + for i = 1, #entries do + if entries[i].id == focusSkin then + local bandTop = math.floor((i - 1) / cols) * (cardH + cardGap) + if bandTop < Studio.libraryScroll then + Studio.libraryScroll = Kit.scrollClamp(bandTop, maxScroll) + elseif bandTop + cardH > Studio.libraryScroll + listH then + Studio.libraryScroll = Kit.scrollClamp(bandTop + cardH - listH, + maxScroll) + end + break + end + end + end + end + Studio.libraryScrollMax = maxScroll + Studio.libraryView = { x = listX, y = listTop, w = listW, h = listH } + Studio.libraryRects = {} + + local jumped = false + local top = Kit.scrollBegin(listX, listTop, listW, listH, + Studio.libraryScroll, maxScroll) + for slot = 1, #entries do + local index = slot - 1 + local cx = listX + (index % cols) * (cardW + cardGap) + local cy = top + math.floor(index / cols) * (cardH + cardGap) + if cy + cardH >= listTop - listH and cy <= listTop + listH * 2 then local entry = entries[slot] local selected = entry.id == activeId Kit.card(cx, cy, cardW, cardH, selected and "selected" or nil) - local artH = cardH * 0.45 drawSkinArtwork(Studio.libraryThumb(entry), cx + 7 * Kit.scale, cy + 7 * Kit.scale, cardW - 14 * Kit.scale, artH) Kit.text("mono", Kit.ellipsize("mono", entry.id, cardW - 20 * Kit.scale), @@ -2524,6 +2560,8 @@ local function drawLibrary() local actionGap = 4 * Kit.scale local actionW = (cardW - 20 * Kit.scale - actionGap) * 0.5 local actionY = cy + cardH - btnH * 2 - actionGap - 8 * Kit.scale + Studio.libraryRects[entry.id] = + { x = cx + 8 * Kit.scale, y = actionY, w = actionW, h = btnH } if Kit.button(cx + 8 * Kit.scale, actionY, actionW, btnH, selected and "Selected" or "Select", { id = "skin-select-" .. entry.id, @@ -2534,7 +2572,8 @@ local function drawLibrary() "Edit", { id = "skin-edit-" .. entry.id, kind = "accent" }) then Studio.loadEntry(entry.id) Studio.enterEditor() - return + jumped = true + break end actionY = actionY + btnH + actionGap if Kit.button(cx + 8 * Kit.scale, actionY, actionW, btnH, @@ -2548,23 +2587,12 @@ local function drawLibrary() end end end - - if pages > 1 then - local pagerY = oy + H - pad - btnH - local prevW, nextW = (W - pad * 2 - gap) * 0.5, (W - pad * 2 - gap) * 0.5 - if Kit.button(ox + pad, pagerY, prevW, btnH, "Previous", - { id = "library-prev", enabled = Studio.libraryPage > 1 }) then - Studio.libraryPage = Studio.libraryPage - 1 - end - if Kit.button(ox + pad + prevW + gap, pagerY, nextW, btnH, "Next", - { id = "library-next", enabled = Studio.libraryPage < pages }) then - Studio.libraryPage = Studio.libraryPage + 1 - end - end + Kit.scrollEnd(listX, listTop, listW, listH, Studio.libraryScroll, maxScroll) + if jumped then return end if #entries == 0 then Kit.text("small", "Create a blank skin or import one to get started.", - ox + pad, y + cardH + gap, PAL.muted) + listX, listTop + gap, PAL.muted) end end @@ -2724,9 +2752,13 @@ function Studio.mousepressed(x, y, button, fromPad) if not fromPad then PadCursor.yieldToPointer() end Studio.pointerX, Studio.pointerY = x, y Studio.pointerDown = true + if Studio.modalUp() then Studio.clicked = true return end + if Studio.mode == "library" then + if fromPad then Studio.clicked = true return end + Studio.libraryPress = { x = x, y = y, at = Studio.libraryScroll or 0 } + return + end Studio.clicked = true - if Studio.modalUp() then return end - if Studio.mode == "library" then return end local r = Studio.lastCanvas if not r then return end if Studio.testing then @@ -2750,7 +2782,17 @@ end function Studio.mousemoved(x, y) Studio.pointerX, Studio.pointerY = x, y if Studio.modalUp() then return end - if Studio.mode == "library" then return end + if Studio.mode == "library" then + local p = Studio.libraryPress + if not p then return end + local ddx, ddy = x - p.x, y - p.y + if ddx * ddx + ddy * ddy > TAP_SLOP2 then p.dragged = true end + if p.dragged then + Studio.libraryScroll = Kit.scrollClamp(p.at - (y - p.y), + Studio.libraryScrollMax or 0) + end + return + end if Studio.testing then TouchControls:touchmoved("studio", x, y) return @@ -2764,6 +2806,9 @@ function Studio.mousereleased(x, y, button) if button ~= 1 then return end Studio.pointerX, Studio.pointerY = x, y Studio.pointerDown = false + local press = Studio.libraryPress + Studio.libraryPress = nil + if press and not press.dragged then Studio.clicked = true end if Studio.testing then TouchControls:touchreleased("studio", x, y) return @@ -2869,6 +2914,7 @@ end function Studio.focus() Studio.drag = nil Studio.clicked = false + Studio.libraryPress = nil Studio.pointerDown, Studio.touchId = false, nil if Studio.testing then TouchControls:reset() end end diff --git a/src/ui/TouchControlsEditor.lua b/src/ui/TouchControlsEditor.lua index a490746d..74735a38 100644 --- a/src/ui/TouchControlsEditor.lua +++ b/src/ui/TouchControlsEditor.lua @@ -78,9 +78,12 @@ function Editor.load(opts) and GameVersion.generation(opts.version) == 2 if gen2 then local gold = type(optsTbl.gold) == "table" and optsTbl.gold or {} + local hotbar = gold.hotbar + if hotbar == nil then hotbar = optsTbl.hotbar end applied = { touchControls = gold.touchControls, haptics = gold.haptics or optsTbl.haptics, + hotbar = hotbar, } end TouchControls:init() diff --git a/src/ui/gen2/BattleAnimView.lua b/src/ui/gen2/BattleAnimView.lua index 53ddf7ec..8303531b 100644 --- a/src/ui/gen2/BattleAnimView.lua +++ b/src/ui/gen2/BattleAnimView.lua @@ -306,6 +306,10 @@ function BattleAnimView.scanlines(bg) if inWindow then local value = signed(byte) if bg.lcdc == "SCX" then dx = -value else src = src + value end + -- home/lcd.asm:3 + if byte ~= 0x90 and scy == 0 then + if src < 0 then src = 0 elseif src >= SCREEN_H then src = SCREEN_H - 1 end + 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 } diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index b07505c9..4c24bb4e 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -235,12 +235,16 @@ end function BattleState:drawsWidescreen() return true end --- BATTLE BG (#1709): WHITE is the cart's paper surround, BLACK plain bars. +-- engine/battle/core.asm:8754 function BattleState:bgMode() local options = self.game and self.game.options - return (options and options.battleBg) == "black" and "black" or "white" + local mode = options and options.battleBg + if mode == "black" or mode == "world" then return mode end + return "white" end +BattleState.BG_WORLD_DIM = 0.55 + function BattleState:bottomUIVisible() if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end return Runtime.call("battle.bottom_ui_visible", function() return true end, diff --git a/src/ui/gen2/Chrome.lua b/src/ui/gen2/Chrome.lua index 419aa8e5..03c987c2 100644 --- a/src/ui/gen2/Chrome.lua +++ b/src/ui/gen2/Chrome.lua @@ -49,7 +49,10 @@ local function letterboxPaper() return c[1] / 255, c[2] / 255, c[3] / 255 end +Chrome.worldSurround = false + function Chrome.letterbox(winW, winH, r, g, b) + if Chrome.worldSurround then return end local Letterbox = require("src.render.Letterbox") local G = love.graphics G.setColor(Letterbox.fill(r or 1, g or 1, b or 1, letterboxPaper)) diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index ed9b2d7f..81a4f0b6 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -327,6 +327,15 @@ local ROWS = { TC.buzz(options.haptics) if game and game.persistOptions then game:persistOptions() end end }, + { id = "hotbar", label = Strings.source("KEY BAR"), port = true, + text = function(options) + return options.hotbar == false and Strings("OFF") or Strings("ON") + end, + cycle = function(options, _delta, game) + options.hotbar = options.hotbar == false + require("src.core.TouchControls"):applyOptions(options) + if game and game.persistOptions then game:persistOptions() end + end }, { label = Strings.source("MAX FPS"), key = "fpsCap", port = true, cycle = function(options, delta) local FrameCap = require("src.core.FrameCap") @@ -336,14 +345,22 @@ local ROWS = { text = function(options) return require("src.core.FrameCap").label(options.fpsCap) end }, + { label = Strings.source("VSYNC"), key = "vsync", port = true, + cycle = function(options, delta) + local VSync = require("src.core.VSync") + options.vsync = VSync.cycle(options.vsync, delta) + VSync.apply(options.vsync) + end, + text = function(options) + return Strings(require("src.core.VSync").label(options.vsync)) + 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. + -- BATTLE BG (#1709): the void around the battle screen. { label = Strings.source("BATTLE BG"), key = "battleBg", port = true, - values = { "white", "black" }, - display = { white = "WHITE", black = "BLACK" } }, + values = { "white", "black", "world" }, + display = { white = "WHITE", black = "BLACK", world = "WORLD" } }, { label = Strings.source("BACK"), cancel = true }, } @@ -361,7 +378,7 @@ local GROUPS = { { id = "group.speed", label = Strings.source("SPEED"), members = { "textSpeed", "speed" } }, { id = "group.video", label = Strings.source("VIDEO"), - members = { "videoMode", "screenPos", "fpsCap" } }, + members = { "videoMode", "screenPos", "fpsCap", "vsync" } }, { id = "group.graphics", label = Strings.source("GRAPHICS"), members = { "color", "uiLetterbox", "shaderfx", "shaderfx2", "frame" } }, { id = "group.audio", label = Strings.source("AUDIO"), @@ -445,7 +462,8 @@ local function buildRows() -- only has to drop this test. local hidden = row.key == "print" or (not showTouch and (row.id == "touchControls" - or row.id == "touchLayout" or row.id == "haptics")) + or row.id == "touchLayout" or row.id == "haptics" + or row.id == "hotbar")) if not hidden then local copy = {} for key, value in pairs(row) do copy[key] = value end diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index 62e442b2..bfa18eed 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -409,6 +409,9 @@ function PackMenu:useSelected() -- _CoinCaseCountText (data/text/common_3.asm:336): "Coins:" then the -- count, text_decimal 4 digits with PRINTNUM_LEFTALIGN_F so no padding. self.message = { "Coins:", tostring(extra or 0) } + elseif result == "blue_card" then + -- _BlueCardBalanceText (../pokecrystal/data/text/common_3.asm:1297). + self.message = { "You now have", tostring(extra or 0) .. " points." } elseif result == "repel_used" then -- ItemUsedText (data/text/common_3.asm): " used the\n." -- World already wrote the counter and took the item out of the bag, so diff --git a/src/ui/gen2/PokedexMenu.lua b/src/ui/gen2/PokedexMenu.lua index 17d6483a..675b1d8f 100644 --- a/src/ui/gen2/PokedexMenu.lua +++ b/src/ui/gen2/PokedexMenu.lua @@ -902,7 +902,8 @@ end 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 } + -- engine/pokedex/pokedex.asm:2459 + local paper = pal and GbcPalette.color(pal, 4) or { 0, 0, 0 } 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) @@ -913,7 +914,7 @@ function PokedexMenu:drawAreaHeader(title) 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) + Chrome.printThrough(title, 2, 0, pal, true, true) end function PokedexMenu:drawArea() diff --git a/src/ui/gen2/UnownPuzzle.lua b/src/ui/gen2/UnownPuzzle.lua index 262adbd2..83e8f3cd 100644 --- a/src/ui/gen2/UnownPuzzle.lua +++ b/src/ui/gen2/UnownPuzzle.lua @@ -285,8 +285,10 @@ function UnownPuzzle:loadGfx() end end +-- WaitPlaySFX (home/audio.asm:220) function UnownPuzzle:sfx(name) local data = self.game and self.game.data + Sound.waitSfxDone() if data then Sound.play(data, name) end end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index b2ffba7b..d44aa8c3 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1135,6 +1135,7 @@ function OverworldState:update(dt) if da.onDone then da.onDone() end end end + self:tickPoisonFlash() -- scripts/VermilionDock.asm:80 .shift_columns_up if self.shipAnim and not self.shipAnim.gone then local sa = self.shipAnim @@ -2758,13 +2759,14 @@ function OverworldState:billsHouseBillExits() local ctx = { game = Game, save = Game.save, overworld = self } Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1") require("src.world.PikachuFollower").onBillExitedMachine(Game, self) + local bill local function done() Game.save.flags.EVENT_MET_BILL = true Game.save.flags.EVENT_MET_BILL_2 = true require("src.core.Music").playMap(Game.data, self.map.id, Game.save.onBike, self.player.surfing) + self:billsHouseSSTicketScene(bill) end - local bill for _, n in ipairs(self.npcs) do if n.def and n.def.name == "BILLSHOUSE_BILL1" then bill = n break end end @@ -2782,6 +2784,22 @@ function OverworldState:billsHouseBillExits() end) end +-- pokeyellow scripts/BillsHouse.asm:211, :232 +function OverworldState:billsHouseSSTicketScene(bill) + if not GameVersion.isYellow() then return end + local function talk() + self.player.facing = "up" + if bill then bill.facing = "down" end + self:showMapText("TEXT_BILLSHOUSE_BILL_SS_TICKET", bill) + end + if not bill then + talk() + return + end + -- RLE_1e219, pokeyellow scripts/BillsHouse.asm:228 + self:scriptMove(self.player, "right", 3, talk) +end + -- Any hidden item still unfound NEAR the player? (the ITEMFINDER, -- engine/items/itemfinder.asm HiddenItemNear: coord > clamp0(player-5) -- and coord <= player+4 (Y) / player+5 (X) -- the clamp excludes @@ -4003,6 +4021,28 @@ end -- ------------------------------------------------------------------------- -- step events +-- engine/gfx/screen_effects.asm:7-8 +function OverworldState:tickPoisonFlash() + if (self.poisonFlash or 0) > 0 then + self.poisonFlash = self.poisonFlash - 1 + end +end + +-- engine/events/poison.asm:57, :93 +function OverworldState:poisonFlashLive() + if (self.poisonFlash or 0) <= 0 then return false end + return not (Game and Game.stack) or Game.stack:top() == self +end + +function OverworldState:poisonShadeMap() + if not self:poisonFlashLive() then return nil end + if PaletteFX.usesGbcPack() and self.map and self.map.renderer + and self.map.renderer.gbcAtlas then + return nil + end + return PaletteFX.POISON_BGP +end + -- Field poison (engine/events/poison.asm ApplyOutOfBattlePoisonDamage): -- every 4th step, 1 HP per poisoned mon; the BG flickers dark with -- SFX_POISONED; fainted mons get their message; a whole-party faint @@ -4030,9 +4070,16 @@ function OverworldState:applyFieldPoison() end end if not anyPoisoned then return false end - require("src.core.Sound").play(Game.data, "Poisoned") - -- engine/gfx/screen_effects.asm:7-8 - self.poisonFlash = 4 + -- engine/events/poison.asm:75-91 .countPoisonedLoop + local stillPoisoned = false + for _, mon in ipairs(save.party) do + if mon.status == "PSN" then stillPoisoned = true break end + end + if stillPoisoned then + require("src.core.Sound").play(Game.data, "Poisoned") + -- engine/gfx/screen_effects.asm:7-8 + self.poisonFlash = 4 + end local queue = {} for _, mon in ipairs(fainted) do local name = mon.nickname or Game.data.pokemon[mon.species].name @@ -5213,8 +5260,9 @@ function OverworldState:drawWorld() -- original's saved offset means. local battleOverWorld = Game and Game.stack and Game.worldBgBattleInStack(Game.stack) + -- home/fade.asm:66 PaletteFX.setShadeMap((self.dark and not battleOverWorld) - and PaletteFX.DARK_BGP or nil) + and PaletteFX.DARK_BGP or self:poisonShadeMap()) -- advance the water/flower tile animation (runs under dialogs too). -- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate -- does not speed or slow the cycle (issue #4). @@ -5784,13 +5832,13 @@ function OverworldState:drawUI() end -- engine/gfx/screen_effects.asm:1-12 - if self.poisonFlash and self.poisonFlash > 0 then - self.poisonFlash = self.poisonFlash - 1 + if self:poisonFlashLive() then local r = Game and Game.renderer - if PaletteFX.shader() then + local map = self:poisonShadeMap() + if PaletteFX.shader() and map then -- home/fade.asm:66 if not PaletteFX.shadeMap() then - PaletteFX.setShadeMap(PaletteFX.POISON_BGP) + PaletteFX.setShadeMap(map) end elseif r then r.screenVeil = { 0, 0.45 } diff --git a/src/world/gen2/Palettes.lua b/src/world/gen2/Palettes.lua index 74d30fa9..d7dfbed7 100644 --- a/src/world/gen2/Palettes.lua +++ b/src/world/gen2/Palettes.lua @@ -143,30 +143,52 @@ function Palettes.withCaveFlicker(set, sourceIndex) return out end +-- LoadSpecialMapPalette (engine/tilesets/tileset_palettes.asm:1) +function Palettes.specialSet(data, mapDef) + local sets = data and data.specialTilesets + local tileset = mapDef and mapDef.tileset + local set = sets and tileset and sets[tileset] + if not set then return nil end + if tileset == "TILESET_ICE_PATH" and mapDef.environment == "INDOOR" then + return nil + end + local out = {} + for slot = 1, 8 do + local source = set[slot] + local colors = {} + for i = 1, 4 do + local c = source and source[i] or BLACK + colors[i] = { c[1], c[2], c[3] } + end + out[slot] = colors + end + return out +end + -- The eight BG palettes loaded for this map, each { {r,g,b} x4 }, with the -- roof override already folded in. Index with a tileset's tilePalettes value. function Palettes.bgSet(data, mapDef, daytime) if not (data and data.bg and data.environments) then return nil end local env = mapDef and mapDef.environment - local row = env and data.environments[env] - -- ENVIRONMENT_5 and friends fall back to the outdoor table, same as the - -- pointer table's duplicate entries do. - row = row or data.environments.TOWN - if not row then return nil end - local indices = row[daytime] or row.DAY - if not indices then return nil end + -- LoadSpecialMapPalette wins over the pool -- engine/gfx/color.asm:1198 + local set = Palettes.specialSet(data, mapDef) + if not set then + local row = env and data.environments[env] + row = row or data.environments.TOWN + if not row then return nil end + local indices = row[daytime] or row.DAY + if not indices then return nil end - local set = {} - for slot = 1, 8 do - local pool = data.bg[indices[slot]] - -- Copy: the roof override below mutates one slot, and the pool entry is - -- shared by every map that picks it. - local colors = {} - for i = 1, 4 do - local c = pool and pool[i] or BLACK - colors[i] = { c[1], c[2], c[3] } + set = {} + for slot = 1, 8 do + local pool = data.bg[indices[slot]] + local colors = {} + for i = 1, 4 do + local c = pool and pool[i] or BLACK + colors[i] = { c[1], c[2], c[3] } + end + set[slot] = colors end - set[slot] = colors end local roofSlot = data.roofSlot or 7 diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index efa6309e..ef8874cf 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -2504,7 +2504,6 @@ end -- Script_loadwildmon's other half: roll the CURRENT map's own table the way a -- step would, and hand it to the startbattle that follows. nil is fine -- --- startScriptedBattle answers "win" rather than hanging when there is no mon. function World:rollWild() local map = self.map if not (map and self.encounters and self.player) then return nil end @@ -4599,6 +4598,10 @@ function World:useFieldItem(itemId) if itemId == "SQUIRTBOTTLE" then return self:useSquirtbottle() end -- CoinCaseEffect (engine/items/item_effects.asm:2243). if itemId == "COIN_CASE" then return "coin_case", self:coins() end + -- BlueCardEffect (../pokecrystal/engine/items/item_effects.asm:2251). + if itemId == "BLUE_CARD" then + return "blue_card", self:readVar(VAR.BLUECARDBALANCE) + end if REPEL_STEPS[itemId] then return self:useRepel(itemId) end if TROPHY_BOXES[itemId] then return self:openTrophyBox(itemId) end if not World.isRod(itemId, items) then return nil end @@ -5305,10 +5308,18 @@ end -- ones the ENGINE writes, where answering out of that store would read back a -- stale 0. RockSmashScript's `readmem wTempWildMonSpecies / iffalse` is the -- whole reason this exists: the byte is written by the callasm one row above. -local WRAM_TEMP_WILD_MON_SPECIES = 0xd117 +-- wTempWildMonSpecies: 01:d117 (pokesilver.sym), 01:d22e (pokecrystal.sym) +local WRAM_TEMP_WILD_MON_SPECIES = { gs = 0xd117, crystal = 0xd22e } + +function World:tempWildMonSpeciesAddress() + local GameVersion = require("src.core.GameVersion") + local save = self.game and self.game.save + local engine = GameVersion.engine((save and save.version) or GameVersion.get()) + return WRAM_TEMP_WILD_MON_SPECIES[engine] or WRAM_TEMP_WILD_MON_SPECIES.gs +end function World:scriptReadMem(addr) - if addr == WRAM_TEMP_WILD_MON_SPECIES then + if addr == self:tempWildMonSpeciesAddress() then return (self.tempWildMon and self.tempWildMon.species) or 0 end return nil @@ -6014,8 +6025,18 @@ end -- ---- fly ------------------------------------------------------------------ --- FlyMap's region split, which reads the PLAYER's landmark and nothing else: --- the first 46 landmarks are Johto, the rest Kanto. +-- ../pokecrystal/constants/landmark_constants.asm:34 +local LANDMARK_PALLET_TOWN = 0x2e +local LANDMARK_FAST_SHIP = 0x5e + +function World:landmarkIndex(id, fallback) + local records = self.landmarks and self.landmarks.landmarks + local record = records and (records["LANDMARK_" .. id] or records[id]) + local index = record and tonumber(record.index) + return index or fallback +end + +-- IsInJohto, the PLAYER's landmark and nothing else (home/region.asm:1) function World:region() local landmarks = self.landmarks and self.landmarks.landmarks local id = self.map and self.map.def and self.map.def.landmark @@ -6027,8 +6048,13 @@ function World:region() entry = order and landmarks and landmarks[order[id + 1]] end local index = (entry and entry.index) or (type(id) == "number" and id) or 0 - if index == 94 then return "johto" end -- LANDMARK_FAST_SHIP - return index >= 46 and "kanto" or "johto" + -- ../pokecrystal/home/region.asm:10 (cp LANDMARK_FAST_SHIP) + if index == self:landmarkIndex("FAST_SHIP", LANDMARK_FAST_SHIP) then + return "johto" + end + -- ../pokecrystal/home/region.asm:23 (cp KANTO_LANDMARK) + return index >= self:landmarkIndex("PALLET_TOWN", LANDMARK_PALLET_TOWN) + and "kanto" or "johto" end function World:flyPoints() @@ -6045,8 +6071,15 @@ local FLY = { LEAF_DEATH_X = 184, LEAF_AMP = 0x40, -- constants/sprite_anim_constants.asm:20 LEAF_MAX = 9, + -- data/sprite_anims/oam.asm:487 over the OAM origin + LEAF_OX = -4 - 8, LEAF_OY = -4 - 16, } +-- engine/sprite_anims/core.asm:216 (UpdateAnimFrame) +function World.leafScreenPos(leaf) + return leaf.x + (leaf.xoff or 0) + FLY.LEAF_OX, leaf.y + FLY.LEAF_OY +end + -- FlyFunction_InitGFX's GetSpeciesIcon (engine/events/field_moves.asm:390): -- the icon of the mon in wCurPartyMon, on PAL_OW_RED like every other OW OBJ. function World:flyIconFor(mon) @@ -6170,9 +6203,9 @@ function World:drawFlyLeaves(s) local function blit() G.setColor(1, 1, 1, 1) for _, leaf in ipairs(fa.leaves) do + local lx, ly = World.leafScreenPos(leaf) G.draw(sheet, self.cutGrassQuad, - math.floor((leaf.x + (leaf.xoff or 0) - 4) * s), - math.floor((leaf.y - 4) * s), 0, s, s) + math.floor(lx * s), math.floor(ly * s), 0, s, s) end end if colors and GbcPalette.available() then @@ -6651,13 +6684,18 @@ function World:startScriptedBattle(record, wild, onDone) if given then opts.wild.item = given end end end + -- engine/overworld/scripting.asm Script_startbattle 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)) + require("src.core.Logger").warn( + "no battle opened (trainer %s, class %s, member %s, party %d, wild %s)", + tostring(record and record.name), tostring(record and record.class), + tostring(record and record.member), + opts.trainer and #opts.trainer.party or 0, + tostring(wild and wild.species)) + if not wild then + self:refuseTrainer(record or (self.vm and self.vm.trainerObject)) end - if onDone then onDone("win") end + if onDone then onDone(nil) end return false end -- wBattleType, which `writevar VAR.BATTLETYPE / loadvar BATTLETYPE_*` armed: @@ -7579,6 +7617,22 @@ function World:loadObjectMasks(opts) self.maskScripted = scripted end +-- InitializeVisibleSprites (engine/overworld/player_object.asm:223) +function World:objectSpawnable(obj) + local map = self.map + local def = map and map.def + local wCells = (map and map.widthCells) or (def and def.width and def.width * 2) + local hCells = (map and map.heightCells) + or (def and def.height and def.height * 2) + if not (obj and wCells and hCells) then return true end + local ox, oy = obj.x or 0, obj.y or 0 + if ox >= 0 and oy >= 0 and ox < wCells and oy < hCells then return true end + local px = (self.player and self.player.cellX) or 0 + local py = (self.player and self.player.cellY) or 0 + local dx, dy = ox - px, oy - py + return dx >= -5 and dx < 7 and dy >= -5 and dy < 6 +end + -- Current-map NPCs + visual-only ghosts on neighbor strips (Gen 1 pattern). function World:rebuildPeople(opts) opts = opts or {} @@ -7615,7 +7669,7 @@ function World:rebuildPeople(opts) masked = not (self.events:objectVisible(obj.eventFlag) and self:objectTimeVisible(obj)) end - if not masked then + if not masked and self:objectSpawnable(obj) then local npc = self:pooledNpc(self.map.id, obj) if npc then fromMap[npc] = true @@ -7815,6 +7869,24 @@ function World:trainerBeaten(record) return self.events:get(record.event) and true or false end +local function trainerKey(record) + if not (record and record.class and record.member) then return nil end + return tostring(record.class) .. "/" .. tostring(record.member) +end + +function World:refuseTrainer(record) + local key = trainerKey(record) + if not key then return end + self.refusedTrainers = self.refusedTrainers or {} + self.refusedTrainers[key] = true +end + +function World:trainerRefused(record) + local key = trainerKey(record) + if not (key and self.refusedTrainers) then return false end + return self.refusedTrainers[key] and true or false +end + -- _CheckTrainerBattle: every visible, unbeaten trainer object that is facing -- the player along a shared row or column, within its own sight range. function World:checkTrainerBattle() @@ -7824,7 +7896,8 @@ function World:checkTrainerBattle() if not (save and save.party and #save.party > 0) then return false end for _, npc in ipairs(self.npcs) do local record = npc.def and npc.def.trainer - if record and not self:trainerBeaten(record) then + if record and not self:trainerBeaten(record) + and not self:trainerRefused(record) then local distance, dir = Trainers.sees(npc, self.player, npc.def.sight) if distance then return self:startTrainerScript(npc, SEEN_BY_TRAINER_SCRIPT, { @@ -7991,7 +8064,8 @@ function World:interactBody() local talkTo = Gen1Facade.talkToWrapper() if talkTo and talkTo(self, npc) then return true end end - if npc and npc.def and npc.def.trainer then + if npc and npc.def and npc.def.trainer + and not self:trainerRefused(npc.def.trainer) then interacted(self, fx, fy, "trainer", npc) return self:startTrainerScript(npc, TALK_TO_TRAINER_SCRIPT, nil) end diff --git a/tests/crystal_import_test.lua b/tests/crystal_import_test.lua index 5d4dd3e7..ed7dc327 100644 --- a/tests/crystal_import_test.lua +++ b/tests/crystal_import_test.lua @@ -194,4 +194,31 @@ for _, path in ipairs({ check(required[path], "the crystal cache contract still requires " .. path) end + +-- engine/tilesets/tileset_palettes.asm:1 +local specialPalettes = { + PokeComPalette = 0x5501, + BattleTowerInsidePalette = 0x5550, + IcePathPalette = 0x559f, + HousePalette = 0x55ee, + RadioTowerPalette = 0x563d, + MansionPalette1 = 0x567d, + MansionPalette2 = 0x56fe, +} +for label, address in pairs(specialPalettes) do + local at = crystal.symbols[label] + check(type(at) == "table", "the crystal manifest names " .. label) + if at then + eq(at[1], 0x12, label .. " lives in bank $12") + eq(at[2], address, ("%s at $%04x"):format(label, address)) + end + eq(gold.symbols[label], nil, "and Gold has no " .. label) + local overlay11 = rev11 and (crystal.symbolRevisions or {})[rev11.sha1] or {} + eq(overlay11[label], nil, label .. " sits at the same place in 1.1") +end + +-- LoadMansionPalette (engine/tilesets/tileset_palettes.asm:131) +check(crystal.symbols.MansionPalette2[2] - crystal.symbols.MansionPalette1[2] + >= 9 * 8, "MansionPalette1 has room for its ninth palette") + S.finish() diff --git a/tests/drivers/battle_scy_deform_bug1895_test.lua b/tests/drivers/battle_scy_deform_bug1895_test.lua index 4fb1f2b8..1cf16c85 100644 --- a/tests/drivers/battle_scy_deform_bug1895_test.lua +++ b/tests/drivers/battle_scy_deform_bug1895_test.lua @@ -82,6 +82,14 @@ return function(game) end end if dup then worst = "two source rows landed on one scanline" end + if bg.lcdc then + local last = math.min(bg.lyEnd, BattleAnimView.SCREEN_H) - 1 + for row = bg.lyStart, last do + if not seen[row] and (bg.lyBackup[row] or 0) ~= 0x90 then + worst = "scanline " .. row .. " was left blank" + end + end + end scanned = scanned + 1 end U.wait(1) @@ -104,6 +112,14 @@ return function(game) { "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." }, + { "NIGHT_SHADE", "05-night-shade", 10, + "NIGHT SHADE: the enemy window starts at scanline 0, so the wave", + "reaches the very top of the screen. Wrong is a white line flashing", + "across the top row every few frames (#1921)." }, + { "SURF", "06-surf", 30, + "SURF: the wave rolls up the screen and everything it has passed", + "ripples with it -- pics and HUD boxes sheared, not ruler straight", + "(#1920)." }, } for _, entry in ipairs(shots) do diff --git a/tests/drivers/bills_ss_ticket_bug1875_test.lua b/tests/drivers/bills_ss_ticket_bug1875_test.lua new file mode 100644 index 00000000..ba64260a --- /dev/null +++ b/tests/drivers/bills_ss_ticket_bug1875_test.lua @@ -0,0 +1,118 @@ +-- pokeyellow scripts/BillsHouse.asm:211, :232 (#1875) +return function(game) + local U = dofile("tests/drivers/util.lua") + local GameVersion = require("src.core.GameVersion") + local Pokemon = require("src.pokemon.Pokemon") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function idle() + while true do coroutine.yield() end + end + + if not check("running the Yellow cache (POKEPORT_VERSION=yellow)", + GameVersion.isYellow()) then + U.log("Re-run with POKEPORT_VERSION=yellow; Red and Blue have no script 7/8.") + idle() + end + + -- data/events/hidden_events.asm: BillsHousePC is hidden_event 1,4 facing up, + local MAP, PC_STAND = "BILLS_HOUSE", { x = 1, y = 5, facing = "up" } + + game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) } + game.save.player.name = "bryan" + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true + game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = nil + game.save.flags.EVENT_MET_BILL = nil + game.save.flags.EVENT_MET_BILL_2 = nil + game.save.flags.EVENT_GOT_SS_TICKET = nil + game.save.flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = nil + + U.teleport(game, MAP, PC_STAND.x, PC_STAND.y, PC_STAND.facing) + U.wait(20) + + local ow = game.overworld + if not check("Bill's House loaded", ow ~= nil and ow.map + and ow.map.id == MAP) then + idle() + end + check("standing on the PC cell facing up", + ow.player.cellX == PC_STAND.x and ow.player.cellY == PC_STAND.y + and ow.player.facing == "up") + + local startX, startY = ow.player.cellX, ow.player.cellY + + U.tap(game, "a") + U.wait(20) + for _ = 1, 120 do + if game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then break end + U.tap(game, "a") + U.wait(6) + end + check("the cell separator ran (EVENT_USED_CELL_SEPARATOR_ON_BILL)", + game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL == true) + + for _ = 1, 900 do + if game.save.flags.EVENT_MET_BILL_2 then break end + U.wait(1) + end + check("Bill left the machine (EVENT_MET_BILL_2)", + game.save.flags.EVENT_MET_BILL_2 == true) + + local function bill() + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == "BILLSHOUSE_BILL1" then return n end + end + return nil + end + + local b = bill() + check("BILL1 is on the map", b ~= nil) + if b then U.log("Bill at", b.cellX, b.cellY, "(scripts say 4,4)") end + + for _ = 1, 600 do + if ow.player.cellX >= startX + 3 then break end + U.wait(1) + end + check("the player was walked three cells east with no input", + ow.player.cellX == startX + 3 and ow.player.cellY == startY) + local opened = false + for _ = 1, 300 do + if game.stack:top() ~= ow then opened = true break end + U.wait(1) + end + check("the SS Ticket text opened with no A press", opened) + check("the player ends facing up (SPRITE_FACING_UP)", + ow.player.facing == "up") + if b then + check("BILL1 was turned down to face the player", b.facing == "down") + end + U.shot(game, SHOT_DIR .. "/bug1875_bills_auto_talk.png") + + for _ = 1, 300 do + if game.save.flags.EVENT_GOT_SS_TICKET then break end + U.tap(game, "a") + U.wait(6) + end + check("the S.S. TICKET was handed over", game.save.flags.EVENT_GOT_SS_TICKET == true) + check("the ticket is in the bag", + (game.save.inventory and game.save.inventory.S_S_TICKET or 0) > 0) + + U.log("On the cart you never walk over to Bill: after the separator he steps") + U.log("out, you slide three cells east on your own and he starts talking.") + U.log("Watch for the slide being cancellable (holding a direction should do") + U.log("nothing through it), and for the ticket jingle riding the RECEIVED") + U.log("box rather than firing early. Input is yours now; walking out to") + U.log("Route 25 arms the Eevee list on the PC.") + + idle() +end diff --git a/tests/drivers/crystal_house_palette_bug1924_test.lua b/tests/drivers/crystal_house_palette_bug1924_test.lua new file mode 100644 index 00000000..deeeece6 --- /dev/null +++ b/tests/drivers/crystal_house_palette_bug1924_test.lua @@ -0,0 +1,56 @@ +-- engine/tilesets/tileset_palettes.asm:1 (#1924) +local U = require("tests.drivers.util") + +local Palettes = require("src.world.gen2.Palettes") + +local GLASS = { + { 247, 230, 214 }, { 255, 156, 197 }, { 132, 107, 25 }, { 58, 58, 58 }, +} + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-house" + 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, "crystal world did not boot") + + -- data/maps/blocks.asm:199 -- House1.blk, whose bottom row is blocks 6 and 7 + game.world:warpToMapId("CHERRYGROVE_GYM_SPEECH_HOUSE", 3, 6, "up") + U.wait(20) + + local world = game.world + local def = world.map and world.map.def + pass(def ~= nil and def.tileset == "TILESET_HOUSE", + "the map loaded on TILESET_HOUSE") + + local special = Palettes.specialSet(world.palettes, def) + pass(special ~= nil, + "LoadSpecialMapPalette answers for it; nil means the cache predates " .. + "specialTilesets and wants a re-import") + + local set = Palettes.bgSet(world.palettes, def, world.daytime or "DAY") + local roof = set and set[world.palettes and world.palettes.roofSlot or 7] + for i, want in ipairs(GLASS) do + local got = roof and roof[i] + pass(got ~= nil and got[1] == want[1] and got[2] == want[2] + and got[3] == want[3], + ("PAL_BG_ROOF colour %d is %d,%d,%d (got %s)"):format( + i - 1, want[1], want[2], want[3], + got and ("%d,%d,%d"):format(got[1], got[2], got[3]) or "nothing")) + end + + U.log("00-house: a Cherrygrove house interior. the potted plants in the") + U.log("bottom-left and bottom-right corners sit in BROWN pots, not blue") + U.log("ones, and the floor around them is the same pink as the room.") + U.wait(4) + U.shot(game, out .. "/00-house.png") + + U.log(("%d check(s) failed"):format(failed)) + + while true do coroutine.yield() end +end diff --git a/tests/drivers/crystal_rock_smash_probe.lua b/tests/drivers/crystal_rock_smash_probe.lua new file mode 100644 index 00000000..3c13dc5e --- /dev/null +++ b/tests/drivers/crystal_rock_smash_probe.lua @@ -0,0 +1,78 @@ +local U = require("tests.drivers.util") + +local GameVersion = require("src.core.GameVersion") +local Mon = require("src.battle.gen2.Mon") + +-- data/wild/treemon_maps.asm RockMonMaps +local ROCK_MAP = "DARK_CAVE_VIOLET_ENTRANCE" +local STAND_X, STAND_Y = 15, 14 + +-- constants/pokemon_constants.asm; TreeMonSet_Rock is 90 KRABBY / 10 SHUCKLE. +local ROCK_SPECIES = { [98] = "KRABBY", [213] = "SHUCKLE" } + +return function(game) + local fails = 0 + + local function say(line) print("[rocksmash] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + U.wait(60) + local world = game.world + if not (world and world.map) then + say("FAIL the crystal world did not boot") + love.event.quit(1) + return + end + ok(GameVersion.engine() == "crystal", + "engine lineage is crystal (got " .. tostring(GameVersion.engine()) .. ")") + ok(world:tempWildMonSpeciesAddress() == 0xd22e, + "the readmem seam claims 01:d22e") + + world:setMap(ROCK_MAP, STAND_X, STAND_Y, "right") + U.wait(60) + ok(world.map and world.map.def and world.map.def.id == ROCK_MAP, + "arrived at " .. ROCK_MAP) + + local save = game.save + save.party = save.party or {} + if not save.party[1] then + save.party[1] = Mon.new(game.data, "GEODUDE", 20) + end + local lead = save.party[1] + lead.moves = lead.moves or {} + lead.moves[#lead.moves + 1] = { id = "ROCK_SMASH", pp = 15, maxPp = 15 } + ok(lead ~= nil, "the lead can be taught ROCK SMASH") + + world.rockmonRandom = function() return 0 end + world.noWildEncounters = true + + local fought + local realScripted = world.startScriptedBattle + world.startScriptedBattle = function(self, record, wild, onDone) + if wild and wild.species then fought = wild.species end + return realScripted(self, record, wild, onDone) + end + + for _ = 1, 60 do + if fought then break end + tap("a", 8) + end + + ok(fought ~= nil, "the smash reached startbattle at all") + ok(fought ~= nil and ROCK_SPECIES[fought] ~= nil, + "and the wild mon came out of TREEMON_SET_ROCK (got " + .. tostring(fought and (ROCK_SPECIES[fought] or fought)) .. ")") + + say(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/elevator_panel_bug395_test.lua b/tests/drivers/elevator_panel_bug395_test.lua index 459b3cb2..69b33b34 100644 --- a/tests/drivers/elevator_panel_bug395_test.lua +++ b/tests/drivers/elevator_panel_bug395_test.lua @@ -85,6 +85,12 @@ return function(game) U.tap(game, "a") U.wait(20) + -- (engine/events/elevator.asm:2-3, home/list_menu.asm:29-31) + U.shot(game, DIR .. "/elev395_1a_prompt.png") + for _ = 1, 180 do + if getmetatable(game.stack:top()) == ListMenu then break end + U.wait(1) + end local menu = getmetatable(game.stack:top()) == ListMenu check("A on the panel opens the floor list", menu) if menu then @@ -127,8 +133,10 @@ return function(game) U.log("The ride is over and the pad is yours: walk down and left onto the") U.log("door tile at (2,3) and you should come out on CELADON MART 2F. The") - U.log("panel is behind you -- A on it should reopen WHICH FLOOR?, and B out") - U.log("of that list should leave you in the car with 2F still the exit.") + U.log("panel is behind you -- A on it should reprint \"Which floor do you") + U.log("want?\" in the bottom box, then open the bordered floor list over the") + U.log("map with CANCEL as its last row; B, or A on CANCEL, should leave you") + U.log("in the car with 2F still the exit.") U.log("Worth checking by hand: Silph Co (same panel cell, 11 floors) and the") U.log("Rocket car, whose panel is at (1,1) so you face LEFT from (2,1) and") U.log("with no LIFT KEY get \"It appears to need a key.\" and no list.") diff --git a/tests/drivers/gold_battle_bg_world_bug1709_test.lua b/tests/drivers/gold_battle_bg_world_bug1709_test.lua new file mode 100644 index 00000000..312a39f2 --- /dev/null +++ b/tests/drivers/gold_battle_bg_world_bug1709_test.lua @@ -0,0 +1,188 @@ +-- maps/Route29.asm:432 (#1709) +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_battle_bg_world_bug1709_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-battle-bg-world love . +local U = require("tests.drivers.util") + +local Chrome = require("src.ui.gen2.Chrome") +local Mon = require("src.battle.gen2.Mon") +local OptionsMenu = require("src.ui.gen2.OptionsMenu") +local Permissions = require("src.world.gen2.Permissions") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-battle-bg-world" + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[bgworld] ok " .. label) + else + failures = failures + 1 + print("[bgworld] 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 bgRow + for _, row in ipairs(OptionsMenu.ROWS) do + if row.label == "BATTLE BG" then bgRow = row end + end + ok("OPTION carries a BATTLE BG row", bgRow ~= nil, bgRow) + if bgRow then + ok("and WORLD is on its ladder", bgRow.display.world == "WORLD", + bgRow.display.world) + end + + local battleModule = require("src.ui.gen2.BattleState") + ok("the battle screen publishes the veil's dim", + battleModule.BG_WORLD_DIM ~= nil, battleModule.BG_WORLD_DIM) + ok("and stays opaque: Game2 draws the map, not StateStack", + battleModule.isOpaque == true, battleModule.isOpaque) + + 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 a void 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 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 "[bgworld] preflight PASS -- the shots below are worth looking at" + or ("[bgworld] preflight FAIL (%d) -- fix these before judging a pixel") + :format(failures)) + + game.options.battleBg = "world" + U.wait(6) + U.log("00: Route 29 with BATTLE BG on WORLD. the overworld is not a battle,") + U.log("so nothing here may be dimmed -- the map and its surround look") + U.log("exactly as they do on WHITE.") + shot(out .. "/00-route29.png") + + 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(("[bgworld] 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 the world surround", battle:bgMode() == "world", + battle:bgMode()) + U.wait(10) + + U.log("01: the battle on WORLD. Route 29 fills the window on all four sides") + U.log("of the GB screen, darkened about halfway; the battle's own field, HUD") + U.log("and message box stay paper white with a clean edge. map pixels") + U.log("creeping over the panel edge, or a dimmed row of the HUD, is the band") + U.log("maths being wrong.") + shot(out .. "/01-battle-world.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 PKMN", pointAt(2), battle.menuIndex) + tap("a", 12) + ok("the party list opened over the battle", battle.phase == "submenu", + battle.phase) + U.log("02: the party list over the same battle. the map must still be there") + U.log("behind it, at the same dim. white flooding back the instant the list") + U.log("goes up means the paper fill is not being suppressed.") + shot(out .. "/02-party-over-world.png") + tap("b", 12) + + ok("the cursor is on PACK", pointAt(3), battle.menuIndex) + tap("a", 14) + U.log("03: the PACK over the battle, same rule -- still the dimmed map.") + shot(out .. "/03-pack-over-world.png") + tap("b", 12) + for _ = 1, 20 do + if battle.phase == "menu" then break end + tap("b", 4) + end + + game.options.battleFit = "fill" + U.wait(8) + U.log("04: WORLD with BATTLE SIZE on FILL. the panel is taller, so the map") + U.log("shows only down the left and right sides -- and still stops dead at") + U.log("the panel edge.") + shot(out .. "/04-battle-world-fill.png") + game.options.battleFit = "fixed" + U.wait(8) + + game.options.battleBg = "black" + U.wait(6) + ok("BLACK still reads black", battle:bgMode() == "black", battle:bgMode()) + U.log("05: the same battle on BLACK -- solid bars, no map anywhere.") + shot(out .. "/05-battle-black.png") + + game.options.battleBg = "white" + U.wait(6) + ok("and WHITE still reads white", battle:bgMode() == "white", battle:bgMode()) + U.log("06: the same battle on WHITE -- paper white to the window edge, no") + U.log("seam where the panel ends.") + shot(out .. "/06-battle-white.png") + + game.options.battleBg = "world" + U.wait(6) + + print(failures == 0 and "[bgworld] PASS gold_battle_bg_world_bug1709" + or ("[bgworld] FAIL gold_battle_bg_world_bug1709 (%d)"):format(failures)) + U.log("the battle is still up on WORLD and the controls are yours.") + + 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 index 76d56252..deafffe4 100644 --- a/tests/drivers/gold_dex_area_bug1891_test.lua +++ b/tests/drivers/gold_dex_area_bug1891_test.lua @@ -62,7 +62,7 @@ return function(game) 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("anywhere. row 0 is a black strip reading \"MILTANK'S NEST\" in pale") 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") @@ -84,8 +84,8 @@ return function(game) 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.") + U.log("pale-on-black caption on top. no \"AREA UNKNOWN\" printed over the") + U.log("baked-in KANTO at the bottom left.") shot("02-kanto") U.log(("%d check(s) failed"):format(failed)) diff --git a/tests/drivers/gold_jingle_bug1860_test.lua b/tests/drivers/gold_jingle_bug1860_test.lua index fcf254a4..1dad7075 100644 --- a/tests/drivers/gold_jingle_bug1860_test.lua +++ b/tests/drivers/gold_jingle_bug1860_test.lua @@ -51,6 +51,7 @@ return function(game) U.wait(2) local everBusy, maxRun, run = false, 0, 0 local sawHeld, popEarly = false, false + local sawArrowWhileBusy = false local pressIn = 4 for _ = 1, 1500 do local busy = Sound.sfxBusy() @@ -63,6 +64,10 @@ return function(game) end local top = game.stack:top() if top and top.sfxWait and busy then sawHeld = true end + -- LoadBlinkingCursor (home/text.asm:749) (#1926) + if busy and top and top.arrowVisible and top:arrowVisible() then + sawArrowWhileBusy = true + end pressIn = pressIn - 1 if pressIn <= 0 then pressIn = 4 @@ -83,6 +88,7 @@ return function(game) :format(label, maxRun, minRun), maxRun >= minRun) check(label .. ": the held box refused A while it rang", sawHeld and not popEarly) + check(label .. ": no arrow while the jingle rang", not sawArrowWhileBusy) end if tm then diff --git a/tests/drivers/gold_unown_puzzle_jingle_test.lua b/tests/drivers/gold_unown_puzzle_jingle_test.lua new file mode 100644 index 00000000..9a744e76 --- /dev/null +++ b/tests/drivers/gold_unown_puzzle_jingle_test.lua @@ -0,0 +1,77 @@ + +local U = require("tests.drivers.util") + +local Sound = require("src.core.Sound") +local UnownPuzzle = require("src.ui.gen2.UnownPuzzle") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/unown-jingle" + local fails = 0 + local function say(line) print("[driver] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "OK " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + assert(world and world.unownPuzzle, "gold world did not boot") + + -- engine/games/unown_puzzle.asm:286 UnownPuzzle_A + local solved = nil + world:unownPuzzle(0, function(done) solved = done end) + U.wait(30) + local screen = game.stack:top() + assert(getmetatable(screen) == UnownPuzzle, "the puzzle screen is not on top") + + for cell = 1, UnownPuzzle.CELLS do + screen.pieces[cell] = UnownPuzzle.SOLVED[cell] + end + local pc = UnownPuzzle.puzcoord + local home, ring = pc(4, 4), pc(0, 0) + screen.pieces[home + 1] = 0 + screen.pieces[ring + 1] = 16 + screen.cursor = ring + + local log = {} + local realPlay = Sound.play + Sound.play = function(data, name) + local src = realPlay(data, name) + log[#log + 1] = name .. (src and "" or " (DROPPED)") + return src + end + + U.tap(game, "a") + U.wait(20) + screen.cursor = home + U.tap(game, "a") + U.wait(45) + Sound.play = realPlay + + say("sfx: " .. table.concat(log, ", ")) + ok(screen.solved == true, "the board is solved") + local clicks = 0 + for _, entry in ipairs(log) do + if entry:match("^Sfx_MegaKick") or entry:match("^Sfx_PlacePuzzlePieceDown") + then + clicks = clicks + 1 + ok(not entry:match("DROPPED"), entry .. " started") + end + end + ok(clicks == 2, "both clicks were played") + local fanfare = nil + for _, entry in ipairs(log) do + if entry:match("^Sfx_1stPlace") then fanfare = entry end + end + ok(fanfare ~= nil, "Sfx_1stPlace was requested") + ok(fanfare ~= nil and not fanfare:match("DROPPED"), + "and the priority gate started it") + U.shot(game, out .. "/solved.png") + + U.tap(game, "a") + U.wait(30) + ok(solved == true, "A after the fanfare closes the screen as solved") + + say(fails == 0 and "PASS" or ("FAIL " .. fails)) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/launcher_find_tags_shot.lua b/tests/drivers/launcher_find_tags_shot.lua new file mode 100644 index 00000000..2dc57f3c --- /dev/null +++ b/tests/drivers/launcher_find_tags_shot.lua @@ -0,0 +1,92 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local RomImporter = require("src.import.RomImporter") + + local dir = os.getenv("SHOT_DIR") or "/tmp/findtags" + os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') + love.window.setMode(1024, 768, { resizable = true, highdpi = true }) + U.wait(2) + + local imp = RomImporter.new(function() end, { launcher = true }) + + local function mod(id, title, games, tags) + return { + id = id, title = title, author = "someone", version = "1.0.0", + summary = "A canned listing for the screenshot.", + categories = { "GAMEPLAY" }, tags = tags, games = games, + update_check = "off", + latest = { version = "1.0.0", zip = { + url = "https://example.invalid/" .. id .. ".zip" } }, + } + end + + imp._refreshFindSources = function() end + imp._refreshFind = function() end + imp._pumpFindFetch = function() end + imp._findFetch = nil + imp.findSources = { { feed = "https://example.invalid/data/index.json", + base = "https://example.invalid/", + label = "example/index" } } + imp.findIndex = { schemaVersion = 1, categories = { "GAMEPLAY" }, mods = { + mod("kanto", "Kanto Only", { "gen1" }, { "challenge", "balance" }), + mod("johto", "Johto Only", { "gen2" }, { "qol" }), + mod("both", "Every Game", { "all" }, { "audio" }), + mod("silent", "Says Nothing", {}, {}), + mod("gold", "Gold Alone", { "gold" }, { "sprites", "art", "extra" }), + } } + imp.findLoaded = true + imp:_switchTab("find") + U.wait(3) + + local pending = nil + love.draw = function() + imp:draw() + if pending then + local path = pending + pending = nil + love.graphics.captureScreenshot(function(imagedata) + local f = io.open(path, "wb") + if f then f:write(imagedata:encode("png"):getString()) f:close() end + end) + end + end + local function shot(name) + pending = dir .. "/" .. name + for _ = 1, 90 do + if not pending then break end + imp:update(1 / 60) + coroutine.yield() + end + U.wait(3) + local f = io.open(dir .. "/" .. name, "rb") + U.log(f and "shot" or "FAIL shot", name) + if f then f:close() end + end + + imp.modScope = "gold" + U.wait(2) + shot("find_tags_all.png") + U.log("rows with modScope=gold:", tostring(#imp:_findRows())) + + imp._filterPopup = true + U.wait(2) + shot("find_filter_popup.png") + imp._filterPopup = nil + + for _, key in ipairs({ "gen1", "gen2" }) do + imp:_setFindGame(key) + U.wait(2) + shot("find_game_" .. key .. ".png") + local ids = {} + for _, e in ipairs(imp:_findRows()) do ids[#ids + 1] = e.id end + U.log("game " .. key .. ":", table.concat(ids, ", ")) + end + imp:_setFindGame(nil) + + love.window.setMode(520, 820, { resizable = true, highdpi = true }) + U.wait(4) + shot("find_tags_narrow.png") + + U.log("done") + love.event.quit() +end diff --git a/tests/drivers/menu_hold_scroll_bug1898_test.lua b/tests/drivers/menu_hold_scroll_bug1898_test.lua index 8a3f9fb1..27eb60a6 100644 --- a/tests/drivers/menu_hold_scroll_bug1898_test.lua +++ b/tests/drivers/menu_hold_scroll_bug1898_test.lua @@ -59,12 +59,35 @@ return function(game) U.log("captured", SHOT_DIR .. "/bug1898_bag_bottom.png") end + -- home/list_menu.asm:47, 61-64, 338-342 + list.index, list.scroll = 4, 1 + U.wait(2) + table.insert(game.input.pressQueue, "down") + game.input.state.down = true + coroutine.yield() + game.input.state.down = false + local blanked = list.cursorBlank + U.log("cursorBlank right after a scrolling step:", tostring(blanked)) + check("a scroll step blanks the arrow", (blanked or 0) > 0) + if U.shot(game, SHOT_DIR .. "/bug1898_cursor_off.png") then + U.log("captured", SHOT_DIR .. "/bug1898_cursor_off.png") + end + U.wait(5) + U.log("cursorBlank five frames later:", tostring(list.cursorBlank)) + check("and puts it back after Delay3", (list.cursorBlank or 0) == 0) + if U.shot(game, SHOT_DIR .. "/bug1898_cursor_on.png") then + U.log("captured", SHOT_DIR .. "/bug1898_cursor_on.png") + end + U.log("Compare the two: the arrow left of the item name is missing in") + U.log("bug1898_cursor_off.png and present in bug1898_cursor_on.png.") + 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.") + U.log("While it is running, the arrow flickers: three frames of every five.") while true do coroutine.yield() diff --git a/tests/drivers/poison_flicker_bug1872_test.lua b/tests/drivers/poison_flicker_bug1872_test.lua index ffd3a112..cac8c0e5 100644 --- a/tests/drivers/poison_flicker_bug1872_test.lua +++ b/tests/drivers/poison_flicker_bug1872_test.lua @@ -33,31 +33,45 @@ return function(game) 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 + local ow = game.overworld + local function walkToTick() + for i = 1, 160 do + local dir = (i % 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 + if (ow and ow.poisonFlash or 0) > 0 then + game.input.state[dir] = false + return true + end + end + game.input.state[dir] = false 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) + return false end - local sawMap = false - for _ = 1, 6 do - U.wait(1) + local armed = walkToTick() + check("a poison tick armed the flicker while walking", armed) + + -- DelayFrames counts vblanks (engine/gfx/screen_effects.asm:1-12, #1908) + local lit, sawMap, drawSafe = armed and 1 or 0, false, true + for _ = 1, 12 do + local before = ow.poisonFlash or 0 + if before > 0 then + for _ = 1, 3 do pcall(function() ow:drawUI() end) end + if (ow.poisonFlash or 0) ~= before then drawSafe = false end + end if PaletteFX.shadeMap() == PaletteFX.POISON_BGP then sawMap = true end + coroutine.yield() + ow = game.overworld + if (ow.poisonFlash or 0) > 0 then lit = lit + 1 else break end + end + U.log("tinted frames", lit) + if armed then + check("it arms four frames, like `ld c, 4 / call DelayFrames`", lit == 4) + check("extra draws inside one logic step never shorten it", drawSafe) end check("the flicker goes through the rBGP shade map, not a canvas overlay", sawMap) @@ -65,10 +79,37 @@ return function(game) love.graphics.rectangle = realRect U.shot(game, "poison_flicker_bug1872.png") + + PaletteFX.setMode("redpp") + game.save.options.colors = "redpp" + U.wait(30) + ow = game.overworld + local advanced = PaletteFX.usesGbcPack() and ow.map and ow.map.renderer + and ow.map.renderer.gbcAtlas ~= nil + check("ADVANCED is live with a baked atlas", advanced and true or false) + if walkToTick() then + local veiled, doubled = false, false + for _ = 1, 8 do + local r = game.renderer + if r and r.screenVeil and r.screenVeil[2] > 0 then veiled = true end + if PaletteFX.shadeMap() == PaletteFX.POISON_BGP then doubled = true end + if (ow.poisonFlash or 0) <= 0 then break end + coroutine.yield() + ow = game.overworld + end + check("ADVANCED falls back to the whole-window veil", veiled) + check("and arms no shade map on top of the veil", not doubled) + U.shot(game, "poison_flicker_bug1908_advanced.png") + else + check("a second poison tick fired under ADVANCED", false) + end 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.") + U.log("The run left COLORS on ADVANCED, where the shift comes from the veil") + U.log("rather than the shade map: switch back through OPTIONS and compare --") + U.log("both should darken by the same amount and for the same four frames.") while true do coroutine.yield() diff --git a/tests/drivers/refresh_cadence_test.lua b/tests/drivers/refresh_cadence_test.lua new file mode 100644 index 00000000..7b47f87a --- /dev/null +++ b/tests/drivers/refresh_cadence_test.lua @@ -0,0 +1,63 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local FrameCap = require("src.core.FrameCap") + local RefreshRate = require("src.core.RefreshRate") + local VSync = require("src.core.VSync") + + local FRAMES = 300 + + U.log("MAX FPS reads", FrameCap.label(game.save.options.fpsCap), + "live cap", FrameCap.current, + "(0 = DISPLAY, no software pacing)") + U.log("VSYNC reads", VSync.label(game.save.options.vsync), + "driver says", + (love.window.getVSync and tostring(love.window.getVSync())) or "unknown") + + local snapped = dofile("src/core/FixedStep.lua") + local bare = dofile("src/core/FixedStep.lua") + local snappedSteps, bareSteps = 0, 0 + snapped:init(function() snappedSteps = snappedSteps + 1 end) + bare:init(function() bareSteps = bareSteps + 1 end) + + local hist, worstRun, run = {}, 0, 0 + local margin, total = 1, 0 + local last = love.timer.getTime() + for _ = 1, FRAMES do + U.wait(1) + local now = love.timer.getTime() + local dt = now - last + last = now + total = total + dt + RefreshRate.sample(dt) + snapped.refreshPeriod = RefreshRate.period() + local before = snappedSteps + snapped:update(dt) + bare:update(dt) + local n = snappedSteps - before + hist[n] = (hist[n] or 0) + 1 + run = (n == 0) and run + 1 or 0 + if run > worstRun then worstRun = run end + local residual = snapped.accum % snapped.STEP + margin = math.min(margin, residual, snapped.STEP - residual) + end + + local hz = RefreshRate.hz() + U.log("SDL/measured refresh", hz and string.format("%.2fHz", hz) or "unknown", + "measured present rate", + string.format("%.2fHz", FRAMES / math.max(total, 1e-6))) + U.log("mismatch (not a multiple of 60Hz)", + tostring(RefreshRate.mismatch() ~= nil)) + local counts = {} + for n = 0, 4 do + if hist[n] then counts[#counts + 1] = n .. "x" .. hist[n] end + end + U.log("steps per display frame", table.concat(counts, " ")) + U.log("longest run of duplicated frames", worstRun) + U.log("snapped steps", snappedSteps, "unsnapped steps", bareSteps, + "expected", math.floor(total * 60 + 0.5)) + U.log("closest the accumulator came to the step boundary", + string.format("%.3fms", margin * 1000)) + + U.log("watch the overworld scroll now; PASS is even motion, FAIL is a", + "two-fast-one-stalled beat") +end diff --git a/tests/drivers/route.lua b/tests/drivers/route.lua index 5432bb85..f0761dff 100644 --- a/tests/drivers/route.lua +++ b/tests/drivers/route.lua @@ -6350,7 +6350,8 @@ local function rideElevator(where, wantMap) if ow().map.id == m.id and p.cellX == sx and p.cellY == sy then faceDir(s[3]) press("a") - if waitFor(isList, 60) then return true end + -- the prompt box types out first (engine/events/elevator.asm:2-3) + if waitFor(isList, 180) then return true end end end end @@ -6366,7 +6367,7 @@ local function rideElevator(where, wantMap) local from = ow().map.id if not isList() and not pressPanel() then note("elevator: no floor menu", where) - say(("elevator on %s: the WHICH FLOOR? menu never opened"):format(from)) + say(("elevator on %s: the floor list never opened"):format(from)) return false end local idx diff --git a/tests/drivers/touch_hotbar_1592.lua b/tests/drivers/touch_hotbar_1592.lua new file mode 100644 index 00000000..243d4067 --- /dev/null +++ b/tests/drivers/touch_hotbar_1592.lua @@ -0,0 +1,69 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local TouchControls = require("src.core.TouchControls") + local Pokemon = require("src.pokemon.Pokemon") + + 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), + Pokemon.new(game.data, "PIKACHU", 30), + } + game.save.player.name = "bryan" + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(8) + + check("the overlay is on screen (POKEPORT_TOUCH=1?)", TouchControls:visible()) + check("and the strip is enabled", TouchControls:hotbarShown()) + + local L = TouchControls:layout() + local z = L.hotbar + if not check("the toggle has a zone", z ~= nil) then + while true do coroutine.yield() end + end + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + U.shot(game, SHOT_DIR .. "/hotbar_folded.png") + + TouchControls:touchpressed("probe", z.cx, z.cy) + TouchControls:touchreleased("probe", z.cx, z.cy) + U.wait(2) + check("tapping the toggle opens the strip", TouchControls.hotbarOpen == true) + U.shot(game, SHOT_DIR .. "/hotbar_open.png") + + local cells = TouchControls:hotbarStrip() or {} + check("the strip laid out its cells", #cells > 0) + local save = cells[1] + if save then + local before = game.save.player.name + TouchControls:touchpressed("probe", save.x + save.w / 2, save.y + save.h / 2) + U.wait(2) + TouchControls:touchreleased("probe", save.x + save.w / 2, save.y + save.h / 2) + U.wait(4) + check("tapping " .. tostring(save.label) .. " left the game running", + game.save ~= nil and game.save.player.name == before) + end + + TouchControls:touchpressed("probe", z.cx, z.cy) + TouchControls:touchreleased("probe", z.cx, z.cy) + U.wait(2) + check("tapping it again folds the strip away", + TouchControls.hotbarOpen == false) + U.shot(game, SHOT_DIR .. "/hotbar_refolded.png") + + U.log("The window is live and the mouse is a finger. The chevron sits at the") + U.log("top right: tapping it should slide a semitransparent bar of SAVE, LOAD,") + U.log("SPEED, COLOR, TILT and ZOOM under it, and a second tap should fold it") + U.log("away. SPEED/COLOR/TILT/ZOOM must change the picture exactly as keys") + U.log("1/2/3/4 do; LOAD must jump to the last save. The bar must never cover") + U.log("the d-pad or A/B, and no cell may leave a key held after the finger") + U.log("lifts -- walk with the d-pad straight after tapping one to be sure.") + U.log("Options > KEY BAR OFF should remove the chevron entirely.") + + while true do + coroutine.yield() + end +end diff --git a/tests/engine/bag_cancel_row_bug1685.lua b/tests/engine/bag_cancel_row_bug1685.lua index ad33646f..605cafe6 100644 --- a/tests/engine/bag_cancel_row_bug1685.lua +++ b/tests/engine/bag_cancel_row_bug1685.lua @@ -114,6 +114,8 @@ local function press(list, btn) list.game.input.pressed = btn list:update(1 / 60) list.game.input.pressed = nil + -- (home/list_menu.asm:61-63, 338-342) + for _ = 1, 4 do list:update(1 / 60) end end -- the row itself: one CANCEL after the last bag entry, carrying no id and no diff --git a/tests/engine/cart_launcher.lua b/tests/engine/cart_launcher.lua index 2616200b..01b48435 100644 --- a/tests/engine/cart_launcher.lua +++ b/tests/engine/cart_launcher.lua @@ -1204,9 +1204,11 @@ local INDEX_FEED = ModIndex.parse(Json.encode({ base_games = { "red", "blue", "yellow", "gold", "silver" }, mods = { { id = "rare_soda", title = "Rare Soda", author = "Ren", version = "0.4.1", - categories = { "GAMEPLAY" }, update_check = "off" }, + categories = { "GAMEPLAY" }, games = { "gen1" }, tags = { "items" }, + update_check = "off" }, { id = "true_colour", title = "True Colour", author = "Sam", - version = "1.0.0", categories = { "ART" }, update_check = "off" }, + version = "1.0.0", categories = { "ART" }, games = { "gen2" }, + update_check = "off" }, }, carts = { INDEX_CART, @@ -1242,6 +1244,39 @@ check(modsFind:find("Mods (2)", 1, true) ~= nil, "the switch says how many mods the feed lists") check(modsFind:find("Carts (2)", 1, true) ~= nil, "and how many carts") check(modsFind:find("Rare Soda", 1, true) ~= nil, "mods are what is listed") +check(modsFind:find("GEN 1", 1, true) ~= nil, + "a listing says which games it is for") +check(modsFind:find("items", 1, true) ~= nil, "and carries its feed tags") +check(find.findGame == nil, "a fresh launcher browses every game") + +do + window(520, 820) + local narrowFind = drawAndCapture(find) + check(narrowFind:find("GEN 1", 1, true) ~= nil, + "a phone-width row keeps the chip: the title ellipsizes around it") + check(narrowFind:find("Rare", 1, true) ~= nil, + "and enough of the title to name the mod") + window(1280, 720) +end + +find.modScope = "gold" +eq(#find:_findRows(), 2, "the MODS tab's game does not narrow the index") +find.modScope = nil + +find:_setFindGame("gen2") +eq(#find:_findRows(), 1, "the index's own game filter does narrow it") +eq(find:_findRows()[1].id, "true_colour", "to the listings for that generation") +do + find._filterPopup = true + local gameFilter = drawAndCapture(find) + find._filterPopup = nil + check(gameFilter:find("Filter by game", 1, true) ~= nil, + "and the popup is where that filter lives") +end +find:_setFindGame("red") +eq(find:_findRows()[1].id, "rare_soda", "a single game reads its generation") +find:_setFindGame(nil) +eq(#find:_findRows(), 2, "clearing it restores the whole index") find:_setFindKind("carts") eq(find.findKind, "carts", "the switch flips to carts") diff --git a/tests/engine/frame_cap_display.lua b/tests/engine/frame_cap_display.lua new file mode 100644 index 00000000..9d3493a7 --- /dev/null +++ b/tests/engine/frame_cap_display.lua @@ -0,0 +1,122 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local FrameCap = require("src.core.FrameCap") +local RefreshRate = require("src.core.RefreshRate") + +RefreshRate.reset() + +T.eq(FrameCap.DISPLAY, 0, "DISPLAY is the zero cap") +T.eq(FrameCap.CYCLE[#FrameCap.CYCLE], FrameCap.DISPLAY, + "and it is the last stop on the cycle ring") +T.eq(#FrameCap.CYCLE, #FrameCap.STEPS + 1, + "the ring is the steps plus that one stop") + +T.eq(FrameCap.normalize(0), FrameCap.DISPLAY, "a stored 0 is DISPLAY") +T.eq(FrameCap.normalize(-5), FrameCap.DISPLAY, "and so is a negative cap") +T.eq(FrameCap.normalize(1), 30, "a positive cap below the floor still clamps") +T.eq(FrameCap.normalize(9999), 160, "and one above the ceiling still clamps") +T.eq(FrameCap.normalize(nil), 60, "nil is still the 60 default") +T.eq(FrameCap.normalize("junk"), 60, "and so is garbage") + +T.eq(FrameCap.label(0), "DISPLAY", "the row prints DISPLAY for the zero cap") +T.eq(FrameCap.label(60), "60", "and plain numbers for the rest") + +T.eq(FrameCap.cycle(160, 1), FrameCap.DISPLAY, "the ceiling cycles to DISPLAY") +T.eq(FrameCap.cycle(FrameCap.DISPLAY, 1), 30, "which then wraps to the floor") +T.eq(FrameCap.cycle(30, -1), FrameCap.DISPLAY, "and back down again") +T.eq(FrameCap.cycle(FrameCap.DISPLAY, -1), 160, "DISPLAY steps down to 160") + +local before = FrameCap.current +FrameCap.apply(FrameCap.DISPLAY) +T.eq(FrameCap.current, 0, "apply stores the uncapped value as-is") +FrameCap.apply(before) + + +FrameCap.migrated = false +local opts = { fpsCap = 60 } +FrameCap.applyOptions(opts) +T.eq(opts.fpsCap, 60, "an unknown refresh leaves the 60 default alone") +T.eq(FrameCap.current, 60, "and paces to it") + +local function measure(hz) + RefreshRate.reset() + for _ = 1, 31 do RefreshRate.sample(1 / hz) end +end + +measure(90) +T.eq(RefreshRate.mismatch(), 90, "90Hz is not a multiple of 60") +T.eq(FrameCap.label(0), "DISPLAY (90HZ)", "so the row names the panel rate") +T.eq(FrameCap.label(60), "60 (90HZ)", "beside whatever the cap reads") + +FrameCap.migrated = false +opts = { fpsCap = 60 } +FrameCap.applyOptions(opts) +T.eq(opts.fpsCap, FrameCap.DISPLAY, "the untouched 60 default moves to DISPLAY") +T.eq(FrameCap.current, 0, "and the loop stops pacing") + +T.eq(opts.fpsCapMigrated, true, "and stamps the marker that says it ran") + +opts = { fpsCap = 60 } +FrameCap.applyOptions(opts) +T.eq(opts.fpsCap, 60, "a later 60 is never rewritten") +T.eq(FrameCap.current, 60, "and paces to it") + +FrameCap.migrated = false +opts = { fpsCap = 60, fpsCapMigrated = true } +FrameCap.applyOptions(opts) +T.eq(opts.fpsCap, 60, "a 60 picked after the migration ran survives the next boot") +T.eq(FrameCap.current, 60, "which is the cap that session paces to") + +FrameCap.migrated = false +opts = { fpsCap = 144 } +FrameCap.applyOptions(opts) +T.eq(opts.fpsCap, 144, "a cap that is not the default is never migrated") + +measure(144) +T.eq(RefreshRate.mismatch(), 144, "144Hz is no multiple of 60 either") +FrameCap.migrated = false +opts = { fpsCap = 60 } +FrameCap.applyOptions(opts) +T.eq(opts.fpsCap, 60, "but a panel above the beat range keeps the shipped cap") +T.eq(opts.fpsCapMigrated, nil, "and is not marked migrated, so a slower panel still is") + +measure(75) +FrameCap.migrated = false +opts = { fpsCap = 60 } +FrameCap.applyOptions(opts) +T.eq(opts.fpsCap, FrameCap.DISPLAY, "75Hz has the same halved beat as 90 and migrates") + +measure(120) +T.eq(RefreshRate.mismatch(), nil, "120Hz shows 60Hz logic 1:1") +T.eq(FrameCap.label(60), "60", "so the row says nothing about it") +FrameCap.migrated = false +opts = { fpsCap = 60 } +FrameCap.applyOptions(opts) +T.eq(opts.fpsCap, 60, "and the 60 default stays put") + +measure(59.94) +T.eq(RefreshRate.mismatch(), nil, "a 59.94Hz panel is within a hertz of 60") + +measure(59) +T.eq(RefreshRate.hz(), 60, "the integer 59 a driver reports for it reads as 60") +T.eq(RefreshRate.period(), 1 / 60, "so the step period snapped to is the 60Hz one") +T.eq(RefreshRate.mismatch(), nil, "and it is no mismatch") +FrameCap.migrated = false +opts = { fpsCap = 60 } +FrameCap.applyOptions(opts) +T.eq(opts.fpsCap, 60, "nor does it move the cap") + +measure(60.3) +T.eq(RefreshRate.hz(), 60, "a measured 60.3 quantizes to the panel's 60") +measure(59.7) +T.eq(RefreshRate.hz(), 60, "and so does a measured 59.7") +measure(89.8) +T.eq(RefreshRate.hz(), 90, "while a measured 89.8 is a 90Hz panel") +T.eq(RefreshRate.period(), 1 / 90, "with the 90Hz period, not the measurement's") + +RefreshRate.reset() +FrameCap.migrated = false +FrameCap.apply(before) + +T.finish("frame cap display") diff --git a/tests/engine/gen2_fly_leaves_bug1889.lua b/tests/engine/gen2_fly_leaves_bug1889.lua index 244bc9e8..6b2e4bc4 100644 --- a/tests/engine/gen2_fly_leaves_bug1889.lua +++ b/tests/engine/gen2_fly_leaves_bug1889.lua @@ -20,6 +20,18 @@ do T.eq(fa.leaves[1].x, 2, "AnimSeq_FlyLeaf's `inc [hl] / inc [hl]` on XCOORD") end +do + local world, fa = newWorld(), { leaves = {} } + world:spawnFlyLeaves(fa) + local leaf = fa.leaves[1] + local x, y = World.leafScreenPos(leaf) + T.eq(x, 2 + 0x40 - 12, + "XCOORD + XOFFSET less the OAM origin and .OAMData_Leaf's own -1 tile") + T.eq(y, 0x40 - 1 - 20, "and YCOORD the same way") + T.eq(select(1, World.leafScreenPos({ x = 0, y = 0 })), -12, + "a leaf with no XOFFSET yet still gets the bias") +end + do local world, fa = newWorld(), { leaves = {} } local rows = {} diff --git a/tests/engine/gen2_jingle_arrow_bug1926.lua b/tests/engine/gen2_jingle_arrow_bug1926.lua new file mode 100644 index 00000000..9a595ad7 --- /dev/null +++ b/tests/engine/gen2_jingle_arrow_bug1926.lua @@ -0,0 +1,104 @@ +-- TextCommand_PROMPT_BUTTON's LoadBlinkingCursor (home/text.asm:749) + +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 TextBox = require("src.render.TextBox") +local Sound = require("src.core.Sound") + +local busy = true +local realBusy, realPlay = Sound.sfxBusy, Sound.play +Sound.sfxBusy = function() return busy end +Sound.play = function() return nil end + +local function newGame() + local game = { + save = { player = {}, options = { textSpeed = "FAST" } }, + data = { text = {} }, + } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + game.input = { + queue = {}, + wasPressed = function(self, btn) return self.queue[btn] or false end, + isDown = function(self, btn) return self.queue[btn] or false end, + } + return game +end + +do + local game = newGame() + local popped = false + local box = TextBox.new(game, "GOLD put the POTION\nin the ITEM POCKET.", + function() popped = true end, { sfxWait = true }) + game.stack:push(box) + + local frames = 0 + while not box.done and frames < 600 do + box:update(1 / 60) + frames = frames + 1 + end + check(box.done, "the page finished typing") + check(not box:arrowVisible(), "no arrow while the jingle is still ringing") + + game.input.queue.a = true + box:update(1 / 60) + check(not popped, "and A does not advance it either") + check(not box:arrowVisible(), "the arrow is still refused") + game.input.queue.b = true + box:update(1 / 60) + check(not popped, "nor does B") + game.input.queue.a, game.input.queue.b = false, false + + busy = false + box:update(1 / 60) + check(box.sfxWait == nil, "the hold is released the frame the sfx ends") + check(box:arrowVisible(), "and the arrow appears") + game.input.queue.a = true + box:update(1 / 60) + check(popped, "the press the arrow advertises now works") +end + +do + local game = newGame() + busy = true + local box = TextBox.new(game, "GOLD put the POTION\nin the ITEM POCKET.", + nil, { sfxWait = true }) + box.waiting = true + check(not box:arrowVisible(), "a mid-text page break waits for the jingle") + box.sfxWait = nil + check(box:arrowVisible(), "and prints the arrow once it has stopped") +end + +do + local game = newGame() + busy = true + local box = TextBox.new(game, "GOLD put the POTION\nin the ITEM POCKET.") + local frames = 0 + while not box.done and frames < 600 do + box:update(1 / 60) + frames = frames + 1 + end + check(box:arrowVisible(), "a plain finished box still blinks its arrow") + box.choice = function() end + check(not box:arrowVisible(), "a choice box still does not") + box.choice = nil + box.stay = {} + check(not box:arrowVisible(), "and neither does a stay box with no prompt") + box.stay = { prompt = true } + check(box:arrowVisible(), "a stay box that prompts does") +end + +Sound.sfxBusy, Sound.play = realBusy, realPlay + +eq(TextBox.new(newGame(), "hi").sfxWait, nil, + "sfxWait is opt-in; no Gen 1 caller sets it") + +T.finish("gen2 jingle arrow bug1926") diff --git a/tests/engine/gen2_night_shade_top_row_bug1921.lua b/tests/engine/gen2_night_shade_top_row_bug1921.lua new file mode 100644 index 00000000..bc194752 --- /dev/null +++ b/tests/engine/gen2_night_shade_top_row_bug1921.lua @@ -0,0 +1,83 @@ +-- engine/battle_anims/bg_effects.asm:1108 (#1921) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local BgEffects = require("src.battle.gen2.BgEffects") +local BattleAnimView = require("src.ui.gen2.BattleAnimView") + +local SCREEN_H = BattleAnimView.SCREEN_H + +-- data/moves/animations.asm:2618 BattleAnim_NightShade: +local bg = BgEffects.new({}, { battleTurn = 0 }) +bg:queue("BATTLE_BG_EFFECT_NIGHT_SHADE", 0, 0, 0x8) +bg:playFrame() +T.eq(bg.lcdc, "SCY", "Night Shade wobbles the target vertically") +T.eq(bg.lyStart, 0x00, "and the enemy window starts at scanline 0") +T.eq(bg.lyEnd, 0x36, "running to $36") + +local negatives, gaps, duplicates = 0, 0, 0 +for _ = 1, 16 do + bg:playFrame() + for row = bg.lyStart, bg.lyEnd - 1 do + local byte = bg.lyBackup[row] or 0 + if byte >= 0x80 and row + (byte - 0x100) < 0 then + negatives = negatives + 1 + end + end + local seen = {} + for _, line in ipairs(BattleAnimView.scanlines(bg)) do + if seen[line.dest] then duplicates = duplicates + 1 end + seen[line.dest] = true + end + for row = bg.lyStart, bg.lyEnd - 1 do + if not seen[row] then gaps = gaps + 1 end + end +end +T.check(negatives > 0, "the wave really does push the top rows off the panel") +T.eq(gaps, 0, "but no window scanline is ever left blank") +T.eq(duplicates, 0, "and none is drawn twice") + +local function panel(fields) + local out = { scx = 0, scy = 0, lyStart = 0, lyEnd = 0x36, lyBackup = {}, + lcdc = "SCY" } + 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(panel({ + lyBackup = { [0] = 0xff, [1] = 0xfe } }))) + T.eq(lines[0] and lines[0].src, 0, "an rSCY of -1 on scanline 0 holds row 0") + T.eq(lines[1] and lines[1].src, 0, "and -2 on scanline 1 holds it too") +end + +do + local lines = byRow(BattleAnimView.scanlines(panel({ + lyEnd = SCREEN_H, lyBackup = { [SCREEN_H - 1] = 4 } }))) + T.eq(lines[SCREEN_H - 1] and lines[SCREEN_H - 1].src, SCREEN_H - 1, + "a wobble past the bottom of the panel holds the last row") +end + +do + local lines = byRow(BattleAnimView.scanlines(panel({ + lyBackup = { [0] = 0x90 } }))) + T.eq(lines[0], nil, "the $90 displacement still sinks its row into blank") +end + +do + local lines = byRow(BattleAnimView.scanlines(panel({ + scy = 4, lcdc = nil, lyEnd = 0 }))) + T.eq(lines[SCREEN_H - 1], nil, + "and a whole-screen hSCY shake still leaves the bottom rows blank") +end + +T.finish("gen2 night shade top row bug 1921") diff --git a/tests/engine/gen2_offmap_object_bug1923.lua b/tests/engine/gen2_offmap_object_bug1923.lua new file mode 100644 index 00000000..c43e4659 --- /dev/null +++ b/tests/engine/gen2_offmap_object_bug1923.lua @@ -0,0 +1,84 @@ +-- InitializeVisibleSprites (engine/overworld/player_object.asm:223) +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +love = require("tests.love_stub") + +local World = require("src.world.gen2.World") + +local MAP_ID = "GOLDENROD_POKECENTER_1F" + +local function pokecenter() + local def = { + id = MAP_ID, group = 24, map = 1, width = 5, height = 4, + blocks = {}, warps = {}, connections = {}, + objects = { + { index = 1, x = 3, y = 1, sprite = "SPRITE_NURSE" }, + { index = 2, x = 16, y = 8, sprite = "SPRITE_LINK_RECEPTIONIST" }, + { index = 3, x = 6, y = 1, sprite = "SPRITE_GAMEBOY_KID" }, + }, + } + local world = World.new({ data = {}, save = { player = {} } }) + world.maps = { [MAP_ID] = def } + world.map = { id = MAP_ID, def = def, widthCells = 10, heightCells = 8 } + world.neighbors = {} + world.player = { cellX = 4, cellY = 6 } + world.pooledNpc = function(_, ownerMap, obj) + return { def = obj, id = ownerMap .. ":" .. tostring(obj.index) } + end + return world, def +end + +local function sprites(world) + local names = {} + for _, npc in ipairs(world.npcs) do names[#names + 1] = npc.def.sprite end + return table.concat(names, ",") +end + +do + local world = pokecenter() + world:rebuildPeople() + eq(#world.npcs, 2, "the two in-bounds objects spawn") + eq(sprites(world), "SPRITE_NURSE,SPRITE_GAMEBOY_KID", + "and the off-grid receptionist is not among them") +end + +do + local world = pokecenter() + local off = { x = 16, y = 8 } + world.player = { cellX = 9, cellY = 7 } + check(not world:objectSpawnable(off), + "delta 7 is the first x the cart's window excludes") + world.player = { cellX = 10, cellY = 7 } + check(world:objectSpawnable(off), "delta 6 is inside it") + world.player = { cellX = 10, cellY = 2 } + check(not world:objectSpawnable(off), "and delta 6 in y is the first y out") + world.player = { cellX = 10, cellY = 3 } + check(world:objectSpawnable(off), "delta 5 in y is inside") + check(world:objectSpawnable({ x = 3, y = 1 }), + "an in-bounds object never consults the window at all") +end + +-- maps/GoldenrodPokecenter1F.asm:26 `moveobject ..., 0, 7` then `appear`: +do + local world, def = pokecenter() + world:rebuildPeople() + eq(#world.npcs, 2, "she starts off the map") + world:moveObject(3, 0, 7) + world:appearObject(3) + eq(#world.npcs, 3, "moveobject + appear puts her in the room") + check(sprites(world):find("SPRITE_LINK_RECEPTIONIST") ~= nil, + "and it is the receptionist that arrived") + + local stash = world.objectSpawns and world.objectSpawns[MAP_ID] + and world.objectSpawns[MAP_ID][2] + eq(stash and stash[1], 16, "the header cell is stashed for the reload") + eq(stash and stash[2], 8, "both halves of it") + def.objects[2].x, def.objects[2].y = stash[1], stash[2] + world:rebuildPeople() + eq(#world.npcs, 2, "and a reload hides her again") +end + +T.finish("gen2 off-map object bug1923") diff --git a/tests/engine/gen2_save_import.lua b/tests/engine/gen2_save_import.lua index abfa88f9..395cbd5a 100644 --- a/tests/engine/gen2_save_import.lua +++ b/tests/engine/gen2_save_import.lua @@ -22,6 +22,7 @@ local check, eq = T.check, T.eq local Gen2Save = require("src.save_convert.Gen2Save") local Gen2Layout = require("src.save_convert.Gen2Layout") local SaveConvert = require("src.save_convert.SaveConvert") +local FieldMoves = require("src.world.gen2.FieldMoves") local SIZE = Gen2Save.SAVE_SIZE @@ -65,7 +66,10 @@ local function pack(b) return table.concat(out) end -local function build(version, bare) +-- SPRITE_TWIN is $26 (constants/sprite_constants.asm:42,149,154). +local WEIRD_TREE_SLOT, SPRITE_TWIN = 4, 0x26 + +local function build(version, bare, female) local L = Gen2Save.layoutFor(version) local b = blank() putName(b, L.wPlayerName, "ASH") @@ -95,7 +99,11 @@ local function build(version, bare) if not bare then put(b, L.wStatusFlags, 0x01) -- STATUSFLAGS_POKEDEX_F put(b, L.wPokegearFlags, 0x81) -- POKEGEAR_OBTAINED_F + map card + put(b, L.wVisitedSpawns + 1, 0x40) + put(b, L.wVisitedSpawns + 2, 0x05) + put(b, L.wVariableSprites + WEIRD_TREE_SLOT, SPRITE_TWIN) end + if L.wPlayerGender then put(b, L.wPlayerGender, female and 1 or 0) 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) @@ -157,6 +165,36 @@ for _, version in ipairs({ "gold", "silver", "crystal" }) do 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") + + local shift = version == "crystal" and 1 or 0 + eq(save.engineFlags[64 + shift], true, version .. ": ENGINE_FLYPOINT_NEW_BARK") + eq(save.engineFlags[66 + shift], true, version .. ": ENGINE_FLYPOINT_VIOLET") + eq(save.engineFlags[67 + shift], true, + version .. ": AZALEA is bit 18, not 17 -- SPAWN_UNION_CAVE has no flag") + eq(save.engineFlags[65 + shift], nil, + version .. ": a town the cart never reached stays unflyable") + eq(bare.engineFlags[64 + shift], nil, + version .. ": and a pre-Mr.-Pokemon save has no flypoint at all") + if shift == 0 then + eq(FieldMoves.hasVisitedSpawn(save, "SPAWN_AZALEA"), true, + version .. ": FLY offers the town the cart unlocked") + eq(FieldMoves.hasVisitedSpawn(save, "SPAWN_CHERRYGROVE"), false, + version .. ": and not the one it did not") + eq(FieldMoves.hasVisitedSpawn(bare, "SPAWN_NEW_BARK"), false, + version .. ": a fresh cart offers nothing") + end + + eq(save.variableSprites[WEIRD_TREE_SLOT], SPRITE_TWIN, + version .. ": the SPRITE_WEIRD_TREE slot comes off the cart") + eq(save.variableSprites[0], nil, + version .. ": a slot the cart never filled stays absent for World's seed") + + -- constants/ram_constants.asm:177 (#1907) + eq(save.player.gender, version == "crystal" and "male" or nil, + version .. ": Gold and Silver have no gender byte to read") + local kris = assert(Gen2Save.decode(build(version, false, true), version)) + eq(kris.player.gender, version == "crystal" and "female" or nil, + version .. ": sCrystalData bit 0 is the imported gender") end end @@ -260,6 +298,9 @@ do save.events[9] = 0xA5 save.party[1].moves[1].pp = 12 save.engineFlags[11] = nil + save.engineFlags[66] = nil + save.engineFlags[69] = true + save.variableSprites[6] = 0x35 local out = assert(Gen2Save.encode(save, "gold", cart, data)) eq(#out, #cart, "the image keeps its size") @@ -288,6 +329,39 @@ do -- 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") + + -- engine/pokegear/pokegear.asm:2189 (#1906) + eq(back.engineFlags[66], nil, "a cleared flypoint clears its wVisitedSpawns bit") + eq(back.engineFlags[69], true, "and a newly earned one is written") + eq(back.engineFlags[64], true, "with the neighbours on either side untouched") + eq(back.engineFlags[67], true, "AZALEA included, across the UNION_CAVE hole") + + -- engine/overworld/scripting.asm:869 (#1905) + eq(back.variableSprites[WEIRD_TREE_SLOT], SPRITE_TWIN, + "the twins' slot survives the round trip") + eq(back.variableSprites[6], 0x35, "and a slot filled inside the port is written") +end + +-- ram/sram.asm:138-144 +do + eq(Gen2Layout.goldSilver.wPlayerGender, nil, + "Gold and Silver have no gender byte") + eq(Gen2Layout.crystal.wPlayerGender, Gen2Layout.crystal.backup.wPlayerGender, + "the Crystal backup shares the primary's gender byte") + + local cart = build("crystal", false, true) + local save = assert(Gen2Save.decode(cart, "crystal")) + eq(save.player.gender, "female", "Kris imports as Kris") + local out = assert(Gen2Save.encode(save, "crystal", cart)) + eq(out:byte(Gen2Layout.crystal.wPlayerGender + 1) % 2, 1, + "and exports back into sCrystalData bit 0") + + save.player.gender = "male" + local male = assert(Gen2Save.encode(save, "crystal", cart)) + eq(male:byte(Gen2Layout.crystal.wPlayerGender + 1) % 2, 0, + "a gender changed in the port reaches the cart image") + eq(assert(Gen2Save.decode(male, "crystal")).player.gender, "male", + "and reads back as Chris") end -- engine/pokemon/stats_screen.asm (#1899) diff --git a/tests/engine/gen2_scy_deform_blit_bug1895.lua b/tests/engine/gen2_scy_deform_blit_bug1895.lua index 5482f0f7..4164486e 100644 --- a/tests/engine/gen2_scy_deform_blit_bug1895.lua +++ b/tests/engine/gen2_scy_deform_blit_bug1895.lua @@ -52,7 +52,7 @@ do "scanline " .. line.dest .. " samples inside the panel") end local gaps = 0 - for row = 3, 0x32 do + for row = 0, 0x35 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") @@ -66,6 +66,32 @@ do T.check(lines[120] ~= nil, "a $90 row outside the window still draws") end +-- home/lcd.asm:3 (#1921) +do + local lines = byRow(BattleAnimView.scanlines( + bg({ lcdc = "SCY", lyStart = 0, lyEnd = 0x36, + lyBackup = { [0] = 0xff, [1] = 0xfe } }))) + T.eq(lines[0] and lines[0].src, 0, + "a window row that wobbles above the panel clamps to row 0") + T.eq(lines[1] and lines[1].src, 0, "two rows above the panel clamp as well") + T.eq(lines[1] and lines[1].dest, 1, "and still land on their own scanline") +end + +do + local lines = byRow(BattleAnimView.scanlines( + bg({ lcdc = "SCY", lyStart = 0x80, lyEnd = SCREEN_H, + lyBackup = { [SCREEN_H - 1] = 2 } }))) + T.eq(lines[SCREEN_H - 1] and lines[SCREEN_H - 1].src, SCREEN_H - 1, + "and a window row that wobbles below it clamps to the last row") +end + +do + local lines = byRow(BattleAnimView.scanlines( + bg({ lcdc = "SCY", scy = 0xfc, lyStart = 0, lyEnd = 0x36, + lyBackup = { [0] = 0xff } }))) + T.eq(lines[0], nil, "an hSCY shake keeps its blank rows, window or not") +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") diff --git a/tests/engine/gen2_surf_ly_override_bug1920.lua b/tests/engine/gen2_surf_ly_override_bug1920.lua new file mode 100644 index 00000000..846fc916 --- /dev/null +++ b/tests/engine/gen2_surf_ly_override_bug1920.lua @@ -0,0 +1,87 @@ +-- engine/battle_anims/functions.asm:1148 (#1920) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local AnimObjects = require("src.battle.gen2.AnimObjects") +local BgEffects = require("src.battle.gen2.BgEffects") +local BattleAnimView = require("src.ui.gen2.BattleAnimView") + +-- data/battle_anims/objects.asm: BATTLE_ANIM_OBJ_SURF, and +-- data/moves/animations.asm:1126 BattleAnim_Surf's anim_obj row. +local DATA = { objects = { SURF = { func = "BATTLE_ANIM_FUNC_SURF" } } } + +local function pools() + local bg = BgEffects.new({}, { battleTurn = 0 }) + local objects = AnimObjects.new(DATA, {}, { battleTurn = 0 }) + objects.hram = bg + bg:queue("BATTLE_BG_EFFECT_SURF", 0, 0, 0) + local st = objects:queue("SURF", 88, 104, 0x8) + return bg, objects, st +end + +-- anim_commands.asm:100: the BG effects run first, then the object functions. +local function step(bg, objects) + bg:playFrame() + objects:playFrame() +end + +local function painted(bg, first, last) + for row = first, last do + if (bg.lyBackup[row] or 0) ~= 0 then return true end + end + return false +end + +do + local bg, objects = pools() + step(bg, objects) + T.eq(bg.lcdc, "SCY", "the Surf object opens the LCD-STAT window") + T.eq(bg.lyStart, 0x58, "at hLYOverrideStart $58") + T.eq(bg.lyEnd, 0x5e, "and hLYOverrideEnd $5e") + T.check(not painted(bg, 0, 0x5e), + "and the BG effect has painted nothing on the frame that opens it") + + step(bg, objects) + T.check(painted(bg, 0x59, 0x5e), + "the next frame rotates the ring into the window") + T.check(bg.lyStart < 0x58, "and the wave front climbs the screen") + + local before = bg.lyStart + for _ = 1, 20 do step(bg, objects) end + T.check(bg.lyStart < before - 0x10, + "it keeps climbing past its own sine wobble") + T.check(painted(bg, bg.lyStart + 1, 0x5e), + "with the water still on screen behind it") + + local lines = BattleAnimView.scanlines(bg) + local sheared = false + for _, line in ipairs(lines) do + if line.src ~= line.dest then sheared = true end + end + T.check(sheared, "so the view distorts at least one scanline") +end + +do + local bg, objects, st = pools() + step(bg, objects) + st.y, st.jt = 0x70, 3 + step(bg, objects) + T.eq(bg.lcdc, nil, "passing y $70 closes the window again") + T.eq(bg.lyStart, 0, "clearing hLYOverrideStart") + T.eq(bg.lyEnd, 0, "and hLYOverrideEnd") + T.eq(st.index, 0, "and deinitialising the object") +end + +do + local bg, objects, st = pools() + step(bg, objects) + st.y = 0x00 + step(bg, objects) + T.eq(bg.lyStart, 0, "reaching the top of the screen zeroes the window start") + T.eq(bg.lcdc, "SCY", "but leaves the pointer open") +end + +T.finish("gen2 surf ly override bug 1920") diff --git a/tests/engine/launch_options_flags_bug1657.lua b/tests/engine/launch_options_flags_bug1657.lua new file mode 100644 index 00000000..6b2c72cc --- /dev/null +++ b/tests/engine/launch_options_flags_bug1657.lua @@ -0,0 +1,91 @@ + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq +love = love or require("tests.love_stub") + +local LaunchOptions = require("src.core.LaunchOptions") + +local realGetenv = os.getenv +local env = {} +os.getenv = function(name) + if env[name] ~= nil then return env[name] end + return realGetenv(name) +end + +local function tasks(argv, rawArgv) + return LaunchOptions.tasks(argv, rawArgv) +end + +do + local t = tasks({}, nil) + eq(t.sync, nil, "with no flags sync is left to the device's own setup") + eq(t.update, false, "and the update stage is strictly opt-in") +end + +do + eq(tasks({ "--update" }, nil).update, true, "--update turns the stage on") + eq(tasks({ "-update" }, nil).update, true, "-update does too") + eq(tasks({}, { "-update" }).update, true, + "a single-dash -update LOVE swallowed is still found in the raw argv") + eq(tasks({}, { "--update" }).update, true, "as is the double-dash spelling") + eq(tasks({ "--sync" }, nil).sync, true, "--sync forces the sync stage") + eq(tasks({ "-sync" }, nil).sync, true, "-sync forces it too") +end + +do + eq(tasks({ "--no-sync" }, nil).sync, false, "--no-sync opts out") + eq(tasks({ "--sync", "--no-sync" }, nil).sync, false, + "no- beats the positive flag in the same argv") + eq(tasks({ "--sync" }, { "--no-sync" }).sync, false, + "and beats it across the parsed / raw argv split") + eq(tasks({ "--update", "--no-update" }, nil).update, false, + "--no-update cancels an --update on the same line") +end + +do + env.POKEPORT_LAUNCH_SYNC = "0" + eq(tasks({}, nil).sync, false, "POKEPORT_LAUNCH_SYNC=0 opts out") + eq(tasks({ "--sync" }, nil).sync, true, "an explicit --sync overrides it") + env.POKEPORT_LAUNCH_SYNC = "1" + eq(tasks({}, nil).sync, true, "POKEPORT_LAUNCH_SYNC=1 opts in") + eq(tasks({ "--no-sync" }, nil).sync, false, "--no-sync overrides that") + env.POKEPORT_LAUNCH_SYNC = "yes" + eq(tasks({}, nil).sync, nil, "any other value is not an opinion") + env.POKEPORT_LAUNCH_SYNC = nil + + env.POKEPORT_LAUNCH_UPDATE = "1" + eq(tasks({}, nil).update, true, "POKEPORT_LAUNCH_UPDATE=1 opts in") + eq(tasks({ "--no-update" }, nil).update, false, "--no-update overrides it") + env.POKEPORT_LAUNCH_UPDATE = nil +end + +do + eq(LaunchOptions.forceLauncher({ "-launcher" }), true, + "-launcher forces the launcher like --launcher does") +end + +do + eq(LaunchOptions.commandFor("red"), "--game red", + "the bare shortcut command is unchanged") + eq(LaunchOptions.commandFor("red", "slot2"), "--game red --slot slot2", + "and so is the slot form") + local cmd = LaunchOptions.commandFor("red", "slot2", + { update = true, sync = false }) + eq(cmd, "--game red --slot slot2 --update --no-sync", + "task flags append in a stable order") + + local argv = {} + for word in cmd:gmatch("%S+") do argv[#argv + 1] = word end + local game, slot = LaunchOptions.resolve(argv) + eq(game, "red", "the round-tripped command still names the game") + eq(slot, "slot2", "and the slot") + local t = tasks(argv, nil) + eq(t.update, true, "and the update task survives the round trip") + eq(t.sync, false, "and so does the sync opt-out") +end + +os.getenv = realGetenv + +T.finish("launch options task flags (#1657)") diff --git a/tests/engine/launch_prelaunch_bug1657.lua b/tests/engine/launch_prelaunch_bug1657.lua new file mode 100644 index 00000000..471c5491 --- /dev/null +++ b/tests/engine/launch_prelaunch_bug1657.lua @@ -0,0 +1,293 @@ + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Json = require("src.link.Json") +local SyncState = require("src.sync.SyncState") +local SyncEngine = require("src.sync.SyncEngine") +local Prelaunch = require("src.core.Prelaunch") + +local function fakeFs(files) + local fs = { files = files or {}, writes = 0, removes = 0 } + fs.getInfo = function(name) + return fs.files[name] and { type = "file" } or nil + end + fs.remove = function(name) + fs.files[name] = nil + fs.removes = fs.removes + 1 + return true + end + fs.write = function(name, data) + fs.files[name] = data or "" + fs.writes = fs.writes + 1 + return true + end + fs.isFused = function() return true end + return fs +end + +local function fakeCheck(seq) + local c = { seq = seq, i = 0, started = 0, downloads = 0 } + c.start = function() c.started = c.started + 1 end + c.download = function() c.downloads = c.downloads + 1 end + c.state = function() + c.i = math.min(c.i + 1, #c.seq) + return c.seq[c.i] + end + return c +end + +local function stage(opts) + local rec = { outcomes = {}, restarts = 0 } + opts.done = function(outcome) rec.outcomes[#rec.outcomes + 1] = outcome end + opts.restart = function() rec.restarts = rec.restarts + 1 end + rec.stage = Prelaunch.new(opts) + return rec +end + +local function pump(rec, times, dt) + for _ = 1, (times or 8) do + if rec.stage then rec.stage:update(dt or 0.1) end + end +end + + +do + local rec = stage({ version = "red", tasks = {}, engine = false, + fs = fakeFs() }) + eq(rec.stage, nil, + "no tasks and no linked device means no stage at all: the shortcut boots " + .. "on the same frame it always did") +end + +do + local rec = stage({ version = "red", tasks = { sync = false, update = true }, + allowUpdate = false, engine = false, fs = fakeFs() }) + eq(rec.stage, nil, "an --update that the platform cannot apply is not a stage") +end + + +do + local fs = fakeFs() + local c = fakeCheck({ + { status = "checking" }, + { status = "available", latest = "0.2.0" }, + { status = "downloading", progress = 0.5 }, + { status = "ready" }, + { status = "ready" }, + }) + local rec = stage({ version = "red", tasks = { update = true, sync = false }, + allowUpdate = true, check = c, engine = false, fs = fs }) + check(rec.stage ~= nil, "an --update shortcut gets a stage") + eq(c.started, 1, "which starts exactly one forced check") + + pump(rec, 2) + eq(c.downloads, 1, "an available release starts downloading") + pump(rec, 1) + check(rec.stage.status:find("50", 1, true) ~= nil, + "and the stage shows the download percentage") + + pump(rec, 1) + eq(rec.restarts, 1, "a ready payload restarts into Boot.run exactly once") + eq(fs.files[Prelaunch.MARKER], "1", "leaving the one-shot marker behind") + eq(#rec.outcomes, 1, "the stage reports once") + eq(rec.outcomes[1], "restart", "and the outcome is the restart, not a boot") + + pump(rec, 4) + eq(rec.restarts, 1, "further frames never restart a second time") + eq(#rec.outcomes, 1, "nor report a second outcome") + + local again = stage({ version = "red", tasks = { update = true, sync = false }, + allowUpdate = true, check = c, engine = false, fs = fs }) + eq(again.stage, nil, "the marker skips the update stage exactly once") + eq(fs.files[Prelaunch.MARKER], nil, "and is consumed on read") +end + +do + local c = fakeCheck({ { status = "uptodate" } }) + local rec = stage({ version = "red", tasks = { update = true, sync = false }, + allowUpdate = true, check = c, engine = false, fs = fakeFs() }) + pump(rec, 1) + eq(rec.outcomes[1], "boot", "an up-to-date check boots without a prompt") + eq(c.downloads, 0, "and downloads nothing") +end + +do + local c = fakeCheck({ { status = "needs_full", full = { url = "x" } } }) + local rec = stage({ version = "red", tasks = { update = true, sync = false }, + allowUpdate = true, check = c, engine = false, fs = fakeFs() }) + pump(rec, 1) + eq(rec.outcomes[1], "boot", + "a full-update requirement is never installed unattended: it boots") +end + +do + local c = fakeCheck({ { status = "checking" } }) + local rec = stage({ version = "red", tasks = { update = true, sync = false }, + allowUpdate = true, check = c, engine = false, fs = fakeFs() }) + pump(rec, 10, 1) + eq(#rec.outcomes, 0, "a check still running inside the budget keeps waiting") + pump(rec, Prelaunch.UPDATE_BUDGET + 2, 1) + eq(rec.outcomes[1], "boot", "but a dead network cannot outlast the budget") +end + +do + local c = fakeCheck({ { status = "checking" } }) + local rec = stage({ version = "red", tasks = { update = true, sync = false }, + allowUpdate = true, check = c, engine = false, fs = fakeFs() }) + rec.stage:cancel() + eq(rec.outcomes[1], "boot", "any button skips the stage straight into the game") + pump(rec, 4) + eq(#rec.outcomes, 1, "and the skipped stage stays finished") +end + + +local function scripted(routes) + local t = { sent = {}, routes = routes, handles = {} } + function t:begin(req) + self.sent[#self.sent + 1] = req + local path = req.url:match("^[^?]*"):gsub("^http://sync%.test", "") + local route = self.routes[req.method .. " " .. path] + local reply = (type(route) == "function" and route(req, self)) or route + or { code = 404, body = '{"error":"no route"}' } + self.handles[#self.sent] = { + status = "ok", code = reply.code or 200, + body = reply.body or Json.encode(reply.data or {}), + } + return #self.sent + end + function t:poll(handle) return self.handles[handle] end + function t:release() end + return t +end + +local function linkedState() + local state = SyncState.defaults() + state.account = "aa11bb22cc33dd44" + state.deviceToken = "tok" + state.enabled = true + return state +end + +local function fakeSaves(entries) + local writes = {} + return { + writes = writes, + list = function() return entries end, + write = function(version, id, blob, mode) + writes[#writes + 1] = { version = version, playthroughId = id, + blob = blob, mode = mode } + return mode == "new" and "slot9" or "slot1" + end, + } +end + +local function syncEngine(routes, entries, state) + local saves = fakeSaves(entries or {}) + local eng = SyncEngine.new({ + baseUrl = "http://sync.test", + transport = scripted(routes), + state = state or linkedState(), + saves = saves, + persist = false, + now = function() return 1700001000 end, + }) + return eng, saves +end + +do + local eng, saves = syncEngine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"red/xyz":{"rev":4,"meta":{"savedAt":900}}}}' }, + ["GET /sync/save"] = { code = 200, + body = '{"rev":4,"meta":{"savedAt":900},"blob":"return { player = {} }"}' }, + }, {}) + + local rec = stage({ version = "red", tasks = {}, engine = eng, + fs = fakeFs() }) + check(rec.stage ~= nil, "a linked, sync-enabled device gets a sync stage") + eq(#saves.writes, 0, "which has not landed the newer save yet") + pump(rec, 24) + eq(#saves.writes, 1, + "the newer remote save is pulled down before the game boots") + eq(saves.writes[1].version, "red", "into the right game") + eq(rec.outcomes[1], "boot", "and only then does the shortcut boot") + check(eng.protectedKey == nil, + "nothing is protected yet: Game has not loaded a playthrough") +end + +do + local eng = syncEngine({}) + local rec = stage({ version = "red", tasks = { sync = false }, engine = eng, + fs = fakeFs() }) + eq(rec.stage, nil, "--no-sync means no stage even on a linked device") +end + +do + local state = linkedState() + state.enabled = false + local eng = syncEngine({}, {}, state) + local rec = stage({ version = "red", tasks = { sync = true }, engine = eng, + fs = fakeFs() }) + eq(rec.stage, nil, "sync switched off is honoured even by an explicit --sync") +end + +do + local eng = syncEngine({}, {}, SyncState.defaults()) + local rec = stage({ version = "red", tasks = { sync = true }, engine = eng, + fs = fakeFs() }) + eq(rec.stage, nil, "an unlinked device has nothing to sync") +end + +do + local entry = { + version = "red", slot = "slot1", playthroughId = "xyz", + blob = "return { player = { name = 'ASH' } }", + meta = { savedAt = 900, sessionStart = 800, playthroughId = "xyz", + summary = { name = "ASH", badges = 2 } }, + } + local eng = syncEngine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"red/xyz":{"rev":9,"meta":{"savedAt":1200,' + .. '"sessionStart":1100,"summary":{"name":"ASH","badges":5}}}}}' }, + }, { entry }) + + local rec = stage({ version = "red", tasks = {}, engine = eng, + fs = fakeFs() }) + pump(rec, 24) + eq(eng.phase, "conflict", "two diverged copies raise a conflict") + eq(rec.outcomes[1], "launcher", + "which must reach the player in the launcher's modal, not be booted over") +end + +do + local eng = syncEngine({ + ["GET /sync/state"] = function() return { code = 200, body = "{" } end, + }, {}) + local rec = stage({ version = "red", tasks = {}, engine = eng, + fs = fakeFs() }) + pump(rec, 24) + eq(rec.outcomes[1], "boot", "a sync that errors still boots the game") +end + +do + local f = assert(io.open("main.lua", "r")) + local src = f:read("*a") + f:close() + local handler = src:match( + "function love%.handlers%.intent_game.-\n(.-)\nend\n") + check(handler ~= nil, "main.lua still defines the Android intent handler") + handler = handler or "" + check(handler:find("Prelaunch", 1, true) ~= nil, + "an Android intent runs the same pre-boot stage a shortcut does") + check(handler:find("tasks.update = false", 1, true) ~= nil, + "but never the update check, which would restart the app unattended") + local bootAt = handler:find("if not Prelaunch then bootGame", 1, true) + check(bootAt ~= nil, + "and boots on the same frame when there is no stage to run") +end + +T.finish("shortcut pre-boot stage (#1657)") diff --git a/tests/engine/launcher_update_all.lua b/tests/engine/launcher_update_all.lua new file mode 100644 index 00000000..f34488b4 --- /dev/null +++ b/tests/engine/launcher_update_all.lua @@ -0,0 +1,282 @@ +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 Platform = require("src.core.Platform") +local RomImporter = require("src.import.RomImporter") + +local RELEASES = { + { version = "2.0.0", zip = { url = "https://example.invalid/a.zip" } }, + { version = "1.0.0", zip = { url = "https://example.invalid/b.zip" } }, +} + +local oldBeginFetch = ModUpdate.beginFetchReleases +local oldBeginZip = ModUpdate.beginDownloadZip +local oldPumpZip = ModUpdate.pumpDownloadZip +local oldInstall = LauncherMods.installDownloadedZip +local oldDeps = LauncherMods.checkDependencies +local oldRemote = Platform.canFetchRemote + +local failId = nil +local installs = {} + +ModUpdate.beginFetchReleases = function() return {} end +ModUpdate.beginDownloadZip = function() return {} end +ModUpdate.pumpDownloadZip = function() return true, "install.zip" end +LauncherMods.installDownloadedZip = function(id, _, version) + installs[#installs + 1] = id + if id == failId then return nil, "the archive had no manifest" end + return true, version +end +LauncherMods.checkDependencies = function() return { hasIssues = false } end +Platform.canFetchRemote = function() return true end + +local function info(status, best) + return { status = status, latest = best and best.version or nil, + best = best, releases = RELEASES } +end + +local function launcher(secondStatus) + local ri = setmetatable({ + mods = { + { id = "one", name = "One", version = "1.0.0", github = "a/one" }, + { id = "two", name = "Two", version = "1.0.0", github = "a/two" }, + { id = "three", name = "Three", version = "1.0.0" }, + }, + modUpdateInfo = { + one = info("available", RELEASES[1]), + two = info(secondStatus or "current", + secondStatus == "available" and RELEASES[1] or nil), + }, + activeCart = {}, + }, RomImporter) + ri._refreshMods = function() end + return ri +end + +local function run(ri, frames) + for _ = 1, frames or 40 do + if not ri._updateAll then break end + ri:_pumpUpdateAll() + ri:_pumpModInstall() + end +end + + +do + local ri = launcher() + local rows = ri:_updateAllRows() + eq(#rows, 1, "only a mod whose badge says a release is available is queued") + eq(rows[1].id, "one", "the current mod and the one with no github stay out") + + ri.modCartPlan = function() return "cart1", { pins = {} }, "gold" end + eq(#ri:_updateAllRows(), 0, "a cart owns its mod set, so it offers no rows") +end + + +do + installs = {} + local ri = launcher("available") + check(ri:pressUpdateAllMods(), "the press starts the queue") + check(ri._busy ~= nil, "behind the blocking overlay") + check(type(ri._busy.cancel) == "function", "which can be cancelled") + check(ri._modInfoFetch ~= nil, "the release checks are refreshed first") + + ri:_pumpUpdateAll() + eq(ri._updateAll.stage, "check", "the queue waits for them to drain") + eq(#installs, 0, "and installs nothing meanwhile") + + ri._modInfoFetch = nil + ri:_pumpUpdateAll() + eq(ri._updateAll.total, 2, "then queues every outdated mod") + + ri:_pumpUpdateAll() + eq(ri._updateAll.index, 1, "one row is started") + ri:_pumpUpdateAll() + eq(ri._updateAll.index, 1, "and the next waits for it, not for the frame") + eq(#installs, 0, "nothing has unzipped yet") + + run(ri) + eq(ri._updateAll, nil, "the queue finishes") + eq(#installs, 2, "having installed both rows") + eq(installs[1], "one", "in feed order") + eq(installs[2], "two", "one after the other") + check(ri.modNotice and ri.modNotice.ok, "and reports the run") + eq(ri.modNotice.text, "Updated 2 mods.", "as one summary") + eq(ri._busy, nil, "with the overlay down") +end + + +do + installs = {} + failId = "two" + local ri = launcher("available") + ri:pressUpdateAllMods() + ri._modInfoFetch = nil + run(ri) + failId = nil + eq(#installs, 2, "the queue carries on past a failure") + check(ri.modNotice and not ri.modNotice.ok, "and says the run was partial") + eq(ri.modNotice.text, "Updated 1 of 2. 1 failed:", "with the counts") + eq(#ri.modNotice.failures, 1, "naming what failed") + check(ri.modNotice.failures[1]:find("Two", 1, true) ~= nil, + "by the mod's own name") +end + + +do + local ri = launcher() + ri.modUpdateInfo = {} + ri:pressUpdateAllMods() + ri._modInfoFetch = nil + run(ri) + eq(ri.modNotice.text, "All mods are up to date.", + "a run with no outdated mod says so rather than going quiet") +end + +do + Platform.canFetchRemote = function() return false end + local ri = launcher("available") + check(ri:pressUpdateAllMods() == false, "no remote fetch, no queue") + eq(ri._updateAll, nil, "nothing is started") + check(ri.modNotice and not ri.modNotice.ok, "and the refusal is on screen") + Platform.canFetchRemote = function() return true end +end + + +do + installs = {} + local ri = launcher("available") + ri:pressUpdateAllMods() + ri._modInfoFetch = nil + ri:_pumpUpdateAll() + ri:_pumpUpdateAll() + ri:_pumpModInstall() + eq(#installs, 1, "one row is through") + ri:_cancelUpdateAll() + check(ri._updateAll == nil or ri._updateAll.cancelled, + "the cancel is taken") + run(ri) + eq(ri._updateAll, nil, "the queue stops") + eq(#installs, 1, "without starting the rest") + check(ri.modNotice.text:find("Stopped after updating", 1, true) ~= nil, + "and says how far it got") +end + +do + local ri = launcher("available") + ri:pressUpdateAllMods() + ri._modInfoFetch = nil + ri:_pumpUpdateAll() + ri:_pumpUpdateAll() + check(ri._modInstall ~= nil, "a row is downloading") + ri:_cancelUpdateAll() + check(ri._busy ~= nil, + "cancelling mid-download keeps the overlay: the download has no abort") + run(ri) + eq(ri._busy, nil, "and it comes down once that row is done") +end + + +do + installs = {} + local ri = launcher() + ri:_beginModInstall({ modId = "one", name = "One", release = RELEASES[1], + verb = "Updated", notice = "mod", quiet = true }) + ri:_pumpModInstall() + eq(ri.modNotice, nil, "a quiet install writes no per-mod notice") + eq(ri._modDepResolver, nil, "and raises no dependency modal mid-queue") +end + +do + local ri = launcher("available") + ri.cartFillRows = function() + return { { id = "one", name = "One", release = RELEASES[1] } } + end + eq(ri:pressInstallCartMods("red"), true, + "a cart fill starts when nothing else is running") + ri._cartFill = nil + + ri:pressUpdateAllMods() + check(ri._updateAll ~= nil, "the update-all queue is live") + eq(ri:pressInstallCartMods("red"), false, + "a cart fill refuses in the frame the queue is between rows") + eq(ri._cartFill, nil, "and starts nothing") + + ri:_beginCartInstall({ id = "cart", title = "Cart", + releases = { { version = "1.0.0", zip = { url = "https://x.invalid/c.zip" } } } }) + eq(ri._cartInstall, nil, "a cart install refuses there too") + eq(ri.findNotice, nil, "silently, since the overlay owns the screen") +end + + +do + 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 LauncherView = require("src.import.LauncherView") + + local realPrint = love.graphics.print + local function draw(imp) + local seen = {} + love.graphics.print = function(str, ...) + seen[#seen + 1] = tostring(str) + return realPrint(str, ...) + end + local ok, err = pcall(LauncherView.draw, imp) + love.graphics.print = realPrint + check(ok, "the frame draws: " .. tostring(err)) + return table.concat(seen, "\n") + end + local function window(w, h) + love.graphics.getDimensions = function() return w, h end + love.graphics.getPixelDimensions = function() return w, h end + end + + local imp = RomImporter.new(function() end, { launcher = true }) + imp._ensureMods = function() end + imp._refreshMods = function() end + imp.findLoaded, imp._findFetch = true, nil + imp.mods = launcher("available").mods + imp.modUpdateInfo = { one = info("available", RELEASES[1]) } + imp.tab = "mods" + + window(1400, 900) + local wide = draw(imp) + check(wide:find("Update all", 1, true) ~= nil, + "a wide header carries Update all beside Check for updates") + + local cache = imp._modUpdateCountCache + eq(cache and cache.count, 1, "the header counts the one outdated mod") + draw(imp) + check(rawequal(imp._modUpdateCountCache, cache), + "and a second frame reuses that count instead of rebuilding a list") + imp.modUpdateInfo.two = info("available", RELEASES[1]) + imp._modUpdateRev = (imp._modUpdateRev or 0) + 1 + draw(imp) + eq(imp._modUpdateCountCache.count, 2, + "a bumped update revision rebuilds it") + + window(430, 860) + local narrow = draw(imp) + check(narrow:find("More...", 1, true) ~= nil, + "a phone-width header collapses to More...") + imp._modHeaderActionsPopup = true + local popup = draw(imp) + imp._modHeaderActionsPopup = nil + check(popup:find("Update all mods", 1, true) ~= nil, + "and More... is where a phone reaches Update all") +end + +ModUpdate.beginFetchReleases = oldBeginFetch +ModUpdate.beginDownloadZip = oldBeginZip +ModUpdate.pumpDownloadZip = oldPumpZip +LauncherMods.installDownloadedZip = oldInstall +LauncherMods.checkDependencies = oldDeps +Platform.canFetchRemote = oldRemote + +T.finish("launcher update all") diff --git a/tests/engine/menu_hold_scroll_bug1898.lua b/tests/engine/menu_hold_scroll_bug1898.lua index 39d2799e..bf9809de 100644 --- a/tests/engine/menu_hold_scroll_bug1898.lua +++ b/tests/engine/menu_hold_scroll_bug1898.lua @@ -117,6 +117,58 @@ do eq(list.scroll, 0, "and the window comes with it") end +-- home/list_menu.asm:47, 61-64, 338-342, 176-190; home/window.asm:14-18 +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.cursorBlank, 0, "the arrow is up before anything moves") + + local function step(dir) + input:press(dir) + list:update(1 / 60) + input:release(dir) + end + + step("down") + eq(list.index, 2, "the press moves one row") + eq(list.scroll, 0, "inside the rows already printed") + eq(list.cursorBlank, 0, "so nothing reprints and the arrow stays up") + step("down") + eq(list.scroll, 0, "the third row is the last wMaxMenuItem reaches") + eq(list.cursorBlank, 0, "and the arrow is still up") + + step("down") + eq(list.index, 4, "the fourth row is past it") + eq(list.scroll, 1, "so wListScrollOffset moves and the entries reprint") + eq(list.cursorBlank, 3, "which wipes the arrow") + list:update(1 / 60) + eq(list.cursorBlank, 2, "it counts down one frame at a time") + list:update(1 / 60) + eq(list.cursorBlank, 1, "over the Delay3 window") + list:update(1 / 60) + eq(list.cursorBlank, 0, "and the arrow comes back") + list:update(1 / 60) + eq(list.cursorBlank, 0, "and stays back while the cursor sits still") + + step("down") + eq(list.cursorBlank, 3, "the next scrolling step blanks it again") + step("up") + eq(list.cursorBlank, 2, + "moving back inside the window reprints nothing, so it just counts down") + + local plain = ListMenu.new(game, "ITEMS", items, { kind = "generic" }) + input:press("down") + for _ = 1, 200 do plain:update(1 / 60) end + input:release("down") + check(plain.scroll > 0, "the generic list scrolled to the bottom") + eq(plain.cursorBlank, 0, "without ever blanking its cursor") +end + do local data = { pokemon = {}, constants = { dexSize = 151, dexDigits = 3 } } for n = 1, 151 do @@ -142,6 +194,8 @@ do 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") + -- arrow is never wiped (engine/menus/pokedex.asm:18, 217-221) + eq(dex.cursorBlank, nil, "and the dex cursor is never blanked") input:release("down") input:press("right") diff --git a/tests/engine/mod_index_tests.lua b/tests/engine/mod_index_tests.lua index b05d040f..082f97d5 100644 --- a/tests/engine/mod_index_tests.lua +++ b/tests/engine/mod_index_tests.lua @@ -378,6 +378,58 @@ do "tag filters") end + +do + local mods = { + { id = "gen1only", title = "Gen 1 Only", games = { "gen1" } }, + { id = "goldonly", title = "Gold Only", games = { "gold" } }, + { id = "everywhere", title = "Everywhere", games = { "all" } }, + { id = "silent", title = "Silent" }, + } + eq(#ModIndex.filter(mods, {}), 4, "no game filter keeps every listing") + local gen1 = ModIndex.filter(mods, { game = "gen1" }) + eq(#gen1, 3, "gen1 keeps the Gen 1 mods, the all-games mod and the silent one") + eq(gen1[1].id, "gen1only", "feed order survives the game filter") + local gen2 = ModIndex.filter(mods, { game = "gen2" }) + eq(#gen2, 3, "gen2 drops the Gen 1-only mod") + eq(gen2[1].id, "goldonly", "and keeps the one that names a Gen 2 game") + eq(#ModIndex.filter(mods, { game = "red" }), 3, + "a single version reads the generation's mods too") + eq(ModIndex.filter(mods, { game = "gold" })[1].id, "goldonly", + "and a Gen 2 version keeps a mod that names only that game") + eq(#ModIndex.filter(mods, { game = "all" }), 4, '"all" filters nothing') + eq(#ModIndex.filter(mods, { game = "nonsense" }), 4, + "a token naming no game this engine has filters nothing") + local silentSeen = false + for _, entry in ipairs(gen2) do + if entry.id == "silent" then silentSeen = true end + end + check(silentSeen, "a listing with no games stays in every game's list") + + local carts = { + { id = "johto", kind = "cart", base = "gold" }, + { id = "kanto", kind = "cart", base = "red" }, + } + eq(ModIndex.filter(carts, { game = "gen2" })[1].id, "johto", + "a cart is filtered by the game it plays as") + eq(#ModIndex.filter(carts, { game = "gen1" }), 1, + "and only that game") +end + +do + eq(ModIndex.targetLabel({ games = { "red", "blue", "yellow" } }), "GEN 1", + "a whole generation chips as GEN 1") + eq(ModIndex.targetLabel({ games = { "all" } }), "GEN 1+2", + "every game chips as both generations") + eq(ModIndex.targetLabel({ games = { "gold" } }), "GOLD", + "a lone game chips as its own name") + check(ModIndex.targetLabel({}) == nil, "a listing with no games has no chip") + check(ModIndex.targetLabel(nil) == nil, "and neither does a nil entry") + eq(#ModIndex.targets({ games = { "gen1" } }), 3, + "targets expands a generation token to its versions") + eq(#ModIndex.targets({}), 0, "and an undeclared games list is empty") +end + do local index = ModIndex.parse(feed({ NUZLOCKE })) local cats = ModIndex.categoriesIn(index) diff --git a/tests/engine/pad_speed_binding_bug1922.lua b/tests/engine/pad_speed_binding_bug1922.lua new file mode 100644 index 00000000..106512b1 --- /dev/null +++ b/tests/engine/pad_speed_binding_bug1922.lua @@ -0,0 +1,158 @@ + +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 Input = require("src.core.Input") +local Strings = require("src.core.Strings") +local BindingsMenu = require("src.ui.BindingsMenu") + +local function newGame() + local game = { save = { options = {} }, data = {}, wroteOptions = 0 } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + game.input = { + queue = {}, + wasPressed = function(self, btn) return self.queue[btn] or false end, + isDown = function() return false end, + } + function game:writeOptions() self.wroteOptions = self.wroteOptions + 1 end + return game +end + +local function press(state, btn) + state.game.input.queue = { [btn] = true } + state:update(1 / 60) + state.game.input.queue = {} +end + +local ROW_A, ROW_SPEED_DOWN, ROW_SPEED_UP = 5, 9, 10 + + +Input:init() +eq(Input:padAction("rightshoulder"), "speedUp", "R1 speeds up by default") +eq(Input:padAction("leftshoulder"), "speedDown", "L1 slows down by default") +eq(Input:padAction("righttrigger"), "speedUp", "R2 alongside it") +eq(Input:padAction("lefttrigger"), "speedDown", "L2 alongside it") +eq(Input:padAction("a"), nil, "a face button is not a pad action") +check(Input.padBindings["rightshoulder"] == nil, + "a pad action never enters the Game Boy button map") +eq(Input.padBindings["a"], "a", "which is otherwise untouched") + +Input:gamepadpressed(nil, "rightshoulder") +Input:step() +local held = 0 +for _ in pairs(Input.state) do held = held + 1 end +eq(held, 0, "pressing R1 presses no Game Boy button") + + +Input:applyBindings({ speedUp = { pad = "y" } }) +eq(Input:padAction("y"), "speedUp", "the overlay moves SPEED + to Y") +eq(Input:padAction("rightshoulder"), nil, "and leaves R1 inert") +eq(Input:padAction("righttrigger"), nil, "including its trigger alias") +eq(Input:padAction("leftshoulder"), "speedDown", "SPEED - keeps its default") +check(Input.padBindings["y"] == nil, + "the moved action does not press a Game Boy button either") + + +Input:applyBindings({ speedUp = false, speedDown = false, a = { pad = "y" } }) +eq(Input:padAction("rightshoulder"), nil, "unbound: R1 does nothing") +eq(Input:padAction("leftshoulder"), nil, "unbound: L1 does nothing") +eq(Input:padAction("righttrigger"), nil, "unbound: R2 does nothing") +eq(Input.padBindings["y"], "a", "a GB rebind in the same overlay still lands") +Input:init() + + +local game = newGame() +local bm = BindingsMenu.new(game) +game.stack:push(bm) +eq(#bm.items, 10, "CONTROLS grew two rows") +eq(bm.items[ROW_SPEED_UP].right, "RB", "SPEED + shows its default shoulder") +eq(bm.items[ROW_SPEED_DOWN].right, "LB", "SPEED - shows its own") + +bm.index = ROW_SPEED_UP +press(bm, "a") +eq(bm.capture, bm.items[ROW_SPEED_UP], "A arms the SPEED + row") +bm:onGamepadPressed("y") +bm:onGamepadReleased("y") +eq(game.save.options.bindings.speedUp.pad, "y", "the release stores pad Y") +eq(bm.items[ROW_SPEED_UP].right, "Y", "and the row redraws") + + +local writes = game.wroteOptions +press(bm, "select") +eq(game.save.options.bindings.speedUp, false, "SELECT unbinds the action") +eq(bm.items[ROW_SPEED_UP].right, Strings("OFF"), "the row reads OFF") +eq(game.wroteOptions, writes + 1, "clearing writes once") +press(bm, "select") +eq(game.wroteOptions, writes + 1, "SELECT on an already-OFF row writes nothing") + +bm:commitBindings() +eq(Input:padAction("rightshoulder"), nil, "closing applies the OFF state") +eq(Input:padAction("y"), nil, "the moved button is inert too") + + +bm.index = ROW_SPEED_DOWN +press(bm, "a") +bm:onGamepadPressed("leftshoulder") +bm:onGamepadReleased("leftshoulder") +eq(bm.items[ROW_SPEED_DOWN].right, "LB", "SPEED - is back on L1") + +bm.index = ROW_A +press(bm, "a") +bm:onGamepadPressed("leftshoulder") +bm:onGamepadReleased("leftshoulder") +eq(game.save.options.bindings.a.pad, "leftshoulder", "GB A takes L1") +eq(game.save.options.bindings.speedDown, false, + "and the speed action yields rather than trading for a face button") +eq(bm.items[ROW_SPEED_DOWN].right, Strings("OFF"), "the row shows it") + +bm:commitBindings() +eq(Input.padBindings["leftshoulder"], "a", "applied: L1 is Game Boy A") +eq(Input:padAction("leftshoulder"), nil, "and no longer a speed shortcut") + + +bm.index = ROW_SPEED_UP +press(bm, "a") +bm:onKeyPressed("q") +check(bm.capture == nil, "a key cancels an armed pad-action row") +if bm.onKeyReleased then bm:onKeyReleased("q") end +eq(game.save.options.bindings.speedUp, false, "and stores nothing") + + +game.save.options.bindings = nil +for _, it in ipairs(bm.items) do it.right = nil end +local fresh = BindingsMenu.new(game) +eq(fresh.items[ROW_SPEED_UP].right, "RB", "a cleared overlay restores SPEED +") +Input:applyBindings(nil) +eq(Input:padAction("rightshoulder"), "speedUp", "and the live map with it") + + +local SaveData = require("src.core.SaveData") +local files = {} +local fs = { + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] ~= nil then return { type = "file" } end + return nil + end, +} +local full = SaveData.defaultOptions() +full.bindings = { speedUp = false, a = { pad = "y" } } +check(SaveData.saveOptions(full, fs) ~= nil, "options with an OFF row write") +local reloaded = SaveData.loadOptions(fs) +eq(reloaded.bindings.speedUp, false, "false round-trips as false, not nil") +eq(reloaded.bindings.a.pad, "y", "beside an ordinary GB rebind") +Input:applyBindings(reloaded.bindings) +eq(Input:padAction("rightshoulder"), nil, "a reloaded OFF row stays off") + +Input:init() +T.finish("pad_speed_binding_bug1922") diff --git a/tests/engine/poison_flicker_bug1872.lua b/tests/engine/poison_flicker_bug1872.lua index 2d18d9e9..22086e6a 100644 --- a/tests/engine/poison_flicker_bug1872.lua +++ b/tests/engine/poison_flicker_bug1872.lua @@ -55,6 +55,34 @@ do local self_ = setmetatable({}, { __index = OW }) self_:applyFieldPoison() eq(self_.poisonFlash, 4, "a poison tick arms four frames of flicker") + + -- engine/events/poison.asm:75-91 .countPoisonedLoop + local sounds = {} + package.loaded["src.core.Sound"] = { + play = function(_, id) sounds[#sounds + 1] = id end, + playCry = function() end, + } + save.party = { Pokemon.new(Data, "FIXMON_A", 20), Pokemon.new(Data, "FIXMON_A", 20) } + save.party[1].status = "PSN" + save.party[1].hp = 1 + save.party[2].status = nil + save.party[2].hp = 20 + save.poisonSteps = 3 + local realTextBox = require("src.render.TextBox") + setUpvalue(OW.applyFieldPoison, "TextBox", { new = function() return {} end }) + local fainter = setmetatable({}, { __index = OW }) + fainter:applyFieldPoison() + setUpvalue(OW.applyFieldPoison, "TextBox", realTextBox) + eq(save.party[1].hp, 0, "the last poisoned mon faints on this tick") + eq(fainter.poisonFlash, nil, "a party with nothing left poisoned never flashes") + eq(#sounds, 0, "and plays no SFX_POISONED") + + save.party[2].status = "PSN" + save.poisonSteps = 3 + local other = setmetatable({}, { __index = OW }) + other:applyFieldPoison() + eq(other.poisonFlash, 4, "a mon still poisoned after the loop keeps the flash") + eq(sounds[1], "Poisoned", "and the sound") package.loaded["src.core.Sound"] = realSound end @@ -76,19 +104,15 @@ do 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 + -- engine/gfx/screen_effects.asm:1-12, DelayFrames counts vblanks (#1908) + for _ = 1, 10 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") + eq(self_.poisonFlash, 4, "drawing never spends the flicker") -- home/fade.asm:66 - self_.poisonFlash = 4 PaletteFX.setShadeMap(PaletteFX.DARK_BGP) self_:drawUI() check(PaletteFX.shadeMap() == PaletteFX.DARK_BGP, @@ -97,7 +121,6 @@ do 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 @@ -105,6 +128,60 @@ do "with no shader the flicker becomes a screen-space veil") eq(#rects, 0, "and still not a 160x144 fill") + PaletteFX.shader = function() return { send = function() end } end + local savedMode = PaletteFX.mode + PaletteFX.mode = "redpp" + self_.map = { renderer = { gbcAtlas = {} } } + PaletteFX.setShadeMap(nil) + renderer.screenVeil = nil + self_:drawUI() + check(renderer.screenVeil ~= nil and renderer.screenVeil[2] > 0, + "a baked GBC atlas falls back to the screen-space veil") + check(PaletteFX.shadeMap() == nil, "and does not arm a map nothing reads") + eq(self_:poisonShadeMap(), nil, + "and drawWorld arms none either, so the veil is not doubled up") + + self_.map = { renderer = {} } + PaletteFX.setShadeMap(nil) + renderer.screenVeil = nil + self_:drawUI() + eq(renderer.screenVeil, nil, "a zoned world needs no veil") + check(PaletteFX.shadeMap() == PaletteFX.POISON_BGP, + "it arms the poison map instead") + check(self_:poisonShadeMap() == PaletteFX.POISON_BGP, + "and drawWorld arms the same one") + PaletteFX.mode = savedMode + self_.map = nil + + -- engine/events/poison.asm:57, :93 + local other = {} + setUpvalue(OW.drawUI, "Game", + { renderer = renderer, stack = { top = function() return other end } }) + PaletteFX.setShadeMap(nil) + renderer.screenVeil = nil + self_:drawUI() + eq(renderer.screenVeil, nil, "a flicker under a text box paints nothing") + check(PaletteFX.shadeMap() == nil, "and arms no shade map") + eq(self_.poisonFlash, 4, "and is still there for when the box closes") + eq(self_:poisonShadeMap(), nil, "and drawWorld holds the map back too") + setUpvalue(OW.drawUI, "Game", + { renderer = renderer, stack = { top = function() return self_ end } }) + PaletteFX.setShadeMap(nil) + self_:drawUI() + check(PaletteFX.shadeMap() == PaletteFX.POISON_BGP, + "and flashes once the overworld owns the loop again") + setUpvalue(OW.drawUI, "Game", { renderer = renderer }) + + -- `ld c, 4 / call DelayFrames` (engine/gfx/screen_effects.asm:1-12) + local ticker = setmetatable({ poisonFlash = 4 }, { __index = OW }) + for i = 3, 0, -1 do + OW.tickPoisonFlash(ticker) + eq(ticker.poisonFlash, i, "update spends one flicker frame per logic tick") + end + OW.tickPoisonFlash(ticker) + eq(ticker.poisonFlash, 0, "and stops at zero") + eq(ticker:poisonShadeMap(), nil, "a spent flash arms no shade map") + PaletteFX.setShadeMap(nil) PaletteFX.shader = savedShader love.graphics.rectangle = savedRect diff --git a/tests/engine/save_convert_extra_flags.lua b/tests/engine/save_convert_extra_flags.lua index 1798b631..f8058aa3 100644 --- a/tests/engine/save_convert_extra_flags.lua +++ b/tests/engine/save_convert_extra_flags.lua @@ -204,4 +204,83 @@ sf:close() check(story2:find("flags.EVENT_GAVE_GUARDS_DRINK", 1, true) ~= nil, "the gate scripts still spell the drink flag EVENT_GAVE_GUARDS_DRINK") +-- ram/wram.asm:2057-2078; engine/debug/debug_party.asm:119 + +eq(OFF.rivalStarter, TOWN_VISITED + 10, "wRivalStarter is wTownVisitedFlag + 10") +eq(OFF.playerStarter, TOWN_VISITED + 12, "wPlayerStarter is wTownVisitedFlag + 12") +check(OFF.playerStarter >= OFF.checksumStart and OFF.playerStarter < OFF.checksumEnd, + "both starter bytes lie inside the main checksum window") + +local cw = GenSave.crosswalks(data) +eq(cw.pokemonIndex.CHARMANDER, 176, "STARTER1 CHARMANDER is internal index 176") +eq(cw.pokemonIndex.SQUIRTLE, 177, "STARTER2 SQUIRTLE is internal index 177") +eq(cw.pokemonIndex.BULBASAUR, 153, "STARTER3 BULBASAUR is internal index 153") + +for _, species in ipairs({ "CHARMANDER", "SQUIRTLE", "BULBASAUR" }) do + eq(events.byName["EVENT_CHOSE_" .. species], nil, + "EVENT_CHOSE_" .. species .. " has no wEventFlags bit to ride") +end + +-- scripts/OaksLab.asm:797-825; scripts/PokemonTower2F.asm:155-168 +local COUNTERPICK = { + CHARMANDER = "SQUIRTLE", SQUIRTLE = "BULBASAUR", BULBASAUR = "CHARMANDER", +} +for player, rival in pairs(COUNTERPICK) do + local chose = seedSave() + chose.flags.EVENT_GOT_STARTER = true + chose.flags["EVENT_CHOSE_" .. player] = true + local bytes = GenSave.encode(chose, data, nil) + eq(bytes:byte(OFF.playerStarter + 1), cw.pokemonIndex[player], + "EVENT_CHOSE_" .. player .. " reaches wPlayerStarter as " .. player) + eq(bytes:byte(OFF.rivalStarter + 1), cw.pokemonIndex[rival], + "and hands the rival " .. rival .. " in wRivalStarter") + check(GenSave.mainChecksumValid(bytes), + "the " .. player .. " export still checksums") + local back = GenSave.decode(bytes, data) + eq(back.flags["EVENT_CHOSE_" .. player], true, + "EVENT_CHOSE_" .. player .. " survives export -> import") + eq(back.flags["EVENT_CHOSE_" .. rival], nil, + "and no second starter flag comes back with it") +end + +local function patch(bytes, off, value) + return bytes:sub(1, off) .. string.char(value) .. bytes:sub(off + 2) +end + +local plain = seedSave() +plain.flags.EVENT_GOT_STARTER = true +local cart = GenSave.encode(plain, data, nil) +cart = patch(cart, OFF.playerStarter, cw.pokemonIndex.BULBASAUR) +cart = patch(cart, OFF.rivalStarter, cw.pokemonIndex.CHARMANDER) +local cartSave = GenSave.decode(cart, data) +eq(cartSave.flags.EVENT_CHOSE_BULBASAUR, true, + "a cart save that chose BULBASAUR imports with the BULBASAUR flag set") + +-- scripts/OaksLab.asm:322-323 +local rivalOnly = GenSave.decode(patch(cart, OFF.playerStarter, 0), data) +eq(rivalOnly.flags.EVENT_CHOSE_BULBASAUR, true, + "a blank wPlayerStarter falls back to the wRivalStarter counterpick") + +local neither = GenSave.decode( + patch(patch(cart, OFF.playerStarter, 0), OFF.rivalStarter, 0), data) +eq(neither.flags.EVENT_CHOSE_BULBASAUR, nil, + "an empty starter pair invents no choice") +local warned = false +for _, w in ipairs(neither.warnings or {}) do + if w:find("starter", 1, true) then warned = true end +end +check(warned, "and the import warns that the rival parties will default") + +local preLab = seedSave() +preLab.flags.EVENT_GOT_STARTER = nil +eq(GenSave.encode(preLab, data, cart):byte(OFF.playerStarter + 1), + cw.pokemonIndex.BULBASAUR, + "a save with no EVENT_CHOSE_* leaves the template's starter bytes alone") + +-- scripts/OaksLab.asm:335 +local preBytes = patch(GenSave.encode(preLab, data, nil), + OFF.playerStarter, cw.pokemonIndex.BULBASAUR) +eq(GenSave.decode(preBytes, data).flags.EVENT_CHOSE_BULBASAUR, nil, + "a pre-lab import sets no starter flag") + T.finish("save_convert_extra_flags") diff --git a/tests/engine/skin_studio_library_reach_bug1912.lua b/tests/engine/skin_studio_library_reach_bug1912.lua new file mode 100644 index 00000000..62a77bf9 --- /dev/null +++ b/tests/engine/skin_studio_library_reach_bug1912.lua @@ -0,0 +1,174 @@ + +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 TouchSkin = require("src.core.TouchSkin") +local Studio = require("src.ui.SkinStudio") +local Kit = require("src.ui.kit.Kit") + +local oldDims = love.graphics.getDimensions +local oldSafe = love.window.getSafeArea +local oldThumb, oldSummary = Studio.libraryThumb, Studio.skinSummary +Studio.libraryThumb = function() return nil end +Studio.skinSummary = function() return "12 buttons 2 pages" end + +local SIZES = { + { 1600, 720, "1600x720 landscape phone" }, + { 2400, 1080, "2400x1080 landscape phone" }, + { 1280, 720, "1280x720" }, + { 1920, 1080, "1920x1080" }, + { 720, 1600, "720x1600 portrait phone" }, + { 400, 800, "400x800 small portrait" }, +} + +local function open(w, h, n) + love.graphics.getDimensions = function() return w, h end + love.window.getSafeArea = function() return 0, 0, w, h end + Studio.skin = TouchSkin.newSkin("t") + Studio.mode = "library" + Studio.modal, Studio.confirm = nil, nil + Studio.available = {} + for i = 1, n do + Studio.available[i] = { id = "skin" .. i, source = "user" } + end + Studio.libraryScroll, Studio.libraryScrollMax = 0, 0 + Studio.libraryPress = nil +end + +local function inside(r, f) + return r.x >= f.x - 0.5 and r.y >= f.y - 0.5 + and r.x + r.w <= f.x + f.w + 0.5 and r.y + r.h <= f.y + f.h + 0.5 +end + +local function tappable(r) + if not r.clip then return { x = r.x, y = r.y, w = r.w, h = r.h } end + local x1 = math.max(r.x, r.clip.x) + local y1 = math.max(r.y, r.clip.y) + local x2 = math.min(r.x + r.w, r.clip.x + r.clip.w) + local y2 = math.min(r.y + r.h, r.clip.y + r.clip.h) + if x2 <= x1 or y2 <= y1 then return nil end + return { x = x1, y = y1, w = x2 - x1, h = y2 - y1 } +end + +for _, size in ipairs(SIZES) do + local w, h, label = size[1], size[2], size[3] + local frame = { x = 0, y = 0, w = w, h = h } + for _, n in ipairs({ 3, 9 }) do + open(w, h, n) + Kit.audit = {} + check(pcall(Studio.draw), label .. " with " .. n .. " skins draws") + + local escaped = nil + for _, r in ipairs(Kit.audit or {}) do + local vis = tappable(r) + if vis and not inside(vis, frame) then escaped = r.label end + end + eq(escaped, nil, label .. "/" .. n + .. ": no tappable control escapes the window") + + local maxScroll = Studio.libraryScrollMax or 0 + local reached = {} + local step = Kit.scrollStep() + local offset = 0 + while true do + Studio.libraryScroll = offset + Kit.audit = {} + check(pcall(Studio.draw), label .. "/" .. n + .. ": draws at scroll " .. math.floor(offset)) + local view = Studio.libraryView + for id, r in pairs(Studio.libraryRects or {}) do + if inside(r, view) and inside(r, frame) then reached[id] = true end + end + if offset >= maxScroll then break end + offset = math.min(maxScroll, offset + step) + end + local missing = nil + for i = 1, n do + if not reached["skin" .. i] then missing = "skin" .. i end + end + eq(missing, nil, label .. "/" .. n + .. ": every skin's Select can be scrolled fully into view") + end +end + +Kit.audit = nil + +do + open(1600, 720, 3) + Studio.draw() + eq(Studio.libraryScrollMax, 0, "three skins fit a 1600x720 window with no scroll") + local view = Studio.libraryView + for i = 1, 3 do + local r = Studio.libraryRects["skin" .. i] + check(r ~= nil and inside(r, view), + "skin" .. i .. "'s Select is on screen at rest") + end +end + +do + open(1600, 720, 9) + Studio.draw() + check((Studio.libraryScrollMax or 0) > 0, + "nine skins overflow the same window and become scrollable") +end + + +do + open(1600, 720, 9) + Studio.draw() + local maxScroll = Studio.libraryScrollMax + local x, y = 800, 400 + + Studio.clicked = false + Studio.touchpressed("f1", x, y) + eq(Studio.clicked, false, "a press in the library does not dispatch a tap") + check(Studio.libraryPress ~= nil, "it arms a drag instead") + Studio.touchmoved("f1", x, y - 200) + check(Studio.libraryScroll > 0, "dragging up scrolls the list") + eq(Studio.libraryScroll, math.min(200, maxScroll), + "by exactly the distance the finger travelled, clamped to the extent") + Studio.touchreleased("f1", x, y - 200) + eq(Studio.clicked, false, "and the drag never becomes a tap on release") + eq(Studio.libraryPress, nil, "the press is cleared") + + Studio.touchmoved("f1", x, y - 400) + eq(Studio.libraryScroll, math.min(200, maxScroll), + "a move after release moves nothing") + + Studio.libraryScroll = 0 + Studio.clicked = false + Studio.touchpressed("f1", x, y) + Studio.touchmoved("f1", x + 2, y - 2) + eq(Studio.libraryScroll, 0, "a jitter inside the tap slop is not a drag") + Studio.touchreleased("f1", x + 2, y - 2) + eq(Studio.clicked, true, "a press with no travel dispatches the tap on release") +end + +do + open(1600, 720, 9) + Studio.draw() + Studio.libraryScroll = 0 + Studio.touchpressed("f1", 800, 400) + Studio.touchmoved("f1", 800, 400 + 500) + eq(Studio.libraryScroll, 0, "dragging down past the top clamps at zero") + Studio.touchreleased("f1", 800, 900) +end + +do + open(1600, 720, 3) + Studio.mode = "editor" + Studio.clicked = false + Studio.libraryPress = nil + Studio.mousepressed(800, 400, 1) + eq(Studio.clicked, true, "the editor still dispatches its tap on press") + eq(Studio.libraryPress, nil, "and never arms a library drag") +end + +Studio.libraryThumb, Studio.skinSummary = oldThumb, oldSummary +love.graphics.getDimensions = oldDims +love.window.getSafeArea = oldSafe + +T.finish("My Skins library reach and scroll (#1912)") diff --git a/tests/engine/speed_shoulders_triggers_test.lua b/tests/engine/speed_shoulders_triggers_test.lua index 0d9438d6..0a0669a9 100644 --- a/tests/engine/speed_shoulders_triggers_test.lua +++ b/tests/engine/speed_shoulders_triggers_test.lua @@ -1,10 +1,6 @@ -- The built-in GAME SPEED hotkeys on controller: the shoulder buttons -- (L1/R1) and the analog triggers (L2/R2) all cycle the engine speed -- ladder, R-side faster and L-side slower, exactly like keyboard hotkey --- 1. Anything else must still fall through to the top-state pad --- routing and never touch the speed ladder. LÖVE reports an analog --- trigger as gamepadpressed once it crosses the press threshold, so --- "lefttrigger"/"righttrigger" land here like any other pad button. -- No pokered cite: port-only (gap C2). -- luajit tests/engine/speed_shoulders_triggers_test.lua @@ -47,7 +43,6 @@ eq(last(), 1, "R2 speeds up") Game.gamepadpressed(game, nil, "lefttrigger") eq(last(), -1, "L2 slows down") --- ---- everything else falls through to the top-state pad routing --------- local routed = nil game.stack.top = function() return { onGamepadPressed = function(_, b) routed = b end } @@ -56,9 +51,21 @@ Game.gamepadpressed(game, nil, "a") eq(routed, "a", "a normal button still reaches the top-state pad routing") routed = nil Game.gamepadpressed(game, nil, "rightshoulder") -check(routed == nil, "the speed buttons are consumed, never routed to a menu") +eq(routed, "rightshoulder", + "a screen owning pad input sees L1/R1 too, so CONTROLS can capture them") Game.gamepadpressed(game, nil, "lefttrigger") -check(routed == nil, "a trigger pull is consumed the same way") -check(#dirs == 4, "only the speed buttons touched the speed ladder") +eq(routed, "lefttrigger", "and the trigger names alongside them") +check(#dirs == 2, "a captured shoulder never touches the speed ladder") + +game.stack.top = function() return nil end +local forwarded = nil +local origPad = Input.gamepadpressed +function Input:gamepadpressed(joystick, button) forwarded = button end +Game.gamepadpressed(game, nil, "rightshoulder") +check(forwarded == nil, "the speed buttons never reach the GB button map") +eq(#dirs, 3, "and they still cycle the ladder") +Game.gamepadpressed(game, nil, "a") +eq(forwarded, "a", "everything else does reach it") +Input.gamepadpressed = origPad T.finish("speed_shoulders_triggers") diff --git a/tests/engine/timing_parity.lua b/tests/engine/timing_parity.lua index 85bb35fb..3ad52617 100644 --- a/tests/engine/timing_parity.lua +++ b/tests/engine/timing_parity.lua @@ -671,4 +671,76 @@ T.check(FixedStep.accum > FixedStep.STEP * 0.25 and FixedStep.accum < FixedStep.STEP * 0.75, "the absorbed hitch frame leaves the accumulator mid-step, not on a boundary") + +T.eq(FixedStep.refreshPeriod, nil, "no refresh period is known headless") + +local function cadence(hz, frames, period, jitter) + local seed = 20250828 + local counts, run, worst = {}, 0, 1 + FixedStep.refreshPeriod = period + FixedStep:init(function() run = run + 1 end) + for i = 1, frames do + local before = run + local dt = 1 / hz + if jitter then + seed = (seed * 1103515245 + 12345) % 2147483648 + dt = dt + (seed / 2147483648 - 0.5) * 2 * jitter + end + FixedStep:update(dt) + counts[i] = run - before + local residual = FixedStep.accum % FixedStep.STEP + worst = math.min(worst, residual, FixedStep.STEP - residual) + end + FixedStep.refreshPeriod = nil + local seen = {} + for _, n in ipairs(counts) do seen[n] = (seen[n] or 0) + 1 end + return run, seen, worst +end + +local ran, seen, margin = cadence(90, 180, 1 / 90) +T.eq(ran, 120, "180 frames of a 90Hz panel are exactly 120 logic steps") +T.eq(seen[0], 60, "60 of them draw the same logic frame twice") +T.eq(seen[1], 120, "the other 120 advance once") +T.eq(seen[2], nil, "and no display frame ever doubles up") +T.check(margin > 0.002, + "the residual cycle sits clear of the step boundary at 90Hz") + +local _, jittered, jitterMargin = cadence(90, 180, 1 / 90, 0.001) +T.eq(jittered[1], 120, "a millisecond of vsync wobble moves no step") +T.eq(jittered[2], nil, "and doubles none up") +T.check(jitterMargin > 0.002, "the snapped dt keeps the wobble out of the sum") + +local _, unsnapped, bareMargin = cadence(90, 180, nil) +T.eq(unsnapped[1], 120, "the same stream unsnapped costs the same 120 steps") +T.check(bareMargin < 0.0005, + "but parks the accumulator on the boundary, with nothing to absorb wobble") + +local at60, sixty = cadence(60, 180, 1 / 60) +T.eq(at60, 180, "a 60Hz panel is one step per frame") +T.eq(sixty[1], 180, "every frame, with no duplicate and no double") + +local at144 = cadence(144, 288, 1 / 144) +T.eq(at144, 120, "288 frames of a 144Hz panel are 120 steps") + +local at5994, nearSixty, nearMargin = cadence(59.94, 600, 1 / 60) +T.eq(at5994, 600, "a 59.94Hz panel quantized to 60 is still one step per frame") +T.eq(nearSixty[2], nil, "with no frame ever doubling up") +T.check(nearMargin > 0.002, "and no drift onto the step boundary") + +local rephased = 0 +FixedStep.refreshPeriod = 1 / 60 +FixedStep:init(function() rephased = rephased + 1 end) +for i = 1, 120 do + FixedStep.refreshPeriod = (1 / 60) * (1 + (i % 2) * 1e-9) + FixedStep:update(1 / 60) +end +local rephasedMargin = math.min(FixedStep.accum % FixedStep.STEP, + FixedStep.STEP - FixedStep.accum % FixedStep.STEP) +FixedStep.refreshPeriod = nil +T.eq(rephased, 120, "a period re-reported every frame still costs one step a frame") +T.check(rephasedMargin > 0.002, + "and the re-phase sets the residual instead of walking it onto the boundary") + +T.eq(FixedStep.refreshPeriod, nil, "and the period is left nil for everyone else") + T.finish("timing parity") diff --git a/tests/engine/touch_hotbar_1592.lua b/tests/engine/touch_hotbar_1592.lua new file mode 100644 index 00000000..ff7f046f --- /dev/null +++ b/tests/engine/touch_hotbar_1592.lua @@ -0,0 +1,145 @@ + +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 TC = require("src.core.TouchControls") + +love.window = love.window or {} +local function setRect(w, h) + love.window.getSafeArea = function() return 0, 0, w, h end + love.graphics.getDimensions = function() return w, h end + TC.layoutW, TC.layoutH, TC.layoutOx, TC.layoutOy, TC.L = nil, nil, nil, nil, nil +end + + +local inList = false +for _, name in ipairs(TC.CONTROLS) do + if name == "hotbar" then inList = true end +end +check(inList, "hotbar is one of the pad's controls, so the editor can drag it") + +for _, r in ipairs({ { 380, 720 }, { 800, 400 } }) do + local L = TC.defaultLayout(r[1], r[2]) + local z = L.hotbar + check(z ~= nil, "defaultLayout places the toggle at " .. r[1] .. "x" .. r[2]) + check(z.cx - z.w / 2 >= 0 and z.cx + z.w / 2 <= r[1], + "the toggle sits inside the rect horizontally") + check(z.cy - z.w / 2 >= 0 and z.cy + z.w / 2 <= r[2], + "and vertically") + check(z.cy < L.dpad.cy, "it is up top, clear of the thumbs") + check(z.cx > L.dpad.cx, "and on the right") +end + + +TC:init() +TC.active = true +TC:ensureImages() +TC.img = TC.img or {} +setRect(380, 720) +TC:applyOptions({ touchControls = { enabled = true } }) +setRect(380, 720) + +local L = TC:layout() +eq(TC:hitTest(L.hotbar.cx, L.hotbar.cy), "hotbar", + "the toggle is what a press at its centre finds") +eq(TC:hitTest(L.a.cx, L.a.cy), "a", "the face buttons are unchanged") + + +check(TC:hotbarStrip() ~= nil, "a strip is laid out") +check(TC:hotbarCellAt(TC:hotbarStrip()[1].x + 1, TC:hotbarStrip()[1].y + 1) == nil, + "but nothing hits while it is folded away") + +check(TC:touchpressed("t1", L.hotbar.cx, L.hotbar.cy), + "the toggle captures its press") +check(TC.hotbarOpen, "and opens the strip") +TC:touchreleased("t1", L.hotbar.cx, L.hotbar.cy) + +local cells = TC:hotbarStrip() +eq(#cells, 6, "six host keys") +local _, _, sw, sh = require("src.core.SafeArea").windowRect() +for _, cell in ipairs(cells) do + check(cell.x >= 0 and cell.x + cell.w <= sw, cell.label .. " fits across") + check(cell.y >= 0 and cell.y + cell.h <= sh, cell.label .. " fits down") +end + + +local keys = {} +local origDown, origUp = love.keypressed, love.keyreleased +love.keypressed = function(key) keys[#keys + 1] = "+" .. key end +love.keyreleased = function(key) keys[#keys + 1] = "-" .. key end + +local save = cells[1] +eq(save.label, "SAVE", "the first cell is quicksave") +local cx, cy = save.x + save.w / 2, save.y + save.h / 2 +local hit = TC:hotbarCellAt(cx, cy) +eq(hit and hit.ctl, save.ctl, "the open strip hit-tests its cells") +check(TC:touchpressed("t2", cx, cy), "a cell captures its press (#807)") +eq(table.concat(keys, " "), "+f1", "the press synthesizes f1 once") +TC:touchreleased("t2", cx, cy) +eq(table.concat(keys, " "), "+f1 -f1", "and the lift releases it once") + +local underA = TC:layout().a +check(TC:hitTest(underA.cx, underA.cy) == "a", "fixture: A is still A") + + +keys = {} +check(TC:touchpressed("t3", cx, cy), "second press lands") +eq(table.concat(keys, " "), "+f1", "held") +TC:reset() +eq(table.concat(keys, " "), "+f1 -f1", "reset releases the synthesized key") +check(not TC.hotbarOpen, "and folds the strip away") + +love.keypressed, love.keyreleased = origDown, origUp + + +TC:applyOptions({ touchControls = { enabled = true }, hotbar = false }) +setRect(380, 720) +L = TC:layout() +check(not TC:hotbarShown(), "options.hotbar = false hides the toggle") +eq(TC:hitTest(L.hotbar.cx, L.hotbar.cy), nil, "nothing to hit there") +check(TC:touchpressed("t4", L.hotbar.cx, L.hotbar.cy) == nil, + "and the press falls through to the mod pointer seam") + +TC:applyOptions({ touchControls = { enabled = true }, hotbar = true }) +setRect(380, 720) +check(TC:hotbarShown(), "and back on again") + + +L = TC:layout() +TC:setControlCenter("hotbar", L.a.cx, L.a.cy) +L = TC:layout() +eq(TC:hitTest(L.a.cx, L.a.cy), "hotbar", + "a toggle dragged onto A is what the editor picks up there") +TC.hotbarOpen = false +check(TC:touchpressed("t5", L.a.cx, L.a.cy), "and the press goes to it too") +check(TC.hotbarOpen, "so hitTest and touchpressed agree on the overlap") +TC:touchreleased("t5", L.a.cx, L.a.cy) +TC.hotbarOpen = false +TC:clearPositions() +setRect(380, 720) + + +TC.img = { + dpad = love.graphics.newImage("assets/touch/dpad.png"), + a = love.graphics.newImage("assets/touch/a.png"), + b = love.graphics.newImage("assets/touch/b.png"), + start = love.graphics.newImage("assets/touch/start.png"), + select = love.graphics.newImage("assets/touch/select.png"), +} +check(TC:visible(), "fixture: the overlay draws at all") +TC.hotbarOpen = true +check(pcall(function() TC:draw() end), "the open strip draws") +TC.hotbarOpen = false +check(pcall(function() TC:draw() end), "and so does the folded toggle") + + +local TouchSkin = require("src.core.TouchSkin") +TouchSkin.setActive(TouchSkin.newSkin("stub")) +check(not TC:hotbarShown(), "a skin suppresses the toggle") +TouchSkin.setActive(nil) + +TC:init() +T.finish("touch_hotbar_1592") diff --git a/tests/engine/vsync_option.lua b/tests/engine/vsync_option.lua new file mode 100644 index 00000000..844ca416 --- /dev/null +++ b/tests/engine/vsync_option.lua @@ -0,0 +1,55 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local VSync = require("src.core.VSync") + +VSync.reset() + +T.same(VSync.MODES, { "on", "off", "adaptive" }, "three modes, in row order") + +T.eq(VSync.default(), "on", "with nothing to ask, vsync reads ON") +T.eq(VSync.normalize(nil), "on", "so a save with no key is ON") +T.eq(VSync.normalize("junk"), "on", "and so is garbage") +T.eq(VSync.normalize("adaptive"), "adaptive", "a real mode is kept") + +T.eq(VSync.label("on"), "ON", "ON prints as ON") +T.eq(VSync.label("off"), "OFF", "OFF as OFF") +T.eq(VSync.label("adaptive"), "ADAPTIVE", "and ADAPTIVE spells itself out") + +T.eq(VSync.cycle("on", 1), "off", "ON cycles to OFF") +T.eq(VSync.cycle("off", 1), "adaptive", "OFF to ADAPTIVE") +T.eq(VSync.cycle("adaptive", 1), "on", "and ADAPTIVE wraps to ON") +T.eq(VSync.cycle("on", -1), "adaptive", "stepping back wraps the other way") +T.eq(VSync.cycle(nil, 1), "off", "a missing key normalizes before it steps") + +T.eq(VSync.apply("off"), "off", "apply answers with what it stored") +T.eq(VSync.isOn(), false, "and OFF is not on") +T.eq(VSync.apply("adaptive"), "adaptive", "adaptive applies") +T.eq(VSync.isOn(), true, "and counts as on: it still syncs a frame in time") +VSync.applyOptions({}) +T.eq(VSync.isOn(), true, "an options table with no key falls back to the boot mode") + + +local calls = {} +local interval = 0 +love.window.getVSync = function() return interval end +love.window.setVSync = function(v) calls[#calls + 1] = v; interval = v end +VSync.reset() + +T.eq(VSync.default(), "off", "a handheld booted with vsync 0 defaults to OFF") +T.eq(VSync.normalize(nil), "off", "so its saves with no key stay OFF") +T.eq(VSync.isOn(), false, "and the run loop sees vsync off") + +VSync.apply("on") +T.eq(calls[#calls], 1, "ON sets the swap interval to 1") +T.eq(VSync.isOn(), true, "and the loop sees vsync on") +VSync.apply("adaptive") +T.eq(calls[#calls], -1, "ADAPTIVE asks for the late-swap interval") +VSync.applyOptions({ vsync = "off" }) +T.eq(calls[#calls], 0, "and OFF turns it off") +T.eq(#calls, 3, "one driver call per apply, no repeats") + +love.window.getVSync, love.window.setVSync = nil, nil +VSync.reset() + +T.finish("vsync option") diff --git a/tests/gen2_battle_anims_test.lua b/tests/gen2_battle_anims_test.lua index 696ea0d0..0fd1625b 100644 --- a/tests/gen2_battle_anims_test.lua +++ b/tests/gen2_battle_anims_test.lua @@ -449,24 +449,29 @@ eq(whirl.lcdc, "SCY", "Whirlpool scrolls vertically") eq(whirl.lyEnd, 0x5e, "over the whole screen, not one pic box") -- Surf: the ring is laid down on the first frame, and `.one` refuses to paint --- anything until some other effect has opened the LCD-STAT window (which is --- what BATTLE_BG_EFFECT_START_WATER is for). +-- engine/battle_anims/functions.asm:1148, the SURF OBJECT, is what does. local surf = BgEffects.new(consts, { battleTurn = 0 }) surf:queue("BATTLE_BG_EFFECT_SURF", 0, 0, 0) surf:playFrame() check(surf.surfWave ~= nil, "Surf builds its $40-byte wave ring") eq(surf.lyBackup[0x20], 0, "but paints nothing without an LCDC pointer") -surf.lcdc = "SCY" -surf.lyStart, surf.lyEnd = 0, 0x36 -surf:playFrame() --- At amplitude 2 most rows of the ring round to zero; what matters is that --- the window is painted at all. -local painted = false -for row = 1, 0x5e do - if (surf.lyBackup[row] or 0) ~= 0 then painted = true end + +-- data/moves/animations.asm:1126 BattleAnim_Surf, driven whole. +local surfRun = runner(data.moves.SURF, { animId = "SURF" }) +local surfOpened, surfPainted = false, false +for _ = 1, 120 do + if not surfRun:step() then break end + if surfRun.bg.lcdc == "SCY" and surfRun.bg.lyEnd == 0x5e then + surfOpened = true + end + if surfOpened then + for row = 1, 0x5e do + if (surfRun.bg.lyBackup[row] or 0) ~= 0 then surfPainted = true end + end + end end -check(painted, "once one is set the water is on screen") -eq(surf.lyBackup[0], 0, "and the row at lyStart stays flat") +check(surfOpened, "the Surf object opens the window the BG effect waits on") +check(surfPainted, "so the water rolls across the scanlines") -- The three water effects as a set: START opens the window and ends itself, -- WATER grows two scanlines a frame until its counter passes $20, END resets. diff --git a/tests/gen2_battle_options_test.lua b/tests/gen2_battle_options_test.lua index e66ce6c5..3062824b 100644 --- a/tests/gen2_battle_options_test.lua +++ b/tests/gen2_battle_options_test.lua @@ -1,5 +1,3 @@ --- BATTLE BG on Gold (#1709): the one battle-surround setting Gold's renderer --- can honour. This covers the wiring a screenshot cannot -- the default key, -- the OPTION row and its ladder, the launcher gear row, the mod-facing member -- table -- plus the band geometry Game2 paints, asserted as rectangles rather -- than as pixels. @@ -83,9 +81,10 @@ check("OPTION has a BATTLE BG row", bgRow ~= nil, true) if bgRow then check("it edits battleBg", bgRow.key, "battleBg") check("it is a port row, not one of the cart's seven", bgRow.port, true) - check("WHITE and BLACK are the whole ladder", #bgRow.values, 2) + check("WHITE, BLACK and WORLD are the whole ladder", #bgRow.values, 3) check("stored lowercase, shown WHITE", bgRow.display.white, "WHITE") check("and BLACK", bgRow.display.black, "BLACK") + check("and WORLD", bgRow.display.world, "WORLD") -- Appended at the tail, so nothing the other suites index by position moves. check("it follows BATTLE SIZE", OptionsMenu.ROWS[bgIndex - 1].label, "BATTLE SIZE") @@ -103,8 +102,8 @@ if sizeRow then 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("it sits in the port's tail, past the cart's seven", + sizeIndex > 7, true) check("BATTLE BG comes straight after it", sizeIndex + 1, bgIndex) end check("the cart's rows are unmoved", OptionsMenu.ROWS[7].key, "frame") @@ -126,9 +125,13 @@ if built then check("right stores black, not the display string", menu.options.battleBg, "black") menu:cycle(built, 1) - check("right again wraps to white", menu.options.battleBg, "white") + check("right again reaches world", menu.options.battleBg, "world") + menu:cycle(built, 1) + check("and wraps to white", menu.options.battleBg, "white") menu:cycle(built, -1) - check("left walks the ladder the other way", menu.options.battleBg, "black") + check("left walks the ladder the other way", menu.options.battleBg, "world") + menu:cycle(built, -1) + check("still the other way", menu.options.battleBg, "black") menu:cycle(built, -1) check("and back", menu.options.battleBg, "white") end @@ -170,6 +173,9 @@ if gearRow then model.opts.gold.battleBg, "black") check("and the row reads BLACK", gearRow.value(), "BLACK") gearRow.step(1) + check("stepping again reaches world", model.opts.gold.battleBg, "world") + check("and the row reads WORLD", gearRow.value(), "WORLD") + gearRow.step(1) check("stepping again returns to white", model.opts.gold.battleBg, "white") end @@ -204,15 +210,21 @@ if type(bgMode) == "function" then check("white on an options table that predates the key", bgMode({ game = { options = {} } }), "white") check("white with no game at all", bgMode({}), "white") - -- Gen 1's third mode leaves the battle non-opaque so the map shows through; - -- Gold has no such path, so an options.lua carried over from Red must not - -- switch the Gold battle to a mode nothing paints. - check("Gen 1's WORLD degrades to white here", - bgMode({ game = { options = { battleBg = "world" } } }), "white") + check("and WORLD is the third rung, not a stray value", + bgMode({ game = { options = { battleBg = "world" } } }), "world") + check("an unknown mode still falls back to white", + bgMode({ game = { options = { battleBg = "plaid" } } }), "white") end +check("the battle screen is opaque in every mode", BattleState.isOpaque, true) + +check("the dim the WORLD veil is painted at is published", + BattleState.BG_WORLD_DIM, 0.55) + check("and mods are told bgMode is backed on this side", Gen2Compat.memberStatus("src.battle.BattleState", "bgMode"), "backed") +check("as is the dim they would read off it", + Gen2Compat.memberStatus("src.battle.BattleState", "BG_WORLD_DIM"), "backed") -- ------------------------------------------------------------- the bands -- @@ -244,11 +256,17 @@ end local blackBattle = { bgMode = function() return "black" end } local whiteBattle = { bgMode = function() return "white" end } +local worldBattle = { + bgMode = function() return "world" end, + BG_WORLD_DIM = BattleState.BG_WORLD_DIM, +} local plainMenu = {} local overworld = {} check("BLACK paints the four bands", #paint({ blackBattle }, 1024, 768), 4) check("WHITE paints nothing at all", #paint({ whiteBattle }, 1024, 768), 0) +check("WORLD paints the same four bands, as a veil", + #paint({ worldBattle }, 1024, 768), 4) check("the overworld alone paints nothing", #paint({ overworld, plainMenu }, 1024, 768), 0) @@ -256,6 +274,16 @@ check("the overworld alone paints nothing", -- stack past states that have no bgMode of their own. check("a party menu over the battle still finds the battle's mode", #paint({ overworld, blackBattle, plainMenu }, 1024, 768), 4) +check("and the same under WORLD", + #paint({ overworld, worldBattle, plainMenu }, 1024, 768), 4) + +local blackPen = paint({ blackBattle }, 1024, 768)[1] +local worldPen = paint({ worldBattle }, 1024, 768)[1] +check("BLACK is opaque", blackPen and blackPen.pen[4], 1) +check("WORLD is the published dim", worldPen and worldPen.pen[4], + BattleState.BG_WORLD_DIM) +check("and a zero dim paints nothing", #paint({ + { bgMode = function() return "world" end, BG_WORLD_DIM = 0 } }, 1024, 768), 0) local function panelSafe(states, w, h, label, scale) scale = scale or Chrome.fitScale(w, h) @@ -285,6 +313,8 @@ for _, size in ipairs({ { 1024, 768 }, { 1280, 840 }, { 1920, 1080 }, { 800, 600 }, { 1366, 768 } }) do panelSafe({ blackBattle }, size[1], size[2], ("%dx%d"):format(size[1], size[2])) + panelSafe({ worldBattle }, size[1], size[2], + ("world %dx%d"):format(size[1], size[2])) end -- SCREEN POS lifts the panel off centre, which is the other way the four @@ -338,24 +368,102 @@ 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 --- out of the source, the way the other Gen 2 suites check this file. It has --- to run right after the widescreen layer has painted its white surround and --- before the letterbox, or there is nothing to repaint over. -local handle = io.open("src/core/Game2.lua", "r") -local source = handle and handle:read("*a") -if handle then handle:close() end -check("Game2's source is readable", source ~= nil, true) -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) + +local function scene(mode, opts) + opts = opts or {} + local log = {} + local function note(what) + log[#log + 1] = { what = what, flag = Chrome.worldSurround } + end + local battle = { + isOpaque = true, + drawsWidescreen = function() return true end, + drawWidescreen = function() note("widescreen") end, + bgMode = function() return mode end, + BG_WORLD_DIM = BattleState.BG_WORLD_DIM, + } + if opts.fill then + battle.battlePanelScale = function(_, w, h) + return BattleState.fillScale(w, h) + end + end + local states = { battle } + if opts.menu then states[2] = plainMenu end + local game = { + stack = { + states = states, + top = function(self) return self.states[#self.states] end, + renderVisible = function(_, state) return state ~= nil end, + visibleBase = function() return 1 end, + draw = function() note("stack") end, + }, + world = { map = {}, draw = function() note("world") end }, + inFillBoot = function() return false end, + letterbox = function(_, _, _, worldActive) + note(worldActive and "letterbox(world)" or "letterbox") + end, + paintBattleSurround = function() note("surround") end, + } + local scales = {} + local realScale = G.scale + G.scale = function(sx, sy) scales[#scales + 1] = sx; realScale(sx, sy) end + Chrome.worldSurround = true -- a stale flag drawScene has to clear itself + local ok, err = pcall(Game2.drawScene, game, 1280, 840) + G.scale = realScale + Chrome.worldSurround = false + if not ok then check("drawScene ran: " .. tostring(err), false, true) end + local order, flags = {}, {} + for i, entry in ipairs(log) do + order[i], flags[entry.what] = entry.what, entry.flag + end + return table.concat(order, ","), flags, game, scales end +do + local order, flags, game = scene("world") + check("a WORLD battle puts the map up before the widescreen layer", + order, "letterbox(world),world,widescreen,surround,letterbox") + check("the widescreen layer paints with the paper suppressed", + flags["widescreen"], true) + check("and so does the surround", flags["surround"], true) + check("the flag is down again by the closing letterbox", + flags["letterbox"], false) + check("and down when drawScene returns", Chrome.worldSurround, false) + check("the frame is marked as having drawn the world", + game.frameWorldActive, true) +end + +do + local order, flags, game = scene("black") + check("BLACK never draws the map", order, "widescreen,surround,letterbox") + check("and never suppresses the paper", flags["widescreen"], false) + check("nor marks the frame", game.frameWorldActive, false) +end + +do + local order, _, _, scales = scene("black", { fill = true, menu = true }) + check("a menu over the battle blits over the widescreen layer", + order, "widescreen,surround,letterbox,stack") + check("at the battle's own panel scale", scales[1], + BattleState.fillScale(1280, 840)) +end + +do + local painted = 0 + local rect, setColor = G.rectangle, G.setColor + G.rectangle = function() painted = painted + 1 end + G.setColor = function() end + local ok = pcall(Chrome.letterbox, 320, 288, 1, 1, 1) + local down = painted + Chrome.worldSurround = true + local okUp = pcall(Chrome.letterbox, 320, 288, 1, 1, 1) + Chrome.worldSurround = false + G.rectangle, G.setColor = rect, setColor + check("Chrome.letterbox paints the paper by default", ok and down, 1) + check("and stands down for a WORLD surround", okUp and painted, 1) +end +check("and the flag is down by default", Chrome.worldSurround, false) + print(("gen2 battle options: %d checks, %d failures"):format(checks, failures)) if failures > 0 then error(("%d assertion(s) failed"):format(failures), 0) diff --git a/tests/gen2_fly_map_test.lua b/tests/gen2_fly_map_test.lua index d31650b4..e19d9a28 100644 --- a/tests/gen2_fly_map_test.lua +++ b/tests/gen2_fly_map_test.lua @@ -157,4 +157,58 @@ do check(ok, "the unstyled fly picker draws (" .. tostring(err) .. ")") end +-- IsInJohto (home/region.asm:10, :23) +do + local World = require("src.world.gen2.World") + + local function landmarkTable(indices) + local out = { landmarks = {}, order = {} } + for id, index in pairs(indices) do + out.landmarks[id] = { index = index, name = id, x = 8, y = 8 } + out.order[index + 1] = id + end + return out + end + + local GOLD = landmarkTable({ + LANDMARK_NEW_BARK_TOWN = 0x01, + LANDMARK_SILVER_CAVE = 0x2d, + LANDMARK_PALLET_TOWN = 0x2e, + LANDMARK_ROUTE_28 = 0x5d, + LANDMARK_FAST_SHIP = 0x5e, + }) + local CRYSTAL = landmarkTable({ + LANDMARK_NEW_BARK_TOWN = 0x01, + LANDMARK_BATTLE_TOWER = 0x1d, + LANDMARK_SILVER_CAVE = 0x2e, + LANDMARK_PALLET_TOWN = 0x2f, + LANDMARK_ROUTE_28 = 0x5e, + LANDMARK_FAST_SHIP = 0x5f, + }) + + local function regionAt(landmarks, id) + return World.region(setmetatable({ + landmarks = landmarks, map = { def = { landmark = id } }, + }, World)) + end + + eq(regionAt(CRYSTAL, "LANDMARK_SILVER_CAVE"), "johto", + "Crystal's $2e is SILVER CAVE, so Mt. Silver flies to Johto") + eq(regionAt(CRYSTAL, "LANDMARK_PALLET_TOWN"), "kanto", + "and its $2f is PALLET TOWN, so Kanto") + eq(regionAt(CRYSTAL, "LANDMARK_FAST_SHIP"), "johto", + "the S.S. Aqua is Johto at $5f") + eq(regionAt(CRYSTAL, "LANDMARK_ROUTE_28"), "kanto", + "and $5e is ROUTE 28, which is Kanto") + + eq(regionAt(GOLD, "LANDMARK_SILVER_CAVE"), "johto", "Gold's $2d is Johto") + eq(regionAt(GOLD, "LANDMARK_PALLET_TOWN"), "kanto", + "Gold's PALLET TOWN is Kanto at $2e") + eq(regionAt(GOLD, "LANDMARK_FAST_SHIP"), "johto", + "and its S.S. Aqua is Johto at $5e") + + eq(World.region(setmetatable({ map = { def = { landmark = 0x2e } } }, World)), + "kanto", "with no landmark table the Gold constants stand in") +end + S.finish() diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua index 11d4fa6f..6b45d448 100644 --- a/tests/gen2_menus_test.lua +++ b/tests/gen2_menus_test.lua @@ -358,8 +358,7 @@ local options = OptionsMenu.new(optionsGame, { }) -- The cart's seven rows, then the port's: CONTROLS, audio, PERFORMANCE, -- speed, display, SHADER FX + SHADER FX 2 (the second slot added alongside --- the dual-shader feature), video mode and screen position. -check("twenty-nine rows", #OptionsMenu.ROWS, 29) +check("thirty-one rows", #OptionsMenu.ROWS, 31) check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame") check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls") check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol") diff --git a/tests/gen2_nests_test.lua b/tests/gen2_nests_test.lua index 9e08a04c..2d1967c5 100644 --- a/tests/gen2_nests_test.lua +++ b/tests/gen2_nests_test.lua @@ -109,4 +109,16 @@ do check(Nests.regionOf(0x5e) == nil, "and neither is FAST_SHIP") end +-- ../pokecrystal/constants/landmark_constants.asm:34 +do + local CRYSTAL = { gen2Landmarks = { landmarks = { + LANDMARK_PALLET_TOWN = { index = 0x2f }, + LANDMARK_FAST_SHIP = { index = 0x5f }, + } } } + eq(Nests.regionOf(0x2e, CRYSTAL), "johto", "Crystal's $2e is SILVER CAVE") + eq(Nests.regionOf(0x2f, CRYSTAL), "kanto", "and its PALLET TOWN is $2f") + eq(Nests.regionOf(0x5e, CRYSTAL), "kanto", "ROUTE 28 is a Kanto nest") + check(Nests.regionOf(0x5f, CRYSTAL) == nil, "and FAST_SHIP is still neither") +end + S.finish() diff --git a/tests/gen2_palettes_test.lua b/tests/gen2_palettes_test.lua index 9d5999a2..53273d76 100644 --- a/tests/gen2_palettes_test.lua +++ b/tests/gen2_palettes_test.lua @@ -156,6 +156,57 @@ checkColor("bgSet does not mutate the pool", mornAgain[7][1], 70, 70, 70) local cave = Palettes.bgSet(data, darkCave, "DARK") check("cave falls back to outdoor", cave ~= nil, true) +-- LoadSpecialMapPalette (engine/tilesets/tileset_palettes.asm:1) +local house = { + id = "CHERRYGROVE_HOUSE", group = 24, environment = "INDOOR", + palette = "PALETTE_AUTO", tileset = "TILESET_HOUSE", +} +local icePath = { + id = "ICE_PATH_1F", group = 3, environment = "CAVE", + palette = "PALETTE_NITE", tileset = "TILESET_ICE_PATH", +} +local hallOfFame = { + id = "HALL_OF_FAME", group = 26, environment = "INDOOR", + palette = "PALETTE_DAY", tileset = "TILESET_ICE_PATH", +} +local crystalData = {} +for key, value in pairs(data) do crystalData[key] = value end +crystalData.specialTilesets = { + TILESET_HOUSE = { + pool(200, 201, 202, 203), pool(210, 211, 212, 213), + pool(220, 221, 222, 223), pool(230, 231, 232, 233), + pool(240, 241, 242, 243), pool(250, 251, 252, 253), + { { 247, 230, 214 }, { 255, 156, 197 }, { 132, 107, 25 }, { 58, 58, 58 } }, + pool(190, 191, 192, 193), + }, + TILESET_ICE_PATH = { + pool(1, 1, 1, 1), pool(2, 2, 2, 2), pool(3, 3, 3, 3), pool(4, 4, 4, 4), + pool(5, 5, 5, 5), pool(6, 6, 6, 6), pool(7, 7, 7, 7), pool(8, 8, 8, 8), + }, +} + +local houseSet = Palettes.bgSet(crystalData, house, "DAY") +checkColor("house glass color0", houseSet[7][1], 247, 230, 214) +checkColor("house glass color1", houseSet[7][2], 255, 156, 197) +checkColor("house glass color2", houseSet[7][3], 132, 107, 25) +checkColor("house gray slot", houseSet[1][1], 200, 200, 200) +local houseNite = Palettes.bgSet(crystalData, house, "NITE") +checkColor("house glass at night", houseNite[7][2], 255, 156, 197) + +-- the Hall of Fame keeps the pool (tileset_palettes.asm:26). +local icePathSet = Palettes.bgSet(crystalData, icePath, "NITE") +checkColor("ice path takes its own block", icePathSet[1][1], 1, 1, 1) +local fameSet = Palettes.bgSet(crystalData, hallOfFame, "DAY") +checkColor("hall of fame keeps the pool", fameSet[1][1], 20, 20, 20) + +local plain = { id = "X", group = 24, environment = "INDOOR", + palette = "PALETTE_DAY", tileset = "TILESET_POKECENTER" } +checkColor("an ordinary tileset is untouched", + Palettes.bgSet(crystalData, plain, "DAY")[1][1], 20, 20, 20) +checkColor("and a cache without the table is unchanged", + Palettes.bgSet(data, house, "DAY")[1][1], 20, 20, 20) +check("specialSet is nil without a table", Palettes.specialSet(data, house), nil) + -- Sprite palettes: paletteId is 0-based PAL_OW_*, so id 0 is entry 1. local spr = Palettes.spritePalette(data, "DAY", { paletteId = 0 }) checkColor("sprite day pal", spr[1], 5, 5, 5) diff --git a/tests/gen2_rock_smash_test.lua b/tests/gen2_rock_smash_test.lua index 9acd2675..b6cd3765 100644 --- a/tests/gen2_rock_smash_test.lua +++ b/tests/gen2_rock_smash_test.lua @@ -170,6 +170,26 @@ do eq(world:scriptReadMem(0xd117), 0, "and the byte is consumed by the battle") end +do + local world = rockWorld("DARK_CAVE_VIOLET_ENTRANCE") + world.game.save.version = "crystal" + eq(world:tempWildMonSpeciesAddress(), 0xd22e, + "a Crystal save claims pokecrystal's address") + eq(world:scriptReadMem(0xd22e), 0, "nothing smashed reads back as zero") + eq(world:scriptReadMem(0xd117), nil, + "and the GS address is not the one Crystal's script reads") + world.rockmonRandom = function() return 3 end + world:rockMonEncounter() + eq(world:scriptReadMem(0xd22e), KRABBY_INDEX, + "a hit reads back where Crystal's readmem looks, so iffalse is skipped") + + local gs = rockWorld("DARK_CAVE_VIOLET_ENTRANCE") + gs.game.save.version = "gold" + eq(gs:tempWildMonSpeciesAddress(), 0xd117, "Gold keeps 01:d117") + gs.game.save.version = "silver" + eq(gs:tempWildMonSpeciesAddress(), 0xd117, "and so does Silver") +end + -- CallAsm.run is the entry point World:callAsm reaches, and the handler must -- leave wScriptVar alone: the cart's routine writes none. do @@ -226,4 +246,39 @@ do end end +do + local cache = os.getenv("CRYSTAL_CACHE") + if not cache then + local home = os.getenv("HOME") or "" + cache = home .. "/Library/Application Support/LOVE/crystal-dev/crystal" + end + local scriptChunk = loadfile(cache .. "/data/generated/scripts.lua") + local encChunk = loadfile(cache .. "/data/generated/encounters.lua") + if not (scriptChunk and encChunk) then + check(true, "no crystal cache: RockSmashScript rows (SKIP)") + else + local scripts = scriptChunk() + local encounters = encChunk() + local rows = scripts["03:4f32"] + check(rows ~= nil, "RockSmashScript is in the Crystal cache at 03:4f32") + eq(rows and rows[9] and rows[9].op, "callasm", "row 9 is the callasm") + eq(rows and rows[9] and rows[9].args and rows[9].args[1], 0x2e, + "in bank $2e") + eq(rows and rows[10] and rows[10].op, "readmem", "row 10 reads the byte") + local args = rows and rows[10] and rows[10].args + eq(args and (args[1] + args[2] * 256), 0xd22e, + "and Crystal's wTempWildMonSpecies is the address it reads") + eq(rows and rows[11] and rows[11].op, "iffalse", "row 11 branches on it") + eq(rows and rows[12] and rows[12].op, "randomwildmon", "row 12 rolls") + eq(rows and rows[13] and rows[13].op, "startbattle", "row 13 fights it") + + local rocks = encounters.rocks or {} + eq(rocks.CIANWOOD_CITY, "TREEMON_SET_ROCK", "RockMonMaps: Cianwood") + eq(rocks.ROUTE_40, "TREEMON_SET_ROCK", "Route 40") + eq(rocks.DARK_CAVE_VIOLET_ENTRANCE, "TREEMON_SET_ROCK", + "Dark Cave Violet Entrance") + eq(rocks.SLOWPOKE_WELL_B1F, "TREEMON_SET_ROCK", "and Slowpoke Well B1F") + end +end + S.finish() diff --git a/tests/gen2_trainers_test.lua b/tests/gen2_trainers_test.lua index 61210034..f9549f33 100644 --- a/tests/gen2_trainers_test.lua +++ b/tests/gen2_trainers_test.lua @@ -332,4 +332,94 @@ fightWorld:startScriptedBattle(champion, nil, function() end) eq(fought.opts and fought.opts.battle and fought.opts.battle.trainer.name, "CHAMPION LANCE", "and no other class loses its prefix") +-- engine/overworld/scripting.asm Script_startbattle +fought.opts = nil +local refused = "unset" +check(fightWorld:startScriptedBattle(nil, nil, function(result) + refused = result +end) == false, "a battle with no trainer and no wild mon does not start") +check(fought.opts == nil, "and no battle screen is pushed") +eq(refused, nil, "the script is answered with nil, not a fabricated win") + +fought.opts = nil +refused = "unset" +check(fightWorld:startScriptedBattle({ class = 1, member = 2, name = "GHOST", + className = "CHAMPION", roster = {} }, nil, function(result) + refused = result +end) == false, "nor does a member whose party builds empty") +eq(refused, nil, "answered with nil the same way") + +local missingEvents = Events.new() +local MISSING_FLAG = 1169 +local missingShown, missingLooked = {}, {} +local missingVm = Vm.new(scripts, texts, missingEvents, { + showText = function(body, onDone) missingShown[#missingShown + 1] = body onDone() end, + showEmote = function() end, + trainerApproach = function(onDone) onDone() end, + encounterMusic = function() end, + lookupTrainer = function(class, member) + missingLooked[#missingLooked + 1] = tostring(class) .. "/" .. tostring(member) + return nil + end, + startBattle = function(_trainer, _wild, onDone) onDone(nil) end, +}) +missingVm.trainerObject = { event = MISSING_FLAG, class = 53, member = 20, + seenText = "t:seen", scriptKey = "s:after" } +check(missingVm:start(SEEN_SCRIPT), "the seen-by script runs") +for _ = 1, 60 do missingVm:update() end +check(not missingVm:running(), "and stops at the refused battle") +check(not missingEvents:get(MISSING_FLAG), + "no SET_FLAG: the trainer is still unbeaten") +eq(missingShown[2], nil, "and the after-battle script never ran") +check(missingVm.missingTrainers and missingVm.missingTrainers["53/20"], + "the missing class/member pair is named in the log ledger") + +-- home/trainers.asm _CheckTrainerBattle gates the sight cone on the same flag. +missingShown = {} +check(missingVm:start(TALK_SCRIPT), "talk-to-trainer runs against the same struct") +for _ = 1, 60 do missingVm:update() end +check(not missingEvents:get(MISSING_FLAG), "still unbeaten") +eq(missingShown[1], nil, "and the after-battle line did not print") + +-- home/trainers.asm:13 _CheckTrainerBattle samples the cone every frame +local ghost = { event = MISSING_FLAG, class = 53, member = 20, + seenText = "t:seen", scriptKey = "s:after" } +local ghostVm = Vm.new(scripts, texts, fightWorld.events, { + showText = function(_body, onDone) onDone() end, + showEmote = function() end, + trainerApproach = function(onDone) onDone() end, + encounterMusic = function() end, + lookupTrainer = function() return nil end, + startBattle = function(_trainer, _wild, onDone) onDone(nil) end, +}) +fightWorld.vm = ghostVm +fightWorld.player = { cellX = 5, cellY = 8, facing = "up", moving = false } +fightWorld.npcs = { { cellX = 5, cellY = 5, facing = "down", + def = { index = 0, sight = 4, trainer = ghost } } } +fightGame.save.party = { { species = "PIDGEY" } } + +eq(Trainers.sees(fightWorld.npcs[1], fightWorld.player, 4), 3, + "the ghost trainer's sight line reaches the player") +ghostVm.trainerObject = ghost +fightWorld.trainerNpc = fightWorld.npcs[1] +check(fightWorld:startScriptedBattle(nil, nil, function() end) == false, + "the struct the port cannot build refuses its battle") +check(not fightWorld:checkTrainerBattle(), + "and the sight cone does not engage it again") +check(not ghostVm:running(), "so no seen-by script is started") +check(not fightWorld.events:get(MISSING_FLAG), + "with the beat flag still clear") + +fightWorld.map.cellCollision = function() return 0 end +fightWorld.player.cellX, fightWorld.player.cellY = 5, 6 +pcall(function() fightWorld:interactBody() end) +eq(ghostVm.lastTalked, 1, "the A press reached the facing trainer object") +check(not ghostVm:running(), + "an A press on a refused trainer starts no talk script either") +check(not fightWorld.events:get(MISSING_FLAG), "and sets no flag") + +fightWorld.refusedTrainers = nil +check(fightWorld:checkTrainerBattle(), + "the same cone engages once the refusal is cleared") + S.finish() diff --git a/tests/gen2_unown_test.lua b/tests/gen2_unown_test.lua index dee63d4b..883408b0 100644 --- a/tests/gen2_unown_test.lua +++ b/tests/gen2_unown_test.lua @@ -452,6 +452,49 @@ press(screen, "a") check("A after the fanfare closes it", closed.count, 1) check("reporting solved", closed.solved, true) +-- WaitPlaySFX (home/audio.asm:220) +do + local Sound = require("src.core.Sound") + local realPlay, realWait = Sound.play, Sound.waitSfxDone + local log = {} + Sound.play = function(_data, name) log[#log + 1] = name end + Sound.waitSfxDone = function() log[#log + 1] = "wait" end + + screen = newPuzzle(2) + for cell = 1, UnownPuzzle.CELLS do + screen.pieces[cell] = UnownPuzzle.SOLVED[cell] + end + screen.pieces[pc(4, 4) + 1] = 0 + screen.pieces[pc(0, 0) + 1] = 16 + screen.cursor = pc(0, 0) + press(screen, "a") + screen.cursor = pc(4, 4) + press(screen, "a") + + check("the solve drains the channels before the fanfare", + table.concat(log, ","), + "wait,Sfx_MegaKick,wait,Sfx_PlacePuzzlePieceDown,wait,Sfx_1stPlace") + check("and the board really is solved", screen.solved, true) + + -- UnownPuzzle_InvalidAction (unown_puzzle.asm:339) + log = {} + screen = newPuzzle(2) + screen.pieces[pc(0, 0) + 1] = 0 + screen.cursor = pc(0, 0) + press(screen, "a") + check("a refused click is not stopped on the frame it starts", + table.concat(log, ","), "wait,Sfx_Wrong") + + log = {} + screen.pieces[pc(0, 0) + 1] = 16 + press(screen, "a") + press(screen, "right") + check("nor is the pickup, and the move click drains before it", + table.concat(log, ","), "wait,Sfx_MegaKick,wait,Sfx_MovePuzzlePiece") + + Sound.play, Sound.waitSfxDone = realPlay, realWait +end + -- A panel cannot be dropped on an occupied cell. screen = newPuzzle(0) screen.cursor = 0 diff --git a/tests/gen2_world_test.lua b/tests/gen2_world_test.lua index f009e401..dfc2741f 100644 --- a/tests/gen2_world_test.lua +++ b/tests/gen2_world_test.lua @@ -66,6 +66,10 @@ local ITEMS = { -- BICYCLE, and both of those are claimed by useFieldItem. COIN_CASE = { id = "COIN_CASE", name = "COIN CASE", pocket = "KEY_ITEM", index = 0x47 }, + -- ../pokecrystal/data/items/attributes.asm:242 + BLUE_CARD = { id = "BLUE_CARD", name = "BLUE CARD", pocket = "KEY_ITEM", + index = 0x74, fieldMenu = "ITEMMENU_CURRENT", + battleMenu = "ITEMMENU_NOUSE" }, REPEL = { id = "REPEL", name = "REPEL", pocket = "ITEM", index = 0x14, canSelect = true }, SUPER_REPEL = { id = "SUPER_REPEL", name = "SUPER REPEL", pocket = "ITEM", @@ -314,6 +318,28 @@ eq(landGame.stack.cleared, 0, "and does not quit the PACK either") landGame.input:press("a") landPack:update(0) +-- BlueCardEffect (../pokecrystal/engine/items/item_effects.asm:2251) +do + landGame.save.inventory.BLUE_CARD = 1 + landPack:rebuild() + landWorld:writeVar(0x18, 12) + landPack.index = 4 + eq(landPack.rows[4].id, "BLUE_CARD", "the BLUE CARD is the fourth key item") + landGame.input:press("a") + landPack:update(0) + landGame.input:press("a") + landPack:update(0) + check(landPack.message ~= nil, "the BLUE CARD prints inside the PACK") + eq(landPack.message[1], "You now have", "_BlueCardBalanceText's first row") + eq(landPack.message[2], "12 points.", "and the balance on the second") + eq(chosen, nil, "the BLUE CARD never reaches onChoose") + eq(landGame.stack.cleared, 0, "and does not quit the PACK") + landGame.input:press("a") + landPack:update(0) + landGame.save.inventory.BLUE_CARD = nil + landPack:rebuild() +end + -- Facing water: the roll lands on $2 .FishGotSomething, the PACK quits -- (PACKSTATE_QUITRUNSCRIPT) and Script_FishCastRod's cast owns the world. local waterCells = { [4 * 100 + 5] = COLL_WATER } @@ -364,6 +390,12 @@ check(busyWorld:useFieldItem("POTION") == nil, "useFieldItem passes an unhandled item back to the PACK") eq(busyWorld:useFieldItem("COIN_CASE"), "coin_case", "the COIN CASE is ITEMMENU_CURRENT and World claims it") +do + busyWorld:writeVar(0x18, 17) + local result, balance = busyWorld:useFieldItem("BLUE_CARD") + eq(result, "blue_card", "the BLUE CARD is ITEMMENU_CURRENT too") + eq(balance, 17, "and it comes back with wBlueCardBalance") +end -- ---- A2. REPEL / SUPER REPEL / MAX REPEL ---------------------------------- -- UseRepel (engine/items/item_effects.asm): the step count is the only thing diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 7c3dd558..49f298ce 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -286,7 +286,7 @@ local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout", "ruleset", "musicVol", "sfxVol", "musicFilter", "performance", "colors", "tilt", "uiLetterbox", "shaderfx", "shaderfx2", "zoom", "voidFill", - "videoMode", "faithfulRes", "screenPos", "fpsCap", + "videoMode", "faithfulRes", "screenPos", "fpsCap", "vsync", "speedOverworld", "speedBattle", "speedMenu", "mods", "controls", "dateFormat", "timeFormat" } local function orow(menu, id) @@ -404,9 +404,26 @@ check(orow(om, "fpsCap").value(om.game) == "75", "the MAX FPS row renders the cap") om.game.save.options.fpsCap = 160 orow(om, "fpsCap").step(om.game, 1) -check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30") +check(om.game.save.options.fpsCap == FrameCap.DISPLAY, + "MAX FPS steps past the ceiling to DISPLAY") +check(orow(om, "fpsCap").value(om.game) == "DISPLAY", + "the uncapped stop renders as DISPLAY") +orow(om, "fpsCap").step(om.game, 1) +check(om.game.save.options.fpsCap == 30, "and wraps from there to the floor") orow(om, "fpsCap").step(om.game, -1) -check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling") +check(om.game.save.options.fpsCap == FrameCap.DISPLAY, + "MAX FPS wraps back down to DISPLAY") + +om.game.save.options.vsync = nil +check(orow(om, "vsync").value(om.game) == "ON", + "VSYNC row reads the boot mode with no saved key") +orow(om, "vsync").step(om.game, 1) +check(om.game.save.options.vsync == "off", "VSYNC steps ON to OFF") +check(orow(om, "vsync").value(om.game) == "OFF", "and renders it") +orow(om, "vsync").step(om.game, 1) +check(om.game.save.options.vsync == "adaptive", "then OFF to ADAPTIVE") +orow(om, "vsync").step(om.game, 1) +check(om.game.save.options.vsync == "on", "and ADAPTIVE wraps to ON") -- ------- FrameCap normalize / cycle (issue #88) check(FrameCap.normalize(nil) == 60, "FrameCap defaults nil to 60") @@ -414,7 +431,9 @@ check(FrameCap.normalize("junk") == 60, "FrameCap defaults garbage to 60") check(FrameCap.normalize(60) == 60, "FrameCap keeps an exact step") check(FrameCap.normalize(58) == 60, "FrameCap snaps 58 to the nearest step 60") check(FrameCap.normalize(72) == 75, "FrameCap snaps 72 to the nearest step 75") -check(FrameCap.normalize(0) == 30, "FrameCap clamps below the floor to 30") +check(FrameCap.normalize(1) == 30, "FrameCap clamps below the floor to 30") +check(FrameCap.normalize(0) == FrameCap.DISPLAY, + "and a zero cap is DISPLAY, not the floor (issue #1910)") check(FrameCap.normalize(9999) == 160, "FrameCap clamps above the ceiling to 160") check(FrameCap.normalize(30) == 30 and FrameCap.normalize(160) == 160, "FrameCap keeps the exact floor and ceiling") @@ -422,8 +441,12 @@ check(FrameCap.label(nil) == "60" and FrameCap.label(144) == "144", "FrameCap.label renders the normalized cap as plain text") check(FrameCap.cycle(60, 1) == 75, "FrameCap cycles 60 up to 75") check(FrameCap.cycle(60, -1) == 50, "FrameCap cycles 60 down to 50") -check(FrameCap.cycle(160, 1) == 30, "FrameCap cycle wraps the ceiling to the floor") -check(FrameCap.cycle(30, -1) == 160, "FrameCap cycle wraps the floor to the ceiling") +check(FrameCap.cycle(160, 1) == FrameCap.DISPLAY, + "FrameCap cycle steps the ceiling to DISPLAY") +check(FrameCap.cycle(FrameCap.DISPLAY, 1) == 30, + "and wraps DISPLAY to the floor") +check(FrameCap.cycle(30, -1) == FrameCap.DISPLAY, + "FrameCap cycle wraps the floor back to DISPLAY") check(FrameCap.cycle(nil, 1) == 75, "FrameCap cycle normalizes a nil cap (60) before stepping") -- apply drives the live value the run loop paces to; never touches love.timer @@ -451,12 +474,16 @@ check(getmetatable(bm) == BindingsMenu, "the CONTROLS row opens the rebind list") check(bm.screenId == "BindingsMenu", "the pushed rebind screen carries its screen id") -check(#bm.items == 8, "one row per logical button") +check(#bm.items == 10, + "one row per logical button, plus the two pad-action rows (#1922)") check(bm.items[1].label == "UP" and bm.items[1].right == "UP/D-UP" and bm.items[5].label == "A" and bm.items[5].right == "Z/A" and bm.items[7].label == "START" and bm.items[7].right == "ESC/START" and bm.items[8].label == "SELECT" and bm.items[8].right == "TAB/BACK", "with no rebind the rows mirror the fixed map, key and pad both (#589)") +check(bm.items[9].label == "SPEED -" and bm.items[9].right == "LB" + and bm.items[10].label == "SPEED +" and bm.items[10].right == "RB", + "and the GAME SPEED shortcuts show the shoulders they sit on (#1922)") check(cbGame.save.options.bindings == nil, "opening the screen alone writes nothing") diff --git a/tests/parity_C.lua b/tests/parity_C.lua index e0e4fd58..d0fa8357 100644 --- a/tests/parity_C.lua +++ b/tests/parity_C.lua @@ -118,9 +118,25 @@ local function openElevator(mapId, inventory, fromMapId) local panel = script.talk and script.talk[PANEL_TEXT[mapId]] check(panel ~= nil, mapId .. " panel bg_event has a talk script") if panel then panel(game, ow, nil, function() end) end + -- opens from its onShown (engine/events/elevator.asm:2-3) + local prompt = items[#items] + if prompt and prompt.stay and prompt.stay.onShown then + check(prompt.isTextBox, mapId .. " prints the prompt in a text box first") + prompt.stay.onShown() + end return items[#items], warpCalls, stack, ow end +-- the $ff terminator's CANCEL row (home/list_menu.asm:371-372, 523-528) +local function checkCancelRow(menu, floors, label) + eq(#menu.items, floors + 1, label .. " lists all " .. floors .. + " floors plus the terminator") + local last = menu.items[#menu.items] + eq(last and last.cancel, true, label .. " ends on the terminator row") + eq(last and last.label, require("src.core.Strings")("CANCEL"), + label .. " prints that row as CANCEL") +end + -- step the ElevatorShake state (pokered ShakeElevator) frame by frame -- until it pops itself; returns how many frames the ride took plus the -- observed scroll-offset trace (first nonzero frame/value, both signs @@ -159,7 +175,7 @@ do openElevator("SILPH_CO_ELEVATOR", nil, "SILPH_CO_5F") check(menu ~= nil and getmetatable(menu) == ListMenu, "SILPH_CO_ELEVATOR opens a ListMenu") if menu then - eq(#menu.items, 11, "Silph Co elevator lists all 11 floors") + checkCancelRow(menu, 11, "Silph Co elevator") local wantOrder = { "1F", "2F", "3F", "4F", "5F", "6F", "7F", "8F", "9F", "10F", "11F" } for i, want in ipairs(wantOrder) do local item = menu.items[i] @@ -242,7 +258,7 @@ do local menu, warpCalls, stack, ow = openElevator("CELADON_MART_ELEVATOR") check(menu ~= nil and getmetatable(menu) == ListMenu, "CELADON_MART_ELEVATOR opens a ListMenu") if menu then - eq(#menu.items, 5, "Celadon Mart elevator lists all 5 floors") + checkCancelRow(menu, 5, "Celadon Mart elevator") local wantOrder = { "1F", "2F", "3F", "4F", "5F" } for i, want in ipairs(wantOrder) do eq(menu.items[i] and menu.items[i].label, want, "Celadon Mart floor " .. i .. " label/order") @@ -304,7 +320,7 @@ do check(menu ~= nil and getmetatable(menu) == ListMenu, "Rocket Hideout with LIFT_KEY opens a ListMenu") if menu then - eq(#menu.items, 3, "Rocket Hideout elevator lists all 3 floors") + checkCancelRow(menu, 3, "Rocket Hideout elevator") local wantOrder = { "B1F", "B2F", "B4F" } for i, want in ipairs(wantOrder) do eq(menu.items[i] and menu.items[i].label, want, "Rocket Hideout floor " .. i .. " label/order") diff --git a/tests/parity_bills_pc.lua b/tests/parity_bills_pc.lua index 482f9b31..b6986fe1 100644 --- a/tests/parity_bills_pc.lua +++ b/tests/parity_bills_pc.lua @@ -219,6 +219,80 @@ f, t = runRoute25({ }) eq(next(t), nil, "already-left Route 25 enter is a no-op") +-- pokeyellow scripts/BillsHouse.asm:211 and :232; pokered's +-- BillsHouseCleanupScript (scripts/BillsHouse.asm:96) has neither state. +local GameVersion = require("src.core.GameVersion") +local realVersion = GameVersion.current + +local function newScene() + local said + local self = setmetatable({ + map = { id = "BILLS_HOUSE", def = { label = "BillsHouse" } }, + player = { cellX = 1, cellY = 5, facing = "up" }, + scriptMoves = {}, + showMapText = function(_, textConst, npc) + said = { textConst = textConst, npc = npc } + end, + }, { __index = OW }) + return self, function() return said end +end + +local function drain(s) + local guard = 0 + while #s.scriptMoves > 0 and guard < 32 do + local mv = table.remove(s.scriptMoves, 1) + if mv.onDone then mv.onDone() end + guard = guard + 1 + end +end + +GameVersion.current = "red" +local scene, saidOf = newScene() +scene:billsHouseSSTicketScene({ facing = "left" }) +eq(#scene.scriptMoves, 0, "Red/Blue queue no forced walk after Bill exits") +eq(saidOf(), nil, "Red/Blue never auto-open the SS Ticket text") + +GameVersion.current = "yellow" +scene, saidOf = newScene() +local billNpc = { facing = "left", def = { name = "BILLSHOUSE_BILL1" } } +scene:billsHouseSSTicketScene(billNpc) +eq(#scene.scriptMoves, 1, "Yellow queues the simulated walk") +eq(scene.scriptMoves[1].entity, scene.player, "the forced walk moves the player") +eq(scene.scriptMoves[1].dir, "right", "RLE_1e219 is PAD_RIGHT") +eq(scene.scriptMoves[1].remaining, 3, "RLE_1e219 runs three steps") +eq(scene.scriptMoves[1].collide, nil, "the simulated walk is unconditional") +eq(saidOf(), nil, "the text waits for the walk to finish") +drain(scene) +eq(scene.player.facing, "up", "player ends facing up (SPRITE_FACING_UP)") +eq(billNpc.facing, "down", "BILL1 is turned down before the text") +eq(saidOf() and saidOf().textConst, "TEXT_BILLSHOUSE_BILL_SS_TICKET", + "the ticket text opens with no A press") +eq(saidOf() and saidOf().npc, billNpc, "the text runs as BILL1") + +scene, saidOf = newScene() +scene:billsHouseSSTicketScene(nil) +eq(#scene.scriptMoves, 0, "no walk without BILL1") +eq(saidOf() and saidOf().textConst, "TEXT_BILLSHOUSE_BILL_SS_TICKET", + "the ticket text still opens without BILL1") + +package.loaded["src.core.Music"] = { stop = function() end, playMap = function() end } +local ticketBill = "unset" +local exitSelf = setmetatable({ + map = { id = "BILLS_HOUSE", def = { label = "BillsHouse" } }, + player = { cellX = 1, cellY = 5, facing = "up" }, + scriptMoves = {}, + npcs = { { def = { name = "BILLSHOUSE_BILL1" }, facing = "down" } }, + billsHouseSSTicketScene = function(_, b) ticketBill = b end, +}, { __index = OW }) +fakeGame.save = SaveData.newGame() +setUpvalue(OW.billsHousePC, "Game", fakeGame) +exitSelf:billsHouseBillExits() +drain(exitSelf) +check(fakeGame.save.flags.EVENT_MET_BILL_2, "Bill's exit sets EVENT_MET_BILL_2") +eq(ticketBill, exitSelf.npcs[1], "the exit walk hands BILL1 to the ticket scene") + +GameVersion.current = realVersion + Commands.hide_object = realHide Commands.show_object = realShow package.loaded["src.ui.Menu"] = realMenu diff --git a/tests/parity_elevator_panel.lua b/tests/parity_elevator_panel.lua index a3f63b92..ad14cc4a 100644 --- a/tests/parity_elevator_panel.lua +++ b/tests/parity_elevator_panel.lua @@ -27,16 +27,22 @@ local realList = package.loaded["src.ui.ListMenu"] local realShake = package.loaded["src.world.ElevatorShake"] local realTextBox = package.loaded["src.render.TextBox"] package.loaded["src.ui.ListMenu"] = { - new = function(_, title, items, opts) - return { kind = "list", title = title, items = items, opts = opts, - close = function() end } + new = function(game, title, items, opts) + local list + list = { kind = "list", title = title, items = items, opts = opts, + close = function() + if game.stack:top() == list then game.stack:pop() end + end } + return list end, } package.loaded["src.world.ElevatorShake"] = { new = function(_, _, opts) return { kind = "shake", opts = opts } end, } package.loaded["src.render.TextBox"] = { - new = function(_, text, done) return { kind = "text", text = text, done = done } end, + new = function(_, text, done, opts) + return { kind = "text", text = text, done = done, opts = opts } + end, } -- panel cell / reading cell from the bg_event coordinates above; facing is @@ -147,14 +153,28 @@ for _, car in ipairs(CARS) do game.save.inventory[car.key] = 1 end - -- reading the panel with a key (or with no gate): WHICH FLOOR? + -- (engine/events/elevator.asm:2-3), then the LIST_MENU_BOX over the map local done = false panel(game, ow, nil, function() done = true end) + local prompt = pushed[#pushed] + eq(#pushed, 1, tag .. ": the panel pushes the prompt box and nothing else") + eq(prompt and prompt.kind, "text", tag .. ": and that state is a text box") + eq(prompt and prompt.text, Data.text._WhichFloorText, + tag .. ": carrying _WhichFloorText, not an invented title") + check(prompt and prompt.opts and prompt.opts.stay + and type(prompt.opts.stay.onShown) == "function", + tag .. ": a `done` box that stays up while the list opens over it") + prompt.opts.stay.onShown() local list = pushed[#pushed] eq(list and list.kind, "list", tag .. ": reading the panel opens a list menu") - eq(list and list.title, "WHICH FLOOR?", tag .. ": titled WHICH FLOOR?") - eq(list and #list.items, car.floors, - tag .. ": every floor that warps into this car is listed") + eq(list and list.title, nil, tag .. ": the box has no title row") + eq(list and list.opts.itemBox, true, + tag .. ": drawn as LIST_MENU_BOX over the still-visible map") + eq(list and #list.items, car.floors + 1, + tag .. ": every floor that warps into this car, plus the terminator") + local last = list and list.items[#list.items] + eq(last and last.cancel, true, tag .. ": the last row is the $ff terminator") + eq(last and last.label, "CANCEL", tag .. ": printed as CANCEL") eq(destinations(ow), seeded, tag .. ": opening the menu has not rewritten anything yet") @@ -164,17 +184,35 @@ for _, car in ipairs(CARS) do entry.onEnter(cancelGame, cancelOw, car.from) local cancelDone = false panel(cancelGame, cancelOw, nil, function() cancelDone = true end) + cancelPushed[#cancelPushed].opts.stay.onShown() local cancelList = cancelPushed[#cancelPushed] + cancelGame.stack:pop() cancelList.opts.onCancel() check(cancelDone, tag .. ": B closes the panel") + eq(#cancelPushed, 0, tag .. ": and the prompt box goes with it") eq(destinations(cancelOw), car.from, tag .. ": B leaves the exit warps on the floor entered from") eq(cancelOw.moveCount(), 0, tag .. ": B moves nobody") + -- A on the CANCEL row exits exactly like B (home/list_menu.asm:105-110) + local rowGame, rowPushed = fakeGame(car.key and { [car.key] = 1 } or nil) + local rowOw = fakeCar(car) + entry.onEnter(rowGame, rowOw, car.from) + local rowDone = false + panel(rowGame, rowOw, nil, function() rowDone = true end) + rowPushed[#rowPushed].opts.stay.onShown() + local rowList = rowPushed[#rowPushed] + rowList.opts.onChoose(rowList.items[#rowList.items], rowList) + check(rowDone, tag .. ": the CANCEL row closes the panel") + eq(#rowPushed, 0, tag .. ": leaving nothing on the stack") + eq(destinations(rowOw), car.from, + tag .. ": the CANCEL row rewrites no warps") + eq(rowOw.moveCount(), 0, tag .. ": the CANCEL row moves nobody") + -- choosing a floor: the ride first, the .UpdateWarp rewrite when it ends local chosen for _, item in ipairs(list.items) do - if item.value.map == car.pick then chosen = item end + if item.value and item.value.map == car.pick then chosen = item end end check(chosen ~= nil, tag .. ": " .. car.pick .. " is on the floor list") list.opts.onChoose(chosen, list) diff --git a/tests/parity_save_fly_towns.lua b/tests/parity_save_fly_towns.lua index 0d386ade..af96b84e 100644 --- a/tests/parity_save_fly_towns.lua +++ b/tests/parity_save_fly_towns.lua @@ -106,7 +106,9 @@ end -- ------------------------------------------------------------------ local fresh = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" }) -fresh.flags = { EVENT_GOT_POKEDEX = true, EVENT_GOT_STARTER = true } +-- ram/wram.asm:2074-2078 +fresh.flags = { EVENT_GOT_POKEDEX = true, EVENT_GOT_STARTER = true, + EVENT_CHOSE_CHARMANDER = true } fresh.money = 54321 local base = GenSave.encode(fresh, cwData, nil) eq(#base, GenSave.SAVE_SIZE, "baseline image is 32768 bytes") diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 8c2b14b2..a5410323 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -2798,10 +2798,11 @@ do press("a") eq(og.save.options.fpsCap, 75, "A cycles MAX FPS up from 60 to 75") eq(FrameCap.current, 75, "the live render cap tracks the MAX FPS option") - -- Driven by the step list rather than a literal press count, like GAME - -- SPEED below: a full loop of #STEPS presses returns to the 60 default. - for _ = 1, #FrameCap.STEPS - 1 do press("a") end + for _ = 1, #FrameCap.CYCLE - 1 do press("a") end eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60") + check(seek("vsync"), "cursor reaches VSYNC") + press("a") + eq(og.save.options.vsync, "off", "A cycles VSYNC to OFF") -- RFC 0007: the single GAME SPEED row is now three independent rows, -- one per GameSpeed.CATEGORIES entry. check(seek("speedOverworld"), "cursor reaches OVERWORLD SPEED") diff --git a/tests/save_convert_tests.lua b/tests/save_convert_tests.lua index bddfa59c..eb7dfc1e 100644 --- a/tests/save_convert_tests.lua +++ b/tests/save_convert_tests.lua @@ -114,7 +114,9 @@ save.inventory = { POKE_BALL = 5, BOULDERBADGE = 1, ANTIDOTE = 1 } save.bagOrder = { "POKE_BALL", "ANTIDOTE" } save.pcItems = { REVIVE = 2 } save.pokedex = { seen = { MEW = true, PIKACHU = true }, owned = { PIKACHU = true } } -save.flags = { EVENT_GOT_STARTER = true, EVENT_GOT_POKEDEX = true } +-- ram/wram.asm:2074-2078 (#1625) +save.flags = { EVENT_GOT_STARTER = true, EVENT_GOT_POKEDEX = true, + EVENT_CHOSE_SQUIRTLE = true } -- the FLY destination set (pokered's wTownVisitedFlag), keyed by map id save.visited = { PALLET_TOWN = true, CELADON_CITY = true } save.boxes = {} @@ -156,6 +158,12 @@ check(decoded2.pokedex.seen.MEW and decoded2.pokedex.seen.PIKACHU and decoded2.p check(not decoded2.pokedex.owned.MEW, "a species only marked seen doesn't also come back owned") check(decoded2.flags.EVENT_GOT_STARTER and decoded2.flags.EVENT_GOT_POKEDEX, "event flags round-trip") +-- scripts/OaksLab.asm:797-825; scripts/PokemonTower2F.asm:155-168 +check(bytes2:byte(OFF.playerStarter + 1) == cw.pokemonIndex.SQUIRTLE + and bytes2:byte(OFF.rivalStarter + 1) == cw.pokemonIndex.BULBASAUR, + "the starter pair reaches wPlayerStarter/wRivalStarter") +check(decoded2.flags.EVENT_CHOSE_SQUIRTLE and not decoded2.flags.EVENT_CHOSE_BULBASAUR, + "the starter choice round-trips as exactly one EVENT_CHOSE_* flag") -- wTownVisitedFlag: bit index == map index, LSB first (PALLET_TOWN is bit 0, -- CELADON_CITY bit 6). Before #263 decode never touched it, so an imported -- save reached FLY with no destinations at all. @@ -625,5 +633,32 @@ do "template name tails (incl. 0x00 bytes) round-trip byte-identical") end +-- scripts/PokemonTower2F.asm:155-168 +do + local Commands = require("src.script.Commands") + local cart = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" }) + cart.flags = { EVENT_GOT_STARTER = true } + local raw = GenSave.encode(cart, data, nil) + local function poke(bytes, off, value) + return bytes:sub(1, off) .. string.char(value) .. bytes:sub(off + 2) + end + raw = poke(raw, OFF.playerStarter, cw.pokemonIndex.BULBASAUR) + raw = poke(raw, OFF.rivalStarter, cw.pokemonIndex.CHARMANDER) + local imported = GenSave.decode(raw, data) + check(imported.flags.EVENT_CHOSE_BULBASAUR, + "a BULBASAUR cartridge save imports with EVENT_CHOSE_BULBASAUR") + + local picked + local realStart = Commands.start_battle + Commands.start_battle = function(_, _, _, party) picked = party end + local ok, err = pcall(Commands.rival_battle, + { save = imported, game = { data = data } }, "OPP_RIVAL2", 4) + Commands.start_battle = realStart + check(ok, "rival_battle runs on the imported save (" .. tostring(err) .. ")") + check(picked == 6, + "Tower 2F picks OPP_RIVAL2 party 6 (CHARMELEON), not 4 (WARTORTLE) -- got " + .. tostring(picked)) +end + print(string.format("save convert: %d/%d checks passed", checks - failures, checks)) if failures > 0 then os.exit(1) end diff --git a/tests/save_convert_yellow_bug838_test.lua b/tests/save_convert_yellow_bug838_test.lua index cbd9e640..f591bf00 100644 --- a/tests/save_convert_yellow_bug838_test.lua +++ b/tests/save_convert_yellow_bug838_test.lua @@ -208,6 +208,29 @@ check(dData and dData.eventFlags.byName.EVENT_GOT_DOME_FOSSIL == 1406 and dData.gameVersion == nil, "versionless loadData still serves the red numbering, untagged") +-- scripts/OaksLab.asm:1020, :231, :381 +local cw = GenSave.crosswalks(yellowData) +local starter = SaveData.newGame({ playerName = "ASH", rivalName = "GARY" }) +starter.flags = { EVENT_GOT_STARTER = true, EVENT_CHOSE_PIKACHU = true } +starter.rivalStarter = 3 + +local sBytes = GenSave.encode(starter, yellowData, nil) +check(sBytes:byte(OFF.playerStarter + 1) == cw.pokemonIndex.PIKACHU, + "yellow encode writes STARTER_PIKACHU into wPlayerStarter (got " + .. sBytes:byte(OFF.playerStarter + 1) .. ")") +check(sBytes:byte(OFF.rivalStarter + 1) == 3, + "yellow encode writes RIVAL_STARTER_VAPOREON into wRivalStarter (got " + .. sBytes:byte(OFF.rivalStarter + 1) .. ")") +local sDec = GenSave.decode(sBytes, yellowData) +check(sDec.rivalStarter == 3, + "yellow decode reads save.rivalStarter back (got " + .. tostring(sDec.rivalStarter) .. ")") +check(sDec.flags.EVENT_CHOSE_PIKACHU == true, + "yellow decode restores EVENT_CHOSE_PIKACHU from wPlayerStarter") + +check(GenSave.decode(GenSave.encode(starter, redData, nil), redData).rivalStarter == nil, + "red/blue decode does not fabricate a rivalStarter index") + print(string.format("save convert yellow #838: %d/%d checks passed", checks - failures, checks)) if failures > 0 then os.exit(1) end diff --git a/tests/save_editor_gen2_tests.lua b/tests/save_editor_gen2_tests.lua index 5383a8fd..246b9f92 100644 --- a/tests/save_editor_gen2_tests.lua +++ b/tests/save_editor_gen2_tests.lua @@ -29,6 +29,7 @@ local MonOps = require("MonOps") local Ops = require("Ops") local State = require("State") local Save2 = require("src.core.gen2.Save") +local Bag = require("src.inventory.Bag") local SaveData = require("src.core.SaveData") local GameVersion = require("src.core.GameVersion") @@ -64,9 +65,11 @@ local data = { SCRATCH = { pp = 35 }, }, items = { - POTION = { pocket = "ITEM" }, - MASTER_BALL = { pocket = "BALL" }, - FLOWER_MAIL = { pocket = "ITEM" }, + POTION = { pocket = "ITEM", index = 20, name = "POTION" }, + MASTER_BALL = { pocket = "BALL", index = 1, name = "MASTER BALL" }, + FLOWER_MAIL = { pocket = "ITEM", index = 8, name = "FLOWER MAIL" }, + BICYCLE = { pocket = "KEY_ITEM", index = 6, name = "BICYCLE" }, + HM_01 = { pocket = "TM_HM", index = 249, name = "HM01" }, }, maps = {}, } @@ -254,6 +257,60 @@ do eq(S.save.player.money, 999999, "money cap") Ops.addCoins(S, 250) eq(S.save.player.coins, 250, "coins write player.coins") + Ops.maxCoins(S) + eq(S.save.player.coins, Ops.COIN_MAX, "maxCoins fills the case on player.coins") + eq(S.save.coins, nil, "Gen 2 coins never land on save.coins") +end + +do + -- (ram/wram.asm:3115 wKeyItems, engine/items/tmhm.asm:390) + local S = newState() + Ops.addToBag(S, "POTION") + Ops.addToBag(S, "MASTER_BALL") + Ops.addToBag(S, "BICYCLE") + Ops.addToBag(S, "HM_01") + + check(Ops.itemStacks(S, "POTION") == true, "an ITEM pocket row stacks") + check(Ops.itemStacks(S, "MASTER_BALL") == true, "a BALL pocket row stacks") + check(Ops.itemStacks(S, "BICYCLE") == false, "a KEY_ITEM pocket row does not") + check(Ops.itemStacks(S, "HM_01") == false, "a Gen 2 HM prints no count either") + S.dirty = false + check(Ops.bagMax(S, "BICYCLE") == false, "bagMax refuses a Gen 2 key item") + check(Ops.bagMax(S, "HM_01") == false, "bagMax refuses a Gen 2 HM") + check(S.dirty == false, "a refused bagMax does not dirty the save") + + check(Ops.bagMaxAll(S) ~= false, "bagMaxAll fills the stackable pockets") + eq(S.save.inventory.POTION, Ops.STACK_MAX, "the ITEM row is maxed") + eq(S.save.inventory.MASTER_BALL, Ops.STACK_MAX, "the BALL row is maxed") + eq(S.save.inventory.BICYCLE, 1, "the KEY_ITEM row is untouched") + eq(S.save.inventory.HM_01, 1, "the HM row is untouched") +end + +do + -- (constants/item_data_constants.asm:41) + local S = newState() + Ops.addToBag(S, "MASTER_BALL") + Ops.addToBag(S, "BICYCLE") + Ops.addToBag(S, "POTION") + Ops.addToBag(S, "FLOWER_MAIL") + Ops.addToBag(S, "HM_01") + + check(Ops.bagSort(S, "index") ~= false, "bagSort runs on a gen2 bag") + local order = Bag.order(S.save, S.data) + local rank = { ITEM = 1, BALL = 2, KEY_ITEM = 3, TM_HM = 4 } + local grouped = true + for i = 2, #order do + if rank[Bag.pocketOf(order[i - 1], S.data)] + > rank[Bag.pocketOf(order[i], S.data)] then + grouped = false + end + end + check(grouped, "a sorted gen2 bag follows the PACK's own pocket order") + eq(order[1], "FLOWER_MAIL", "the ITEM pocket sorts by item number inside itself") + eq(order[2], "POTION", "and keeps the rest of that pocket in number order") + eq(order[3], "MASTER_BALL", "the BALL pocket follows the ITEM pocket") + eq(order[4], "BICYCLE", "then the KEY_ITEM pocket") + eq(order[#order], "HM_01", "and the TM/HM pocket comes last") end do diff --git a/tests/save_editor_task6_tests.lua b/tests/save_editor_task6_tests.lua index 34d9a4de..048f08a9 100644 --- a/tests/save_editor_task6_tests.lua +++ b/tests/save_editor_task6_tests.lua @@ -156,6 +156,23 @@ do check(S.dirty == false, "a no-op addMoney does not dirty the save") end +do + -- Game Corner coins are a Gen 1 save field too (ram/wram.asm:1908) + local S = newState() + + Ops.addCoins(S, 250) + eq(S.save.coins, 250, "addCoins writes save.coins on a Gen 1 save") + Ops.addCoins(S, -1000) + eq(S.save.coins, 0, "addCoins clamps at zero") + Ops.maxCoins(S) + eq(S.save.coins, Ops.COIN_MAX, "maxCoins fills the case") + eq(S.save.player and S.save.player.coins, nil, + "Gen 1 coins never land on player.coins") + S.dirty = false + check(Ops.addCoins(S, 100) == false, "addCoins at the cap is a no-op") + check(S.dirty == false, "a no-op addCoins does not dirty the save") +end + do local S = newState() local id = S.cat.items[1] @@ -230,6 +247,203 @@ do eq(#Ops.pcOrder(S), seeded, "pcDrop shrinks the PC order back") end +-- key item and one HM (home/list_menu.asm:474 prints no quantity for either) +local function itemFixtures(S) + local stack, key, hm = {}, nil, nil + for _, id in ipairs(S.cat.items) do + local def = S.data.items[id] + if Ops.isBadgeId(id) then + elseif id:find("^HM_") then hm = hm or id + elseif def and def.keyItem then key = key or id + elseif #stack < 3 then stack[#stack + 1] = id + end + end + return stack, key, hm +end + +do + -- (engine/items/inventory.asm:74) + local S = newState() + local stack, key, hm = itemFixtures(S) + check(#stack == 3 and key ~= nil and hm ~= nil, + "the catalog has stackable, key and HM items to max") + for _, id in ipairs(stack) do Ops.addToBag(S, id) end + Ops.addToBag(S, key) + Ops.addToBag(S, hm) + local rows = #Bag.order(S.save) + + check(Ops.itemStacks(S, stack[1]) == true, "a plain item carries a quantity") + check(Ops.itemStacks(S, key) == false, "a key item has no quantity to max") + check(Ops.itemStacks(S, hm) == false, "an HM has no quantity to max") + + check(Ops.bagMax(S, stack[1]) ~= false, "bagMax fills a stack") + eq(S.save.inventory[stack[1]], Ops.STACK_MAX, "bagMax lands on the 99 cap") + S.dirty = false + check(Ops.bagMax(S, stack[1]) == false, "bagMax at the cap is a no-op") + check(S.status:match("already at x99") ~= nil, "a refused bagMax says why") + check(Ops.bagMax(S, key) == false, "bagMax refuses a key item") + check(Ops.bagMax(S, hm) == false, "bagMax refuses an HM") + check(S.dirty == false, "a refused bagMax does not dirty the save") + + check(Ops.bagCanMax(S) == true, "bagCanMax sees the rows still under 99") + check(Ops.bagMaxAll(S) ~= false, "bagMaxAll tops up the rest of the bag") + check(S.status:match("^Maxed %d+ bag stack") ~= nil, + "bagMaxAll reports how many stacks it filled") + eq(S.save.inventory[stack[2]], Ops.STACK_MAX, "bagMaxAll fills the second stack") + eq(S.save.inventory[stack[3]], Ops.STACK_MAX, "bagMaxAll fills the third stack") + eq(S.save.inventory[key], 1, "bagMaxAll leaves a key item alone") + eq(S.save.inventory[hm], 1, "bagMaxAll leaves an HM alone") + eq(#Bag.order(S.save), rows, "maxing adds no bag rows") + S.dirty = false + check(Ops.bagMaxAll(S) == false, "a second bagMaxAll is a no-op") + check(Ops.bagCanMax(S) == false, "bagCanMax is false once every stack is 99") + check(S.dirty == false, "a no-op bagMaxAll does not dirty the save") + + local badge = Ops.badgeIds(S)[1] + Ops.toggleBadge(S, badge) + Ops.bagMaxAll(S) + eq(S.save.inventory[badge], 1, "bagMaxAll never stacks a badge") +end + +do + local S = newState() + local stack = itemFixtures(S) + for _, id in ipairs(stack) do Ops.addToPc(S, id) end + local pc = Ops.pcItems(S) + + check(Ops.pcMax(S, stack[1]) ~= false, "pcMax fills a PC stack") + eq(pc[stack[1]], Ops.STACK_MAX, "pcMax lands on the 99 cap") + S.dirty = false + check(Ops.pcMax(S, stack[1]) == false, "pcMax at the cap is a no-op") + check(Ops.pcMax(S, "NOT_A_REAL_ITEM") == false, + "pcMax refuses an id that is not in PC storage") + check(S.dirty == false, "a refused pcMax does not dirty the save") + + check(Ops.pcCanMax(S) == true, "pcCanMax sees the rows still under 99") + check(Ops.pcMaxAll(S) ~= false, "pcMaxAll tops up the rest of PC storage") + eq(pc[stack[2]], Ops.STACK_MAX, "pcMaxAll fills the second PC stack") + eq(pc[stack[3]], Ops.STACK_MAX, "pcMaxAll fills the third PC stack") + S.dirty = false + check(Ops.pcMaxAll(S) == false, "a second pcMaxAll is a no-op") + check(Ops.pcCanMax(S) == false, "pcCanMax is false once every PC stack is 99") + check(S.dirty == false, "a no-op pcMaxAll does not dirty the save") +end + +do + local S = newState() + local ids = {} + for _, id in ipairs(S.cat.items) do + if not Ops.isBadgeId(id) and #ids < 6 then ids[#ids + 1] = id end + end + for i = #ids, 1, -1 do Ops.addToBag(S, ids[i]) end + Ops.bagAdjust(S, ids[1], 1) + local qty = S.save.inventory[ids[1]] + local order = Bag.order(S.save) + local n = #order + local before = {} + for _, id in ipairs(order) do before[id] = true end + + S.dirty = false + check(Ops.bagSort(S, "bogus") == false, "bagSort refuses an unknown mode") + check(S.dirty == false, "a refused bagSort does not dirty the save") + + check(Ops.bagSort(S, "index") ~= false, "bagSort by index runs") + check(Bag.order(S.save) == order, "bagSort rewrites save.bagOrder in place") + eq(#order, n, "sorting drops no rows") + eq(S.save.inventory[ids[1]], qty, "sorting leaves the quantities alone") + local kept, ascending = true, true + local seen = {} + for i, id in ipairs(order) do + if not before[id] or seen[id] then kept = false end + seen[id] = true + if i > 1 then + local a = S.data.items[order[i - 1]] + local b = S.data.items[id] + if ((a and a.index) or math.huge) > ((b and b.index) or math.huge) then + ascending = false + end + end + end + check(kept, "sorting keeps exactly the ids the bag had") + check(ascending, "bagSort index leaves the bag ascending by item number") + + Ops.bagSort(S, "name") + local byName = true + for i = 2, #order do + local a = S.data.items[order[i - 1]] + local b = S.data.items[order[i]] + local an = tostring((a and a.name) or order[i - 1]):lower() + local bn = tostring((b and b.name) or order[i]):lower() + if an > bn then byName = false end + end + check(byName, "bagSort name leaves the bag alphabetical") + local first = table.concat(order, ",") + Ops.bagSort(S, "name") + eq(table.concat(order, ","), first, "sorting twice by the same mode is stable") + + local empty = newState() + empty.save.inventory = {} + empty.save.bagOrder = nil + empty.dirty = false + check(Ops.bagSort(empty, "index") == false, "an empty bag has nothing to sort") + check(empty.dirty == false, "a refused bagSort on an empty bag stays clean") +end + +do + local S = newState() + local ids = {} + for _, id in ipairs(S.cat.items) do + if not Ops.isBadgeId(id) and #ids < 4 then ids[#ids + 1] = id end + end + for _, id in ipairs(ids) do Ops.addToPc(S, id) end + + eq(S.pcSort, "index", "the PC list starts in item-number order") + local byIndex = Ops.pcOrder(S) + S.pcOffset = 3 + S.dirty = false + check(Ops.pcSort(S, "name") == true, "pcSort switches the PC view order") + eq(S.pcOffset, 0, "switching the PC sort resets its scroll") + check(S.dirty == false, "a view-only PC sort does not dirty the save") + check(Ops.pcSort(S, "name") == false, "re-picking the same PC mode is a no-op") + check(Ops.pcSort(S, "bogus") == false, "pcSort refuses an unknown mode") + + local byName = Ops.pcOrder(S) + eq(#byName, #byIndex, "both PC orders list the same rows") + local differs = false + for i = 1, #byName do + if byName[i] ~= byIndex[i] then differs = true end + end + check(differs, "the two PC orders are not the same list") + + S.save.pcOrder = { "STALE_ID" } + S.dirty = false + check(Ops.pcSort(S, "index") ~= false, "pcSort re-runs after a mode change") + check(S.dirty == true, "rewriting an imported PC order dirties the save") + eq(#S.save.pcOrder, #byIndex, "the imported PC order is rebuilt from the view") + eq(S.save.pcOrder[1], byIndex[1], "and matches what the panel draws") +end + +do + local S = newState() + local ids = {} + for _, id in ipairs(S.cat.items) do + if not Ops.isBadgeId(id) and #ids < 4 then ids[#ids + 1] = id end + end + for _, id in ipairs(ids) do Ops.addToPc(S, id) end + local byIndex = Ops.pcOrder(S) + S.save.pcOrder = { byIndex[#byIndex], byIndex[1] } + S.dirty = false + eq(S.pcSort, "index", "an imported save opens in item-number order") + check(Ops.pcSort(S, "index") ~= false, + "the first press rebuilds an imported order already in the default mode") + check(S.dirty == true, "that rebuild dirties the save") + eq(#S.save.pcOrder, #byIndex, "the rebuilt list holds every PC row") + eq(S.save.pcOrder[1], byIndex[1], "in the order the panel draws") + S.dirty = false + check(Ops.pcSort(S, "index") == false, "a second press changes nothing") + check(S.dirty == false, "and leaves the save clean") +end + do -- badges are truthy flags on inventory, toggled not stacked; the engine -- writes them as 1 (#515), so the editor must too diff --git a/tools/gen2_sram_offsets.py b/tools/gen2_sram_offsets.py index 39f00e96..1a8e2e7b 100644 --- a/tools/gen2_sram_offsets.py +++ b/tools/gen2_sram_offsets.py @@ -28,11 +28,14 @@ FIELDS = [ "wKeyItems", "wNumBalls", "wBalls", "wTMsHMs", "wPokedexCaught", "wPokedexSeen", "wCurBox", "wBoxNames", "wMapGroup", "wMapNumber", "wXCoord", "wYCoord", "wEventFlags", "wPlayerState", - "wStatusFlags", "wStatusFlags2", "wPokegearFlags", - "wGameTimeHours", "wGameTimeMinutes", + "wStatusFlags", "wStatusFlags2", "wPokegearFlags", "wVisitedSpawns", + "wVariableSprites", "wGameTimeHours", "wGameTimeMinutes", ] GUARDS = ["sCheckValue1", "sCheckValue2", "sChecksum", "sGameData", "sGameDataEnd"] +# ram/sram.asm:138-144 +SRAM_FIELDS = [("wPlayerGender", "sCrystalData", "wCrystalData")] + # The 14 archived PC boxes. Emitted as real per-box offsets, never a stride: # boxes 1-7 live in SRAM bank 2 and 8-14 in bank 3, so the step from box 7 to # box 8 is 0x620 rather than the 0x450 every other pair uses. Computing them @@ -69,10 +72,13 @@ def backup_table(sym, rows, label): "sChecksum": off("sBackupChecksum"), "sGameData": off("sBackupGameData"), "sGameDataEnd": off("sBackupGameDataEnd")} + absolute = {n for n, _, _ in SRAM_FIELDS} out = [] for name, value in rows: if name in guards: out.append((name, guards[name])) + elif name in absolute: + out.append((name, value)) else: out.append((name, value + delta)) return out @@ -106,6 +112,10 @@ def table(sym, label): skipped.append(f + " (outside sGameData..sGameDataEnd)") continue rows.append((f, base + (w[1] - anchor))) + for name, sbase, wbase in SRAM_FIELDS: + if name in sym and sbase in sym and wbase in sym: + at = sym[sbase][0] * 0x2000 + (sym[sbase][1] - 0xA000) + rows.append((name, at + (sym[name][1] - sym[wbase][1]))) boxes = [] for i in range(1, BOX_COUNT + 1): b = sym.get("sBox%d" % i) diff --git a/tools/rom_manifest_crystal.json b/tools/rom_manifest_crystal.json index dead47fd..9e3e3f11 100644 --- a/tools/rom_manifest_crystal.json +++ b/tools/rom_manifest_crystal.json @@ -14728,6 +14728,10 @@ 32, 19642 ], + "BattleTowerInsidePalette": [ + 18, + 21840 + ], "BattleTowerMons": [ 126, 17488 @@ -16436,6 +16440,10 @@ 116, 20223 ], + "HousePalette": [ + 18, + 21998 + ], "HungOnText": [ 32, 20121 @@ -16468,6 +16476,10 @@ 110, 19840 ], + "IcePathPalette": [ + 18, + 21919 + ], "IconPointers": [ 35, 27583 @@ -17220,6 +17232,14 @@ 96, 28093 ], + "MansionPalette1": [ + 18, + 22141 + ], + "MansionPalette2": [ + 18, + 22270 + ], "MantineBackpic": [ 89, 20850 @@ -18508,6 +18528,10 @@ 108, 17110 ], + "PokeComPalette": [ + 18, + 21761 + ], "PokecenterSignText": [ 108, 17791 @@ -18784,6 +18808,10 @@ 36, 21773 ], + "RadioTowerPalette": [ + 18, + 22077 + ], "RageBuildingText": [ 32, 20723 diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index fe26cec9..7960b639 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -910,6 +910,8 @@ function Ops.maxMoney(S) return Ops.addMoney(S, Ops.MONEY_MAX) end +-- ram/wram.asm:1908 wPlayerCoins is two BCD bytes, so 9999 is the ceiling on +-- both generations (misc_constants.asm:47 MAX_COINS). Ops.COIN_MAX = 9999 function Ops.addCoins(S, delta) @@ -923,6 +925,10 @@ function Ops.addCoins(S, delta) return Ops.mark(S, ("Coins set to %d"):format(want)) end +function Ops.maxCoins(S) + return Ops.addCoins(S, Ops.COIN_MAX) +end + function Ops.addToBag(S, id) if not id then return Ops.say(S, "Pick an item first") end local pocket = Bag.pocketOf(id, S.data) @@ -959,6 +965,105 @@ function Ops.bagDrop(S, id) return Ops.mark(S, ("Dropped all %d %s"):format(qty, id)) end +-- home/list_menu.asm:474 IsKeyItem (Gen 1 prints no count for key items or +-- HMs); ram/wram.asm:3115 wKeyItems (Gen 2 stores that pocket as bare ids) +-- and engine/items/tmhm.asm:390 prints no count for an HM. +function Ops.itemStacks(S, id) + if not id then return false end + if Gen.ofState(S) == 2 then + return Bag.pocketOf(id, S.data) ~= "KEY_ITEM" + and tostring(id):sub(1, 3) ~= "HM_" + end + local def = S.data and S.data.items and S.data.items[id] + return not ((def and def.keyItem) or tostring(id):find("^HM_") ~= nil) +end + +-- engine/items/inventory.asm:74 caps a slot at 99 +function Ops.bagMax(S, id) + if not id then return Ops.say(S, "No bag row selected") end + local have = S.save.inventory[id] or 0 + if have <= 0 then return Ops.say(S, ("%s is not in the bag"):format(id)) end + if not Ops.itemStacks(S, id) then + return Ops.say(S, ("%s has no quantity to max"):format(id)) + end + if have >= Ops.STACK_MAX then + return Ops.say(S, ("%s is already at x%d"):format(id, Ops.STACK_MAX)) + end + Bag.add(S.save, id, Ops.STACK_MAX - have, S.data) + return Ops.mark(S, ("%s x%d"):format(id, Ops.STACK_MAX)) +end + +function Ops.bagCanMax(S, id) + if id then + local have = S.save.inventory[id] or 0 + return have > 0 and have < Ops.STACK_MAX and Ops.itemStacks(S, id) + end + for _, rowId in ipairs(Bag.order(S.save, S.data)) do + if Ops.bagCanMax(S, rowId) then return true end + end + return false +end + +function Ops.bagMaxAll(S) + local order = Bag.order(S.save, S.data) + local ids = {} + for i = 1, #order do ids[i] = order[i] end + local n = 0 + for _, id in ipairs(ids) do + local have = S.save.inventory[id] or 0 + if have > 0 and have < Ops.STACK_MAX and Ops.itemStacks(S, id) then + Bag.add(S.save, id, Ops.STACK_MAX - have, S.data) + n = n + 1 + end + end + if n == 0 then + return Ops.say(S, ("Every bag stack is already at x%d"):format(Ops.STACK_MAX)) + end + return Ops.mark(S, ("Maxed %d bag stack%s to x%d") + :format(n, n == 1 and "" or "s", Ops.STACK_MAX)) +end + +-- constants/item_data_constants.asm:41 +local POCKET_RANK = { ITEM = 1, BALL = 2, KEY_ITEM = 3, TM_HM = 4 } + +local ITEM_SORT_KEYS = { + index = function(def, id) return (def and def.index) or math.huge end, + name = function(def, id) + return tostring((def and def.name) or id):lower() + end, +} + +local function itemRows(S, ids, mode) + local make = ITEM_SORT_KEYS[mode] or ITEM_SORT_KEYS.index + local items = S.data and S.data.items + local gen2 = Gen.ofState(S) == 2 + local rows = {} + for i = 1, #ids do + local id = ids[i] + local def = items and items[id] + rows[i] = { id = id, key = make(def, id), + rank = gen2 and (POCKET_RANK[Bag.pocketOf(id, S.data)] or 9) or 0 } + end + table.sort(rows, function(a, b) + if a.rank ~= b.rank then return a.rank < b.rank end + if a.key ~= b.key then return a.key < b.key end + return a.id < b.id + end) + local out = {} + for i = 1, #rows do out[i] = rows[i].id end + return out +end + +function Ops.bagSort(S, mode) + if not ITEM_SORT_KEYS[mode] then return false end + local order = Bag.order(S.save, S.data) + if #order < 2 then return Ops.say(S, "Nothing to sort in the bag") end + local sorted = itemRows(S, order, mode) + for i = 1, #sorted do order[i] = sorted[i] end + S.bagOffset = 0 + return Ops.mark(S, ("Bag sorted by %s (%d items)"):format(mode, #order)) +end + function Ops.pcItems(S) S.save.pcItems = S.save.pcItems or {} return S.save.pcItems @@ -967,8 +1072,29 @@ end function Ops.pcOrder(S) local ids = {} for id in pairs(Ops.pcItems(S)) do ids[#ids + 1] = id end - table.sort(ids) - return ids + return itemRows(S, ids, S.pcSort) +end + +function Ops.pcSort(S, mode) + if not ITEM_SORT_KEYS[mode] then return false end + local repeated = S.pcSort == mode + S.pcSort = mode + if not repeated then S.pcOffset = 0 end + local stored = S.save.pcOrder + if type(stored) == "table" then + local sorted = Ops.pcOrder(S) + local same = #stored == #sorted + for i = 1, #sorted do + if stored[i] ~= sorted[i] then same = false end + end + if not same then + S.pcOffset = 0 + for i = 1, #stored do stored[i] = nil end + for i = 1, #sorted do stored[i] = sorted[i] end + return Ops.mark(S, ("PC storage sorted by %s"):format(mode)) + end + end + return not repeated end function Ops.addToPc(S, id) @@ -1007,6 +1133,50 @@ function Ops.pcDrop(S, id) return Ops.mark(S, ("Dropped all %d %s from PC storage"):format(qty, id)) end +function Ops.pcMax(S, id) + if not id then return Ops.say(S, "No PC row selected") end + local pc = Ops.pcItems(S) + if not pc[id] then return Ops.say(S, ("%s is not in PC storage"):format(id)) end + if not Ops.itemStacks(S, id) then + return Ops.say(S, ("%s has no quantity to max"):format(id)) + end + if pc[id] >= Ops.STACK_MAX then + return Ops.say(S, ("%s is already at x%d"):format(id, Ops.STACK_MAX)) + end + pc[id] = Ops.STACK_MAX + return Ops.mark(S, ("%s x%d in PC storage"):format(id, Ops.STACK_MAX)) +end + +function Ops.pcCanMax(S, id) + local pc = Ops.pcItems(S) + if id then + return (pc[id] or 0) > 0 and pc[id] < Ops.STACK_MAX and Ops.itemStacks(S, id) + end + for rowId, qty in pairs(pc) do + if qty > 0 and qty < Ops.STACK_MAX and Ops.itemStacks(S, rowId) then + return true + end + end + return false +end + +function Ops.pcMaxAll(S) + local pc = Ops.pcItems(S) + local n = 0 + for _, id in ipairs(Ops.pcOrder(S)) do + local qty = pc[id] or 0 + if qty > 0 and qty < Ops.STACK_MAX and Ops.itemStacks(S, id) then + pc[id] = Ops.STACK_MAX + n = n + 1 + end + end + if n == 0 then + return Ops.say(S, ("Every PC stack is already at x%d"):format(Ops.STACK_MAX)) + end + return Ops.mark(S, ("Maxed %d PC stack%s to x%d") + :format(n, n == 1 and "" or "s", Ops.STACK_MAX)) +end + -- Badges are truthy inventory flags, not stackable items, which is why the -- design gives them toggle chips instead of quantity rows. function Ops.isBadgeId(id) diff --git a/tools/save-editor/State.lua b/tools/save-editor/State.lua index de9acaa3..13040d26 100644 --- a/tools/save-editor/State.lua +++ b/tools/save-editor/State.lua @@ -77,6 +77,7 @@ function State.new() itemPickOffset = 0, -- scroll position in the ADD ITEM list (#595) bagOffset = 0, pcOffset = 0, + pcSort = "index", itemsScroll = 0, -- stacked-layout pixel scroll (#715) -- events diff --git a/tools/save-editor/panels/Items.lua b/tools/save-editor/panels/Items.lua index 94db78bb..e2cf5687 100644 --- a/tools/save-editor/panels/Items.lua +++ b/tools/save-editor/panels/Items.lua @@ -24,14 +24,15 @@ local PAL = Theme.PAL local M = {} local MONEY_STEPS = { -1000, -100, 100, 1000 } +local COIN_STEPS = { -100, -10, 10, 100 } -- One quantity row shape, shared by the bag and the PC list: id, qty, then --- the -/+/drop cluster. Returns true when the row body was clicked. -local function quantityRow(S, Kit, x, y, w, h, id, qty, selected, onMinus, onPlus, onDrop) +local function quantityRow(S, Kit, x, y, w, h, id, qty, selected, onMinus, onPlus, + onMax, canMax, onDrop) local s = Kit.scale local clicked = Kit.row(x, y, w, h, selected, PAL.blue, 9 * s) local btn = 24 * s - local bx = x + w - 10 * s - 3 * btn - 2 * (6 * s) + local bx = x + w - 10 * s - 4 * btn - 3 * (6 * s) if Kit.stepper(bx, y + (h - btn) / 2, btn, btn, "-", { font = "small" }) then onMinus() end @@ -39,7 +40,11 @@ local function quantityRow(S, Kit, x, y, w, h, id, qty, selected, onMinus, onPlu { font = "small" }) then onPlus() end - if Kit.button(bx + 2 * (btn + 6 * s), y + (h - btn) / 2, btn, btn, "x", + if Kit.stepper(bx + 2 * (btn + 6 * s), y + (h - btn) / 2, btn, btn, + tostring(Ops.STACK_MAX), { font = "tiny", enabled = canMax }) then + onMax() + end + if Kit.button(bx + 3 * (btn + 6 * s), y + (h - btn) / 2, btn, btn, "x", { kind = "danger", font = "tiny", radius = 6 * s }) then onDrop() end @@ -60,13 +65,13 @@ end -- Each card is a function of its own rect so the wide (three column) and the -- stacked (#715) layouts are the same drawing code with different geometry. +local function coinsHeight(Kit, s) + return 12 * s + math.max(Kit.textHeight("caption"), 26 * s) + 8 * s + 30 * s +end + local function moneyHeight(Kit, s, pad, S) - local h = pad * 2 + Kit.textHeight("caption") + 8 * s - + Kit.textHeight("headline") + 10 * s + 30 * s - if S and Gen.of(S.save) == 2 then - h = h + 28 * s - end - return h + return pad * 2 + Kit.textHeight("caption") + 8 * s + + Kit.textHeight("headline") + 10 * s + 30 * s + coinsHeight(Kit, s) end local function drawMoney(S, Kit, x, y, w, h) @@ -82,15 +87,8 @@ local function drawMoney(S, Kit, x, y, w, h) end Kit.text("headline", ("$%d"):format(Gen.money(S.save)), x + pad, y + pad + Kit.textHeight("caption") + 8 * s, PAL.yellow) - if Gen.of(S.save) == 2 then - Kit.text("mono", ("COINS %d"):format(Gen.coins(S.save)), x + pad + 160 * s, - y + pad + Kit.textHeight("caption") + 8 * s, PAL.muted) - if Kit.button(x + w - pad - 74 * s, y + pad + 26 * s, 74 * s, 22 * s, "+100 coins", - { kind = "ghost", font = "tiny", radius = 6 * s }) then - Ops.addCoins(S, 100) - end - end - local mbY = y + h - pad - 30 * s + local coinsH = coinsHeight(Kit, s) + local mbY = y + h - pad - coinsH - 30 * s local mbW = (w - 2 * pad - 3 * 8 * s) / 4 for i, delta in ipairs(MONEY_STEPS) do local label = (delta > 0 and "+" or "") .. tostring(delta) @@ -99,6 +97,29 @@ local function drawMoney(S, Kit, x, y, w, h) Ops.addMoney(S, delta) end end + + -- Game Corner coins, both generations: ram/wram.asm:1908 wPlayerCoins + local rowH = math.max(Kit.textHeight("caption"), 26 * s) + local cy = mbY + 30 * s + 12 * s + Kit.caption(x + pad, cy + (rowH - Kit.textHeight("caption")) / 2, "COINS") + local coinMaxW = 52 * s + if Kit.button(x + w - pad - coinMaxW, cy, coinMaxW, 26 * s, "Max", + { kind = "accent", font = "tiny", radius = 7 * s, + enabled = Gen.coins(S.save) < Ops.COIN_MAX }) then + Ops.maxCoins(S) + end + Kit.text("mono", ("%d"):format(Gen.coins(S.save)), + x + pad + Kit.captionWidth("COINS") + 12 * s, + cy + (rowH - Kit.textHeight("mono")) / 2, PAL.yellow) + local cbY = cy + rowH + 8 * s + local cbW = (w - 2 * pad - 3 * 8 * s) / 4 + for i, delta in ipairs(COIN_STEPS) do + local label = (delta > 0 and "+" or "") .. tostring(delta) + if Kit.button(x + pad + (i - 1) * (cbW + 8 * s), cbY, cbW, 30 * s, label, + { kind = "accent", font = "tiny", radius = 8 * s }) then + Ops.addCoins(S, delta) + end + end end -- engine/menus/init_gender.asm:55-56 @@ -191,14 +212,36 @@ local function drawPicker(S, Kit, x, y, w, h) end end --- The bag and PC cards share one shape: a caption line, an optional meter, --- a quantity-row list with wheel/drag + pager. +local SORT_MODES = { { "#", "index" }, { "A-Z", "name" } } + local function drawQuantityCard(S, Kit, x, y, w, h, cfg) local s = Kit.scale local pad = 16 * s Kit.card(x, y, w, h) Kit.caption(x + pad, y + pad, cfg.title) - Kit.textRight("mono", cfg.counter, x + w - pad, y + pad, PAL.caption) + local hx = x + w - pad + local hy = y + pad - 4 * s + local maxW = Kit.textWidth("tiny", "Max all") + 22 * s + hx = hx - maxW + if Kit.button(hx, hy, maxW, 24 * s, "Max all", + { kind = "accent", font = "tiny", radius = 7 * s, + enabled = cfg.canMaxAll() }) then + cfg.maxAll() + end + for i = #SORT_MODES, 1, -1 do + local mode = SORT_MODES[i] + local bw = Kit.textWidth("tiny", mode[1]) + 22 * s + hx = hx - 6 * s - bw + if Kit.button(hx, hy, bw, 24 * s, mode[1], + { kind = "ghost", font = "tiny", radius = 6 * s }) then + cfg.sort(mode[2]) + end + end + local counterX = hx - 8 * s + if counterX - Kit.textWidth("mono", cfg.counter) + >= x + pad + Kit.captionWidth(cfg.title) + 8 * s then + Kit.textRight("mono", cfg.counter, counterX, y + pad, PAL.caption) + end local rowsTop = y + pad + Kit.textHeight("caption") + 8 * s if cfg.meterFrac then Kit.meter(x + pad, rowsTop, w - 2 * pad, 5 * s, cfg.meterFrac * 100, @@ -230,6 +273,7 @@ local function drawQuantityCard(S, Kit, x, y, w, h, cfg) cfg.qty(id), id == cfg.selected(), function() cfg.adjust(id, -1) end, function() cfg.adjust(id, 1) end, + function() cfg.max(id) end, cfg.canMax(id), function() cfg.drop(id) end) then cfg.select(id) end @@ -256,6 +300,11 @@ local function drawBag(S, Kit, x, y, w, h) Ops.say(S, ("Selected %s in the bag"):format(id)) end, adjust = function(id, d) Ops.bagAdjust(S, id, d) end, + max = function(id) Ops.bagMax(S, id) end, + canMax = function(id) return Ops.bagCanMax(S, id) end, + maxAll = function() Ops.bagMaxAll(S) end, + canMaxAll = function() return Ops.bagCanMax(S) end, + sort = function(mode) Ops.bagSort(S, mode) end, drop = function(id) Ops.bagDrop(S, id) end, }) end @@ -275,6 +324,11 @@ local function drawPc(S, Kit, x, y, w, h) Ops.say(S, ("Selected %s in PC storage"):format(id)) end, adjust = function(id, d) Ops.pcAdjust(S, id, d) end, + max = function(id) Ops.pcMax(S, id) end, + canMax = function(id) return Ops.pcCanMax(S, id) end, + maxAll = function() Ops.pcMaxAll(S) end, + canMaxAll = function() return Ops.pcCanMax(S) end, + sort = function(mode) Ops.pcSort(S, mode) end, drop = function(id) Ops.pcDrop(S, id) end, }) end @@ -285,15 +339,19 @@ function M.draw(S, Kit, x, y, w, h) local pad = 16 * s Ops.pcItems(S) - if w < 900 * s then + local moneyH = moneyHeight(Kit, s, pad, S) + local badgeH = badgeHeight(S, Kit, s, pad) + local topH = 0 + if Gen.hasPlayerGender(S.save, S.version) then + topH = trainerHeight(Kit, s, pad) + gap + end + if w < 900 * s or h - topH - moneyH - badgeH - 2 * gap < 150 * s then -- stacked (#715): one full-width column, scrolled in pixels. The offset -- from LAST frame's scrollPixels call positions this frame, and the call -- itself comes after the cards so their inner lists claim the wheel or a -- drag over their own bodies first. local off = Theme.clamp(S.itemsScroll or 0, 0, math.max(0, (S._itemsContentH or 0) - h)) - local moneyH = moneyHeight(Kit, s, pad, S) - local badgeH = badgeHeight(S, Kit, s, pad) local pickH = 280 * s local listH = 300 * s Kit.pushClip(x, y, w, h) @@ -321,11 +379,7 @@ function M.draw(S, Kit, x, y, w, h) -- Money and badges are fixed-height so the picker gets every pixel left -- over: cycling through ~250 item ids in a two-row list was the thing that -- made the old panel unusable. - local moneyH = moneyHeight(Kit, s, pad, S) - local badgeH = badgeHeight(S, Kit, s, pad) - local topH = 0 - if Gen.hasPlayerGender(S.save, S.version) then - topH = trainerHeight(Kit, s, pad) + gap + if topH > 0 then drawTrainer(S, Kit, x, y, leftW, topH - gap) end drawMoney(S, Kit, x, y + topH, leftW, moneyH)