From 1905261c5b416a45c2b199f1e8350eab803fbffb Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Mon, 24 Aug 2026 07:52:05 -0400 Subject: [PATCH] Spider Car Unleashed CLOSES #1483, CLOSES #1610, CLOSES #1615, CLOSES #1646, CLOSES #1649, CLOSES #1651, CLOSES #1653, CLOSES #1656, CLOSES #1683, CLOSES #1685, CLOSES #1686, CLOSES #1687, CLOSES #1688, CLOSES #1689, CLOSES #1690, CLOSES #1693, CLOSES #1694, CLOSES #1695, CLOSES #1696, CLOSES #1702, CLOSES #1704, CLOSES #1705, CLOSES #1706, CLOSES #1707, CLOSES #1708, CLOSES #1710, CLOSES #1711, CLOSES #1712, CLOSES #1713, CLOSES #1716, CLOSES #1717, CLOSES #1718, CLOSES #1719, CLOSES #1720, CLOSES #1721, CLOSES #1725, CLOSES #1732, CLOSES #1745, CLOSES #1748, CLOSES #1749, CLOSES #1751, CLOSES #1754 --- README.md | 7 +- build-rg34xxsp.sh | 12 - conf.lua | 6 + data/scripts/flavor/pokemon_fan_club.lua | 24 +- data/scripts/flavor/ss_anne_1f_rooms.lua | 5 +- data/scripts/flavor/ss_anne_b1f_rooms.lua | 7 +- data/scripts/flavor/vermilion_city.lua | 7 +- .../scripts/flavor/vermilion_pidgey_house.lua | 8 +- data/scripts/story.lua | 58 +- data/scripts/story2.lua | 23 + data/scripts/story3.lua | 120 +- data/scripts/story5.lua | 10 +- docs/anbernic-rg34xxsp.md | 10 - docs/mod-api-gen2-compat.md | 2 +- docs/modding.md | 2 +- docs/new-features.md | 2 + docs/shaderfx.md | 673 +++++++++++ docs/switch-install.md | 1 - main.lua | 18 +- .../android/app/src/main/AndroidManifest.xml | 4 + scripts/build.sh | 31 +- scripts/build_android.sh | 33 +- src/battle/AnimPlayer.lua | 11 + src/battle/BattleState.lua | 46 +- src/battle/EffectRegistry.lua | 18 +- src/battle/MoveEffects.lua | 2 + src/battle/WideBattle.lua | 4 +- src/battle/gen2/Battle.lua | 18 +- src/battle/gen2/Effects.lua | 41 +- src/carts/CartManifest.lua | 95 +- src/carts/CartStore.lua | 31 +- src/core/ChipSynth.lua | 1 + src/core/Game.lua | 21 +- src/core/Game2.lua | 175 ++- src/core/GameSpeed.lua | 35 +- src/core/GameVersion.lua | 8 +- src/core/HostShell.lua | 25 +- src/core/Performance.lua | 41 +- src/core/SaveData.lua | 136 ++- src/core/Sensors.lua | 178 +++ src/core/Sound.lua | 8 + src/core/gen2/Boxes.lua | 13 +- src/core/gen2/BugContest.lua | 4 +- src/core/gen2/Save.lua | 6 +- src/import/LauncherSettings.lua | 27 +- src/import/LauncherView.lua | 375 +++++- src/import/RomExtractorGen2.lua | 31 +- src/import/RomImporter.lua | 773 +++++++++++- src/mods/Gen2Compat.lua | 12 +- src/mods/Loader.lua | 40 +- src/mods/ModIndex.lua | 149 ++- src/net/Fetch.lua | 9 +- src/net/fetch_worker.lua | 15 +- src/render/GBCFX.lua | 310 ----- src/render/Renderer.lua | 76 +- src/render/ShaderFX.lua | 1053 +++++++++++++++++ src/render/ShaderFixup.lua | 295 +++++ src/render/ShaderSourcePatches.lua | 67 ++ src/render/Zoom.lua | 27 + src/save_convert/SaveConvert.lua | 2 +- src/script/Commands.lua | 21 +- src/script/gen2/CallAsm.lua | 7 +- src/script/gen2/Movement.lua | 25 +- src/script/gen2/Specials.lua | 7 + src/ui/BagMenu.lua | 110 +- src/ui/ListMenu.lua | 50 +- src/ui/MoveLearnMenu.lua | 45 +- src/ui/OptionsMenu.lua | 59 +- src/ui/PartyMenu.lua | 54 +- src/ui/Screens.lua | 3 + src/ui/ShaderFXParamsScreen.lua | 161 +++ src/ui/ShaderFXScreen.lua | 159 +++ src/ui/ShopMenu.lua | 107 +- src/ui/StartMenu.lua | 11 +- src/ui/gen2/BattleHud.lua | 24 +- src/ui/gen2/BattleState.lua | 31 +- src/ui/gen2/BoxMenu.lua | 13 +- src/ui/gen2/CrystalIntro.lua | 10 +- src/ui/gen2/CrystalSplash.lua | 423 +++++++ src/ui/gen2/GameFreakPresents.lua | 9 +- src/ui/gen2/OptionsMenu.lua | 62 +- src/ui/gen2/PackGfx.lua | 5 +- src/ui/gen2/PackMenu.lua | 80 +- src/ui/gen2/Pokegear.lua | 52 +- src/ui/gen2/SummaryMenu.lua | 51 +- src/ui/kit/Theme.lua | 2 +- src/world/NPC.lua | 17 + src/world/OverworldController.lua | 256 +++- src/world/Player.lua | 37 +- src/world/gen2/FieldMoves.lua | 8 +- src/world/gen2/Npc.lua | 23 +- src/world/gen2/Player.lua | 96 +- src/world/gen2/StepEvents.lua | 6 +- src/world/gen2/World.lua | 355 +++++- tests/drivers/bag_cancel_row_bug1685_test.lua | 174 +++ tests/drivers/bike_dismount_bug513_test.lua | 5 +- .../drivers/bike_option_box_bug1705_test.lua | 342 ++++++ tests/drivers/crystal_shaderfx_shots.lua | 94 ++ tests/drivers/crystal_splash_shots.lua | 86 ++ tests/drivers/gold_battle_bg_bug1709_test.lua | 278 +++++ .../gold_cycling_road_bug1718_1749_test.lua | 274 +++++ tests/drivers/gold_field_anim_shots.lua | 31 +- .../drivers/gold_fishing_rod_bug1708_test.lua | 369 ++++++ tests/drivers/gold_fly_anim_bug1711_test.lua | 305 +++++ .../gold_flymap_cursor_bug1712_test.lua | 234 ++++ tests/drivers/gold_frame_seams.lua | 36 +- tests/drivers/gold_ledge_hop_bug1713_test.lua | 272 +++++ tests/drivers/gold_ow_bounce_bug1748_test.lua | 328 +++++ tests/drivers/gold_pack_rows_bug1695_test.lua | 243 ++++ .../gold_shaderfx_menu_black_crop_test.lua | 90 ++ .../gold_shaderfx_zoom_sizing_test.lua | 73 ++ tests/drivers/gold_splash_skip.lua | 42 + .../gold_stats_page_tint_bug1693_test.lua | 279 +++++ .../drivers/gold_tm_fanfare_bug1483_test.lua | 338 ++++++ .../gold_whirlpool_forceturn_bug1716_test.lua | 213 ++++ .../gold_whirlpool_sound_bug1717_test.lua | 240 ++++ .../museum_back_entrance_bug1690_test.lua | 211 ++++ tests/drivers/pet_cries_bug1687_1649_test.lua | 303 +++++ .../drivers/ss_anne_departure_bug360_test.lua | 20 +- tests/drivers/wrap_anim_bug1653_test.lua | 404 +++++++ .../engine/bag_bicycle_no_options_bug1705.lua | 244 ++++ tests/engine/bag_cancel_row_bug1685.lua | 253 ++++ tests/engine/cart_launcher.lua | 870 +++++++++++++- tests/engine/cart_manifest.lua | 94 ++ tests/engine/cart_saves.lua | 84 ++ tests/engine/cart_seal.lua | 171 +++ tests/engine/cart_store.lua | 78 +- tests/engine/game_speed_categories_test.lua | 27 + tests/engine/gen2_stats_tiles_bug1558.lua | 17 +- tests/engine/launcher_skins_ux.lua | 28 + tests/engine/mod_index_tests.lua | 148 +++ .../museum_clerk_back_entrance_bug1690.lua | 222 ++++ tests/engine/performance_tiers.lua | 10 +- tests/gen2_audio_test.lua | 6 + tests/gen2_battle_cursor_test.lua | 200 ++++ tests/gen2_battle_options_test.lua | 262 ++++ tests/gen2_battle_test.lua | 36 +- tests/gen2_billspc_dpad_test.lua | 171 +++ tests/gen2_box_intake_test.lua | 280 +++++ tests/gen2_contest_test.lua | 2 +- tests/gen2_cycling_road_test.lua | 279 +++++ tests/gen2_dex_gift_test.lua | 152 +++ tests/gen2_field_anim_test.lua | 11 +- tests/gen2_ledge_hop_test.lua | 270 +++++ tests/gen2_menus_test.lua | 23 +- tests/gen2_obedience_test.lua | 2 +- tests/gen2_ow_bounce_test.lua | 311 +++++ tests/gen2_pack_rows_test.lua | 404 +++++++ tests/gen2_sleep_counter_test.lua | 213 ++++ tests/gen2_sprites_test.lua | 11 +- tests/gen2_timed_heal_test.lua | 315 +++++ tests/gen2_vm_test.lua | 5 + tests/gen2_whirlpool_test.lua | 229 ++++ tests/mod_graphics_tests.lua | 6 +- tests/mod_ui_tests.lua | 41 +- tests/mod_world_tests.lua | 6 +- tests/modkit/record.lua | 4 +- tests/parity_cycling_road_brake.lua | 3 + tests/parity_escort_lockstep.lua | 4 +- tests/parity_gbcfx.lua | 171 --- tests/parity_rare_candy_menu.lua | 2 +- tests/parity_ss_anne_departure.lua | 80 +- tests/parity_ss_anne_guard.lua | 62 +- tests/parity_trainer_evolution_order.lua | 17 +- tests/parity_trashcans.lua | 1 + tests/pet_cries_test.lua | 157 +++ tests/run_tests.lua | 72 +- tests/switch_transfer_docs_test.lua | 1 - tools/cartkit.py | 29 +- tools/crystal_movie_symbols.py | 4 +- tools/crystal_symbol_deltas.py | 4 +- tools/make_gold_manifest.py | 3 + tools/rom_manifest_crystal.json | 12 + tools/rom_manifest_gold.json | 4 + tools/rom_manifest_silver.json | 4 + tools/shaderfx-bridge/.gitignore | 1 + tools/shaderfx-bridge/Cargo.lock | 955 +++++++++++++++ tools/shaderfx-bridge/Cargo.toml | 16 + tools/shaderfx-bridge/src/lib.rs | 1009 ++++++++++++++++ tools/shaderfx-bridge/src/main.rs | 123 ++ 180 files changed, 19567 insertions(+), 1556 deletions(-) create mode 100644 docs/shaderfx.md create mode 100644 src/core/Sensors.lua delete mode 100644 src/render/GBCFX.lua create mode 100644 src/render/ShaderFX.lua create mode 100644 src/render/ShaderFixup.lua create mode 100644 src/render/ShaderSourcePatches.lua create mode 100644 src/ui/ShaderFXParamsScreen.lua create mode 100644 src/ui/ShaderFXScreen.lua create mode 100644 src/ui/gen2/CrystalSplash.lua create mode 100644 tests/drivers/bag_cancel_row_bug1685_test.lua create mode 100644 tests/drivers/bike_option_box_bug1705_test.lua create mode 100644 tests/drivers/crystal_shaderfx_shots.lua create mode 100644 tests/drivers/crystal_splash_shots.lua create mode 100644 tests/drivers/gold_battle_bg_bug1709_test.lua create mode 100644 tests/drivers/gold_cycling_road_bug1718_1749_test.lua create mode 100644 tests/drivers/gold_fishing_rod_bug1708_test.lua create mode 100644 tests/drivers/gold_fly_anim_bug1711_test.lua create mode 100644 tests/drivers/gold_flymap_cursor_bug1712_test.lua create mode 100644 tests/drivers/gold_ledge_hop_bug1713_test.lua create mode 100644 tests/drivers/gold_ow_bounce_bug1748_test.lua create mode 100644 tests/drivers/gold_pack_rows_bug1695_test.lua create mode 100644 tests/drivers/gold_shaderfx_menu_black_crop_test.lua create mode 100644 tests/drivers/gold_shaderfx_zoom_sizing_test.lua create mode 100644 tests/drivers/gold_splash_skip.lua create mode 100644 tests/drivers/gold_stats_page_tint_bug1693_test.lua create mode 100644 tests/drivers/gold_tm_fanfare_bug1483_test.lua create mode 100644 tests/drivers/gold_whirlpool_forceturn_bug1716_test.lua create mode 100644 tests/drivers/gold_whirlpool_sound_bug1717_test.lua create mode 100644 tests/drivers/museum_back_entrance_bug1690_test.lua create mode 100644 tests/drivers/pet_cries_bug1687_1649_test.lua create mode 100644 tests/drivers/wrap_anim_bug1653_test.lua create mode 100644 tests/engine/bag_bicycle_no_options_bug1705.lua create mode 100644 tests/engine/bag_cancel_row_bug1685.lua create mode 100644 tests/engine/museum_clerk_back_entrance_bug1690.lua create mode 100644 tests/gen2_battle_cursor_test.lua create mode 100644 tests/gen2_battle_options_test.lua create mode 100644 tests/gen2_billspc_dpad_test.lua create mode 100644 tests/gen2_box_intake_test.lua create mode 100644 tests/gen2_cycling_road_test.lua create mode 100644 tests/gen2_dex_gift_test.lua create mode 100644 tests/gen2_ledge_hop_test.lua create mode 100644 tests/gen2_ow_bounce_test.lua create mode 100644 tests/gen2_pack_rows_test.lua create mode 100644 tests/gen2_sleep_counter_test.lua create mode 100644 tests/gen2_timed_heal_test.lua create mode 100644 tests/gen2_whirlpool_test.lua delete mode 100644 tests/parity_gbcfx.lua create mode 100644 tests/pet_cries_test.lua create mode 100644 tools/shaderfx-bridge/.gitignore create mode 100644 tools/shaderfx-bridge/Cargo.lock create mode 100644 tools/shaderfx-bridge/Cargo.toml create mode 100644 tools/shaderfx-bridge/src/lib.rs create mode 100644 tools/shaderfx-bridge/src/main.rs diff --git a/README.md b/README.md index d769fb0d..897a2874 100644 --- a/README.md +++ b/README.md @@ -126,24 +126,23 @@ supported out of the box. | `2` | Cycle COLORS | | `3` | Cycle TILT (free-roam overworld) | | `4` | Cycle ZOOM through every level (free-roam overworld) | -| `5` | Cycle GBC FX | | `F1` | Save | | `F2` | Load | | `F10` | Open / close the mod manager | -COLORS, TILT, ZOOM, GBC FX, GAME SPEED, and VOID FILL are also in the +COLORS, TILT, ZOOM, SHADER FX, GAME SPEED, and VOID FILL are also in the Options menu and persist in `options.lua`. ### Low-end devices **OPTIONS → PERFORMANCE** scales the port's optional extras for weaker -hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt or GBC FX), +hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt), **LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which picks a tier from your device (ARM handhelds → LOW, phones → BALANCED, normal desktops → HIGH, unchanged). It only scales presentation; the fixed-step game logic is identical on every tier, and a lower tier hides -your tilt/zoom/GBC-FX preferences without forgetting them. Details in +your tilt/zoom preferences without forgetting them. Details in [docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices). ### Rulesets diff --git a/build-rg34xxsp.sh b/build-rg34xxsp.sh index bfa0f8c4..85c2bcfb 100755 --- a/build-rg34xxsp.sh +++ b/build-rg34xxsp.sh @@ -230,11 +230,6 @@ export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}" export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}" # Mali / H700: prefer GLES where available export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}" -# Same GPU class as a phone, but getOS() here says "Linux", so the Android -# gate (issue #136) would not fire on its own: refuse GBC FX explicitly. -# Hides the OPTIONS row, pins the level to OFF, and heals a level already -# persisted in options.lua. -export POKEPORT_GBCFX="${POKEPORT_GBCFX:-0}" $ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64 $ESUDO chmod 666 /dev/uinput 2>/dev/null || true @@ -325,13 +320,6 @@ Native LÖVE 11.5 port of gen1recomp for Anbernic RG34XXSP on In-game controls use the normal PortMaster / SDL pad map (rebind under OPTIONS → CONTROLS). -### Display options - -GBC FX is disabled on this device (the H700's Mali GPU compiles that present -pass and then shows a black frame), so the OPTIONS row is hidden. COLORS, -TILT, ZOOM, VOID FILL and MAX FPS all work. To try it anyway, launch with -`POKEPORT_GBCFX=1`. - ### First run Stock OS has no zenity file picker. Put the `.gb` in `lovegame/`, then press diff --git a/conf.lua b/conf.lua index 1fe4d5da..974a9c86 100644 --- a/conf.lua +++ b/conf.lua @@ -66,6 +66,12 @@ function love.conf(t) t.modules.audio = not companion t.modules.joystick = not companion t.modules.physics = false + -- love.sensor exposes raw accelerometer/gyroscope data (love.sensor.getData), + -- independent of t.accelerometerjoystick below (which instead maps the + -- accelerometer onto joystick axes and stays off -- see that flag's + -- comment for why). Explicit here so a future effect (e.g. a tilt-driven + -- reflective-screen look) has sensor data available without reviving #468. + t.modules.sensor = true -- love.system is not loaded during love.conf; love._os is set by the -- engine before conf runs (LÖVE 11.x / 11.5). diff --git a/data/scripts/flavor/pokemon_fan_club.lua b/data/scripts/flavor/pokemon_fan_club.lua index 1efd93dd..f91866cc 100644 --- a/data/scripts/flavor/pokemon_fan_club.lua +++ b/data/scripts/flavor/pokemon_fan_club.lua @@ -36,19 +36,29 @@ M.POKEMON_FAN_CLUB = { { "clear_flag", "EVENT_SEEL_FAN_BOAST" }, -- 8 }, - -- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm): the - -- PIKACHU itself, just a flavor line (its cry isn't playable in the - -- port's talk pipeline, so it's dropped like other cry-only lines). + -- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm:71): PrintText, + -- then ld a, PIKACHU / call PlayCry (:76) / call WaitForSoundToFinish. TEXT_POKEMONFANCLUB_PIKACHU = { - { "show_text", "_PokemonFanClubPikachuText" }, + { "play_cry", "PIKACHU", true }, -- 1 PlayCry (#1649) + { "show_text", "_PokemonFanClubPikachuText" }, -- 2 PrintText }, - -- PokemonFanClubSeelText (scripts/PokemonFanClub.asm): the SEEL - -- itself, flavor line only. + -- PokemonFanClubSeelText (scripts/PokemonFanClub.asm:84): the same + -- shape with SEEL, PlayCry at :89. TEXT_POKEMONFANCLUB_SEEL = { - { "show_text", "_PokemonFanClubSeelText" }, + { "play_cry", "SEEL", true }, -- 1 PlayCry (#1649) + { "show_text", "_PokemonFanClubSeelText" }, -- 2 PrintText }, }, } +-- pokeyellow/scripts/PokemonFanClub.asm:149 PokemonFanClubClefairyText: Yellow's +-- pet is a CLEFAIRY on its own TEXT_POKEMONFANCLUB_CLEFAIRY, PlayCry at :154. +if require("src.core.GameVersion").isYellow() then + M.POKEMON_FAN_CLUB.talk.TEXT_POKEMONFANCLUB_CLEFAIRY = { + { "play_cry", "CLEFAIRY", true }, -- 1 PlayCry + { "show_text", "_PokemonFanClubClefairyText" }, -- 2 PrintText + } +end + return M diff --git a/data/scripts/flavor/ss_anne_1f_rooms.lua b/data/scripts/flavor/ss_anne_1f_rooms.lua index bde124b5..5d2fab56 100644 --- a/data/scripts/flavor/ss_anne_1f_rooms.lua +++ b/data/scripts/flavor/ss_anne_1f_rooms.lua @@ -1,10 +1,11 @@ --- pokered/scripts/SSAnne1FRooms.asm: SSAnne1FRoomsWigglytuffText --- text_far _SSAnne1FRoomsWigglytuffText; then ld a, WIGGLYTUFF / call PlayCry (cosmetic cry sound, not ported) +-- pokered/scripts/SSAnne1FRooms.asm:66 SSAnne1FRoomsWigglytuffText +-- text_far _SSAnne1FRoomsWigglytuffText, then ld a, WIGGLYTUFF / call PlayCry (:70) return { SS_ANNE_1F_ROOMS = { talk = { TEXT_SSANNE1FROOMS_WIGGLYTUFF = { {"face_player"}, + {"play_cry", "WIGGLYTUFF", true}, -- 1 PlayCry (#1687) {"show_text", "_SSAnne1FRoomsWigglytuffText"}, }, }, diff --git a/data/scripts/flavor/ss_anne_b1f_rooms.lua b/data/scripts/flavor/ss_anne_b1f_rooms.lua index 76f5bc3c..1c1038c3 100644 --- a/data/scripts/flavor/ss_anne_b1f_rooms.lua +++ b/data/scripts/flavor/ss_anne_b1f_rooms.lua @@ -1,12 +1,11 @@ --- pokered/scripts/SSAnneB1FRooms.asm: SSAnneB1FRoomsMachokeText --- text_far _SSAnneB1FRoomsMachokeText, then `ld a, MACHOKE / call PlayCry` --- (cry playback has no equivalent Commands.lua verb in this port, so only --- the flavor text is ported). +-- pokered/scripts/SSAnneB1FRooms.asm:82 SSAnneB1FRoomsMachokeText +-- text_far _SSAnneB1FRoomsMachokeText, then ld a, MACHOKE / call PlayCry (:86) return { SS_ANNE_B1F_ROOMS = { talk = { TEXT_SSANNEB1FROOMS_MACHOKE = { { "face_player" }, + { "play_cry", "MACHOKE", true }, -- 1 PlayCry (#1687) { "show_text", "_SSAnneB1FRoomsMachokeText" }, }, }, diff --git a/data/scripts/flavor/vermilion_city.lua b/data/scripts/flavor/vermilion_city.lua index c31d36fd..7c1bc24d 100644 --- a/data/scripts/flavor/vermilion_city.lua +++ b/data/scripts/flavor/vermilion_city.lua @@ -18,10 +18,11 @@ return { -- VermilionCityMachopText: cries out, then follows up with a -- second line about stomping the land flat. - -- (pokered/scripts/VermilionCity.asm) + -- (pokered/scripts/VermilionCity.asm:224, PlayCry at :228) TEXT_VERMILIONCITY_MACHOP = { - { "show_text", "_VermilionCityMachopText" }, -- 1 - { "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 2 + { "play_cry", "MACHOP", true }, -- 1 PlayCry (#1649) + { "show_text", "_VermilionCityMachopText" }, -- 2 + { "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 3 }, }, }, diff --git a/data/scripts/flavor/vermilion_pidgey_house.lua b/data/scripts/flavor/vermilion_pidgey_house.lua index 043c2c6b..29023bb5 100644 --- a/data/scripts/flavor/vermilion_pidgey_house.lua +++ b/data/scripts/flavor/vermilion_pidgey_house.lua @@ -1,14 +1,12 @@ --- pokered/scripts/VermilionPidgeyHouse.asm: VermilionPidgeyHousePidgeyText --- text_far _VermilionPidgeyHousePidgeyText, then text_asm plays the PIDGEY --- cry (ld a, PIDGEY / call PlayCry / call WaitForSoundToFinish) before --- TextScriptEnd. This port has no cry-playback command, so only the --- flavor line is ported. +-- pokered/scripts/VermilionPidgeyHouse.asm:15 VermilionPidgeyHousePidgeyText +-- text_far, then ld a, PIDGEY / call PlayCry (:19) / call WaitForSoundToFinish return { VERMILION_PIDGEY_HOUSE = { talk = { TEXT_VERMILIONPIDGEYHOUSE_PIDGEY = { {"face_player"}, + {"play_cry", "PIDGEY", true}, -- 1 PlayCry (#1649) {"show_text", "_VermilionPidgeyHousePidgeyText"}, }, }, diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 4dc2507e..372a1836 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -381,22 +381,35 @@ M.VERMILION_CITY = { return true end, talk = { - -- the sailor guarding the dock gangway (VermilionCitySailor1Text): - -- flashing the ticket just lets you through -- he never hides, and - -- once the ship has sailed he only reports it gone - TEXT_VERMILIONCITY_SAILOR1 = { - { "face_player" }, -- 1 - { "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2 - { "jump_if_true", 11 }, -- 3 - { "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4 - { "check_item", "S_S_TICKET" }, -- 5 - { "jump_if_false", 9 }, -- 6 - { "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7 - { "jump", 12 }, -- 8 - { "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 9 - { "jump", 12 }, -- 10 - { "show_text", "_VermilionCitySailor1ShipSetSailText" }, -- 11 - }, + -- scripts/VermilionCity.asm:158 (#1651) + TEXT_VERMILIONCITY_SAILOR1 = function(game, ow, npc, done) + local Flags = require("src.script.Flags") + local TextBox = require("src.render.TextBox") + local t = game.data.text + if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then + game.stack:push(TextBox.new(game, + t._VermilionCitySailor1ShipSetSailText or "The ship set sail.", + done)) + return + end + -- scripts/VermilionCity.asm:195 + local p = ow and ow.player + if not p or p.facing == "right" + or (p.cellX == 19 and (p.cellY == 29 or p.cellY == 31)) then + game.stack:push(TextBox.new(game, + t._VermilionCitySailor1WelcomeToSSAnneText + or "Welcome to S.S.\nANNE!", done)) + return + end + local ask = t._VermilionCitySailor1DoYouHaveATicketText + or "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?" + local tail = ((game.save.inventory.S_S_TICKET or 0) > 0) + and (t._VermilionCitySailor1FlashedTicketText + or "{PLAYER} flashed\nthe S.S.TICKET!") + or (t._VermilionCitySailor1YouNeedATicketText + or "You need a ticket\nto get aboard.") + game.stack:push(TextBox.new(game, ask .. "\f" .. tail, done)) + end, }, } @@ -405,13 +418,14 @@ M.SS_ANNE_2F = { TEXT_SSANNE2F_RIVAL = { { "face_player" }, -- 1 { "check_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 2 - { "jump_if_true", 9 }, -- 3 + { "jump_if_true", 9 }, -- 3 (beaten: silent) { "show_text", "_SSAnne2FRivalText" }, -- 4 - { "rival_battle", "OPP_RIVAL2", 1 }, -- 5 - { "jump_if_false", 10 }, -- 6 - { "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7 - { "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8 - { "jump", 10 }, -- 9 (already beaten: silent) + -- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers + -- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688) + { "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5 + { "rival_battle", "OPP_RIVAL2", 1 }, -- 6 + { "jump_if_false", 9 }, -- 7 + { "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8 }, }, } diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index 09fea14d..a28ae8a9 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -731,12 +731,35 @@ M.MT_MOON_B2F = { local function museumClerk(game, ow, done, onDecline) local TextBox = require("src.render.TextBox") local t = game.data.text or {} + local p = ow and ow.player + -- scripts/Museum1F.asm:45 (#1690) + if p and ((p.cellY == 4 and p.cellX == 13) + or (p.cellY == 3 and p.cellX == 12)) then + game.stack:push(TextBox.new(game, + t._Museum1FScientist1DoYouKnowWhatAmberIsText + or "You can't sneak\nin the back way!\fOh, whatever!\nDo you know what\vAMBER is?", + nil, { choice = function(yes) + game.stack:push(TextBox.new(game, yes + and (t._Museum1FScientist1TheresALabSomewhereText + or "There's a lab\nsomewhere trying\vto resurrect\vancient POKéMON\vfrom AMBER.") + or (t._Museum1FScientist1AmberIsFossilizedTreeSapText + or "AMBER is fossil-\nized tree sap."), done)) + end })) + return + end if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then game.stack:push(TextBox.new(game, t._Museum1FScientist1TakePlentyOfTimeText or "Take your time,\nand enjoy it all!", done)) return end + -- scripts/Museum1F.asm:58 + if p and p.cellY ~= 4 then + game.stack:push(TextBox.new(game, + t._Museum1FScientist1GoToOtherSideText + or "Please go to the\nother side!", done)) + return + end -- scripts/Museum1F.asm:72 local money = function() return game.save.money end game.stack:push(TextBox.new(game, diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index d2d4dbd5..b84aa405 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -9,20 +9,30 @@ local M = {} -- FuchsiaGoodRodHouse.asm, Route12SuperRodHouse.asm) -- ------------------------------------------------------------------- -local function rodGiver(askText, receivedText, afterText, rodItem, flag) - return { - { "face_player" }, -- 1 - { "check_flag", flag }, -- 2 - { "jump_if_true", 9 }, -- 3 - { "ask", askText }, -- 4 - { "jump_if_false", 10 }, -- 5 +-- refusedText is the .ThatsSoDisappointingText tail on NO; followText is +-- the second half of the received chain (scripts/VermilionOldRodHouse.asm:45) +local function rodGiver(askText, receivedText, afterText, rodItem, flag, + refusedText, followText) + local rows = { + { "face_player" }, + { "check_flag", flag }, + { "jump_if_true", "already_got" }, + { "ask", askText }, + { "jump_if_false", "refused" }, -- give-then-print like the three rod-house scripts (GiveItem fills -- wStringBuffer; the received texts read OLD/GOOD/SUPER ROD from it) - { "give_item", rodItem, 1, false }, -- 6 - { "show_text", receivedText }, -- 7 - { "set_flag", flag }, -- 8 - { "jump", 10 }, -- 9 is below + { "give_item", rodItem, 1, false }, + { "set_flag", flag }, + { "show_text", receivedText }, } + if followText then rows[#rows + 1] = { "show_text", followText } end + rows[#rows + 1] = { "jump", "end" } + rows[#rows + 1] = { "label", "refused" } + rows[#rows + 1] = { "show_text", refusedText } + rows[#rows + 1] = { "jump", "end" } + rows[#rows + 1] = { "label", "already_got" } + rows[#rows + 1] = { "show_text", afterText } + return rows end M.VERMILION_OLD_ROD_HOUSE = { @@ -31,11 +41,11 @@ M.VERMILION_OLD_ROD_HOUSE = { "_VermilionOldRodHouseFishingGuruDoYouLikeToFishText", "_VermilionOldRodHouseFishingGuruTakeThisText", "_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText", - "OLD_ROD", "EVENT_GOT_OLD_ROD"), + "OLD_ROD", "EVENT_GOT_OLD_ROD", + "_VermilionOldRodHouseFishingGuruThatsSoDisappointingText", + "_VermilionOldRodHouseFishingGuruFishingIsAWayOfLifeText"), }, } -M.VERMILION_OLD_ROD_HOUSE.talk.TEXT_VERMILIONOLDRODHOUSE_FISHING_GURU[9] = - { "show_text", "_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText" } M.FUCHSIA_GOOD_ROD_HOUSE = { talk = { @@ -43,11 +53,10 @@ M.FUCHSIA_GOOD_ROD_HOUSE = { "_FuchsiaGoodRodHouseFishingGuruText", "_FuchsiaGoodRodHouseFishingGuruReceivedGoodRodText", "_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText", - "GOOD_ROD", "EVENT_GOT_GOOD_ROD"), + "GOOD_ROD", "EVENT_GOT_GOOD_ROD", + "_FuchsiaGoodRodHouseFishingGuruThatsSoDisappointingText"), }, } -M.FUCHSIA_GOOD_ROD_HOUSE.talk.TEXT_FUCHSIAGOODRODHOUSE_FISHING_GURU[9] = - { "show_text", "_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText" } M.ROUTE_12_SUPER_ROD_HOUSE = { talk = { @@ -55,11 +64,11 @@ M.ROUTE_12_SUPER_ROD_HOUSE = { "_Route12SuperRodHouseFishingGuruDoYouLikeToFishText", "_Route12SuperRodHouseFishingGuruReceivedSuperRodText", "_Route12SuperRodHouseFishingGuruTryFishingText", - "SUPER_ROD", "EVENT_GOT_SUPER_ROD"), + "SUPER_ROD", "EVENT_GOT_SUPER_ROD", + "_Route12SuperRodHouseFishingGuruThatsDisappointingText", + "_Route12SuperRodHouseFishingGuruFishingWayOfLifeText"), }, } -M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] = - { "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" } -- ------------------------------------------------------------------- -- Pokemon Tower 5F purified zone (scripts/PokemonTower5F.asm @@ -893,16 +902,6 @@ local DOCK_SHIP_BLOCKS = { { bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 }, } --- her four hull columns bow-to-stern (upper-half / lower-half block ids) --- and the open-water ids of the rows she sits in -local DOCK_SHIP_COLUMNS = { - { bx = 5, top = 4, bottom = 8 }, - { bx = 6, top = 5, bottom = 9 }, - { bx = 7, top = 6, bottom = 10 }, - { bx = 8, top = 7, bottom = 11 }, -} -local DOCK_WATER_TOP, DOCK_WATER_BOTTOM = 1, 13 - M.VERMILION_DOCK = { onEnter = function(game, ow) local Flags = require("src.script.Flags") @@ -922,52 +921,29 @@ M.VERMILION_DOCK = { end)) elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then -- VermilionDockSSAnneLeavesScript: only stepping OFF the ship - -- triggers the departure (wDestinationWarpID == 1 in pokered) -- - -- Music_Surfing plays for the sail-away cutscene, smoke puffs - -- drift off the funnel, the horn blows, the ship is erased to - -- open water, and the player is walked off the dock into the - -- city past the guard (VermilionCity's - -- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk) + -- triggers the departure (wDestinationWarpID == 1 in pokered) Flags.set(game.save, "EVENT_SS_ANNE_LEFT") local Music = require("src.core.Music") Music.stop() Music.play(game.data, "Music_Surfing") - local function puff(n, cx) - if n <= 0 then return end - ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end) - end - puff(3, 15) - -- scripts/VermilionDock.asm:182-203 - local rows = {} - local function setBlock(bx, by, block) - if bx < 1 or bx > 8 then return end - rows[#rows + 1] = { "replace_block", bx, by, block } - end - rows[#rows + 1] = { "wait", 120 } - rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" } - -- .shift_columns_up slides her tile columns west behind a mid-frame - -- rSCX split; with no split scroll here she sails one block per beat - -- and the water closes in astern (#360) - for step = 1, 8 do - for _, col in ipairs(DOCK_SHIP_COLUMNS) do - setBlock(col.bx - step, 1, col.top) - setBlock(col.bx - step, 2, col.bottom) - end - setBlock(9 - step, 1, DOCK_WATER_TOP) - setBlock(9 - step, 2, DOCK_WATER_BOTTOM) - rows[#rows + 1] = { "wait", 20 } - end - -- the second horn as she clears the dock, then EraseSSAnne's 120 - -- frames before the walk out - rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" } - rows[#rows + 1] = { "wait", 120 } - rows[#rows + 1] = { "move_player", "up", 2 } - -- no keepMusic on this warp: Music_Surfing belongs to the dock's - -- cutscene, and VERMILION_CITY's own theme has to take over as the - -- player crosses in (EnterMap's PlayDefaultMusic) - rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" } - rows[#rows + 1] = { "move_player", "up", 2 } - ow:queueScript(rows) + ow:queueScript({ + -- scripts/VermilionDock.asm:50 zeroes the player image index and + -- :77 freezes sprite updates, so he faces DOWN throughout (#1689) + { "face_player_dir", "down" }, + { "wait", 120 }, + { "play_sound", "SS_Anne_Horn" }, + -- scripts/VermilionDock.asm:80 .shift_columns_up + { "ss_anne_departs" }, + -- scripts/VermilionDock.asm:205 VermilionDock_EraseSSAnne + { "play_sound", "SS_Anne_Horn" }, + { "wait", 120 }, + { "move_player", "up", 2 }, + -- no keepMusic on this warp: Music_Surfing belongs to the dock's + -- cutscene, and VERMILION_CITY's own theme has to take over as the + -- player crosses in (EnterMap's PlayDefaultMusic) + { "warp", "VERMILION_CITY", 18, 31, "up" }, + { "move_player", "up", 2 }, + }) end end, } diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index 98016848..87a4a4ff 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -922,10 +922,12 @@ M.SS_ANNE_2F = { { "move_npc_to", 2, 36, onLeft and 7 or 8 }, -- 2 { "face_object", 2, onLeft and "down" or "right" }, -- 3 { "show_text", "_SSAnne2FRivalText" }, -- 4 - { "rival_battle", "OPP_RIVAL2", 1 }, -- 5 - { "jump_if_false", 13 }, -- 6 - { "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7 - { "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8 + -- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers + -- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688) + { "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5 + { "rival_battle", "OPP_RIVAL2", 1 }, -- 6 + { "jump_if_false", 13 }, -- 7 + { "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8 { "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9 { "play_music", "Music_MeetRival", { start = "rival" } }, -- 10 { "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 11 diff --git a/docs/anbernic-rg34xxsp.md b/docs/anbernic-rg34xxsp.md index 441090ab..916aa25d 100644 --- a/docs/anbernic-rg34xxsp.md +++ b/docs/anbernic-rg34xxsp.md @@ -55,16 +55,6 @@ In-game controls use the normal PortMaster / SDL pad map, rebindable under ## Notes -**GBC FX is off on this device.** The launcher exports `POKEPORT_GBCFX=0`, -which hides the GBC FX row from OPTIONS, pins the level to OFF, and clears a -level carried over in an `options.lua` from another machine. The H700's Mali -GPU is in the same class as the phone GPUs that compile that present pass and -then show a black frame (issue #136), and `love.system.getOS()` reports -`"Linux"` here, so the Android gate would not have caught it. Every other -display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If -your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to -put the row back. - **PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults to AUTO, which reads this device as an ARM Linux handheld and resolves to **LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped, diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index 54f94243..1e42c91f 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -573,7 +573,7 @@ gains a field instead of the name gaining a prefix. at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it -- the logic tick before the pad is read, a pointer the touch overlay gets first refusal on, the palette zone list handed to the present pass, the - composed frame before GBCFX, the letterbox, and the finished playfield rect + composed frame before ShaderFX, the letterbox, and the finished playfield rect -- and carries the same payload. `render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale` diff --git a/docs/modding.md b/docs/modding.md index 7c724404..587b1fc4 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -806,7 +806,7 @@ contract, so a mod does not need a desktop-specific rendering path. `render.output_enabled` and `render.output` are the later, whole-window seam for mods that need the engine's normal composite rather than its separate -layers. It runs after registered present pipelines and before GBCFX, +layers. It runs after registered present pipelines and before ShaderFX, `render.hud`, and touch controls. A mod wraps both hooks: the first returns `true` only while output ownership is needed, and the second receives `(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`, diff --git a/docs/new-features.md b/docs/new-features.md index 9e07ed9a..f58475cc 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -16,6 +16,8 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Pokédex diploma and printer image exports** * **Shareable mod lists** over save sync, optionally carrying the options set for those mods, which the receiving device is asked about before anything is changed * **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 ## Gen 2 Specifics diff --git a/docs/shaderfx.md b/docs/shaderfx.md new file mode 100644 index 00000000..03e34c5b --- /dev/null +++ b/docs/shaderfx.md @@ -0,0 +1,673 @@ +# ShaderFX: runtime slang shader presets + +ShaderFX plays real libretro `.slangp` shader presets over the finished frame. +It replaced `src/render/GBCFX.lua`, a hand-ported fixed four-level effect, with +a picker: any preset the player drops in a folder, or any preset pulled from +the RetroArch buildbot, can be selected and run. Engine: +`src/render/ShaderFX.lua` (discovery, download, translation call, pass-graph +runtime, render entry point), `src/render/ShaderFixup.lua` (GLSL rewrites), +`src/render/ShaderSourcePatches.lua` (pre-translation source patches), +`src/core/Sensors.lua` (accelerometer and gyroscope), `src/ui/ShaderFXScreen.lua` +(the picker), `src/ui/ShaderFXParamsScreen.lua` (per-preset parameter editor), +`tools/shaderfx-bridge/` (the Rust translator). Call sites: +`src/render/Renderer.lua` (Gen 1) and `src/core/Game2.lua` (Gen 2), both at the +end of the frame. Drivers: `tests/drivers/gold_shaderfx_zoom_sizing_test.lua`, +`tests/drivers/gold_shaderfx_menu_black_crop_test.lua`. + +This is first-party engine code, not a mod. It calls the native translator +directly through `ffi.load`, with no `Sandbox.lua`, no `native` permission +declaration, and no mod boundary. Mods do not get that, and the distinction is +deliberate: the upstream maintainer will not accept `native` in mods. + +## What a player sees + +**OPTIONS** carries two rows, `SHADER FX` and `SHADER FX 2`. Each opens the +same pushed list screen (`ShaderFXScreen`) on a different slot. The list is +`OFF`, then every `.slangp` found on disk, then a permanent `DOWNLOAD SHADERS` +action row at the bottom. + +A preset that has never been translated draws muted with a `CONVERT` hint on +the right. `A` on that row translates it in place and stays open: converting is +a preparation step, not a selection. `A` on a converted row activates it, +persists the choice, and closes. `SELECT` on a converted row opens +`ShaderFXParamsScreen`, which lists every `#pragma parameter` the preset +declares and lets the player step each one (`A` wraps, Left/Right clamp, +`SELECT` resets one row, `START` resets all behind a confirm). + +Presets live in a plain OS folder, `shaders/` under the portable base directory +when running portable (`SaveData.portableBaseDir()`, the SD-card convention the +Anbernic pack uses, see `docs/anbernic-rg34xxsp.md`) and otherwise under LOVE's +save directory. `ShaderFX.list()` scans it recursively, so a shader pack keeps +whatever nested layout it shipped with. These are real filesystem paths rather +than `love.filesystem` virtual paths on purpose: the native translator does +plain `std::fs` reads and knows nothing about LOVE's mounts, and neither do the +LUT loads. + +Persisted state, all in `save.options`: + +| Key | Meaning | +| --- | --- | +| `shaderfx` | main slot's preset name, or absent for OFF | +| `shaderfxSecondary` | secondary slot's preset name | +| `shaderfxParams[name][paramId]` | one preset's edited pragma values | + +`POKEPORT_SHADERFX=` activates a preset in the main slot for scratch +harnesses that never call `ShaderFX.applyOptions`. It is a stand-in that +predates the real OPTIONS row; `applyOptions` marks itself as having run so the +env var can never later override a real player choice, including a real choice +of OFF. + +One quiet behavior worth knowing about: if a slot wants a real preset while +PERFORMANCE is still on AUTO, and AUTO would resolve to a tier that caps +ShaderFX off, `ShaderFX.applyOptions` pins PERFORMANCE to `HIGH`. Without it +the saved choice was force-deactivated a few lines later on every boot, which +looks identical to "the setting does not save" from the player's side. On +Android and iOS that is the common case, because AUTO always resolves to +`balanced` there. It never touches an already-explicit performance choice and +never un-escalates. + +## The five stages + +| Stage | Owner | +| --- | --- | +| Fetch | `ShaderFX.list`, `ShaderFX.downloadPresets`, `ShaderFX.installDownloaded` | +| Translate | `tools/shaderfx-bridge/` via `ShaderFX.translate` | +| Fixup | `src/render/ShaderFixup.lua` | +| Cache | `ShaderFX.convert` writes, `ShaderFX.load` reads | +| Run | `ShaderFX.runChain` / `runPass` / `ShaderFX.render` | + +### Fetch + +Two acquisition paths, and they land in the same place. A player can copy a +shader pack into `shaders/` by hand, or press `DOWNLOAD SHADERS`, which fetches +`https://buildbot.libretro.com/assets/frontend/shaders_slang.zip` (the same +~54 MB archive RetroArch's own "Update Shaders" entry pulls) through +`src/net/Fetch.lua`, the curl-on-a-`love.thread` transport the self-updater and +mod index already use. + +Downloaded presets are *not* pre-converted. The buildbot is RetroArch's asset +mirror and has no notion of this project's cache format, so a downloaded preset +goes through the same `CONVERT` row a hand-copied one does. Every platform +ships both convert and use; there is no asymmetry to work around. + +Repeat downloads are conditional. The zip itself is deleted right after +extraction, so what is cached instead is the buildbot's own ETag +(`shaderfx_buildbot.etag`), replayed as `If-None-Match`. The server answers a +match with 304 and no body, and curl writes no file at all in that case, so +`installDownloaded(notModified)` short-circuits before touching the filesystem +and reports "already up to date" rather than "FAILED". + +The interesting part is what gets extracted. `handheld/` is not self-contained: +its 78 presets carry 101 references that escape the folder (color-mod LUTs +into `../shaders/color/`, console-border helpers into `../../reshade/`, shared +motion-blur and misc helpers, a shared `stock.slang`) across roughly 40 of +them. Extracting `handheld/` alone silently breaks about a third of its own +list; extracting the whole zip drags in ~5600 files of CRT, arcade and console +content nobody asked for. So `extractClosure` walks the real file-level +dependency closure in Lua before a single file is copied, the same +`#reference`-closure idea librashader applies internally, done up front because +deciding what to copy has to happen before the translator ever sees these +files. Against this zip that is 207 files and about 9.5 MB with zero broken +references. The closure seeds from `KEPT_PRESETS`, a curated shortlist rather +than all 78, after most of `color-mod/` and `console-border/` turned out either +irrelevant (color-only, no LCD effect) or broken for this project. That list +is a temporary trim pending wider testing and is expected to change. + +Two mechanical details in that walk that are easy to get wrong a second time. +`extractRefs` tries a quoted `key = "path"` match per line first, with an +unrestricted `[^"]+` capture, because the closing quote is an unambiguous +delimiter and real packs ship paths with spaces and parentheses in them +(`"shaders/handheld/color-mod/Game Boy (Color).slang"`); only a line with no +quoted match falls back to a conservative character class, since an unquoted +path has no delimiter to trust past. And `love.filesystem.write` does not +create intermediate directories, so each destination directory is created once +before anything is written into it; without that, every file under a subfolder +`handheld/` never had before was silently dropped while flat writes succeeded. +Cleanup is equally literal: `love.filesystem.unmount()` takes the archive path +originally passed to `mount()`, not the mountpoint. Called with the mountpoint +it returns false, leaves the zip's handle open, and the following `remove()` +silently fails too, so every download used to leave 54 MB on disk forever. + +### Translate: the native bridge + +`tools/shaderfx-bridge/` is a small Rust crate (`spike`) that builds a cdylib +named `librashader_bridge`. It wraps `librashader-presets`, +`librashader-preprocess` and `librashader-reflect` behind a two-function C ABI: + +``` +char* librashader_translate_preset(const char* preset_path, int es); +void librashader_free_string(char* s); +``` + +It returns a JSON `TranslateResult`: `pass_count`, a `passes` array (each with +its emitted `vertex`/`fragment` GLSL, `filter`, `wrap_mode`, `scale_x`/ +`scale_y`, its own `#pragma parameter` declarations, a classified `samplers` +list and a classified `size_uniforms` list), the preset's `textures` (LUTs, +with resolved absolute paths and filter/wrap settings), its +`parameter_overrides`, and an `error` field. + +**What the bridge is not.** No librashader runtime backend is linked in, for +any API: no GL, Vulkan, D3D or Metal crate is a dependency, and no live +graphics context is ever touched. It is the translation step only. LOVE still +owns every draw call, every canvas and every shader object. The bridge hands +back text and metadata and nothing else. + +**When it is called.** Only from `ShaderFX.convert()`. Translation is ahead of +time, not just in time. `ShaderFX.load()`, `ShaderFX.activate()`, boot-time +reactivation of a saved choice and every frame of `ShaderFX.render()` read the +cached artifact and never call the library. An entry with no cached artifact +fails `activate()` loudly instead of silently live-translating. + +The classification is done with librashader's real semantics resolution rather +than name matching on the Lua side, and that matters for correctness, not just +tidiness. Sampler classification uses `ShaderSemantics::create_pass_semantics` +plus the `TextureSemanticMap` lookup (explicit alias and LUT-name entries +first, then the built-in `Source`/`Original`/`OriginalHistoryN`/`PassOutputN`/ +`PassFeedbackN` conventions), so a pass reachable only by its real `.slangp` +alias resolves. The per-pass convenience API only registers the alias of the +pass being compiled, so the bridge mirrors upstream's `insert_pass_semantics` +loop and builds a preset-wide alias map first; without that, +`ds-hybrid-scalefx.slangp`'s pass 2 sampling `scalefx_pass0` by alias, with no +`PassOutput1`-shaped name anywhere, can never resolve. Size-uniform +classification runs the same resolution over the pass's `uniform_semantics` +map, which also covers shapes (`PassFeedbackSizeN`, `UserSizeN`) that no +present preset uses but that the convention allows. + +The `es` flag picks the emitted dialect: 1 for GLSL ES 1.00 (mobile, LOVE's ES +dialect), 0 for GLSL 1.20 (LOVE's desktop dialect). `ShaderFX` picks it from +`love.system.getOS()`, and the same function decides which dialect +`validateShader` is asked about at run time, so the two always agree. Convert +and render always happen on the same device; **artifacts are not portable +across platforms**. + +One class of fixup has to happen in the bridge rather than in `ShaderFixup.lua`, +on the raw `.slang` text before SPIR-V compilation: `textureSize`, +`texelFetchOffset` and `textureOffset` are all ES 3.00+ only, and spirv-cross +refuses to *emit* them for an ES 1.00 target, failing the whole pass with +`UnsupportedSpirv("textureSize is not supported in ESSL 100.")`. No GLSL text +is ever produced for a later pass to patch, so `rewrite_essl100_gaps` rewrites +the source first, and only for the ES target: + +- `textureSize(Tex, lod)` becomes a literal `ivec2(w, h)` when `Tex` is one of + the preset's declared static textures, with dimensions read straight out of + each PNG's IHDR chunk. A texture that is not one of those, or a non-literal + `lod`, is left alone so it fails as loudly as before instead of guessing. +- `texelFetchOffset` and `textureOffset` become ordinary `texture()` calls at + the equivalent texel-centre UV, using the texture's own `Size.zw` + reciprocal-size uniform. The containing block instance (`params`, `global`, + whatever) is discovered by scanning the source's own uniform block bodies, + never assumed. When a pass samples another pass purely through these calls it + may never declare that `Size` uniform at all, so one is injected first, + before any byte offsets are computed. + +That last rewrite has an honest limit: `texture()` honours the sampler's wrap +mode at out-of-range coordinates, which is not necessarily identical to +`texelFetch`'s implementation-defined out-of-bounds behavior. Any edge-of-image +discrepancy for passes whose offsets can leave the image is unmeasured. + +**Building it.** `cargo build --release` inside `tools/shaderfx-bridge/`. +`ShaderFX` looks for the library, most specific first: the +`LIBRASHADER_BRIDGE_DLL` environment variable, the source directory, +`/tools/shaderfx-bridge/target/release/`, the save directory, and +finally the bare name handed to the system loader. Per-OS names are +`librashader_bridge.dll` (Windows), `liblibrashader_bridge.dylib` or +`librashader_bridge.dylib` (macOS), and `liblibrashader_bridge.so` or +`librashader_bridge.so` (Linux and Android). Android resolves the bare name +because the `.so` ships as an ordinary `jniLibs` entry, so `dlopen` finds it +without a path. The desktop path is still a developer build sitting in cargo's +output directory; nothing packages it next to a shipped game yet. +`ShaderFX.canConvert()` reports whether the library resolved on this machine, +and `ShaderFX.bridgeError()` says why not. Activating an already-converted +preset never needs any of this. + +### Fixup + +`ShaderFixup.lua` mechanically rewrites the emitted GLSL into something LOVE +will accept. librashader emits a standalone `void main()` /`gl_FragData[0]` / +`gl_Position` shape (translated Vulkan GLSL); LOVE requires the `effect()` and +`position()` convention and refuses a raw `main()`-shaped source outright. This +is a targeted rewriter, not a GLSL parser, and every rule below exists because a +real preset in the corpus failed without it. This list is the least guessable +part of the whole feature. + +**`#version` line.** Stripped; LOVE prepends its own. + +**Array constructors.** SPIR-V Cross emits ES 3.0 array-constructor syntax +(`const float _17[5] = float[](0.0, 1.0, ...)`) for compile-time array +literals, which validation rejects with "arrayed constructor: not supported for +this version". GLSL ES 1.00 has no array-constructor syntax at all. There are +three real shapes in the corpus and each is handled: a `const` global (declared +without an initializer at global scope, with per-element assignments relocated +to the top of `main()`), a non-const local declaration with initializer +(rewritten in place, since a function body can hold assignment statements where +the literal was), and a bare reassignment of an array declared elsewhere (also +in place). Splitting the element list needs `splitTopLevelCommas`, because a +naive comma split breaks on any element containing its own parentheses +(`vec2(-1.0, 0.0)`), and the outer capture needs `%b()` rather than a +`[^%)]-` class for the same reason: the class stops at the first inner `)`, the +whole match fails, and the literal passes through completely untouched. + +**Whole-array copies.** `float param_1[7] = coeffs;` is how SPIR-V Cross clones +a function-parameter array before passing it on, since GLSL array arguments are +by value. ES 1.00 has no whole-array assignment either, so it becomes a bare +declaration plus an element-by-element copy. The size is known from the +declaration, so no comma splitting is involved. This one only became reachable +once the other array shapes stopped masking it in the same file. + +**Integer modulo.** ES 1.00 has no `%` operator, and SPIR-V Cross emits it +anyway for an upstream integer-modulo op. `%` in GLSL is only defined for +integer operands, so routing through float `mod()` and back is exact for the +non-negative operands this shader family uses (rotation and orientation enum +indices). Four patterns are tried in order (paren/paren, paren/bare, +bare/paren, bare/bare) because SPIR-V Cross fully parenthesizes a compound +operand and leaves a simple one bare, and the balanced form must be tried +before a plain identifier can partially match. Seen live on +`authentic_gbc`'s subpixel-rotation math. + +**Precision.** SPIR-V Cross hardcodes an unguarded `precision highp float;` / +`precision highp int;` pair with no toggle. That is removed and replaced with +the same guard the old hand-written `DotMatrix` port used: claim `highp` only +where `GL_FRAGMENT_PRECISION_HIGH` says the driver actually offers fragment +highp, and fall through to the stage default otherwise. + +**Struct flattening.** This is the big one. LOVE's `Shader:send` cannot address +a member of a custom struct-typed uniform: neither an `INSTANCE.member` dot path +nor sending the whole struct as a table works, both raise "Shader uniform '...' +does not exist." librashader emits every pass's `#pragma parameter`s and size +uniforms as exactly that kind of struct, so as shipped the output is unusable +from LOVE, not merely inefficient. `flattenStruct` deletes the struct and its +instance uniform and re-declares the members at top level. Scalar members are +*packed* four at a time into synthetic `uniform vec4 LIBRA_PACKED_N;` slots, +because GLSL ES 1.00 guarantees only 16 fragment uniform *vectors* and every +scalar costs a whole one; `gb-pass4`'s pass 0 alone has 14 scalars, already over +budget unpacked. Non-scalar members keep their own uniform, since packing an +existing `vec4` saves nothing. Every `instance.member` reference is rewritten to +`LIBRA_PACKED_N.x` (or `.y`/`.z`/`.w`), and a member whose original declared +type was `int` or `bool` gets an explicit cast back on every read, since a +packed slot only stores floats. Vertex and fragment declare identically ordered +structs for the same parameters, so packing both with the same prefix assigns +the same slot and component to the same parameter in both stages, and one +`shader:send` reaches whichever stage uses it. + +Two ordering constraints inside that function are load bearing and look +arbitrary from the outside. Packing must walk the members in the struct's own +declaration order, because that order is what keeps the scalars in one +contiguous run; an earlier version sorted them longest-name-first before +packing and produced six packed vec4s instead of four on `gb-pass4`, blowing the +budget. Substitution, separately, must go longest-name-first, so a replacement +can never land as a substring inside a still-pending member name that shares a +prefix. The two orders are separate copies of the list for exactly that reason. + +`Fixup.packValues` turns a flat `{name = value}` table back into the +`{uniform = value_or_vec4}` shape the packed shader expects, per the manifest +`flattenStruct` returned. `Fixup.countUniformSlots` counts declared uniform +slots against that same 16-vector budget, samplers excluded. Its pattern uses +`[%w_]+` rather than `%w+` because Lua's `%w` does not include underscore +unlike regex `\w`, and every generated name here is full of underscores. + +**UBO blocks.** `LIBRA_UBO_FRAGMENT` and `LIBRA_UBO_VERTEX` have the identical +problem and are flattened the same way, under a distinct `LIBRA_UBO_PACKED_` +prefix so their groups cannot collide with the push block's numbering. `MVP` is +the one special member: it is substituted directly to `transform_projection` +instead of becoming a uniform, because "multiply the incoming vertex by it" is +exactly what LOVE's `transform_projection` already is on a full-screen draw, and +nothing would ever supply a value for it. An earlier version assumed the UBO +block only ever carried `MVP` and deleted the whole declaration after +substituting it. That is true only for presets that declare their parameters in +a push-constant block; presets that use a UBO instead (many real handheld and +console-border presets do) had every other member reference left dangling, and +the driver then read `INSTANCE.PAR` as a swizzle, which is where the "undeclared +identifier" and "unknown swizzle selection" errors on real Android hardware came +from. + +**Fragment entry point.** `void main()` becomes LOVE's `effect()` signature. +The parameter list is qualified by an `EFFECT_PREC` define rather than a literal +precision, because LOVE forward-declares `effect()`'s prototype under its own +header's precision default before this source runs, which can mismatch whatever +the precision guard above raises the default to. `Fixup.PREC_HEADS` holds the +two variants (`mediump`, then unqualified) and the caller tries them in order +against `validateShader`, taking the first that passes. + +**Fragment output.** `gl_FragData` does not exist in LOVE's `effect()` +convention, so every `gl_FragData[0]` occurrence is rewritten to a local +`gbFragColor`, declared at the top of the function, with a single `return` +appended before the closing brace. Rewriting *every* occurrence regardless of +the operator that follows is necessary, not just symmetric with the vertex side: +an earlier assign-then-return pair assumed one write at the very end of +`main()`, which holds for most presets but not for ones like `ds-hybrid-sabr` +that write once with `=` and later accumulate with `+=`. The `+=` statement +passed through unconverted and collided with LOVE's own `gl_FragColor` write +("Cannot use both gl_FragColor and gl_FragData"). A bare early `return;` is +rewritten to `return gbFragColor;`, which holds the value assigned just before +it on every real shape seen. + +**Vertex entry point.** `void main()` becomes `position(mat4 +transform_projection, vec4 vertex_position)`, the source's own `attribute` +redeclarations of `Position`/`TexCoord` are dropped since LOVE supplies them, +`gl_Position = X;` becomes `gbClipPos = X;` (named to share no substring with +`Position`, or the next step would mangle it), and a `return gbClipPos;` is +appended. The `Position` and `TexCoord` substitutions are frontier-matched +whole identifiers (`%f[%w]...%f[%W]`), not blind substring replacements: real +presets declare their own unrelated locals such as `vec2 vTexCoord;`, and a +blind `gsub` turned that declaration into the invalid `vec2 vVertexTexCoord.xy;` +(`dot.slangp` pass 0, a real driver "unexpected DOT" error). + +### Cache + +`ShaderFX.convert(entry)` is the only path that calls the bridge. It runs +`ShaderSourcePatches.apply` first, translates, then serializes the decoded +result to `ShaderFX.artifactPath(entry)`: the source `.slangp`'s own absolute +path with the extension swapped to `.lua`, so the artifact sits next to the +preset it came from. The file is a plain `return { ... }` chunk written by +`serializeLua`, which handles the string/number/boolean/nested-table shape +`Json.decode` produces. Array detection walks every key rather than trusting +`#t`, since `#t` counts a trailing nil as absent and a sparse table can pass a +naive length check by accident. + +`ShaderFX.load(entry)` `loadfile`s that chunk and builds a chain state. On +failure it says "convert this preset first" rather than falling back to a live +translation. + +**AOT rather than JIT** is the whole point of this stage. Translation is a rare, +explicit, user-initiated action whose result is stable for a given preset and +dialect, so paying for it once and writing the answer to disk keeps `ffi.load` +and the native call off every activation, every boot and every frame. The cost +is a staleness gap: `ShaderFX.isConverted()` is a plain "does the artifact file +exist" check with no version or content stamp, and there is no explicit +"reconvert" action in the UI. A preset converted by an older build never picks +up a later translator fix on its own. This was seen on a real device, where +`sunlight_shimmer.slangp`'s `Accelerometer` uniform never reached the shader +because that device's cache predated the fix while `pixel_transparency`'s +happened to be fresher. Two places compensate by reconverting unconditionally: +`ShaderFXScreen`'s explicit selection of an already-converted row, and +`ShaderFX.applyOptions` on every boot and options save. Both are human-paced, +CPU-only work with no GPU compile, and neither is on the per-frame path. + +`ShaderSourcePatches.lua` sits just before translation and patches the raw +`.slang`/`.inc` files *on disk*, because only librashader's own preset parser, +reading the real files, discovers `#pragma parameter` lines and struct members; +nothing downstream can add one. Patches are small, explicit, per-preset literal +find/replace pairs (plain `find`, not `gsub`, since GLSL source is full of Lua +pattern magic), re-applied idempotently on every convert so a buildbot +re-download that replaces the upstream file wholesale does not quietly undo +them. **The patch table ships empty on purpose and nothing registers one.** Its +original use case, wiring gyroscope yaw into `sunlight_shimmer.slangp` as new +`PT_YAW_*` pragma parameters, was reverted precisely because of the staleness +gap above: a new pragma can only reach an artifact that gets reconverted, and at +the time nothing forced one. The mechanism is kept for a future preset that +genuinely needs a new declaration, but an already-wired engine-side channel is +preferred whenever one exists. + +### Run: the pass graph + +`ShaderFX.activate(slot, entry, paramOverrides)` loads the artifact, layers the +player's edited parameters over the artifact's own defaults, loads the preset's +LUTs once, and snapshots the accelerometer rest pose. `ShaderFX.render` then +runs the chain each frame. + +`newChainState` builds one instance of chain-local state per loaded preset, +never module-global, so switching presets cannot leak a previous preset's +canvases or dimensions. `ALL_DEFAULTS` is built in layers: each pass's declared +`initial`, then the preset's own `parameter_overrides`, then (in `activate`) the +player's `shaderfxParams` edits. + +Sizes resolve through `resolveScale`, which handles all four slang scale types +(`absolute`, `viewport`, `source`, `original`) against the viewport, the pass's +input dimensions and the original frame. Size uniforms are packed as +`{w, h, 1/w, 1/h}`, the slang convention. + +`runPass` caches two things per `(state, pass index)`. The **shader** is +compiled once for the state's lifetime, along with the fragment manifest it was +compiled against, since a pass's GLSL depends only on the preset and never on +per-frame input. The **canvas** is reallocated only when the pass's resolved +size actually changes, a window resize or a different preset. Every harness this +runtime was ported from ran the chain once and quit, so allocating a fresh +canvas and compiling a fresh shader on every call was invisible there. On a real +per-frame render path it is one GPU allocation per pass per frame, and a shader +recompile on top. The same discipline applies to the crop canvas in +`cropToGbSource`, which is called once per frame and whose size grows with the +world canvas as the player zooms out; leaving it uncached was a real cost that +scaled with zoom level even with a single preset active. + +Sampler binding is by semantic, from the bridge's classification, never by a +hardcoded per-preset name check: `Source` is the previous pass's output (or the +input frame for pass 0), `Original` and `OriginalHistory` are the input frame, +`PassOutput` indexes an earlier pass's canvas, and `User` resolves a LUT by the +real name the translation reported. A sampler that resolves to nothing asserts +rather than drawing garbage. + +**LUTs** are loaded once per `activate`. `ShaderFX.loadImageFromPath` reads the +bytes with plain `io.open` and goes through `love.data.newByteData` and +`love.image.newImageData`, because a preset's texture paths are arbitrary +absolute OS paths outside any LOVE mount and `love.graphics.newImage` refuses +those outright ("Could not open file ... Does not exist") even when the file is +real. Wrap modes are mapped from librashader's names to LOVE's +(`clamp_to_border` to `clampzero`, `clamp_to_edge` to `clamp`, `repeat`, +`mirrored_repeat` to `mirroredrepeat`). A LUT that fails to load is logged and +left nil; the fail-loud point is the sampler assertion in `runPass` that +actually needed it, not the loader. + +**History ring.** `OriginalHistoryN` currently resolves to a steady state: every +history slot reads the current frame, both for the sampler binding and for the +size uniform. Real per-frame history rotation has been proven out in a desktop +harness but is not wired into this path. + +**Feedback.** `PassFeedback` is not implemented. A `PassFeedback` or `User` size +uniform raises an explicit "not yet supported" error, and a `PassFeedback` +sampler resolves to nothing and trips the binding assertion. No preset in the +shipped shortlist uses it. + +**Blending.** Every pass draws with `replace`, and the chain's final pass uses +`replace, premultiplied`. Intermediate canvases are `nearest` filtered. + +`ShaderFX.render(canvas, rect, source, dpiX, dpiY)` is the entry point +`Renderer:endFrame` and `Game2` call. `canvas` is the finished window-sized +composite (world, UI, and any post-process pipeline that already ran); `rect` is +this frame's real playfield rectangle in physical framebuffer pixels and +`source` is the real pixel size of the content it frames. The sequence is: crop +`rect` out of the composite, run whichever slots are active over that crop, draw +the untouched composite, then stretch the chain output back over `rect`. UI and +letterbox bars outside the playfield pass through untouched. If the chain throws, +the frame still shows the unprocessed composite; a broken preset degrades to +"shader off", never to a crash or a blank frame. + +Three details in that path are non-obvious: + +- **DPI.** `love.graphics.newCanvas` and `draw` work in LOVE's DPI-aware + logical units, not raw pixels, so the viewport handed to the pass graph and + the final draw-back position are both converted from `rect`'s physical pixels + first. On a `dpiscale = 1` desktop the two are numerically identical and the + bug is invisible; at dpiscale 3 on real Android hardware the chain output + rendered about three times too large and at a pixel-valued offset in unit + space. +- **Draw color.** `cropToGbSource` sets `setColor(1, 1, 1, 1)` explicitly. The + caller can leave the draw color dirty (a menu's black text leaves it at + `(0,0,0,x)`), the crop draw multiplies the canvas texels by the active color, + and `push("all")` saves state for `pop()` without resetting it. That was the + root cause of the Gen 2 blank-menu bug, confirmed on a desktop repro where + `getColor()` read `0,0,0,1` here exactly when a menu was on the stack. + `flushBatch()` on the line above is cheap insurance against a read-after-write + ordering hazard between this draw and whatever last rendered into the canvas; + it was never confirmed to fix anything on its own. +- **The final blit stretches.** A slang chain's last pass is not required to + land on the viewport size, and most presets (21 of the 78 in the corpus) + declare their last pass `scale_type = "source"` and stay at native Game Boy + resolution, relying on the frontend's blit exactly as RetroArch does. + Requiring an exact size match here used to skip the draw outright for every + such preset on every frame, which is a silent total no-op rather than a sizing + quirk. The stretch uses the last-run chain's own final-pass `filter` to pick + nearest or linear. + +### What this engine feeds shaders that a libretro core does not + +A stock libretro core hands its frontend a raw framebuffer and a frame count. +This engine has more context available and passes some of it through. + +| Context | How it reaches the shader | +| --- | --- | +| Playfield rect and true source size | `rect`/`source` per frame from `Renderer:endFrame` or `Game2`, so the chain sees real on-screen geometry at any survey zoom or Faithful Ratio state rather than a fixed 160x144 assumption that then gets stretched | +| Blit scale | `rect.scale`, the crisp integer scale the composite was built at, used to derive the crop's own draw scale | +| SGB zone coloring and palette | Baked into the input frame. `PaletteFX` zone passes run before the composite reaches ShaderFX, so a preset shades an already-zone-tinted image | +| Performance tier | `chainRenderScale()` reads `Performance.CAPS[tier].shaderfx`, a chain-resolution multiplier; the viewport and the cropped source both shrink by it and the final blit upscales | +| Accelerometer | `Sensors.read("accelerometer")`, bound to the `Accelerometer` unique semantic | +| Gyroscope | `Sensors.read("gyroscope")`, bound to `Gyroscope`, plus the integrated yaw twist below | + +Two motion semantics are deliberately pinned rather than guessed. `Rotation` is +bound to 0 because librashader's own documentation is explicit that it is +`retroarch_get_rotation()`, the *content's* requested rotation (a vertically +oriented arcade core, say), not device orientation. Nothing here ever rotates +Game Boy content, so 0 is the correct answer, not a placeholder. +`AccelerometerRest` is bound to `{0, 0, 0}`: it is librashader's "reading at +rest" calibration reference, no preset in the corpus reads it, and a fixed +placeholder beats an invented value. + +`src/core/Sensors.lua` is what makes the two real motion semantics work. +`love.sensor` does not exist in LOVE 11.5, the version this project ships, on +any platform including Android; it is a LOVE 12 addition. The working path is +raw FFI into the SDL2 that LOVE already links, the same technique +`src/core/Orientation.lua` uses, opening the first `SDL_SENSOR_ACCEL` or +`SDL_SENSOR_GYRO` device via `SDL_NumSensors`/`SDL_SensorGetDeviceType`/ +`SDL_SensorOpen`. The `love.sensor` path is kept above it and simply stops being +dead code after a future LOVE 12 upgrade. Loading order matters: +`ffi.load("SDL2")` first, needed on desktop where SDL2 is a separate DLL, then +bare `ffi.C`, needed on Android where love-android links SDL2 statically into +`libmain.so` and there is no `libSDL2.so` for `ffi.load` to find by name. A +device with no sensor is probed once and then permanently reports zeros, so a +desktop run does not pay for it every frame. + +SDL keeps sensor readings in the device's fixed chassis frame regardless of +screen orientation, so `rotateForScreen` remaps x and y into +"as currently displayed" terms using `SDL_GetDisplayOrientation`. That +compensation is mobile-only: a desktop monitor is legitimately and permanently +"landscape" to that query, which says something about the monitor's shape and +nothing about how a player is holding anything. + +The accelerometer path in `sizeTable` does three things to the raw reading +before it becomes a uniform, all of them driven by real on-device data: + +1. **Rest-pose subtraction.** `activate()` snapshots whatever pose the player is + actually holding the device in and every later reading is measured relative + to that, rather than to an assumed idealized vertical. The shipped tilt maths + (`pt_base.inc`'s `getOrientedTilt`) was authored assuming gravity sits almost + entirely on one axis at rest; a natural, comfortable hold already puts 56 to + 66 percent of gravity's magnitude on the axis the shader reads as tilt, so + the effect sat near-saturated all the time instead of starting near neutral. +2. **Axis swap.** The tilt maths assumes a device resting flat, with gravity + dominant on Z, the one axis it never reads. This engine's rest pose is + upright portrait, where Y is gravity-dominant, so y and z are swapped to put + gravity back on the ignored axis. +3. **Denominator stabilization.** `getOrientedTilt` normalizes by the full + vector's magnitude. Before calibration that magnitude was a stable ~9.8 that + quietly damped tilt and noise alike by the same factor; calibration correctly + zeroes x and y at neutral but also shrinks the magnitude near rest, and real + logs showed it swinging between 0.65 and 11.4 second to second on ordinary + hand jitter, which reads as wildly bouncing. A fixed constant is re-injected + on the ignored axis to keep the denominator stable, but only when there is a + genuine live reading to calibrate against. An all-zero raw read is + `Sensors.lua`'s explicit "no hardware at all" sentinel, never a real value on + Earth, and injecting into that case would make the shader believe it had + sensor data and silently replace its own static fallback with fake motion. + +Yaw is a separate mechanism. A raw gyroscope reading is angular velocity, not an +angle, so it only becomes a usable on-screen offset by integrating over time, +and only the per-slot state persists frame to frame to do that. `updateYawTwist` +is deliberately a decaying spring rather than a true integrated heading: +gyro-only integration drifts without a magnetometer to correct it, so this +settles back toward neutral and stays bounded by construction. It is folded onto +the accelerometer's x component before the shader's own normalize and clamp, +because that is the only already-compiled channel the stock upstream maths +reads, and reaching an already-converted artifact with no reconvert was worth +the tradeoff that the twist reads as an added simulated tilt rather than a +cleanly separate motion. It applies to `sunlight_shimmer.slangp` only, the one +preset in the shortlist with a twist-reactive channel. `YAW_GAIN = 0.6` and a +clamp of +/-2 were tuned against real device data (a moderate real yaw turn +peaks around 1.5 to 1.7 rad/s); a much larger gain was tried on-device and +looked worse, because overshooting a comfortable range reads worse than being +subtle. Retune in small steps with real device checks, not big jumps. + +## Two slots + +`ShaderFX.SLOTS` is `{"main", "secondary"}` and `ShaderFX.OPTION_KEY` maps each +to its save key. The slots are activated and persisted independently, and the +same `ShaderFXScreen` serves both, opened with the slot as its argument. Pragma +parameter edits are keyed by *preset name*, not by slot, because a preset's +values are a property of the preset the same way its cached artifact is; +editing them re-activates every slot currently showing that preset and persists +for the next load in either. + +When both slots are active, `render` runs main's chain first and hands its +finished output to secondary as secondary's own input frame, along with its +dimensions, so a secondary preset that scales off its input sees main's real +output size rather than the original crop. Either slot alone behaves exactly as +a single-preset path; neither active is a plain passthrough. + +**This is not how RetroArch composes multiple presets.** RetroArch merges +presets into a *single* pass list through `#reference` and `Append`, producing +one pass graph with one shared semantics map, where a later pass can reference +an earlier one's output by alias and the whole thing resolves as one unit. Two +slots here are two independent librashader chains run back to back, which is +what stacking two separate preset chains would give you, not what merging them +gives you. Presets that assume merged semantics will not behave the same way. + +## Test seams + +`ShaderFX` exposes a few fields purely so a headless harness can assert on real +per-frame values without taking a screenshot: `_lastRect` and `_lastSource` +(the rect and source dimensions a caller handed in), `_lastCrop` (the exact crop +canvas, which the later unconditional draw-back would otherwise mask), +`_lastYawTwist` and `_lastAccelPacked` (the integrated twist and the values that +actually reached the packed uniform, per slot). `Sensors.setOverride`, +`Sensors.clearOverride` and `Sensors.setOrientationOverride` inject synthetic +readings on a machine with no hardware. + +## Limitations + +None of these are theoretical. + +- **Tested on very little real hardware.** Essentially one Android phone, one + desktop, and the automated harnesses. Anything about how a preset actually + looks or performs elsewhere is unverified. +- **No performance tier is actually tuned.** The chain-resolution multiplier in + `Performance.CAPS` is a working mechanism, but every tier that permits + ShaderFX at all sets it to 1.0. Nothing runs at reduced chain resolution + today. Picking a real value for weak hardware needs a device this project does + not have. +- **The dual-slot design does not match RetroArch.** See above. Two chains in + sequence is not one merged pass list. +- **`OriginalHistoryN` is a steady state.** Real per-frame history rotation + falls back to "every slot is the current frame" in the live render path, and + is unverified there. +- **`PassFeedback` is unimplemented.** Its size uniform raises an explicit + error and its sampler trips an assertion. +- **`ShaderFXScreen` has a known text-overlap bug on long preset names.** + `ListMenu`'s `fitLabel` truncation covers the ordinary case, but a long enough + player-supplied filename still collides with the row's right-hand hint. +- **`ShaderSourcePatches` ships with an empty patch table and nothing uses it.** + Intentional, for the reason given above, but it means the mechanism has no + live coverage. +- **Cached artifacts have no staleness detection.** Existence is the only check. + The two unconditional reconvert points paper over it; anything that does not + go through them can be running a stale translation. +- **Artifacts are per-device.** The GLSL dialect is baked in at convert time. + Copying a converted preset folder between a phone and a desktop copies a + wrong artifact along with it. +- **The bundled bridge is only as good as the build machine.** + `scripts/build.sh` bundles the cdylib for mac, win and linux via + `bundle_shader_bridge`, building it with cargo when a prebuilt one is not + supplied through `SHADERFX_BRIDGE`. A build host without cargo produces a + package that can run converted presets but cannot CONVERT new ones, and says + so rather than failing. Android ships the `.so` via `jniLibs`. +- **The buildbot shortlist is a temporary trim.** `KEPT_PRESETS` reflects one + manual pass over `handheld/` and is expected to change, most likely to shrink. +- **Tilt direction is unverified.** Which way forward and back rocking moves the + effect was never confirmed on a device; if it feels backwards the fix is a + sign flip on the swapped axis, not a deeper bug. Likewise, whether the + landscape rotation compensation matches real RetroArch is genuinely unknown: + RetroArch's Android input driver computes a screen rotation but does not + visibly apply it to the accelerometer values that reach shader uniforms, so + this project's compensation may be an improvement over upstream rather than a + match to it. +- **`texelFetch` wrap behavior at image edges may differ.** The ES 1.00 rewrite + in the bridge turns those calls into `texture()`, which honours the sampler's + wrap mode out of range where `texelFetch`'s out-of-bounds behavior is + implementation defined. Unmeasured. diff --git a/docs/switch-install.md b/docs/switch-install.md index a9ad0ade..282d5cc3 100644 --- a/docs/switch-install.md +++ b/docs/switch-install.md @@ -183,7 +183,6 @@ hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs). | ----- | -------------- | ------------------- | | Select + **A** | `2` | COLORS | | Select + **B** | `3` | TILT | -| Select + **Y** | `5` | GBC FX | | Select + **X** | `6` | Mod pipeline hotkey (if a mod registers `6`) | | Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) | diff --git a/main.lua b/main.lua index ee5b41f8..8880c075 100644 --- a/main.lua +++ b/main.lua @@ -374,6 +374,11 @@ local function returnToLauncher() Game = nil autopilot = nil driverCo = nil + -- Leave the cart's scope behind: the launcher's own settings and slots are + -- the base game's, not the cart's. The speed ladder is cart state too, so + -- a 1x/2x cart must not pin the launcher or the next game. + require("src.core.SaveData").setCart(nil) + require("src.core.GameSpeed").setAllowed(nil) local Input = require("src.core.Input") local TouchControls = require("src.core.TouchControls") @@ -407,15 +412,24 @@ function bootGame(version, cartId) -- (Blue/Yellow/Gold caches live under blue/ / yellow/ / gold/). CacheFs.prefix = GameVersion.cachePrefix() CacheFs.mountVersion(GameVersion.get()) - local cartHash + local cartHash, cartSpeeds, cartOptions if cartId then local ok, cart, hash = pcall(function() return require("src.carts.CartStore").get(cartId) end) - if ok and cart then cartHash = hash else cartId = nil end + if ok and cart then + cartHash, cartSpeeds, cartOptions = hash, cart.speeds, cart.options + else + cartId = nil + end end local SaveData = require("src.core.SaveData") SaveData.setCart(cartId, cartHash) + -- The author's settings land in the cart's own scope the first time only; + -- after that the player owns them. + if cartOptions then SaveData.seedCartOptions(cartOptions) end + -- A cart may narrow or pin the speed ladder; nil restores the full one. + require("src.core.GameSpeed").setAllowed(cartSpeeds) if cartId then SaveData.adoptCartSeal(cartId) end -- NX: always write nx-asset-probe.log so Yellow/Blue art failures are -- diagnosable from the SD without enabling switch-debug.txt. diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 2c7d8798..6c8010de 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -30,6 +30,10 @@ + + /dev/null 2>&1; then + say "building the ShaderFX bridge with cargo" + if (cd "$crate" && cargo build --release >/dev/null 2>&1); then + src="$crate/target/release/$name" + fi + fi + if [ -n "$src" ] && [ -f "$src" ]; then + mkdir -p "$dest" + cp "$src" "$dest/$name" + say "bundled $name for SHADER FX preset conversion" + else + warn "$name not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE or install cargo)" + fi +} + build_mac() { say "building macOS app" local love_app="${LOVE_APP:-/Applications/love.app}" @@ -180,6 +205,7 @@ build_mac() { # drop any bundled placeholder .love and fuse ours in find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love" + bundle_shader_bridge "$out_app/Contents/MacOS" "liblibrashader_bridge.dylib" local plist="$out_app/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$plist" 2>/dev/null \ @@ -293,9 +319,11 @@ build_win() { cp "$tls_dll" "$out_dir/gen1tls.dll" say "bundled gen1tls.dll for Windows TLS (wss://)" else - warn "gen1tls.dll not found — Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)" + warn "gen1tls.dll not found: Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)" fi + bundle_shader_bridge "$out_dir" "librashader_bridge.dll" + # The exe's icon lives in love.exe's PE resources, so it must be patched # BEFORE the .love is appended: peresed rewrites the whole file and would # drop the fused bytes. peresed (pipx install pe_tools) has no .ico input, @@ -391,6 +419,7 @@ build_linux() { unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null cp "$LOVE_FILE" "$appdir/game.love" + bundle_shader_bridge "$appdir" "liblibrashader_bridge.so" # Replace LÖVE's own desktop entry rather than keeping it: it says # Name=LÖVE / Icon=love, which is what appimaged, app menus and file diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 4389548f..7451e87a 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -2,11 +2,22 @@ # Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a. # # Usage: scripts/build_android.sh [--version X.Y.Z] [--release] [--package-only] +# [--test-application-id ID] # -# --version X.Y.Z set app.version_name / app.version_code (else left as-is) -# --release build the production-signed release APK (requires the -# GEN1RECOMP_ANDROID_* signing environment variables) -# --package-only zip game.love + apply branding; skip gradle +# --version X.Y.Z set app.version_name / app.version_code (else left as-is) +# --release build the production-signed release APK (requires the +# GEN1RECOMP_ANDROID_* signing environment variables) +# --package-only zip game.love + apply branding; skip gradle +# --test-application-id ID install under a distinct application id (e.g. +# com.theboisclub.pokemonred.shaderfxtest) so a +# test build installs side by side with a real +# played copy instead of overwriting it. App name +# gets a " (test)" suffix so it's distinguishable +# in the launcher too. Without this flag, +# apply_android_branding always writes back the +# real shipping identity, which previously had to +# be restored by hand in gradle.properties after +# every test build. # # Prerequisites: # - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md) @@ -40,6 +51,7 @@ CRYSTAL_MANIFEST_URL="${CRYSTAL_MANIFEST_URL:-https://raw.githubusercontent.com/ VERSION="" PACKAGE_ONLY=false RELEASE=false +TEST_APPLICATION_ID="" say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; } @@ -50,11 +62,12 @@ while [ $# -gt 0 ]; do --version) VERSION="$2"; shift ;; --package-only) PACKAGE_ONLY=true ;; --release) RELEASE=true ;; + --test-application-id) TEST_APPLICATION_ID="$2"; shift ;; -h|--help) - sed -n '2,20p' "$0" + sed -n '2,28p' "$0" exit 0 ;; - *) fail "unknown argument: $1 (try --version X.Y.Z, --release, or --package-only)" ;; + *) fail "unknown argument: $1 (try --version X.Y.Z, --release, --package-only, or --test-application-id ID)" ;; esac shift done @@ -86,6 +99,14 @@ if $RELEASE; then || fail "Android signing keystore does not exist: $GEN1RECOMP_ANDROID_KEYSTORE" fi +if [ -n "$TEST_APPLICATION_ID" ]; then + if ! printf '%s' "$TEST_APPLICATION_ID" | grep -Eq '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$'; then + fail "invalid --test-application-id '$TEST_APPLICATION_ID' (expected a dotted package id, e.g. com.theboisclub.pokemonred.shaderfxtest)" + fi + APPLICATION_ID="$TEST_APPLICATION_ID" + APP_NAME="$APP_NAME (test)" +fi + # --------------------------------------------------------------- preconditions if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then fail "love-android not found at mobile/android/. diff --git a/src/battle/AnimPlayer.lua b/src/battle/AnimPlayer.lua index ef2b9b00..7f305432 100644 --- a/src/battle/AnimPlayer.lua +++ b/src/battle/AnimPlayer.lua @@ -348,6 +348,12 @@ local EMITTERS = { SE_PETALS_FALLING = function() return fallingObjectSteps(20, PETAL_TILE, "e4") end, } +-- home/copy2.asm:62 CopyVideoData -- 8 tiles a frame plus the tail frame +local function tilesetLoadFrames(data, ts) + local sheet = data and data.tilesheets and data.tilesheets[ts or 0] + return math.floor((sheet and sheet.tiles or 79) / 8) + 1 +end + -- One OAM entry for tile `t` of a frame block anchored at base coord `bc`, -- with the subanimation transform applied (DrawFrameBlock). local function placeTile(transform, bc, t, tileset) @@ -456,6 +462,11 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts) local obp0Flip = false for _, row in ipairs(anim.seq) do + -- engine/battle/animations.asm:252 LoadMoveAnimationTiles, before the + -- row's sound and its first frame block (#1653) + if not row.effect then + emit(tilesetLoadFrames(self.data, row.tileset)) + end -- PlayAnimation/PlaySubanimation: each row's sound byte is a move id -- whose MoveSoundTable entry (sfx + pitch/tempo modifiers) plays as -- the row starts (GetMoveSound) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index bae8aaa7..b47abf01 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -638,6 +638,9 @@ local function newBattle(game) -- wPartyAndBillsPCSavedMenuItem, so entering a battle drops the party -- cursor the field menu has been carrying (src/ui/PartyMenu.lua). #768 game.partyMenuSavedIndex = nil + -- the same run covers wBagSavedMenuItem, and wListScrollOffset follows it + -- (init_battle_variables.asm:7-12) #1732 + game.bagSavedMenuItem, game.bagListScrollOffset = nil, nil self.data = game.data -- ruleset from the merged registry (the requires above are the same -- records on a mod-free boot); an unknown save value falls back to the @@ -1918,6 +1921,8 @@ function BattleState:exit() -- end_of_battle.asm clears wPartyAndBillsPCSavedMenuItem as well, so the -- field party menu comes back on slot 1 after a battle. #768 self.game.partyMenuSavedIndex = nil + -- and wBagSavedMenuItem / wListScrollOffset (end_of_battle.asm:57-62) #1732 + self.game.bagSavedMenuItem, self.game.bagListScrollOffset = nil, nil -- Free this battle's own GPU objects now rather than waiting on a GC -- finalizer: the two full-screen wavy-effect canvases (colorMode) and -- the AnimPlayer's per-instance tilesheet images/quads. The shared @@ -1942,6 +1947,7 @@ local function clearTrapping(battler) battler.trappingTurns = nil battler.trapMove = nil battler.trapDamage = nil + battler.trapHitSfx = nil end -- SendOutMon (core.asm:1733-1735) clears both battle cursors, though the @@ -4070,12 +4076,15 @@ function BattleState:continueTrapping(user, target) -- .MultiturnMoveCheck (core.asm:3554-3566) prints AttackContinuesText -- then jumps to GetPlayerAnimationType, so the trapping move's full -- animation replays each locked turn (same damage, animation shown). - -- Mirror performMove's anim row (BattleState.lua ~1307), gated on the - -- OPTIONS animation toggle. - if user.trapMove and self:animationsOn() then + -- Mirror performMove's anim row (BattleState.lua ~1307), with the + -- applying-attack shake GetPlayerAnimationType picks (core.asm:3159 / + -- :5555 -- a trapping move's effect is nonzero, so type 5 / 2) (#1653). + if user.trapMove then self.nextInsert = (self.nextInsert or 0) + 1 table.insert(self.queue, self.nextInsert, - { anim = user.trapMove, attackerIsPlayer = user.isPlayer }) + { anim = user.trapMove, attackerIsPlayer = user.isPlayer, + hit = { animType = user.isPlayer and 5 or 2, + sfx = user.trapHitSfx } }) end -- the counter can sit at 0 until the END of the turn: the trapping -- bit is only cleared by CheckNumAttacksLeft (core.asm:439/467) @@ -4250,7 +4259,7 @@ function BattleState:awardExp() local levels, gained = Experience.apply(self.data, mon, self.enemy.def, self.enemy.mon.level, self.kind == "trainer", split, traded) - -- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle -> + -- Track level-ups for EvolveAfterBattle (BattleState:finish -> -- Evolution.checkParty). B-cancel leaves the mon at/above threshold; -- without this gate it re-triggers after every later fight (#213). if #levels > 0 then @@ -4627,6 +4636,14 @@ function BattleState:playerMonFainted() -- Exception: the Oak's Lab starter rival (HandlePlayerBlackOut). if not nextMon and self.result ~= "lose" then if self.oppClass == "OPP_RIVAL1" then + -- HandlePlayerBlackOut (core.asm:1139-1146): ClearScreenArea, the pic + -- scroll-in and DelayFrames 40 all run before Rival1WinText (#1721) + self:actNext(function() + self.showEnemyTrainer = self.trainerPic ~= nil + if self.showEnemyTrainer then self:slidePic("foe", 64, 16, 2) end + end) + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, { wait = 64 }) local TextBox = require("src.render.TextBox") local raw = (self.data.text and self.data.text._Rival1WinText) or Strings("{RIVAL}: Yeah! Am\nI great or what?") @@ -5216,6 +5233,14 @@ function BattleState:finish() self.phase = "messages" return end + -- EndOfBattle runs EvolutionAfterBattle on the battle screen, before the + -- GBPalWhiteOut back to the map (end_of_battle.asm:42-45) (#1656, #213) + if not self.evolutionsChecked then + self.evolutionsChecked = true + require("src.pokemon.Evolution").checkParty(self.game, + function() self:finish() end, self.leveledUp) + return + end -- Invariant: a battle can never hand the overworld a party with nothing -- healthy in it -- except the Oak's Lab starter rival, where pret skips -- the blackout and OaksLabRivalEndBattleScript HealParty's immediately. @@ -5256,6 +5281,13 @@ function BattleState:finish() local result = self.result or "run" local onFinish = self.onFinish if result == "lose" then + -- .battleOccurred skips the faint check in OAKS_LAB and re-enters the map + -- through MapEntryAfterBattle (home/overworld.asm:343-352) (#1721) + if BattleState.isOaksLabStarterRival(self) then + self.game.stack:push(require("src.render.Transition").battleReturn( + self.game, function() if onFinish then onFinish(result) end end)) + return + end -- the blackout path warps to the heal point with its own transition if onFinish then onFinish(result) end return @@ -6120,8 +6152,10 @@ function BattleState:drawHUDs(slide) self:drawBallRow(self:playerPartyView(), 88, 80, 8) end local hidePlayer = self.safari or self.demo + -- RemoveFaintedPlayerMon clears the player HUD (core.asm:1024-1026) and + -- nothing redraws it until the next SendOutMon (#1721) if showStatus and self.player and not hidePlayer and not self.showPlayerBack - and slide == 0 then + and slide == 0 and not self.player.fainted then -- player HUD (DrawPlayerHUDAndHPBar): name (10,7), +level -- (14,8), HP bar (10,9), HP numbers row 10, underline row 11 with -- the tick at (18,10) and the triangle at (9,11) diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 525a6c58..999a6637 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -260,6 +260,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) local totalDealt = 0 local landed, brokeSub = 0, false + local critPending, ohkoPending = info.crit, info.ohko for h = 1, hits do if target.mon.hp <= 0 then break end local hitRow @@ -280,12 +281,16 @@ function EffectRegistry.runDamaging(battle, ctx, record) totalDealt = totalDealt + dealt landed = h if dealt > 0 then hitRow.hit = hitFx end - -- PrintCriticalOHKOText + DisplayEffectiveness run inside the - -- multi-hit loop (core.asm .moveDidNotMiss before the jump back - -- to GetPlayerAnimationType), so crit/effectiveness reprint on - -- every strike -- damage was only rolled once - if info.crit then battle:sayNext(romText(battle.data, "_CriticalHitText", "Critical hit!")) end - if info.ohko then battle:sayNext(romText(battle.data, "_OHKOText", "One-hit KO!")) end + -- PrintCriticalOHKOText zeroes wCriticalHitOrOHKO after printing + -- (core.asm:3809-3811); DisplayEffectiveness re-reads its own flag (#1720) + if critPending then + battle:sayNext(romText(battle.data, "_CriticalHitText", "Critical hit!")) + critPending = false + end + if ohkoPending then + battle:sayNext(romText(battle.data, "_OHKOText", "One-hit KO!")) + ohkoPending = false + end -- PrintCriticalOHKOText closes with `ld c, 20 / jp DelayFrames` at its -- .done label (core.asm:3812-3814) -- and the no-crit path jumps to that -- same label (:3799), so this hold is paid on EVERY landed hit, not just @@ -323,6 +328,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- post-damage effect bookkeeping (recoil/drain/trap/thrash/...) ctx.rawDamage, ctx.totalDealt = dmg, totalDealt ctx.brokeSub, ctx.hits = brokeSub, hits + ctx.hitSfx = hitSfx if record and record.afterDamage then record.afterDamage(ctx, totalDealt) elseif moveInst.struggle then diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 2dfdc191..39d96387 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -574,6 +574,8 @@ MoveEffects.full = { local r = ctx.rng(0, 7) user.trappingTurns = ({ 1, 1, 1, 2, 2, 2, 3, 4 })[r + 1] user.trapDamage = ctx.rawDamage + -- PlayApplyingAttackSound (animations.asm:2639) replays it each turn + user.trapHitSfx = ctx.hitSfx -- remember the move so its animation can replay on each locked -- continuation (core.asm:3554-3566 -> GetPlayerAnimationType) user.trapMove = ctx.move.id diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 75df4b6f..ad459574 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -161,8 +161,10 @@ local function drawHUDs(battle, slide) -- item, not a HUD element (DisplayBattleMenu prints wNumSafariBalls inside -- the battle menu box, engine/battle/core.asm:2074-2079), so it rides in -- drawCommandMenu below like the classic layout's (#540). + -- RemoveFaintedPlayerMon clears the player HUD (core.asm:1024-1026) (#1721) if showStatus and not battle.safari and battle.player and not battle.demo - and not battle.showPlayerBack and slide == 0 then + and not battle.showPlayerBack and slide == 0 + and not battle.player.fainted then drawStatusPanel(battle, battle.player, 184, 56, true) end end diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 2cc93065..2b0b0543 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -16,7 +16,7 @@ -- -- Status handling follows Gen 2's rules rather than Gen 1's: burn is 1/8 max HP -- (not 1/16) and halves physical Attack, poison is 1/8, and sleep counts down --- from 1-7 turns. +-- from 2-7 turns. local Damage = require("src.battle.gen2.Damage") local Ai = require("src.battle.gen2.Ai") @@ -254,6 +254,9 @@ function Battle.new(opts) -- around the Tower's own StartBattle (engine/events/battle_tower/ -- battle_tower.asm:220-223) and cleared again at :253-254. self.inBattleTowerBattle = opts.battleTower and true or false + -- wTimeOfDay: only BattleCommand_TimeBasedHealContinue reads it in battle + -- (engine/battle/effect_commands.asm:6401-6404). + self.timeOfDay = opts.timeOfDay self.events = {} self.turn = 0 self.over = false @@ -2469,7 +2472,7 @@ end -- BattleCommand_TimeBasedHealContinue (effect_commands.asm:6374) answers the -- same two lines BattleCommand_Heal does: HPIsFullText (:6447) and -- RegainedHealthText (:6440). -for effect in pairs(Effects.SUN_HEAL) do +for effect, wants in pairs(Effects.SUN_HEAL) do Battle.MOVE_EFFECTS[effect] = function(self, attacker) local maxHp = attacker.maxHp or (attacker.stats and attacker.stats.hp) or 1 if (attacker.hp or 0) >= maxHp then @@ -2478,7 +2481,9 @@ for effect in pairs(Effects.SUN_HEAL) do text = self:monName(attacker) .. "'s HP is full!" }) return end - local fraction = Effects.weatherHealFraction(self.weather) + -- effect_commands.asm:6396-6417, the time of day and the weather (#1751). + local fraction = Effects.timeBasedHealFraction(self.weather, wants, + self.timeOfDay) self:heal(attacker, math.max(1, math.floor(maxHp * fraction))) self:emit({ kind = "message", text = self:monName(attacker) .. " regained health!" }) @@ -2925,9 +2930,12 @@ Battle.STATUSES = { id = "sleep", label = "SLP", hudLabel = "SLP", healClass = "slp", inflictText = " fell asleep!", catchBonus = 10, catchBonusIntended = 10, - -- BattleCommand_Sleep rolls a 3-bit value, retried until nonzero. + -- BattleCommand_SleepTarget's .random_loop rerolls 0 and SLP_MASK before + -- `inc a`, so sleep opens at 2 (effect_commands.asm:3591-3598, #1707). + -- Crystal masks the roll to %011 in the Battle Tower, capping it at 4 + -- (../pokecrystal/engine/battle/effect_commands.asm:3609-3613). onInflict = function(battle, mon) - mon.statusTurns = rand(battle.random, 7) + 1 + mon.statusTurns = rand(battle.random, battle.inBattleTowerBattle and 3 or 6) + 2 end, beforeMovePriority = 40, beforeMove = function(battle, mon, name) diff --git a/src/battle/gen2/Effects.lua b/src/battle/gen2/Effects.lua index 00dc8c13..a36f8ea3 100644 --- a/src/battle/gen2/Effects.lua +++ b/src/battle/gen2/Effects.lua @@ -267,7 +267,7 @@ Effects.MAGNITUDE_POWER = { -- power and the magnitude number the text prints. -- -- `random` is BattleRandom (0..n-1). If none is supplied, roll via love.math --- / math.random — never hard-code 0 (that always yields Magnitude 4). +-- / math.random -- never hard-code 0 (that always yields Magnitude 4). function Effects.magnitudePower(random) local roll if type(random) == "function" then @@ -356,19 +356,38 @@ function Effects.sandstormHits(types) return true end --- Morning Sun / Synthesis / Moonlight heal a HALF normally, and the weather --- shifts that one step either way: x2 in sun, /2 in rain or sandstorm --- (BattleCommand_Heal's .Weather block walks a multiplier index). +-- BattleCommand_TimeBasedHealContinue's .Multipliers +-- (engine/battle/effect_commands.asm:6450-6454). +Effects.HEAL_MULTIPLIERS = { 1 / 8, 1 / 4, 1 / 2, 1 } + +-- wTimeOfDay (constants/ram_constants.asm:134-139): MORN_F 0, DAY_F 1, +-- NITE_F 2, DARKNESS_F 3. +Effects.TIME_OF_DAY_ID = { MORN = 0, DAY = 1, NITE = 2, DARK = 3 } + +-- Morning Sun / Synthesis / Moonlight and the wTimeOfDay each one wants +-- (engine/battle/effect_commands.asm:6362-6371). Effects.SUN_HEAL = { - EFFECT_MORNING_SUN = true, - EFFECT_SYNTHESIS = true, - EFFECT_MOONLIGHT = true, + EFFECT_MORNING_SUN = 0, + EFFECT_SYNTHESIS = 1, + EFFECT_MOONLIGHT = 2, } -function Effects.weatherHealFraction(weather) - if weather == "sun" then return 2 / 3 end - if weather == "rain" or weather == "sandstorm" then return 1 / 4 end - return 1 / 2 +function Effects.timeOfDayIndex(timeOfDay) + if type(timeOfDay) == "number" then return math.floor(timeOfDay) end + return Effects.TIME_OF_DAY_ID[timeOfDay] +end + +-- engine/battle/effect_commands.asm:6388-6417: the index opens at a half, the +-- wrong time of day steps it down, sun steps it up, rain and sandstorm down. +function Effects.timeBasedHealFraction(weather, wants, timeOfDay) + local index = 3 + local now = Effects.timeOfDayIndex(timeOfDay) + if wants ~= nil and now ~= nil and now ~= wants then index = index - 1 end + if weather then + index = index + 1 + if weather ~= "sun" then index = index - 2 end + end + return Effects.HEAL_MULTIPLIERS[math.max(1, math.min(4, index))] end -- ---------------------------------------------------------------- Perish Song diff --git a/src/carts/CartManifest.lua b/src/carts/CartManifest.lua index cb27e68a..40c91d5b 100644 --- a/src/carts/CartManifest.lua +++ b/src/carts/CartManifest.lua @@ -11,7 +11,13 @@ CartManifest.SCHEMA = 1 CartManifest.EXT = ".g1rcart" CartManifest.FORMAT = "g1rcart" CartManifest.DIR = "carts" -CartManifest.SEALS = { sealed = true, open = true } +-- sealed: the pinned set runs exactly as pinned. sealed+: the same fixed set, +-- but the player may switch any pinned mod on or off. open: additive. +CartManifest.SEALS = { sealed = true, ["sealed+"] = true, open = true } +-- Shell and label finishes the launcher cartridge can render. +CartManifest.FINISHES = { + sparkle = true, holo = true, ["sparkle+holo"] = true, +} CartManifest.SOURCES = { github = true, gamebanana = true, ["local"] = true } CartManifest.ART_ENCODINGS = { base64 = true } CartManifest.PNG_SIGNATURE = "\137PNG\r\n\26\10" @@ -143,12 +149,24 @@ local function parseMod(raw, index, seen) entry.md5 = raw.md5 end + if raw.enabled ~= nil and type(raw.enabled) ~= "boolean" then + return nil, label .. " enabled must be true or false" + end + -- Only a declared OFF is carried: an absent or explicit true is the default, + -- so a cart that says nothing serializes exactly as it did before this field. + if raw.enabled == false then entry.enabled = false end + local options, err = parseOptions(raw.options, label) if err then return nil, err end entry.options = options return entry end +-- A pin the cart ships switched off is installed and configured but not run. +function CartManifest.modEnabled(entry) + return type(entry) == "table" and entry.enabled ~= false +end + local function parseOrder(raw, mods) local out = {} if raw == nil then @@ -244,12 +262,45 @@ function CartManifest.parse(tbl) engine = trim(tbl.engine) end + local speeds + if tbl.speeds ~= nil then + if type(tbl.speeds) ~= "table" or #tbl.speeds == 0 then + return nil, "cart speeds must be a non-empty array of multipliers" + end + local GameSpeed = require("src.core.GameSpeed") + local valid, seen = {}, {} + for _, want in ipairs(GameSpeed.LEVELS) do + for _, have in ipairs(tbl.speeds) do + if have == want and not seen[want] then + seen[want] = true + valid[#valid + 1] = want + end + end + end + if #valid ~= #tbl.speeds then + return nil, "cart speeds must all be GameSpeed levels" + end + speeds = valid + end + + local finish = tbl.finish + if finish ~= nil then + if type(finish) ~= "string" or not CartManifest.FINISHES[finish] then + return nil, "cart finish must be sparkle, holo or sparkle+holo" + end + end + local seal = tbl.seal if seal == nil then seal = "sealed" end if type(seal) ~= "string" or not CartManifest.SEALS[seal] then - return nil, "cart seal must be sealed or open" + return nil, "cart seal must be sealed, sealed+ or open" end + -- The author's preferred game settings, seeded once per cart + -- (SaveData.CART_OPTION_KEYS); unknown keys are kept but ignored. + local cartOptions, cartOptErr = parseOptions(tbl.options, "cart") + if cartOptErr then return nil, cartOptErr end + if type(tbl.mods) ~= "table" then return nil, "cart mods must be an array" end local count = #tbl.mods if count < 1 or count > CartManifest.MAX_MODS then @@ -273,10 +324,13 @@ function CartManifest.parse(tbl) repo = repo, summary = summary, shell = shell, + finish = finish, + speeds = speeds, label = label, base = tbl.base, engine = engine, seal = seal, + options = cartOptions, mods = mods, load_order = order, } @@ -364,6 +418,10 @@ local function writeValue(out, value) out[#out + 1] = "#" .. number(value) elseif kind == "boolean" then out[#out + 1] = value and "T" or "F" + elseif kind == "table" then + -- Length-prefixed so a list can never collide with another field's text. + out[#out + 1] = "*" .. tostring(#value) + for _, item in ipairs(value) do writeValue(out, item) end else writeText(out, "$", tostring(value)) end @@ -375,23 +433,31 @@ local function writeField(out, name, value) writeValue(out, value) end -local CART_FIELDS = { "author", "base", "engine", "id", "label", "repo", - "seal", "shell", "summary", "title", "version" } -local MOD_FIELDS = { "file", "id", "md5", "mod", "repo", "sha256", +local CART_FIELDS = { "author", "base", "engine", "finish", "id", "label", + "repo", "seal", "shell", "speeds", "summary", "title", + "version" } +local MOD_FIELDS = { "enabled", "file", "id", "md5", "mod", "repo", "sha256", "source", "version" } +local function writeOptions(out, marker, options) + out[#out + 1] = marker + local keys = {} + for key in pairs(options or {}) do keys[#keys + 1] = key end + table.sort(keys) + for _, key in ipairs(keys) do writeField(out, key, options[key]) end +end + function CartManifest.canonical(cart) local out = { "[cart]" } for _, field in ipairs(CART_FIELDS) do writeField(out, field, cart[field]) end + -- Absent when the cart ships no settings, so a cart written before this + -- field hashes byte for byte as it did. + if cart.options ~= nil then writeOptions(out, "[cart_options]", cart.options) end out[#out + 1] = "[mods]" for _, entry in ipairs(cart.mods or {}) do writeText(out, "@", tostring(entry.id)) for _, field in ipairs(MOD_FIELDS) do writeField(out, field, entry[field]) end - out[#out + 1] = "[options]" - local keys = {} - for key in pairs(entry.options or {}) do keys[#keys + 1] = key end - table.sort(keys) - for _, key in ipairs(keys) do writeField(out, key, entry.options[key]) end + writeOptions(out, "[options]", entry.options) end out[#out + 1] = "[order]" for _, id in ipairs(cart.load_order or {}) do writeText(out, "@", tostring(id)) end @@ -407,11 +473,14 @@ function CartManifest.encode(cart) format = CartManifest.FORMAT, formatVersion = CartManifest.SCHEMA, labelArt = CartManifest.parseLabelArt(cart.labelArt), + -- Every field parse() keeps: a name missing here is a field the file + -- round trip silently drops, as finish and speeds were. cart = { id = cart.id, title = cart.title, version = cart.version, author = cart.author, repo = cart.repo, summary = cart.summary, - shell = cart.shell, label = cart.label, base = cart.base, - engine = cart.engine, seal = cart.seal, mods = cart.mods, - load_order = cart.load_order }, + shell = cart.shell, finish = cart.finish, speeds = cart.speeds, + label = cart.label, base = cart.base, + engine = cart.engine, seal = cart.seal, options = cart.options, + mods = cart.mods, load_order = cart.load_order }, }) end diff --git a/src/carts/CartStore.lua b/src/carts/CartStore.lua index 2516a6fd..7d2c1d81 100644 --- a/src/carts/CartStore.lua +++ b/src/carts/CartStore.lua @@ -10,7 +10,7 @@ CartStore.OPTIONS_KEY = "carts" CartStore.UNPINNED_VERSION = "0.0.0" local RECORD_FIELDS = { "id", "title", "version", "author", "base", "seal", - "shell", "summary", "hash", "file" } + "shell", "finish", "speeds", "summary", "hash", "file" } local function fsOr(fs) return fs or (love and love.filesystem) or nil @@ -52,14 +52,26 @@ end local function recordFor(cart, hash) return { id = cart.id, title = cart.title, version = cart.version, author = cart.author, base = cart.base, seal = cart.seal, - shell = cart.shell, summary = cart.summary, + shell = cart.shell, finish = cart.finish, speeds = cart.speeds, + summary = cart.summary, hash = hash, file = fileFor(cart.id) } end +local function sameValue(a, b) + if type(a) == "table" and type(b) == "table" then + if #a ~= #b then return false end + for i = 1, #a do + if a[i] ~= b[i] then return false end + end + return true + end + return a == b +end + local function sameRecord(a, b) if type(a) ~= "table" or type(b) ~= "table" then return false end for _, field in ipairs(RECORD_FIELDS) do - if a[field] ~= b[field] then return false end + if not sameValue(a[field], b[field]) then return false end end return true end @@ -67,7 +79,8 @@ end local function entryFor(cart, hash) return { id = cart.id, title = cart.title, version = cart.version, author = cart.author, base = cart.base, seal = cart.seal, - shell = cart.shell, summary = cart.summary, label = cart.label, + shell = cart.shell, finish = cart.finish, speeds = cart.speeds, + summary = cart.summary, label = cart.label, cart = cart, cartHash = hash, file = fileFor(cart.id) } end @@ -299,7 +312,7 @@ function CartStore.capture(identity, available, modOptions) if type(identity) ~= "table" then return nil, "cart identity is required" end local mods, order, unresolved = {}, {}, {} for _, row in ipairs(available or {}) do - if type(row) == "table" and row.enabled and safeId(row.id) then + if type(row) == "table" and safeId(row.id) then local m = manifestOf(row) local version = textOf(row.version, m and m.version) local semver = version and Semver.parse(version) and version or nil @@ -319,6 +332,9 @@ function CartStore.capture(identity, available, modOptions) reason = pinReason(repo, version, semver, hash), } end + -- A switched-off mod is pinned OFF rather than dropped, so the cart + -- still ships it with the author's settings. + if not row.enabled then entry.enabled = false end entry.options = frozenOptions(modOptions and modOptions[row.id]) mods[#mods + 1] = entry order[#order + 1] = row.id @@ -327,8 +343,9 @@ function CartStore.capture(identity, available, modOptions) local cart, err = CartManifest.parse({ id = identity.id, title = identity.title, version = identity.version, author = identity.author, repo = identity.repo, summary = identity.summary, - shell = identity.shell, label = identity.label, base = identity.base, - engine = identity.engine, seal = identity.seal, + shell = identity.shell, finish = identity.finish, speeds = identity.speeds, + label = identity.label, base = identity.base, + engine = identity.engine, seal = identity.seal, options = identity.options, mods = mods, load_order = order, }) if not cart then return nil, err end diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index df9173b5..382e8e82 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -1147,6 +1147,7 @@ function Engine:drumInstrumentGen2(kit, pitch) ticks = ticks + duration end end + extendDrumEnvelope(segments) self.noiseInstruments[key] = segments return segments end diff --git a/src/core/Game.lua b/src/core/Game.lua index b1b730b9..3137193a 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -817,14 +817,6 @@ function Game:keypressed(key) self:writeOptions() end return - elseif key == "5" then - -- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on - -- desktop. Mobile refuses the present shader (issue #136). - local GBCFX = require("src.render.GBCFX") - if not GBCFX.isSupported() then return end - self.save.options.gbcfx = GBCFX.cycle() - self:writeOptions() - return end -- Mod render pipelines claim their hotkeys last, so one can never shadow -- an engine display key however a mod declares it (12 §rendering @@ -1258,8 +1250,10 @@ function Game:applyOptions(opts) require("src.render.Pipelines").applyOptions(opts) require("src.render.Zoom").applyOptions(opts) require("src.render.TileRenderer").applyOptions(opts) - -- returns true when a persisted GBC FX level was cleared on mobile - local gbcCleared = require("src.render.GBCFX").applyOptions(opts) + -- returns true when a persisted preset name no longer resolves (deleted + -- from the drop-in folder, or failed to (re)translate) and had to be + -- cleared back to OFF -- "sanitized, please persist" contract + local shaderfxCleared = require("src.render.ShaderFX").applyOptions(opts) require("src.core.VideoMode").applyOptions(opts) -- Android orientation lock (#592); no-op everywhere else require("src.core.Orientation").applyOptions(opts) @@ -1274,12 +1268,12 @@ function Game:applyOptions(opts) -- tier. Every heavy feature was just applied from the stored options -- above; here we clamp the *live* state down for a weaker device without -- rewriting what the player saved, so raising the tier later restores - -- their exact TILT / GBC FX / ZOOM / MAX FPS choices. A HIGH tier (the + -- their exact TILT / ZOOM / MAX FPS choices. A HIGH tier (the -- default on a normal desktop, and every options.lua predating this -- option) clamps nothing, so it is a no-op for the common case. local caps = require("src.core.Performance").applyOptions(opts) if not caps.tilt then require("src.render.Tilt").setLevel(0) end - if not caps.gbcfx then require("src.render.GBCFX").setLevel(0) end + if not caps.shaderfx then require("src.render.ShaderFX").deactivate() end local Zoom = require("src.render.Zoom") Zoom.allowSurvey = caps.survey if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end @@ -1289,8 +1283,7 @@ function Game:applyOptions(opts) end Input:applyBindings(opts.bindings) TouchControls:applyOptions(opts) - -- heal soft-bricked APK installs that already saved gbcfx > 0 (#136) - if gbcCleared then self:writeOptions() end + if shaderfxCleared then self:writeOptions() end end function Game:restoreSave(loaded, recovered, opts) diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 176ee13f..db6f54b9 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -367,14 +367,22 @@ function Game2:showIntro() }) end +-- ../pokegold/engine/menus/intro_menu.asm:848-851 IntroSequence, and Crystal's +-- at ../pokecrystal/engine/menus/intro_menu.asm:964-967: a skip means the title. function Game2:showGameFreak() self.stack:clear() self.phase = "boot" - Screens.push(self, "Gen2GameFreakPresents", { + local id = (GameVersion.engine() == "crystal") + and "Gen2CrystalSplash" or "Gen2GameFreakPresents" + Screens.push(self, id, { title = self.titleData or {}, oakSpeech = self.oakSpeechData or {}, - onDone = function() - self:showIntro() + onDone = function(skipped) + if skipped then + self:showTitle() + else + self:showIntro() + end end, }) end @@ -1098,18 +1106,26 @@ function Game2:load() -- The play clock only runs in the overworld, the way wGameTimerPaused is -- set while the intro menu is up. Save.tickPlayTime(self.save) - -- START and SELECT are read only at the tail of OWPlayerInput, which - -- PlayerEvents never reaches while a script is running or the player is - -- mid-step (World:acceptsMenuInput transcribes the three gates). A press - -- that arrives under one of them is dropped, not queued -- and the frame - -- still runs, so world:step must not be skipped on the swallowed press. - if self.input:wasPressed("start") and self.world:acceptsMenuInput() then - self:openStartMenu() - return - end - if self.input:wasPressed("select") and self.world:acceptsMenuInput() then - self:useSelectItem() - return + -- MAPEVENTS_OFF skips GetJoypad for the whole of a step, so the hJoyDown + -- mirror is frozen -- events.asm:190-198, :211-227 (#525, #1718) + local accepts = self.world:acceptsMenuInput() + local latch = self.joyLatch + if accepts then + self.joyLatch = nil + if self.input:wasPressed("start") + or (latch and latch.start and self.input:isDown("start")) then + self:openStartMenu() + return + end + if self.input:wasPressed("select") + or (latch and latch.select and self.input:isDown("select")) then + self:useSelectItem() + return + end + else + if not latch then latch = {}; self.joyLatch = latch end + if self.input:wasPressed("start") then latch.start = true end + if self.input:wasPressed("select") then latch.select = true end end self.world:pollInput(self.input) if self.input:wasPressed("a") then @@ -1161,8 +1177,8 @@ end -- The screen-pixels-per-GB-pixel scale the post passes need so their grid and -- shadow offsets stay window-size independent. Always the plain letterbox --- fit, never the survey zoom: GBC FX is simulating the PANEL the picture is --- being shown on, and the panel does not resize when the player zooms the map +-- fit, never the survey zoom: SHADER FX is simulating the PANEL the picture +-- is being shown on, and the panel does not resize when the player zooms the map -- -- Gen 1 hands the same pass its `Renderer:fitScale()` for that reason -- (Renderer:endFrame's Sp). Following the zoom used to shrink the LCD grid to -- one screen pixel a cell out at survey range. @@ -1359,25 +1375,29 @@ end -- Gold's frame, and then the passes that run over it. -- -- The Gen 1 path gets these for free because everything it draws goes through --- src/render/Renderer.lua, which owns a present canvas and calls GBCFX there. --- Gold draws straight to the screen instead, which is why its GBC FX row used --- to change a number and nothing else: nothing ever presented a canvas for the --- shader to read. So compose into one here when a pass wants it, and skip the --- canvas entirely when none does -- the common case, and one less full-screen --- blit than the old path would have paid. +-- src/render/Renderer.lua, which owns a present canvas and calls ShaderFX +-- there. Gold draws straight to the screen instead, which is why its SHADER +-- FX row used to change a number and nothing else (back when it was GBC FX): +-- nothing ever presented a canvas for the shader to read. So compose into +-- one here when a pass wants it, and skip the canvas entirely when none does +-- -- the common case, and one less full-screen blit than the old path would +-- have paid. -- --- CLASSIC runs first and GBC FX second, matching the Gen 1 order: the palette --- IS the picture, and the screen effects are simulating the panel that picture --- is being shown on. Mod post-processes fold in between the two, where --- Renderer.lua:1058 folds them -- a blur or a colour grade is what the LCD grid --- is then drawn over, rather than something that smears the grid itself. +-- CLASSIC runs first and SHADER FX second, matching the Gen 1 order: the +-- palette IS the picture, and the screen effects are simulating the panel +-- that picture is being shown on. Mod post-processes fold in between the +-- two, where Renderer.lua:1058 folds them -- a blur or a colour grade is +-- what the LCD grid is then drawn over, rather than something that smears +-- the grid itself. function Game2:drawViewportFrame() local G = love.graphics local w, h = GameViewport.dimensions() - local GBCFX = require("src.render.GBCFX") + local ShaderFX = require("src.render.ShaderFX") local GbcPalette = require("src.render.GbcPalette") local Pipelines = require("src.render.Pipelines") - local fx = GBCFX.active() + -- Same dispatch src/render/Renderer.lua:1185 already uses for Gen 1 + -- (ShaderFX replaced GBCFX's slot; GBCFX.lua itself is removed). + local shaderfx = ShaderFX.active() -- render.zones, at the instant Gen 1 raises it: the palette list is settled -- and the blit has not happened yet. Gen 1's list is the SGB packet zones @@ -1400,14 +1420,14 @@ function Game2:drawViewportFrame() local zoned = type(zones) == "table" and zones[1] ~= nil -- A present canvas is paid for only when something reads it: the zone pass, - -- GBC FX, a mod post-process, render.compose, or an enabled render.output + -- SHADER FX, a mod post-process, render.compose, or an enabled render.output -- subscriber. With none of them the frame draws straight to the screen -- exactly as it always did. local composing = ModRuntime.wantsHook("render.compose") local hasOutputHook = ModRuntime.wantsHook("render.output") and ModRuntime.call("render.output_enabled", function() return false end) == true local scene = nil - if zoned or fx or composing or Pipelines.wantsPresent() or hasOutputHook then + if zoned or shaderfx or composing or Pipelines.wantsPresent() or hasOutputHook then scene = self:presentCanvas(1, w, h) end if not scene then @@ -1435,11 +1455,11 @@ function Game2:drawViewportFrame() end -- The zone pass has to land in a texture whenever anything still reads one - -- after it: GBC FX and a post-process both sample the tinted image, not the - -- untinted one. On its own the tint rides the final blit and no second + -- after it: SHADER FX and a post-process both sample the tinted image, not + -- the untinted one. On its own the tint rides the final blit and no second -- canvas is paid for. local source = scene - local reread = fx or Pipelines.wantsPresent() or hasOutputHook + local reread = shaderfx or Pipelines.wantsPresent() or hasOutputHook if zoned and reread then local tinted = self:presentCanvas(2, w, h) if tinted then @@ -1459,7 +1479,7 @@ function Game2:drawViewportFrame() -- Post-process pipelines run over the finished composite and before GBC -- FX. Each hands back a canvas; with none registered this returns `source` -- unchanged and the frame is byte-identical (Renderer.lua:1058). - local scale, ox, oy, dpi = self:frameFit(w, h) + local scale, ox, oy, dpi, pw, ph = self:frameFit(w, h) source = Pipelines.present(source, { width = w, height = h, scale = scale, dpi = dpi, dpiX = dpi, dpiY = dpi }) or source local outputHandled = hasOutputHook @@ -1473,8 +1493,30 @@ function Game2:drawViewportFrame() if not outputHandled then local cx, cy, cw, ch = Playfield.cutout(w, h) if cx then G.setScissor(cx, cy, cw, ch) end - if fx then - GBCFX.present(source, self:pixelScale(w, h)) + if shaderfx then + -- rect is physical framebuffer pixels and source is the un-scaled + -- size, matching Renderer.lua's fxRectPx / fxSrc contract. + -- A live overworld draws edge to edge at World:zoomScale, so the + -- faithful 160*scale box would leave the rest of the map unshaded. + local rect, srcW, srcH + if self.frameWorldActive and self.world then + local s = self.world:zoomScale() * dpi + srcW = self.world.viewW or 160 + srcH = self.world.viewH or 144 + local rw, rh = srcW * s, srcH * s + rect = { + x = math.floor((pw - rw) / 2), y = math.floor((ph - rh) / 2), + w = rw, h = rh, scale = s, + } + else + srcW, srcH = 160, 144 + rect = { + x = ox * dpi, y = oy * dpi, + w = 160 * scale * dpi, h = 144 * scale * dpi, + scale = scale * dpi, + } + end + ShaderFX.render(source, rect, { w = srcW, h = srcH }, dpi, dpi) else G.setColor(1, 1, 1, 1) G.draw(source, 0, 0) @@ -1518,6 +1560,28 @@ function Game2:drawContained(w, h) if not ok then error(err, 0) end 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 + for i = #states, 1, -1 do + local state = states[i] + if state and state.bgMode then mode = state:bgMode() break end + end + if mode ~= "black" then return end + local G = love.graphics + local scale = Chrome.fitScale(w, h) + local ox, oy = Chrome.fitOrigin(w, h, scale) + local pw, ph = 160 * scale, 144 * scale + G.setColor(0, 0, 0, 1) + 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 + if ox + pw < w then G.rectangle("fill", ox + pw, oy, w - ox - pw, ph) end + G.setColor(1, 1, 1, 1) +end + function Game2:drawScene(w, h) local G = love.graphics -- render.compose reads this after the scene is drawn; the plain overworld @@ -1587,6 +1651,7 @@ function Game2:drawScene(w, h) and base.drawWidescreen and base) if wide then wide:drawWidescreen(w, h) + self:paintBattleSurround(w, h) self:letterbox(w, h, false) if wide ~= top then -- The pushed box blits at the same integer fit the widescreen layer @@ -1691,7 +1756,6 @@ end -- F1/F2 write / reload the save 1 GAME SPEED -- - = zoom one step out / in 2 COLOR -- 4 cycle ZOOM 3 TILT (mnemonic: 3D) --- 5 GBC FX -- -- `2` is COLOR here rather than Gen 1's COLORS. The Gen 1 row cycles SGB -- palette packs, which a CGB-native game has no use for; what it cycles here @@ -1726,13 +1790,6 @@ function Game2:hotkey(key) options.tilt = Tilt.cycle() persist() return true - elseif key == "5" then - local GBCFX = require("src.render.GBCFX") - if GBCFX.isSupported() then - options.gbcfx = GBCFX.cycle() - persist() - end - return true end if not (self.world and self.world.map) then return self:pipelineHotkey(key, options, persist) @@ -1967,7 +2024,8 @@ end -- Push the saved display options into the modules that own them. Called -- whenever the options table changes hands (boot, CONTINUE, the OPTION --- screen), so a reload comes back at the zoom, tilt and GBC FX the player left. +-- screen), so a reload comes back at the zoom, tilt and SHADER FX the player +-- left. function Game2:applyOptions() local options = self.options or {} Music.applyOptions(options) @@ -1994,9 +2052,26 @@ function Game2:applyOptions() require("src.core.ScreenPosition").applyOptions(options) require("src.core.FrameCap").applyOptions(options) require("src.world.gen2.BorderFill").applyOptions(options) - local GBCFX = require("src.render.GBCFX") - if GBCFX.applyOptions(options) and self.save then - -- applyOptions returns true when it had to clear an unsupported level. + -- returns true when a persisted preset name no longer resolves (deleted + -- from the drop-in folder, or failed to (re)translate) and had to be + -- cleared back to OFF -- src\core\Game.lua:1215 mirrors this call for + -- Gen 1 (SHADER FX reaches Gen 2 too) + local shaderfxCleared = require("src.render.ShaderFX").applyOptions(options) + -- Scale the optional presentation extras to the device's performance + -- tier, same clamp src/core/Game.lua:1222-1230 applies for Gen 1 -- see + -- that site's comment for the full rationale. + local caps = require("src.core.Performance").applyOptions(options) + if not caps.tilt then require("src.render.Tilt").setLevel(0) end + if not caps.shaderfx then require("src.render.ShaderFX").deactivate() end + local Zoom = require("src.render.Zoom") + Zoom.allowSurvey = caps.survey + 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 + end + if shaderfxCleared and self.save then + -- applyOptions returns true when it had to clear an unresolved preset. self.save.options = options end end diff --git a/src/core/GameSpeed.lua b/src/core/GameSpeed.lua index 96f08815..495bd27f 100644 --- a/src/core/GameSpeed.lua +++ b/src/core/GameSpeed.lua @@ -29,11 +29,38 @@ end -- nearest valid level for an arbitrary value (a hand-edited options.lua or -- a --speed argument), so a bad number degrades to something sane +-- A cart may narrow the levels a player can reach (CartManifest's `speeds`). +-- nil restores the full ladder; a one-entry list pins the speed outright. +local allowed + +function GameSpeed.setAllowed(levels) + if type(levels) ~= "table" or #levels == 0 then allowed = nil; return end + local valid, seen = {}, {} + for _, want in ipairs(GameSpeed.LEVELS) do + for _, have in ipairs(levels) do + if have == want and not seen[want] then + seen[want] = true + valid[#valid + 1] = want + end + end + end + allowed = (#valid > 0) and valid or nil +end + +function GameSpeed.allowed() + return allowed or GameSpeed.LEVELS +end + +function GameSpeed.isLocked() + return allowed ~= nil and #allowed <= 1 +end + function GameSpeed.clamp(v) v = tonumber(v) - if not v then return GameSpeed.DEFAULT end - local best, bestDiff = GameSpeed.DEFAULT, math.huge - for _, level in ipairs(GameSpeed.LEVELS) do + local levels = GameSpeed.allowed() + if not v then return levels[1] or GameSpeed.DEFAULT end + local best, bestDiff = levels[1] or GameSpeed.DEFAULT, math.huge + for _, level in ipairs(levels) do local diff = math.abs(level - v) if diff < bestDiff then best, bestDiff = level, diff end end @@ -42,7 +69,7 @@ end -- cycle to the next/previous level, wrapping (the options row idiom) function GameSpeed.cycle(v, dir) - local levels = GameSpeed.LEVELS + local levels = GameSpeed.allowed() local cur = 1 for i, level in ipairs(levels) do if level == GameSpeed.clamp(v) then cur = i break end diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index e9b57cc7..3e39ced9 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -60,9 +60,7 @@ GameVersion.VERSIONS = { id = "gold", label = "Gold", displayName = "Pokemon Gold", - -- Still Gen 2 Phase work; the launcher panel / Play button say Beta so - -- players do not treat it like the shipped Gen 1 columns. - launcherName = "Gold (Beta)", + launcherName = "Gold", sha1 = "d8b8a3600a465308c9953dfa04f0081c05bdcb94", manifest = "tools/rom_manifest_gold.json", cachePrefix = "gold/", -- gold/data/generated, gold/assets/generated @@ -77,7 +75,7 @@ GameVersion.VERSIONS = { id = "silver", label = "Silver", displayName = "Pokemon Silver", - launcherName = "Silver (Beta)", + launcherName = "Silver", sha1 = "49b163f7e57702bc939d642a18f591de55d92dae", manifest = "tools/rom_manifest_silver.json", cachePrefix = "silver/", -- silver/data/generated, silver/assets/generated @@ -89,6 +87,8 @@ GameVersion.VERSIONS = { id = "crystal", label = "Crystal", displayName = "Pokemon Crystal", + -- Still Gen 2 Phase work; the launcher panel / Play button say Beta so + -- players do not treat it like the shipped Gold and Silver columns. launcherName = "Crystal (Beta)", sha1 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", manifest = "tools/rom_manifest_crystal.json", diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 82a42bfb..cb6d1082 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -376,7 +376,19 @@ end -- notice a quit command until curl returns. With the launcher's default 300s -- ceiling, closing the window during a mod download hung the process for -- minutes. Callers on the interactive fetch pool pass something short. -function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime) +-- +-- `etagPath` (optional, absolute host path) turns this into a conditional +-- GET: curl sends `If-None-Match` from whatever ETag is already saved there +-- (`--etag-compare`) and overwrites it with the response's own ETag on +-- success (`--etag-save`), same file for both so a first call with no prior +-- ETag degrades to a plain unconditional download. A server that replies 304 +-- Not Modified is reported as a third return value (`notModified`), and -- +-- confirmed empirically against the real buildbot.libretro.com endpoint +-- (TrueFX/etag-cache-repro/) before this was wired in here -- curl does NOT +-- write `absPath` at all in that case (not even an empty file), matching +-- HTTP: a 304 carries no body. A caller must not treat a missing file as an +-- error when `notModified` comes back true. +function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime, etagPath) if type(url) ~= "string" or url == "" then return nil, "missing url" end if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end userAgent = userAgent or "gen1recomp" @@ -388,6 +400,10 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime) if accept then cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " " end + if type(etagPath) == "string" and etagPath ~= "" then + cmd = cmd .. "--etag-compare " .. HostShell.quote(etagPath) .. " " + .. "--etag-save " .. HostShell.quote(etagPath) .. " " + end cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " " .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " .. HostShell.quote(url) .. " 2>&1" @@ -400,6 +416,9 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime) -- status is here purely so the failure can NAME itself: "download failed" -- with no URL and no code is the report this whole change exists to fix. local body, status, noise = splitCurlOutput(readOk and out or "") + if status == 304 then + return true, nil, true + end if status and (status < 200 or status >= 300) then return nil, fetchError(url, status, body) end @@ -411,6 +430,10 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime) if not haveBridge() then return nil, "no network transport on this platform" end + -- The Android/iOS bridge has no conditional-GET support -- etagPath is + -- silently ignored here, so a downloaded preset re-fetches in full every + -- time on those platforms. No transport-level ETag/If-None-Match hook + -- exists on the bridge to hang one off, and it's low-priority for now. local ok, done = pcall(love.system.httpDownload, url, absPath, userAgent, accept) if ok and done then return true end return nil, "download failed for " .. url diff --git a/src/core/Performance.lua b/src/core/Performance.lua index b15b520e..4d61b65f 100644 --- a/src/core/Performance.lua +++ b/src/core/Performance.lua @@ -3,9 +3,12 @@ -- -- The heavy extras are all things the Game Boy never had and this port -- adds on top: the whole-screen 3D TILT (transforms the entire map as a --- ground plane), the GBC FX post-process shader (a fullscreen pass), and --- survey ZOOM (zooming out renders the connected neighbor maps -- a lot of --- extra overdraw). A hard FPS ceiling caps present cost on top of that. +-- ground plane), survey ZOOM (zooming out renders the connected +-- neighbor maps -- a lot of extra overdraw), and SHADER FX (a live- +-- translated libretro slang-shader chain, reasoned -- not yet measured -- +-- to be heavier per-frame than GBCFX.lua's old fixed shader it replaced). +-- A hard FPS ceiling caps present cost on +-- top of that. -- None of this touches game logic, which is fixed-step off dt -- (src/core/FixedStep.lua), so every tier plays identically; they differ -- only in how much optional eye-candy the renderer is allowed to do. @@ -13,14 +16,15 @@ -- The tier is persisted as save.options.performance: -- auto pick a default from the device (see detect()) -- high everything on -- the historical behavior --- balanced no TILT, no GBC FX (kept: survey zoom, colors, uncapped FPS) --- low no TILT, no GBC FX, no survey zoom, FPS capped +-- balanced no TILT, no SHADER FX (kept: survey zoom, colors, uncapped FPS) +-- low no TILT, no survey zoom, no SHADER FX, FPS capped -- -- AUTO only chooses the *default* for the current device; every tier is -- selectable in OPTIONS, so a heuristic that guesses wrong is one row away -- from being overridden. The clamps are applied live in Game:applyOptions --- against the stored options without rewriting them, so raising the tier --- restores exactly the TILT / GBC FX / ZOOM / FPS the player had. +-- (Gen 1) / Game2:applyOptions (Gen 2) against the stored options without +-- rewriting them, so raising the tier restores exactly the TILT / SHADER FX +-- / ZOOM / FPS the player had. Wired into both generations. -- -- Zero requires: loads during love.conf and under plain Lua for tools and -- tests, the same way src/core/GameVersion.lua does. @@ -40,10 +44,24 @@ Performance.LABELS = { -- What each concrete tier permits. `auto` is resolved to one of these -- before caps are read, so it has no row here. fpsMax = false means no -- extra ceiling (the player's own MAX FPS still applies). +-- +-- `shaderfx` is `false` (fully off) or a positive number: an internal +-- chain-resolution multiplier (1.0 = native, e.g. 0.5 = render the whole +-- SHADER FX pass chain at half resolution, then stretch up for display -- +-- src/render/ShaderFX.lua's ShaderFX.render already stretch-blits its +-- final output to the real display rect regardless of the chain's own +-- internal size, so shrinking the chain's working +-- resolution costs nothing extra at that final blit). Every existing +-- truthy/falsy call site (`if not caps.shaderfx then ... end`) still works +-- unchanged: `false` stays falsy, any positive number stays truthy -- only +-- `ShaderFX.render` itself reads the actual number. No tier is +-- set below 1.0 yet -- tuning the actual per-tier value for real low-end +-- hardware needs a device this project doesn't have to hand right now; this +-- is the mechanism, not the tuning. Performance.CAPS = { - high = { tilt = true, gbcfx = true, survey = true, fpsMax = false }, - balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false }, - low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 }, + high = { tilt = true, survey = true, shaderfx = 1.0, fpsMax = false }, + balanced = { tilt = false, survey = true, shaderfx = false, fpsMax = false }, + low = { tilt = false, survey = false, shaderfx = false, fpsMax = 60 }, } -- Live resolved tier (never "auto"); Game:applyOptions sets it and the @@ -93,8 +111,7 @@ function Performance.detect() if isArm and os == "Linux" then return "low" end - -- Phones and tablets: GBC FX is already force-disabled here (issue #136); - -- balanced additionally drops the 3D tilt, the heaviest remaining extra. + -- Phones and tablets: balanced drops the 3D tilt, the heaviest extra. if os == "Android" or os == "iOS" then return "balanced" end diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 8d3d2fd0..7263d2b0 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -272,10 +272,9 @@ function SaveData.defaultOptions() speedOverworld = 1, speedBattle = 1, speedMenu = 1, - -- port display options (OptionsMenu / hotkeys 2/3/4/5) + -- port display options (OptionsMenu / hotkeys 2/3/4) colors = "gbc", tilt = 0, - gbcfx = 0, -- survey zoom offset from window fit scale (0 = FIT); see Zoom.lua zoom = 0, -- OVERWORLD beyond-edge fill: trees | water | black @@ -290,8 +289,8 @@ function SaveData.defaultOptions() fpsCap = 60, -- graphics performance tier: auto | high | balanced | low. "auto" -- picks a default from the device (ARM handhelds/phones drop the heavy - -- extras); scales TILT / GBC FX / survey ZOOM / FPS but never game - -- logic. See src/core/Performance.lua. + -- extras); scales TILT / survey ZOOM / FPS but never game logic. See + -- src/core/Performance.lua. performance = "auto", -- Per-pipeline display levels, keyed by render_pipelines id (see -- src/render/Pipelines.lua). A level for a mod that is not installed @@ -360,9 +359,24 @@ function SaveData.defaultOptions() timeFormat = "device", -- device | 24h | 12h saveSync = { enabled = false, lastSyncAt = 0, revs = {}, stamps = {}, pendingConflicts = {} }, + -- Cart-scoped settings, beside cartSlots: cartOptions[cartId][key] layers + -- over the global value for CART_OPTION_KEYS while that cart is playing. + cartOptions = {}, + -- The player's own answer for a mod a cart pins, kept out of the per-game + -- flags: cartMods[cartId][modId], absent while the cart's switch stands. + cartMods = {}, + -- The carts whose shipped defaults were already seeded, so a player's + -- later change is never overwritten (compare modProfilesSeeded). + cartOptionsSeeded = {}, } end +-- Settings that belong to a playthrough and follow the cart: pacing and the +-- battle rules. Everything else -- audio, video, bindings, touch, language, +-- launcher state -- belongs to the machine and stays global. +SaveData.CART_OPTION_KEYS = { "animations", "battleStyle", "ruleset", + "speedBattle", "speedMenu", "speedOverworld", "textSpeed" } + -- Merge loaded keys over defaults (shallow). Unknown keys are kept so -- future options aren't dropped by older builds writing the file back. function SaveData.mergeOptions(loaded) @@ -437,6 +451,60 @@ end -- ------- options +-- The cart being played, if any; SaveData.setCart owns it. Declared here +-- because the options overlay below reads it. +local activeCart, activeCartHash + +local function cartBucket(opts, cartId) + local root = type(opts) == "table" and opts.cartOptions or nil + local bucket = type(root) == "table" and root[cartId] or nil + return type(bucket) == "table" and bucket or nil +end + +-- Lay the active cart's values over the globals for the cart-scoped keys; a +-- key the cart never set falls through to the global value. +local function applyCartOverlay(opts) + if not (activeCart and type(opts) == "table") then return opts end + local bucket = cartBucket(opts, activeCart) + if not bucket then return opts end + for _, key in ipairs(SaveData.CART_OPTION_KEYS) do + if bucket[key] ~= nil then opts[key] = bucket[key] end + end + return opts +end + +local function cartGlobals(onDisk) + local out = {} + if type(onDisk) ~= "table" then return out end + for _, key in ipairs(SaveData.CART_OPTION_KEYS) do out[key] = onDisk[key] end + return out +end + +-- The inverse, run just before a write: the cart-scoped keys go into the +-- active cart's bucket and the globals keep whatever the file already held. +local function splitCartOverlay(opts, globals, onDisk) + if not (activeCart and type(opts) == "table") then return opts end + local root = {} + local prior = type(onDisk) == "table" and onDisk.cartOptions or nil + if type(prior) == "table" then + for id, bucket in pairs(prior) do root[id] = deepCopy(bucket) end + end + for id, bucket in pairs(type(opts.cartOptions) == "table" and opts.cartOptions or {}) do + root[id] = bucket + end + local bucket = type(root[activeCart]) == "table" and root[activeCart] or {} + local defaults = SaveData.defaultOptions() + for _, key in ipairs(SaveData.CART_OPTION_KEYS) do + if opts[key] ~= nil then bucket[key] = opts[key] end + local global = globals and globals[key] + if global == nil then global = defaults[key] end + opts[key] = global + end + root[activeCart] = bucket + opts.cartOptions = root + return opts +end + -- Both take an optional fs (write/getInfo/read) defaulting to -- love.filesystem, so the mod loader's injected filesystem can carry the -- options round-trip headless (no love global). @@ -458,6 +526,10 @@ function SaveData.saveOptions(opts, fs) -- activeProfile -- not defaultOptions members -- can be deleted by their -- sites precisely because those sites always write full tables. local onDisk = readTable(fs, OPTIONS_FILENAME) + -- Fold from the cart's view of the file, not the globals underneath it, or + -- a partial write would push the global value into the cart's bucket. + local cartGlobalValues = cartGlobals(onDisk) + applyCartOverlay(onDisk) local isFull = type(opts) == "table" if isFull then for k in pairs(SaveData.defaultOptions()) do @@ -497,6 +569,7 @@ function SaveData.saveOptions(opts, fs) end opts.modOptions = merged end + splitCartOverlay(opts, cartGlobalValues, onDisk) local encoded = SaveSerializer.encode(opts) -- Stage the new bytes and roll the last good file aside BEFORE the main -- write truncates it, the same tmp/bak dance SaveData.save uses for @@ -544,7 +617,8 @@ function SaveData.saveOptions(opts, fs) fs.write(OPTIONS_BACKUP_FILENAME, encoded) -- the staged witness has served its purpose; the main file is verified remove(fs, OPTIONS_TMP_FILENAME) - return opts + -- hand back the cart's view, matching what loadOptions would answer + return applyCartOverlay(opts) end function SaveData.loadOptions(fs) @@ -572,11 +646,11 @@ function SaveData.loadOptions(fs) if fs.write then fs.write(OPTIONS_FILENAME, SaveSerializer.encode(recovered)) end - return SaveData.mergeOptions(recovered) + return applyCartOverlay(SaveData.mergeOptions(recovered)) end return SaveData.defaultOptions() end - return SaveData.mergeOptions(data) + return applyCartOverlay(SaveData.mergeOptions(data)) end -- ------- per-game mod enablement @@ -768,7 +842,6 @@ local freshPlaythrough local CART_PREFIX = "cart_" -local activeCart, activeCartHash local sealBroken = false local function cartKey(cartId) @@ -1314,6 +1387,53 @@ function SaveData.getCart() return activeCart end +-- The player's answer for one mod a cart pins, or nil while they have not +-- given one and the cart's own switch stands. +function SaveData.cartModEnabled(options, cartId, modId) + if not cartKey(cartId) or type(modId) ~= "string" then return nil end + local root = type(options) == "table" and options.cartMods or nil + local bucket = type(root) == "table" and root[cartId] or nil + if type(bucket) == "table" and type(bucket[modId]) == "boolean" then + return bucket[modId] + end + return nil +end + +-- Kept apart from options.mods so switching a cart's mod never changes what +-- the base game runs, and vice versa. +function SaveData.setCartModEnabled(cartId, modId, enabled, fs) + if not cartKey(cartId) or type(modId) ~= "string" or modId == "" then + return false + end + local opts = SaveData.loadOptions(fs) + local root = type(opts.cartMods) == "table" and opts.cartMods or {} + local bucket = type(root[cartId]) == "table" and root[cartId] or {} + bucket[modId] = enabled and true or false + root[cartId], opts.cartMods = bucket, root + return SaveData.saveOptions(opts, fs) ~= nil +end + +-- Seed the active cart's shipped settings into its overlay, once ever. Keys +-- outside CART_OPTION_KEYS are ignored; a later player change is never redone. +function SaveData.seedCartOptions(defaults, fs) + if not (activeCart and type(defaults) == "table") then return false end + local opts = SaveData.loadOptions(fs) + local seeded = type(opts.cartOptionsSeeded) == "table" + and opts.cartOptionsSeeded or {} + if seeded[activeCart] then return false end + local root = type(opts.cartOptions) == "table" and opts.cartOptions or {} + local bucket = type(root[activeCart]) == "table" and root[activeCart] or {} + for _, key in ipairs(SaveData.CART_OPTION_KEYS) do + if defaults[key] ~= nil and bucket[key] == nil then + bucket[key] = defaults[key] + opts[key] = defaults[key] + end + end + root[activeCart], opts.cartOptions = bucket, root + seeded[activeCart], opts.cartOptionsSeeded = true, seeded + return SaveData.saveOptions(opts, fs) ~= nil +end + function SaveData.setCartHash(cartHash) activeCartHash = (type(cartHash) == "string" and cartHash ~= "") and cartHash or nil return activeCartHash diff --git a/src/core/Sensors.lua b/src/core/Sensors.lua new file mode 100644 index 00000000..446eb283 --- /dev/null +++ b/src/core/Sensors.lua @@ -0,0 +1,178 @@ +-- Accelerometer and gyroscope reads for the tilt-driven shader presets, plus +-- screen-rotation compensation. love.sensor does not exist in LOVE 11.5 on any +-- platform, so the working path is raw FFI into the SDL2 LOVE already links, +-- the same technique src/core/Orientation.lua uses. What is and is not verified +-- on real hardware is written down in docs/shaderfx.md. + +local Sensors = {} + +-- Test seams: a no-device harness injects synthetic readings. +local overrides = {} +local orientationOverride = nil + +function Sensors.setOverride(kind, x, y, z) + overrides[kind] = { x, y, z } +end + +function Sensors.clearOverride(kind) + overrides[kind] = nil +end + +function Sensors.clearAllOverrides() + overrides = {} + orientationOverride = nil +end + +-- "landscape" | "landscapeFlipped" | "portrait" | "portraitFlipped" | nil +-- (nil = ask SDL for real). Test seam for rotateForScreen below. +function Sensors.setOrientationOverride(o) + orientationOverride = o +end + +-- ---- love.sensor path (kept for a future LOVE 12 upgrade; always nil today). +local function safeHasSensor(kind) + if not love or not love.sensor then return false end + local ok, has = pcall(love.sensor.hasSensor, kind) + return ok and has == true +end + +local function loveSensorRead(kind) + if not safeHasSensor(kind) then return nil end + local ok, x, y, z = pcall(love.sensor.getData, kind) + if not ok then return nil end + return x or 0, y or 0, z or 0 +end + +-- ---- raw SDL2 FFI path (the one that actually works on LÖVE 11.5). +local SDL_SENSOR_ACCEL, SDL_SENSOR_GYRO = 1, 2 +local SDL_INIT_SENSOR = 0x00008000 +local KIND_TO_SDL_TYPE = { accelerometer = SDL_SENSOR_ACCEL, gyroscope = SDL_SENSOR_GYRO } + +local sdlCdefOk = nil +local sdlSensorHandles = {} -- kind -> SDL_Sensor* (nil once confirmed absent) +local sdlSensorTried = {} + +-- ffi.load("SDL2") first, needed on desktop where SDL2 is a separate library, +-- then bare ffi.C, needed on Android where love-android links SDL2 statically +-- into libmain.so and there is no standalone libSDL2.so to attach to by name +-- (Orientation.lua's own sdlFfi resolves the same way on real hardware, #592/#716). +local sdlLib = nil +local function sdlFfi() + local okFfi, ffi = pcall(require, "ffi") + if not okFfi then return nil end + if sdlCdefOk == nil then + sdlCdefOk = pcall(ffi.cdef, [[ + typedef struct SDL_Window SDL_Window; + typedef struct SDL_Sensor SDL_Sensor; + int SDL_InitSubSystem(unsigned int flags); + int SDL_NumSensors(void); + int SDL_SensorGetDeviceType(int device_index); + SDL_Sensor *SDL_SensorOpen(int device_index); + void SDL_SensorUpdate(void); + int SDL_SensorGetData(SDL_Sensor *sensor, float *data, int num_values); + SDL_Window *SDL_GL_GetCurrentWindow(void); + int SDL_GetWindowDisplayIndex(SDL_Window *window); + int SDL_GetDisplayOrientation(int displayIndex); + ]]) + if sdlCdefOk then + local okLoad, lib = pcall(ffi.load, "SDL2") + sdlLib = okLoad and lib or ffi.C + end + end + if not sdlCdefOk or not sdlLib then return nil end + return sdlLib +end + +-- Opens and caches once, and returns nil permanently once a real attempt finds +-- none, so a no-hardware desktop run pays this cost once rather than per frame. +local function sdlSensorHandle(lib, kind) + if sdlSensorTried[kind] then return sdlSensorHandles[kind] end + sdlSensorTried[kind] = true + local wantType = KIND_TO_SDL_TYPE[kind] + if not wantType then return nil end + local okInit = pcall(lib.SDL_InitSubSystem, SDL_INIT_SENSOR) + if not okInit then return nil end + local okCount, count = pcall(lib.SDL_NumSensors) + if not okCount or count <= 0 then return nil end + for i = 0, count - 1 do + local okType, t = pcall(lib.SDL_SensorGetDeviceType, i) + if okType and t == wantType then + local okOpen, handle = pcall(lib.SDL_SensorOpen, i) + if okOpen and handle ~= nil then + sdlSensorHandles[kind] = handle + return handle + end + end + end + return nil +end + +local function sdlSensorRead(kind) + local lib = sdlFfi() + if not lib then return nil end + local handle = sdlSensorHandle(lib, kind) + if not handle then return nil end + pcall(lib.SDL_SensorUpdate) + local data = require("ffi").new("float[3]") + local okGet, n = pcall(lib.SDL_SensorGetData, handle, data, 3) + if not okGet or n ~= 0 then return nil end + return data[0], data[1], data[2] +end + +-- ---- screen-rotation compensation (x/y only; z is perpendicular to the screen +-- and unaffected by an in-plane rotation). Mobile-only: a desktop monitor is +-- legitimately, permanently "landscape" to SDL_GetDisplayOrientation. +local function isMobile() + if not love or not love.system or not love.system.getOS then return false end + local os = love.system.getOS() + return os == "Android" or os == "iOS" +end + +local function sdlOrientation() + if not isMobile() then return nil end + local lib = sdlFfi() + if not lib then return nil end + local okWin, win = pcall(lib.SDL_GL_GetCurrentWindow) + if not okWin or win == nil then return nil end + local okIdx, idx = pcall(lib.SDL_GetWindowDisplayIndex, win) + if not okIdx or idx < 0 then return nil end + local okOr, o = pcall(lib.SDL_GetDisplayOrientation, idx) + if not okOr then return nil end + -- SDL_DisplayOrientation: 0=UNKNOWN, 1=LANDSCAPE, 2=LANDSCAPE_FLIPPED, + -- 3=PORTRAIT, 4=PORTRAIT_FLIPPED. + if o == 1 then return "landscape" + elseif o == 2 then return "landscapeFlipped" + elseif o == 3 then return "portrait" + elseif o == 4 then return "portraitFlipped" + else return nil end +end + +-- Remaps raw device-frame (x,y) into "as currently displayed" (x,y). portrait, +-- and unknown/undetectable (desktop always lands here), is a no-op. +local function rotateForScreen(x, y) + local o = orientationOverride or sdlOrientation() + if o == "landscape" then return -y, x + elseif o == "landscapeFlipped" then return y, -x + elseif o == "portraitFlipped" then return -x, -y + else return x, y end +end + +-- Returns x, y, z for "accelerometer" | "gyroscope". {0,0,0} when overridden +-- that way, no hardware/module is available, or an underlying call errors. +-- x/y are rotated to be screen-relative; z passes through unchanged. +function Sensors.read(kind) + local override = overrides[kind] + if override then + local x, y = rotateForScreen(override[1], override[2]) + return x, y, override[3] + end + + local x, y, z = loveSensorRead(kind) + if not x then x, y, z = sdlSensorRead(kind) end + if not x then return 0, 0, 0 end + + x, y = rotateForScreen(x, y) + return x, y, z +end + +return Sensors diff --git a/src/core/Sound.lua b/src/core/Sound.lua index f2982353..01a82299 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -363,6 +363,14 @@ function Sound.sfxBusy() return true end +-- WaitSFX (home/audio.asm), the drain above GiveItemScript's `specialsound` +-- (engine/overworld/scripting.asm:445), so ch5-ch8 are free for it (#1483). +function Sound.waitSfxDone() + if not curSfx then return end + pcall(curSfx.src.stop, curSfx.src) + curSfx = nil +end + local function startSfx(data, name, def) local src = playPath(data, name, def) if not src then return end diff --git a/src/core/gen2/Boxes.lua b/src/core/gen2/Boxes.lua index 7a87bd93..fa5a702d 100644 --- a/src/core/gen2/Boxes.lua +++ b/src/core/gen2/Boxes.lua @@ -109,6 +109,17 @@ function Boxes.restorePP(mon) end end +-- box_struct has no MON_STATUS and no MON_HP (macros/ram.asm:7-26), so +-- CalcTempmonStats refills a BOXMON from MAXHP (engine/pokemon/tempmon.asm:56-83). +function Boxes.enterBox(mon) + if not mon then return mon end + Boxes.restorePP(mon) + mon.status = nil + mon.statusTurns = nil + mon.hp = mon.isEgg and 0 or (mon.maxHp or mon.hp) + return mon +end + function Boxes.deposit(save, partyIndex, boxIndex) local ok, reason = Boxes.canDeposit(save, partyIndex, boxIndex) if not ok then return false, reason end @@ -122,7 +133,7 @@ function Boxes.deposit(save, partyIndex, boxIndex) box[#box + 1] = mon -- SendGetMonIntoFromBox's PC_DEPOSIT arm ends in RestorePPOfDepositedPokemon -- (engine/pokemon/move_mon.asm:633-635, :696-700). - Boxes.restorePP(mon) + Boxes.enterBox(mon) return true, mon end diff --git a/src/core/gen2/BugContest.lua b/src/core/gen2/BugContest.lua index ffd332d5..6dc1521b 100644 --- a/src/core/gen2/BugContest.lua +++ b/src/core/gen2/BugContest.lua @@ -803,7 +803,9 @@ function BugContest.collectCaughtMon(save, partySize, boxes) end boxes = boxes or require("src.core.gen2.Boxes") local box = boxes.box(save, save.currentBox or 1) - if box then box[#box + 1] = mon end + -- .TryAddToBox copies BOXMON_STRUCT_LENGTH and tails into + -- RestorePPOfDepositedPokemon (engine/pokemon/caught_nickname.asm:72-90). + if box then box[#box + 1] = boxes.enterBox(mon) end return BugContest.BOXED_MON, mon end diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index b9faa345..dd10851c 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -298,9 +298,11 @@ Save.DEFAULT_OPTIONS = { -- uses (src/core/SaveData.lua) and they drive the same shared modules, so -- a player's display and speed choices mean the same thing in both games. speed = 1, -- GameSpeed.LEVELS multiplier, logic only + -- graphics performance tier: auto | high | balanced | low. "auto" is the + -- Gen 1 save's own default (src/core/SaveData.lua); same key, same module. + performance = "auto", zoom = 0, -- Zoom offset from the window's fit scale tilt = 0, -- Tilt.LEVELS degrees, 0 = off - gbcfx = 0, -- GBCFX ladder, 0 = off -- COLOR: GbcPalette.MODES. "gbc" is the cart's own palettes and the -- default -- this is a Game Boy Color game, so colour is ON out of the box -- and the other two rungs are the deliberate step DOWN to a grey or green @@ -309,6 +311,8 @@ Save.DEFAULT_OPTIONS = { color = "gbc", videoMode = "windowed", fpsCap = 60, + -- 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). voidFill = "fade", diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 7d828cab..3739dd8a 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -266,18 +266,6 @@ local function coreRows(opts, hooks) end) end - -- issue #136: GBC FX soft-bricks the mobile present shader; same gate as - -- the in-game row. - local okFx, GBCFX = pcall(require, "src.render.GBCFX") - if okFx and GBCFX.isSupported() then - add(Strings("GBC FX"), - function() return GBCFX.levelLabel(opts.gbcfx or 0) end, - function(dir) - opts.gbcfx = wrapIndex((opts.gbcfx or 0) + dir, 5) - return true - end) - end - local okZ, Zoom = pcall(require, "src.render.Zoom") if okZ then add(Strings("ZOOM"), @@ -678,17 +666,6 @@ local function gen2Rows(opts, hooks) end) end - -- Same #136 gate as the Gen 1 row and the in-game one. - local okFx, GBCFX = pcall(require, "src.render.GBCFX") - if okFx and GBCFX.isSupported() then - add(Strings("GBC FX"), - function() return GBCFX.levelLabel(opts.gbcfx or 0) end, - function(dir) - opts.gbcfx = wrapIndex((opts.gbcfx or 0) + dir, 5) - return true - end) - end - local okVm, VideoMode = pcall(require, "src.core.VideoMode") if okVm then add(Strings("VIDEO MODE"), @@ -709,6 +686,10 @@ local function gen2Rows(opts, hooks) end) end + -- BATTLE BG (#1709): the WHITE/BLACK pair Gold's battle screen honours. + add(Strings("BATTLE BG"), ladder(opts, "battleBg", + { { "white", "WHITE" }, { "black", "BLACK" } }, "white")) + addTouchRows(rows, add, opts, hooks) return rows diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index fae3cfe4..fe8b77e9 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -417,14 +417,27 @@ local function shellColor(hex) return { tonumber(r, 16), tonumber(g, 16), tonumber(b, 16) } end +-- The real Crystal shell is glitter-flecked translucent plastic over a foil +-- label, so it is the one stock cart that ships with a finish. +local STOCK_FINISH = { crystal = "sparkle+holo" } + +local function finishFlags(name) + name = tostring(name or "") + return name:find("sparkle", 1, true) ~= nil, name:find("holo", 1, true) ~= nil +end + local function cartSkin(imp, version) local row = imp.activeCartRow and imp:activeCartRow(version) or nil if not row then + local sparkle, holo = finishFlags(STOCK_FINISH[version]) return { cacheKey = version, color = cartColor(version), + sparkle = sparkle, holo = holo, labelPath = "assets/labels/" .. tostring(version) .. ".png" } end + local sparkle, holo = finishFlags(row.finish) return { cacheKey = "cart:" .. tostring(row.id), color = shellColor(row.shell) or cartColor(version), + sparkle = sparkle, holo = holo, name = row.title, cart = row, cartId = row.id } end @@ -572,8 +585,56 @@ vec4 position(mat4 transform_projection, vec4 vertex_position) { #endif #ifdef PIXEL +// finish: 0 plain, 1 sparkle (glitter suspended in the shell), 2 holographic +// label sweep. Both are ours, not ported from anywhere. +extern float finish; +extern float finish_time; +extern float finish_spin; + +float sparkHash(vec2 p) { + return fract(sin(dot(p, vec2(41.7321, 289.113))) * 43758.5453); +} + +// Flecks live on a jittered lattice so they read as suspended grains rather +// than a regular grid, and each one twinkles on its own phase. +vec3 sparkle(vec2 screen_coords) { + vec2 cell = screen_coords / 19.0; + vec2 id = floor(cell); + vec2 f = fract(cell); + float peak = 0.0; + for (int oy = -1; oy <= 1; oy++) { + for (int ox = -1; ox <= 1; ox++) { + vec2 n = vec2(float(ox), float(oy)); + vec2 h = vec2(sparkHash(id + n), sparkHash(id + n + 17.0)); + float phase = sparkHash(id + n + 71.0) * 6.2831; + float tw = sin(finish_time * 2.6 + phase + finish_spin * 3.0) * 0.5 + 0.5; + float d = length(f - (n + h)); + peak = max(peak, smoothstep(0.13, 0.0, d) * pow(tw, 16.0)); + } + } + return vec3(peak); +} + +// Angle-dependent spectral sweep: a diagonal band whose hue walks with view +// angle, so tilting the cart moves the rainbow the way real foil does. +vec3 holo(vec2 uv) { + float band = uv.x * 0.9 + uv.y * 0.6 + finish_spin * 0.55 + finish_time * 0.06; + vec3 hue = 0.5 + 0.5 * cos(6.2831 * (band + vec3(0.0, 0.33, 0.67))); + float interference = 0.5 + 0.5 * sin((uv.x - uv.y) * 62.0 + finish_time * 0.9); + return hue * (0.55 + 0.45 * interference); +} + vec4 effect(vec4 color, Image tex, vec2 texture_coords, vec2 screen_coords) { - return Texel(tex, texture_coords) * color; + vec4 px = Texel(tex, texture_coords) * color; + if (finish > 1.5) { + vec3 sheen = holo(texture_coords); + float lum = dot(px.rgb, vec3(0.299, 0.587, 0.114)); + px.rgb = mix(px.rgb, px.rgb * (0.65 + sheen), 0.30 * (0.35 + 0.65 * lum)); + px.rgb += sheen * 0.05 * px.a; + } else if (finish > 0.5) { + px.rgb += sparkle(screen_coords) * 0.70 * px.a; + } + return px; } #endif ]] @@ -601,6 +662,18 @@ local function cartSendHover(shader, mx, my, hovering, screenScale) return ok end +-- FINISH_* match the shader's `finish` uniform. +local FINISH_NONE, FINISH_SPARKLE, FINISH_HOLO = 0, 1, 2 + +local function cartSendFinish(shader, mode, spin) + if not shader or not shader.send then return end + pcall(function() + shader:send("finish", mode) + shader:send("finish_time", Kit.time or 0) + shader:send("finish_spin", spin or 0) + end) +end + local function cartridgeButton(imp, x, y, w, h, key, skin, gameName, action) local state = cartridgeState(imp, skin.cacheKey) markNoDrag(imp, x, y, w, h) @@ -723,6 +796,10 @@ local function cartridgeButton(imp, x, y, w, h, key, skin, gameName, action) love.graphics.rotate(juiceR * 2) love.graphics.translate(-cx, -cy) if useHover then love.graphics.setShader(shader) end + if useHover then + cartSendFinish(shader, skin.sparkle and FINISH_SPARKLE or FINISH_NONE, + state.spin) + end local capH = h * 3 / 65 local mainTop = -halfH + capH @@ -837,6 +914,9 @@ local function cartridgeButton(imp, x, y, w, h, key, skin, gameName, action) local labelPoints = cartQuad(project, labelX, labelY, labelW, labelH, faceZ + 1.2) local label = cartridgeLabel(imp, skin.cacheKey, skin.labelPath, skin.cartId) local mesh = label and cartLabelMesh(imp, skin.cacheKey, label, labelPoints) + if useHover and skin.holo then + cartSendFinish(shader, FINISH_HOLO, state.spin) + end if mesh then love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(mesh) @@ -845,6 +925,10 @@ local function cartridgeButton(imp, x, y, w, h, key, skin, gameName, action) love.graphics.draw(label.image, labelPoints[1][1], labelPoints[1][2], 0, artScale, artScale) end + if useHover and skin.holo then + cartSendFinish(shader, skin.sparkle and FINISH_SPARKLE or FINISH_NONE, + state.spin) + end cartPolygon({ { project(-w * 0.07, h * 0.37, faceZ + 1) }, { project(w * 0.07, h * 0.37, faceZ + 1) }, @@ -863,6 +947,8 @@ local function modStatusColor(status) if status == "safe_mode" then return Strings("Safe mode"), PAL.yellow end if status == "needs_import" then return Strings("Import required"), PAL.yellow end if status == "conflict" then return Strings("Conflict"), PAL.red end + -- a cart pins it, but nothing on this install provides it + if status == "missing" then return Strings("Not installed"), PAL.red end -- not a fault: the mod is intact, this is simply not a game it is for -- (src/mods/ModTargets.lua) if status == "other_game" then return Strings("Not for this game"), PAL.muted end @@ -971,6 +1057,28 @@ local function buildModScopeRow(imp, x, y, w, m) return h + math.floor(8 * m.s) end +-- Says whose mod list is on screen when a cart owns it, and what its seal +-- lets the player do with it. +local function buildModCartRow(imp, x, y, w, m, cartId, report) + if not cartId then return 0 end + local title = (report and report.title) or cartId + local seal = (report and report.seal) or "sealed" + local line + if seal == "sealed+" then + line = Strings("%s pins these mods. You may switch any of them on or off, but not add or remove any.", + title) + elseif seal == "open" then + line = Strings("%s pins these mods. Anything it ships switched off is yours to switch on.", + title) + else + line = Strings("%s is sealed: these are its mods, and they run exactly as pinned. Break the seal on the cart's own page to change that.", + title) + end + local h = Kit.textWrapped("small", line, x, y, w, + seal == "sealed" and PAL.yellow or PAL.blue, 3) + return h + math.floor(8 * m.s) +end + local function buildSaveCartRow(imp, x, y, w, m) local version = imp.modScope local h = m.btnH @@ -1000,6 +1108,12 @@ local function buildSaveCartRow(imp, x, y, w, m) return h + gap end +-- The launcher's name for a game id, where all a row has is the id. +local function gameLabel(version) + local info = GameVersion.info(version) + return (info and (info.launcherName or info.displayName)) or tostring(version) +end + local function findActionFor(entry, installedVersion) local ModIndex = require("src.mods.ModIndex") if not ModIndex.canInstall(entry) then @@ -1837,6 +1951,11 @@ local function SEAL_LABEL(armed) return armed and Strings("Break it") or Strings("Break the seal") end +local function FILL_LABEL(count) + if count > 1 then return Strings("Install %d required mods", count) end + return Strings("Install required mods") +end + local function sealSlotName(slot) if not slot then return nil end if type(slot.label) == "string" and slot.label ~= "" then return slot.label end @@ -1849,16 +1968,25 @@ local function buildCartCard(imp, x, y, w, m, version) if not report then return 0 end local title = tostring(report.title or report.id or "") local broken = (slot and slot.sealBroken == true) or false + local fillCount = imp.cartFillRows and #imp:cartFillRows(version) or 0 local state, stateCol, body = nil, PAL.green, {} if report.refused then state, stateCol = Strings("This cart will not start"), PAL.red body[#body + 1] = { report.message, PAL.detail } + if fillCount > 0 then + body[#body + 1] = { Strings("Install the mods it pins to play it the way its author built it."), + PAL.detail } + end body[#body + 1] = { Strings("Break the seal to play it with the mods you have."), PAL.detail } elseif broken then state, stateCol = Strings("Seal broken"), PAL.yellow body[#body + 1] = { Strings("This save loads the cart's pinned mods first, then your other enabled mods. It is marked modified."), PAL.detail } + elseif report.seal == "sealed+" then + state = Strings("Sealed - ready to play") + body[#body + 1] = { Strings("This cart loads only the mods it pins. You can switch any of them on or off."), + PAL.detail } elseif report.sealed then state = Strings("Sealed - ready to play") body[#body + 1] = { Strings("This cart loads only the mods it pins."), @@ -1883,15 +2011,41 @@ local function buildCartCard(imp, x, y, w, m, version) PAL.yellow } body[#body + 1] = { Strings("Press Break it again to do it."), PAL.yellow } end + -- What the last install run managed, and per mod what it could not. + local fillNotice = imp.cartFillNotice + if fillNotice then + body[#body + 1] = { fillNotice.text, + fillNotice.ok and PAL.green or PAL.red } + for _, line in ipairs(fillNotice.failures or {}) do + body[#body + 1] = { line, PAL.red } + end + end local pad = math.floor(14 * m.s) local iw = w - 2 * pad - local chipH = offer and math.max(Kit.tapMin(), math.floor(30 * m.s)) or 0 + local fillLabel = FILL_LABEL(fillCount) + local chipGap = math.floor(8 * m.s) + local chipH = math.max(Kit.tapMin(), math.floor(30 * m.s)) + local fillW = fillCount > 0 + and math.min(iw, chipWidth(fillLabel, m)) or 0 + local sealW = offer and math.min(iw, math.max(chipWidth(SEAL_LABEL(false), m), + chipWidth(SEAL_LABEL(true), m))) or 0 + -- Both chips share a row when they fit; a narrow card stacks them instead. + local sideBySide = fillW > 0 and sealW > 0 + and (fillW + chipGap + sealW) <= iw + local chipRows = 0 + if fillW > 0 then chipRows = chipRows + 1 end + if sealW > 0 then chipRows = chipRows + 1 end + if sideBySide then chipRows = 1 end + if chipRows == 0 then chipH = 0 end + local h = pad + Kit.textHeight("button") + math.floor(4 * m.s) for _, line in ipairs(body) do h = h + Kit.wrapHeight("small", line[1] or "", iw, 3) end - if offer then h = h + math.floor(8 * m.s) + chipH end + if chipRows > 0 then + h = h + math.floor(8 * m.s) + chipRows * chipH + (chipRows - 1) * chipGap + end h = h + pad Kit.card(x, y, w, h) @@ -1902,14 +2056,23 @@ local function buildCartCard(imp, x, y, w, m, version) cy = cy + Kit.textWrapped("small", line[1] or "", x + pad, cy, iw, line[2], 3) end - if offer then + if chipRows > 0 then cy = cy + math.floor(8 * m.s) - local cw = math.max(chipWidth(SEAL_LABEL(false), m), - chipWidth(SEAL_LABEL(true), m)) - btn(imp, x + pad, cy, cw, chipH, "seal-" .. scope, SEAL_LABEL(armed), { - kind = "danger", font = "small", keepArm = true, - action = function() imp:pressBreakSeal(version) end, - }) + local bx = x + pad + if fillW > 0 then + btn(imp, bx, cy, fillW, chipH, "cart-fill-" .. scope, fillLabel, { + kind = "primary", font = "small", + action = function() imp:pressInstallCartMods(version) end, + }) + if sideBySide then bx = bx + fillW + chipGap + else cy = cy + chipH + chipGap end + end + if sealW > 0 then + btn(imp, bx, cy, sealW, chipH, "seal-" .. scope, SEAL_LABEL(armed), { + kind = "danger", font = "small", keepArm = true, + action = function() imp:pressBreakSeal(version) end, + }) + end end return h end @@ -2182,6 +2345,10 @@ local function buildModsPanel(imp, x, y, w, availH, m) local mods = imp.mods or {} local gap = m.gap local cy = y + local cartId, cartReport + if imp.modCartPlan then cartId, cartReport = imp:modCartPlan() end + -- a cart owns its mod set: only the pins it already ships may be switched + local bulkOk = not safeMode and cartId == nil -- header: progressive action cluster. Surfaces primary/frequent actions -- (Import, Updates, Sort) directly on the bar across screen sizes, placing @@ -2210,11 +2377,11 @@ local function buildModsPanel(imp, x, y, w, availH, m) action = function() imp:chooseMod() end }) btn(imp, place(disableW), cy, disableW, bh, "mods-disable-all", Strings("Disable all"), { kind = "warn", font = "small", - enabled = not safeMode, + enabled = bulkOk, action = function() imp:_setAllMods(false) end }) btn(imp, place(enableW), cy, enableW, bh, "mods-enable-all", Strings("Enable all"), { kind = "good", font = "small", - enabled = not safeMode, + enabled = bulkOk, action = function() imp:_setAllMods(true) end }) btn(imp, place(checkFullW), cy, checkFullW, bh, "mods-check-updates", Strings("Check for updates"), { font = "small", @@ -2275,6 +2442,7 @@ local function buildModsPanel(imp, x, y, w, availH, m) + math.floor(8 * m.s) cy = cy + buildModScopeRow(imp, x, cy, w, m) + cy = cy + buildModCartRow(imp, x, cy, w, m, cartId, cartReport) cy = cy + buildSaveCartRow(imp, x, cy, w, m) if #mods == 0 then @@ -2368,22 +2536,40 @@ local function buildModsPanel(imp, x, y, w, availH, m) -- These answer separate games, not a single shared install flag. The -- importer receives the game id so an experimental confirmation also - -- applies only to the checkbox the player pressed. + -- applies only to the checkbox the player pressed. A cart's pin answers + -- one game -- the cart's -- so it gets one switch instead of the row. local flipped = false local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s) - Kit.text("micro", gamesLabel, px, - gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted) - local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s) - for _, game in ipairs(GameVersion.ORDER) do - local togKey = "mod-toggle-" .. mod.id .. "-" .. game - if modGameCheckbox(tx, gamesY, togH, - mod.enabledByVersion and mod.enabledByVersion[game] == true, - game, togKey, not safeMode) then - local version = game - queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end) + local rowLabel = gamesLabel + if mod.cartPin then + rowLabel = mod.cartTogglable and Strings("In this cart:") + or Strings("Pinned, sealed:") + end + Kit.text("micro", rowLabel, px, + gamesY + (togH - Kit.textHeight("micro")) / 2, + mod.cartPin and not mod.cartTogglable and PAL.yellow or PAL.muted) + local tx = px + Kit.textWidth("micro", rowLabel) + math.floor(10 * m.s) + if mod.cartPin then + local togKey = "mod-toggle-" .. mod.id .. "-cart" + -- pressable even when the seal refuses it, so the panel can say why + if modGameCheckbox(tx, gamesY, togH, mod.enabled == true, + mod.cartBase or "red", togKey, not safeMode) then + queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, nil) end) flipped = true end tx = tx + togH + togGap + else + for _, game in ipairs(GameVersion.ORDER) do + local togKey = "mod-toggle-" .. mod.id .. "-" .. game + if modGameCheckbox(tx, gamesY, togH, + mod.enabledByVersion and mod.enabledByVersion[game] == true, + game, togKey, not safeMode) then + local version = game + queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end) + flipped = true + end + tx = tx + togH + togGap + end end -- The checkboxes sit inside the row's rect, so their press also passes the -- row hit test; `flipped` gates the row action to everywhere else. @@ -2399,17 +2585,27 @@ local function buildModsPanel(imp, x, y, w, availH, m) -- in-game manager shows (src/mods/ModTargets.lua) local gamesW = mod.targets and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0 + -- the cart's own list, not the player's: every row says so + local pinLabel = mod.cartPin and Strings("PINNED") or nil + local pinW = pinLabel + and Kit.textWidth("micro", pinLabel) + math.floor(12 * m.s) or 0 local nameShown = Kit.ellipsize("button", mod.name, - textW - badgeW - gamesW - math.floor(12 * m.s)) + textW - badgeW - gamesW - pinW - math.floor(12 * m.s)) local headingCol = isFullyDisabled and PAL.muted or PAL.heading Kit.text("button", nameShown, px, ly, headingCol) local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s) Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge, mod.experimental and PAL.yellow or PAL.muted) + tagX = tagX + badgeW + math.floor(4 * m.s) if mod.targets then - Kit.tag(tagX + badgeW + math.floor(4 * m.s), ly, gamesW, + Kit.tag(tagX, ly, gamesW, Kit.textHeight("button"), mod.targets, mod.targetsHere == false and PAL.steel or PAL.blue) + tagX = tagX + gamesW + math.floor(4 * m.s) + end + if pinLabel then + Kit.tag(tagX, ly, pinW, Kit.textHeight("button"), pinLabel, + mod.cartTogglable and PAL.blue or PAL.yellow) end ly = ly + Kit.textHeight("button") + math.floor(4 * m.s) @@ -2830,14 +3026,45 @@ local function buildBugPanel(imp, x, y, w, availH, m) end }) end +-- FIND tab: which half of the feed is on screen, in the same chip idiom the +-- MODS tab's scope row uses. The counts say which half is worth pressing. +local function buildFindKindRow(imp, x, y, w, m) + local h = math.max(Kit.tapMin(), math.floor(26 * m.s)) + local gap = math.floor(6 * m.s) + local label = Strings("Browse:") + Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted) + local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s) + local options = { + { id = "mods", label = Strings("Mods"), + n = #((imp.findIndex and imp.findIndex.mods) or {}) }, + { id = "carts", label = Strings("Carts"), + n = #((imp.findIndex and imp.findIndex.carts) or {}) }, + } + for _, opt in ipairs(options) do + local text = ("%s (%d)"):format(opt.label, opt.n) + local cw = Kit.textWidth("micro", text) + math.floor(18 * m.s) + if cx + cw > x + w then break end + if Kit.chip(cx, y, cw, h, text, imp.findKind == opt.id, PAL.lineStrong, + "find-kind-" .. opt.id) then + local want = opt.id + queueAction(imp, "find-kind-" .. want, + function() imp:_setFindKind(want) end) + end + cx = cx + cw + gap + end + return h + math.floor(8 * m.s) +end + local function buildFindPanel(imp, x, y, w, availH, m) imp:_ensureFind() imp:_ensureMods() local ModIndex = require("src.mods.ModIndex") local ModUpdate = require("src.mods.ModUpdate") local sources = imp.findSources or {} + local carts = imp.findKind == "carts" local rows = imp:_findRows() - local total = #((imp.findIndex and imp.findIndex.mods) or {}) + local total = #((imp.findIndex + and (carts and imp.findIndex.carts or imp.findIndex.mods)) or {}) local gap = m.gap local cy = y @@ -2868,6 +3095,8 @@ local function buildFindPanel(imp, x, y, w, availH, m) return (cy - y) + h end + cy = cy + buildFindKindRow(imp, x, cy, w, m) + -- One row: the search field, then Filter / Sort / Indexes popup buttons. local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s)) local bgap = math.floor(6 * m.s) @@ -2880,22 +3109,31 @@ 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 is active, - -- so a filtered-down list never reads as "the index shrank". + -- 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 fw = Kit.textWidth("small", Strings("Filter")) + math.floor(20 * m.s) btn(imp, place(fw), cy, fw, fieldH, "find-filter", Strings("Filter"), { - kind = imp.findCategory and "accent" or "ghost", font = "small", + kind = activeFilter and "accent" or "ghost", font = "small", action = function() imp._filterPopup = true end }) local searchW = place(0) - x - bgap textField(imp, x, cy, searchW, fieldH, "find-search", imp.findQuery or "", - Strings("Search mods"), imp._findSearchFocus == true, + carts and Strings("Search carts") or Strings("Search mods"), + imp._findSearchFocus == true, function() imp:_toggleFindSearchFocus() end) cy = cy + fieldH + math.floor(8 * m.s) if #rows == 0 then - Kit.emptyBox(x, cy, w, math.floor(110 * m.s), - (total == 0) and Strings("This index lists no mods yet.") - or Strings("No mods match that search.")) + local empty + if carts then + empty = (total == 0) and Strings("This index lists no carts yet.") + or Strings("No carts match that search.") + else + empty = (total == 0) and Strings("This index lists no mods yet.") + or Strings("No mods match that search.") + end + Kit.emptyBox(x, cy, w, math.floor(110 * m.s), empty) return (cy - y) + math.floor(110 * m.s) end @@ -2995,7 +3233,7 @@ local function buildFindPanel(imp, x, y, w, availH, m) if imp:_findThumbPending(entry.id) then Kit.spinner(px + thumb / 2, ly + thumb / 2, thumb * 0.28) else - Kit.textCenter("micro", "MOD", px, + Kit.textCenter("micro", carts and "CART" or "MOD", px, ly + (thumb - Kit.textHeight("micro")) / 2, thumb, PAL.faint) end end @@ -3018,7 +3256,12 @@ local function buildFindPanel(imp, x, y, w, availH, m) or nil local rest = {} if entry.author then rest[#rest + 1] = entry.author end - if entry.categories and entry.categories[1] then + if carts then + -- A cart has no categories; the game it plays as and its seal are what + -- a reader is actually choosing between. + rest[#rest + 1] = gameLabel(entry.base) + if entry.seal then rest[#rest + 1] = entry.seal end + elseif entry.categories and entry.categories[1] then rest[#rest + 1] = entry.categories[1] end if dates then @@ -3300,6 +3543,8 @@ local function buildConfirmModal(imp, m) imp._modConfirm = nil if c.indexEntry then imp:_findInstall(c.indexEntry) + elseif c.kind == "cartPins" then + imp:_installCartPins(c.version, c.id) elseif c.kind == "update" then imp:_confirmModUpdate(c.id, c.release) elseif c.kind == "enableAll" then @@ -3624,12 +3869,15 @@ local function buildModHeaderActionsModal(imp, m) local pad = math.floor(18 * m.s) local w = math.floor(380 * m.s) local gap = math.floor(8 * m.s) + -- same gate as the header cluster: a cart's mod set is not bulk-editable + local bulkOk = not imp.safeMode + and not (imp.modCartPlan and imp:modCartPlan()) 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("Enable all mods"), kind = "good", enabled = not imp.safeMode, + { label = Strings("Enable all mods"), kind = "good", enabled = bulkOk, action = function() imp:_setAllMods(true) end }, - { label = Strings("Disable all mods"), kind = "warn", enabled = not imp.safeMode, + { label = Strings("Disable all mods"), kind = "warn", enabled = bulkOk, action = function() imp:_setAllMods(false) end }, { label = Strings("Sort mods..."), action = function() imp._sortPopup = "mods" end }, } @@ -3760,8 +4008,10 @@ local function buildGameModal(imp, m) action = function() imp._gamePopup = nil end }) end +local SEAL_WORD = { open = "open", ["sealed+"] = "sealed+" } + local function cartRowLabel(row) - local seal = (row.seal == "open") and Strings("open") or Strings("sealed") + local seal = Strings(SEAL_WORD[row.seal] or "sealed") return Strings("%s - v%s - %s", tostring(row.title or row.id), tostring(row.version or "?"), seal) end @@ -3828,11 +4078,12 @@ local function buildCartModal(imp, m) Kit.pager(px + pad, cy, pw - 2 * pad, cur, #rows, perPage, pageKey)) cy = cy + pagerH + gap end + local FilePicker = require("src.core.FilePicker") btn(imp, px + pad, cy, pw - 2 * pad, rowH, "cartpop-more", - Strings("Get more carts"), { kind = "accent", font = "small", - action = function() - imp._cartNotice = Strings("Browsing for carts arrives in a later update.") - end }) + FilePicker.available() and Strings("Import a cart") + or Strings("Get more carts"), + { kind = "accent", font = "small", + action = function() imp:importCartFile(version) end }) cy = cy + rowH + gap btn(imp, px + pad, cy, pw - 2 * pad, rowH, "cartpop-close", Strings("Close"), { font = "small", @@ -3949,12 +4200,17 @@ local function buildCartSaveModal(imp, m) PAL.muted) end --- Category filter for FIND MODS. Two columns, because an index can list --- enough categories to overflow a single stacked column on a short window. +-- 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 buildFilterModal(imp, m) - local cats = (imp.findIndex and imp.findIndex.categories) or {} + local carts = imp.findKind == "carts" + local keys = (imp.findIndex + and (carts and imp.findIndex.baseGames or imp.findIndex.categories)) or {} local items = { { key = nil, label = Strings("All") } } - for _, c in ipairs(cats) do items[#items + 1] = { key = c, label = c } end + for _, c in ipairs(keys) do + items[#items + 1] = { key = c, label = carts and gameLabel(c) or c } + end local pad = math.floor(18 * m.s) local w = math.floor(440 * m.s) local gap = math.floor(8 * m.s) @@ -3963,18 +4219,20 @@ local function buildFilterModal(imp, m) + 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 category"), px + pad, cy, PAL.heading) + 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) 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 local bx = px + pad + ((i - 1) % 2) * (colW + gap) local by = cy + math.floor((i - 1) / 2) * (m.btnH + gap) local key = it.key btn(imp, bx, by, colW, m.btnH, "filterpop-" .. (key or "all"), it.label, { - kind = (imp.findCategory == key) and "primary" or "ghost", + kind = (active == key) and "primary" or "ghost", font = "small", action = function() - imp.findCategory = key + if carts then imp.findBase = key else imp.findCategory = key end setPage(imp, "find", 1) imp._filterPopup = nil end }) @@ -4332,8 +4590,14 @@ local function buildFindEntryModal(imp, m) end trend = (#trend > 0) and table.concat(trend, " - ") or nil local trendH = trend and (Kit.textHeight("small") + math.floor(2 * m.s)) or 0 + -- What a cart actually is: a pinned mod list. Its own page installs them; + -- this popup only ever installs the cart file. + local pins = ModIndex.isCart(entry) + and Strings("Pins %d mod(s) - install them from the cart's own page", + #(entry.mods or {})) or nil + local pinsH = pins and (Kit.textHeight("small") + math.floor(2 * m.s)) or 0 local h = pad + Kit.textHeight("button") + math.floor(4 * m.s) - + Kit.textHeight("small") + trendH + noteH + math.floor(12 * m.s) + + Kit.textHeight("small") + trendH + pinsH + noteH + math.floor(12 * m.s) + nBtns * (m.btnH + gap) - gap + pad local px, py, pw = modalPanel(m, w, h) local cy = py + pad @@ -4342,7 +4606,10 @@ local function buildFindEntryModal(imp, m) cy = cy + Kit.textHeight("button") + math.floor(4 * m.s) local lead = "v" .. tostring(ModIndex.displayVersion(entry)) if entry.author then lead = lead .. " - " .. entry.author end - if entry.categories and entry.categories[1] then + if ModIndex.isCart(entry) then + lead = lead .. " - " .. gameLabel(entry.base) + if entry.seal then lead = lead .. " - " .. entry.seal end + elseif entry.categories and entry.categories[1] then lead = lead .. " - " .. entry.categories[1] end local dl = stats and ModUpdate.downloadsShort(stats.total) or nil @@ -4359,6 +4626,12 @@ local function buildFindEntryModal(imp, m) px + pad, cy, PAL.muted) cy = cy + Kit.textHeight("small") end + if pins then + cy = cy + math.floor(2 * m.s) + Kit.text("small", Kit.ellipsize("small", pins, pw - 2 * pad), + px + pad, cy, PAL.muted) + cy = cy + Kit.textHeight("small") + end if note then cy = cy + math.floor(4 * m.s) Kit.text("small", note, px + pad, cy, PAL.green) diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 0de65b5d..9584a9b6 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -1386,11 +1386,9 @@ end -- tiles, no sheet of its own. So the rows here point at the icon sheets -- extractIcons already writes rather than at a second copy of them. -- --- One frame, not two. _DoesSpriteHaveFacings sends everything from --- SPRITE_POKEMON up to .only_down, and a doll is a still object that never --- steps, so the only OAM set it ever uses is FacingStepDown0 -- tiles $00..$03, --- the icon's FIRST frame (data/sprites/facings.asm). The second frame is the --- party menu's bob and never reaches the map. +-- Two frames: SPRITEMOVEDATA_POKEMON's OBJECT_ACTION_BOUNCE swaps +-- FacingStepDown0's tiles $00..$03 for FacingStepUp0's $04..$07 (#1748). +-- data/sprites/map_objects.asm:181-187, engine/overworld/map_object_action.asm:184 -- -- Palette 0 because _GetSpritePalette answers `xor a` for every mon sprite, -- which is PAL_OW_RED in the MapObjectPals set. @@ -1418,7 +1416,7 @@ function RomExtractorGen2:extractMonSprites(out) id = constName, source = ("ROM:SpriteMons[%d]"):format(row), image = "assets/generated/icons/gen2/" .. base .. ".png", - frames = 1, + frames = 2, walker = false, spriteType = "POKEMON_SPRITE", palette = SPRITE_PALETTE_NAME[0], @@ -4039,6 +4037,12 @@ function RomExtractorGen2:splashGfx() local fade = self:symbol("GameFreakDittoPaletteFade") out.dittoFade = self:colors(fade.bank, fade.address, 16) end + -- gfx/splash/ditto.pal, the OBJ palette _CGB_GamefreakLogo loads + -- (../pokecrystal/engine/gfx/cgb_layouts.asm:876-893). + if self.symbols["_CGB_GamefreakLogo.GamefreakDittoPalette"] then + local pal = self:symbol("_CGB_GamefreakLogo.GamefreakDittoPalette") + out.dittoPalette = self:colors(pal.bank, pal.address, 4) + end return out end @@ -5844,6 +5848,21 @@ function RomExtractorGen2:extractMenuGfx() 8, 8, "emotes/grass_rustle.png", true) emotes.grassRustle = "assets/generated/emotes/grass_rustle.png" end + -- LoadFishingGFX (engine/events/fishing_gfx.asm:1-21): three 16x8 pose rows + -- over the standing frames' bottom tiles, then the rod tiles $fc/$fd (#1708). + local fishing = self.symbols["FishingGFX"] + if fishing then + self:write2bpp(self.rom:bytes(fishing[1], fishing[2], 8 * 16), + 16, 32, "emotes/fishing.png", true) + emotes.fishing = "assets/generated/emotes/fishing.png" + end + -- ../pokecrystal/engine/events/fishing_gfx.asm:41, Kris' half of that art. + local krisFishing = self.symbols["KrisFishingGFX"] + if krisFishing then + self:write2bpp(self.rom:bytes(krisFishing[1], krisFishing[2], 8 * 16), + 16, 32, "emotes/fishing_female.png", true) + emotes.fishingFemale = "assets/generated/emotes/fishing_female.png" + end out.emotes = emotes -- The Pokecenter heal machine's OBJ art (engine/events/heal_machine_anim diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 2b0097f7..0c7074f6 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -161,6 +161,9 @@ local VERSION_REQUIRED_FILES_OVERRIDE = { "assets/generated/card_flip/card_flip_1.png", -- PCMailGFX (engine/pokemon/bills_pc.asm:2170-2173) "assets/generated/pc/mail_item.png", + -- FishingGFX (engine/events/fishing_gfx.asm:23): the pose rows and the rod + -- tiles, which a cache built before #1708 has none of. + "assets/generated/emotes/fishing.png", }, crystal = { "data/generated/constants.lua", @@ -208,6 +211,8 @@ local VERSION_REQUIRED_FILES_OVERRIDE = { "assets/generated/battle/player_back_female.png", "assets/generated/battle/trainers/kris.png", "assets/generated/battle/trainers/chris.png", + -- ../pokecrystal/engine/events/fishing_gfx.asm:38-42 + "assets/generated/emotes/fishing.png", }, } -- Same Gen 2 extract, so a Silver cache is complete when the same files exist. @@ -709,7 +714,7 @@ function RomImporter:ensureImportsDir() return false end --- NX mod zip inbox (separate from ROM imports/). Parent imports/ first — +-- NX mod zip inbox (separate from ROM imports/). Parent imports/ first -- -- love.filesystem.createDirectory does not create nested parents. function RomImporter:ensureModsInboxDir() self:ensureImportsDir() @@ -723,7 +728,7 @@ function RomImporter:ensureModsInboxDir() end -- NX raw .sav inbox per game: imports/saves/{red,blue,yellow}/. --- Parent imports/ then imports/saves/ first — createDirectory is not nested. +-- Parent imports/ then imports/saves/ first -- createDirectory is not nested. -- Creates all three version folders so MTP browsing shows where each game goes. function RomImporter:ensureSavesInboxDir(version) self:ensureImportsDir() @@ -797,7 +802,7 @@ local function listRomPaths(dir) local paths = {} for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do -- Skip AppleDouble / hidden junk from Mac MTP (._cart.gb ends in .gb - -- but is not a ROM — rescan would try it first and block the real dump). + -- but is not a ROM -- rescan would try it first and block the real dump). if name:sub(1, 1) ~= "." then local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name) if name:lower():match("%.gbc?$") @@ -872,7 +877,7 @@ local function listZipPaths(dir) local paths = {} for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do -- Skip AppleDouble / hidden junk from Mac MTP (._foo.zip ends in .zip - -- but is not a PhysFS archive — mount fails with "could not be opened"). + -- but is not a PhysFS archive -- mount fails with "could not be opened"). if name:sub(1, 1) ~= "." then local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name) if name:lower():match("%.zip$") @@ -888,7 +893,7 @@ local function listSavPaths(dir) local paths = {} for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do -- Skip AppleDouble / hidden junk from Mac MTP (._foo.sav ends in .sav - -- but is not a real battery save — import would fail and invent noise). + -- but is not a real battery save -- import would fail and invent noise). if name:sub(1, 1) ~= "." then local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name) if name:lower():match("%.sav$") @@ -1064,7 +1069,7 @@ function RomImporter:rescanSavesAction(version) elseif skipCount > 0 then self.saveNotice[version] = { ok = true, - text = Strings("Already imported — %d file(s) skipped. Check SAVE SLOT.", + text = Strings("Already imported: %d file(s) skipped. Check SAVE SLOT.", skipCount), } end @@ -1075,7 +1080,7 @@ end -- picking the first pending file would jump Yellow → Red (and switch the -- launcher tab via startData). Other known dumps stay for their own tabs. -- Junk (wrong size / unknown hash) still surfaces when nothing matches the --- tab and no other known dump is present — same feedback as before for a +-- tab and no other known dump is present -- same feedback as before for a -- lone bad file. function RomImporter:rescanAction(version) if self.workState == "working" then return end @@ -1130,7 +1135,7 @@ function RomImporter:rescanAction(version) status = Strings("No matching ROM found."), detail = Strings( "%s is matched by SHA-1 on this tab. Other dumps in imports/ stay " - .. "for their own tabs — open that game and Scan again.", label), + .. "for their own tabs; open that game and Scan again.", label), } return end @@ -1617,6 +1622,9 @@ function RomImporter.new(onComplete, opts) findLoaded = false, findSources = nil, findIndex = nil, findScroll = 0, findNotice = nil, findQuery = "", findCategory = nil, _findSearchFocus = false, _findThumbs = nil, + -- 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, 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 @@ -2223,7 +2231,7 @@ end -- Android mirrors ROM import: scan for a pending .zip in the save dir (USB -- or a fresh SAF drop), else love.system.pickFile("mod") -> picked_mod.zip -- which focus/Choose consumes on return. --- NX: no HostShell/desktop picker — rescan imports/mods/ inbox instead. +-- NX: no HostShell/desktop picker -- rescan imports/mods/ inbox instead. function RomImporter:chooseMod() if self.workState == "working" then return end if self.isNX then @@ -2517,7 +2525,7 @@ end -- "Import save" button: open a native .sav picker and import the pick. -- Android mirrors ROM / mod import via love.system.pickFile("sav"). --- NX: no HostShell/desktop picker — rescan imports/saves/ inbox instead. +-- NX: no HostShell/desktop picker -- rescan imports/saves/ inbox instead. function RomImporter:chooseSaveImport(version) if self.workState == "working" then return end version = self:_resolveSaveVersion(version) @@ -2820,6 +2828,8 @@ function RomImporter:update(dt) self:_pumpSync(dt) self:_pumpModCheck() self:_pumpModInstall() + self:_pumpCartInstall() + self:_pumpCartFill() 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 @@ -2854,6 +2864,10 @@ function RomImporter:update(dt) if os.getenv("POKEPORT_LAUNCHER_SETTINGS") == "1" then self:_openSettings() end + -- POKEPORT_LAUNCHER_FIND_KIND=mods|carts picks which half of the feed + -- the FIND tab is browsing; the switch is otherwise only a click. + local findKind = os.getenv("POKEPORT_LAUNCHER_FIND_KIND") + if findKind and findKind ~= "" then self:_setFindKind(findKind) end local query = os.getenv("POKEPORT_LAUNCHER_QUERY") if query and query ~= "" then self.findQuery = query @@ -3124,7 +3138,7 @@ function RomImporter:_updatePadCursor(dt) require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48) end -- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer. - -- NX: the getPosition bridge already returns pad coords — skip setPosition. + -- NX: the getPosition bridge already returns pad coords -- skip setPosition. if not self.isNX and love.mouse.setPosition then pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y) self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y @@ -3512,7 +3526,11 @@ function RomImporter:_disableSkins() local SaveData = require("src.core.SaveData") local opts = SaveData.loadOptions() local tc = type(opts.touchControls) == "table" and opts.touchControls or {} - tc.enabled, tc.skin = false, nil + -- `enabled` is the pad's own switch, not a skins switch: turning a skin off + -- has to leave the built-in pad on, which is what the notice promises. A + -- skin can only be active while the pad is enabled, so this never overrides + -- a player who turned the pad off deliberately. + tc.enabled, tc.skin = true, nil opts.touchControls = tc SaveData.saveOptions(opts) self._skinNotice = { ok = true, text = "Skins are off. Mobile will use the built-in pad when needed." } @@ -4075,6 +4093,50 @@ end -- The view's open-folder affordance needs the same file:// encoding the old -- notice line used. +-- Desktop picks a .cart file; everywhere else CartStore's stray scan already +-- adopts anything dropped in the folder, so we just point at it. +function RomImporter:importCartFile(version) + local CartStore = require("src.carts.CartStore") + local FilePicker = require("src.core.FilePicker") + if not FilePicker.available() then + local dir = self:cartsDir() + self._cartNotice = dir + and Strings("Drop .cart files in %s, then reopen this list.", dir) + or Strings("No filesystem available to import from.") + return false + end + local path = FilePicker.open("Choose a cart", + { label = "Cart", exts = { CartStore.EXT:gsub("^%.", "") } }) + if not path then return false end + local bytes = FilePicker.read(path) + if not bytes then + self._cartNotice = Strings("Could not read %s", FilePicker.basename(path)) + return false + end + local cart, err = CartStore.install(bytes) + if not cart then + self._cartNotice = Strings("That cart could not be imported: %s", + tostring(err)) + return false + end + self:_refreshCarts(version) + self._cartNotice = Strings("Imported %s. It is in this list now.", + tostring(cart.title or cart.id)) + return true +end + +-- The real OS path of the cart folder, so the launcher can open it and so a +-- player can drop a .cart in by hand. +function RomImporter:cartsDir() + local CartStore = require("src.carts.CartStore") + local fs = love and love.filesystem + if not fs then return nil end + if fs.createDirectory then pcall(fs.createDirectory, CartStore.DIR) end + local base = fs.getSaveDirectory and fs.getSaveDirectory() + if not base or base == "" then return nil end + return base .. "/" .. CartStore.DIR +end + function RomImporter:fileUrl(path) return fileUrl(path) end @@ -4262,17 +4324,21 @@ function RomImporter:keypressed(key) end end +-- list, not index: index reads only the registry, so a .g1rcart dropped into +-- the carts folder by hand would never appear. list adopts strays and heals +-- the registry, and this is cached per version so it parses once. function RomImporter:_refreshCarts(version) local out = {} local ok, rows = pcall(function() - return require("src.carts.CartStore").index() + return require("src.carts.CartStore").listFor(version) end) if ok and type(rows) == "table" then - for _, row in ipairs(rows) do - if row.base == version then out[#out + 1] = row end - end + for _, row in ipairs(rows) do out[#out + 1] = row end end self.carts[version] = out + -- The FIND panel's installed-cart map spans every game, so it goes stale + -- on any cart change, not just this version's. + self._findCartMap = nil return out end @@ -4303,9 +4369,12 @@ end function RomImporter:_selectCart(version, id) self._cartPopup = nil self._cartNotice = nil + self.cartFillNotice = nil if id ~= nil and not self:_cartById(version, id) then return end self.activeCart[version] = id self._cartPlan = nil + -- the MODS panel answers for the cart now, so its cached rows are stale + self.mods, self._modSortCache = nil, nil local scope = self:slotScope(version) self.slots[scope] = nil self.slotScroll[scope] = nil @@ -4381,6 +4450,187 @@ function RomImporter:breakCartSeal(version) return ok and true or false end +-- ------- installing the mods a cart pins +-- +-- The other way out of a refusal: fetch every pin at the version the cart +-- names, verified against the sha256 it recorded, instead of breaking the seal. + +local function pinSameVersion(want, have) + local Semver = require("src.mods.Semver") + local order = Semver.compare(want, have) + if order ~= nil then return order == 0 end + return want == have +end + +-- Why a pin cannot be fetched. Only a github pin carries a repo, a version +-- and an archive hash, which is all three things an install needs. +local function pinUnresolvable(pin) + local source = type(pin) == "table" and pin.source or nil + if source == "gamebanana" then + return "is pinned to a GameBanana file, which the launcher cannot download" + end + if source == "local" then + return "is pinned to a copy on the machine that built the cart, so there is nothing to download" + end + if source ~= "github" then + return "carries no source the launcher can install from" + end + if type(pin.repo) ~= "string" or pin.repo == "" then + return "names no GitHub repository" + end + if type(pin.version) ~= "string" or pin.version == "" then + return "names no version to install" + end + if type(pin.sha256) ~= "string" or #pin.sha256 ~= 64 then + return "records no sha256, so its archive could not be verified" + end + return nil +end + +-- The pins the player cannot satisfy: absent, or installed at some other +-- version. A mismatch needs the PINNED version, so it queues like a gap. +function RomImporter:cartFillRows(version) + local report = self:cartPlan(version) + if type(report) ~= "table" then return {} end + local rows = {} + for _, row in ipairs(report.missing or {}) do + rows[#rows + 1] = { id = row.id, version = row.version, + pin = (report.pins or {})[row.id] } + end + for _, row in ipairs(report.mismatched or {}) do + rows[#rows + 1] = { id = row.id, version = row.version, + installed = row.installed, pin = (report.pins or {})[row.id] } + end + return rows +end + +-- A configured index already knows where a mod's archive lives, so it saves a +-- round trip -- but only when it points at exactly the pinned version. +function RomImporter:_cartPinIndexRelease(row) + local mods = self.findIndex and self.findIndex.mods or nil + if type(mods) ~= "table" then return nil end + local ModIndex = require("src.mods.ModIndex") + for _, entry in ipairs(mods) do + if type(entry) == "table" and entry.id == row.id + and pinSameVersion(row.version, ModIndex.displayVersion(entry)) then + local release = ModIndex.releaseFor(entry) + if type(release) == "table" and release.zip and release.zip.url then + return release + end + end + end + return nil +end + +function RomImporter:pressInstallCartMods(version) + if self._cartFill or self._modInstall or self._cartInstall then return false end + local rows = self:cartFillRows(version) + if #rows == 0 then return false end + self.cartFillNotice = nil + self._cartFill = { version = version, rows = rows, index = 0, + installed = 0, failures = {}, stage = "next" } + self:_pumpCartFill() + return true +end + +function RomImporter:_cartFillFailed(msg) + local job = self._cartFill + if not job then return end + local row = job.row + job.failures[#job.failures + 1] = + ("%s %s"):format(tostring(row and row.id or "?"), tostring(msg)) + job.stage = "next" +end + +function RomImporter:_finishCartFill() + local job = self._cartFill + self._cartFill = nil + self:_clearBusy() + -- Re-plan against what is on disk now, so the card answers for itself. + self._cartPlan = nil + pcall(self._refreshMods, self) + if not job then return end + if #job.failures == 0 then + self.cartFillNotice = { ok = true, + text = Strings("Installed %d of the mods this cart pins.", job.installed) } + return + end + self.cartFillNotice = { ok = false, failures = job.failures, + text = Strings("Installed %d of %d. %d could not be installed:", + job.installed, #job.rows, #job.failures) } +end + +function RomImporter:_pumpCartFill() + local job = self._cartFill + if not job then return end + local ModUpdate = require("src.mods.ModUpdate") + + if job.stage == "next" then + job.index = job.index + 1 + job.row = job.rows[job.index] + if not job.row then return self:_finishCartFill() end + local pin = job.row.pin + local why = pinUnresolvable(pin) + if why then return self:_cartFillFailed(why) end + local release = self:_cartPinIndexRelease(job.row) + if release then + job.release, job.stage = release, "install" + return + end + job.fetch = ModUpdate.beginFetchReleases(pin.repo, job.row.id, + { force = true }) + job.stage = "fetch" + self:_setBusy(Strings("Finding %s v%s", tostring(job.row.id), + tostring(job.row.version))) + return + end + + if job.stage == "fetch" then + local done, releases, err = ModUpdate.pumpFetchReleases(job.fetch) + if not done then return end + job.fetch = nil + if type(releases) ~= "table" then + return self:_cartFillFailed("could not be looked up: " + .. tostring(err or "release check failed")) + end + local pick + for _, rel in ipairs(releases) do + if type(rel) == "table" and pinSameVersion(job.row.version, rel.version) then + pick = rel + break + end + end + if not pick then + return self:_cartFillFailed(("has no release tagged v%s") + :format(tostring(job.row.version))) + end + if not (pick.zip and pick.zip.url) then + return self:_cartFillFailed(("v%s has no .zip asset") + :format(tostring(job.row.version))) + end + job.release, job.stage = pick, "install" + return + end + + if job.stage == "install" then + if self._modInstall then return end + local row = job.row + job.stage = "installing" + self:_beginModInstall({ + modId = row.id, name = row.id, release = job.release, + verb = "Installed", notice = "cart", sha256 = row.pin.sha256, + done = function(ok, text) + if ok then + job.installed = job.installed + 1 + job.stage = "next" + else + self:_cartFillFailed(text) + end + end, + }) + end +end + function RomImporter:_restoreActiveCarts(opts) local saved = opts and opts.activeCart if type(saved) ~= "table" then return end @@ -4419,13 +4669,11 @@ local function cartModRows(imp, version) return kept end +-- Every mod that targets this game, switched on or off: CartStore.capture +-- pins the off ones too, so they are part of the cart it would write. function RomImporter:_cartCaptureCount(version) if not GameVersion.VERSIONS[version] then return 0 end - local n = 0 - for _, row in ipairs(cartModRows(self, version)) do - if row.enabled then n = n + 1 end - end - return n + return #cartModRows(self, version) end function RomImporter:_cartAuthor() @@ -4460,10 +4708,7 @@ function RomImporter:_beginCartSave(version) modOptions = LauncherMods.modOptions(), unresolved = {}, publishable = false, } - st.count = 0 - for _, row in ipairs(st.mods) do - if row.enabled then st.count = st.count + 1 end - end + st.count = #st.mods local cart, unresolved = CartStore.capture( cartIdentity(st, "preview", "Preview"), st.mods, st.modOptions) if cart then @@ -4811,14 +5056,84 @@ function RomImporter:_refreshMods() .. table.concat(failed, ", ") } end end - self.mods = LauncherMods.list(self.modScope) or {} + local listed = LauncherMods.list(self.modScope) or {} + self.mods = listed if self.modScope then local kept = {} - for _, m in ipairs(self.mods) do + for _, m in ipairs(listed) do if m.targetsHere ~= false then kept[#kept + 1] = m end end self.mods = kept end + -- a pin is judged against the whole listing: the cart named it, so it is + -- listed even where the game filter above would have dropped it + local cartId, report = self:modCartPlan() + if cartId then self.mods = self:_cartPinRows(cartId, report, listed) end +end + +-- The cart the MODS panel is answering for, with the plan that resolves its +-- pins, or nil when the panel is on a base game. +function RomImporter:modCartPlan() + local version = self.modScope + if not version then return nil end + local id = self.activeCart and self.activeCart[version] or nil + if not id then return nil end + local report = self:cartPlan(version) + if type(report) ~= "table" or type(report.pins) ~= "table" then return nil end + return id, report, version +end + +local function missingPinRow(imp, id, pin) + local version = type(pin) == "table" and pin.version or nil + return { id = id, name = id, version = version, badge = "MOD", + description = "", enabled = false, status = "missing", + statusDetail = Strings("Pinned by this cart but not installed"), + experimental = false, targetsHere = true, + manifest = { id = id, version = version }, + dependencySpecs = {}, requiredImports = {}, imports = {}, + missingRequiredImports = 0, missingOptionalImports = 0, + safeMode = imp.safeMode == true, cartMissing = true } +end + +-- The pinned set, resolved exactly as Loader:_applyCart resolves it: the +-- cart's own switch, overridden by the player's answer only where the seal +-- hands that switch over (Loader.pinTogglable). +function RomImporter:_cartPinRows(cartId, report, rows) + local CartManifest = require("src.carts.CartManifest") + local Loader = require("src.mods.Loader") + local SaveData = require("src.core.SaveData") + local byId = {} + for _, row in ipairs(rows or {}) do byId[row.id] = row end + local options = SaveData.loadOptions() + local out = {} + for _, id in ipairs(report.order or {}) do + local pin = report.pins[id] + local source = byId[id] + -- a copy: the listing's own row still answers for the base game + local row = {} + if source then + for key, value in pairs(source) do row[key] = value end + row.cartMissing = nil + else + row = missingPinRow(self, id, pin) + end + local togglable = Loader.pinTogglable(report, pin) + local on = CartManifest.modEnabled(pin) + if togglable then + local chosen = SaveData.cartModEnabled(options, cartId, id) + if type(chosen) == "boolean" then on = chosen end + end + row.cartId, row.cartSeal = cartId, report.seal + row.cartTitle = report.title or cartId + row.cartPin, row.cartTogglable = true, togglable and not row.cartMissing + row.cartBase = self.modScope + -- a pin nothing provides cannot run, whatever the cart asked for + row.enabled = on and not self.safeMode and not row.cartMissing + -- one cart, one game: the per-game checkbox row is not this row's answer + row.enabledByVersion = nil + out[#out + 1] = row + end + return out end -- Point the MODS panel at one game (or nil for all of them) and relist, so @@ -4926,6 +5241,11 @@ function RomImporter:_toggleMod(id, confirmed, version) self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." } return end + local cartId, cartReport = self:modCartPlan() + if cartId then + self:_toggleCartMod(cartId, cartReport, id, confirmed) + return + end local LauncherMods = require("src.mods.LauncherMods") local cur, experimental = false, false for _, m in ipairs(self.mods or {}) do @@ -4958,6 +5278,62 @@ function RomImporter:_toggleMod(id, confirmed, version) self:_refreshMods() end +-- A cart's pin is switched in the cart's own scope, never in the per-game +-- flags, and only where the seal hands that switch over. Loader.pinTogglable +-- is the single authority on that, in the launcher as in the game. +function RomImporter:_toggleCartMod(cartId, report, id, confirmed) + local CartManifest = require("src.carts.CartManifest") + local Loader = require("src.mods.Loader") + local SaveData = require("src.core.SaveData") + local title = (report and report.title) or cartId + local pin = report and report.pins and report.pins[id] or nil + if not pin then + self.modNotice = { ok = false, text = Strings( + "%s decides which mods run. A cart's mod set cannot be added to or taken from.", + title) } + return + end + local row + for _, m in ipairs(self.mods or {}) do + if m.id == id then row = m break end + end + if row and row.cartMissing then + self.modNotice = { ok = false, text = Strings( + "%s pins %s, but it is not installed.", title, id) } + return + end + if not Loader.pinTogglable(report, pin) then + self.modNotice = { ok = false, text = Strings( + "%s is sealed: its mods run exactly as pinned. Break the seal on the cart's own page to change that.", + title) } + return + end + local on = CartManifest.modEnabled(pin) + local chosen = SaveData.cartModEnabled(SaveData.loadOptions(), cartId, id) + if type(chosen) == "boolean" then on = chosen end + local want = not on + if want and row and row.experimental and not confirmed then + self._modConfirm = { + kind = "experimental", id = id, version = self.modScope, + title = "Experimental mod", + yesLabel = "Enable", + lines = { + "This mod is marked experimental.", + "It may be unfinished or unstable.", + "Enable it anyway?", + }, + } + return + end + self._modConfirm = nil + SaveData.setCartModEnabled(cartId, id, want) + self:_refreshMods() + local name = (row and row.name) or id + self.modNotice = { ok = true, text = want + and Strings("Switched %s on for %s.", name, title) + or Strings("Switched %s off for %s.", name, title) } +end + -- Enable all / Disable all (#647). One options write for the whole list -- (LauncherMods.setAllEnabled) and one relist afterwards, so the count, the -- switches and every status chip resolve together instead of per mod. @@ -4965,11 +5341,20 @@ end -- opt-in is the only warning an experimental mod ever gets, and a bulk button -- must not be the way around it. Disabling needs no confirm -- it is the -- recovery action, and Delete is the only destructive one on this panel. +-- An active cart owns its mod set, so the bulk buttons are refused there +-- rather than becoming a way to add to it or empty it. function RomImporter:_setAllMods(want, confirmed) if self.safeMode then self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." } return end + local cartId, cartReport = self:modCartPlan() + if cartId then + self.modNotice = { ok = false, text = Strings( + "%s decides which mods run. Switch its pins one at a time, or pick the base game above.", + (cartReport and cartReport.title) or cartId) } + return + end local LauncherMods = require("src.mods.LauncherMods") local ids, experimental = {}, false for _, m in ipairs(self.mods or {}) do @@ -5143,9 +5528,12 @@ end -- (update a mod, install a specific version, install from an index) funnel -- into one in-flight job, so two installs can never race for the same id. -- --- `spec` = { modId, name, release, notice = "mod"|"find", verb, entry } +-- `spec` = { modId, name, release, notice = "mod"|"find"|"cart", verb, entry, +-- sha256, done } +-- `sha256` gates the unzip; `done(ok, text)` fires on either outcome so a +-- queue can drive one install at a time. function RomImporter:_beginModInstall(spec) - if self._modInstall then return end + if self._modInstall or self._cartInstall then return end local ModIndex = require("src.mods.ModIndex") local release = spec.release -- An index entry only tells us WHERE the zip is; resolving that is @@ -5178,8 +5566,39 @@ end function RomImporter:_modInstallFailed(spec, msg) local notice = { ok = false, text = tostring(msg) } if spec.notice == "find" then self.findNotice = notice - else self.modNotice = notice end + elseif spec.notice ~= "cart" then self.modNotice = notice end self:_clearBusy() + if spec.done then spec.done(false, tostring(msg)) end +end + +local function sha256hex(data) + local digest = love.data.hash("sha256", data) + if type(digest) == "userdata" and digest.getString then + digest = digest:getString() + end + return love.data.encode("string", "hex", digest) +end + +-- A cart pin records the sha256 of the archive it was built against. A hash +-- that does not match is a different build, so nothing is installed. +function RomImporter.verifyArchiveSha256(path, want) + if type(want) ~= "string" or not want:lower():match("^%x+$") + or #want ~= 64 then + return false, "the cart records no usable sha256 for this mod" + end + local ok, data = pcall(love.filesystem.read, path) + if not ok or type(data) ~= "string" or data == "" then + return false, "the downloaded archive could not be read back" + end + local hashed, got = pcall(sha256hex, data) + if not hashed or type(got) ~= "string" then + return false, "this platform cannot hash the archive, so it was not installed" + end + if got:lower() ~= want:lower() then + return false, ("archive sha256 %s does not match the pinned %s") + :format(got:sub(1, 12), want:sub(1, 12)) + end + return true end function RomImporter:_pumpModInstall() @@ -5202,6 +5621,16 @@ function RomImporter:_pumpModInstall() self:_modInstallFailed(spec, err or "download failed") return end + -- Hash gate before the unzip: a pinned archive that does not match the cart + -- is thrown away rather than installed. + if spec.sha256 then + local verified, why = RomImporter.verifyArchiveSha256(path, spec.sha256) + if not verified then + pcall(love.filesystem.remove, path) + self:_modInstallFailed(spec, why) + return + end + end -- Unzip + manifest check. Fast, and it must run here: love.filesystem -- writes are main-thread only. self:_setBusy(Strings("Installing %s", tostring(spec.name or spec.modId))) @@ -5225,14 +5654,138 @@ function RomImporter:_pumpModInstall() tostring(spec.name or spec.modId), shown) if spec.notice == "find" then self.findNotice = { ok = true, text = text } - else + elseif spec.notice ~= "cart" then self.modNotice = { ok = true, text = text } end - local LauncherMods = require("src.mods.LauncherMods") - local depCheck = LauncherMods.checkDependencies({ id = spec.modId }) - if depCheck and depCheck.hasIssues then - self._modDepResolver = depCheck + -- 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 + local depCheck = LauncherMods.checkDependencies({ id = spec.modId }) + if depCheck and depCheck.hasIssues then + self._modDepResolver = depCheck + end end + if spec.done then spec.done(true, text) end +end + +-- ------- cart install from an index listing +-- +-- A cart is one .g1rcart file, so the bytes go whole to CartStore.install and +-- 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 + 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 + self.findNotice = { ok = false, + text = ("%s: %s"):format(tostring(entry.title or entry.id), + tostring(why or "this cart has no downloadable release")) } + return + end + local ModUpdate = require("src.mods.ModUpdate") + local version = release.version or entry.version or os.time() + local tmpName = ("cart_install_%s_%s%s"):format( + tostring(entry.id):gsub("[^%w%-_]", "_"), tostring(version), + require("src.carts.CartStore").EXT) + self._cartInstall = { + entry = entry, version = release.version or entry.version, + h = ModUpdate.beginDownloadZip(release.zip.url, tmpName, release.zip.size), + } + self:_setBusy(Strings("Downloading %s", tostring(entry.title or entry.id)), + "v" .. tostring(release.version or entry.version or "?")) +end + +function RomImporter:_cartInstallFailed(msg) + self._cartInstall = nil + self:_clearBusy() + self.findNotice = { ok = false, text = tostring(msg) } +end + +function RomImporter:_pumpCartInstall() + local job = self._cartInstall + if not job then return end + local ModUpdate = require("src.mods.ModUpdate") + local ok, done, path, err, progress = pcall(ModUpdate.pumpDownloadZip, job.h) + if ok and not done then + if progress and self._busy then self._busy.progress = progress end + return + end + local entry = job.entry + local name = tostring(entry.title or entry.id) + if not ok then + return self:_cartInstallFailed(name .. ": download failed: " .. tostring(done)) + end + if not path then + return self:_cartInstallFailed(name .. ": " .. tostring(err or "download failed")) + end + self._cartInstall = nil + local read, bytes = pcall(love.filesystem.read, path) + pcall(love.filesystem.remove, path) + if not read or type(bytes) ~= "string" or bytes == "" then + return self:_cartInstallFailed(name .. ": the download could not be read back") + end + self:_setBusy(Strings("Installing %s", name)) + local CartStore = require("src.carts.CartStore") + local ran, cart, installErr = pcall(CartStore.install, bytes) + self:_clearBusy() + if not ran then + return self:_cartInstallFailed(name .. ": install failed: " .. tostring(cart)) + end + if not cart then + return self:_cartInstallFailed(name .. ": " .. tostring(installErr)) + end + -- _refreshCarts caches per version, so the Custom Carts list for the game + -- this cart plays as would keep its pre-install copy until a relaunch. + -- The label art is cached per cart too, and a reinstall can repaint it. + self:_refreshCarts(cart.base) + self._cartPlan = nil + self._cartridgeLabels = nil + self.findNotice = { ok = true, + text = Strings("Installed %s v%s. It is in this game's cart list now.", + tostring(cart.title or cart.id), tostring(cart.version or "?")) } + self:_offerCartPins(cart) +end + +-- The pins a cart is missing, answered without disturbing whichever cart the +-- player currently has selected for that game. +function RomImporter:_cartPinsMissing(version, id) + local wasCart = self.activeCart[version] + local wasPlan = self._cartPlan + self.activeCart[version] = id + self._cartPlan = nil + local ok, rows = pcall(self.cartFillRows, self, version) + self.activeCart[version] = wasCart + self._cartPlan = wasPlan + return (ok and type(rows) == "table") and rows or {} +end + +-- A cart whose pins are missing will not start, so ask once rather than +-- dead-end, and route a yes at the existing hash-verified fill queue. +function RomImporter:_offerCartPins(cart) + local rows = self:_cartPinsMissing(cart.base, cart.id) + if #rows == 0 then return end + local info = GameVersion.info(cart.base) + local title = tostring(cart.title or cart.id) + self._modConfirm = { + kind = "cartPins", version = cart.base, id = cart.id, + title = "Install this cart's mods", + yesLabel = "Install", + lines = { + ("%s pins %d mod(s) you do not have."):format(title, #rows), + "Install them now? Each one is checked against the cart's own hash.", + ("This selects %s as the cart for %s."):format(title, + tostring((info and (info.launcherName or info.displayName)) + or cart.base)), + }, + } +end + +-- The confirm's yes. The cart being filled has to be the selected one, which +-- is what cartFillRows and pressInstallCartMods both answer for. +function RomImporter:_installCartPins(version, id) + self:_selectCart(version, id) + self:pressInstallCartMods(version) end -- Start an async pull for a single dependency @@ -5409,7 +5962,7 @@ function RomImporter:_refreshFind(force) -- the index looks empty. if not Platform.canFetchRemote() then self.findLoaded = true - self.findIndex = { mods = {}, categories = {} } + self.findIndex = { mods = {}, carts = {}, categories = {}, baseGames = {} } self.findNotice = { ok = false, text = "Mod indexes cannot be fetched on this platform. Install a mod .zip from storage instead." } return @@ -5418,7 +5971,7 @@ function RomImporter:_refreshFind(force) self:_refreshFindSources() local sources = self.findSources or {} if #sources == 0 then - self.findIndex = { mods = {}, categories = {} } + self.findIndex = { mods = {}, carts = {}, categories = {}, baseGames = {} } self.findLoaded = true return end @@ -5433,6 +5986,7 @@ function RomImporter:_refreshFind(force) self._findFetch = { handles = handles, force = force == true, mods = {}, seen = {}, cats = {}, catSeen = {}, errs = {}, + carts = {}, cartSeen = {}, bases = {}, baseSeen = {}, stale = false, oldest = nil, at = 1, } self:_setBusy(Strings("Fetching mod index"), @@ -5489,29 +6043,51 @@ function RomImporter:_pumpFindFetch() f.mods[#f.mods + 1] = entry end end + -- Carts merge on the same first-source-wins rule, in their own id + -- space: a cart and a mod may share an id without shadowing each other. + for _, entry in ipairs(index.carts or {}) do + if not f.cartSeen[entry.id] then + f.cartSeen[entry.id] = true + entry._source = source.label or source.feed + entry._base = source.base + f.carts[#f.carts + 1] = entry + end + end for _, c in ipairs(ModIndex.categoriesIn(index)) do if not f.catSeen[c] then f.catSeen[c] = true f.cats[#f.cats + 1] = c end end + for _, b in ipairs(ModIndex.baseGamesIn(index)) do + if not f.baseSeen[b] then + f.baseSeen[b] = true + f.bases[#f.bases + 1] = b + end + end end end - self.findIndex = { mods = f.mods, categories = f.cats, stale = f.stale, + self.findIndex = { mods = f.mods, carts = f.carts, categories = f.cats, + baseGames = f.bases, stale = f.stale, checkedAt = f.oldest } self.findLoaded = true if #f.errs > 0 then self.findNotice = { ok = false, text = table.concat(f.errs, " - ") } elseif f.force then self.findNotice = { ok = true, - text = Strings("Refreshed - %d mods listed", #f.mods) } + text = (#f.carts > 0) + and Strings("Refreshed - %d mods and %d carts listed", #f.mods, #f.carts) + or Strings("Refreshed - %d mods listed", #f.mods) } end -- A category that no longer exists after a refresh would filter everything - -- away with no way back except guessing. + -- away with no way back except guessing. Same for a base game. if self.findCategory and not f.catSeen[self.findCategory] then self.findCategory = nil end + if self.findBase and not f.baseSeen[self.findBase] then + self.findBase = nil + end self.findPage = 1 self._findFetch = nil self:_clearBusy() @@ -5540,7 +6116,7 @@ function RomImporter:_ensureFind() if #(self.findSources or {}) == 0 then -- Nothing to fetch, but the panel is loaded: the empty state is the -- answer, not a pending request. - self.findIndex = { mods = {}, categories = {} } + self.findIndex = { mods = {}, carts = {}, categories = {}, baseGames = {} } self.findLoaded = true else self:_refreshFind(false) @@ -5548,45 +6124,93 @@ function RomImporter:_ensureFind() end end +-- Whether the FIND panel is browsing carts rather than mods. +function RomImporter:findingCarts() + return self.findKind == "carts" +end + +-- The switch itself. Query survives the flip; the category / base filter +-- does not, because neither vocabulary applies to the other list. +function RomImporter:_setFindKind(kind) + kind = (kind == "carts") and "carts" or "mods" + if self.findKind == kind then return end + self.findKind = kind + self._findRowsCache = nil + self._findSortCache = nil + if self._pages then self._pages["find"] = 1 end + self.findPage = 1 +end + -- The rows the filters leave, and the installed-mod context the compatibility -- warnings are judged against. function RomImporter:_findRows() - local all = (self.findIndex and self.findIndex.mods) or {} + local carts = self:findingCarts() + local all = (self.findIndex + and (carts and self.findIndex.carts or self.findIndex.mods)) or {} -- The view asks every frame (immediate mode); only re-filter when the - -- index, query, or category actually changed. + -- index, query, or filter actually changed. local c = self._findRowsCache if c and c.src == all and c.query == self.findQuery - and c.category == self.findCategory and c.scope == self.modScope then + and c.category == self.findCategory and c.base == self.findBase + and c.scope == self.modScope then return c.rows end local ModIndex = require("src.mods.ModIndex") local rows = ModIndex.filter(all, { query = self.findQuery, - category = self.findCategory, + category = (not carts) and self.findCategory or nil, + base = carts and self.findBase or nil, }) if self.modScope 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 + if carts then + -- A cart plays as exactly one game, so the scope is a plain match on + -- the base rather than a ModTargets coverage question. + local kept = {} + for _, entry in ipairs(rows) do + if entry.base == self.modScope then kept[#kept + 1] = entry end end + rows = kept + else + local ModTargets = require("src.mods.ModTargets") + local gen = GameVersion.generation(self.modScope) + local kept = {} + for _, entry in ipairs(rows) do + local versions = ModTargets.normalize(entry.games) + if #versions == 0 or ModTargets.covers(versions, gen) then + kept[#kept + 1] = entry + end + end + rows = kept end - rows = kept end self._findRowsCache = { src = all, query = self.findQuery, - category = self.findCategory, scope = self.modScope, rows = rows } + category = self.findCategory, base = self.findBase, + scope = self.modScope, rows = rows } return rows end function RomImporter:_findInstalledMap() + if self:findingCarts() then return self:_findInstalledCarts() end local map = {} for _, m in ipairs(self.mods or {}) do map[m.id] = m.version or true end return map end +-- id -> installed version for every cart on disk, whatever game it plays as. +-- Cached because the row loop asks once per frame. +function RomImporter:_findInstalledCarts() + if self._findCartMap then return self._findCartMap end + local map = {} + local ok, rows = pcall(function() + return require("src.carts.CartStore").index() + end) + if ok and type(rows) == "table" then + for _, row in ipairs(rows) do map[row.id] = row.version or true end + end + self._findCartMap = map + return map +end + -- One thumbnail per frame, and only for a card actually on screen: the fetch -- is a blocking curl, so downloading a whole listing's worth on open would -- stall the launcher for as many seconds as there are mods. A failure is @@ -5843,6 +6467,9 @@ function RomImporter:_findConfirmInstall(entry) text = (entry.title or entry.id) .. ": " .. tostring(why) } return end + if ModIndex.isCart(entry) then + return self:_findConfirmCartInstall(entry) + end local installed = self:_findInstalledMap() local issues = ModIndex.compatIssues(entry, { modApi = Version.modApi, @@ -5869,7 +6496,45 @@ function RomImporter:_findConfirmInstall(entry) } end +-- The cart twin of the confirm above. The pinned list is what the player is +-- agreeing to; installing the cart installs none of it. +function RomImporter:_findConfirmCartInstall(entry) + local ModIndex = require("src.mods.ModIndex") + local Version = require("src.core.Version") + local issues = ModIndex.compatIssues(entry, { + modApi = Version.modApi, + engineVersion = Version.engine, + installed = self:_findInstalledMap(), + }) + local version = ModIndex.displayVersion(entry) + local info = GameVersion.info(entry.base) + local lines = { (entry.title or entry.id) .. " v" .. tostring(version) } + if entry.author then lines[#lines + 1] = "by " .. entry.author end + lines[#lines + 1] = "Plays as " + .. tostring((info and (info.launcherName or info.displayName)) or entry.base) + .. " - " .. tostring(entry.seal) + lines[#lines + 1] = ("Pins %d mod(s), installed separately from its page") + :format(#(entry.mods or {})) + local have = self:_findInstalledCarts()[entry.id] + if have then + lines[#lines + 1] = "Replaces installed v" .. tostring(have) + end + for _, issue in ipairs(issues) do + lines[#lines + 1] = "! " .. issue.text + end + lines[#lines + 1] = "Carts are not reviewed - trust the author." + self._modConfirm = { + kind = (#issues > 0) and "warn" or "update", + indexEntry = entry, + title = have and "Reinstall cart" or "Install cart", + yesLabel = have and "Reinstall" or "Install", + lines = lines, + } +end + function RomImporter:_findInstall(entry) + local ModIndex = require("src.mods.ModIndex") + if ModIndex.isCart(entry) then return self:_beginCartInstall(entry) end self:_beginModInstall({ modId = entry.id, name = entry.title or entry.id, entry = entry, verb = "Installed", notice = "find", diff --git a/src/mods/Gen2Compat.lua b/src/mods/Gen2Compat.lua index 2594e8b6..b2a6c789 100644 --- a/src/mods/Gen2Compat.lua +++ b/src/mods/Gen2Compat.lua @@ -1582,7 +1582,7 @@ local function buildPartyMenu() elseif opts.battle and opts.onSwitch then prompt, battleSubmenu = "choose", true elseif opts.pickOnly then - prompt = "useItem" + prompt = opts.itemUse and "useItem" or "choose" elseif opts.onSwitch then -- src/ui/PartyMenu.lua:569: onSwitch OUTSIDE battle fires on A itself, -- so the field submenu must not swallow the press @@ -1647,7 +1647,7 @@ COVERAGE["src.ui.PartyMenu"] = { warned = "keepOpen tmhm", absent = "drawIcon frameFor mirrorsIcon iconFrames sgbPalettes animateTo " .. "heal softboiledFrom battle subItems subIndex swapFrom blink onSwitch " - .. "pickOnly forceSwitch", + .. "pickOnly itemUse forceSwitch", notes = { new = "onSwitch(mon, menu) is wrapped onto onChoose(index, mon); opts." .. "battle carries only its BOOLEAN sense and self.battle is left nil " @@ -1663,7 +1663,7 @@ COVERAGE["src.ui.PartyMenu"] = { onSwitch = "replacing menu.onSwitch on a LIVE instance writes a field " .. "Gen 2 never reads; pass it to .new instead", bottomMessage = "returns Gold's strings with / charmap glyphs, so " - .. "a compare against \"Use on which one?\" will not match", + .. "a compare against \"Use item on which\\nPOKéMON?\" will not match", ["hook ui.party.submenu"] = "same name and arity; rows carry `id` on Gold " .. "where Gen 1 carries `action`, and ctx.battle is a BOOLEAN, not a " .. "BattleState", @@ -1838,7 +1838,7 @@ local function buildBattleState() "syncSides", "playerHasPP", "lockedAction", "computeMusicKind", "throwBall", "ballChain", "tossAnimFor", "ballFlicker", "ballMissMessage", "storeCaughtMon", "safariAction", "safariEnemyTurn", "drawBallRow", - "drawClassic", "isWideBattleLayout", "wideLayout", "bgMode", "uiSize", + "drawClassic", "isWideBattleLayout", "wideLayout", "uiSize", "sgbPalettes", "trainerPalette", "trainerPicPath", "trainerTrueColor", "trainerSprite", "invalidate", "imageBattleScale", "resolveBattleScale", "backPlacement", @@ -1922,11 +1922,11 @@ COVERAGE["src.battle.BattleState"] = { backed = "update draw __index isOpaque openParty swapMoves " .. "lowHealthAlarmActive playVictoryMusic say sayAuto openItems " .. "openReplacementMenu finish askNicknameUI playEntranceCry stampOT " - .. "tryRun wantsFillScale", + .. "tryRun wantsFillScale bgMode", warned = "tryRun askNicknameUI", absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn " .. "computeDamage catchAttempt runRoll enter exit sgbPalettes " - .. "isWideBattleLayout wideLayout bgMode uiSize letterboxWhite " + .. "isWideBattleLayout wideLayout uiSize letterboxWhite " .. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath " .. "trainerTrueColor trainerSprite invalidate " .. "backPlacement frontPlacement StatBox drawClassic drawBallRow " diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 0b298d20..6ae33583 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1,6 +1,7 @@ local Json = require("src.link.Json") local Logger = require("src.core.Logger") local SaveData = require("src.core.SaveData") +local CartManifest = require("src.carts.CartManifest") local CartStore = require("src.carts.CartStore") local Data = require("src.core.Data") local GameVersion = require("src.core.GameVersion") @@ -269,7 +270,7 @@ function Loader.new(opts) events = Events.new(), hooks = Hooks.new(), content = {}, assets = {}, exports = {}, migrations = {}, order = {}, modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {}, - modInput = {}, modEnv = {}, stepsQueues = {}, + modInput = {}, modEnv = {}, stepsQueues = {}, cartSwitches = {}, fs = (opts and opts.fs) or (love and love.filesystem), cart = opts and opts.cart or nil, dev = dev, @@ -369,7 +370,11 @@ function Loader:_saveState() local scope = self:_enableScope() local version = self:_targetVersion() for id in pairs(self.mods) do - SaveData.setModEnabled(options, id, not self.disabled[id], scope) + -- a switch the cart owns is answered in the cart's scope by setEnabled, + -- so it never rewrites what the base game runs + if not self.cartSwitches[id] then + SaveData.setModEnabled(options, id, not self.disabled[id], scope) + end -- only the games this boot can answer for: another version's overrides -- are not this run's to rewrite. With no version (an injected-generation -- harness) the override stays in memory for this boot only. @@ -436,6 +441,9 @@ function Loader:setEnabled(id, enabled) if not self.mods[id] then return false end self.disabled[id] = not enabled self.mods[id].enabled = enabled + if self.cartSwitches[id] and self.cartReport then + SaveData.setCartModEnabled(self.cartReport.id, id, enabled, self.fs) + end self:_saveState() return true end @@ -530,6 +538,15 @@ local function cartComplaints(report) return parts end +-- Whether the player's own enable flag decides a pinned mod. "sealed+" hands +-- every pin over; any other seal hands over only the pins the cart ships off. +function Loader.pinTogglable(report, pin) + if type(report) ~= "table" or type(pin) ~= "table" then return false end + if report.seal == "sealed+" then return true end + if pin.enabled ~= false then return false end + return not (report.seal == "sealed" and not report.broken) +end + function Loader.planCart(cart, installed, broken) local report = { seal = "sealed", sealed = true, broken = broken == true, order = {}, rank = {}, pins = {}, missing = {}, mismatched = {}, @@ -541,8 +558,8 @@ function Loader.planCart(cart, installed, broken) return report end report.id, report.title = cart.id, cart.title - report.seal = cart.seal == "open" and "open" or "sealed" - report.sealed = report.seal == "sealed" + report.seal = CartManifest.SEALS[cart.seal] and cart.seal or "sealed" + report.sealed = report.seal ~= "open" report.enforced = report.sealed and not report.broken local have = installedVersions(installed) local pins = {} @@ -599,9 +616,20 @@ function Loader:_applyCart() return end if report.message then Logger.warn("cart %s: %s", cartId, report.message) end + -- the pins whose switch the cart hands to the player: their answer lives in + -- the cart's own scope, never in the per-game flags + self.cartSwitches = {} + local options = SaveData.loadOptions(self.fs) for id, mod in pairs(self.mods) do - if report.pins[id] then - mod.enabled, mod.state = true, "pending" + local pin = report.pins[id] + if pin then + local on = CartManifest.modEnabled(pin) + if Loader.pinTogglable(report, pin) then + local chosen = SaveData.cartModEnabled(options, cartId, id) + if type(chosen) == "boolean" then on = chosen end + self.cartSwitches[id] = true + end + mod.enabled, mod.state = on, on and "pending" or "disabled" elseif report.enforced then mod.enabled, mod.state = false, "disabled" end diff --git a/src/mods/ModIndex.lua b/src/mods/ModIndex.lua index b136297d..78618e49 100644 --- a/src/mods/ModIndex.lua +++ b/src/mods/ModIndex.lua @@ -18,6 +18,9 @@ -- schema_version is a hard gate, not a hint: a bumped feed may reuse a field -- name for something else, so an unknown version is refused outright rather -- than parsed hopefully. +-- +-- Carts ride the same feed additively at schema_version 1: `doc.carts` is a +-- second array beside `doc.mods`, and a feed without it lists no carts. local ModIndex = {} @@ -26,7 +29,7 @@ local ModIndex = {} -- single repo's releases; a whole index is heavier and changes more slowly. ModIndex.CACHE_TTL = 24 * 60 * 60 ModIndex.SCHEMA_VERSION = 1 -ModIndex.CACHE_VERSION = 2 +ModIndex.CACHE_VERSION = 3 -- ------- pure: source resolution @@ -225,8 +228,95 @@ local function parseEntry(raw) } end --- parse(jsonText [, Json]) -> { schemaVersion, generatedAt, categories, mods } --- | nil, err +local function numArray(v) + local out = {} + if type(v) == "table" then + for _, entry in ipairs(v) do + local n = tonumber(entry) + if n then out[#out + 1] = n end + end + end + return out +end + +-- One pinned mod out of a cart's `mods` array, in the bundle's own cart.json +-- shape: github pins carry repo/version/sha256, gamebanana pins mod/file/md5. +local function parseCartPin(raw) + if type(raw) ~= "table" or not str(raw.id) then return nil end + local source = str(raw.source) + if source ~= "github" and source ~= "gamebanana" then return nil end + local pin = { + id = raw.id, + source = source, + repo = str(raw.repo), + version = str(raw.version), + sha256 = str(raw.sha256), + mod = tonumber(raw.mod), + file = tonumber(raw.file), + md5 = str(raw.md5), + options = type(raw.options) == "table" and raw.options or nil, + } + if raw.enabled == false then pin.enabled = false end + return pin +end + +-- One cart listing. The eight fields the cart schema marks required are the +-- gate: a row missing any of them is dropped rather than half-listed, because +-- every one of them is load bearing for installing or naming the cart. +local function parseCartEntry(raw) + if type(raw) ~= "table" then return nil end + if not (str(raw.id) and str(raw.title) and str(raw.author) + and str(raw.version) and str(raw.base) and str(raw.seal) + and str(raw.repo) and type(raw.mods) == "table") then + return nil + end + local pins = {} + for _, pin in ipairs(raw.mods) do + local parsed = parseCartPin(pin) + if parsed then pins[#pins + 1] = parsed end + end + if #pins == 0 then return nil end + return { + kind = "cart", + folder = str(raw.folder), + id = raw.id, + title = raw.title, + author = raw.author, + version = raw.version, + base = raw.base, + seal = raw.seal, + summary = str(raw.summary) or "", + shell = str(raw.shell), + finish = str(raw.finish), + speeds = numArray(raw.speeds), + tags = strArray(raw.tags), + repo = raw.repo, + github = str(raw.github), + downloadURL = str(raw.downloadURL), + automatic_version_check = raw.automatic_version_check ~= false, + fixed_release_tag = str(raw.fixed_release_tag), + game_version = str(raw.game_version), + license = str(raw.license), + mods = pins, + load_order = strArray(raw.load_order), + thumbnail = str(raw.thumbnail), + description_url = str(raw.description_url), + downloads = parseDownloads(raw.downloads), + first_release = str(raw.first_release), + last_release = str(raw.last_release), + latest = parseLatest(raw.latest), + update_check = str(raw.update_check) or "pending", + } +end + +-- True for a listing that installs through CartStore, not the mod installer. +function ModIndex.isCart(entry) + return type(entry) == "table" and entry.kind == "cart" +end + +-- parse(jsonText [, Json]) +-- -> { schemaVersion, generatedAt, categories, baseGames, mods, carts } +-- | nil, err -- Never throws: a truncated download, an HTML error page, or a feed from a -- future schema all come back as a message the panel can print. function ModIndex.parse(jsonText, Json) @@ -254,11 +344,21 @@ function ModIndex.parse(jsonText, Json) local entry = parseEntry(raw) if entry then mods[#mods + 1] = entry end end + -- Absent carts is the old-feed case, not an error. + local carts = {} + if type(doc.carts) == "table" then + for _, raw in ipairs(doc.carts) do + local entry = parseCartEntry(raw) + if entry then carts[#carts + 1] = entry end + end + end return { schemaVersion = schema, generatedAt = str(doc.generated_at), categories = strArray(doc.categories), + baseGames = strArray(doc.base_games), mods = mods, + carts = carts, } end) if not ok then return nil, "could not read the index: " .. tostring(result) end @@ -421,12 +521,14 @@ function ModIndex.matches(entry, query) return true end --- filter(mods, opts) -> a new array. opts = { query, category, tag }. --- Category and tag compare case-insensitively; feed order (already sorted by --- title) is preserved. +-- filter(entries, opts) -> a new array. opts = { query, category, base, tag }. +-- 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. function ModIndex.filter(mods, opts) opts = opts or {} 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 out = {} for _, entry in ipairs(mods or {}) do @@ -437,6 +539,9 @@ function ModIndex.filter(mods, opts) if tostring(c):lower() == want then keep = true; break end end end + if keep and wantBase then + keep = tostring(entry.base or ""):lower() == wantBase + end if keep and wantTag then keep = false for _, t in ipairs(entry.tags or {}) do @@ -469,6 +574,32 @@ function ModIndex.categoriesIn(index) return out end +-- Every base game the feed's carts actually play as, in the feed's declared +-- base_games order, with anything a cart names that the header forgot +-- appended. The cart-side twin of categoriesIn. +function ModIndex.baseGamesIn(index) + local out, seen = {}, {} + if type(index) ~= "table" then return out end + local used = {} + for _, entry in ipairs(index.carts or {}) do + if entry.base then used[entry.base] = true end + end + for _, base in ipairs(index.baseGames or {}) do + if used[base] and not seen[base] then + seen[base] = true + out[#out + 1] = base + end + end + for _, entry in ipairs(index.carts or {}) do + local base = entry.base + if base and not seen[base] then + seen[base] = true + out[#out + 1] = base + end + end + return out +end + -- ------- sources (options.modIndexes) local function loadOptions() @@ -569,7 +700,9 @@ function ModIndex.writeCache(feed, index) version = ModIndex.CACHE_VERSION, generatedAt = index.generatedAt, categories = index.categories, + baseGames = index.baseGames, mods = index.mods, + carts = index.carts, } SaveData.saveOptions(opts) end) @@ -609,7 +742,9 @@ function ModIndex.fetch(source, opts) schemaVersion = ModIndex.SCHEMA_VERSION, generatedAt = entry.generatedAt, categories = entry.categories or {}, + baseGames = entry.baseGames or {}, mods = entry.mods or {}, + carts = entry.carts or {}, }, nil, { fromCache = true, stale = stale, checkedAt = entry.checkedAt } end @@ -674,7 +809,9 @@ local function cachedIndex(feed, stale) schemaVersion = ModIndex.SCHEMA_VERSION, generatedAt = entry.generatedAt, categories = entry.categories or {}, + baseGames = entry.baseGames or {}, mods = entry.mods or {}, + carts = entry.carts or {}, }, nil, { fromCache = true, stale = stale, checkedAt = entry.checkedAt } end diff --git a/src/net/Fetch.lua b/src/net/Fetch.lua index 2a987125..b7955bc8 100644 --- a/src/net/Fetch.lua +++ b/src/net/Fetch.lua @@ -87,6 +87,7 @@ local function drain() j.status = msg.ok and "ok" or "error" j.body, j.err, j.path = msg.body, msg.err, msg.path j.code = msg.code + j.notModified = msg.notModified j.progress = msg.ok and 1 or j.progress end end @@ -154,12 +155,18 @@ end -- Download a URL to `saveRel`, a path relative to the LOVE save directory. -- Progress is reported as a 0..1 fraction when `size` is known. +-- opts.etagRel (optional, relative to the save directory like saveRel itself) +-- turns this into a conditional GET against a cached ETag at that path -- +-- see HostShell.httpDownload's own comment for the 304/notModified contract. +-- A caller checks Fetch.poll(job).notModified once status is "ok" to tell a +-- real download apart from a no-op cache hit (no file was written for the +-- latter). function Fetch.download(url, saveRel, opts) opts = opts or {} return submit({ kind = "download", url = url, dest = saveRel, size = opts.size, userAgent = opts.userAgent or "gen1recomp", - accept = opts.accept, maxSeconds = opts.maxSeconds }) + accept = opts.accept, maxSeconds = opts.maxSeconds, etagRel = opts.etagRel }) end -- Non-blocking status. Returns a table; never nil, even for an unknown id diff --git a/src/net/fetch_worker.lua b/src/net/fetch_worker.lua index 79006d62..def8d1fb 100644 --- a/src/net/fetch_worker.lua +++ b/src/net/fetch_worker.lua @@ -73,6 +73,12 @@ end -- inside the call. Where the caller knows the expected size we poll the -- growing file from a second pass instead; where it does not, the job simply -- reports indeterminate progress and the UI shows a spinner. +-- +-- job.etagRel (optional, relative to saveDir like job.dest) turns this into +-- a conditional GET -- see HostShell.httpDownload's own comment for the +-- 304/notModified contract. A notModified result never writes `rel` at all +-- (confirmed empirically, not assumed -- see TrueFX/etag-cache-repro/), so +-- the usual "did a file actually land" check below is skipped for it. local function doDownload(job) if not HostShell then post({ id = job.id, ok = false, err = "no transport" }) @@ -84,12 +90,17 @@ local function doDownload(job) if dir then love.filesystem.createDirectory(dir) end love.filesystem.remove(rel) - local ok, err = HostShell.httpDownload(job.url, abs, job.userAgent, - job.accept, tonumber(job.maxSeconds) or DOWNLOAD_MAX_SECONDS) + local etagAbs = job.etagRel and (saveDir .. "/" .. job.etagRel) or nil + local ok, err, notModified = HostShell.httpDownload(job.url, abs, job.userAgent, + job.accept, tonumber(job.maxSeconds) or DOWNLOAD_MAX_SECONDS, etagAbs) if not ok then post({ id = job.id, ok = false, err = err or "download failed" }) return end + if notModified then + post({ id = job.id, ok = true, path = rel, done = true, notModified = true }) + return + end local info = love.filesystem.getInfo(rel) if not info or (info.size or 0) == 0 then love.filesystem.remove(rel) diff --git a/src/render/GBCFX.lua b/src/render/GBCFX.lua deleted file mode 100644 index c2788a2a..00000000 --- a/src/render/GBCFX.lua +++ /dev/null @@ -1,310 +0,0 @@ --- GBC Effects post-process ("Pixel Transparency" style, see --- github.com/mattakins/Pixel_Transparency). A cumulative 4-level ladder --- applied after palette colorization and before the CRT pass: --- 1 reflective screen: bright pixels blend toward a procedurally --- grained warm backing (the unlit-GBC "transparent whites" look) --- 2 + LCD subpixel grid --- 3 + drop shadows (dark pixels float above the backing) --- 4 + sunlight: specular glare + rainbow QWP shimmer with a slowly --- drifting light source --- Levels OFF/1/2/3/4 persist as save.options.gbcfx; hotkey 5 cycles. --- Spec: docs/new-features.md (Custom Options / GBC FX) --- --- One shader for all levels: features are gated by the `level` uniform --- (float comparisons), so cycling never recompiles. All spatial effects --- key off `pixelScale` (screen pixels per GB pixel) so grid pitch, --- shadow offsets and grain stay window-size independent. - -local GBCFX = {} - -GBCFX.LABELS = { "OFF", "1", "2", "3", "4" } -GBCFX.level = 0 - -local shader -- false = unavailable (headless / no shader support) - --- Mobile GPUs often compile this pass but present a black frame, and the --- level persists in options.lua -- soft-bricking the APK until a manual --- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect. --- --- POKEPORT_GBCFX overrides the platform default either way ("1" force on, --- "0" force off), same tri-state as POKEPORT_TOUCH. Handheld packs whose --- GPU is in the same class as a phone's but whose getOS() says "Linux" set --- it to 0 in their launcher (build-rg34xxsp.sh); it also lets a desktop --- checkout exercise the unsupported path without stubbing love.system. -function GBCFX.isSupported() - local env = os.getenv("POKEPORT_GBCFX") - if env == "1" then return true end - if env == "0" then return false end - if not love or not love.system or not love.system.getOS then return true end - local osName = love.system.getOS() - return osName ~= "Android" and osName ~= "iOS" -end - --- GLSL 1.20-compatible (no array initializers; wavelength terms and the --- shadow blur are unrolled by hand). -local SHADER_SRC = [[ -extern number level; -extern number time; -extern number pixelScale; // screen pixels per GB pixel (integer fit scale) - -#define PI 3.14159265359 - -// ---- level thresholds (cumulative ladder) ---- -#define L_GRID 1.5 -#define L_SHADOW 2.5 -#define L_SUN 3.5 - -// ---- level 1: reflective backing ---- -#define BACK_BRIGHTNESS 0.48 -#define GRAIN_INTENSITY 0.065 -// #A6AC84 "Pocket" backing tint, normalized to unit mean brightness -#define POCKET_TINT vec3(1.0596, 1.0979, 0.8424) -#define BASE_ALPHA 0.20 -#define WHITE_EXTRA 0.75 -// front polarizer film tint -#define POLARIZER vec3(0.94, 1.0, 0.865) - -// ---- level 2: LCD grid (lcd1x style) ---- -#define BRIGHTEN_SCANLINES 16.0 -#define BRIGHTEN_LCD 4.0 - -// ---- level 3: drop shadow ---- -#define SHADOW_OFFSET 3.0 -#define SHADOW_OPACITY 0.5 - -// ---- level 4: sunlight ---- -#define GLARE_INTENSITY 0.15 -#define GLARE_SIGMA 0.25 -#define SHIMMER_INTENSITY 0.25 -// chroma amplification so the bands read on the already-desaturated, -// backing-blended image (reference applies 0.25 to raw film reflectance) -#define SHIMMER_CHROMA_GAIN 3.0 -#define SHIMMER_SPREAD 1.8 -#define LIGHT_RANGE 0.6 -#define FILM_NOISE_AMOUNT 0.5 -#define REFLECT_FLOOR 0.03 - -float hash21(vec2 p) -{ - return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); -} - -// smooth value noise, ~[0,1] -float vnoise(vec2 p) -{ - vec2 i = floor(p); - vec2 f = fract(p); - vec2 u = f * f * (3.0 - 2.0 * f); - float a = hash21(i); - float b = hash21(i + vec2(1.0, 0.0)); - float c = hash21(i + vec2(0.0, 1.0)); - float d = hash21(i + vec2(1.0, 1.0)); - return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); -} - -float luma(vec3 c) -{ - return dot(c, vec3(0.2126, 0.7152, 0.0722)); -} - -vec4 effect(vec4 color, Image tex, vec2 tc, vec2 pc) -{ - vec4 src = Texel(tex, tc); - if (level < 0.5) { - return src * color; - } - - float ps = max(pixelScale, 1.0); - vec2 gbPix = pc / ps; // GB-pixel coordinates - vec2 texel = 1.0 / love_ScreenSize.xy; // one screen pixel in tc - vec2 gbTexel = texel * ps; // one GB pixel in tc - - // Drifting light position (normalized screen coords, upper area). - // Computed unconditionally: level 3 borrows it for shadow drift. - vec2 lightPos = vec2(0.5 + 0.35 * sin(time * 0.13), - 0.3 + 0.2 * sin(time * 0.07)); - - // ---- level 1: procedural backing material ---- - // flat gray + 3-octave paper grain, tinted warm - float grain = vnoise(gbPix * 0.9) * 0.5 - + vnoise(gbPix * 2.1 + vec2(17.0, 5.0)) * 0.3 - + vnoise(gbPix * 4.3 + vec2(3.0, 29.0)) * 0.2; - float backLum = BACK_BRIGHTNESS + (grain - 0.5) * (GRAIN_INTENSITY * 2.0); - vec3 back = backLum * POCKET_TINT; - - // ---- level 3: dark pixels cast a soft shadow onto the backing ---- - if (level >= L_SHADOW) { - vec2 shOff = vec2(SHADOW_OFFSET); - if (level >= L_SUN) { - // subtle drift opposite the light's wander - shOff += vec2((0.5 - lightPos.x) * 3.0, (0.3 - lightPos.y) * 3.0); - } - vec2 so = tc - shOff * gbTexel; - vec2 e = gbTexel; - // 9-tap gaussian blur of the offset sample's brightness (unrolled) - float s = 0.0; - s += luma(Texel(tex, so).rgb) * 4.0; - s += luma(Texel(tex, so + vec2( e.x, 0.0)).rgb) * 2.0; - s += luma(Texel(tex, so + vec2(-e.x, 0.0)).rgb) * 2.0; - s += luma(Texel(tex, so + vec2(0.0, e.y)).rgb) * 2.0; - s += luma(Texel(tex, so + vec2(0.0, -e.y)).rgb) * 2.0; - s += luma(Texel(tex, so + vec2( e.x, e.y)).rgb) * 1.0; - s += luma(Texel(tex, so + vec2( e.x, -e.y)).rgb) * 1.0; - s += luma(Texel(tex, so + vec2(-e.x, e.y)).rgb) * 1.0; - s += luma(Texel(tex, so + vec2(-e.x, -e.y)).rgb) * 1.0; - s /= 16.0; - float dark = 1.0 - s; - // deadzone: near-white pixels (dark ~ 0) cast no shadow at all - float shadow = dark * smoothstep(0.08, 0.30, dark) * SHADOW_OPACITY; - back = mix(back, back * 0.2, shadow); - } - - // ---- level 2: LCD subpixel grid on the lit image only ---- - vec3 lit = src.rgb; - if (level >= L_GRID) { - vec2 angle = 2.0 * PI * (gbPix - 0.25); - float yfac = (BRIGHTEN_SCANLINES + sin(angle.y)) - / (BRIGHTEN_SCANLINES + 1.0); - float xfac = (BRIGHTEN_LCD + sin(angle.x)) / (BRIGHTEN_LCD + 1.0); - lit *= yfac * xfac; - } - - // ---- level 1: brightness-proportional pixel transparency ---- - float lum = luma(src.rgb); - float a = BASE_ALPHA * lum; - // near-white pixels (luma > 0.90 AND min channel > 0.81) are nearly - // fully transparent -- narrow smoothsteps stand in for the hard AND - float mn = min(src.r, min(src.g, src.b)); - a += WHITE_EXTRA * smoothstep(0.88, 0.92, lum) * smoothstep(0.79, 0.83, mn); - vec3 col = mix(lit, back, clamp(a, 0.0, 1.0)); - - // ---- level 4: sunlight (glare + rainbow QWP shimmer) ---- - float glare = 0.0; - if (level >= L_SUN) { - float aspect = love_ScreenSize.x / love_ScreenSize.y; - vec2 p = vec2(tc.x * aspect, tc.y); - vec2 lp = vec2(lightPos.x * aspect, lightPos.y); - float d = distance(p, lp); - - // specular gaussian hotspot (added after the polarizer tint: - // it reflects off the front glass, not the LCD) - glare = GLARE_INTENSITY * exp(-d * d / (2.0 * GLARE_SIGMA * GLARE_SIGMA)); - - // quarter-wave-plate film: effective retardance (nm) grows with - // distance from the light point -> concentric interference bands; - // smooth "film thickness" noise makes the bands splotchy - float film = vnoise(gbPix * 0.06 + vec2(7.3, 2.9) + time * 0.01); - float gammaEff = (260.0 + 620.0 * SHIMMER_SPREAD * (d / LIGHT_RANGE)) - * (1.0 + FILM_NOISE_AMOUNT * (film - 0.5)); - float ph = 4.0 * PI * gammaEff; - - // 7 wavelength samples 400..700nm with approximate spectral RGB, - // unrolled (no const arrays in GLSL 1.20) - vec3 rb = vec3(0.0); - float cw; - cw = cos(ph / 400.0); rb += cw * cw * vec3(0.15, 0.00, 0.50); - cw = cos(ph / 450.0); rb += cw * cw * vec3(0.00, 0.10, 1.00); - cw = cos(ph / 500.0); rb += cw * cw * vec3(0.00, 0.80, 0.40); - cw = cos(ph / 550.0); rb += cw * cw * vec3(0.20, 1.00, 0.00); - cw = cos(ph / 600.0); rb += cw * cw * vec3(1.00, 0.60, 0.00); - cw = cos(ph / 650.0); rb += cw * cw * vec3(1.00, 0.10, 0.00); - cw = cos(ph / 700.0); rb += cw * cw * vec3(0.70, 0.00, 0.00); - rb /= vec3(3.05, 2.60, 1.90); // per-channel weight sums -> peak 1.0 - - // fade with distance from the light, kill on dark pixels, - // weight by pixel color squared - float att = 1.0 - smoothstep(0.0, LIGHT_RANGE, d); - float refl = max(lum, REFLECT_FLOOR); - // luminance-preserving tint: add only the chroma of the rainbow - vec3 shimmer = (rb - vec3(luma(rb))) * SHIMMER_CHROMA_GAIN - * src.rgb * src.rgb * refl * att; - col += shimmer * SHIMMER_INTENSITY; - } - - // front polarizer tint, then front-surface glare on top - col *= POLARIZER; - col += vec3(glare); - - return vec4(col, src.a) * color; -} -]] - -GBCFX.SHADER_SRC = SHADER_SRC -- exposed for the standalone compile check - -function GBCFX.shader() - if not GBCFX.isSupported() then return nil end - if shader == nil then - local ok, sh = pcall(love.graphics.newShader, SHADER_SRC) - shader = ok and sh or false - end - return shader or nil -end - -function GBCFX.setLevel(level) - if not GBCFX.isSupported() then - GBCFX.level = 0 - return - end - level = math.floor(tonumber(level) or 0) - if level < 0 then level = 0 end - if level > 4 then level = 4 end - GBCFX.level = level -end - --- Advance OFF → 1 → 2 → 3 → 4 → OFF. Returns the new level. -function GBCFX.cycle() - if not GBCFX.isSupported() then - GBCFX.level = 0 - return 0 - end - GBCFX.setLevel((GBCFX.level + 1) % 5) - return GBCFX.level -end - --- Apply opts.gbcfx. On unsupported platforms force OFF and clear a --- persisted non-zero value so boot recovers from a soft-brick. Returns --- true when opts was sanitized (caller should persist options.lua). -function GBCFX.applyOptions(opts) - if not GBCFX.isSupported() then - local had = opts and (tonumber(opts.gbcfx) or 0) ~= 0 - if opts then opts.gbcfx = 0 end - GBCFX.level = 0 - return had and true or false - end - GBCFX.setLevel(opts and opts.gbcfx or 0) - return false -end - -function GBCFX.levelLabel(level) - return GBCFX.LABELS[(level or GBCFX.level) + 1] or "OFF" -end - -function GBCFX.active() - return GBCFX.isSupported() and GBCFX.level > 0 and GBCFX.shader() ~= nil -end - --- Draw `canvas` fullscreen through the GBC FX shader into the current --- render target (or plain if the shader is unavailable). pixelScale is --- the integer screen-pixels-per-GB-pixel scale so grid/shadow offsets --- stay window-size independent. -function GBCFX.present(canvas, pixelScale) - local sh = GBCFX.shader() - if not sh or GBCFX.level <= 0 then - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(canvas, 0, 0) - return - end - local t = 0 - if love.timer and love.timer.getTime then - t = love.timer.getTime() - end - sh:send("level", GBCFX.level) - sh:send("time", t) - sh:send("pixelScale", math.max(1, math.floor(tonumber(pixelScale) or 1))) - love.graphics.setShader(sh) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(canvas, 0, 0) - love.graphics.setShader() -end - -return GBCFX diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 5ed14d47..c9da8b27 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -154,8 +154,8 @@ function Renderer:setWorldOverride(canvas) end -- Integer framebuffer pixels per GB pixel that fit the window. Zoom / --- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE --- units via / dpiX and / dpiY when drawing. +-- ShaderFX / callers treat this as the crisp scale; endFrame converts to +-- LOVE units via / dpiX and / dpiY when drawing. function Renderer:fitScale() local _, _, pw, ph = displayMetrics() local w, h = self:uiSize() @@ -594,7 +594,7 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target, local zoneShader = zoneList and zoneList[1] and PaletteFX.shader() or nil if zoneShader then love.graphics.setShader(zoneShader) - -- same trueColor sentinel the flat blit honors (14 §trueColor) + -- same trueColor sentinel the flat blit below honors local bare = false for _, z in ipairs(zoneList) do local plain = z.colors == false @@ -785,8 +785,8 @@ end -- visible map area separately), applied to the world pass; the world -- pass falls back to the UI zones when absent. Each zone is drawn -- scissored through the shade-remap shader, later zones on top. --- When GBC FX is active the composite is drawn into presentCanvas and --- presented through the GBC FX shader as a final pass. +-- When ShaderFX is active the composite is drawn into presentCanvas and +-- presented through the shader chain as a final pass. function Renderer:frameRects() local ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut = displayMetrics() local r = { @@ -850,7 +850,11 @@ function Renderer:endFrame(zones, worldZones) local vpw, vph, ox, oy = R.vpw, R.vph, R.ox, R.oy local Ux, Uy = R.Ux, R.Uy local uvpw, uvph, uox, uoy = R.uvpw, R.uvph, R.uox, R.uoy - local GBCFX = require("src.render.GBCFX") + local Up = R.Up + -- Physical-pixel numerators for ShaderFX's per-frame rect; derived from + -- the unit rects frameRects already lifted so both agree. + local uoxPx, uoyPx = uox * dpiX, uoy * dpiY + local ShaderFX = require("src.render.ShaderFX") -- Forced mono/Classic modes still need a whole-screen zone when a state -- exposes no SGB packets (raw DMG canvas), so sendColors can remap. zones = PaletteFX.ensureZones(zones) @@ -893,12 +897,12 @@ function Renderer:endFrame(zones, worldZones) end end - -- Post-process pipelines, GBC FX and an enabled final-output owner need the + -- Post-process pipelines, ShaderFX and an enabled final-output owner need the -- whole composite in a canvas. With none of them, the frame draws straight -- to the screen exactly as it always did. local hasOutputHook = Runtime.wantsHook("render.output") and Runtime.call("render.output_enabled", function() return false end) == true - local needPresent = GBCFX.active() or Pipelines.wantsPresent() or hasOutputHook + local needPresent = ShaderFX.active() or Pipelines.wantsPresent() or hasOutputHook local present = nil if needPresent then if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww @@ -1003,6 +1007,17 @@ function Renderer:endFrame(zones, worldZones) return Renderer.clipToView(R, x, y, w, h) end + -- Real per-frame ShaderFX game rect + source content size, in PHYSICAL + -- framebuffer pixels -- what ShaderFX.render below actually draws through + -- the chain, instead of it reconstructing a fixed 160x144-at-base-Sp + -- approximation of its own. Defaults to this frame's real UI rect, which + -- is already correct whenever neither branch below overrides it + -- (title/menu/credits, no world active) -- uiFill is already folded into + -- Up above, so that case needs no extra handling here. + local fxRectPxX, fxRectPxY, fxRectPxW, fxRectPxH, fxScale = + uoxPx, uoyPx, uiw * Up, uih * Up, Up + local fxSrcW, fxSrcH = uiw, uih + if self.worldOverride then -- A render pipeline already produced the whole world -- terrain, -- characters and its own FX overlay -- as one window-resolution image, @@ -1010,6 +1025,13 @@ function Renderer:endFrame(zones, worldZones) -- skipped entirely (nothing drew into it). The UI blit below still -- runs, so dialogs, menus and the HUD sit on top as usual. love.graphics.setColor(1, 1, 1, 1) + -- worldOverride is already a window-resolution image drawn 1:1 at the + -- origin -- ShaderFX's real rect degenerates to the whole image, no + -- crop needed (source size == rect size). + fxRectPxX, fxRectPxY = 0, 0 + fxRectPxW, fxRectPxH = self.worldOverride:getPixelWidth(), self.worldOverride:getPixelHeight() + fxScale = 1 + fxSrcW, fxSrcH = fxRectPxW, fxRectPxH love.graphics.setScissor(vux, vuy, vuw, vuh) local loveMajor = love.getVersion() if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then @@ -1030,8 +1052,20 @@ function Renderer:endFrame(zones, worldZones) local sx, sy = sp / dpiX, sp / dpiY local wvw = self.worldCanvas:getWidth() local wvh = self.worldCanvas:getHeight() - local wox = (vx + math.floor((pw - wvw * sp) / 2)) / dpiX - local woy = (vy + math.floor((ph - wvh * sp) / 2) - R.lift) / dpiY + local woxPx = vx + math.floor((pw - wvw * sp) / 2) + local woyPx = vy + math.floor((ph - wvh * sp) / 2) - R.lift + local wox, woy = woxPx / dpiX, woyPx / dpiY + -- The real on-screen world rect at the CURRENT survey zoom -- can be + -- larger (zoomed out, more map revealed) or smaller than the UI's own + -- default rect above. ShaderFX.render below now shades this real rect + -- against this real wvw x wvh source, not a fixed 160x144 box, so a + -- grid/LCD-style effect's own math lines up with true on-screen pixels + -- at any zoom level. Covers both the flat blit below and the + -- Tilt-projected blit -- both share this wox/woy/sp/wvw/wvh. + fxRectPxX, fxRectPxY = woxPx, woyPx + fxRectPxW, fxRectPxH = wvw * sp, wvh * sp + fxScale = sp + fxSrcW, fxSrcH = wvw, wvh -- Tilt mode projects the ground world pass through the perspective mesh -- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone -- scissoring here). drawTiltedWorld returns false when tilt is off or @@ -1243,9 +1277,9 @@ function Renderer:endFrame(zones, worldZones) if present then GameViewport.setTarget() -- Post-process pipelines run over the finished composite -- world, UI - -- and all -- and before GBC FX, so a blur or colour grade is what the - -- LCD grid is then drawn over rather than something that smears the - -- grid itself. Each pass hands back a canvas; with none registered + -- and all -- and before ShaderFX, so a blur or colour grade is what a + -- shader preset is then drawn over rather than something that smears + -- it. Each pass hands back a canvas; with none registered -- this returns `present` unchanged and the frame is byte-identical. local composed = Pipelines.present(present, { width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present @@ -1260,9 +1294,19 @@ function Renderer:endFrame(zones, worldZones) }) == true if not outputHandled then if cut then love.graphics.setScissor(vux, vuy, vuw, vuh) end - if GBCFX.active() then - -- shader grid/shadow math is in framebuffer pixels - GBCFX.present(composed, Sp) + if ShaderFX.active() then + -- The ShaderFX feature replaced GBCFX.lua's fixed level ladder + -- with a preset picker (GBCFX.lua itself removed). fxRectPx*/fxScale/fxSrc* + -- are this frame's REAL game rect + source size, set above by + -- whichever branch actually ran (worldOverride, worldActive at the + -- current survey zoom, or the UI-rect default for no-world/uiFill + -- states) -- not a fixed 160x144-at-base-Sp reconstruction, so the + -- chain sees true on-screen pixel geometry at any zoom/Faithful + -- Ratio state. + ShaderFX.render(composed, + { x = fxRectPxX, y = fxRectPxY, w = fxRectPxW, h = fxRectPxH, scale = fxScale }, + { w = fxSrcW, h = fxSrcH }, + dpiX, dpiY) else -- the present canvas only existed for the post-process, so put the -- result on the screen at the same 1:1 unit mapping it was built at diff --git a/src/render/ShaderFX.lua b/src/render/ShaderFX.lua new file mode 100644 index 00000000..9d41b833 --- /dev/null +++ b/src/render/ShaderFX.lua @@ -0,0 +1,1053 @@ +-- ShaderFX: runtime libretro slang-shader presets. Translated ahead of time +-- into a cached Lua artifact, then run as a LOVE canvas pass-graph over the +-- finished frame. Two independent slots, "main" and "secondary", chain in +-- that order. See docs/shaderfx.md. + +local ShaderFX = {} + +local Json = require("src.link.Json") +local SaveData = require("src.core.SaveData") +local Performance = require("src.core.Performance") + +-- ------- preset discovery: the local drop-in folder + +-- A real OS path, not a love.filesystem mount: the native bridge and the LUT +-- loads both read it directly. +function ShaderFX.presetDir() + local base = (SaveData.portableBaseDir and SaveData.portableBaseDir()) + or (love.filesystem and love.filesystem.getSaveDirectory and love.filesystem.getSaveDirectory()) + if not base then return nil end + local sep = package.config:sub(1, 1) + return base .. sep .. "shaders" +end + +function ShaderFX.list() + local dir = ShaderFX.presetDir() + if not dir then return {} end + love.filesystem.createDirectory("shaders") + local out = {} + local function scan(relPath) + local items = love.filesystem.getDirectoryItems(relPath) + for _, name in ipairs(items) do + local rel = relPath .. "/" .. name + local info = love.filesystem.getInfo(rel) + if info and info.type == "directory" then + scan(rel) + elseif name:match("%.slangp$") then + local entry = { name = name, relPath = rel, fullPath = dir .. rel:gsub("^shaders", "") } + entry.converted = ShaderFX.isConverted(entry) + out[#out + 1] = entry + end + end + end + local ok, err = pcall(scan, "shaders") + if not ok then + require("src.core.Logger").error("ShaderFX.list: scan failed: %s", tostring(err)) + return {} + end + table.sort(out, function(a, b) return a.name < b.name end) + return out +end + +function ShaderFX.artifactPath(entry) + return (entry.fullPath:gsub("%.slangp$", ".lua")) +end + +-- A real OS path outside any love.filesystem mount, so io.open, not getInfo. +function ShaderFX.isConverted(entry) + local f = io.open(ShaderFX.artifactPath(entry), "rb") + if not f then return false end + f:close() + return true +end + +-- ------- buildbot download: the second acquisition path, alongside the +-- local drop-in folder above. Downloaded presets arrive unconverted. +ShaderFX.BUILDBOT_URL = "https://buildbot.libretro.com/assets/frontend/shaders_slang.zip" + +local DOWNLOAD_ZIP_REL = "shaderfx_buildbot.zip" +local DOWNLOAD_MOUNT = "shaderfx_buildbot_mount" +-- The buildbot's own ETag, replayed as If-None-Match so a repeat download +-- that changed nothing costs one small request instead of the full transfer. +local DOWNLOAD_ETAG_REL = "shaderfx_buildbot.etag" + +-- Returns a Fetch job id; poll it with ShaderFX.downloadStatus(). A status of +-- "ok" with .notModified true means nothing was re-downloaded. +function ShaderFX.downloadPresets() + return require("src.net.Fetch").download(ShaderFX.BUILDBOT_URL, DOWNLOAD_ZIP_REL, + { etagRel = DOWNLOAD_ETAG_REL }) +end + +function ShaderFX.downloadStatus(jobId) + return require("src.net.Fetch").poll(jobId) +end + +-- Collapses "." and ".." segments; Lua has no stdlib equivalent. +local function normalizePath(path) + local parts = {} + for part in path:gmatch("[^/]+") do + if part == ".." then + if #parts > 0 and parts[#parts] ~= ".." then + parts[#parts] = nil + else + parts[#parts + 1] = ".." + end + elseif part ~= "." and part ~= "" then + parts[#parts + 1] = part + end + end + return table.concat(parts, "/") +end + +local function dirname(path) + return path:match("^(.*)/[^/]+$") or "" +end + +-- .png (a LUT) is a real dependency but never itself a source of references. +local TEXT_EXT = { slang = true, slangp = true, inc = true, h = true } +local REF_EXT = { slang = true, slangp = true, inc = true, h = true, png = true } + +-- Quoted assignments are matched first: the closing quote is an unambiguous +-- delimiter, so a path with spaces or parens survives (real packs ship them). +local function extractRefs(text) + local refs, seen = {}, {} + local function addPathRef(path) + local ext = path:match("%.(%a+)$") + if ext and REF_EXT[ext:lower()] and not seen[path] then + seen[path] = true + refs[#refs + 1] = path + end + end + for line in text:gmatch("[^\r\n]+") do + local quoted = line:match('=%s*"([^"]+)"') + if quoted then + addPathRef(quoted) + else + local bare = line:match('=%s*([%.%w/_%-]+)') + if bare then addPathRef(bare) end + end + end + for path in text:gmatch('#include%s+"([^"]+)"') do + if not seen[path] then seen[path] = true; refs[#refs + 1] = path end + end + for path in text:gmatch('#reference%s+"([^"]+)"') do + if not seen[path] then seen[path] = true; refs[#refs + 1] = path end + end + return refs +end + +local function findAll(dir, pattern) + local out = {} + local function scan(rel) + for _, name in ipairs(love.filesystem.getDirectoryItems(rel)) do + local sub = rel .. "/" .. name + local info = love.filesystem.getInfo(sub) + if info and info.type == "directory" then + scan(sub) + elseif name:match(pattern) then + out[#out + 1] = sub + end + end + end + scan(dir) + return out +end + +-- Curated shortlist of handheld/'s own 78 presets -- a temporary trim, expect +-- this list to change (see docs/shaderfx.md). +local KEPT_PRESETS = { + ["gameboy.slangp"] = true, + ["gameboy-advance-dot-matrix.slangp"] = true, + ["gameboy-color-dot-matrix.slangp"] = true, + ["gameboy-color-dot-matrix-white-bg.slangp"] = true, + ["gameboy-dark-mode.slangp"] = true, + ["gameboy-light.slangp"] = true, + ["gameboy-light-mode.slangp"] = true, + ["gameboy-pocket.slangp"] = true, + ["gameboy-pocket-high-contrast.slangp"] = true, + ["gb-palette-dmg.slangp"] = true, + ["gb-palette-light.slangp"] = true, + ["gb-palette-pocket.slangp"] = true, + ["sameboy-lcd.slangp"] = true, + ["dot.slangp"] = true, + ["lcd1x.slangp"] = true, + ["lcd1x_nds.slangp"] = true, + ["lcd1x_psp.slangp"] = true, + ["lcd3x.slangp"] = true, + ["simpletex_lcd.slangp"] = true, + ["simpletex_lcd_720p.slangp"] = true, + ["simpletex_lcd-4k.slangp"] = true, + ["zfast-lcd.slangp"] = true, + ["bevel.slangp"] = true, + ["sunlight_shimmer.slangp"] = true, + ["pixel_transparency.slangp"] = true, + ["retro-v3.slangp"] = true, +} + +-- BFS over the real file-level dependency closure of every KEPT_PRESETS entry +-- inside the mounted zip, copied preserving each file's zip-relative path. +local function extractClosure(mountRoot) + local queue = {} + for _, s in ipairs(findAll(mountRoot .. "/handheld", "%.slangp$")) do + if KEPT_PRESETS[s:match("([^/]+)$")] then queue[#queue + 1] = s end + end + local closure = {} + for _, s in ipairs(queue) do closure[s] = true end + + local head = 1 + while queue[head] do + local rel = queue[head] + head = head + 1 + local zipRel = rel:sub(#mountRoot + 2) -- strip "/" + local ext = zipRel:match("%.(%a+)$") + if ext and TEXT_EXT[ext:lower()] then + local text = love.filesystem.read(rel) + if text then + local base = dirname(zipRel) + for _, ref in ipairs(extractRefs(text)) do + local resolved = normalizePath(base == "" and ref or (base .. "/" .. ref)) + local mounted = mountRoot .. "/" .. resolved + if not closure[mounted] and love.filesystem.getInfo(mounted) then + closure[mounted] = true + queue[#queue + 1] = mounted + end + end + end + end + end + + -- love.filesystem.write does not create intermediate directories. + local madeDirs = {} + local function ensureDir(destPath) + local dir = dirname(destPath) + if dir == "" or madeDirs[dir] then return end + madeDirs[dir] = true + love.filesystem.createDirectory(dir) + end + + local copied = 0 + for rel in pairs(closure) do + local destPath = "shaders/" .. rel:sub(#mountRoot + 2) + local bytes = love.filesystem.read(rel) + if bytes then + ensureDir(destPath) + if love.filesystem.write(destPath, bytes) then copied = copied + 1 end + end + end + return copied +end + +-- Returns the number of files copied, or nil + an error string; a third +-- `unchanged` return means the ETag matched and no zip was ever written. +function ShaderFX.installDownloaded(notModified) + if notModified then + return 0, nil, true + end + love.filesystem.createDirectory("shaders") + if not love.filesystem.mount(DOWNLOAD_ZIP_REL, DOWNLOAD_MOUNT) then + return nil, "could not open the downloaded archive" + end + local ok, copiedOrErr = pcall(extractClosure, DOWNLOAD_MOUNT) + -- unmount() takes the ARCHIVE path passed to mount(), not the mountpoint. + love.filesystem.unmount(DOWNLOAD_ZIP_REL) + love.filesystem.remove(DOWNLOAD_ZIP_REL) + if not ok then return nil, tostring(copiedOrErr) end + return copiedOrErr +end + +-- ------- the native bridge (translation only, called by convert()) + +-- Where the vendored Rust bridge crate builds to, relative to the source dir. +ShaderFX.BRIDGE_DIR = "tools/shaderfx-bridge" + +-- Candidate names per OS, same shape as src/net/Gen1Tls.lua's libNames. +local function libNames() + local osName = (love and love.system and love.system.getOS + and love.system.getOS()) or "" + if osName == "Windows" then + return { "librashader_bridge.dll" } + elseif osName == "OS X" then + return { "liblibrashader_bridge.dylib", "librashader_bridge.dylib" } + end + -- Android resolves the bare name through jniLibs, so try it last. + return { "liblibrashader_bridge.so", "librashader_bridge.so" } +end + +-- getSource is the game directory (where the vendored crate sits); the base +-- directory is its parent (where a packaged build puts the library). +local function sourceDirs() + local out, seen = {}, {} + local function add(dir) + if type(dir) == "string" and dir ~= "" and not seen[dir] then + seen[dir] = true + out[#out + 1] = dir + end + end + local fs = love and love.filesystem + if fs then + if fs.getSource then add(fs.getSource()) end + if fs.getSourceBaseDirectory then add(fs.getSourceBaseDirectory()) end + if fs.getWorkingDirectory then add(fs.getWorkingDirectory()) end + end + if type(arg) == "table" and type(arg[0]) == "string" then + add(arg[0]:match("^(.*)[/\\]")) + end + add(".") + return out +end + +local function saveDir() + if love and love.filesystem and love.filesystem.getSaveDirectory then + local dir = love.filesystem.getSaveDirectory() + if type(dir) == "string" and dir ~= "" then return dir end + end + return nil +end + +local function fileReadable(path) + local f = io.open(path, "rb") + if not f then return false end + f:close() + return true +end + +-- Every place the bridge may sit, most specific first. +local function libCandidates() + local out = {} + local override = os.getenv("LIBRASHADER_BRIDGE_DLL") + if override and override ~= "" then out[#out + 1] = override end + local dirs, save = sourceDirs(), saveDir() + for _, name in ipairs(libNames()) do + for _, dir in ipairs(dirs) do + out[#out + 1] = dir .. "/" .. name + out[#out + 1] = dir .. "/" .. ShaderFX.BRIDGE_DIR .. "/target/release/" .. name + end + if save then out[#out + 1] = save .. "/" .. name end + out[#out + 1] = name + end + return out +end + +local lib +local libError + +local function ensureLib() + if lib then return lib end + if libError then return nil, libError end + local okFfi, ffi = pcall(require, "ffi") + if not okFfi or type(ffi) ~= "table" then + libError = "this build has no ffi, so presets cannot be converted here" + return nil, libError + end + pcall(ffi.cdef, [[ + char* librashader_translate_preset(const char* preset_path, int es); + void librashader_free_string(char* s); + ]]) + local tried = {} + for _, path in ipairs(libCandidates()) do + -- A bare name goes to the system loader; a path only when a file is there. + local bare = not path:find("[/\\]") + if bare or fileReadable(path) then + local ok, loaded = pcall(ffi.load, path) + if ok then lib = loaded; return lib end + end + tried[#tried + 1] = path + end + libError = "librashader bridge not found; looked in " .. table.concat(tried, ", ") + return nil, libError +end + +-- True when a preset can be converted on this machine. Activating an +-- already-converted preset never needs the bridge. +function ShaderFX.canConvert() + local l = ensureLib() + return l ~= nil +end + +function ShaderFX.bridgeError() + return libError +end + +-- `es`: true for GLSL ES 1.00 (mobile), false for GLSL 1.20 (desktop). Only +-- ever called from ShaderFX.convert(). +function ShaderFX.translate(fullPath, es) + local ffi = require("ffi") + local ok, l = pcall(ensureLib) + if not ok then return nil, "ffi.load failed: " .. tostring(l) end + local ptr = l.librashader_translate_preset(fullPath, es and 1 or 0) + if ptr == nil then return nil, "librashader_translate_preset returned NULL" end + local json = ffi.string(ptr) + l.librashader_free_string(ptr) + local preset, decodeErr = Json.decode(json) + if not preset then return nil, "JSON decode failed: " .. tostring(decodeErr) end + if preset.error then return nil, "translate_preset reported an error: " .. tostring(preset.error) end + if preset.pass_count ~= #preset.passes then + return nil, ("pass_count metadata (%s) disagrees with passes array length (%d)") + :format(tostring(preset.pass_count), #preset.passes) + end + return preset +end + +-- love.graphics.newImage cannot open an absolute path outside LOVE's own +-- mounts, which is where a preset's LUTs live, so read the bytes by hand. +function ShaderFX.loadImageFromPath(path) + local f, err = io.open(path, "rb") + if not f then return nil, "io.open failed: " .. tostring(err) end + local bytes = f:read("*a") + f:close() + local byteData = love.data.newByteData(bytes) + local imgData = love.image.newImageData(byteData) + return love.graphics.newImage(imgData) +end + +-- librashader's wrap_mode names are not LOVE's; ES3-legal set only. +local function loveWrapMode(w) + if w == "clamp_to_border" then return "clampzero" + elseif w == "clamp_to_edge" then return "clamp" + elseif w == "repeat" then return "repeat" + elseif w == "mirrored_repeat" then return "mirroredrepeat" + else error("loveWrapMode: unknown wrap_mode " .. tostring(w)) end +end + +-- Called once per activate(). A LUT that fails to load is logged and left nil; +-- runPass's own sampler assert is the fail-loud point, not this. +local function loadLuts(preset) + local byName = {} + for _, t in ipairs(preset.textures) do + local img, err = ShaderFX.loadImageFromPath(t.path) + if img then + img:setFilter(t.filter_mode, t.filter_mode) + img:setWrap(loveWrapMode(t.wrap_mode)) + else + require("src.core.Logger").error("ShaderFX: LUT %s (%s) failed to load: %s", + t.name, tostring(t.path), tostring(err)) + end + byName[t.name] = img + end + return byName +end + +-- ------- the pass-graph runtime + +local Fixup = require("src.render.ShaderFixup") +local Sensors = require("src.core.Sensors") +local ShaderSourcePatches = require("src.render.ShaderSourcePatches") + +-- Glsl100Es (mobile) vs Glsl120 (desktop). Convert and render always run on +-- the same device, so the dialect emitted and the one validated always agree. +local function defaultEs() + if not love or not love.system or not love.system.getOS then return false end + local osName = love.system.getOS() + return osName == "Android" or osName == "iOS" +end + +local function roundDim(x) return math.max(1, math.floor(x + 0.5)) end + +local function resolveScale(scaling, viewportDim, inputDim, originalDim) + local st = scaling.scale_type + if st == "absolute" then return roundDim(scaling.factor) + elseif st == "viewport" then return roundDim(viewportDim * scaling.factor) + elseif st == "source" then return roundDim(inputDim * scaling.factor) + elseif st == "original" then return roundDim(originalDim * scaling.factor) + else error("resolveScale: unknown scale_type " .. tostring(st)) end +end + +local function sizeVec(dims) return { dims.w, dims.h, 1 / dims.w, 1 / dims.h } end + +-- One instance per loaded preset, never module-global, so switching presets +-- cannot leak a previous preset's canvases or dims. +local function newChainState(preset) + local state = { preset = preset, outputDims = {}, ALL_DEFAULTS = {}, + shaderCache = {}, canvasCache = {} } + for _, pass in ipairs(preset.passes) do + for _, p in ipairs(pass.parameters) do state.ALL_DEFAULTS[p.id] = p.initial end + end + for _, o in ipairs(preset.parameter_overrides) do state.ALL_DEFAULTS[o.name] = o.value end + return state +end + +local function passInputDims(state, i, original) + return (i == 0) and original or assert(state.outputDims[i - 1], + ("pass%d: input dims requested before pass%d ran"):format(i, i - 1)) +end + +local function computePassDims(state, i, pass, viewport, original) + local inputDims = passInputDims(state, i, original) + return { + w = resolveScale(pass.scale_x, viewport.w, inputDims.w, original.w), + h = resolveScale(pass.scale_y, viewport.h, inputDims.h, original.h), + } +end + +-- love.sensor.SensorType each built-in motion semantic reads from. +-- AccelerometerRest is librashader's calibration reference, not a real kind. +local SENSOR_KIND = { Accelerometer = "accelerometer", Gyroscope = "gyroscope" } + +-- Yaw integration: deliberately a decaying spring, not a true heading -- +-- gyro-only integration drifts without a magnetometer. Tuned on-device. +local YAW_SHADER_NAME = "sunlight_shimmer.slangp" -- the only twist-reactive preset +local YAW_GAIN = 0.6 +local YAW_TWIST_CLAMP = 2 +local YAW_DECAY_PER_SEC = 2.0 -- higher = snaps back to neutral faster + +-- Throttled on-device diagnostic (~1/sec per slot), tagged for logcat. +local debugLastLog = {} +local function debugThrottle(key) + local now = love.timer and love.timer.getTime() or os.clock() + if not debugLastLog[key] or now - debugLastLog[key] >= 1.0 then + debugLastLog[key] = now + return true + end + return false +end + +local function updateYawTwist(state, dt) + if not state or state.entryName ~= YAW_SHADER_NAME then return end + local gx, yawRate, gz = Sensors.read("gyroscope") + local twist = (state.yawTwist or 0) + yawRate * dt * YAW_GAIN + twist = twist * math.exp(-YAW_DECAY_PER_SEC * dt) + twist = math.max(-YAW_TWIST_CLAMP, math.min(YAW_TWIST_CLAMP, twist)) + state.yawTwist = twist + if debugThrottle("yaw:" .. tostring(state.entryName)) then + require("src.core.Logger").info( + "[DEBUG-tfxsns2] yaw entry=%s gyro=(%.3f,%.3f,%.3f) dt=%.4f twist=%.4f", + tostring(state.entryName), gx, yawRate, gz, dt, twist) + end +end + +local function sizeTable(state, i, dims, sizeUniforms, viewport, original) + local values = { + OutputSize = sizeVec(dims), + SourceSize = sizeVec(passInputDims(state, i, original)), + OriginalSize = sizeVec(original), + } + for _, entry in ipairs(sizeUniforms) do + if entry.kind == "unique" and entry.semantic == "Output" then + values[entry.name] = sizeVec(dims) + elseif entry.kind == "unique" and SENSOR_KIND[entry.semantic] then + local x, y, z = Sensors.read(SENSOR_KIND[entry.semantic]) + local dbgRawX, dbgRawY, dbgRawZ = x, y, z + if entry.semantic == "Accelerometer" then + -- Calibrate against the pose captured at activate(), then swap gravity + -- onto the axis getOrientedTilt ignores (this engine rests upright). + if state.accelRest then + x = x - state.accelRest[1] + y = y - state.accelRest[2] + z = z - state.accelRest[3] + end + y, z = z, y + -- Keep getOrientedTilt's normalize denominator stable, but only against + -- a live reading: an all-zero raw read is Sensors.lua's no-hardware sentinel. + if state.accelRest and (dbgRawX ~= 0 or dbgRawY ~= 0 or dbgRawZ ~= 0) then + z = 9.8 + end + if state.entryName == YAW_SHADER_NAME and state.yawTwist then + -- The only already-compiled channel this preset's stock math reads. + x = x + state.yawTwist + end + end + values[entry.name] = { x, y, z } + if entry.semantic == "Accelerometer" then + -- Test seam: the values that actually reached the packed uniform. + state.lastAccelPacked = { x, y, z } + if debugThrottle("accel:" .. tostring(state.entryName)) then + local d = state.ALL_DEFAULTS or {} + require("src.core.Logger").info( + "[DEBUG-tfxsns2] accel entry=%s raw=(%.3f,%.3f,%.3f) packed=(%.3f,%.3f,%.3f) mag=%.3f twist=%s " .. + "ACCEL_ENABLE=%s SHADOW_MOTION=%s YAW_ENABLE=%s SENSITIVITY=%s", + tostring(state.entryName), dbgRawX, dbgRawY, dbgRawZ, x, y, z, + math.sqrt(x * x + y * y + z * z), tostring(state.yawTwist), + tostring(d.PT_ACCEL_ENABLE), tostring(d.PT_SHADOW_MOTION), + tostring(d.PT_YAW_ENABLE), tostring(d.PT_ACCEL_SENSITIVITY)) + end + end + elseif entry.kind == "unique" and entry.semantic == "AccelerometerRest" then + values[entry.name] = { 0, 0, 0 } + elseif entry.kind == "unique" and entry.semantic == "Rotation" then + -- retroarch_get_rotation(): the CONTENT's rotation, not the device's. + -- Nothing here rotates GB content, so 0 is correct, not a placeholder. + values[entry.name] = 0 + elseif entry.kind == "texture" then + if entry.semantic == "Original" then + values[entry.name] = sizeVec(original) + elseif entry.semantic == "Source" then + values[entry.name] = sizeVec(passInputDims(state, i, original)) + elseif entry.semantic == "OriginalHistory" then + -- Steady-state fallback: every history slot reads the current frame. + values[entry.name] = sizeVec(original) + elseif entry.semantic == "PassOutput" then + values[entry.name] = sizeVec(assert(state.outputDims[entry.index], + ("pass%d: %s references pass%d's output, not yet computed") + :format(i, entry.name, entry.index))) + elseif entry.semantic == "PassFeedback" or entry.semantic == "User" then + error(("pass%d: %s is a %s size uniform -- not yet supported") + :format(i, entry.name, entry.semantic)) + end + end + end + return values +end + +-- Runs pass `i`, drawing `srcImg` into a freshly sized canvas. `lutByName` +-- resolves a User-semantic sampler by the name the translation reported. +local function runPass(state, i, pass, outputs, frameSource, lutByName, viewport, original) + local dims = computePassDims(state, i, pass, viewport, original) + state.outputDims[i] = dims + + -- Compiled once per (state, pass): a pass's GLSL and manifest depend only on + -- the preset, never on per-frame input. fragManifest is cached alongside it. + local cached = state.shaderCache[i] + local shader, fragManifest + if cached then + shader, fragManifest = cached.shader, cached.fragManifest + else + local fixedFragBody + fixedFragBody, fragManifest = Fixup.fragment(pass.fragment) + local fixedVert, vertManifest = Fixup.vertex(pass.vertex) + assert(#fragManifest == #vertManifest, ("pass%d: PUSH struct member count differs"):format(i)) + + local validateErrs = {} + local isEs = defaultEs() + for headIdx, head in ipairs(Fixup.PREC_HEADS) do + local fixedFrag = head .. fixedFragBody + local okShader, err = love.graphics.validateShader(isEs, fixedFrag, fixedVert) + if okShader then + shader = love.graphics.newShader(fixedFrag, fixedVert) + break + else + validateErrs[#validateErrs + 1] = ("variant %d: %s"):format(headIdx, tostring(err)) + end + end + if not shader then + require("src.core.Logger").error("ShaderFX: pass%d shader failed to validate -- %s", + i, table.concat(validateErrs, " | ")) + end + assert(shader, ("pass%d: no PREC_HEADS variant validated (%s)") + :format(i, table.concat(validateErrs, " | "))) + state.shaderCache[i] = { shader = shader, fragManifest = fragManifest } + end + + for _, s in ipairs(pass.samplers) do + local img + if s.semantic == "Source" then img = (i == 0) and frameSource or outputs[i - 1] + elseif s.semantic == "Original" then img = frameSource + elseif s.semantic == "OriginalHistory" then img = frameSource + elseif s.semantic == "PassOutput" then img = outputs[s.index] + elseif s.semantic == "User" then img = lutByName and lutByName[s.user_name] end + assert(img, ("pass%d: no binding resolved for sampler %s (semantic=%s)") + :format(i, s.name, tostring(s.semantic))) + pcall(shader.send, shader, "LIBRA_TEXTURE_" .. s.name, img) + end + + local values = sizeTable(state, i, dims, pass.size_uniforms, viewport, original) + for name, value in pairs(state.ALL_DEFAULTS) do values[name] = value end + local packed = Fixup.packValues(fragManifest, values) + for name, value in pairs(packed) do pcall(shader.send, shader, name, value) end + + local srcImg = (i == 0) and frameSource or outputs[i - 1] + local srcDims = passInputDims(state, i, original) + local drawScaleX, drawScaleY = dims.w / srcDims.w, dims.h / srcDims.h + + -- Reallocated only when this pass's resolved size actually changes. + local cc = state.canvasCache[i] + local canvas + if cc and cc.w == dims.w and cc.h == dims.h then + canvas = cc.canvas + else + canvas = love.graphics.newCanvas(dims.w, dims.h) + canvas:setFilter("nearest", "nearest") + state.canvasCache[i] = { canvas = canvas, w = dims.w, h = dims.h } + end + + love.graphics.push("all") + love.graphics.setCanvas(canvas) + love.graphics.clear(0, 0, 0, 0) + love.graphics.setShader(shader) + if i == state.preset.pass_count - 1 then + love.graphics.setBlendMode("replace", "premultiplied") + else + love.graphics.setBlendMode("replace") + end + love.graphics.draw(srcImg, 0, 0, 0, drawScaleX, drawScaleY) + love.graphics.pop() + + outputs[i] = canvas +end + +-- Runs the whole chain once over `frameSource` (sized `original`), with +-- `viewport` the size "viewport" scale_type passes resolve against. +function ShaderFX.runChain(state, frameSource, lutByName, viewport, original) + local outputs = {} + state.outputDims = {} + for i = 0, state.preset.pass_count - 1 do + runPass(state, i, state.preset.passes[i + 1], outputs, frameSource, lutByName, viewport, original) + end + return outputs[state.preset.pass_count - 1] +end + +-- ------- public, minimal load/render entry points + +-- Loads one entry from its cached Lua artifact; never calls the bridge. +-- Returns a chain state, or nil + an error when it has not been converted. +function ShaderFX.load(entry) + local path = ShaderFX.artifactPath(entry) + local chunk, err = loadfile(path) + if not chunk then + return nil, "no cached artifact, convert this preset first: " .. tostring(err) + end + local ok, preset = pcall(chunk) + if not ok then return nil, "cached artifact failed to run: " .. tostring(preset) end + if type(preset) ~= "table" then return nil, "cached artifact did not return a table" end + local state = newChainState(preset) + state.entryName = entry.name -- see updateYawTwist/sizeTable's Accelerometer branch + return state +end + +-- Every #pragma parameter the entry's artifact declares, deduped by id, each +-- with its currently-effective default. Reads the artifact only. +function ShaderFX.listParams(entry) + local state, err = ShaderFX.load(entry) + if not state then return nil, err end + local order, byId = {}, {} + for _, pass in ipairs(state.preset.passes) do + for _, p in ipairs(pass.parameters) do + if not byId[p.id] then + byId[p.id] = p + order[#order + 1] = p.id + end + end + end + local out = {} + for _, id in ipairs(order) do + local p = byId[id] + -- "!"-prefixed descriptions are engine-owned, not a player preference, so + -- they never become an editable row. No preset uses this today. + if not (p.description and p.description:sub(1, 1) == "!") then + out[#out + 1] = { + id = p.id, description = p.description, + minimum = p.minimum, maximum = p.maximum, step = p.step, + initial = state.ALL_DEFAULTS[p.id], + } + end + end + return out +end + +-- ------- active-preset tracking and the real endFrame render entry point + +-- ------- AOT convert: the one function that calls the bridge, writing its +-- decoded result to disk as a plain Lua artifact. + +-- #t counts a trailing nil as absent, so walk every key instead. +local function arrayLen(t) + local n = 0 + for _ in pairs(t) do n = n + 1 end + for i = 1, n do + if t[i] == nil then return nil end + end + return n +end + +-- Serializes the string/number/boolean/nested-table shape Json.decode +-- produces; array-shaped tables (arrayLen) serialize positionally. +local function serializeLua(v, buf) + local t = type(v) + if t == "string" then + buf[#buf + 1] = string.format("%q", v) + elseif t == "number" or t == "boolean" then + buf[#buf + 1] = tostring(v) + elseif t == "table" then + buf[#buf + 1] = "{" + local n = arrayLen(v) + if n then + for i = 1, n do + serializeLua(v[i], buf) + buf[#buf + 1] = "," + end + else + for k, item in pairs(v) do + assert(type(k) == "string", "serializeLua: non-string, non-array-index table key") + buf[#buf + 1] = "[" .. string.format("%q", k) .. "]=" + serializeLua(item, buf) + buf[#buf + 1] = "," + end + end + buf[#buf + 1] = "}" + else + error("serializeLua: unsupported value type " .. t) + end +end + +-- Translates `entry` via the bridge and writes ShaderFX.artifactPath(entry). +-- Sets entry.converted on success; an existing artifact survives a failure. +function ShaderFX.convert(entry, es) + ShaderSourcePatches.apply(entry) + local preset, err = ShaderFX.translate(entry.fullPath, es == nil and defaultEs() or es) + if not preset then return false, err end + local buf = {} + serializeLua(preset, buf) + local path = ShaderFX.artifactPath(entry) + local f, ferr = io.open(path, "wb") + if not f then return false, "io.open failed: " .. tostring(ferr) end + f:write("return ") + f:write(table.concat(buf)) + f:write("\n") + f:close() + entry.converted = true + return true +end + +-- Two independent slots. "shaderfx" stays main's option key, so existing +-- saves keep meaning what they already meant. +ShaderFX.SLOTS = { "main", "secondary" } +ShaderFX.OPTION_KEY = { main = "shaderfx", secondary = "shaderfxSecondary" } + +local slots = { main = {}, secondary = {} } -- slots[s] = { state=, entry= } +local autoActivateTried = false -- see applyOptions()/tryAutoActivateFromEnv() below + +-- Loads `entry`'s cached artifact into `slot` and makes it that slot's active +-- preset. `paramOverrides` layers a player's pragma edits over ALL_DEFAULTS. +function ShaderFX.activate(slot, entry, paramOverrides) + assert(slots[slot], "ShaderFX.activate: unknown slot " .. tostring(slot)) + local state, err = ShaderFX.load(entry) + if not state then return false, err end + if paramOverrides then + for id, value in pairs(paramOverrides) do state.ALL_DEFAULTS[id] = value end + end + state.luts = loadLuts(state.preset) + -- Snapshot whatever pose the player is actually holding right now as neutral, + -- subtracted from every later reading. Recalibrates on every activate(). + state.accelRest = { Sensors.read("accelerometer") } + require("src.core.Logger").info( + "[DEBUG-tfxsns2] activate entry=%s accelRest=(%.3f,%.3f,%.3f)", + tostring(entry and entry.name), state.accelRest[1], state.accelRest[2], state.accelRest[3]) + slots[slot].state, slots[slot].entry = state, entry + return true +end + +-- `slot` nil deactivates both. +function ShaderFX.deactivate(slot) + if slot then + slots[slot].state, slots[slot].entry = nil, nil + else + for _, s in pairs(slots) do s.state, s.entry = nil, nil end + end +end + +-- `slot` nil answers "is anything active". +function ShaderFX.active(slot) + if slot then return slots[slot].state ~= nil end + return slots.main.state ~= nil or slots.secondary.state ~= nil +end + +function ShaderFX.activeEntry(slot) + return slots[slot] and slots[slot].entry +end + +-- Finds a ShaderFX.list() entry by its `name` field, or nil (e.g. the preset +-- was deleted from the drop-in folder since it was selected). +function ShaderFX.findEntry(name) + if not name or name == "" then return nil end + for _, entry in ipairs(ShaderFX.list()) do + if entry.name == name then return entry end + end + return nil +end + +-- Under AUTO, a tier that caps shaderfx off would force-deactivate a persisted +-- preset choice every boot; pin "high" instead. Never touches an explicit +-- performance choice, and never un-escalates on deactivate. +local function escalatePerformanceIfNeeded(opts) + if not opts then return false end + local wantsShaderfx = false + for _, slot in ipairs(ShaderFX.SLOTS) do + local want = opts[ShaderFX.OPTION_KEY[slot]] + if want and want ~= "" then wantsShaderfx = true end + end + if not wantsShaderfx then return false end + if opts.performance ~= nil and opts.performance ~= "auto" then return false end + local Performance = require("src.core.Performance") + if Performance.caps("auto").shaderfx then return false end + opts.performance = "high" + return true +end + +function ShaderFX.applyOptions(opts) + autoActivateTried = true + local cleared = false + for _, slot in ipairs(ShaderFX.SLOTS) do + local key = ShaderFX.OPTION_KEY[slot] + local want = opts and opts[key] + if not want or want == "" then + ShaderFX.deactivate(slot) + else + local entry = ShaderFX.findEntry(want) + if not entry then + if opts then opts[key] = nil end + ShaderFX.deactivate(slot) + cleared = true + else + -- isConverted() is existence-only with no staleness check, so a saved + -- choice reconverts here too -- at boot/options-save, never per frame. + local convOk, convErr = ShaderFX.convert(entry) + if not convOk then + require("src.core.Logger").error("ShaderFX.applyOptions: reconvert failed for %s (%s): %s", + want, slot, tostring(convErr)) + end + local paramOverrides = opts.shaderfxParams and opts.shaderfxParams[entry.name] + local ok, err = ShaderFX.activate(slot, entry, paramOverrides) + if not ok then + require("src.core.Logger").error("ShaderFX.applyOptions: %s (%s) failed to activate: %s", + want, slot, tostring(err)) + if opts then opts[key] = nil end + ShaderFX.deactivate(slot) + cleared = true + end + end + end + end + if escalatePerformanceIfNeeded(opts) then cleared = true end + return cleared +end + +-- Stand-in from before the real OPTIONS row existed; only still fires for +-- harnesses that call ShaderFX.render() without ever calling applyOptions(). +local function tryAutoActivateFromEnv() + autoActivateTried = true + local want = os.getenv("POKEPORT_SHADERFX") + if not want or want == "" then return end + local entry = ShaderFX.findEntry(want) + if not entry then + require("src.core.Logger").error("ShaderFX: POKEPORT_SHADERFX=%s not found under %s", + want, tostring(ShaderFX.presetDir())) + return + end + local ok, err = ShaderFX.activate("main", entry) + if not ok then + require("src.core.Logger").error("ShaderFX: POKEPORT_SHADERFX=%s failed to activate: %s", + want, tostring(err)) + end +end + +-- Crops the playfield rect out of `canvas` at `renderScale`. The output canvas +-- is cached module-wide and reallocated only on a real size change. +local cropCanvasCache +local function cropToGbSource(canvas, rect, srcW, srcH, renderScale) + -- Cheap insurance against a read-after-write hazard between this draw and + -- whatever last rendered into `canvas`. + love.graphics.flushBatch() + local outW, outH = roundDim(srcW * renderScale), roundDim(srcH * renderScale) + local quad = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h, + canvas:getPixelWidth(), canvas:getPixelHeight()) + local out + if cropCanvasCache and cropCanvasCache.w == outW and cropCanvasCache.h == outH then + out = cropCanvasCache.canvas + else + out = love.graphics.newCanvas(outW, outH) + out:setFilter("nearest", "nearest") + cropCanvasCache = { canvas = out, w = outW, h = outH } + end + local invScale = renderScale / rect.scale + love.graphics.push("all") + love.graphics.setCanvas(out) + love.graphics.clear(0, 0, 0, 0) + love.graphics.setBlendMode("replace") + -- push("all") saves the caller's draw color but does not reset it: a menu can + -- leave it black, which would multiply the whole crop to black. + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(canvas, quad, 0, 0, 0, invScale, invScale) + love.graphics.pop() + return out +end + +-- The tier's SHADER FX chain-resolution multiplier; 1.0 when the tier's cap is +-- not a number. +local function chainRenderScale() + local caps = Performance.CAPS[Performance.tier] + return (caps and tonumber(caps.shaderfx)) or 1.0 +end + +-- Renderer.lua's endFrame entry point. `canvas` is the finished window-size +-- composite; `rect` is this frame's real game rect in PHYSICAL framebuffer +-- pixels and `source` the content size it frames. See docs/shaderfx.md. +function ShaderFX.render(canvas, rect, source, dpiX, dpiY) + if not autoActivateTried then tryAutoActivateFromEnv() end + if not slots.main.state and not slots.secondary.state then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(canvas, 0, 0) + return + end + + dpiX, dpiY = tonumber(dpiX) or 1, tonumber(dpiY) or 1 + -- Same physical location as `rect`, in the logical units LOVE draws in. + local uRect = { + x = math.floor(rect.x / dpiX), y = math.floor(rect.y / dpiY), + w = math.floor(rect.w / dpiX), h = math.floor(rect.h / dpiY), + } + -- Runs the chain at a reduced internal resolution on tiers that ask for one. + local scale = chainRenderScale() + local viewport = { w = roundDim(uRect.w * scale), h = roundDim(uRect.h * scale) } + + -- exposed for tests only (tests/drivers/gold_shaderfx_zoom_sizing_test.lua) + ShaderFX._lastRect, ShaderFX._lastSource = rect, source + + local dt = love.timer and love.timer.getDelta() or 0 + updateYawTwist(slots.main.state, dt) + updateYawTwist(slots.secondary.state, dt) + -- Test seam: the live per-frame integration, before it rides into the shader. + ShaderFX._lastYawTwist = { + main = slots.main.state and slots.main.state.yawTwist, + secondary = slots.secondary.state and slots.secondary.state.yawTwist, + } + + local ok, chainOut, chainPreset = pcall(function() + local frameSource = cropToGbSource(canvas, rect, source.w, source.h, scale) + -- exposed for tests only (gold_shaderfx_menu_black_crop_test.lua) + ShaderFX._lastCrop = frameSource + local dims = { w = frameSource:getWidth(), h = frameSource:getHeight() } + local img, preset = frameSource, nil + if slots.main.state then + img = ShaderFX.runChain(slots.main.state, img, slots.main.state.luts, viewport, dims) + dims = { w = img:getWidth(), h = img:getHeight() } + preset = slots.main.state.preset + end + if slots.secondary.state then + img = ShaderFX.runChain(slots.secondary.state, img, slots.secondary.state.luts, viewport, dims) + dims = { w = img:getWidth(), h = img:getHeight() } + preset = slots.secondary.state.preset + end + return img, preset + end) + + if not ok or not chainOut then + require("src.core.Logger").error( + "ShaderFX.render chain failed ok=%s chainOut=%s err=%s", + tostring(ok), tostring(chainOut), tostring(not ok and chainOut or nil)) + end + + love.graphics.setColor(1, 1, 1, 1) + love.graphics.setBlendMode("replace") + love.graphics.draw(canvas, 0, 0) + if ok and chainOut then + -- A chain's final pass need not land on the viewport size (most presets end + -- at scale_type="source"), so stretch, using that pass's own filter. + local cw, ch = chainOut:getWidth(), chainOut:getHeight() + local lastPass = chainPreset.passes[chainPreset.pass_count] + local mode = (lastPass.filter == "linear") and "linear" or "nearest" + chainOut:setFilter(mode, mode) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.setBlendMode("alpha") + love.graphics.draw(chainOut, uRect.x, uRect.y, 0, uRect.w / cw, uRect.h / ch) + elseif not ok then + require("src.core.Logger").error("ShaderFX.render: chain failed, showing unprocessed frame: %s", + tostring(chainOut)) + end + love.graphics.setBlendMode("alpha") + -- Test seam; set only after the real chain ran. + ShaderFX._lastAccelPacked = { + main = slots.main.state and slots.main.state.lastAccelPacked, + secondary = slots.secondary.state and slots.secondary.state.lastAccelPacked, + } +end + +return ShaderFX diff --git a/src/render/ShaderFixup.lua b/src/render/ShaderFixup.lua new file mode 100644 index 00000000..ff0293db --- /dev/null +++ b/src/render/ShaderFixup.lua @@ -0,0 +1,295 @@ +-- Mechanical GLSL rewrites over librashader's emitted output: it emits a +-- standalone void main()/gl_FragData[0]/gl_Position shape, LOVE requires the +-- effect()/position() convention. Not a GLSL parser -- every rule here exists +-- because a real preset failed without it. See docs/shaderfx.md. +local Fixup = {} + +local function stripVersion(src) + return (src:gsub("#version%s+%d+%s*\n", "")) +end + +-- ES 1.00 has no array-constructor syntax at all; SPIRV-Cross emits it anyway. +-- Splitting needs paren-depth awareness, since an element can be vec2(a, b). +local function splitTopLevelCommas(s) + local parts, depth, start = {}, 0, 1 + for i = 1, #s do + local c = s:sub(i, i) + if c == "(" then + depth = depth + 1 + elseif c == ")" then + depth = depth - 1 + elseif c == "," and depth == 0 then + parts[#parts + 1] = s:sub(start, i - 1) + start = i + 1 + end + end + parts[#parts + 1] = s:sub(start) + return parts +end + +local function elementAssignLines(name, elemsStr) + local lines = {} + for i, elem in ipairs(splitTopLevelCommas(elemsStr)) do + lines[#lines + 1] = ("%s[%d] = %s;"):format(name, i - 1, elem:match("^%s*(.-)%s*$")) + end + return lines +end + +local function rewriteArrayLiterals(src) + local injections = {} + -- const, global scope: relocate the per-element assignments into main(). + src = src:gsub( + "const%s+([%w_]+)%s+([%w_]+)%s*%[%s*(%d+)%s*%]%s*=%s*[%w_]+%s*%[%s*%]%s*(%b())%s*;", + function(typ, name, size, parenElems) + for _, line in ipairs(elementAssignLines(name, parenElems:sub(2, -2))) do + injections[#injections + 1] = " " .. line + end + return ("%s %s[%s];"):format(typ, name, size) + end) + if #injections > 0 then + src = src:gsub("(void%s+main%s*%(%s*%)%s*{)", + "%1\n" .. table.concat(injections, "\n"), 1) + end + -- non-const, local declaration+initializer: rewrite in place. + src = src:gsub( + "([%w_]+)%s+([%w_]+)%s*%[%s*(%d+)%s*%]%s*=%s*[%w_]+%s*%[%s*%]%s*(%b())%s*;", + function(typ, name, size, parenElems) + local lines = elementAssignLines(name, parenElems:sub(2, -2)) + return ("%s %s[%s];\n "):format(typ, name, size) .. table.concat(lines, "\n ") + end) + -- bare reassignment of an already-declared array: rewrite in place. + src = src:gsub( + "([%w_]+)%s*=%s*[%w_]+%s*%[%s*%]%s*(%b())%s*;", + function(name, parenElems) + return table.concat(elementAssignLines(name, parenElems:sub(2, -2)), "\n ") + end) + -- array-to-array copy (`float param_1[7] = coeffs;`): ES 1.00 has no + -- whole-array assignment either, so copy element by element. + src = src:gsub( + "([%w_]+)%s+([%w_]+)%s*%[%s*(%d+)%s*%]%s*=%s*([%w_]+)%s*;", + function(typ, name, size, srcName) + local lines = {} + for i = 0, tonumber(size) - 1 do + lines[#lines + 1] = ("%s[%d] = %s[%d];"):format(name, i, srcName, i) + end + return ("%s %s[%s];\n "):format(typ, name, size) .. table.concat(lines, "\n ") + end) + return src +end +Fixup.rewriteArrayLiterals = rewriteArrayLiterals + +-- ES 1.00 has no `%` operator. `%` is only ever defined for integer operands, +-- so float mod() is exact here; balanced-paren operand shapes are tried first. +local function rewriteIntegerModulo(src) + local function convert(l, r) + return ("int(mod(float(%s), float(%s)))"):format(l, r) + end + src = src:gsub("(%b())%s*%%%s*(%b())", convert) + src = src:gsub("(%b())%s*%%%s*([%w_]+)", convert) + src = src:gsub("([%w_]+)%s*%%%s*(%b())", convert) + src = src:gsub("([%w_]+)%s*%%%s*([%w_]+)", convert) + return src +end +Fixup.rewriteIntegerModulo = rewriteIntegerModulo + +-- SPIRV-Cross hardcodes an unguarded `precision highp` pair with no toggle; +-- claim highp only where the driver actually offers fragment highp. +local function guardPrecision(src) + src = src:gsub("precision%s+highp%s+float%s*;%s*\nprecision%s+highp%s+int%s*;%s*\n", "") + local guard = [[ +#ifdef GL_ES +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +precision highp int; +#endif +#endif +]] + return guard .. src +end + +-- LOVE's Shader:send cannot address a member of a struct-typed uniform, so the +-- struct is deleted and its members re-declared: scalars packed 4-at-a-time +-- into vec4 slots (ES 1.00 guarantees only 16 fragment uniform vectors), +-- vectors kept whole. `specialMembers` substitutes a member to fixed text +-- instead of declaring it at all (see UBO_SPECIAL_MEMBERS). +local SCALAR_TYPES = { float = true, int = true, bool = true } +local COMPONENTS = { "x", "y", "z", "w" } + +local function flattenStruct(src, structName, packedPrefix, specialMembers) + packedPrefix = packedPrefix or "LIBRA_PACKED_" + specialMembers = specialMembers or {} + local body = src:match("struct%s+" .. structName .. "%s*{(.-)}%s*;") + if not body then return src, {} end + local instance = src:match("uniform%s+" .. structName .. "%s+([%w_]+)%s*;") + assert(instance, "flattenStruct: found struct " .. structName .. " but no instance uniform") + + src = src:gsub("struct%s+" .. structName .. "%s*{.-}%s*;%s*\n", "") + src = src:gsub("uniform%s+" .. structName .. "%s+" .. instance .. "%s*;%s*\n", "") + + local members = {} + for typ, name in body:gmatch("([%w_]+)%s+([%w_]+)%s*;") do + members[#members + 1] = { type = typ, name = name } + end + -- Declaration order matters here: packing scalars 4-at-a-time only minimizes + -- group count against the struct's own vec4-then-scalars layout. + + local decls, manifest, specialSubs = {}, {}, {} + local scalarGroup, packIndex = {}, 0 + + local function flushGroup() + if #scalarGroup == 0 then return end + local uniformName = packedPrefix .. packIndex + decls[#decls + 1] = ("uniform vec4 %s;"):format(uniformName) + for i, m in ipairs(scalarGroup) do + manifest[#manifest + 1] = { name = m.name, uniform = uniformName, component = COMPONENTS[i], type = m.type } + end + packIndex, scalarGroup = packIndex + 1, {} + end + + for _, m in ipairs(members) do + if specialMembers[m.name] then + specialSubs[#specialSubs + 1] = { name = m.name, replacement = specialMembers[m.name] } + elseif SCALAR_TYPES[m.type] then + scalarGroup[#scalarGroup + 1] = m + if #scalarGroup == 4 then flushGroup() end + else + flushGroup() + decls[#decls + 1] = ("uniform %s %s;"):format(m.type, m.name) + manifest[#manifest + 1] = { name = m.name, uniform = m.name } + end + end + flushGroup() + + -- longest-name-first so a substitution can't land inside a longer member name + -- sharing a prefix; a separate order from the packing above. A packed slot + -- only stores floats, so an int/bool member needs a cast back on every read. + local subs = {} + for _, entry in ipairs(manifest) do + local replacement = entry.component and (entry.uniform .. "." .. entry.component) or entry.uniform + if entry.component and (entry.type == "int" or entry.type == "bool") then + replacement = ("%s(%s)"):format(entry.type, replacement) + end + subs[#subs + 1] = { name = entry.name, replacement = replacement } + end + for _, entry in ipairs(specialSubs) do subs[#subs + 1] = entry end + table.sort(subs, function(a, b) return #a.name > #b.name end) + for _, entry in ipairs(subs) do + src = src:gsub(instance .. "%." .. entry.name, entry.replacement) + end + + return table.concat(decls, "\n") .. "\n" .. src, manifest +end +Fixup.flattenStruct = flattenStruct + +-- Turns a flat {member = value} table into the {uniform = value_or_vec4} shape +-- the packed shader expects. A missing value packs as 0 in its component. +function Fixup.packValues(manifest, values) + local packed = {} + local slot = { x = 1, y = 2, z = 3, w = 4 } + for _, entry in ipairs(manifest) do + if entry.component then + packed[entry.uniform] = packed[entry.uniform] or { 0, 0, 0, 0 } + packed[entry.uniform][slot[entry.component]] = values[entry.name] or 0 + else + packed[entry.uniform] = values[entry.name] + end + end + return packed +end + +-- GLSL ES 1.00 guarantees only 16 fragment uniform *vectors* and every scalar +-- costs a whole one; samplers do not come out of that budget. +Fixup.SLOTS = { float = 1, int = 1, bool = 1, vec2 = 1, vec3 = 1, vec4 = 1, mat3 = 3, mat4 = 4 } + +function Fixup.countUniformSlots(src) + -- Lua's %w does NOT match underscore, unlike regex \w, and every name here is + -- full of them, so this must be [%w_]+ or whole declarations go uncounted. + local total = 0 + for typ in src:gmatch("uniform%s+([%w_]+)%s+[%w_]+%s*;") do + if typ ~= "sampler2D" and typ ~= "Image" then + total = total + (Fixup.SLOTS[typ] or 1) + end + end + return total +end + +-- LIBRA_UBO_* (the uniform-buffer block) has the same +-- LOVE-can't-address-a-struct-member problem as LIBRA_PUSH_*, so it is +-- flattened the same way, with MVP alone special-cased. Deleting the block +-- outright instead left every other member reference dangling. + +-- LOVE forward-declares effect()'s prototype under its own header's precision +-- default, which can mismatch guardPrecision's; pin the parameter list to a +-- #define so the caller can try each variant in order. +Fixup.PREC_HEADS = { "#define EFFECT_PREC mediump\n", "#define EFFECT_PREC\n" } + +-- MVP is meaningless in the fragment stage but gets declared anyway; special- +-- casing it keeps a dead field from becoming a uniform nothing would send. +local UBO_SPECIAL_MEMBERS = { MVP = "transform_projection" } + +function Fixup.fragment(src) + src = stripVersion(src) + src = rewriteArrayLiterals(src) + src = rewriteIntegerModulo(src) + local pushManifest, uboManifest + src, pushManifest = flattenStruct(src, "LIBRA_PUSH_FRAGMENT") + src, uboManifest = flattenStruct(src, "LIBRA_UBO_FRAGMENT", "LIBRA_UBO_PACKED_", UBO_SPECIAL_MEMBERS) + local manifest = pushManifest + for _, entry in ipairs(uboManifest) do manifest[#manifest + 1] = entry end + src = guardPrecision(src) + src = src:gsub("void%s+main%s*%(%s*%)", + "vec4 effect(EFFECT_PREC vec4 love_UnusedColor, Image love_UnusedTex, " + .. "EFFECT_PREC vec2 love_UnusedTc, EFFECT_PREC vec2 love_UnusedSc)") + -- gl_FragData isn't available in LOVE's effect() convention. Rewrite EVERY + -- occurrence, whatever operator follows: real presets (ds-hybrid-sabr) write + -- it once with `=` and later accumulate with `+=`. + src = src:gsub("gl_FragData%[0%]", "gbFragColor") + src = src:gsub( + "(vec4 effect%(EFFECT_PREC vec4 love_UnusedColor, Image love_UnusedTex, " + .. "EFFECT_PREC vec2 love_UnusedTc, EFFECT_PREC vec2 love_UnusedSc%)%s*\n{)", + "%1\n vec4 gbFragColor;") + -- a bare early-exit `return;` needs a value now -- gbFragColor already + -- holds whatever was assigned just before it on every real shape seen. + src = src:gsub("return%s*;", "return gbFragColor;") + local lastBrace = src:match("()}%s*$") + assert(lastBrace, "fragment fixup: could not find effect()'s closing brace") + src = src:sub(1, lastBrace - 1) .. " return gbFragColor;\n" .. src:sub(lastBrace) + return src, manifest +end + +function Fixup.vertex(src) + src = stripVersion(src) + src = rewriteArrayLiterals(src) + src = rewriteIntegerModulo(src) + local pushManifest, uboManifest + src, pushManifest = flattenStruct(src, "LIBRA_PUSH_VERTEX") + -- MVP means "multiply the incoming vertex by it", which on an ordinary + -- full-screen LOVE draw is transform_projection, so it substitutes rather + -- than being packed. A distinct prefix keeps the UBO's packed groups off + -- LIBRA_PUSH_*'s own numbering. + src, uboManifest = flattenStruct(src, "LIBRA_UBO_VERTEX", "LIBRA_UBO_PACKED_", UBO_SPECIAL_MEMBERS) + local manifest = pushManifest + for _, entry in ipairs(uboManifest) do manifest[#manifest + 1] = entry end + -- LOVE supplies VertexPosition/VertexTexCoord itself. + src = src:gsub("attribute%s+vec4%s+Position%s*;%s*\n", "") + src = src:gsub("attribute%s+vec2%s+TexCoord%s*;%s*\n", "") + src = src:gsub("void%s+main%s*%(%s*%)", + "vec4 position(mat4 transform_projection, vec4 vertex_position)") + -- gl_Position isn't available in LOVE's position() convention; carry it in a + -- local named to share no substring with "Position". + src = src:gsub("gl_Position%s*=%s*([^;]+);", "gbClipPos = %1;") + -- Whole-identifier frontier match, not a blind gsub: a preset's own local + -- `vec2 vTexCoord;` would otherwise be mangled (dot.slangp pass0). + src = src:gsub("%f[%w]Position%f[%W]", "vertex_position") + src = src:gsub("%f[%w]TexCoord%f[%W]", "VertexTexCoord%.xy") + src = src:gsub( + "(vec4 position%(mat4 transform_projection, vec4 vertex_position%)%s*\n{)", + "%1\n vec4 gbClipPos;") + -- append the return just before the function's final closing brace + local lastBrace = src:match("()}%s*$") + assert(lastBrace, "vertex fixup: could not find main()'s closing brace") + src = src:sub(1, lastBrace - 1) .. " return gbClipPos;\n" .. src:sub(lastBrace) + return src, manifest +end + +return Fixup diff --git a/src/render/ShaderSourcePatches.lua b/src/render/ShaderSourcePatches.lua new file mode 100644 index 00000000..e5b10337 --- /dev/null +++ b/src/render/ShaderSourcePatches.lua @@ -0,0 +1,67 @@ +-- Local find/replace patches applied to a preset's raw .slang/.inc source on +-- disk, before ShaderFX.convert() hands it to the bridge -- only librashader's +-- own parser can discover a new #pragma parameter, so nothing downstream can +-- add one. Ships with an empty table on purpose; see docs/shaderfx.md. + +local Logger = require("src.core.Logger") + +local M = {} + +-- entry.name -> { { relPath = , +-- patches = { { old = , new = }, ... } } } +M.PATCHES = {} + +local function dirname(path) + return path:match("^(.*)[/\\][^/\\]*$") or "." +end + +-- Plain (non-pattern) substring replace: GLSL source is full of Lua pattern +-- magic characters, so gsub on literal source text is not safe here. +local function replaceOnce(text, old, new) + local s, e = text:find(old, 1, true) + if not s then return text, false end + return text:sub(1, s - 1) .. new .. text:sub(e + 1), true +end + +-- Applies every registered patch for `entry` to the real files on disk. A patch +-- whose anchor is gone (already applied, or upstream changed) is skipped. +function M.apply(entry) + local list = M.PATCHES[entry.name] + if not list then return end + local base = dirname(entry.fullPath) + for _, file in ipairs(list) do + local path = base .. "/" .. file.relPath + local f = io.open(path, "rb") + if not f then + Logger.warn("ShaderSourcePatches: %s: cannot open %s", entry.name, path) + else + local text = f:read("*a") + f:close() + local changed = false + for _, p in ipairs(file.patches) do + local already = text:find(p.new, 1, true) ~= nil + if not already then + local newText, ok = replaceOnce(text, p.old, p.new) + if ok then + text = newText + changed = true + else + Logger.warn("ShaderSourcePatches: %s: patch anchor not found in %s (upstream changed?)", + entry.name, file.relPath) + end + end + end + if changed then + local wf, werr = io.open(path, "wb") + if not wf then + Logger.warn("ShaderSourcePatches: %s: cannot write %s: %s", entry.name, path, tostring(werr)) + else + wf:write(text) + wf:close() + end + end + end + end +end + +return M diff --git a/src/render/Zoom.lua b/src/render/Zoom.lua index 00484251..a448870f 100644 --- a/src/render/Zoom.lua +++ b/src/render/Zoom.lua @@ -17,6 +17,25 @@ Zoom.offset = 0 -- close-up. Nil/true keeps the historical full range. Zoom.allowSurvey = true +-- The deepest legal survey step (offset = 1-S, effective scale s' = S+lo = +-- 1 px/world px) plus an active SHADER FX chain crashes the app on real +-- hardware (Pixel 10 Pro XL, "OUT7" == this exact step at that device's own +-- fit scale of 8 -- reported by the user, 2026-08-21). A quantifying repro +-- (TrueFX/zoom-mem-scan/) ruled out ShaderFX's own per-pass canvases as the +-- driver (worst real-corpus case at that step: ~31MB, not crash-sized) -- +-- the actual cost is almost certainly the world canvas/tile-render path +-- itself at that resolution (a preexisting cost of deep survey zoom, +-- unrelated to ShaderFX), which a shader chain reading that same +-- full-resolution canvas as its own input then pushes over some real +-- device/driver ceiling this project cannot measure without the device +-- in hand. Removing just the single deepest step while a preset is active +-- is the smallest change that directly targets what was actually reported +-- ("OUT7 + any shader", not "OUT7 alone" and not "OUT6 or shallower") -- +-- not a guessed-at general zoom restriction. Needs the user's own on-device +-- confirmation; extend the margin (currently 1 step) if OUT6 turns out to +-- be risky too. +local ShaderFX + -- legal offset range for a given fit scale (vanilla: survey at 1 px/world -- through 2× fit). zoom.range may widen or shrink the window. -- When the window only fits 1×, 1-S is 0 and there would be no OUT @@ -37,6 +56,14 @@ function Zoom.offsetRange(S) elseif lo > -3 then lo = -3 end + -- SHADER FX + the single deepest survey step: see the comment above. + -- The floor is one step above the deepest this call would otherwise + -- allow, so the three-step minimum above still keeps a zoom-out. + ShaderFX = ShaderFX or require("src.render.ShaderFX") + if ShaderFX.active() then + local floor = math.min(2 - S, lo + 1) + if lo < floor then lo = floor end + end return lo, hi end diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index 8181e048..e9d11bd0 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -193,7 +193,7 @@ local function defaultsSave() battleLayout = "og", ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, pikaVol = 7, musicFilter = 0, - speed = 1, colors = "gbc", tilt = 0, gbcfx = 0, + speed = 1, colors = "gbc", tilt = 0, videoMode = "windowed", mods = {}, }, } diff --git a/src/script/Commands.lua b/src/script/Commands.lua index c3520a34..3646939a 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -340,10 +340,8 @@ function Commands.start_battle(ctx, kind, a, b) ctx.lastBattleResult = result ctx.lastCheck = result == "win" if ctx.overworld then - -- A map script often follows a trainer battle with its own text. - -- Keep a level evolution behind that text: otherwise afterBattle - -- pushes the evolution screen, then this runner resumes and pushes - -- the trainer's text on top of it. + -- The map-side follow-up (stampClosedDoors, #372) rides behind the + -- script's own text; the evolution now runs in BattleState:finish. if result == "win" then ctx.afterScript = ctx.afterScript or {} table.insert(ctx.afterScript, function() @@ -1211,6 +1209,16 @@ function Commands.replace_block(ctx, bx, by, blockId) if ctx.overworld then ctx.overworld:replaceBlock(bx, by, blockId) end end +-- ss_anne_departs: scripts/VermilionDock.asm:80 .shift_columns_up, blocking +-- until she has cleared her own width +function Commands.ss_anne_departs(ctx) + local ow = ctx.overworld + if not ow or not ow.startSsAnneDeparture then return end + local runner = ctx.runner + ow:startSsAnneDeparture(function() runner:resume() end) + runner:yield() +end + -- set_tile_anim : override the current tileset's animation -- ("TILEANIM_WATER"; false stops it) until the next map change restores -- the record (setMap) @@ -1422,7 +1430,7 @@ Commands.meta = {} for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp", "open_mart", "trade", "push_screen", "record_hall_of_fame", "old_man_demo", "static_battle", "rival_battle", "give_item", - "give_pokemon", "fade", "pan_camera" }) do + "give_pokemon", "fade", "pan_camera", "ss_anne_departs" }) do Commands.meta[verb] = { foreground = true } end for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp", @@ -1430,7 +1438,8 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp", "old_man_demo", "static_battle", "rival_battle", "give_item", "give_pokemon", "wait", "wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc", - "emote", "fade", "pan_camera", "play_once", "pikachu_make_way" }) do + "emote", "fade", "pan_camera", "play_once", "pikachu_make_way", + "ss_anne_departs" }) do local meta = Commands.meta[verb] or {} Commands.meta[verb] = meta meta.blocking = true diff --git a/src/script/gen2/CallAsm.lua b/src/script/gen2/CallAsm.lua index d2f9f7fd..4feb0155 100644 --- a/src/script/gen2/CallAsm.lua +++ b/src/script/gen2/CallAsm.lua @@ -390,9 +390,12 @@ function H.CutDownTreeOrGrass(ctx) end -- DisappearWhirlpool is CutDownTreeOrGrass with PlayWhirlpoolSound in place of --- OWCutAnimation: the same block write, the same redraw. +-- OWCutAnimation: the same block write, the same redraw, then the surf wash the +-- port owns as World:playWhirlpoolSound. #1717 function H.DisappearWhirlpool(ctx) - return H.CutDownTreeOrGrass(ctx) + local ret = H.CutDownTreeOrGrass(ctx) + call(ctx, "playWhirlpoolSound") + return ret end -- BlindingFlash sets STATUSFLAGS_FLASH_F and reloads the palettes. Setting diff --git a/src/script/gen2/Movement.lua b/src/script/gen2/Movement.lua index 24cece57..413d5390 100644 --- a/src/script/gen2/Movement.lua +++ b/src/script/gen2/Movement.lua @@ -32,6 +32,12 @@ local FIX_FACING = 0x3b -- STEP_TYPE_SLEEP with OBJECT_ACTION set to OBJECT_ACTION_WEIRD_TREE. local TREE_SHAKE = 0x56 local TREE_SHAKE_FRAMES = 24 +-- engine/events/forced_movement.asm:25-51; step_dig's frame count is the +-- byte after it (macros/scripts/movement.asm:163-167) +local TURN_HEAD = 0x00 +local TURN_IN = 0x24 +local STEP_DIG = 0x4f +local STEP_DIG_FRAMES = 16 function Movement.dir(nibble) return DIR[nibble % 4] or "down" @@ -57,8 +63,10 @@ function Movement.decodeByte(b) return { kind = "step", dir = dir } elseif family == 0x14 or family == 0x18 or family == 0x1c then -- slides return { kind = "step", dir = dir } - elseif family == 0x20 or family == 0x24 or family == 0x28 then -- turn_away/in/waterfall - return { kind = "turn", dir = dir } + elseif family == 0x20 or family == 0x24 or family == 0x28 then + -- turn_away / turn_in / turn_waterfall all `jp TurningStep`, which steps a + -- cell under OBJECT_ACTION_SPIN -- movement.asm:483-513, :693-715 (#1716) + return { kind = "step", dir = dir, spin = true } elseif family == 0x2c or family == 0x30 or family == 0x34 then -- slow_jump_step / jump_step / fast_jump_step, all of which reach -- JumpStep (engine/overworld/movement.asm:741) and so run under @@ -141,6 +149,8 @@ Movement.REMOVE_FIXED_FACING = REMOVE_FIXED_FACING Movement.FIX_FACING = FIX_FACING Movement.TREE_SHAKE = TREE_SHAKE Movement.TREE_SHAKE_FRAMES = TREE_SHAKE_FRAMES +Movement.STEP_DIG = STEP_DIG +Movement.STEP_DIG_FRAMES = STEP_DIG_FRAMES -- SetFacingWeirdTree's own index: it increments OBJECT_STEP_FRAME BEFORE -- masking (`inc a / maskbits NUM_DIRECTIONS, 2 / rrca / rrca`), so the count @@ -177,4 +187,15 @@ function Movement.stepByte(dir) return 0x0c + (DIR_BYTE[dir] or 0) end +-- Script_ForcedMovement's stream, `back` being the direction thrown in +-- -- engine/events/forced_movement.asm:25-51 +function Movement.forcedMovementBytes(back) + local d = DIR_BYTE[back] or 0 + return { + STEP_DIG, STEP_DIG_FRAMES, TURN_IN + d, + STEP_DIG, STEP_DIG_FRAMES, TURN_HEAD + d, + STEP_END, + } +end + return Movement diff --git a/src/script/gen2/Specials.lua b/src/script/gen2/Specials.lua index b45b4065..c0240a48 100644 --- a/src/script/gen2/Specials.lua +++ b/src/script/gen2/Specials.lua @@ -1375,6 +1375,13 @@ H.GiveShuckle = function(vm) mon.ot = Specials.MANIA_OT mon.otId = Specials.MANIA_OT_ID list[#list + 1] = mon + -- TryAddMonToParty's .registerpokedex (engine/pokemon/move_mon.asm:188-196) #1719 + local record = save(vm) + if record then + record.pokedex = record.pokedex or { seen = {}, caught = {} } + record.pokedex.seen[mon.species] = true + record.pokedex.caught[mon.species] = true + end answer(vm, TRUE) end diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index a59adc2e..949dcfcc 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -6,6 +6,7 @@ local ItemEffects = require("src.inventory.ItemEffects") local ListMenu = require("src.ui.ListMenu") local Runtime = require("src.mods.Runtime") local TextBox = require("src.render.TextBox") +local romText = require("src.core.RomText") local BagMenu = {} @@ -26,11 +27,19 @@ local function buildItems(game) right = (not unsellable) and ("x" .. game.save.inventory[id]) or nil, }) end + -- the $ff terminator's row: CANCEL is selectable and exits like B + -- (home/list_menu.asm:105-110, 523-528) #1685 + items[#items + 1] = { cancel = true, label = Strings("CANCEL") } return items end -local function consume(game, id) +local function consume(game, id, list) Bag.remove(game.save, id, 1) + -- engine/items/inventory.asm:131-136 + if not game.save.inventory[id] then + game.bagSavedMenuItem, game.bagListScrollOffset = 0, 0 + if list then list.index, list.scroll = 1, 0 end + end end local function save_name(game) @@ -66,6 +75,13 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) if picker then picker:close() end end + -- .useItem_closeMenu ends at CloseStartMenu, so the START menu kept open + -- behind the bag comes down with it (start_sub_menus.asm:400-407) #1745 + local function closeBag() + list:close() + if list.closeStartMenu then list.closeStartMenu() end + end + -- field POKé FLUTE: play the tune, then the no-effect text if result == "flute_field" then require("src.core.Sound").play(game.data, "Pokeflute") @@ -84,7 +100,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) -- field POKé FLUTE next to a not-yet-beaten Snorlax: "had effect" text, -- then the woke-up/battle sequence (data/scripts/story.lua snorlaxWake) if result == "flute_wake" then - list:close() + closeBag() require("src.core.Sound").play(game.data, "Pokeflute") showMessages(game, payload, function() local ow = game.overworld @@ -97,7 +113,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) end if result == "consumed_escape" then -- Poké Doll - consume(game, id) + consume(game, id, list) list:close() showMessages(game, payload, function() -- ItemUsePokeDoll sets wEscapedFromBattle and never touches @@ -120,12 +136,11 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) -- scripts -- the BICYCLE refuses with _CannotGetOffHereText and jumps -- back to ItemMenuLoop, so the bag stays open and no dismount happens -- (#513). The gate sits ahead of UseItem, before ItemUseBicycle ever - -- runs, which is why it precedes list:close() here. + -- runs, which is why it precedes the mount/dismount here. if game.save.forcedBike then showMessages(game, { Strings("You can't get off\nhere.") }) return end - list:close() local ow = game.overworld local Music = require("src.core.Music") -- IsBikeRidingAllowed (home/overworld.asm): the tilesets of @@ -146,27 +161,33 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) return false end if game.save.onBike then + closeBag() game.save.onBike = false Music.playMap(game.data, ow and ow.map.id, false) showMessages(game, { Strings("%s got off\nthe BICYCLE.", save_name(game)) }) elseif bikeAllowed() then + closeBag() game.save.onBike = true Music.playMap(game.data, ow.map.id, true) showMessages(game, { Strings("%s got on\nthe BICYCLE!", save_name(game)) }) else + -- NoCyclingAllowedHere -> ItemUseFailed zeroes the result byte and + -- .useItem_closeMenu loops back (item_effects.asm:2305-2319) showMessages(game, { Strings("No cycling\nallowed here.") }) end return end if result == "fish" then - list:close() local ow = game.overworld local p = ow and ow.player if ow and p and ow:facingIsShoreOrWater() then + closeBag() ow:goFishing(id) return end + -- FishingInit's `ret c` -> ItemUseNotTime -> ItemUseFailed, so a rod + -- away from water leaves the bag up (item_effects.asm:1893-1901) showMessages(game, { Strings("No good! It's not\neven near water.") }) return end @@ -177,7 +198,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) game.save.player.name) }) return end - consume(game, id) + consume(game, id, list) list:close() battle:throwBall(id) return @@ -197,21 +218,22 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) -- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton -- (learn_move.asm), so the jingle rides the box showMessages(game, { Strings("%s learned\n%s!", target.nickname or - game.data.pokemon[target.species].name, mdef.name) }, nil, + game.data.pokemon[target.species].name, mdef.name) }, closePicker, TextBox.soundOpts(game, "Get_Item1")) - if result == "learn" then consume(game, id) end + if result == "learn" then consume(game, id, list) end list.items = buildItems(game) list.index = math.min(list.index, math.max(1, #list.items)) taught() else require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId, function(learned) - if learned and result == "learn" then consume(game, id) end + if learned and result == "learn" then consume(game, id, list) end if learned then list.items = buildItems(game) list.index = math.min(list.index, math.max(1, #list.items)) end if learned then taught() end + closePicker() end) end end @@ -265,8 +287,8 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) local ow = game.overworld if ow and ESCAPE_ROPE_TILESETS[ow.map.def.tileset] and ow.map.id ~= "AGATHAS_ROOM" then - list:close() - consume(game, id) + closeBag() + consume(game, id, list) -- LeaveMapAnim spin-up + SFX_TELEPORT_EXIT_1, a fade, then land OUTSIDE -- the last Pokémon Center town door like Fly (#196), via the shared -- departure helper -- the same path Dig/Teleport take from the party menu @@ -292,7 +314,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) end if result == "consumed" then - consume(game, id) + consume(game, id, list) -- refresh counts in the list for i, it in ipairs(list.items) do if it.value == id then @@ -388,6 +410,13 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) return end + -- .chooseMon loops on a refusal, so the TM/HM picker stays up for another + -- pick (engine/items/item_effects.asm:2234, :2237) (#1686) + local machineDef = game.data.items[id] + if picker and picker.keepOpen and machineDef and machineDef.machine then + showMessages(game, payload) + return + end -- .healingItemNoEffect prints over the still-drawn party menu too, so the -- refusal closes the picker the same way (#252) showMessages(game, payload, closePicker) -- failed @@ -406,10 +435,15 @@ local function pickTargetAndUse(game, battle, id, list) local def = game.data.items[id] local opts = { pickOnly = true, + -- USE_ITEM_PARTY_MENU / EVO_STONE_PARTY_MENU, not the trade and daycare + -- NORMAL_PARTY_MENU (item_effects.asm:813, :768). #1610 + itemUse = true, battle = battle, -- HP medicine animates with the picker up (#252), RARE CANDY prints over - -- the party menu (item_effects.asm:1392-1418) - keepOpen = (not battle) and ItemEffects.keepsPartyMenuOpen(id), + -- the party menu (item_effects.asm:1392-1418); TM/HM stays up through + -- `predef LearnMove` (item_effects.asm:2238) (#1686) + keepOpen = (not battle) + and (ItemEffects.keepsPartyMenuOpen(id) or (def and def.machine ~= nil)), onSwitch = function(mon, picker) if not wantsMove then useOn(game, battle, id, mon, list, nil, picker) @@ -460,9 +494,15 @@ local function useItem(game, battle, id, list) local moveDef = game.data.moves[def.machine.move] local moveName = moveDef and moveDef.name or def.machine.move local booted = def.machine.kind == "HM" - and "Booted up an HM!" or Strings("Booted up a TM!") - showMessages(game, { booted, Strings("It contained\n%s!", moveName) }, - function() pickTargetAndUse(game, battle, id, list) end) + and romText(game.data, "_BootedUpHMText", "Booted up an HM!") + or romText(game.data, "_BootedUpTMText", "Booted up a TM!") + -- engine/items/item_effects.asm:2177 (#1686) + showMessages(game, { booted, + romText(game.data, "_TeachMachineMoveText", + "It contained\n%s!\fTeach %s\nto a POKéMON?", moveName, moveName) }, + nil, { choice = function(yes) + if yes then pickTargetAndUse(game, battle, id, list) end + end }) return end pickTargetAndUse(game, battle, id, list) @@ -484,7 +524,8 @@ function BagMenu.new(game, opts) onCancel = opts.onCancel, -- SELECT reorders items like the original bag (swap_items.asm) onSelectKey = function(item, l) - if not item then return end + -- attempts to swap the CANCEL row are ignored (swap_items.asm:19-22) + if not item or item.cancel then return end if not l.swapIndex then l.swapIndex = l.index return @@ -496,6 +537,12 @@ function BagMenu.new(game, opts) l.items = buildItems(game) end, onChoose = function(item) + -- A on CANCEL leaves the list exactly like B (home/list_menu.asm:105-110) + if item and item.cancel then + list:close() + if opts.onCancel then opts.onCancel() end + return + end local id = item.value local def = game.data.items[id] if list.swapIndex then -- A also completes a pending swap @@ -510,6 +557,12 @@ function BagMenu.new(game, opts) useItem(game, battle, id, list) return end + -- the BICYCLE never gets the option box: StartMenu_Item jumps straight + -- to .useOrTossItem (start_sub_menus.asm:340-342) #1705 + if id == "BICYCLE" then + useItem(game, battle, id, list) + return + end -- USE / TOSS submenu (the original's item options). -- data/text_boxes.asm USE_TOSS_MENU_TEMPLATE: box (13,10)-(19,14), -- text at (15,11); start_sub_menus.asm then sets wTopMenuItemY/X to @@ -548,6 +601,25 @@ function BagMenu.new(game, opts) }, { tx = 13, ty = 10, tw = 7, th = 5 })) end, }) + -- reopen on the saved cursor: engine/battle/core.asm:2230-2234 and + -- engine/menus/start_sub_menus.asm:319-323 #1732 + local n, rows = #list.items, list.cursorRows or list.rows + if n > 0 then + local saved = game.bagListScrollOffset or 0 + local index = math.min(saved + (game.bagSavedMenuItem or 0) + 1, n) + local scroll = math.min(saved, index - 1, math.max(0, n - rows)) + list.index, list.scroll = index, math.max(0, scroll, index - rows) + end + local baseUpdate = list.update + list.update = function(self, dt) + baseUpdate(self, dt) + game.bagListScrollOffset = self.scroll + game.bagSavedMenuItem = self.index - self.scroll - 1 + end + list.closeStartMenu = opts.onClose + -- the item box overlaps the kept-open START menu box, so neither docks to + -- a screen edge on its own (start_sub_menus.asm:302-329) #1745 + list.holdsUIAnchors = true return list end diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index 01d0a24c..dad1bda9 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -16,6 +16,34 @@ function ListMenu:sgbPalettes(game) return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") end +local BLACK = { 0, 0, 0, 1 } +local MUTED_TEXT = { 0.55, 0.55, 0.55, 1 } + +-- row text runs from x=16 to the 160px screen's right margin (160-8, same +-- margin item.right right-aligns against); GAP is the blank strip kept +-- between a truncated label and item.right so the two never touch. +local ROW_LEFT = 16 +local ROW_RIGHT_MARGIN = 160 - 8 +local LABEL_GAP = 4 + +-- Truncate `text` to `pixels`, same convention as WideBattle.lua's own +-- fitName (HP-bar names facing the identical "arbitrary text vs. fixed +-- pixel budget" problem): cut on a whole glyph span, never mid-character, +-- and mark the cut with a trailing '.'. ShaderFXScreen's preset names are +-- player-supplied filenames of arbitrary length -- unclipped, a long one +-- either overlapped/garbled item.right's "CONVERT" hint or ran past the +-- screen's right edge outright. +local function fitLabel(text, pixels) + local spans = Font.split(text or "") + local n = Font.spansFitting(spans, pixels) + if n >= #spans then return text or "" end + local out = {} + for i = 1, math.max(0, n - 1) do + out[#out + 1] = (text or ""):sub(spans[i].from, spans[i].to) + end + return table.concat(out) .. "." +end + local ROWS = 7 -- LIST_MENU_BOX 4,2 - 19,12 (data/text_boxes.asm:13); 4 names from -- hlcoord 6,4 two rows apart (home/list_menu.asm:51-52, 364-365, 471-479) @@ -237,12 +265,13 @@ function ListMenu:drawItemBox() if #self.items == 0 then Font.draw(Strings("Nothing here."), ITEM_NAME_X, ITEM_TOP_Y) end - local shown = 0 + local shown, sawCancel = 0, false for row = 1, self.rows do local i = self.scroll + row local item = self.items[i] if not item then break end shown = shown + 1 + if item.cancel then sawCancel = true end local y = ITEM_TOP_Y + (row - 1) * 16 Font.draw(item.label, ITEM_NAME_X, y) if item.right then @@ -262,7 +291,7 @@ function ListMenu:drawItemBox() end -- the terminator prints CANCEL and returns before the '▼' -- (home/list_menu.asm:372, 518-524) - if shown == self.rows then + if shown == self.rows and not sawCancel then Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y) end love.graphics.setColor(1, 1, 1, 1) @@ -282,22 +311,33 @@ function ListMenu:draw() local item = self.items[i] if not item then break end local y = 8 + row * 16 - Font.draw(item.label, 16, y) + -- item.muted: a real, selectable row that isn't fully "ready" yet (e.g. + -- ShaderFXScreen's unconverted presets) -- readable, never hidden, same + -- "always still readable" convention kit/Theme.lua's own disabled state + -- documents, just not the generic list-item shape that lived here + -- before ShaderFXScreen needed it. + local textColor = item.muted and MUTED_TEXT or BLACK + love.graphics.setColor(unpack(textColor)) + local budget = ROW_RIGHT_MARGIN - ROW_LEFT + if item.right then budget = budget - Font.width(item.right) - LABEL_GAP end + local label = fitLabel(item.label, budget) + Font.draw(label, 16, y) if item.ball then -- the Pokédex owned-ball marker tile -- one blank glyph after the name, measured in glyph advances rather -- than bytes: NIDORAN♂/♀ carry a multi-byte charmap entry, so -- `#item.label` overcounted by 2 and pushed their ball 16px right (#285) - local bx = 16 + Font.width(item.label) + 8 + 3 + local bx = 16 + Font.width(label) + 8 + 3 local by = y + 3 love.graphics.circle("fill", bx, by, 3.5) love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", bx - 3.5, by - 0.5, 7, 1) love.graphics.circle("fill", bx, by, 1.2) - love.graphics.setColor(0, 0, 0, 1) + love.graphics.setColor(unpack(textColor)) end if item.right then Font.draw(item.right, 160 - 8 - Font.width(item.right), y) end + love.graphics.setColor(unpack(BLACK)) if i == self.index then -- hollowIndex: a chosen row keeps the hollow '▷' left behind by -- pokered's PlaceUnfilledArrowMenuCursor (the old man demo's diff --git a/src/ui/MoveLearnMenu.lua b/src/ui/MoveLearnMenu.lua index 58c41b1d..5ceb00ce 100644 --- a/src/ui/MoveLearnMenu.lua +++ b/src/ui/MoveLearnMenu.lua @@ -1,7 +1,7 @@ -- "Which move should be forgotten?", replaces a move when a Pokémon with -- four moves learns a new one (engine/pokemon/learn_move.asm). Opens -- with the TryingToLearnText "Delete an older move...?" YES/NO; HM moves --- can't be forgotten; B / CANCEL gives up on the new move. +-- can't be forgotten; B gives up on the new move. local Font = require("src.render.Font") local Strings = require("src.core.Strings") @@ -66,7 +66,9 @@ end function MoveLearnMenu:update(dt) if not self.selecting then return end local input = self.game.input - local n = #self.mon.moves + 1 -- moves + CANCEL + -- wMaxMenuItem = wNumMovesMinusOne, no CANCEL row + -- engine/pokemon/learn_move.asm:144 (#1686) + local n = #self.mon.moves if input:wasPressed("up") then self.index = self.index > 1 and self.index - 1 or n elseif input:wasPressed("down") then @@ -74,23 +76,19 @@ function MoveLearnMenu:update(dt) elseif input:wasPressed("b") then self:confirmAbandon() elseif input:wasPressed("a") then - if self.index > #self.mon.moves then - self:confirmAbandon() - else - local old = self.mon.moves[self.index] - if HM_MOVES[old.id] then - -- HMCantDeleteText, then back to the forget list - local TextBox = require("src.render.TextBox") - self.game.stack:push(TextBox.new(self.game, - romText(self.game.data, "_HMCantDeleteText", - "HM techniques\ncan't be deleted!"))) - return - end - local mdef = self.game.data.moves[self.newMoveId] - self.mon.moves[self.index] = { id = self.newMoveId, pp = mdef.pp } - self.forgot = self.game.data.moves[old.id].name - self:finish(true) + local old = self.mon.moves[self.index] + if HM_MOVES[old.id] then + -- HMCantDeleteText, then back to the forget list + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, + romText(self.game.data, "_HMCantDeleteText", + "HM techniques\ncan't be deleted!"))) + return end + local mdef = self.game.data.moves[self.newMoveId] + self.mon.moves[self.index] = { id = self.newMoveId, pp = mdef.pp } + self.forgot = self.game.data.moves[old.id].name + self:finish(true) end end @@ -143,15 +141,14 @@ end function MoveLearnMenu:draw() if not self.selecting then return end - -- single-spaced move list box (TryingToLearn: TextBoxBorder at 4,7) - -- plus the port's extra CANCEL row - Font.drawBox(4, 5, 16, 7) + -- TextBoxBorder 4,7 b=4 c=14; PlaceString 6,8; cursor 5,8 + -- engine/pokemon/learn_move.asm:123-140 (#1686) + Font.drawBox(4, 7, 16, 6) love.graphics.setColor(0, 0, 0, 1) for i, mv in ipairs(self.mon.moves) do - Font.draw(self.game.data.moves[mv.id].name, 48, (5 + i) * 8) + Font.draw(self.game.data.moves[mv.id].name, 48, (7 + i) * 8) end - Font.draw(Strings("CANCEL"), 48, (6 + #self.mon.moves) * 8) - Font.drawCode(CURSOR, 40, (5 + self.index) * 8) + Font.drawCode(CURSOR, 40, (7 + self.index) * 8) -- WhichMoveToForgetText in the bottom dialogue box Font.drawBox(0, 12, 20, 6) Font.draw(Strings("Which move should"), 8, 14 * 8) diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 503b5dfa..366cfb86 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -4,7 +4,7 @@ -- quirks), plus the port's audio rows and display rows: music/SFX -- volume (0-7), PIKACHU VOL (0-7, Yellow only: trims the PCM voice clips -- under SFX VOL), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT / --- GBC FX / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens +-- SHADER FX / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens -- the mod manager. -- Rows are descriptors fed through the ui.options.rows hook, so mods can -- add their own; CANCEL is appended after the hook and stays fixed on the @@ -13,7 +13,7 @@ local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") local Tilt = require("src.render.Tilt") -local GBCFX = require("src.render.GBCFX") +local ShaderFX = require("src.render.ShaderFX") local Zoom = require("src.render.Zoom") local TileRenderer = require("src.render.TileRenderer") local GameSpeed = require("src.core.GameSpeed") @@ -143,6 +143,20 @@ end local function sameRows(_, rows) return rows end +-- "OFF" plus every entry's display name, extension stripped -- a +-- Strings() lookup like RULESET's own record.name so a translation catalog +-- could rewrite "OFF" without needing to know about arbitrary preset +-- filenames. +local function shaderfxLabel(entry) + if not entry then return Strings("OFF") end + return Strings((entry.name:gsub("%.slangp$", ""))) +end + +-- Dual-shader slots: "main" backs the original SHADER +-- FX row (unchanged save key, unchanged default OFF), "secondary" backs the +-- new SHADER FX 2 row below it -- when both are set, ShaderFX.render() runs +-- main's chain into secondary's, same as stacking two RetroArch presets. + -- the vanilla rows as descriptors; each step body is the old per-index -- ladder's, so the save.options mutations are unchanged local function buildRows(game) @@ -323,10 +337,10 @@ local function buildRows(game) return true end }, -- Heads the port's display group: one tier that scales the heavy extras - -- (TILT / GBC FX / survey ZOOM) and the FPS ceiling for weaker devices. + -- (TILT / survey ZOOM) and the FPS ceiling for weaker devices. -- AUTO picks a default from the hardware; every tier is overridable. -- Re-applies live so the extras clamp (or, on a higher tier, restore to - -- the player's stored TILT / GBC FX / ZOOM) the moment the row changes. + -- the player's stored TILT / ZOOM) the moment the row changes. { id = "performance", label = Strings("PERFORMANCE"), value = function(g) return Strings(Performance.label(g.save.options.performance)) @@ -366,15 +380,30 @@ local function buildRows(game) end return true end }, - { id = "gbcfx", label = Strings("GBC FX"), + -- The generalized slang-shader-preset feature: a picker over + -- ShaderFX.list()'s real drop-in presets -- replaced GBCFX.lua's fixed + -- level ladder entirely (GBCFX.lua removed). A pushes a real list + -- screen -- OFF plus one row per .slangp found -- the same + -- "activate, not step" shape CONTROLS/MODS already use, rather than + -- cycling in place on this row. + -- ShaderFXScreen.lua does the actual list/activate; this row only opens + -- it and shows what is currently active. + { id = "shaderfx", label = Strings("SHADER FX"), value = function(g) - return GBCFX.levelLabel(g.save.options.gbcfx or 0) + return shaderfxLabel(ShaderFX.activeEntry("main")) end, - step = function(g, dir) - local o = g.save.options - o.gbcfx = wrapIndex((o.gbcfx or 0) + dir, 5) - GBCFX.setLevel(o.gbcfx) - return true + activate = function(g) + require("src.ui.Screens").push(g, "ShaderFXScreen", "main") + end }, + -- The secondary slot: same picker screen, opened on "secondary" instead + -- -- its own row so both slots are visible/settable independently + -- without a submenu inside a submenu. + { id = "shaderfx2", label = Strings("SHADER FX 2"), + value = function(g) + return shaderfxLabel(ShaderFX.activeEntry("secondary")) + end, + activate = function(g) + require("src.ui.Screens").push(g, "ShaderFXScreen", "secondary") end }, { id = "zoom", label = Strings("ZOOM"), value = function(g) @@ -565,14 +594,6 @@ local function buildRows(game) return true end }, } - -- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks - if not GBCFX.isSupported() then - local filtered = {} - for _, row in ipairs(rows) do - if row.id ~= "gbcfx" then filtered[#filtered + 1] = row end - end - rows = filtered - end -- ORIENTATION only on the platforms Orientation.apply reaches (#1638). if not (Orientation.isAndroid() or Orientation.isIOS()) then local filtered = {} diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 4e2b9535..718c5ba3 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -305,6 +305,7 @@ function PartyMenu.new(game, opts) self.onSwitch = opts.onSwitch self.onCancel = opts.onCancel self.pickOnly = opts.pickOnly + self.itemUse = opts.itemUse -- USE_ITEM_PARTY_MENU (item_effects.asm:813) -- Medicine keeps the picker on screen: item_effects.asm .doneHealing -- animates the party HP bar and then prints the message through -- RedrawPartyMenu with the menu STILL up, so BagMenu asks for keepOpen and @@ -725,16 +726,19 @@ end -- the party menu always prints a message in the bottom text box. With the -- normal message id that is PartyMenuBattleText ("Bring out which POKéMON?") -- when IsInBattle else PartyMenuNormalText ("Choose a POKéMON."); the swap / --- item / TM-HM ids print their own strings, which draw() handles inline. --- Pure (no side effects) so drivers can assert it. #147 +-- item / TM-HM ids print their own strings, and EVO_STONE shares +-- PartyMenuItemUseText (party_menu.asm:229 PartyMenuMessagePointers). +-- Pure (no side effects) so drivers can assert it. #147 #1610 function PartyMenu:bottomMessage() if self.swapFrom then - return "Move to where?" - elseif self.softboiledFrom or self.pickOnly then - return "Use on which one?" + return self.game.data.text._PartyMenuSwapMonText + or Strings("Move POKéMON\nwhere?") elseif self.tmhm then return self.game.data.text._PartyMenuUseTMText or Strings("Use TM on which\nPOKéMON?") + elseif self.softboiledFrom or self.itemUse then + return self.game.data.text._PartyMenuItemUseText + or Strings("Use item on which\nPOKéMON?") elseif self.battle then return self.game.data.text._PartyMenuBattleText or Strings("Bring out which\nPOKéMON?") @@ -858,38 +862,14 @@ function PartyMenu:draw() Font.drawCode(Theme.cursorHollow, 0, cursorY) end end - if self.swapFrom then - Font.draw(Strings("Move to where?"), 8, 136) - elseif self.softboiledFrom then - Font.draw(Strings("Use on which one?"), 8, 136) - elseif self.tmhm then - -- "Use TM on which\nPOKeMON?" in the standard bottom text box - -- (party_menu.asm keeps the message box for the TM/HM menu); box + line - -- geometry match TextBox's default (rows 12-17, text on rows 14/16). #210 - Font.drawBox(0, 12, 20, 6) - love.graphics.setColor(0, 0, 0, 1) - local prompt = self.game.data.text._PartyMenuUseTMText - or Strings("Use TM on which\nPOKéMON?") - local ly = 112 - for line in (prompt .. "\n"):gmatch("([^\n]*)\n") do - Font.draw(line, 8, ly) - ly = ly + 16 - end - elseif self.pickOnly then - Font.draw(Strings("Use on which one?"), 8, 136) - else - -- default field party menu (StartMenu) and the battle voluntary-switch - -- (BattleState:openParty): Gen1 prints PartyMenuNormalText / PartyMenuBattleText - -- in the standard bottom text box (party_menu.asm PartyMenuMessage), not - -- plain bottom-row text. Box + line geometry match the #210 TM/HM case and - -- TextBox's default (rows 12-17, text on rows 14/16). #147 - Font.drawBox(0, 12, 20, 6) - love.graphics.setColor(0, 0, 0, 1) - local ly = 112 - for line in (self:bottomMessage() .. "\n"):gmatch("([^\n]*)\n") do - Font.draw(line, 8, ly) - ly = ly + 16 - end + -- every message id prints through PrintText, so it lands in the standard + -- bottom text box, rows 12-17 (party_menu.asm:174). #147 #210 #1610 + Font.drawBox(0, 12, 20, 6) + love.graphics.setColor(0, 0, 0, 1) + local ly = 112 + for line in (self:bottomMessage() .. "\n"):gmatch("([^\n]*)\n") do + Font.draw(line, 8, ly) + ly = ly + 16 end if self.submenu then local n = #self.subItems diff --git a/src/ui/Screens.lua b/src/ui/Screens.lua index 424bfa58..755f2472 100644 --- a/src/ui/Screens.lua +++ b/src/ui/Screens.lua @@ -56,6 +56,9 @@ local GEN2 = { -- ../pokecrystal/engine/movie/intro.asm:1 CrystalIntro, the Crystal-only -- peer of GoldSilverIntro below. "CrystalIntro", + -- ../pokecrystal/engine/movie/splash.asm:1 SplashScreen, the Crystal-only + -- Ditto peer of GameFreakPresents below. + "CrystalSplash", "DayCareMenu", "DecorationMenu", "Diploma", "EggHatchAnim", "ElevatorMenu", "EvolutionAnim", "GameFreakPresents", diff --git a/src/ui/ShaderFXParamsScreen.lua b/src/ui/ShaderFXParamsScreen.lua new file mode 100644 index 00000000..3a8a65e0 --- /dev/null +++ b/src/ui/ShaderFXParamsScreen.lua @@ -0,0 +1,161 @@ +-- Per-preset #pragma parameter editor, reached via SELECT on a converted row in +-- ShaderFXScreen. A steps and wraps, Left/Right step and clamp, SELECT resets +-- one row, START resets all. Edits are keyed by preset name, not by slot. +-- See docs/shaderfx.md. + +local ListMenu = require("src.ui.ListMenu") +local ChoiceBox = require("src.ui.ChoiceBox") +local ShaderFX = require("src.render.ShaderFX") +local Strings = require("src.core.Strings") +local Logger = require("src.core.Logger") + +local ShaderFXParamsScreen = setmetatable({}, { __index = ListMenu }) +ShaderFXParamsScreen.__index = ShaderFXParamsScreen + +local function overridesFor(game, entry) + local opts = game.save and game.save.options + local all = opts and opts.shaderfxParams + return all and all[entry.name] +end + +local function currentValue(p, overrides) + local v = overrides and overrides[p.id] + if v == nil then v = p.initial end + return v +end + +-- Trims trailing zeros but always keeps one decimal place: many real pragma +-- steps are 0.01-0.05, which a fixed-width format would hide. +local function fmt(v) + local s = ("%.3f"):format(v) + s = s:gsub("(%..-)0+$", "%1"):gsub("%.$", ".0") + return s +end + +local function buildItems(game, entry, params) + local overrides = overridesFor(game, entry) + local items = {} + for _, p in ipairs(params) do + items[#items + 1] = { + label = Strings(p.description), param = p, + right = fmt(currentValue(p, overrides)), + } + end + return items +end + +function ShaderFXParamsScreen.new(game, entry) + local params, err = ShaderFX.listParams(entry) + if not params then + Logger.error("ShaderFXParamsScreen: %s: %s", entry.name, tostring(err)) + params = {} + end + local items = buildItems(game, entry, params) + local title = Strings((entry.name:gsub("%.slangp$", ""))) + + local self = setmetatable(ListMenu.new(game, title, items, { + rows = 6, + footer = Strings("A/L/R:STEP\nSELECT:RESET START:ALL"), + }), ShaderFXParamsScreen) + + -- Re-activates whichever slot(s) currently show this preset, so an edit + -- reaches the running chain by the next frame. Cheap enough to call on every + -- step: activate() re-reads the cached artifact, it never calls the bridge. + local function applyLive(overrides) + for _, slot in ipairs(ShaderFX.SLOTS) do + local active = ShaderFX.activeEntry(slot) + if active and active.name == entry.name then + ShaderFX.activate(slot, active, overrides) + end + end + end + + local function setParam(item, value) + local p = item.param + value = math.max(p.minimum, math.min(p.maximum, value)) + local opts = game.save and game.save.options + if not opts then return end + opts.shaderfxParams = opts.shaderfxParams or {} + opts.shaderfxParams[entry.name] = opts.shaderfxParams[entry.name] or {} + opts.shaderfxParams[entry.name][p.id] = value + item.right = fmt(value) + applyLive(opts.shaderfxParams[entry.name]) + if game.writeOptions then + game:writeOptions() + elseif game.persistOptions then + game:persistOptions() + end + end + + self.onChoose = function(item) + local overrides = overridesFor(game, entry) + local cur = currentValue(item.param, overrides) + local nextVal = cur + item.param.step + if nextVal > item.param.maximum + 1e-6 then nextVal = item.param.minimum end + setParam(item, nextVal) + end + self.onSelectKey = function(item) + setParam(item, item.param.initial) + end + + -- dir=-1/+1; clamps at the ends instead of onChoose's wrap. + local function stepCurrent(dir) + local item = self.items[self.index] + if not item then return end + local overrides = overridesFor(game, entry) + local cur = currentValue(item.param, overrides) + setParam(item, cur + dir * item.param.step) + end + + -- One write + one applyLive for the whole preset, not one per row. + local function resetAll() + local opts = game.save and game.save.options + if not opts then return end + opts.shaderfxParams = opts.shaderfxParams or {} + local ov = {} + opts.shaderfxParams[entry.name] = ov + for _, item in ipairs(items) do + local v = item.param.initial + ov[item.param.id] = v + item.right = fmt(v) + end + applyLive(ov) + if game.writeOptions then + game:writeOptions() + elseif game.persistOptions then + game:persistOptions() + end + end + + -- START: confirm, then reset every row. + local function confirmResetAll() + local hint = self.footer + self.footer = Strings("RESET ALL PARAMS?") + game.stack:push(ChoiceBox.new(game, function(yes) + self.footer = hint + if yes then resetAll() end + end, { defaultNo = true })) + end + + local baseUpdate = ListMenu.update + self.update = function(self_, dt) + local input = self_.game.input + if #self_.items > 0 then + if input:wasPressed("left") then + stepCurrent(-1) + return + elseif input:wasPressed("right") then + stepCurrent(1) + return + elseif input:wasPressed("start") then + confirmResetAll() + return + end + end + baseUpdate(self_, dt) + end + + return self +end + +return ShaderFXParamsScreen diff --git a/src/ui/ShaderFXScreen.lua b/src/ui/ShaderFXScreen.lua new file mode 100644 index 00000000..8c7a1b75 --- /dev/null +++ b/src/ui/ShaderFXScreen.lua @@ -0,0 +1,159 @@ +-- The SHADER FX preset picker: OFF, every .slangp ShaderFX.list() finds, then a +-- permanent DOWNLOAD SHADERS action row. `slot` picks which of ShaderFX.SLOTS +-- A activates and which save.options key persists it. See docs/shaderfx.md. + +local ListMenu = require("src.ui.ListMenu") +local ShaderFX = require("src.render.ShaderFX") +local Strings = require("src.core.Strings") +local Screens = require("src.ui.Screens") + +local ShaderFXScreen = setmetatable({}, { __index = ListMenu }) +ShaderFXScreen.__index = ShaderFXScreen + +-- OFF plus the preset's display name with its extension stripped. +local function label(entry) + if not entry then return Strings("OFF") end + return Strings((entry.name:gsub("%.slangp$", ""))) +end + +-- One row's display state from its entry.converted, shared by new() and +-- onChoose() so a successful convert updates a row exactly as building it did. +local function applyRowState(item) + if not item.entry then return end + -- NOT `cond and a or b`: that idiom breaks when `a` is itself nil/false. + if item.entry.converted then + item.muted = false + item.right = nil + else + item.muted = true + item.right = Strings("CONVERT") + end +end + +-- OFF, every real preset, then the permanent DOWNLOAD SHADERS action row. +local function buildItems(active) + local items = { { label = label(nil), entry = nil } } + local selected = 1 + for _, entry in ipairs(ShaderFX.list()) do + local item = { label = label(entry), entry = entry } + applyRowState(item) + items[#items + 1] = item + if active and active.name == entry.name then selected = #items end + end + items[#items + 1] = { label = Strings("DOWNLOAD SHADERS"), download = true } + return items, selected +end + +-- `slot` defaults to "main" so a caller that still pushes this screen with no +-- argument keeps today's behavior rather than erroring on a nil slot key. +function ShaderFXScreen.new(game, slot) + slot = slot or "main" + local optKey = ShaderFX.OPTION_KEY[slot] + local title = (slot == "secondary") and "SHADER FX 2" or "SHADER FX" + local items, selected = buildItems(ShaderFX.activeEntry(slot)) + + local self = setmetatable(ListMenu.new(game, title, items, { + -- 7 rows leaves the title line and the one-line footer hint free. + rows = 7, + footer = Strings("SELECT:EDIT PARAMS"), + }), ShaderFXScreen) + -- Opens on the active preset (or OFF). + self.index = selected + self.downloadJob = nil + + -- Rebuilds from ShaderFX.list() (e.g. after an install adds presets), keeping + -- the cursor on the same row index (clamped) rather than resetting to OFF. + local function refresh() + local newItems = buildItems(ShaderFX.activeEntry(slot)) + self.items = newItems + self.index = math.max(1, math.min(self.index, #newItems)) + end + + -- Polls the in-flight buildbot download alongside ListMenu's own input + -- handling. installDownloaded() itself is synchronous, a sub-second job. + local baseUpdate = ListMenu.update + self.update = function(self_, dt) + if self_.downloadJob then + local st = ShaderFX.downloadStatus(self_.downloadJob) + local row = self_.items[#self_.items] + if st.status == "pending" then + local pct = st.progress and (" %d%%"):format(st.progress * 100) or "" + row.right = Strings("...") .. pct + elseif st.status == "ok" then + local copied, err, unchanged = ShaderFX.installDownloaded(st.notModified) + self_.downloadJob = nil + if unchanged then + require("src.core.Logger").info("ShaderFXScreen: buildbot presets already up to date") + row.right = Strings("UP TO DATE") + elseif copied then + require("src.core.Logger").info("ShaderFXScreen: buildbot install copied %d files", copied) + refresh() + else + require("src.core.Logger").error("ShaderFXScreen: buildbot install failed: %s", tostring(err)) + self_.items[#self_.items].right = Strings("FAILED") + end + else -- "error" or "cancelled" + self_.downloadJob = nil + require("src.core.Logger").error("ShaderFXScreen: buildbot download failed: %s", tostring(st.err)) + row.right = Strings("FAILED") + end + end + baseUpdate(self_, dt) + end + + self.onChoose = function(item) + if item.download then + -- Ignore a repeat press while one is already in flight. + if self.downloadJob then return end + self.downloadJob = ShaderFX.downloadPresets() + item.right = Strings("...") + return + end + + -- A on an unconverted preset converts it in place instead of activating; + -- the screen stays open either way, since a convert is not a selection. + if item.entry and not item.entry.converted then + local ok, err = ShaderFX.convert(item.entry) + if not ok then + require("src.core.Logger").error("ShaderFXScreen: convert failed for %s: %s", + item.entry.name, tostring(err)) + end + applyRowState(item) + return + end + + local opts = game.save and game.save.options + if not item.entry then + ShaderFX.deactivate(slot) + if opts then opts[optKey] = nil end + else + -- isConverted() is existence-only with no staleness check, so an explicit + -- selection always reconverts. Human-paced, CPU-only, never per frame. + local convOk, convErr = ShaderFX.convert(item.entry) + if not convOk then + require("src.core.Logger").error("ShaderFXScreen: reconvert failed for %s: %s", + item.entry.name, tostring(convErr)) + end + local overrides = opts and opts.shaderfxParams and opts.shaderfxParams[item.entry.name] + local ok = ShaderFX.activate(slot, item.entry, overrides) + if opts then opts[optKey] = ok and item.entry.name or nil end + end + if game.writeOptions then + game:writeOptions() + elseif game.persistOptions then + game:persistOptions() + end + self:close() + end + + -- SELECT on a real, converted row opens its pragma-parameter editor; OFF, the + -- action row and an unconverted preset have nothing to read metadata from. + self.onSelectKey = function(item) + if not item.entry or item.download or not item.entry.converted then return end + Screens.push(game, "ShaderFXParamsScreen", item.entry) + end + + return self +end + +return ShaderFXScreen diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua index 23ecc7ad..7d8b22d0 100644 --- a/src/ui/ShopMenu.lua +++ b/src/ui/ShopMenu.lua @@ -9,10 +9,12 @@ local Bag = require("src.inventory.Bag") local ChoiceBox = require("src.ui.ChoiceBox") +local Font = require("src.render.Font") local ListMenu = require("src.ui.ListMenu") local Menu = require("src.ui.Menu") local QuantityBox = require("src.ui.QuantityBox") local Strings = require("src.core.Strings") +local TextBox = require("src.render.TextBox") local romText = require("src.core.RomText") local ShopMenu = {} @@ -21,7 +23,21 @@ local function txt(game, key, fallback) return game.data.text[key] or fallback end -local function buy(game, stock) +-- engine/events/pokemart.asm:204 (.returnToMainPokemartMenu) +local function anythingElse(game) + return txt(game, "_PokemartAnythingElseText", + Strings("Is there anything\nelse I can do?")) +end + +-- prompt refusals leave the list for the mart menu -- pokemart.asm:113 +local function refuse(game, menu, list, text) + if list then list:close() end + game.stack:push(TextBox.new(game, text, function() + menu.footer = anythingElse(game) + end)) +end + +local function buy(game, stock, menu) local items = {} for _, id in ipairs(stock) do local def = game.data.items[id] @@ -36,11 +52,14 @@ local function buy(game, stock) local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.") local notEnough = txt(game, "_PokemartNotEnoughMoneyText", Strings("You don't have\nenough money.")) + local bagFull = txt(game, "_PokemartItemBagFullText", + Strings("You can't carry\nany more items.")) local list list = ListMenu.new(game, "BUY", items, { dialogue = true, money = function() return game.save.money end, footer = greet, + onCancel = function() menu.footer = anythingElse(game) end, onChoose = function(item) local def = game.data.items[item.value] if game.save.money < def.price then @@ -66,18 +85,22 @@ local function buy(game, stock) return end if game.save.money < cost then - list.footer = notEnough + refuse(game, menu, list, notEnough) return end if not Bag.add(game.save, item.value, qty, game.data) then - list.footer = txt(game, "_PokemartItemBagFullText", - Strings("You can't carry\nany more items.")) + refuse(game, menu, list, bagFull) return end - require("src.core.Sound").play(game.data, "Purchase") game.save.money = game.save.money - cost - list.footer = txt(game, "_PokemartBoughtItemText", - Strings("Here you are!\nThank you!")) + -- SFX_PURCHASE drains before the receipt -- pokemart.asm:193 + game.stack:push(TextBox.new(game, + txt(game, "_PokemartBoughtItemText", + Strings("Here you are!\nThank you!")), + function() list.footer = greet end, + { preSound = function() + return require("src.core.Sound").play(game.data, "Purchase") + end })) end)) end, })) @@ -86,7 +109,7 @@ local function buy(game, stock) game.stack:push(list) end -local function sell(game) +local function sell(game, menu) -- Sell list is ITEMLISTMENU with wPrintItemPrices cleared -- (pokemart.asm .sellMenuLoop): name + quantity only. Price shows -- in the quantity chooser. Stuffing "xN" into the label next to a @@ -100,12 +123,22 @@ local function sell(game) right = "x" .. game.save.inventory[id], }) end - local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.") + -- engine/events/pokemart.asm:50 + local greet = txt(game, "_PokemonSellingGreetingText", + Strings("What would you\nlike to sell?")) + if #items == 0 then + refuse(game, menu, nil, txt(game, "_PokemartItemBagEmptyText", + Strings("You don't have\nanything to sell."))) + return + end + local unsellable = txt(game, "_PokemartUnsellableItemText", + Strings("I can't put a\nprice on that.")) local list list = ListMenu.new(game, "SELL", items, { dialogue = true, money = function() return game.save.money end, footer = greet, + onCancel = function() menu.footer = anythingElse(game) end, onSelectKey = function(item, l) if not item then return end if not l.swapIndex then @@ -135,8 +168,7 @@ local function sell(game) -- indexing nil below -- guards saves that already picked up a bogus -- ITEM_NONE "0" from Blue's House before that pickup was fixed (#11). if not def or def.keyItem or item.value:find("^HM_") then - list.footer = txt(game, "_PokemartUnsellableItemText", - Strings("I can't put a\nprice on that.")) + refuse(game, menu, list, unsellable) return end local unit = math.floor(def.price / 2) @@ -157,6 +189,8 @@ local function sell(game) return end game.save.money = game.save.money + unit * qty + -- home/inventory.asm:15 AddAmountSoldToMoney sounds SFX_PURCHASE + require("src.core.Sound").play(game.data, "Purchase") Bag.remove(game.save, item.value, qty) local left = game.save.inventory[item.value] if left then @@ -164,7 +198,8 @@ local function sell(game) else list:removeCurrent() end - list.footer = txt(game, "_PokemartThankYouText", "Thank you!") + -- a sale prints nothing -- engine/events/pokemart.asm:112 + list.footer = greet end)) end, })) @@ -173,15 +208,53 @@ local function sell(game) game.stack:push(list) end +-- MONEY_BOX 11,0 (data/text_boxes.asm:35) over the greeting PrintText left +-- in the bottom box (home/text_script.asm:143) +local function drawClerk(menu) + local game = menu.game + love.graphics.setColor(1, 1, 1, 1) + Font.drawBox(11, 0, 9, 3) + love.graphics.setColor(0, 0, 0, 1) + local money = ("¥%d"):format((game.save and game.save.money) or 0) + Font.draw(money, 152 - Font.width(money), 8) + love.graphics.setColor(1, 1, 1, 1) + Font.drawBox(0, 12, 20, 6) + love.graphics.setColor(0, 0, 0, 1) + if menu.footer then + local flat = {} + for _, page in ipairs(TextBox.paginate(menu.footer)) do + for _, line in ipairs(page) do flat[#flat + 1] = line end + end + local y = 112 + for i = math.max(1, #flat - 1), #flat do + Font.draw(flat[i], 8, y) + y = y + 16 + end + end + love.graphics.setColor(1, 1, 1, 1) +end + function ShopMenu.new(game, stock, onQuit) -- keepOpen: the mart menu stays underneath its list so closing the -- list lands back here; only QUIT (or B) leaves and fires onQuit - local menu = Menu.new(game, { - { label = Strings("BUY"), keepOpen = true, onSelect = function() buy(game, stock) end }, - { label = Strings("SELL"), keepOpen = true, onSelect = function() sell(game) end }, - { label = Strings("QUIT"), onSelect = onQuit }, + local menu + -- engine/events/pokemart.asm:220 .done, reached by QUIT and by B alike + local function farewell() + game.stack:push(TextBox.new(game, + txt(game, "_PokemartThankYouText", "Thank you!"), onQuit)) + end + menu = Menu.new(game, { + { label = Strings("BUY"), keepOpen = true, onSelect = function() buy(game, stock, menu) end }, + { label = Strings("SELL"), keepOpen = true, onSelect = function() sell(game, menu) end }, + { label = Strings("QUIT"), onSelect = farewell }, }, { tx = 0, ty = 0, tw = 8, th = 8 }) - menu.onCancel = onQuit + menu.onCancel = farewell + menu.footer = txt(game, "_PokemartGreetingText", + Strings("Hi there!\nMay I help you?")) + menu.draw = function(self) + drawClerk(self) + Menu.draw(self) + end return menu end diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index 65f01166..7df11e4c 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -42,9 +42,14 @@ function StartMenu.new(game) Screens.push(game, "PartyMenu", { onCancel = reopen }) end }) - table.insert(items, { label = Strings("ITEM"), onSelect = function() - Screens.push(game, "BagMenu", { onCancel = reopen }) - end }) + -- StartMenu_Item draws LIST_MENU_BOX over the still-drawn START menu and + -- only redisplays it on the way out (start_sub_menus.asm:302-329) #1745 + table.insert(items, { label = Strings("ITEM"), keepOpen = true, + onSelect = function() + Screens.push(game, "BagMenu", { onClose = function() + if menu and game.stack:top() == menu then game.stack:pop() end + end }) + end }) -- the player's name opens the trainer card (StartMenu_TrainerInfo) table.insert(items, { label = game.save.player.name or "RED", diff --git a/src/ui/gen2/BattleHud.lua b/src/ui/gen2/BattleHud.lua index 716ed85d..7822a1ae 100644 --- a/src/ui/gen2/BattleHud.lua +++ b/src/ui/gen2/BattleHud.lua @@ -100,11 +100,11 @@ end -- The 4-colour palette the HP-bar cells draw with: white, the bar's own light -- colour, the state's fill colour, black -- which is HPBarPals' two colours -- bracketed the way every Gen 2 palette is. -function BattleHud:barColors(key) +function BattleHud:barColors(key, zero) local pal = self.palettes and self.palettes.hpBar and self.palettes.hpBar[key] if not pal then return nil end return { - { 255, 255, 255 }, + zero or { 255, 255, 255 }, { pal[1][1], pal[1][2], pal[1][3] }, { pal[2][1], pal[2][2], pal[2][3] }, { 0, 0, 0 }, @@ -145,9 +145,9 @@ end -- HUD's bar minus the "HP:" prefix -- same tiles, same HPBarPals colour, same -- one-pixel-at-a-time fill. Sharing this method is what keeps the two screens -- from ever disagreeing about how full a bar looks. -function BattleHud:drawBar(hp, maxHp, tx, ty) +function BattleHud:drawBar(hp, maxHp, tx, ty, zero) local pixels = HpBar.pixels(hp, maxHp) - local colors = self:barColors(HpBar.palette(pixels)) + local colors = self:barColors(HpBar.palette(pixels), zero) for cell = 0, HpBar.LENGTH_TILES - 1 do local remaining = pixels - cell * 8 local filled = math.max(0, math.min(8, remaining)) @@ -160,9 +160,11 @@ end -- "HP:" plus the six bar cells plus the end cap, starting at tile (tx, ty). -- Returns the column just past the assembly (tx + 9), so the caller can put the -- frame's vertical stub there. -function BattleHud:drawHpBar(hp, maxHp, tx, ty) +-- `zero` overrides colour 0: the stats screen puts the page tint there +-- (engine/gfx/color.asm:386-390). #1693 +function BattleHud:drawHpBar(hp, maxHp, tx, ty, zero) local pixels = HpBar.pixels(hp, maxHp) - local colors = self:barColors(HpBar.palette(pixels)) + local colors = self:barColors(HpBar.palette(pixels), zero) -- The "HP:" badge sits inside the bar's own attrmap region, so it wears the -- HP palette too: its background is HPBarPals' light colour (the cream the -- cart shows) and its letters are black. Drawing it white-on-black was the @@ -170,7 +172,7 @@ function BattleHud:drawHpBar(hp, maxHp, tx, ty) self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_HP_LABEL, tx, ty, colors) self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_HP_LABEL + 1, tx + 1, ty, colors) - self:drawBar(hp, maxHp, tx + 2, ty) + self:drawBar(hp, maxHp, tx + 2, ty, zero) self:drawTile("hpBar", FIRST_BATTLE_EXTRA, TILE_BAR_END, tx + 2 + HpBar.LENGTH_TILES, ty, colors) return tx + 3 + HpBar.LENGTH_TILES @@ -198,23 +200,23 @@ local EXP_PARTIAL_BASE = 0x54 -- $54 + remainder lands in ExpBarGFX -- PAL_BATTLE_BG_EXP, which the attrmap lays over (10,11)..(18,11) -- (engine/gfx/cgb_layouts.asm:142-145). -function BattleHud:expColors() +function BattleHud:expColors(zero) local pal = self.palettes and self.palettes.expBar if not pal then return nil end return { - { 255, 255, 255 }, + zero or { 255, 255, 255 }, { pal[1][1], pal[1][2], pal[1][3] }, { pal[2][1], pal[2][2], pal[2][3] }, { 0, 0, 0 }, } end -function BattleHud:drawExpBar(fraction, tx, ty) +function BattleHud:drawExpBar(fraction, tx, ty, zero) if not self:image("hpBar") then return false end fraction = math.max(0, math.min(1, fraction or 0)) local pixels = math.floor(fraction * BattleHud.EXP_LENGTH_PX) -- The whole row wears the exp bar's palette, full and empty cells included. - local colors = self:expColors() + local colors = self:expColors(zero) local remaining = pixels for cell = BattleHud.EXP_CELLS - 1, 0, -1 do diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 365b8f67..64eed26f 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -209,6 +209,12 @@ end function BattleState:wantsFillScale() return true end function BattleState:drawsWidescreen() return true end +-- BATTLE BG (#1709): WHITE is the cart's paper surround, BLACK plain bars. +function BattleState:bgMode() + local options = self.game and self.game.options + return (options and options.battleBg) == "black" and "black" or "white" +end + 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, @@ -2184,15 +2190,21 @@ function BattleState:update(_dt) end if self.phase == "menu" then - -- 2x2 grid: left/right swap the column, up/down the row. + -- 2x2 grid: left/right swap the column, up/down the row. No + -- STATICMENU_WRAP in BattleMenuHeader (engine/battle/menu.asm:31-33), so + -- the cursor clamps at each edge (engine/menus/menu.asm:156-166) (#1706). -- MenuClickSound / PlayClickSFX (home/menu.asm:746-762): SFX_READ_TEXT_2 -- on A/B only, never on D-pad. - if input:wasPressed("left") or input:wasPressed("right") then - self.menuIndex = self.menuIndex % 2 == 1 and self.menuIndex + 1 - or self.menuIndex - 1 - elseif input:wasPressed("up") or input:wasPressed("down") then - self.menuIndex = self.menuIndex <= 2 and self.menuIndex + 2 - or self.menuIndex - 2 + local col = (self.menuIndex - 1) % 2 + local row = math.floor((self.menuIndex - 1) / 2) + if input:wasPressed("left") then + self.menuIndex = row * 2 + math.max(0, col - 1) + 1 + elseif input:wasPressed("right") then + self.menuIndex = row * 2 + math.min(1, col + 1) + 1 + elseif input:wasPressed("up") then + self.menuIndex = math.max(0, row - 1) * 2 + col + 1 + elseif input:wasPressed("down") then + self.menuIndex = math.min(1, row + 1) * 2 + col + 1 elseif input:wasPressed("a") then self:playSfx("Sfx_ReadText2") self:chooseMenu(MENU_ACTION[MENU[self.menuIndex]]) @@ -2819,8 +2831,9 @@ function BattleState:pushCaught(enemy, itemId) -- PC's own move is InsertPokemonIntoBox, which inserts at the cursor. table.insert(box, 1, enemy) -- SendMonIntoBox refills the boxed slot's PP before it closes SRAM - -- (move_mon.asm:1062-1063). - Boxes.restorePP(enemy) + -- (move_mon.asm:1062-1063); the box_struct it writes carries no HP and no + -- status at all (macros/ram.asm:7-26). #1696 + Boxes.enterBox(enemy) -- `.SendToPC` re-reads sBoxCount AFTER the insert and sets -- BATTLERESULT_BOX_FULL when the box has just filled -- (item_effects.asm:612-619); Script_reloadmapafterbattle tests that bit diff --git a/src/ui/gen2/BoxMenu.lua b/src/ui/gen2/BoxMenu.lua index 1d7d28e4..027ccf42 100644 --- a/src/ui/gen2/BoxMenu.lua +++ b/src/ui/gen2/BoxMenu.lua @@ -432,7 +432,7 @@ function BoxMenu:insertMon() table.insert(dest, math.max(1, math.min(target, #dest + 1)), mon) -- .CopyToBox is InsertPokemonIntoBox, which tails into -- RestorePPOfDepositedPokemon (engine/pokemon/move_mon_wo_mail.asm:35-37). - if not self:isParty(destIndex) then Boxes.restorePP(mon) end + if not self:isParty(destIndex) then Boxes.enterBox(mon) end self.phase = nil self.moveFrom, self.backup = nil, nil self.index, self.scroll = 1, 0 @@ -453,9 +453,8 @@ function BoxMenu:cancelMove() self:clampIndex() end --- BillsPC_PressLeft / BillsPC_PressRight. The move screen wraps through box 0 --- (the PARTY); the withdraw list has no party to walk into, so it wraps inside --- the fourteen boxes. +-- BillsPC_PressLeft / BillsPC_PressRight, reached only from +-- MoveMonWithoutMail_DPad: the move screen wraps through box 0 (the PARTY). function BoxMenu:stepBox(delta) local low = self.mode == "move" and PARTY_BOX or 1 local span = Boxes.NUM_BOXES - low + 1 @@ -531,9 +530,11 @@ function BoxMenu:update(_dt) elseif input:wasPressed("down") then self.index = self.index < total and self.index + 1 or 1 self:ensureVisible() - elseif input:wasPressed("left") and self.mode ~= "deposit" then + -- Withdraw_UpDown reads PAD_UP and PAD_DOWN and nothing else; only + -- MoveMonWithoutMail_DPad walks the boxes (bills_pc.asm:806-820, :822-845). + elseif input:wasPressed("left") and self.mode == "move" then self:stepBox(-1) - elseif input:wasPressed("right") and self.mode ~= "deposit" then + elseif input:wasPressed("right") and self.mode == "move" then self:stepBox(1) elseif input:wasPressed("a") then self:act() diff --git a/src/ui/gen2/CrystalIntro.lua b/src/ui/gen2/CrystalIntro.lua index 7f14ecef..50afead6 100644 --- a/src/ui/gen2/CrystalIntro.lua +++ b/src/ui/gen2/CrystalIntro.lua @@ -282,12 +282,12 @@ SEQUENCES.IntroPichuWooper = function(_, st) end -- SpriteAnimFunc_IntroUnown (engine/sprite_anims/functions.asm:797-821): --- circle at radius VAR1, three sine steps per frame. +-- VAR1 is the angle, the counter is the radius (core.asm:543-545). SEQUENCES.IntroUnown = function(_, st) - local angle = st.jt + local radius = st.jt st.jt = (st.jt + 3) % 256 - st.yOffset = SpriteAnims.sine(angle, st.var1) - st.xOffset = SpriteAnims.cosine(angle, st.var1) + st.yOffset = SpriteAnims.sine(st.var1, radius) + st.xOffset = SpriteAnims.cosine(st.var1, radius) end -- SpriteAnimFunc_IntroUnownF (engine/sprite_anims/functions.asm:823-829): @@ -488,7 +488,7 @@ local function unownFade(self, pal, t) end -- CrystalIntro_InitUnownAnim (engine/movie/intro.asm:1191-1229): four structs --- at one spot, radii $08/$18/$28/$38, framesets 4/3/1/2. +-- at one spot, angles $08/$18/$28/$38, framesets 4/3/1/2. local UNOWN_SWIRL = { { 0x08, "IntroUnown4" }, { 0x18, "IntroUnown3" }, { 0x28, "IntroUnown1" }, { 0x38, "IntroUnown2" }, diff --git a/src/ui/gen2/CrystalSplash.lua b/src/ui/gen2/CrystalSplash.lua new file mode 100644 index 00000000..6436a6fa --- /dev/null +++ b/src/ui/gen2/CrystalSplash.lua @@ -0,0 +1,423 @@ +-- The Crystal GAME FREAK splash (pokecrystal engine/movie/splash.asm:1-342): +-- Ditto bounces in, rests, transforms into the logo, then GAME FREAK / presents. + +local Chrome = require("src.ui.gen2.Chrome") +local GbcPalette = require("src.render.GbcPalette") +local Music = require("src.core.Music") +local Runtime = require("src.mods.Runtime") +local Sound = require("src.core.Sound") +local SpriteAnims = require("src.ui.gen2.SpriteAnims") +local TileSheet = require("src.ui.gen2.TileSheet") + +local CrystalSplash = {} +CrystalSplash.__index = CrystalSplash +CrystalSplash.isOpaque = true + +local SCREEN_W, SCREEN_H = 160, 144 + +-- splash.asm:91 `depixel 10, 11, 4, 0` is y-first: x=e, y=d +-- (pokecrystal engine/sprite_anims/core.asm:113). +local LOGO_X, LOGO_Y = 11 * 8 + 0, 10 * 8 + 4 +-- pokecrystal constants/gfx_constants.asm:36 OAM_YCOORD_HIDDEN. +local YCOORD_HIDDEN = 160 + +-- splash.asm:161-164 and :183-186; tile $0d is the logo's own first tile +-- borrowed as the space. +local GAME_FREAK = { 0x00, 0x01, 0x02, 0x03, 0x0d, 0x04, 0x05, 0x03, 0x01, 0x06 } +local GAME_FREAK_X, GAME_FREAK_Y = 5, 10 +local PRESENTS = { 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c } +local PRESENTS_X, PRESENTS_Y = 7, 11 + +-- splash.asm:121-122 `ld c, 16 / call DelayFrames`. +local EXIT_FRAMES = 16 + +local BLACK = { 0, 0, 0 } +local WHITE = { 255, 255, 255 } + +-- pokecrystal gfx/splash/ditto.pal, loaded into OBJ pals 0 and 1 by +-- _CGB_GamefreakLogo (engine/gfx/cgb_layouts.asm:876-893). +local DITTO_OB = { WHITE, { 107, 90, 0 }, { 189, 99, 230 }, BLACK } +-- pokecrystal gfx/sgb/predef.pal:79 PREDEFPAL_GAMEFREAK_LOGO_BG. +local DEFAULT_BG = { BLACK, { 66, 90, 90 }, { 173, 173, 173 }, WHITE } + +-------------------------------------------------------------------------- +-- Sprite-anim data, registered into SpriteAnims' shared tables +-------------------------------------------------------------------------- + +-- dbsprite (pokecrystal macros/gfx.asm): y byte first. +local function s(xTile, yTile, xPixel, yPixel, tile, attr) + return { + y = (yTile * 8 + yPixel) % 256, + x = (xTile * 8 + xPixel) % 256, + tile = tile, + attr = attr, + } +end + +-- pokecrystal data/sprite_anims/oam.asm:1098-1108 .OAMData_GameFreakLogo1_3. +local DITTO_SMALL = {} +for row = 0, 2 do + for col = 0, 2 do + DITTO_SMALL[#DITTO_SMALL + 1] = + s(col - 2, row - 2, 4, 0, row * 0x10 + col, 1) + end +end + +-- pokecrystal data/sprite_anims/oam.asm:1110-1135 .OAMData_GameFreakLogo4_11. +local DITTO_MORPH = {} +for row = 0, 5 do + for col = 0, 3 do + DITTO_MORPH[#DITTO_MORPH + 1] = + s(col - 2, row - 5, 4, 0, row * 0x10 + col, 1) + end +end + +-- pokecrystal data/sprite_anims/oam.asm:139-149: vtile bases into the +-- 256-tile Ditto sheet GameFreakPresentsInit loads whole (splash.asm:72-85). +local OAMSETS = { + CRYSTAL_GAMEFREAK_LOGO_1 = { 0xd0, DITTO_SMALL }, + CRYSTAL_GAMEFREAK_LOGO_2 = { 0xd3, DITTO_SMALL }, + CRYSTAL_GAMEFREAK_LOGO_3 = { 0xd6, DITTO_SMALL }, + CRYSTAL_GAMEFREAK_LOGO_4 = { 0x6c, DITTO_MORPH }, + CRYSTAL_GAMEFREAK_LOGO_5 = { 0x68, DITTO_MORPH }, + CRYSTAL_GAMEFREAK_LOGO_6 = { 0x64, DITTO_MORPH }, + CRYSTAL_GAMEFREAK_LOGO_7 = { 0x60, DITTO_MORPH }, + CRYSTAL_GAMEFREAK_LOGO_8 = { 0x0c, DITTO_MORPH }, + CRYSTAL_GAMEFREAK_LOGO_9 = { 0x08, DITTO_MORPH }, + CRYSTAL_GAMEFREAK_LOGO_10 = { 0x04, DITTO_MORPH }, + CRYSTAL_GAMEFREAK_LOGO_11 = { 0x00, DITTO_MORPH }, +} + +local function f(oamset, duration, flags) + return { oamset = oamset, duration = duration, flags = flags or 0 } +end + +-- pokecrystal data/sprite_anims/framesets.asm:142-158 .Frameset_GameFreakLogo. +local FRAMESETS = { + CrystalGameFreakLogo = { + f("CRYSTAL_GAMEFREAK_LOGO_1", 12), f("CRYSTAL_GAMEFREAK_LOGO_2", 1), + f("CRYSTAL_GAMEFREAK_LOGO_3", 1), f("CRYSTAL_GAMEFREAK_LOGO_2", 4), + f("CRYSTAL_GAMEFREAK_LOGO_1", 12), f("CRYSTAL_GAMEFREAK_LOGO_2", 12), + f("CRYSTAL_GAMEFREAK_LOGO_3", 4), f("CRYSTAL_GAMEFREAK_LOGO_4", 32), + f("CRYSTAL_GAMEFREAK_LOGO_5", 3), f("CRYSTAL_GAMEFREAK_LOGO_6", 3), + f("CRYSTAL_GAMEFREAK_LOGO_7", 4), f("CRYSTAL_GAMEFREAK_LOGO_8", 4), + f("CRYSTAL_GAMEFREAK_LOGO_9", 4), f("CRYSTAL_GAMEFREAK_LOGO_10", 10), + f("CRYSTAL_GAMEFREAK_LOGO_11", 7), + "end", + }, +} + +local SEQUENCES = {} + +-- GameFreakLogoSpriteAnim (splash.asm:201-342): VAR1 jump height, VAR2 sine +-- offset / frame counter; sys.flag carries the transform's NextScene call. +SEQUENCES.CrystalGameFreakLogo = function(sys, st) + local splash = sys.splash + if st.jt == 0 then + -- GameFreakLogo_Init (splash.asm:221-225). + st.jt = 1 + elseif st.jt == 1 then + -- GameFreakLogo_Bounce (splash.asm:227-283). + if st.var1 == 0 then + st.jt = 2 + st.var2 = 0 + if splash then splash:playSfx("Sfx_DittoPopUp") end + return + end + local angle = st.var2 % 0x40 + if angle < 32 then angle = angle + 32 end + st.yOffset = SpriteAnims.sine(angle, st.var1) + local before = st.var2 + st.var2 = (st.var2 - 1) % 256 + if before % 0x20 == 0 then + st.var1 = (st.var1 - 48) % 256 + if splash then splash:playSfx("Sfx_DittoBounce") end + end + elseif st.jt == 2 then + -- GameFreakLogo_Ditto (splash.asm:285-304). + if st.var2 >= 32 then + st.jt = 3 + st.var2 = 0 + if splash then splash:playSfx("Sfx_DittoTransform") end + else + st.var2 = st.var2 + 1 + end + elseif st.jt == 3 then + -- GameFreakLogo_Transform (splash.asm:306-340). + if st.var2 == 64 then + st.jt = 4 + sys.flag = 1 + else + local step = math.floor(st.var2 / 4) + st.var2 = st.var2 + 1 + if splash then splash:fadeTo(step) end + end + end +end + +-- pokecrystal data/sprite_anims/objects.asm:11-12 SPRITE_ANIM_OBJ_GAMEFREAK_LOGO. +local OBJECTS = { + CRYSTAL_GAMEFREAK_LOGO = { "CrystalGameFreakLogo", "CrystalGameFreakLogo" }, +} + +local function register(target, entries) + for name, value in pairs(entries) do + if target[name] == nil then target[name] = value end + end +end + +register(SpriteAnims.OAMSETS, OAMSETS) +register(SpriteAnims.FRAMESETS, FRAMESETS) +register(SpriteAnims.OBJECTS, OBJECTS) +register(SpriteAnims.SEQUENCES, SEQUENCES) + +-------------------------------------------------------------------------- +-- The screen +-------------------------------------------------------------------------- + +function CrystalSplash:wantsFillScale() return true end +function CrystalSplash:drawsWidescreen() return true end + +-- opts: oakSpeech (data/generated/oak_speech.lua, for its `splash` table), +-- onDone(skipped) +function CrystalSplash.new(game, opts) + opts = opts or {} + local self = setmetatable({}, CrystalSplash) + self.game = game + self.onDone = opts.onDone + local splash = (opts.oakSpeech or {}).splash or {} + local ob = splash.dittoPalette or DITTO_OB + self.obColors = { ob[1], ob[2], ob[3], ob[4] } + self.bgColors = splash.bgPalette or DEFAULT_BG + self.dittoFade = splash.dittoFade + + -- GameFreakLogoGFX's 28 1bpp tiles land at vTiles2 $00 (splash.asm:62-65): + -- letters $00-$0c, the 3x5 logo $0d-$1b. + self.sheets = { + TileSheet.new({ path = splash.presents + or "assets/generated/splash/presents.png", wide = 13, firstTile = 0x00 }), + TileSheet.new({ path = splash.logo + or "assets/generated/splash/logo.png", wide = 3, firstTile = 0x0d }), + } + self.ditto = TileSheet.new({ path = splash.ditto + or "assets/generated/splash/ditto.png", + wide = splash.dittoTilesWide or 16, firstTile = 0x00 }) + + self.anims = SpriteAnims.new() + self.anims.splash = self + -- splash.asm:91-102: spawn hidden, VAR1=96, VAR2=48. + local st = self.anims:init("CRYSTAL_GAMEFREAK_LOGO", LOGO_X, LOGO_Y) + if st then + st.yOffset = YCOORD_HIDDEN + st.var1 = 96 + st.var2 = 48 + end + + self.scene = 0 + self.timer = 0 + self.tiles = {} -- the BG tilemap, sparse: [y][x] = tile id + self.frames = 0 + self.exitTail = nil + self.done = false + self.skipped = nil + return self +end + +function CrystalSplash:enter() + local data = self.game and self.game.data + if data and data.audio and data.audio.runtime then + Music.stop() + end + if Runtime.wants("intro.boot.gamefreak") then + Runtime.emit("intro.boot.gamefreak", { screen = self, game = self.game }) + end +end + +function CrystalSplash:finish() + if self.done then return end + self.done = true + if self.onDone then self.onDone(self.skipped) end +end + +function CrystalSplash:playSfx(name) + local data = self.game and self.game.data + if data and data.audio and data.audio.sfx and data.audio.sfx[name] then + Sound.play(data, name) + end +end + +-- The transform walks OBJ pal 1 colour 2 down GameFreakDittoPaletteFade +-- (splash.asm:306-334, gfx/splash/ditto_fade.pal). +function CrystalSplash:fadeTo(step) + local fade = self.dittoFade + local color = fade and fade[step + 1] + if color then self.obColors[3] = color end +end + +function CrystalSplash:placeString(tiles, tx, ty) + local row = self.tiles[ty] + if not row then + row = {} + self.tiles[ty] = row + end + for index, tile in ipairs(tiles) do + row[tx + index - 1] = tile + end +end + +-------------------------------------------------------------------------- +-- GameFreakPresentsScene (splash.asm:125-199) +-------------------------------------------------------------------------- + +function CrystalSplash:sceneWaitSpriteAnim() + if self.anims.flag == 0 then return end + self.scene = 1 + self.timer = 0 +end + +function CrystalSplash:scenePlaceGameFreak() + if self.timer < 32 then + self.timer = self.timer + 1 + return + end + self.timer = 0 + self:placeString(GAME_FREAK, GAME_FREAK_X, GAME_FREAK_Y) + self.scene = 2 + self:playSfx("Sfx_GameFreakPresents") +end + +function CrystalSplash:scenePlacePresents() + if self.timer < 64 then + self.timer = self.timer + 1 + return + end + self.timer = 0 + self:placeString(PRESENTS, PRESENTS_X, PRESENTS_Y) + self.scene = 3 +end + +function CrystalSplash:sceneWaitForTimer() + if self.timer < 128 then + self.timer = self.timer + 1 + return + end + self:beginExit() +end + +local SCENES = { + CrystalSplash.sceneWaitSpriteAnim, + CrystalSplash.scenePlaceGameFreak, + CrystalSplash.scenePlacePresents, + CrystalSplash.sceneWaitForTimer, +} + +-- GameFreakPresentsEnd (splash.asm:117-123). +function CrystalSplash:beginExit() + if self.exitTail then return end + self.anims:clear() + self.tiles = {} + self.exitTail = 0 +end + +function CrystalSplash:update(_dt) + self.frames = self.frames + 1 + if self.exitTail then + self.exitTail = self.exitTail + 1 + if self.exitTail > EXIT_FRAMES then self:finish() end + return + end + local input = self.game and self.game.input + if input and (input:wasPressed("a") or input:wasPressed("b") + or input:wasPressed("start") or input:wasPressed("select")) then + -- SplashScreen.pressed_button (splash.asm:51-54) returns carry. + self.skipped = true + self:beginExit() + return + end + local scene = SCENES[self.scene + 1] + if scene then scene(self) end + self.anims:playFrame() +end + +-------------------------------------------------------------------------- +-- Drawing +-------------------------------------------------------------------------- + +function CrystalSplash:sheetFor(tile) + for _, sheet in ipairs(self.sheets) do + if tile >= sheet.firstTile and sheet:available() then + local index = tile - sheet.firstTile + local image = sheet:image() + local _, height = image:getDimensions() + if index < sheet.wide * (height / 8) then return sheet end + end + end + return nil +end + +function CrystalSplash:drawTile(tile, tx, ty, colors) + local sheet = self:sheetFor(tile) + if not sheet then return end + sheet.palette = colors + sheet:draw(tile, tx, ty) +end + +function CrystalSplash:drawObjects() + local G = love.graphics + local sheet = self.ditto + if not sheet:available() then return end + local oam = self.anims.oam + for index = #oam, 1, -1 do + local entry = oam[index] + local quad = sheet:quad(entry.tile) + if quad then + local flipX = math.floor(entry.attr / SpriteAnims.OAM_XFLIP) % 2 == 1 + local flipY = math.floor(entry.attr / SpriteAnims.OAM_YFLIP) % 2 == 1 + local function body() + G.setColor(1, 1, 1, 1) + G.draw(sheet:image(), quad, + entry.x - 8 + (flipX and 8 or 0), entry.y - 16 + (flipY and 8 or 0), + 0, flipX and -1 or 1, flipY and -1 or 1) + end + if GbcPalette.available() then + GbcPalette.with(self.obColors, body) + else + body() + end + end + end +end + +function CrystalSplash:drawPanel() + local G = love.graphics + local backdrop = GbcPalette.color(self.bgColors, 1) or BLACK + G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) + G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H) + G.setColor(1, 1, 1, 1) + for ty, row in pairs(self.tiles) do + for tx, tile in pairs(row) do + self:drawTile(tile, tx, ty, self.bgColors) + end + end + self:drawObjects() + G.setColor(1, 1, 1, 1) +end + +function CrystalSplash:draw() + self:drawPanel() +end + +function CrystalSplash:drawWidescreen(winW, winH) + local G = love.graphics + local backdrop = GbcPalette.color(self.bgColors, 1) or BLACK + G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) + G.rectangle("fill", 0, 0, winW, winH) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(Chrome.fitOrigin(winW, winH, scale)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return CrystalSplash diff --git a/src/ui/gen2/GameFreakPresents.lua b/src/ui/gen2/GameFreakPresents.lua index 7eab4456..c7a4650f 100644 --- a/src/ui/gen2/GameFreakPresents.lua +++ b/src/ui/gen2/GameFreakPresents.lua @@ -45,9 +45,9 @@ local SCREEN_W, SCREEN_H = 160, 144 -- wSpriteAnimDict[SPRITE_ANIM_DICT_GS_SPLASH] = $8d (GameFreakPresentsInit). local DICT_VTILE = 0x8d --- `depixel 10, 11, 4, 0` and `depixel 11, 11`: the macro is --- (x tile, y tile, x pixel, y pixel) and lands in de as x, y. -local LOGO_X, LOGO_Y = 10 * 8 + 4, 11 * 8 + 0 +-- `depixel 10, 11, 4, 0` and `depixel 11, 11` are y-first: x=e, y=d +-- (engine/sprite_anims/core.asm:113). +local LOGO_X, LOGO_Y = 11 * 8 + 0, 10 * 8 + 4 local SPARKLE_X, SPARKLE_Y = 11 * 8, 11 * 8 -- GameFreakPresents_PlaceGameFreak / _PlacePresents. $8d is the logo's own @@ -152,7 +152,7 @@ end function GameFreakPresents:finish() if self.done then return end self.done = true - if self.onDone then self.onDone() end + if self.onDone then self.onDone(self.skipped) end end -- PlaceString into the sparse tilemap. @@ -280,6 +280,7 @@ function GameFreakPresents:update(_dt) if input and (input:wasPressed("a") or input:wasPressed("b") or input:wasPressed("start") or input:wasPressed("select")) then -- .pressed_button: everything is torn down and the splash is over. + self.skipped = true self:beginExit() return end diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index 3ff6f334..61e2b431 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -20,6 +20,7 @@ local Chrome = require("src.ui.gen2.Chrome") local Logger = require("src.core.Logger") +local Performance = require("src.core.Performance") local Runtime = require("src.mods.Runtime") local Save = require("src.core.gen2.Save") @@ -112,6 +113,22 @@ local ROWS = { text = function(options) return FILTERS[(options.musicFilter or 0) + 1] end }, + -- Heads the port's display group, same spot src/ui/OptionsMenu.lua's own + -- PERFORMANCE row occupies relative to ZOOM/VOID FILL/TILT/SHADER FX below + -- (the extras this tier scales). Gen 1 row shape (`value`/`step`, not this + -- file's own `text`/`cycle`) works here unmodified: OptionsMenu:cycle + -- answers `row.step` first, and drawPanel already reads a function + -- `row.value` -- both written for exactly this kind of shared mod row. + { id = "performance", label = "PERFORMANCE", port = true, + value = function(g) + return Performance.label(g.options and g.options.performance) + end, + step = function(g, dir) + local o = g.options + o.performance = Performance.cycle(o.performance, dir) + g:applyOptions() + return true + end }, { label = "GAME SPEED", key = "speed", port = true, cycle = function(options, delta) local GameSpeed = require("src.core.GameSpeed") @@ -169,19 +186,33 @@ local ROWS = { text = function(options) return require("src.render.GbcPalette").modeLabel(options.color or "gbc") end }, - { label = "GBC FX", key = "gbcfx", port = true, - cycle = function(options, delta) - local GBCFX = require("src.render.GBCFX") - if not GBCFX.isSupported() then - options.gbcfx = 0 - return - end - local level = ((options.gbcfx or 0) + delta) % 5 - options.gbcfx = level - GBCFX.setLevel(level) - end, + -- SHADER FX reaches Gen 2 too, not just Gen 1. Same "activate" shape as + -- CONTROLS/TOUCH LAYOUT below (a pushed screen, not a `cycle` ladder) -- + -- ShaderFXScreen is the shared list screen both generations push, `id` + -- matching the Gen 1 row's so a mod filtering "shaderfx" on Red also + -- reaches Gold. + { id = "shaderfx", label = "SHADER FX", port = true, text = function(options) - return require("src.render.GBCFX").levelLabel(options.gbcfx or 0) + local ShaderFX = require("src.render.ShaderFX") + local entry = ShaderFX.activeEntry("main") + if not entry then return "OFF" end + return (entry.name:gsub("%.slangp$", "")):upper() + end, + activate = function(game) + require("src.ui.Screens").push(game, "ShaderFXScreen", "main") + end }, + -- Dual-shader secondary slot, same shared ShaderFXScreen as the row + -- above, opened on "secondary" instead -- see src/ui/OptionsMenu.lua's + -- mirror of this row for the full rationale. + { id = "shaderfx2", label = "SHADER FX 2", port = true, + text = function(options) + local ShaderFX = require("src.render.ShaderFX") + local entry = ShaderFX.activeEntry("secondary") + if not entry then return "OFF" end + return (entry.name:gsub("%.slangp$", "")):upper() + end, + activate = function(game) + require("src.ui.Screens").push(game, "ShaderFXScreen", "secondary") end }, { label = "VIDEO MODE", key = "videoMode", port = true, cycle = function(options, delta) @@ -240,6 +271,11 @@ local ROWS = { text = function(options) return require("src.core.FrameCap").label(options.fpsCap) end }, + -- BATTLE BG (#1709): the void around the battle screen. Gold has no WIDE + -- layout and no WORLD backdrop, so the ladder is the WHITE/BLACK pair only. + { label = "BATTLE BG", key = "battleBg", port = true, + values = { "white", "black" }, + display = { white = "WHITE", black = "BLACK" } }, { label = "CANCEL", cancel = true }, } @@ -257,7 +293,7 @@ local function sameRows(_, rows) return rows end -- edit into the next opening -- the Gen 1 site rebuilds its descriptors for the -- same reason (src/ui/OptionsMenu.lua buildRows). -- --- `id` is the key a Gen 1 mod filters a row on ("gbcfx", "speed", "musicVol") +-- `id` is the key a Gen 1 mod filters a row on ("shaderfx", "speed", "musicVol") -- and is added ALONGSIDE this file's own `key`, never in place of it: a mod -- written against Red's OPTION screen finds the shared rows where it expects -- them, and the rows Gold has that Red does not (PRINT, MENU ACCOUNT, FRAME, diff --git a/src/ui/gen2/PackGfx.lua b/src/ui/gen2/PackGfx.lua index bb8ed195..68103dc8 100644 --- a/src/ui/gen2/PackGfx.lua +++ b/src/ui/gen2/PackGfx.lua @@ -12,8 +12,9 @@ -- which DrawPackGFX swaps per pocket out of PackGFX -- DrawPocketName lays a 5x3 block at (0,7) from its own 5x12 tilemap -- _CGB_PackPals loads six BG palettes and colours five rectangles with --- them: the two header halves, the quantity column, the --- pocket plaque (red) and the bag picture (green) +-- them: the two header halves, the CURSOR column (7,2) 1x9 +-- whose colour 3 is red, the pocket plaque (red) and the bag +-- picture (green) -- engine/gfx/cgb_layouts.asm:715-734 -- -- A cache from before the pack stage simply has no `pack` table; PackMenu -- falls back to its plain boxes then. diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index 432ad490..3a5d970c 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -229,9 +229,9 @@ function PackMenu.label(itemId, def) return (tostring(itemId):gsub("_", " ")) end --- TMHMPocket (engine/items/tmhm.asm) writes GetMoveName's string under the --- TM's own name, so the second line of a TM row is the MOVE's name and not the --- constant the attributes row carries. +-- TMHM_DisplayPocketItems (engine/items/tmhm.asm:381-385) places GetMoveName's +-- string three tiles right of the row's number, so a TM/HM row reads as the +-- MOVE's name and the TM's own item name is never printed. function PackMenu:moveLabel(moveId) if not moveId then return nil end local moves = self.game and self.game.data and self.game.data.moves @@ -239,6 +239,17 @@ function PackMenu:moveLabel(moveId) return (def and def.name) or (tostring(moveId):gsub("_", " ")) end +-- engine/items/tmhm.asm:357-375 -- the number a TM/HM row prints: a TM with +-- PRINTNUM_LEADINGZEROS, an HM as 'H' and its own left-aligned ordinal. +local function tmhmLabelFor(itemId, def) + local digits = tostring((def and def.tmLabel) or (def and def.name) + or itemId):match("(%d+)") + local n = tonumber(digits) + if not n then return nil end + if tostring(itemId):sub(1, 3) == "HM_" then return "H" .. n end + return ("%02d"):format(n) +end + function PackMenu:rebuild() local pocket = self:pocket().id local rows = {} @@ -260,6 +271,7 @@ function PackMenu:rebuild() name = PackMenu.label(itemId, def), teaches = self:moveLabel(def and def.teaches), tmNumber = def and def.tmNumber, + tmhmLabel = (pocket == "TM_HM" and tmhmLabelFor(itemId, def)) or nil, -- A KEY_ITEM never shows one, and engine/items/tmhm.asm:390 skips the -- count for an HM only -- a TM prints ×NN like any other stack. showCount = pocket == "ITEM" or pocket == "BALL" @@ -929,13 +941,27 @@ function PackMenu:description() return def and def.description or nil end +-- engine/gfx/cgb_layouts.asm:723-726 -- the cursor column (7,2) 1x9 takes +-- palette $3, whose colour 3 is red (gfx/pack/pack.pal). +function PackMenu:cursorAt(tx, ty, hollow) + local palette = self.gfx and self.gfx:available() + and self.gfx:colorsAt(tx, ty) + if palette then + Chrome.cursorThrough(tx, ty, palette, false, hollow) + else + Chrome.cursor(tx, ty, hollow) + end +end + -- The list, description and cursor, on top of whatever chrome was drawn. -- --- PlaceMenuItemQuantity (engine/menus/menu_2.asm:10) writes the ×N one row --- DOWN and one column RIGHT of the name -- the quantity is the entry's second --- line, not a right-aligned column, which is why every PACK row is two tiles --- tall. Its `lb bc, 1, 2` is a TWO-digit field with the leading digit blanked, --- so the ones digit sits at name + 3 whether the count is 5 or 50. +-- ScrollingMenu_CallFunctions1and2 (engine/menus/scrolling_menu.asm:424-429) +-- steps the coord on by the header's `db 5, 8` COLUMN count before +-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:19-25) adds SCREEN_WIDTH + 1, +-- so the ×N sits at name + 9 on the row BELOW the name, flush right in every +-- pocket (#1425, #1693). Its `lb bc, 1, 2` is a TWO-digit field with the +-- leading digit blanked. engine/items/tmhm.asm:392-403 writes that same +-- column for a TM. -- -- ScrollingMenu_PlaceCursor (engine/menus/scrolling_menu.asm:438) marks the -- row SELECT armed with the hollow ▷ while the solid ▶ goes on looking. @@ -945,27 +971,23 @@ function PackMenu:drawList(listX, listY) local ty = listY + (row - 1) * LIST_SPACING if i <= #self.rows then local entry = self.rows[i] - if i == self.index then - Chrome.cursor(listX - 1, ty) - elseif i == self.switching then - Chrome.cursor(listX - 1, ty, true) + -- engine/items/tmhm.asm:355-385 (#1695) + if entry.tmhmLabel then + Chrome.print(entry.tmhmLabel, listX - 3, ty) end - Chrome.print(entry.name, listX, ty) - if entry.teaches then - -- The TM pocket puts the move the TM teaches on that second line, and - -- its count at listX + 9 (engine/items/tmhm.asm:392) -- on the LABEL's - -- line here, since the move name owns the one below it. - Chrome.print(entry.teaches, listX + 1, ty + 1) - if entry.showCount then - Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2), - listX + 9, ty) - end - elseif entry.showCount then + if i == self.index then + self:cursorAt(listX - 1, ty) + elseif i == self.switching then + self:cursorAt(listX - 1, ty, true) + end + Chrome.print((entry.tmhmLabel and entry.teaches) or entry.name, + listX, ty) + if entry.showCount then Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2), - listX + 1, ty + 1) + listX + 9, ty + 1) end elseif i == self:total() then - if i == self.index then Chrome.cursor(listX - 1, ty) end + if i == self.index then self:cursorAt(listX - 1, ty) end Chrome.print("CANCEL", listX, ty) end end @@ -979,8 +1001,12 @@ function PackMenu:drawDescription(ty) local lines = self.message or (self.confirm and self.confirm.prompt) if lines then local name = self:playerName() + -- TEXTBOX_INNERY and home/text.asm:397 LineChar: rows 14 and 16 (#1725). + -- Only a message too tall for those two packs its rows one apart. + local top, step = ty, 2 + if #lines > 2 then top, step = ty - 1, 1 end for i, line in ipairs(lines) do - Chrome.print((line:gsub("{PLAYER}", name)), 1, ty + i - 2) + Chrome.print((line:gsub("{PLAYER}", name)), 1, top + (i - 1) * step) end return end @@ -1056,7 +1082,7 @@ function PackMenu:drawPanel() Chrome.box(0, 0, 20, 3) Chrome.print(self:pocket().label, 2, 1) Chrome.box(0, 3, 20, 12) - self:drawList(2, 4) + self:drawList(5, 4) Chrome.box(0, 12, 20, 6) self:drawDescription(14) self:drawOverlays() diff --git a/src/ui/gen2/Pokegear.lua b/src/ui/gen2/Pokegear.lua index 9c1dfdbe..bbb0e5e8 100644 --- a/src/ui/gen2/Pokegear.lua +++ b/src/ui/gen2/Pokegear.lua @@ -933,6 +933,7 @@ function Pokegear.new(game, opts) -- reject already dropped, so the cursor's skip loop is just "next row". self.fly = opts.fly self.onFly = opts.onFly + self.flyMon = opts.flyMon if self.fly and #self.fly > 0 then self.cards = { FLY_MAP_CARD } self.cardIndex = 1 @@ -956,6 +957,9 @@ function Pokegear.new(game, opts) -- so the RED_WALK icon keeps the overworld's own OBJ palette. self.sprites = opts.sprites or data.gen2Sprites self.palettes = opts.palettes or data.gen2Palettes + -- TownMapMon reads wCurPartyMon's icon + -- (../pokecrystal/engine/pokegear/pokegear.asm:2708-2721). + self.icons = opts.icons or data.gen2Icons local gfx = (opts.menuGfx or data.gen2MenuGfx or {}).pokegear self.gfx = gfx @@ -2130,7 +2134,11 @@ function Pokegear:drawMap() end end if current and current.x and current.y then - self:mapCursorSprite(current.x, current.y) + -- FlyMap's cursor is TownMapMon, the FlyMon's icon; only the MAP card's is + -- the POKEGEAR_ARROW (../pokecrystal/engine/pokegear/pokegear.asm:2326). + if not (self.fly and self:drawFlyMonCursor(current.x, current.y)) then + self:mapCursorSprite(current.x, current.y) + end end end @@ -2174,6 +2182,48 @@ function Pokegear:drawPlayerIcon(x, y) return true end +-- TownMapMon (../pokecrystal/engine/pokegear/pokegear.asm:2708-2721): the +-- FlyMon's party icon, on PAL_OW_RED like the RED_WALK icon beside it. +function Pokegear:loadFlyMonIcon() + if self.flyMonIcon ~= nil then return end + self.flyMonIcon = false + local mon = self.flyMon + if type(mon) ~= "table" then return end + local icons = self.icons + local iconId = mon.isEgg and "ICON_EGG" + or (icons and icons.species and mon.species + and icons.species[mon.species]) + local entry = iconId and icons and icons.icons and icons.icons[iconId] + if not (entry and entry.image) then return end + local def = { + id = "SPRITE_FLY_MON", image = entry.image, frames = 2, walker = false, + spriteType = "POKEMON_SPRITE", palette = "PAL_OW_RED", paletteId = 0, + species = mon.species, icon = iconId, + } + local ok, icon = pcall(SpriteRenderer.new, def, "flymon") + if not (ok and icon) then return end + local world = self.game and self.game.world + local daytime = (world and world.daytime) + or Palettes.clockDaytime(self.clock and self.clock.hour or nil) + local colors = self.palettes + and Palettes.spritePalette(self.palettes, daytime, def) + if colors then + icon:setObjPalette(colors, ("gen2:%s:0"):format(tostring(daytime))) + end + self.flyMonIcon = icon +end + +-- .Frameset_PartyMon is two 8-frame icon beats and no mirror +-- (data/sprite_anims/framesets.asm:66-69). +function Pokegear:drawFlyMonCursor(x, y) + self:loadFlyMonIcon() + if not self.flyMonIcon then return false end + love.graphics.setColor(1, 1, 1, 1) + self.flyMonIcon:draw(x - 8, y - 8, 0, -4, "down", 0, false, false, false, + math.floor((self.iconTimer or 0) / 8) % 2) + return true +end + -- The cursor arrow. It is the same PokegearSpritesGFX sheet the mode -- indicator uses, at tile $04; without the sheet the port falls back to -- Chrome's own cursor glyph so the card is still navigable. diff --git a/src/ui/gen2/SummaryMenu.lua b/src/ui/gen2/SummaryMenu.lua index 38f27dfb..431b778f 100644 --- a/src/ui/gen2/SummaryMenu.lua +++ b/src/ui/gen2/SummaryMenu.lua @@ -103,6 +103,14 @@ local PAGE_PALETTES = { { { 255, 255, 255 }, { 140, 255, 255 }, { 140, 255, 255 }, { 0, 0, 0 } }, } +-- gfx/stats/stats.pal, the colour LoadStatsScreenPals writes over colour 0 of +-- BG palettes 0 and 2 (engine/gfx/color.asm:386-390). #1693 +local PAGE_TINTS = { + { 255, 156, 255 }, + { 173, 255, 115 }, + { 140, 255, 255 }, +} + -- PrintTempMonStats' .StatNames, and the wTempMon fields it prints beside -- them. steps two rows, so the five labels are 2 rows apart and the -- values start one row below the first label. @@ -901,13 +909,20 @@ end -- A tile out of StatsScreenPageTilesGFX. The fallback arm draws each of the -- seven shapes by hand for a cache built before menu_gfx.stats existed. -function SummaryMenu:pageTile(id, tx, ty) +function SummaryMenu:pageTile(id, tx, ty, colors) local G = love.graphics local px, py = tx * 8, ty * 8 local sheet = self:statsTiles() if sheet and sheet.quads[id] then G.setColor(1, 1, 1, 1) - G.draw(sheet.image, sheet.quads[id], px, py) + local function body() + G.draw(sheet.image, sheet.quads[id], px, py) + end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end return end G.setColor(0, 0, 0, 1) @@ -1106,8 +1121,26 @@ function SummaryMenu:drawHorizontalDivider() end end +-- BG palette 0 as the stats screen leaves it: the page tint in colour 0, black +-- ink in colour 3 (engine/gfx/color.asm:386-390). +function SummaryMenu:lowerColors() + local tint = PAGE_TINTS[self.page] or PAGE_TINTS[PINK_PAGE] + return { tint, tint, tint, { 0, 0, 0 } } +end + +-- StatsScreen_LoadGFX's .ClearBox: hlcoord 0, 8 / lb bc, 10, 20, the ten rows +-- LoadStatsScreenPals then tints (engine/pokemon/stats_screen.asm:549-557). +function SummaryMenu:drawPageBackground() + local G = love.graphics + local tint = GbcPalette.color(self:lowerColors(), 1) or { 255, 255, 255 } + G.setColor(tint[1] / 255, tint[2] / 255, tint[3] / 255, 1) + G.rectangle("fill", 0, 8 * 8, Chrome.SCREEN_W * 8, 10 * 8) + G.setColor(0, 0, 0, 1) +end + function SummaryMenu:drawVerticalDivider(tx) - for y = 8, 17 do self:pageTile(TILE_VERTICAL_DIVIDER, tx, y) end + local colors = self:lowerColors() + for y = 8, 17 do self:pageTile(TILE_VERTICAL_DIVIDER, tx, y, colors) end end function SummaryMenu:drawUpperHalf() @@ -1125,11 +1158,12 @@ end function SummaryMenu:drawPinkPage() local mon = self.mon or {} local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 0 + local tint = PAGE_TINTS[self.page] or PAGE_TINTS[PINK_PAGE] -- DrawPlayerHP is DrawBattleHPBar with d = 6 and b = 0: "HP:" at (0,9), six -- bar cells, and the end cap at (8,9) -- which LoadPinkPage then rewrites as -- $41, the same shape from the stats sheet. if self.hud and self.hud:available() then - self.hud:drawHpBar(mon.hp, maxHp, 0, 9) + self.hud:drawHpBar(mon.hp, maxHp, 0, 9, tint) else HpBar.drawWithLabel(self.palettes, mon.hp, maxHp, 0, 9, Font) end @@ -1141,15 +1175,16 @@ function SummaryMenu:drawPinkPage() -- and (19,16). local fraction = HpBar.expFraction(mon, self:growth(), Mon.experienceForLevel) if self.hud and self.hud:available() then - self.hud:drawExpBar(fraction, 11, 16) + self.hud:drawExpBar(fraction, 11, 16, tint) else -- No HUD sheet in the cache: the plain rule, which is HP_BAR_LENGTH_PX -- (48) wide rather than the exp bar's 64, so it stops two tiles short of -- the $41 cap. A cache old enough to hit this has no bar tiles at all. HpBar.drawExp(self.palettes, fraction, 11 * 8, 16 * 8 + 3) end - self:pageTile(TILE_BAR_CAP_LEFT, 10, 16) - self:pageTile(TILE_BAR_CAP_RIGHT, 19, 16) + local colors = self:lowerColors() + self:pageTile(TILE_BAR_CAP_LEFT, 10, 16, colors) + self:pageTile(TILE_BAR_CAP_RIGHT, 19, 16, colors) end function SummaryMenu:drawGreenPage() @@ -1211,6 +1246,7 @@ function SummaryMenu:drawPanel() self:drawMoveDetail() else Chrome.clear() + self:drawPageBackground() self:drawUpperHalf() if self.page == GREEN_PAGE then self:drawGreenPage() @@ -1244,6 +1280,7 @@ SummaryMenu.STAT_LABELS = STAT_LABELS SummaryMenu.STAT_KEYS = STAT_KEYS SummaryMenu.TYPE_NAMES = TYPE_NAMES SummaryMenu.PAGE_PALETTES = PAGE_PALETTES +SummaryMenu.PAGE_TINTS = PAGE_TINTS SummaryMenu.levelText = levelText return SummaryMenu diff --git a/src/ui/kit/Theme.lua b/src/ui/kit/Theme.lua index 66a3fa30..6cb3f525 100644 --- a/src/ui/kit/Theme.lua +++ b/src/ui/kit/Theme.lua @@ -65,7 +65,7 @@ local PAL = { railGold = { 255, 203, 5 }, -- Yellow cartridge (bright) railAmber = { 218, 145, 32 }, -- Gold cartridge (deeper metal) railSilver = { 190, 198, 210 }, -- Silver cartridge (cool light metal) - railCrystal = { 168, 120, 236 }, -- Crystal cartridge (translucent violet) + railCrystal = { 132, 196, 228 }, -- Crystal cartridge (translucent ice blue) } -- Semantic aliases kept so ported call sites read the same as before. PAL.cardBorder = PAL.line diff --git a/src/world/NPC.lua b/src/world/NPC.lua index ef29b7fd..f8e41663 100644 --- a/src/world/NPC.lua +++ b/src/world/NPC.lua @@ -42,6 +42,23 @@ function NPC.new(data, mapId, objDef) return self end +-- LoadMapHeader .loadSpriteData zeroes the sprite state data and re-seeds +-- MAPY/MAPX from the map header -- home/overworld.asm:2133 +function NPC:resetToSpawn() + local def = self.def + self.cellX, self.cellY = def.x, def.y + self.px, self.py = self.cellX * 16, self.cellY * 16 + self.facing = FACING_FROM_RANGE[def.range] or "down" + self.targetX, self.targetY = nil, nil + self.moving = false + self.marching = false + self.hopStep = nil + self.progress = 0 + self.animClock = 0 + self.stepFlip = false + self.timer = love.math.random(30, 120) +end + function NPC:facePlayer(player) local dx = player.cellX - self.cellX local dy = player.cellY - self.cellY diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index e0391b3f..3ab5d66a 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -81,6 +81,24 @@ local HEAL_BALL_XY = { -- swaps the two middle shades of the monitor/ball art in place local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 } +-- scripts/VermilionDock.asm:39 VermilionDockSSAnneLeavesScript: her hull is +-- the four blocks (5..8, 1..2) of VermilionDock.blk +local SS_ANNE_BLOCK = { x = 5, y = 1, w = 4, h = 2 } +-- scripts/VermilionDock.asm:164 VermilionDock_SyncScrollWithLY splits rSCX at +-- LY $50, so her top 16px -- the player's own cell row -- never scrolls +local SS_ANNE_KEEP_PX = 16 +-- scripts/VermilionDock.asm:79 `ld e, $8`: eight 16px columns, and each +-- .delay_between_drifts pass is eight frames per pixel +local SS_ANNE_SAIL_PX, SS_ANNE_PX_FRAMES = 128, 8 +-- scripts/VermilionDock.asm:182 VermilionDock_EraseSSAnne: block 1 is the +-- shoreline row she sat in, block 13 the open water below it +local SS_ANNE_WATER = { 1, 13 } +-- scripts/VermilionDock.asm:142 VermilionDock_EmitSmokePuff: a puff per +-- column off the front smokestack, drifting east 2px per drift step +local SS_ANNE_SMOKE = { dx = 64, dy = 20, drift = 2, every = 16, count = 5 } +-- scripts/VermilionDock.asm:65 `ldh [rOBP1], a` with a = 0: the puff is white +local SS_ANNE_SMOKE_MAP = { [0] = 0, [1] = 0, [2] = 0, [3] = 0 } + -- Fishing rod placement (FishingRodOAM, engine/overworld/player_animations -- .asm). Those dbsprite rows are raw shadow-OAM bytes like HEAL_BALL_XY -- above (screen = tile*8 + pixel - 8/16), measured against the player @@ -320,6 +338,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts) self.parallelQueue = {} end self.marchers = {} + self.shipAnim = nil local queue = self.pendingScripts if queue then for i = #queue, 1, -1 do @@ -413,12 +432,14 @@ function OverworldState:setMap(mapId, x, y, facing, opts) -- (home/overworld.asm), so an NPC who walked up to the player stands -- on her spawn cell again the next time that map loads (#1028). Only -- the save-side spawn flags survive, and those live in Game.save, not - -- here. Warps rebuild the whole pool from scratch. + -- here. Warps rebuild the whole pool from scratch; a seam crossing arms + -- the re-seed and applyPendingSpawnResets lands it off camera (#1755). if not (opts and opts.seamless and self.npcPool) then self.npcPool = {} elseif fromMapId ~= mapId then for _, obj in ipairs(self.map.def.objects or {}) do - self.npcPool[mapId .. "_obj_" .. obj.index] = nil + local npc = self.npcPool[mapId .. "_obj_" .. obj.index] + if npc then npc.pendingSpawnReset = true end end end self.npcs = {} @@ -624,6 +645,41 @@ function OverworldState:rebuildNeighbors() end end +-- the deferred half of the seam re-seed armed in setMap (#1755): the cart's +-- connected map has no sprites to be seen snapping -- home/overworld.asm:2133 +function OverworldState:applyPendingSpawnResets() + local pool = self.npcPool + if not (pool and self.camera) then return end + local due + for _, npc in pairs(pool) do + if npc.pendingSpawnReset then + due = due or {} + due[npc] = true + end + end + if not due then return end + local cam = self.camera + local vw, vh = Game.renderer:worldViewSize() + local function onCamera(px, py) + return px + 16 > cam.x - 16 and px < cam.x + vw + 16 + and py + 16 > cam.y - 16 and py < cam.y + vh + 16 + end + for _, mv in ipairs(self.scriptMoves or {}) do due[mv.entity] = nil end + for entity in pairs(self.marchers or {}) do due[entity] = nil end + for _, npc in ipairs(self.npcs or {}) do + if due[npc] and onCamera(npc.px, npc.py) then due[npc] = nil end + end + for _, g in ipairs(self.ghosts or {}) do + if due[g.npc] and onCamera(g.npc.px + g.ox, g.npc.py + g.oy) then + due[g.npc] = nil + end + end + for npc in pairs(due) do + npc.pendingSpawnReset = nil + npc:resetToSpawn() + end +end + -- SGB overworld palette (engine/gfx/palettes.asm SetPal_Overworld): -- towns use their own palette, routes PAL_ROUTE, interiors the town or -- route they are in (wLastMap = our lastOutdoor), with tileset and @@ -1068,6 +1124,27 @@ function OverworldState:update(dt) if da.onDone then da.onDone() end end end + -- scripts/VermilionDock.asm:80 .shift_columns_up + if self.shipAnim and not self.shipAnim.gone then + local sa = self.shipAnim + sa.frames = sa.frames + 1 + if sa.frames >= SS_ANNE_PX_FRAMES then + sa.frames = 0 + sa.off = sa.off + 1 + if sa.off % SS_ANNE_SMOKE.every == 1 + and #sa.puffs < SS_ANNE_SMOKE.count then + sa.puffs[#sa.puffs + 1] = { x = sa.px - sa.off + SS_ANNE_SMOKE.dx, + y = sa.py + SS_ANNE_SMOKE.dy } + end + for _, p in ipairs(sa.puffs) do p.x = p.x + SS_ANNE_SMOKE.drift end + if sa.off >= SS_ANNE_SAIL_PX then + sa.gone, sa.puffs = true, {} + local done = sa.onDone + sa.onDone = nil + if done then done() end + end + end + end if self.cutAnim then local ca = self.cutAnim ca.frames = ca.frames - 1 @@ -1227,6 +1304,7 @@ function OverworldState:update(dt) -- escort then walks an extra tile before PlayerEntryMovementRLE, and -- the player lands on desk Oak. local scripted = self.runner:isRunning() or #self.scriptMoves > 0 + or (self.hopLand or 0) > 0 or self.engaging or self.emote or self.teleportOut or self.flyAnim or self.flyArrive if not scripted and not self.transitioning then @@ -1236,6 +1314,7 @@ function OverworldState:update(dt) -- direction handling (JoypadOverworld runs the map script first) -- -- the player can never start another step after being spotted. scripted = self.runner:isRunning() or #self.scriptMoves > 0 + or (self.hopLand or 0) > 0 or self.engaging or self.emote or self.teleportOut or self.flyAnim or self.flyArrive end @@ -1244,6 +1323,7 @@ function OverworldState:update(dt) if not scripted and not self.transitioning and Game.stack:top() == self then self:handleInput() end + if (self.hopLand or 0) > 0 then self.hopLand = self.hopLand - 1 end local stepped = self.player:update() -- the warp-arrival cell goes stale the instant the player's real cell @@ -1271,6 +1351,7 @@ function OverworldState:update(dt) self.camera:follow(self.player.px, self.player.py, Game.renderer:worldViewSize()) + self:applyPendingSpawnResets() -- pan_camera offset rides on top of the follow; the ramp resumes its -- runner when it lands @@ -1509,6 +1590,76 @@ function OverworldState:startDustAnim(cx, cy, onDone) self.dustAnim = { x = cx, y = cy, frames = 32, onDone = onDone } end +-- scripts/VermilionDock.asm:39 VermilionDockSSAnneLeavesScript: snapshot her +-- hull tiles, flood her box with water, and slide the snapshot west from there +function OverworldState:startSsAnneDeparture(onDone) + local map = self.map + local tx0, ty0 = SS_ANNE_BLOCK.x * 4, SS_ANNE_BLOCK.y * 4 + local tiles = {} + for row = 1, SS_ANNE_BLOCK.h * 4 do + local r = {} + for col = 1, SS_ANNE_BLOCK.w * 4 do + r[col] = map:tileAt(tx0 + col - 1, ty0 + row - 1) + end + tiles[row] = r + end + for bx = SS_ANNE_BLOCK.x, SS_ANNE_BLOCK.x + SS_ANNE_BLOCK.w - 1 do + for by = SS_ANNE_BLOCK.y, SS_ANNE_BLOCK.y + SS_ANNE_BLOCK.h - 1 do + map:setBlock(bx, by, SS_ANNE_WATER[by - SS_ANNE_BLOCK.y + 1] or 13) + end + end + map.renderer:rebuild() + self.shipAnim = { px = tx0 * 8, py = ty0 * 8, tiles = tiles, + off = 0, frames = 0, puffs = {}, onDone = onDone } +end + +-- scripts/VermilionDock.asm:164: the rSCX split keeps her top 16px (the +-- shoreline row and the gangway under the player) put while the rest sails +function OverworldState:drawShipAnim(camX, camY) + local sa = self.shipAnim + if not sa then return end + local renderer = self.map.renderer + local img, quads = renderer.image, renderer.quads + local ox, oy = -math.floor(camX), -math.floor(camY) + local keep = SS_ANNE_KEEP_PX / 8 + love.graphics.setColor(1, 1, 1, 1) + for row = 1, #sa.tiles do + if row <= keep or not sa.gone then + local slide = row > keep and sa.off or 0 + local wy = sa.py + (row - 1) * 8 + oy + for col = 1, #sa.tiles[row] do + local quad = quads[sa.tiles[row][col]] + if quad then + love.graphics.draw(img, quad, sa.px + (col - 1) * 8 - slide + ox, wy) + end + end + end + end + if #sa.puffs == 0 then return end + local fxDef = Game.data.field.overworldFx + local smoke = fxDef and fxDef.smoke + if not smoke then return end + if self.smokeImg == nil then + local ok, image = pcall(love.graphics.newImage, smoke.path) + self.smokeImg = ok and image or false + end + if not self.smokeImg then return end + local shader = PaletteFX.shader() + if shader then + PaletteFX.sendColors(shader, + PaletteFX.permute(PaletteFX.GRAYS, SS_ANNE_SMOKE_MAP)) + love.graphics.setShader(shader) + end + for _, p in ipairs(sa.puffs) do + for i = 0, 1 do + for j = 0, 1 do + love.graphics.draw(self.smokeImg, p.x + i * 8 + ox, p.y + j * 8 + oy) + end + end + end + if shader then love.graphics.setShader() end +end + -- Ledge hops (data/tilesets/ledge_tiles.asm): standing tile + ledge tile -- in front + matching input direction -> jump two cells. function OverworldState:checkLedgeHop(dir) @@ -1542,17 +1693,22 @@ function OverworldState:checkLedgeHop(dir) return false end require("src.core.Sound").play(Game.data, "Ledge") - local hop = (p.stepFramesCur or p.stepFrames or 16) * 2 + p.ledgeHop = true -- BIT_LEDGE_OR_FISHING, no bike speedup mid-hop + local hop = p:stepLength() * 2 p.hopFrames, p.hopTotal = hop, hop -- jump arc (cosmetic) - self:scriptMove(p, dir, 1, function() self:checkEdgeExit(dir) end) + self:scriptMove(p, dir, 1, function() + self:checkEdgeExit(dir) + self:finishLedgeHop() + end) return true end if not Collision.occupied(self.entities, lx, ly, p) and self.map:isWalkableCell(lx, ly) then require("src.core.Sound").play(Game.data, "Ledge") - local hop = (p.stepFramesCur or p.stepFrames or 16) * 2 + p.ledgeHop = true -- BIT_LEDGE_OR_FISHING, no bike speedup mid-hop + local hop = p:stepLength() * 2 p.hopFrames, p.hopTotal = hop, hop -- jump arc (cosmetic) - self:scriptMove(p, dir, 2) + self:scriptMove(p, dir, 2, function() self:finishLedgeHop() end) return true end end @@ -1560,6 +1716,13 @@ function OverworldState:checkLedgeHop(dir) return false end +-- _HandleMidJump .finishedJump lands with UpdateSprites + Delay3 before it +-- clears the joypad bytes -- engine/overworld/player_animations.asm:509 +function OverworldState:finishLedgeHop() + self.player.ledgeHop = nil + self.hopLand = 3 +end + -- walking off the map edge: connection crossing or edge warp (exit mats) function OverworldState:checkEdgeExit(dir) local p = self.player @@ -1665,9 +1828,7 @@ function OverworldState:crossConnection(dir, conn) -- fresh walk-cycle clock so the seam step always shows leg frames -- (mid-cycle stand phase would otherwise look like a slide) p.animClock = 0 - p.stepFramesCur = Game.save.onBike - and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8) - or (FieldDefaults.world(Game.data, "stepFrames") or 16) + p.stepFramesCur = p:stepLength() require("src.core.FixedStep"):discardCatchup() return true end @@ -2413,16 +2574,12 @@ function OverworldState:trashCanSwitch(canIndex) local adj = tc.adjacent[puz.first] local masked = require("bit").band(love.math.random(0, 255), #adj) puz.second = masked == 0 and 0 or adj[masked] - -- VermilionGymTrashSuccessText1's text_asm tail plays SFX_SWITCH only - -- after the text has printed (text_far ...; text_asm; - -- WaitForSoundToFinish; PlaySound SFX_SWITCH; WaitForSoundToFinish), - -- and DisplayTextID's WaitForTextScrollButtonPress then holds the box - -- until the player dismisses it -- so the beep belongs on close, not - -- open. + -- engine/events/hidden_events/vermilion_gym_trash.asm:130 (text_asm tail: + -- SFX_SWITCH once the text has printed, before the button wait) (#1702) Game.stack:push(TextBox.new(Game, t._VermilionGymTrashSuccessText1 or Strings("Hey! There's a\nswitch under the\ntrash!\fThe 1st electric\nlock opened!"), - function() require("src.core.Sound").play(Game.data, "Switch") end)) + nil, TextBox.soundOpts(Game, "Switch"))) return end -- .trySecondLock @@ -2433,25 +2590,28 @@ function OverworldState:trashCanSwitch(canIndex) -- the clear floor block opens the doors (VermilionGymSetDoorTile) local door = FieldDefaults.fieldValue(Game.data, "hiddenExtras", "trashCans", "doorBlock") - self:replaceBlock(door.bx, door.by, door.block) - -- SuccessText3's text_asm tail plays SFX_GO_INSIDE after the text - -- prints, so the beep fires as the box closes, not as it opens. + -- engine/events/hidden_events/vermilion_gym_trash.asm:153 beeps as the + -- text finishes; scripts/VermilionGym.asm:30 beeps on the swap (#1702) Game.stack:push(TextBox.new(Game, t._VermilionGymTrashSuccessText3 or Strings("The 2nd electric\nlock opened!\fThe motorized door\nopened!"), - function() require("src.core.Sound").play(Game.data, "Go_Inside") end)) + function() + require("src.core.Sound").play(Game.data, "Go_Inside") + self:replaceBlock(door.bx, door.by, door.block) + end, + TextBox.soundOpts(Game, "Go_Inside"))) else -- wrong can: ResetEvent EVENT_1ST_LOCK_OPENED and immediately -- re-roll the first switch (Random & $e) save.flags.EVENT_1ST_LOCK_OPENED = nil puz.first = love.math.random(0, 7) * 2 puz.second = nil - -- VermilionGymTrashFailText's text_asm tail plays SFX_DENIED after the - -- text prints, so the beep fires as the box closes, not as it opens. + -- engine/events/hidden_events/vermilion_gym_trash.asm:162 (text_asm tail: + -- SFX_DENIED once the text has printed, before the button wait) (#1702) Game.stack:push(TextBox.new(Game, t._VermilionGymTrashFailText or Strings("Nope! There's\nonly trash here.\fHey! The electric\nlocks were reset!"), - function() require("src.core.Sound").play(Game.data, "Denied") end)) + nil, TextBox.soundOpts(Game, "Denied"))) end end @@ -2899,10 +3059,10 @@ function OverworldState:talkTo(npc) if entry then if entry.mart then npc:facePlayer(self.player) - Game.stack:push(TextBox.new(Game, romText(Game.data, "_PokemartGreetingText", "Hi there!\nMay I help you?"), function() - Screens.push(Game, "ShopMenu", entry.mart) - unfreeze() - end)) + -- the greeting stays in the box under the menu, so ShopMenu owns it + -- now -- home/text_script.asm:143 + Screens.push(Game, "ShopMenu", entry.mart) + unfreeze() return end if entry.nurse then @@ -3365,22 +3525,18 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText or (header and header.won and Game.data.text[header.won]) local BattleState = require("src.battle.BattleState") + local stingPlayed = false + -- home/trainers.asm:109 prints, :123 engages (BIT_SEEN_BY_TRAINER = + -- self.engaging), then home/text_script.asm:96 waits for A (#764, #1683) + local function playMeetSting() + if stingPlayed or self.engaging then return end + stingPlayed = true + local theme = meetTrainerTheme(d.trainerClass) + if theme then require("src.core.Music").play(Game.data, theme) end + end local function startBattle(options) self.cancelledTrainerSight = nil - -- TalkToTrainer (home/trainers.asm:88) prints the before-battle text - -- FIRST and only then runs `call EngageMapTrainer` / `jp - -- StartTrainerBattle`, so a trainer challenged on foot gets the sting - -- over the battle transition rather than under the dialogue. Its - -- `bit BIT_SEEN_BY_TRAINER, [hl] / ret nz` guard is self.engaging - -- here: TrainerEngage (engine/overworld/trainer_sight.asm:224) already - -- started the sting before the "!" bubble on the sight path, so it - -- must not restart. Script-driven challenges (gyms.lua leaders, - -- scripts/SilphCo11F.asm:269 Giovanni, scripts/FightingDojo.asm:122) - -- all `call EngageMapTrainer` too, and reach this same path (#764). - if not self.engaging then - local theme = meetTrainerTheme(d.trainerClass) - if theme then require("src.core.Music").play(Game.data, theme) end - end + playMeetSting() local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty, options) battle.checkpointOrigin = { @@ -3451,7 +3607,8 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText if skipBattleText then prepareBattle() else - Game.stack:push(TextBox.new(Game, battleText, prepareBattle)) + Game.stack:push(TextBox.new(Game, battleText, prepareBattle, + { auto = { wait = true, delay = 0, sound = playMeetSting } })) end end @@ -4395,18 +4552,11 @@ function OverworldState:afterBattle(result, battle) Logger.info("battle over: %s (lead %s %d/%d)", tostring(result), lead and lead.species or "-", lead and lead.hp or 0, lead and lead.stats.hp or 0) - local Evolution = require("src.pokemon.Evolution") - local function evolutions() - -- Only mons that gained a level this battle (EXP.ALL included). - -- Scanning the whole party re-offered B-cancelled evolutions forever (#213). - Evolution.checkParty(Game, nil, battle and battle.leveledUp) - end if result == "lose" then local oaksLabRival = battle and battle.oppClass == "OPP_RIVAL1" and self.map and self.map.id == "OAKS_LAB" if oaksLabRival then -- stay in the lab; OaksLabRivalEndBattleScript heals and continues - evolutions() return end -- blackout: revive the party at the last heal point; half the @@ -4419,7 +4569,7 @@ function OverworldState:afterBattle(result, battle) / (FieldDefaults.world(Game.data, "blackoutMoneyDivisor") or 2)) Runtime.emit("world.blacked_out", { save = Game.save, healTarget = self:healPoint() }) - self:warpToHealPoint(evolutions) + self:warpToHealPoint() else -- EndTrainerBattle sets BIT_CUR_MAP_LOADED_1 (home/trainers.asm), which -- re-runs the floor's door callback: beating the last Rocket Hideout guard @@ -4429,7 +4579,6 @@ function OverworldState:afterBattle(result, battle) if Game.save.safari and Game.save.safari.balls <= 0 then self:safariGameOver(Strings("PA: You're out of\nSAFARI BALLs!")) end - evolutions() end end @@ -4858,6 +5007,9 @@ function OverworldState:updateScriptMoves() e.facing = mv.dir local tx, ty = Collision.target(e.cellX, e.cellY, mv.dir) e.targetX, e.targetY = tx, ty + -- a simulated d-pad press runs at the CURRENT walk/bike speed, not + -- whatever the last real step left behind -- home/overworld.asm:276 + if e.stepLength then e.stepFramesCur = e:stepLength() end e.moving = true e.progress = 0 mv.remaining = mv.remaining - 1 @@ -5018,6 +5170,7 @@ function OverworldState:drawWorld() for _, nb in ipairs(self.neighbors) do nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh) end + self:drawShipAnim(cam.x, bgY) end -- per-billboard SGB palette source; only needed (and only paid for) when -- tilting. nil headless / on stale palettes -> billboards go uncolorized. @@ -5374,6 +5527,7 @@ function OverworldState:drawWorld() for _, nb in ipairs(self.neighbors) do nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh) end + self:drawShipAnim(cam.x, bgY) end end diff --git a/src/world/Player.lua b/src/world/Player.lua index 54727a7b..5fd33c47 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -110,6 +110,26 @@ function Player:turnWindow() return frames end +-- the bicycle doubles walking speed (8 frames per step); movement.speed +-- lets a mod multiply or replace that (running shoes, dash, etc.) +-- DoBikeSpeedup is skipped mid-hop -- home/overworld.asm:283 +function Player:stepLength() + local Game = require("src.core.Game") + local save = Game.save + local onBike = (save and save.onBike and not self.ledgeHop) or false + local frames = onBike and self.bikeStepFrames or self.stepFrames or STEP_FRAMES + if Runtime.wantsHook("movement.speed") then + frames = Runtime.call("movement.speed", function(f) return f end, frames, { + onBike = onBike, + surfing = self.surfing and true or false, + player = self, + input = Game.input, + save = save, + }) + end + return math.max(1, math.floor(tonumber(frames) or STEP_FRAMES)) +end + -- Attempt to start a step; returns "moved"|"turned"|"blocked"|nil. function Player:tryMove(dir, map, entities) if self.moving or self.inputLocked then return nil end @@ -145,22 +165,7 @@ function Player:tryMove(dir, map, entities) self.moving = true self.bumpFrames = nil -- a real step supersedes any in-place bonk self.progress = 0 - -- the bicycle doubles walking speed (8 frames per step); movement.speed - -- lets a mod multiply or replace that (running shoes, dash, etc.) - local Game = require("src.core.Game") - local save = Game.save - local frames = (save and save.onBike) and self.bikeStepFrames - or self.stepFrames or STEP_FRAMES - if Runtime.wantsHook("movement.speed") then - frames = Runtime.call("movement.speed", function(f) return f end, frames, { - onBike = save and save.onBike or false, - surfing = self.surfing and true or false, - player = self, - input = Game.input, - save = save, - }) - end - self.stepFramesCur = math.max(1, math.floor(tonumber(frames) or STEP_FRAMES)) + self.stepFramesCur = self:stepLength() return "moved" end diff --git a/src/world/gen2/FieldMoves.lua b/src/world/gen2/FieldMoves.lua index 66f8d57c..97cb0cc9 100644 --- a/src/world/gen2/FieldMoves.lua +++ b/src/world/gen2/FieldMoves.lua @@ -102,8 +102,8 @@ FieldMoves.TEXT = { .. "\fA #MON may be\nable to pass it."), ASK_WHIRLPOOL = Strings.source("A whirlpool is in\nthe way." .. "\fWant to use\nWHIRLPOOL?"), - -- Not a cart line: the stand-in destination prompt World:askFlyPoint uses - -- until the POKeGEAR's MAP card grows _FlyMap's cursor mode. + -- Not a cart line: the prompt World:askFlyPoint falls back to when there is + -- no screen at all to push -- a headless probe, never a real run. ASK_FLY_TO = Strings.source("Fly to %s?"), } @@ -183,6 +183,10 @@ function FieldMoves.bindEngineFlags(order) -- The flag IS wPlayerGender's bit 0, so World routes it to the gender byte -- (data/events/engine_flags.asm:131, constants/engine_flags.asm:121). FieldMoves.FEMALE_FLAG = byName[FieldMoves.FEMALE_FLAG_NAME] + -- Crystal's ENGINE_MOBILE_SYSTEM (constants/engine_flags.asm:25) has no Gold + -- row, so every id from BUG_CONTEST_TIMER up shifts one. + FieldMoves.BUG_CONTEST_FLAG = byName["ENGINE_BUG_CONTEST_TIMER"] or 16 + FieldMoves.BIKE_SHOP_CALL_FLAG = byName["ENGINE_BIKE_SHOP_CALL_ENABLED"] or 19 return flags end diff --git a/src/world/gen2/Npc.lua b/src/world/gen2/Npc.lua index ac21731e..3ad40933 100644 --- a/src/world/gen2/Npc.lua +++ b/src/world/gen2/Npc.lua @@ -31,6 +31,8 @@ local MOVE = { STANDING_LEFT = 8, STANDING_RIGHT = 9, SPINRANDOM_FAST = 10, + -- data/sprites/map_objects.asm:181-187 + POKEMON = 0x16, SPINCOUNTERCLOCKWISE = 0x1e, SPINCLOCKWISE = 0x1f, -- The three rows whose palette-flags byte is `STRENGTH_BOULDER | BIG_OBJECT` @@ -123,6 +125,11 @@ local SPIN_NEXT = { -- each quarter, no Random anywhere in the loop. local SPIN_TURN_FRAMES = 16 +-- SetFacingBounce steps OBJECT_STEP_FRAME once a frame and reads bit 3, so a +-- mon object holds each icon frame for eight -- map_object_action.asm:184-201 +local BOUNCE_PERIOD = 16 +local BOUNCE_HALF = 8 + local function rand(a, b) if love and love.math and love.math.random then return love.math.random(a, b) @@ -252,6 +259,8 @@ function NPC.new(mapId, objDef, spriteDef) bigObject = BIG_OBJECT[movement] == true, bigFacing = NPC.bigFacing(movement, spriteDef and spriteDef.id), fixedFacing = FIXED_FACING_MOVE[movement] or nil, + bouncing = movement == MOVE.POKEMON or nil, + bounceStep = 0, timer = rand(30, 120), sprite = SpriteRenderer.new(spriteDef, string.format("%s_obj_%d", mapId, objDef.index or 0)), -- The sheet is grayscale and carries no alpha; PAL_OW_* crossed with the @@ -501,6 +510,14 @@ function NPC:walkPhase() return (p >= frames / 4 and p < frames * 3 / 4) and 1 or 0 end +-- OBJECT_ACTION_BOUNCE's two columns, SetFacingBounce and +-- SetFacingFreezeBounce -- engine/overworld/map_object_action.asm:184-201 +function NPC:bounceFrame() + if not self.bouncing then return nil end + if self.frozen then return 0 end + return ((self.bounceStep or 0) >= BOUNCE_HALF) and 1 or 0 +end + -- Gen 1's seven-value entity pose (src/world/NPC.lua:124), on the class so a -- mod poses the object it is FOLLOWING, not only one it built itself. function NPC:pose() @@ -522,6 +539,9 @@ function NPC:update(map, entities) self.spawnLatched = true self.inGrass = NPC.grassAt(map, self.cellX, self.cellY) end + if self.bouncing and not self.frozen then + self.bounceStep = ((self.bounceStep or 0) + 1) % BOUNCE_PERIOD + end -- The teleport step type owns the object outright (it replaces -- STEP_TYPE_FROM_MOVEMENT until its last beat), so it runs above the frozen -- gate the way the walk interpolation does. @@ -763,7 +783,8 @@ function NPC:draw(ox, oy, scale) else self.sprite:draw( self.px, self.py + yOffset, 0, 0, - self.facing, self:walkPhase(), self.stepFlip) + self.facing, self:walkPhase(), self.stepFlip, + false, false, self:bounceFrame()) end G.pop() end diff --git a/src/world/gen2/Player.lua b/src/world/gen2/Player.lua index 4e4153fe..5a0734e4 100644 --- a/src/world/gen2/Player.lua +++ b/src/world/gen2/Player.lua @@ -23,6 +23,19 @@ local JUMP_Y = { -11, -10, -9, -8, -6, -4, 0, 0, } +-- FacingFish*'s loose rod OAM, offset from the sprite's top-left, and which +-- 8x8 of the sheet's rod row it draws (data/sprites/facings.asm:122-152). +local ROD_OAM = { + down = { dx = 0, dy = 16, tile = 0 }, + up = { dx = 0, dy = -8, tile = 0 }, + left = { dx = -8, dy = 5, tile = 1, flip = true }, + right = { dx = 16, dy = 5, tile = 1 }, +} + +-- The sheet row LoadFishingGFX lays over each standing frame's bottom tiles +-- (engine/events/fishing_gfx.asm:2-20). +local FISH_ROW = { down = 0, up = 1, left = 2, right = 2 } + function Player.new(cx, cy, facing, spriteDef) local self = setmetatable({ cellX = cx, cellY = cy, @@ -138,6 +151,17 @@ function Player:scriptStep(dir) return true end +-- CounterclockwiseSpinAction's .facings, seeded from the current direction by +-- Movement_step_dig -- map_object_action.asm:96-152, movement.asm:113-116 +local SPIN_FACINGS = { "down", "right", "up", "left" } +local SPIN_START = { down = 0, right = 1, up = 2, left = 3 } + +function Player:scriptSpin(frames) + if not frames or frames <= 0 then return end + self.spinFrames = frames + self.spinTimer = (SPIN_START[self.facing] or 0) * 4 +end + -- Gen 1's name for the cell being faced (src/world/Player.lua), so a mod that -- wraps World:interact asks one question of either generation. function Player:facingCell() @@ -158,6 +182,11 @@ function Player:update() if self.turnTimer > 0 then self.turnTimer = self.turnTimer - 1 end + if self.spinFrames then + self.spinTimer = (self.spinTimer or 0) + 1 + self.spinFrames = self.spinFrames - 1 + if self.spinFrames <= 0 then self.spinFrames = nil end + end if not self.moving then -- Re-arm turn-in-place once a poll finds no held direction (caller -- clears this while a dir is held; we only set it from idle). @@ -170,17 +199,27 @@ function Player:update() -- and the facing-delta math walked only half of it, leaving the sprite a -- cell behind where the grid said the player was. local frames = self.stepFrames or STEP_FRAMES - local adv = math.floor(self.progress * 16 / frames) local dx = (self.targetX or self.cellX) - self.cellX local dy = (self.targetY or self.cellY) - self.cellY - self.px = self.cellX * 16 + dx * adv - self.py = self.cellY * 16 + dy * adv + -- engine/overworld/map_objects.asm:331 -- AddStepVector moves the object + -- every frame, so the span is the whole move, not one cell scaled by dx. + local span = math.max(math.abs(dx), math.abs(dy), 1) + local adv = math.floor(self.progress * 16 * span / frames) + self.px = self.cellX * 16 + (dx / span) * adv + self.py = self.cellY * 16 + (dy / span) * adv if self.jumping then - -- engine/overworld/map_objects.asm:1815 - local idx = math.floor((self.progress - 1) / 2) + 1 + -- engine/overworld/map_objects.asm:1796 -- one table entry per cart frame, + -- tweened across our doubled step (#1713) + local t = (self.progress - 1) * (#JUMP_Y - 1) + / math.max(frames - 1, 1) + 1 + local idx = math.floor(t) if idx < 1 then idx = 1 end - if idx > #JUMP_Y then idx = #JUMP_Y end - self.spriteYOffset = JUMP_Y[idx] + if idx >= #JUMP_Y then + self.spriteYOffset = JUMP_Y[#JUMP_Y] + else + self.spriteYOffset = math.floor( + JUMP_Y[idx] + (JUMP_Y[idx + 1] - JUMP_Y[idx]) * (t - idx) + 0.5) + end end if self.progress >= frames then self.cellX, self.cellY = self.targetX, self.targetY @@ -195,12 +234,36 @@ function Player:update() return false end +-- FacingFishDown/Up/Left/Right: the standing frame's bottom tile row swapped +-- for the fishing sheet, plus the loose rod tile -- facings.asm:122-152 (#1708) +function Player:drawFishing(yOffset) + local sprite = self.sprite + local py = self.py + yOffset + local facing = self.facing + sprite:draw(self.px, py, 0, 0, facing, 0, false, true) + if not self.fishQuads then + self.fishQuads = { pose = {}, rod = {} } + for i = 0, 2 do + self.fishQuads.pose[i] = love.graphics.newQuad(0, i * 8, 16, 8, 16, 32) + end + for i = 0, 1 do + self.fishQuads.rod[i] = love.graphics.newQuad(i * 8, 24, 8, 8, 16, 32) + end + end + local sx, sy = sprite:getScreenOrigin(self.px, py, 0, 0) + sprite:drawTile(self.fishSheet, sx, + sy + math.max(0, sprite.frameHeight - 8), facing == "right", + self.fishQuads.pose[FISH_ROW[facing] or 0]) + local oam = ROD_OAM[facing] or ROD_OAM.down + sprite:drawTile(self.fishSheet, sx + oam.dx, sy + oam.dy, oam.flip, + self.fishQuads.rod[oam.tile]) +end + function Player:draw(ox, oy, scale) local G = love.graphics -- OBJECT_SPRITE_Y_OFFSET: added to the OBJ's y as it is written to OAM, so -- it moves the sprite without moving the player off the tile they are - -- standing on. StepFunction_GotBite's `xor 1` rod bob and the fly take-off - -- lift both ride this one byte. + -- standing on. StepFunction_GotBite's `xor 1` rod bob rides this one byte. local yOffset = self.spriteYOffset or 0 if self.jumping then -- engine/overworld/map_objects.asm:1995 @@ -217,9 +280,18 @@ function Player:draw(ox, oy, scale) G.scale(scale, scale) -- Chris is PAL_OW_RED; World:applyPalettes keeps the SpriteRenderer's -- OBJ palette current. - self.sprite:draw( - self.px, self.py + yOffset, 0, 0, - self.facing, self:walkPhase(), self.stepFlip) + if self.fishing and self.fishSheet then + self:drawFishing(yOffset) + else + local facing, phase = self.facing, self:walkPhase() + -- OBJECT_ACTION_SPIN (map_object_action.asm:96-152), for step_dig. + if self.spinFrames then + facing = SPIN_FACINGS[math.floor(self.spinTimer / 4) % 4 + 1] + phase = 0 + end + self.sprite:draw( + self.px, self.py + yOffset, 0, 0, facing, phase, self.stepFlip) + end G.pop() return end diff --git a/src/world/gen2/StepEvents.lua b/src/world/gen2/StepEvents.lua index 228b8213..7010ea29 100644 --- a/src/world/gen2/StepEvents.lua +++ b/src/world/gen2/StepEvents.lua @@ -23,7 +23,7 @@ -- -- Everything here is love-free and takes its state as arguments so the whole -- chain is testable without a world. -local Bike = require("src.world.gen2.Bike") +local FieldMoves = require("src.world.gen2.FieldMoves") local Breeding = require("src.core.gen2.Breeding") local Happiness = require("src.core.gen2.Happiness") local Phone = require("src.core.gen2.Phone") @@ -118,7 +118,7 @@ end local function bikeShopCallEnabled(save) local flags = save.engineFlags if type(flags) == "table" then - local set = flags[Bike.ENGINE_BIKE_SHOP_CALL_ENABLED] + local set = flags[FieldMoves.BIKE_SHOP_CALL_FLAG] if set ~= nil then return set == true end end return save.bikeShopCall == true @@ -137,7 +137,7 @@ function StepEvents.bikeStep(save, opts) Phone.queueSpecialCall(save, Phone.SPECIALCALL.SPECIALCALL_BIKESHOP) -- `res STATUSFLAGS2_BIKE_SHOP_CALL_F`: one call, ever. if type(save.engineFlags) == "table" then - save.engineFlags[Bike.ENGINE_BIKE_SHOP_CALL_ENABLED] = nil + save.engineFlags[FieldMoves.BIKE_SHOP_CALL_FLAG] = nil end save.bikeShopCall = false return true diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index df18d937..13a3f101 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -56,6 +56,7 @@ local Roamers = require("src.core.gen2.Roamers") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local Sound = require("src.core.Sound") +local SpriteRenderer = require("src.render.SpriteRenderer") local StepEvents = require("src.world.gen2.StepEvents") local Tilt = require("src.render.Tilt") local Strings = require("src.core.Strings") @@ -85,6 +86,7 @@ local SFX = { EXIT_BUILDING = 35, JUMP_OVER_LEDGE = 0x16, BUMP = 0x24, + FLY = 0x18, } local EMOTE_SHOCK = 0 @@ -757,6 +759,15 @@ function World:load() self.tilesets = tilesets self.roofs = self:dataTable("gen2Roofs", "data/generated/roofs.lua") self.sprites = self:dataTable("gen2Sprites", "data/generated/sprites.lua") + -- A pre-#1748 cache stamps a SpriteMons row `frames = 1`, leaving + -- OBJECT_ACTION_BOUNCE one frame -- engine/overworld/map_object_action.asm:184 + for _, def in pairs(self.sprites or {}) do + if type(def) == "table" and (def.frames or 1) < 2 + and type(def.source) == "string" + and def.source:find("^ROM:SpriteMons") then + def.frames = 2 + end + end -- A cache from before the palette stage existed simply has no palettes.lua; -- everything below falls back to the grayscale path rather than failing. self.palettes = self:dataTable("gen2Palettes", "data/generated/palettes.lua") @@ -800,6 +811,14 @@ function World:load() local okImg, img = pcall(Assets.image, emotes.grassRustle) if okImg then self.grassRustleImage = img end end + -- LoadFishingGFX's two sheets + -- (../pokecrystal/engine/events/fishing_gfx.asm:7-12) + if emotes.fishing and pcall(Assets.image, emotes.fishing) then + self.fishingSheet = emotes.fishing + end + if emotes.fishingFemale and pcall(Assets.image, emotes.fishingFemale) then + self.fishingSheetFemale = emotes.fishingFemale + end end -- The heal machine's two OBJ tiles and their CGB palette, for the -- Pokecenter light show (World:startHealMachineAnim). A cache from before @@ -1427,6 +1446,9 @@ function World:busy() -- and the waterfall climb are all applymovement / pause commands inside a -- queued script, so nothing else may run under them. or self.fieldMove ~= nil + -- FlyFromAnim and FlyToAnim are blocking `callasm`s inside .FlyScript + -- (engine/events/overworld.asm:599, :605). + or self.flyAnim ~= nil end -- CheckMenuOW (engine/overworld/events.asm:802) is the tail of OWPlayerInput, @@ -1434,12 +1456,15 @@ end -- away while wScriptRunning is non-zero (events.asm:238-243). Two more gates -- sit above it even with no script up: PlayerMovement answering -- PLAYERMOVEMENT_CONTINUE, i.e. the player is mid-step (events.asm:474-477), --- and CheckStandingOnIce carrying (events.asm:479-480). START and SELECT are --- read NOWHERE else in the overworld, so a press that arrives while any of --- those hold is not queued or deferred, it is never read at all. +-- and CheckStandingOnIce carrying (events.asm:479-480). The frame a step is +-- QUEUED answers PLAYERMOVEMENT_FINISH instead (player_movement.asm:455-461), +-- which is zero, so the poll still runs there -- and on the Cycling Road's +-- forced roll that landing frame is the only one there is (#1718). function World:acceptsMenuInput() if self.battleActive or self:busy() then return false end - if self.player and self.player.moving then return false end + if self.player and self.player.moving and not self.stepFinished then + return false + end -- The same latch pair World:step's slide uses: a latched direction on an ice -- tile is CheckStandingOnIce's carry. if self.turningDirection and Permissions.isIce(self:playerCollision()) then @@ -1695,7 +1720,7 @@ function World:engineFlag(flag) -- how the two would come apart -- the officer's `setflag` and the results -- script's `clearflag` are the only writers, and both go through the pair -- below. - if flag == BugContest.ENGINE_BUG_CONTEST_TIMER and save then + if flag == FieldMoves.BUG_CONTEST_FLAG and save then return BugContest.isActive(save) end -- Badges live in save.player.badges, not in the flag table: on the cart the @@ -1736,7 +1761,7 @@ end function World:setEngineFlag(flag, value) if flag == nil then return end local save = self.game and self.game.save - if flag == BugContest.ENGINE_BUG_CONTEST_TIMER and save then + if flag == FieldMoves.BUG_CONTEST_FLAG and save then -- Route35NationalParkGate_OkayToProceed sets the flag BEFORE `special -- GiveParkBalls`, so starting here and starting again there is the cart's -- own order and the second start is what puts the balls on the counter. @@ -2042,7 +2067,7 @@ function World:breedmonSpriteDef(species) local def = { id = "SPRITE_DAY_CARE_MON", image = entry.image, - frames = 1, + frames = 2, walker = false, spriteType = "POKEMON_SPRITE", palette = "PAL_OW_RED", @@ -2311,6 +2336,9 @@ end -- rang the ordinary item jingle while the item argument was thrown away. An -- item the cache cannot name takes the `cp TM_HM / jr z` fall-through, SFX.ITEM. function World:specialSound(itemIndex) + -- The `waitsfx` above it (scripting.asm:445): SFX_READ_TEXT_2 ($08), which + -- the box rings on its own press, outranks SFX_GET_TM ($9b) here (#1483). + Sound.waitSfxDone() local id = itemIndex and self:itemIdByIndex(itemIndex) local items = self.game and self.game.data and self.game.data.items local def = id and items and items[id] @@ -3482,14 +3510,6 @@ function World:updateMapSetup() if ms.phase == "out" then ms.step = ms.step + 1 self.fade, self.fadeLevel = "white", ms.step / FADE_STEPS - -- FlyFromAnim carries the player up and off the map under the fade. The - -- bird's own frames are not in the cache, but the lift is: it is the same - -- OBJECT_SPRITE_Y_OFFSET sine the teleport step type walks - -- (src/script/gen2/Movement.lua), stepped over the fade's four levels. - if ms.lift and self.player then - self.player.spriteYOffset = Movement.teleportYOffset( - Movement.TELEPORT_RISE_HEIGHT + ms.step * FADE_STEPS) - end if ms.step >= FADE_STEPS then -- setMap clears self.fade (a map load repaints everything), so the sheet -- has to be re-armed at full strength on the far side for the fade in to @@ -3501,16 +3521,12 @@ function World:updateMapSetup() return end ms.step = ms.step - 1 - -- FlyToAnim, the same curve read backwards: the player comes down onto the - -- destination tile as the fade lets go of the screen. - if ms.lift and self.player then - self.player.spriteYOffset = Movement.teleportYOffset( - Movement.TELEPORT_RISE_HEIGHT + ms.step * FADE_STEPS) - end if ms.step <= 0 then self.fade, self.fadeLevel = nil, nil self.mapSetup = nil - if ms.lift and self.player then self.player.spriteYOffset = 0 end + -- `callasm FlyToAnim` is the command straight after `newloadmap + -- MAPSETUP_TELEPORT` (engine/events/overworld.asm:604-605). + if ms.flyIn then self:startFlyAnim("to", ms.flyIn) end return end self.fadeLevel = ms.step / FADE_STEPS @@ -3865,6 +3881,15 @@ function World:updateMovement() while st.i <= #st.bytes do local b = st.bytes[st.i] st.i = st.i + 1 + -- Movement_step_dig spins for the frames in the byte that follows + -- -- engine/overworld/movement.asm:113-131 (#1716) + if b == Movement.STEP_DIG then + local duration = st.bytes[st.i] or 0 + st.i = st.i + 1 + if ent.scriptSpin then ent:scriptSpin(duration) end + st.sleep = duration + return + end -- engine/overworld/movement.asm:163 if b == 0x57 then local duration = st.bytes[st.i] or 0 @@ -3890,6 +3915,11 @@ function World:updateMovement() elseif act.kind == "step" then local fromX, fromY = ent.cellX, ent.cellY if ent:scriptStep(act.dir) then + -- TurningStep's OBJECT_ACTION_SPIN, for the length of the step + -- (engine/overworld/movement.asm:693-699). + if act.spin and ent.scriptSpin then + ent:scriptSpin(ent.stepFrames or 16) + end self:followStep(ent, fromX, fromY) end return @@ -4797,15 +4827,35 @@ end -- src/world/gen2/Bike.lua owns every decision; this is the world state those -- decisions read and the presentation they end in. +-- ../pokecrystal/constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM shifts every +-- later id up one: :36 ALWAYS_ON_BIKE against ../pokegold's :35. +function World:engineFlagId(name, goldId) + local order = self.constants and self.constants.engineFlagOrder + if type(order) ~= "table" then return goldId end + local ids = self.engineFlagIds + if not ids then + ids = {} + for index, entry in pairs(order) do + if type(index) == "number" and type(entry) == "string" then + ids[entry] = index - 1 + end + end + self.engineFlagIds = ids + end + return ids[name] or goldId +end + -- wBikeFlags' three bits are ENGINE_* ids like any other flag, so the map -- callbacks that set them (Route16AlwaysOnBikeCallback, -- Route17AlwaysOnBikeCallback) already land on save.engineFlags. function World:alwaysOnBike() - return self:engineFlag(Bike.ENGINE_ALWAYS_ON_BIKE) + return self:engineFlag(World.engineFlagId( + self, "ENGINE_ALWAYS_ON_BIKE", Bike.ENGINE_ALWAYS_ON_BIKE)) end function World:downhill() - return self:engineFlag(Bike.ENGINE_DOWNHILL) + return self:engineFlag(World.engineFlagId( + self, "ENGINE_DOWNHILL", Bike.ENGINE_DOWNHILL)) end -- GetPlayerTilePermission's operand: the collision under the player's feet. @@ -4963,6 +5013,11 @@ function World:beginFishing(outcome, wild) if self.player then self.player.fishing = true self.player.fishingState = self.fishing + -- LoadFishingGFX reads wPlayerGender + -- (../pokecrystal/engine/events/fishing_gfx.asm:8-12) + self.player.fishSheet = + (FieldMoves.isFemale(self:playerGender()) and self.fishingSheetFemale) + or self.fishingSheet end end @@ -5345,6 +5400,9 @@ function World:fieldContext(mon) -- digFromMenu reads this rather than re-deriving the banked warp. canEscapeRope = self:escapeRopeTarget() ~= nil, playerState = self.playerState, + -- SurfFunction.TrySurf and TrySurfOW both refuse while wBikeFlags' + -- ALWAYS_ON_BIKE is set (engine/events/overworld.asm:343-345, :498-500). + alwaysOnBike = self:alwaysOnBike(), strengthActive = self.strengthActive, -- FlashFunction tests wTimeOfDayPalset, not the map header, so a -- PALETTE_DARK map that FLASH has already lit refuses a second FLASH. @@ -5524,15 +5582,34 @@ function World:runCut(result) end -- Script_UsedWhirlpool, which is Script_Cut with DisappearWhirlpool and --- PlayWhirlpoolSound (a bare SFX.SURF) in place of the snip. +-- PlayWhirlpoolSound in place of the snip. function World:runWhirlpool(result) self:setNickname(result.mon) self:showText(Strings(result.text), function() self:replaceBlock(result.blockIndex, result.replacement) - self:playSfx(SFX.SURF) + self:playWhirlpoolSound() end) end +-- PlayWhirlpoolSound is WaitSFX, SFX_SURF, WaitSFX, never a bare PlaySFX +-- -- engine/events/field_moves.asm:5-10 (#1717) +function World:playWhirlpoolSound() + self.fieldMove = { phase = "whirlpoolsfx", waiting = true, left = 180 } +end + +-- PlayerMovementPointers' .force_turn arm and the Script_ForcedMovement it +-- calls -- events.asm:786-793, forced_movement.asm:1-51 (#1716) +local FORCED_BACK = { up = "down", down = "up", left = "right", right = "left" } + +function World:runForcedMovement() + local p = self.player + if not p or p.moving or self.moveState then return false end + local back = FORCED_BACK[p.facing] + if not back then return false end + self:beginMovement(0, Movement.forcedMovementBytes(back)) + return true +end + -- Script_UseFlash: the text plays SFX.FLASH from inside itself -- (UseFlashTextScript's text_asm), and BlindingFlash then sets -- STATUSFLAGS_FLASH_F and reloads the palettes. Setting the flag is all there @@ -5685,7 +5762,7 @@ function World:runFieldMove(result) elseif action == "waterfall" then self:runWaterfall(result) elseif action == "fly" then - self:openFlyMap() + self:openFlyMap(result.mon) elseif action == "headbutt" then self:runHeadbutt(result.facingX, result.facingY, result.mon) elseif action == "sweetscent" then @@ -5869,25 +5946,138 @@ function World:flyPoints() self.game and self.game.save, self.landmarks, self:region()) end --- .FlyScript: WarpToSpawnPoint, then `newloadmap MAPSETUP.TELEPORT` brings the --- map up with the player back in PLAYER_NORMAL. MapSetupScript_Teleport opens --- on FadeOutToWhite and falls through into _Warp, so flying is bracketed by the --- same pair of fades a door is, which is where the two fly animations ride: --- `lift` below hands them to World:updateMapSetup. -function World:flyTo(spawnId) +-- FlyFromAnim / FlyToAnim (engine/events/field_moves.asm:300, :334) and the two +-- curves they run (engine/sprite_anims/functions.asm:1350, :1418). +local FLY = { + FROM_FRAMES = 128, TO_FRAMES = 64, HOVER = 0x40, + AMP_MAX = 0x40, TO_AMP = 11 * 8, RISE = 84, +} + +-- 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) + if type(mon) ~= "table" then return nil end + local data = self.game and self.game.data + local icons = data and data.gen2Icons + local iconId = mon.isEgg and "ICON_EGG" + or (icons and icons.species and mon.species + and icons.species[mon.species]) + local entry = iconId and icons and icons.icons and icons.icons[iconId] + if not (entry and entry.image) then return nil end + local def = { + id = "SPRITE_FLY_MON", image = entry.image, frames = 2, walker = false, + spriteType = "POKEMON_SPRITE", palette = "PAL_OW_RED", paletteId = 0, + species = mon.species, icon = iconId, + } + local ok, icon = pcall(SpriteRenderer.new, def, "gen2fly") + if not (ok and icon) then return nil end + local daytime = self.daytime or Palettes.daytimeFor( + self.map and self.map.def, self:hour(), self.flashUsed) + local colors = Palettes.spritePalette(self.palettes, daytime, def) + if colors then + icon:setObjPalette(colors, ("gen2:%s:0"):format(tostring(daytime))) + end + return icon +end + +-- False when there is no icon sheet (or no love at all): the caller then flies +-- the way it always did rather than parking the world on an animation. +function World:startFlyAnim(phase, mon, onDone) + local icon = self:flyIconFor(mon) + local p = self.player + if not (icon and p) then return false end + local landing = phase == "to" + self.flyAnim = { + phase = phase, icon = icon, onDone = onDone, t = 0, + px = p.px, py = p.py, xoff = 0, wave = 0, + left = landing and FLY.TO_FRAMES or FLY.FROM_FRAMES, + hover = landing and 0 or FLY.HOVER, + amp = landing and FLY.TO_AMP or 0, + y = landing and -FLY.RISE or 0, + } + return true +end + +-- FlyFunction_FrameTimer (engine/events/field_moves.asm:409) over the two +-- AnimSeq_Fly* curves; the wobble is Sprites_Cosine's d * cos(n * pi / 32). +function World:stepFlyAnim() + local fa = self.flyAnim + if not fa then return end + local left = fa.left + if left <= 0 then + local done = fa.onDone + self.flyAnim = nil + if done then done() end + return + end + fa.left = left - 1 + if left >= 0x40 and left % 8 == 0 then + self:playSfxNamed("Sfx_Fly", SFX.FLY) + end + fa.t = fa.t + 1 + local amp = fa.amp + if fa.phase == "to" then + if fa.y >= 0 then return end + fa.y = fa.y + 2 + if amp > 0 then fa.amp = amp - 2 end + else + if fa.hover > 0 then + fa.hover = fa.hover - 1 + return + end + if fa.y <= -FLY.RISE then return end + fa.y = fa.y - 2 + if amp < FLY.AMP_MAX then fa.amp = amp + 8 end + end + fa.xoff = math.floor(amp * math.cos((fa.wave % 64) * math.pi / 32)) + fa.wave = fa.wave + 1 +end + +-- .Frameset_RedWalk is two 8-frame icon beats, the fourth mirrored +-- -- data/sprite_anims/framesets.asm:81-86 +function World:drawFlyAnim(s, billboard) + local fa = self.flyAnim + if not (fa and fa.icon) then return end + local G = love.graphics + local cam = self.camera + local px = fa.px + fa.xoff + local py = fa.py + fa.y + local ox = math.floor((0 - cam.x) * s) + local oy = math.floor((0 - cam.y) * s) + local beat = math.floor(fa.t / 8) % 4 + local function body() + G.setColor(1, 1, 1, 1) + G.push() + G.translate(ox, oy) + G.scale(s, s) + fa.icon:draw(px, py, 0, 0, "down", 0, false, false, beat == 3, beat % 2) + G.pop() + end + if billboard then + billboard(ox + (px + 8) * s, oy + (py + 16) * s, body) + else + body() + end +end + +-- .FlyScript: FlyFromAnim, WarpToSpawnPoint, `newloadmap MAPSETUP_TELEPORT`, +-- then FlyToAnim -- engine/events/overworld.asm:595-609 +function World:flyTo(spawnId, mon) local spawn = self.landmarks and self.landmarks.spawns and self.landmarks.spawns[spawnId] if not (spawn and spawn.map and self.maps and self.maps[spawn.map]) then return false end - self:applyPlayerState(FieldMoves.PLAYER_NORMAL) - local ok = self:runMapSetup(MAPSETUP.TELEPORT, function() - return self:setMap(spawn.map, spawn.x, spawn.y, "down") - end) - -- FlyFromAnim / FlyToAnim ride the setup script's own two fades: the take-off - -- lift under the fade out, the landing under the fade in. - if self.mapSetup then self.mapSetup.lift = true end - return ok + local function warp() + self:applyPlayerState(FieldMoves.PLAYER_NORMAL) + local ok = self:runMapSetup(MAPSETUP.TELEPORT, function() + return self:setMap(spawn.map, spawn.x, spawn.y, "down") + end) + if self.mapSetup then self.mapSetup.flyIn = mon end + return ok + end + if self:startFlyAnim("from", mon, warp) then return true end + return warp() end -- _FlyMap: the town map with the cursor locked to visited flypoints, A takes @@ -5898,7 +6088,7 @@ end -- "Where?" plate over it instead of the card strip. A run with no love at all -- (a headless probe) has no screen to push, so the destinations are offered -- one at a time through the same yesorno box every other field move uses. -function World:openFlyMap() +function World:openFlyMap(mon) local points = self:flyPoints() if #points == 0 then return false end -- Loaded on demand and through pcall: a headless run has no love, and this @@ -5909,28 +6099,31 @@ function World:openFlyMap() save = self.game.save, currentLandmark = self:currentLandmarkId(), fly = points, + -- TownMapMon draws wCurPartyMon's icon as the cursor + -- (../pokecrystal/engine/pokegear/pokegear.asm:2708-2721). + flyMon = mon, onFly = function(spawnId) self.game.stack:pop() - self:flyTo(spawnId) + self:flyTo(spawnId, mon) end, onClose = function() self.game.stack:pop() end, }) return true end - self:askFlyPoint(points, 1) + self:askFlyPoint(points, 1, mon) return true end -function World:askFlyPoint(points, index) +function World:askFlyPoint(points, index, mon) local row = points[index] if not row then return end local name = (row.name or row.landmark):gsub("\n", " ") self:showText(Strings(FieldMoves.TEXT.ASK_FLY_TO, name), function() self:askYesNo(function(yes) if yes then - self:flyTo(row.spawn) + self:flyTo(row.spawn, mon) else - self:askFlyPoint(points, index + 1) + self:askFlyPoint(points, index + 1, mon) end end) end) @@ -5958,6 +6151,18 @@ function World:updateFieldMove() st.timer = st.timer - 1 return end + if st.phase == "whirlpoolsfx" then + st.left = (st.left or 0) - 1 + if st.waiting then + if Sound.sfxBusy() and st.left > 0 then return end + st.waiting = nil + self:playSfxNamed("Sfx_Surf", SFX.SURF) + return + end + if Sound.sfxBusy() and st.left > 0 then return end + self.fieldMove = nil + return + end if st.phase == "strength" then self.fieldMove = nil if st.text then self:showText(Strings(st.text)) end @@ -6056,6 +6261,9 @@ function World:startBattle(opts, onDone) -- wInBattleTowerBattle (../pokecrystal/engine/events/battle_tower/ -- battle_tower.asm:220-223), which turns DoBadgeTypeBoosts off. battleTower = opts.battleTower, + -- wTimeOfDay, for BattleCommand_TimeBasedHealContinue + -- (engine/battle/effect_commands.asm:6401-6404). + timeOfDay = self:timeOfDayId(), }) self:playBattleMusic(opts) local function pushBattle() @@ -8639,8 +8847,10 @@ function World:setMap(mapId, cx, cy, facing, opts) -- MAPCALLBACK_NEWMAP runs, because the Cycling Road's callback is what sets -- them straight back again: leaving them set is how one visit to Route 17 -- would keep the player glued to the bike for the rest of the game. - self:setEngineFlag(Bike.ENGINE_ALWAYS_ON_BIKE, false) - self:setEngineFlag(Bike.ENGINE_DOWNHILL, false) + self:setEngineFlag(World.engineFlagId( + self, "ENGINE_ALWAYS_ON_BIKE", Bike.ENGINE_ALWAYS_ON_BIKE), false) + self:setEngineFlag(World.engineFlagId( + self, "ENGINE_DOWNHILL", Bike.ENGINE_DOWNHILL), false) -- "Respawn in Pokemon Centers" (home/map.asm, LoadMapAttributes' .SetSpawn): -- walking from an OUTDOOR map into an INDOOR one whose tileset is -- TILESET_POKECENTER rewrites wLastSpawnMapGroup / wLastSpawnMapNumber, and @@ -9741,6 +9951,7 @@ end function World:stepBody() if not self.map or not self.player then return end + self.stepFinished = false self:pollTimeOfDay() -- ShakeScreen and the `musicfadeout` tail both run UNDER a script (the VM is -- parked on the earthquake's own waitFrames while the screen is still @@ -9821,6 +10032,7 @@ function World:stepBody() -- it ticks here above the input gate the same way the emote does; its -- last flash's onDone is what resumes the nurse. if self.healAnim then self:stepHealAnim() end + if self.flyAnim then self:stepFlyAnim() end -- HandleCmdQueue sits in the overworld loop, once a frame, above the input -- gate: it is what drops a boulder that is already sitting on a hole, and it @@ -9841,8 +10053,9 @@ function World:stepBody() -- Freeze player input while a script / textbox / cutscene move is up. if self:busy() then - -- Keep scripted entities animating mid-step. - if self.player and self.player.moving then + -- Keep scripted entities animating mid-step. A step_dig spin has the + -- player standing still, so it has to tick here too. + if self.player and (self.player.moving or self.player.spinFrames) then self:playerStepGrass() if self.player:update() then self.player.inGrass = @@ -9856,6 +10069,7 @@ function World:stepBody() local p = self.player self:playerStepGrass() local landed = p:update() + self.stepFinished = landed -- CopyCoordsTileToLastCoordsTile -> SetTallGrassFlags, which is what a step -- ENDS on (engine/overworld/map_objects.asm:196-208, :247). if landed then p.inGrass = self:grassAt(p.cellX, p.cellY) end @@ -9917,6 +10131,11 @@ function World:stepBody() local dir = self.heldDir if not p.moving then local coll = self:playerCollision() + -- .CheckTile tests CheckWhirlpoolTile above the nybble ladder + -- -- engine/overworld/player_movement.asm:117-123 (#1716) + if Permissions.isWhirlpool(coll) and self:runForcedMovement() then + return + end local current = Permissions.currentDirection(coll) or Permissions.doorForcedDirection(coll) if current then @@ -10030,18 +10249,21 @@ function World:drawPeople(s, billboard) local G = love.graphics local p = self.player local cam = self.camera - local drawList = { - { kind = "player", py = p.py, ox = 0, oy = 0 }, - } - for _, npc in ipairs(self.npcs) do - drawList[#drawList + 1] = { - kind = "npc", npc = npc, ox = 0, oy = 0, py = npc.py, - } - end - for _, g in ipairs(self.ghosts) do - drawList[#drawList + 1] = { - kind = "npc", npc = g.npc, ox = g.ox, oy = g.oy, py = g.oy + g.npc.py, - } + -- .FlyScript hides the map's objects from HideSprites until its + -- LoadWalkingSpritesGFX tail -- engine/events/overworld.asm:597, :608 + local drawList = {} + if not self.flyAnim then + drawList[1] = { kind = "player", py = p.py, ox = 0, oy = 0 } + for _, npc in ipairs(self.npcs) do + drawList[#drawList + 1] = { + kind = "npc", npc = npc, ox = 0, oy = 0, py = npc.py, + } + end + for _, g in ipairs(self.ghosts) do + drawList[#drawList + 1] = { + kind = "npc", npc = g.npc, ox = g.ox, oy = g.oy, py = g.oy + g.npc.py, + } + end end table.sort(drawList, function(a, b) return a.py < b.py end) @@ -10076,6 +10298,7 @@ function World:drawPeople(s, billboard) self:drawEmote(s, billboard) self:drawHealAnim(s, billboard) + self:drawFlyAnim(s, billboard) end -- Split out of drawPeople so World:drawPipeline composites the one copy the @@ -10143,11 +10366,12 @@ function World:drawPipeline(id, w, h, s) -- the art: nil, like Gen 1 returns in its true-colour modes. paletteFor = function() return nil end, spriteColors = function() return nil end, - -- Gold's only standing effects; it has no dust/cutTree/bird/rod overlay, - -- and Gen 1's `at` skips a nil body, so those keys are simply absent. + -- Gold's only standing effects; it has no dust/cutTree/rod overlay, and + -- Gen 1's `at` skips a nil body, so those keys are simply absent. fx = { emote = function() self:drawEmote(1, nil) end, heal = function() self:drawHealAnim(1, nil) end, + bird = function() self:drawFlyAnim(1, nil) end, }, } -- `project(wx, wy)` -> canvas pixels, nil behind the camera. s = 1 lays the @@ -10165,6 +10389,7 @@ function World:drawPipeline(id, w, h, s) end self:drawEmote(1, at) self:drawHealAnim(1, at) + self:drawFlyAnim(1, at) end local override = Pipelines.drawWorld(id, ctx) -- world post-processes fold in here, so they never touch the text box on top diff --git a/tests/drivers/bag_cancel_row_bug1685_test.lua b/tests/drivers/bag_cancel_row_bug1685_test.lua new file mode 100644 index 00000000..cc5c7e05 --- /dev/null +++ b/tests/drivers/bag_cancel_row_bug1685_test.lua @@ -0,0 +1,174 @@ +-- Eye check on the CANCEL row at the foot of the item list (#1685). +-- PrintListMenuEntries prints ListMenuCancelText on the $ff terminator and +-- returns there (pokered home/list_menu.asm:371-372, 523-528), so the '▼' +-- at :518-522 never shares a page with CANCEL; DisplayListMenuIDLoop treats +-- that row as ExitListMenu (:105-110). No POKEPORT_SPEED: the arrow and +-- the cursor step are being judged frame by frame. +-- POKEPORT_DRIVER=tests/drivers/bag_cancel_row_bug1685_test.lua POKEPORT_IDENTITY=bug1685 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local ListMenu = require("src.ui.ListMenu") + local Menu = require("src.ui.Menu") + local Screens = require("src.ui.Screens") + local Strings = require("src.core.Strings") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local ok = true + local function check(label, pass) + U.log(pass and "PASS" or "FAIL", label) + if not pass then ok = false end + return pass + end + + -- pokered data/maps/objects/PalletTown.asm: the house doors sit at (5,5) + -- and (13,5) and the lab's at (12,11), so (10,8) is open ground. + local TOWN, TOWN_X, TOWN_Y = "PALLET_TOWN", 10, 8 + -- six items: page one fills all four printed rows (so the '▼' is there to + -- lose), and CANCEL lands on the page after it + local STOCK = { "POTION", "ANTIDOTE", "BURN_HEAL", "ICE_HEAL", + "AWAKENING", "PARLYZ_HEAL" } + + for _, id in ipairs(STOCK) do + check(id .. " exists as an item", game.data.items[id] ~= nil) + end + check("CANCEL has a string", Strings("CANCEL") ~= nil and Strings("CANCEL") ~= "") + check("renderer is up", game.renderer ~= nil) + + game.save.player.name = "SEBAS" + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.save.inventory = {} + game.save.bagOrder = nil + local Bag = require("src.inventory.Bag") + for i, id in ipairs(STOCK) do Bag.add(game.save, id, i) end + + local function stationary() + for _ = 1, 60 do + if not (game.overworld and game.overworld.player.moving) then break end + coroutine.yield() + end + end + + local function openBag(where) + stationary() + U.tap(game, "start") + U.wait(20) + local menu = game.stack:top() + if getmetatable(menu) == Menu then + local ITEM = Strings("ITEM") + for _ = 1, 20 do + if menu.items[menu.index].label == ITEM then break end + U.tap(game, "down") + U.wait(6) + end + U.tap(game, "a") + U.wait(20) + end + local bag = game.stack:top() + if getmetatable(bag) ~= ListMenu then + U.log("START -> ITEM did not reach the bag " .. where + .. ", pushing BagMenu directly") + bag = Screens.push(game, "BagMenu") + U.wait(20) + end + return getmetatable(bag) == ListMenu and bag or nil + end + + local function toTop(bag) + for _ = 1, #bag.items + 2 do + if bag.index == 1 then break end + U.tap(game, "up") + U.wait(6) + end + end + + U.teleport(game, TOWN, TOWN_X, TOWN_Y, "down") + U.wait(20) + + local bag = openBag("in Pallet Town") + if check("the bag opened", bag ~= nil) then + check("six items are in it", #bag.items == #STOCK + 1) + local last = bag.items[#bag.items] + check("the row after the last item is the terminator's CANCEL", + last ~= nil and last.cancel == true) + check("and it carries no item id", last ~= nil and last.value == nil) + check("labelled CANCEL", last ~= nil and last.label == Strings("CANCEL")) + + toTop(bag) + check("page one shot reached disk", + U.shot(game, SHOT_DIR .. "/bug1685_page_one.png")) + + -- walk down until the cursor stops moving: it must stop ON the CANCEL + -- row, not one row short of it + local before + for _ = 1, #bag.items + 4 do + before = bag.index + U.tap(game, "down") + U.wait(6) + if bag.index == before then break end + end + check("the cursor walked all the way onto the CANCEL row", + bag.items[bag.index] ~= nil and bag.items[bag.index].cancel == true) + check("and stops there", bag.index == #bag.items) + check("with the list scrolled to its last page", + bag.scroll == #bag.items - (bag.cursorRows or bag.rows)) + check("cancel row shot reached disk", + U.shot(game, SHOT_DIR .. "/bug1685_cancel_row.png")) + + -- A on CANCEL is ExitListMenu: the same exit B takes + U.tap(game, "a") + U.wait(25) + local stillUp = false + for _, s in ipairs(game.stack.states) do + if s == bag then stillUp = true end + end + check("A on CANCEL closed the item list", not stillUp) + check("and left no box or submenu over it", + getmetatable(game.stack:top()) ~= ListMenu) + check("after-cancel shot reached disk", + U.shot(game, SHOT_DIR .. "/bug1685_after_cancel.png")) + end + + for _ = 1, 10 do + if game.stack:top() == game.overworld then break end + U.tap(game, "b") + U.wait(12) + end + + -- an empty bag is a box with CANCEL alone, which is what the cart shows; + -- the port used to print an invented "Nothing here." instead + game.save.inventory = {} + game.save.bagOrder = nil + local empty = openBag("with an empty bag") + if check("the empty bag still opens a box", empty ~= nil) then + check("holding exactly one row", #empty.items == 1) + check("and that row is CANCEL", + empty.items[1] ~= nil and empty.items[1].cancel == true) + check("empty bag shot reached disk", + U.shot(game, SHOT_DIR .. "/bug1685_empty_bag.png")) + end + + U.log("") + if ok then + U.log("the bag has been opened, walked to the bottom and cancelled for you.") + U.log("in bug1685_page_one.png the four item names fill the box and the") + U.log("down arrow sits in the lower right corner. in bug1685_cancel_row.png") + U.log("CANCEL is one row below the last item, at the same left edge as the") + U.log("names, with the cursor on it -- and that corner arrow must be GONE.") + U.log("an arrow still painted next to CANCEL is the near miss here: it is") + U.log("easy to miss in a still and it is the exact thing the terminator's") + U.log("tail jump settles.") + U.log("bug1685_empty_bag.png is an empty bag: a box with CANCEL and nothing") + U.log("else. that replaces the port's old \"Nothing here.\" line, which the") + U.log("cart never printed, so it is a deliberate change, not a regression.") + U.log("the empty bag is on screen now; A or B should both close it.") + U.log("yellow shares this list code: rerun with POKEPORT_VERSION=yellow and") + U.log("a yellow identity and the three shots should look the same.") + else + U.log("a check above failed, so nothing on screen is worth reading yet.") + end + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/bike_dismount_bug513_test.lua b/tests/drivers/bike_dismount_bug513_test.lua index e9994a11..259d986f 100644 --- a/tests/drivers/bike_dismount_bug513_test.lua +++ b/tests/drivers/bike_dismount_bug513_test.lua @@ -160,9 +160,8 @@ return function(game) if check("the bag opened", isBag) then if stepTo(bag, function(b) return b.items[b.index].value == "BICYCLE" end, "BICYCLE") then - U.tap(game, "a") -- USE / TOSS submenu - U.wait(20) - U.tap(game, "a") -- USE is the first row + -- the BICYCLE skips USE/TOSS (start_sub_menus.asm:340-342) + U.tap(game, "a") U.wait(30) end end diff --git a/tests/drivers/bike_option_box_bug1705_test.lua b/tests/drivers/bike_option_box_bug1705_test.lua new file mode 100644 index 00000000..194db22d --- /dev/null +++ b/tests/drivers/bike_option_box_bug1705_test.lua @@ -0,0 +1,342 @@ +-- Eye check that the BICYCLE never shows the USE/TOSS box (#1705). +-- StartMenu_Item .choseItem does `cp BICYCLE / jp z, .useOrTossItem` before +-- it loads USE_TOSS_MENU_TEMPLATE (engine/menus/start_sub_menus.asm:340-342), +-- so mount, dismount and the Cycling Road refusal all land on one A press. +-- No POKEPORT_SPEED: it scales only the logic clock, and the frame the box +-- would flash on is the whole point of this run. +-- POKEPORT_DRIVER=tests/drivers/bike_option_box_bug1705_test.lua POKEPORT_IDENTITY=bug1705 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local TextBox = require("src.render.TextBox") + local ListMenu = require("src.ui.ListMenu") + local Menu = require("src.ui.Menu") + local Screens = require("src.ui.Screens") + local Strings = require("src.core.Strings") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local ok = true + local function check(label, pass) + U.log(pass and "PASS" or "FAIL", label) + if not pass then ok = false end + return pass + end + + -- pokered data/maps/objects/PalletTown.asm: the house doors sit at (5,5) + -- and (13,5) and the lab's at (12,11), so (10,8) is open ground. OVERWORLD + -- tileset, so IsBikeRidingAllowed says yes here (home/overworld.asm). + local TOWN, TOWN_X, TOWN_Y = "PALLET_TOWN", 10, 8 + -- pokered data/maps/force_bike_surf.asm: ROUTE_16 (17,11) is a landing cell + -- out of the Route 16 gate's south door and arms BIT_ALWAYS_ON_BIKE. + local FORCE_MAP, FORCE_X, FORCE_Y = "ROUTE_16", 17, 11 + -- pokered data/maps/objects/Route16Gate1F.asm: warps on x 0 and 7, the + -- guard on (4,5), so (3,6) is plain floor -- and the gate script is what + -- clears the flag again (scripts/Route16Gate1F.asm `res BIT_ALWAYS_ON_BIKE`) + local GATE_MAP, GATE_X, GATE_Y = "ROUTE_16_GATE_1F", 3, 6 + + -- a missing item def, an unextracted refusal line and a bag that never + -- opens all look like the bug from the couch + check("BICYCLE exists as an item", game.data.items.BICYCLE ~= nil) + local cannot = game.data.text._CannotGetOffHereText + check("_CannotGetOffHereText was extracted", + type(cannot) == "string" and cannot:find("get off", 1, true) ~= nil) + check("POTION exists as the control item", game.data.items.POTION ~= nil) + check("renderer is up", game.renderer ~= nil) + + game.save.player.name = "SEBAS" + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.save.inventory = game.save.inventory or {} + game.save.inventory.BICYCLE = 1 + game.save.inventory.POTION = 3 + game.save.onBike = false + game.save.forcedBike = nil + + local function stationary() + for _ = 1, 90 do + game.input.state.b = true + if not (game.overworld and game.overworld.player.moving) then break end + coroutine.yield() + end + game.input.state.b = false + end + + -- START -> ITEM -> the bag, by real presses. Route 16/17 roll the player + -- south whenever nothing is held, so brake into a standstill first or + -- handleInput eats the START. + local function openBag(where) + stationary() + U.tap(game, "start") + U.wait(20) + local menu = game.stack:top() + if getmetatable(menu) == Menu then + local ITEM = Strings("ITEM") + for _ = 1, 20 do + if menu.items[menu.index].label == ITEM then break end + U.tap(game, "down") + U.wait(6) + end + U.tap(game, "a") + U.wait(20) + end + local bag = game.stack:top() + if getmetatable(bag) ~= ListMenu then + -- a start menu that reordered itself would otherwise strand the run; + -- say so, then get to the moment anyway + U.log("START -> ITEM did not reach the bag on " .. where + .. ", pushing BagMenu directly") + bag = Screens.push(game, "BagMenu") + U.wait(20) + end + return getmetatable(bag) == ListMenu and bag or nil + end + + -- the cursor comes back on the last-used row (#1732), so walk to the top + -- before walking down or a row above the saved one is unreachable + local function cursorTo(bag, id) + for _ = 1, #bag.items do + if bag.index == 1 then break end + U.tap(game, "up") + U.wait(5) + end + for _ = 1, #bag.items do + local row = bag.items[bag.index] + if row and row.value == id then return true end + U.tap(game, "down") + U.wait(5) + end + return bag.items[bag.index] and bag.items[bag.index].value == id + end + + -- StartMenu_Item keeps the START menu up behind the bag, and that is a + -- Menu too, so only a Menu that was NOT already there counts as the + -- option box + local function menuSnapshot() + local seen = {} + for _, s in ipairs(game.stack.states) do seen[s] = true end + return seen + end + + local function newMenu(before) + for _, s in ipairs(game.stack.states) do + if getmetatable(s) == Menu and not before[s] then return true end + end + return false + end + + -- press A and watch every frame after it: a USE/TOSS box that appears and + -- is replaced two frames later is invisible in a still, and is the near + -- miss this whole run exists to catch + local function chooseAndWatch(frames) + local before = menuSnapshot() + U.tap(game, "a") + local flashed = newMenu(before) + for _ = 1, frames or 30 do + if newMenu(before) then flashed = true end + coroutine.yield() + end + return flashed, before + end + + local function boxSays() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return nil end + local lines = {} + for _, page in ipairs(top.pages or {}) do + for _, line in ipairs(page) do lines[#lines + 1] = line end + end + return table.concat(lines, " / ") + end + + local function dismissBox() + for _ = 1, 8 do + if getmetatable(game.stack:top()) ~= TextBox then break end + U.tap(game, "b") + U.wait(12) + end + end + + local function backToOverworld() + for _ = 1, 10 do + if game.stack:top() == game.overworld then break end + U.tap(game, "b") + U.wait(12) + end + end + + -- the control: an ordinary item DOES open the box, so a run where nothing + -- ever appears cannot be mistaken for the fix working + U.teleport(game, TOWN, TOWN_X, TOWN_Y, "down") + U.wait(20) + local bag = openBag("Pallet Town") + if check("the bag opened in " .. TOWN, bag ~= nil) then + if check("found the POTION row", cursorTo(bag, "POTION")) then + U.tap(game, "a") + U.wait(20) + check("a POTION still opens USE/TOSS", getmetatable(game.stack:top()) == Menu) + check("control shot reached disk", + U.shot(game, SHOT_DIR .. "/bug1705_potion_usetoss.png")) + U.tap(game, "b") + U.wait(15) + end + end + + -- mount: one press, straight to the text + if bag and check("found the BICYCLE row", cursorTo(bag, "BICYCLE")) then + local flashed, before = chooseAndWatch(4) + check("no option box in the first frames after A (mount)", not flashed) + check("early shot reached disk", + U.shot(game, SHOT_DIR .. "/bug1705_mount_first_frames.png")) + U.wait(60) + check("still no option box while the text types", not newMenu(before)) + local said = boxSays() + U.log("the box reads:", tostring(said)) + check("the single press mounted the bike", game.save.onBike == true) + check("and the line is the mount text", + said ~= nil and said:find("got on", 1, true) ~= nil) + check("mount shot reached disk", U.shot(game, SHOT_DIR .. "/bug1705_mount.png")) + end + dismissBox() + backToOverworld() + + -- dismount: the same one press + bag = openBag("Pallet Town (riding)") + if bag and check("found the BICYCLE row again", cursorTo(bag, "BICYCLE")) then + local flashed = chooseAndWatch(4) + check("no option box in the first frames after A (dismount)", not flashed) + U.wait(60) + local said = boxSays() + U.log("the box reads:", tostring(said)) + check("the single press dismounted", game.save.onBike == false) + check("and the line is the dismount text", + said ~= nil and said:find("got off", 1, true) ~= nil) + check("dismount shot reached disk", + U.shot(game, SHOT_DIR .. "/bug1705_dismount.png")) + end + dismissBox() + backToOverworld() + + -- the Cycling Road refusal, armed the way the game arms it: by arriving on + -- the forced-bike cell (setMap runs checkForcedMovement on entry) + game.save.onBike = false + U.teleport(game, FORCE_MAP, FORCE_X, FORCE_Y, "down") + U.wait(25) + check("stepping onto the Route 16 cell mounts the bike", game.save.onBike == true) + check("and arms the forced-bike flag", game.save.forcedBike == true) + bag = openBag("Route 16") + if bag and check("found the BICYCLE row on the road", cursorTo(bag, "BICYCLE")) then + local flashed = chooseAndWatch(4) + check("no option box in the first frames after A (refusal)", not flashed) + U.wait(60) + local said = boxSays() + U.log("the box reads:", tostring(said)) + check("the refusal printed, not the dismount", + said ~= nil and said:find("get off", 1, true) ~= nil) + check("the player is still riding", game.save.onBike == true) + local stillUp = false + for _, s in ipairs(game.stack.states) do + if s == bag then stillUp = true end + end + check("and the bag list is still on the stack under the box (#513)", stillUp) + check("refusal shot reached disk", + U.shot(game, SHOT_DIR .. "/bug1705_forced_refusal.png")) + end + dismissBox() + backToOverworld() + + -- walking into the gate releases the flag; the gate's tileset is not one of + -- IsBikeRidingAllowed's (home/overworld.asm), so the map change dismounts on + -- the way in and the release has to be shown back out on the road + U.teleport(game, GATE_MAP, GATE_X, GATE_Y, "up") + U.wait(25) + check("the gate clears the forced-bike flag", + game.save.forcedBike == nil or game.save.forcedBike == false) + + -- a plain Route 16 cell: walkable, nobody on it, and NOT one of the two + -- forced-bike tiles, so nothing re-arms the flag when we land + local function plainRoadCell() + local ow = game.overworld + local fm = game.data.field.forcedMovement + local forced = {} + for _, t in ipairs((fm and fm.tiles and fm.tiles[FORCE_MAP]) or {}) do + forced[t.x .. "," .. t.y] = true + end + if not ow then return nil end + for y = 0, 35 do + for x = 0, 19 do + if not forced[x .. "," .. y] and ow.map:inBounds(x, y) + and ow.map:isWalkableCell(x, y) and not ow:npcAtCell(x, y) then + return x, y + end + end + end + return nil + end + + U.teleport(game, FORCE_MAP, FORCE_X, FORCE_Y - 3, "up") + U.wait(20) + local px, py = plainRoadCell() + if px then + U.teleport(game, FORCE_MAP, px, py, "up") + U.wait(20) + end + check("back on Route 16, off the forced tiles, flag still clear", + game.save.forcedBike == nil or game.save.forcedBike == false) + + -- with the flag released the bike answers again: mount, then get off, one + -- press each, and the get-off is the very thing the road refused + if not game.save.onBike then + bag = openBag("Route 16 (released)") + if bag and check("found the BICYCLE row to remount", cursorTo(bag, "BICYCLE")) then + local flashed = chooseAndWatch(4) + check("no option box on the remount", not flashed) + U.wait(60) + U.log("the box reads:", tostring(boxSays())) + check("the remount worked", game.save.onBike == true) + end + dismissBox() + backToOverworld() + end + + bag = openBag("Route 16 (getting off)") + if bag and check("found the BICYCLE row again on the road", + cursorTo(bag, "BICYCLE")) then + local flashed = chooseAndWatch(4) + check("no option box on the released dismount", not flashed) + U.wait(60) + local said = boxSays() + U.log("the box reads:", tostring(said)) + check("the flag really is released: this one gets off", + game.save.onBike == false) + check("and the line is the dismount text, not the refusal", + said ~= nil and said:find("got off", 1, true) ~= nil) + check("released dismount shot reached disk", + U.shot(game, SHOT_DIR .. "/bug1705_released_dismount.png")) + end + + check("the BICYCLE was never consumed", (game.save.inventory.BICYCLE or 0) == 1) + + U.log("") + if ok then + U.log("the bike has been used five times for you: mounted and dismounted in") + U.log("PALLET TOWN, refused on the Route 16 forced stretch, then mounted and") + U.log("taken off again after the gate released the flag. every one of those") + U.log("was a single A press on the BICYCLE row, with no USE/TOSS box.") + U.log("shots are in " .. SHOT_DIR .. ": bug1705_potion_usetoss.png is the") + U.log("control, the box a POTION still gets. bug1705_mount_first_frames.png") + U.log("is two frames after the A press on the bike -- that one must show") + U.log("the mount text or a bare map, never a small box in the lower right.") + U.log("the near miss is a box that appears and is replaced before the text") + U.log("types, which reads as a pass in every later shot.") + U.log("you are on Route 16 with the bag closed; open it and try the bike as") + U.log("often as you like. it also has no TOSS any more, since the box that") + U.log("carried it is the one that no longer opens.") + U.log("yellow shares this code path: rerun with POKEPORT_VERSION=yellow") + U.log("and a yellow identity, and every beat should read the same.") + else + U.log("a check above failed, so nothing on screen is worth reading yet.") + end + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/crystal_shaderfx_shots.lua b/tests/drivers/crystal_shaderfx_shots.lua new file mode 100644 index 00000000..bec16cc1 --- /dev/null +++ b/tests/drivers/crystal_shaderfx_shots.lua @@ -0,0 +1,94 @@ +-- SHADER FX preset examples on Crystal, including a stacked pair. +-- +-- POKEPORT_IDENTITY=crystal-dev POKEPORT_GAME=crystal POKEPORT_TOUCH=0 \ +-- POKEPORT_SHOT_DIR=/tmp/shaderfx \ +-- POKEPORT_DRIVER=tests/drivers/crystal_shaderfx_shots.lua love . +-- +-- Never run this under POKEPORT_SPEED: the shots are of a live frame. +local U = require("tests.drivers.util") +local ShaderFX = require("src.render.ShaderFX") + +local WANT = { + { file = "gameboy-color-dot-matrix.slangp", tag = "gbc-dot-matrix" }, + { file = "sameboy-lcd.slangp", tag = "sameboy-lcd" }, + { file = "crt-caligari.slangp", tag = "crt-caligari" }, +} + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shaderfx" + + U.wait(30) + U.log(("bridge canConvert=%s"):format(tostring(ShaderFX.canConvert()))) + if not ShaderFX.canConvert() then + U.log("FAIL no librashader bridge, nothing can be converted") + return + end + + local list = ShaderFX.list() + U.log(("found %d presets"):format(#list)) + if #list == 0 then U.log("FAIL no presets on disk") return end + + local byName = {} + for _, e in ipairs(list) do byName[e.name] = e end + + local ready = {} + for _, want in ipairs(WANT) do + local entry = byName[want.file] + if not entry then + U.log(("SKIP %s not in the preset set"):format(want.file)) + else + local ok, err = true, nil + if not entry.converted then ok, err = ShaderFX.convert(entry) end + if ok then + ready[#ready + 1] = { entry = entry, tag = want.tag } + U.log(("PASS converted %s"):format(want.file)) + else + U.log(("FAIL convert %s: %s"):format(want.file, tostring(err))) + end + end + end + if #ready == 0 then U.log("FAIL nothing converted") return end + + U.wait(60) + local world = game.world + if world and world.setMap then + world:setMap("CHERRYGROVE_CITY", 21, 6, "down") + U.wait(20) + else + U.log("note: no gen2 world yet, shooting whatever is on screen") + end + + ShaderFX.deactivate("main") + ShaderFX.deactivate("secondary") + U.wait(20) + U.shot(game, out .. "/00-none.png") + U.log("shot: no shader") + + for i, r in ipairs(ready) do + ShaderFX.deactivate("secondary") + local ok, err = ShaderFX.activate("main", r.entry) + U.wait(30) + if ok then + U.shot(game, ("%s/%02d-%s.png"):format(out, i, r.tag)) + U.log(("shot: %s"):format(r.tag)) + else + U.log(("FAIL activate %s: %s"):format(r.tag, tostring(err))) + end + end + + if #ready >= 2 then + local a, b = ready[1], ready[2] + local okA = ShaderFX.activate("main", a.entry) + local okB = ShaderFX.activate("secondary", b.entry) + U.wait(30) + if okA and okB then + U.shot(game, ("%s/%02d-stacked-%s-over-%s.png"):format(out, #ready + 1, a.tag, b.tag)) + U.log(("shot: stacked %s + %s"):format(a.tag, b.tag)) + else + U.log("FAIL could not stack two slots") + end + end + + U.log("done. the pad is yours; SHADER FX rows are in OPTIONS.") + while true do coroutine.yield() end +end diff --git a/tests/drivers/crystal_splash_shots.lua b/tests/drivers/crystal_splash_shots.lua new file mode 100644 index 00000000..fa46ff92 --- /dev/null +++ b/tests/drivers/crystal_splash_shots.lua @@ -0,0 +1,86 @@ +-- The Crystal GAME FREAK splash (pokecrystal engine/movie/splash.asm), shot +-- finely enough to catch both Ditto bounces, plus both IntroSequence exits +-- (pokecrystal engine/menus/intro_menu.asm:964-967): watched -> CrystalIntro, +-- skipped -> the title. +local U = require("tests.drivers.util") +local CrystalIntro = require("src.ui.gen2.CrystalIntro") +local CrystalSplash = require("src.ui.gen2.CrystalSplash") +local TitleState = require("src.ui.gen2.TitleState") + +local LIMIT = 900 + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-splash" + local interval = tonumber(os.getenv("POKEPORT_SHOT_INTERVAL") or "") or 2 + + U.wait(10) + local finished, skipped + local splash = CrystalSplash.new(game, { + oakSpeech = game.oakSpeechData or {}, + onDone = function(s) + finished, skipped = true, s + end, + }) + game.stack:clear() + game.stack:push(splash) + + local st = splash.anims.structs[1] + assert(st and st.index ~= 0, "no Ditto sprite anim struct was spawned") + local minY, sawMorph, scene = 256, false, -1 + while not finished and splash.frames < LIMIT do + U.wait(interval) + local offset = st.yOffset + if offset >= 128 then offset = offset - 256 end + if st.jt == 1 and offset < minY then minY = offset end + if st.jt >= 3 then sawMorph = true end + if splash.scene ~= scene then + scene = splash.scene + U.log(("scene %d at frame %d (jt=%d var1=%d)") + :format(scene, splash.frames, st.jt, st.var1)) + end + if not finished then + U.shot(game, ("%s/splash-%04d-scene%d.png") + :format(out, splash.frames, splash.scene)) + end + end + assert(finished, "the splash never finished (frame " + .. splash.frames .. ", scene " .. splash.scene .. ")") + assert(not skipped, "an unskipped splash reported skipped") + assert(minY <= -90, "Ditto never reached the top of its 96px bounce (min " + .. minY .. ")") + assert(sawMorph, "the transform scene never ran") + U.log(("splash done at frame %d, min bounce offset %d") + :format(splash.frames, minY)) + + -- Watched through: Game2 routes to the intro movie. + game:showGameFreak() + U.wait(5) + assert(getmetatable(game.stack:top()) == CrystalSplash, + "showGameFreak did not open the Crystal splash") + for _ = 1, LIMIT do + if getmetatable(game.stack:top()) == CrystalIntro then break end + U.wait(5) + end + assert(getmetatable(game.stack:top()) == CrystalIntro, + "a watched splash did not hand off to CrystalIntro (top " + .. tostring(game.stack:top()) .. ")") + U.log("watched splash handed off to the intro movie") + + -- Skipped: straight to the title, no intro movie. + game:showGameFreak() + U.wait(40) + U.shot(game, out .. "/skip-before.png") + U.tap(game, "b") + for _ = 1, 60 do + if getmetatable(game.stack:top()) == TitleState then break end + assert(getmetatable(game.stack:top()) ~= CrystalIntro, + "a skipped splash still played the intro movie") + U.wait(1) + end + assert(getmetatable(game.stack:top()) == TitleState, + "a skipped splash did not land on the title (top " + .. tostring(game.stack:top()) .. ")") + U.wait(30) + U.shot(game, out .. "/skip-title.png") + U.log("PASS crystal splash shots in " .. out) +end diff --git a/tests/drivers/gold_battle_bg_bug1709_test.lua b/tests/drivers/gold_battle_bg_bug1709_test.lua new file mode 100644 index 00000000..742f0fdb --- /dev/null +++ b/tests/drivers/gold_battle_bg_bug1709_test.lua @@ -0,0 +1,278 @@ +-- #1709: BATTLE BG on Gold, the WHITE / BLACK void around the battle screen, +-- and whether it survives a menu opened over the battle. The cart has no such +-- setting; maps/Route29.asm:432 is only where this parks the player. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_battle_bg_bug1709_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-battle-bg love . +-- +-- No POKEPORT_SPEED: the shots land a fixed number of frames after a +-- transition, and a logic clock running ahead of the render moves them. +local U = require("tests.drivers.util") + +local Chrome = require("src.ui.gen2.Chrome") +local Mon = require("src.battle.gen2.Mon") +local OptionsMenu = require("src.ui.gen2.OptionsMenu") +local Permissions = require("src.world.gen2.Permissions") +local Save = require("src.core.gen2.Save") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-battle-bg" + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[battlebg] ok " .. label) + else + failures = failures + 1 + print("[battlebg] 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") + + -- ---- what the eye cannot check ----------------------------------------- + -- Each of these fails the same way the bug did: the void just stays white. + ok("battleBg has a default in the Gold options table", + Save.DEFAULT_OPTIONS.battleBg == "white", Save.DEFAULT_OPTIONS.battleBg) + + local bgRow + for i, row in ipairs(OptionsMenu.ROWS) do + if row.label == "BATTLE BG" then bgRow = i end + end + ok("OPTION carries a BATTLE BG row", bgRow ~= nil, bgRow) + if bgRow then + ok("and it is the last row before CANCEL", + OptionsMenu.ROWS[bgRow + 1] and OptionsMenu.ROWS[bgRow + 1].cancel == true, + bgRow) + end + + local battleModule = require("src.ui.gen2.BattleState") + ok("the Gold battle screen answers bgMode", + type(battleModule.bgMode) == "function", type(battleModule.bgMode)) + ok("and Game2 owns the repaint, not the battle screen", + type(game.paintBattleSurround) == "function", + type(game.paintBattleSurround)) + + -- At the letterbox size there is no void, so nothing below would show. + 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) + ok("and so does the wild PIDGEY", wild ~= nil and #wild.moves > 0, + wild and #wild.moves) + game.save.party = { player } + game.save.inventory = { POKE_BALL = 5, POTION = 3 } + + -- maps/Route29.asm:432, the teacher's WALK_LEFT_RIGHT lane, so (15,11) and + -- its neighbours are floor; a map edit that moves it falls back below. + 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 "[battlebg] preflight PASS -- the shots below are worth looking at" + or ("[battlebg] preflight FAIL (%d) -- fix these before judging a pixel") + :format(failures)) + + -- ---- the run ----------------------------------------------------------- + -- Start from WHITE whatever the gold-dev identity was left on, so the first + -- half of the run is the same every time. + game.options.battleBg = "white" + shot(out .. "/00-route29.png") + + -- OPTION, walked to off the START menu the way a player gets there. + tap("start") + local startMenu = game.stack:top() + ok("START opened the menu", startMenu ~= nil and startMenu.items ~= nil, + startMenu) + if startMenu and startMenu.list then + for _ = 1, #startMenu.items do + local item = startMenu.items[startMenu.list.index] + if item and item.value == "option" then break end + tap("down", 3) + end + local landed = startMenu.items[startMenu.list.index] + ok("the cursor found OPTION", landed and landed.value == "option", + landed and landed.value) + end + tap("a", 10) + + local options = game.stack:top() + ok("the OPTION screen is up", options ~= nil and options.rows ~= nil, options) + if options then + -- UP from the first row wraps onto CANCEL, so BATTLE BG is two presses + -- away however many rows the build has. + for _ = 1, #options.rows do + if options:row() and options:row().key == "battleBg" then break end + tap("up", 3) + end + local row = options:row() + ok("the cursor is on BATTLE BG", row and row.key == "battleBg", + row and row.label) + U.log("01: the OPTION screen, cursor on BATTLE BG. it should read WHITE,") + U.log("sitting under MAX FPS and above CANCEL.") + shot(out .. "/01-option-white.png") + tap("right", 6) + ok("right stored black", game.options.battleBg == "black", + game.options.battleBg) + U.log("02: same row after one press right. it should read BLACK, and no") + U.log("other row on screen should have changed.") + shot(out .. "/02-option-black.png") + end + -- Gold plays with an empty stack: the world is not a state, so "back on the + -- map" is nothing on top rather than the overworld being on top. + tap("b", 10) + for _ = 1, 20 do + if game.stack:top() == nil then break end + tap("b", 4) + end + ok("the menus closed", game.stack:top() == nil, game.stack:top()) + + U.log("03: Route 29 with BATTLE BG on BLACK. the overworld is not a battle,") + U.log("so there must be NO black bars here -- the surround around the map is") + U.log("whatever VOID FILL draws, exactly as it was before.") + shot(out .. "/03-route29-black-set.png") + + -- ---- the battle -------------------------------------------------------- + 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(("[battlebg] 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 black surround", + battle:bgMode() == "black", battle:bgMode()) + U.wait(10) + + U.log("04: the battle on BLACK. the void on all four sides of the GB screen") + U.log("is solid black; the battle's own field, HUD and message box stay") + U.log("white. black creeping over the panel edge -- a dark frame eating a") + U.log("row of the HUD or the box border -- is the band maths being wrong.") + shot(out .. "/04-battle-black.png") + + -- The 2x2 is FIGHT, / PACK, RUN. Clamp onto FIGHT first and step + -- from there: one cell off is RUN, which ends the battle instead of a menu. + 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("05: the party list over the same battle. this is the one that used to") + U.log("go wrong: the surround must still be black. white returning the") + U.log("instant the list goes up means the repaint is on the wrong layer.") + shot(out .. "/05-party-over-black.png") + tap("b", 12) + + ok("the cursor is on PACK", pointAt(3), battle.menuIndex) + tap("a", 14) + U.log("06: the PACK over the battle, same rule -- still black behind it.") + shot(out .. "/06-pack-over-black.png") + tap("b", 12) + for _ = 1, 20 do + if battle.phase == "menu" then break end + tap("b", 4) + end + + -- SCREEN POS moves the panel off centre, which is the other way the bands + -- can stop lining up with where the panel actually landed. + local positions = { + { 7, "upper", "the panel sits high, so the black above it is thin and", + "the band below is deep. both stop dead at the panel edge." }, + { 8, "top", "the panel is flush with the top, so there is no black", + "above it at all. a band up there means the lift was ignored." }, + } + for _, pos in ipairs(positions) do + game.options.screenPos = pos[2] + game:applyOptions() + U.wait(8) + local px, py = Chrome.fitOrigin(love.graphics.getDimensions()) + U.log(("0%d: SCREEN POS %s, panel at %d,%d."):format( + pos[1], pos[2], px, py)) + U.log(pos[3]) + U.log(pos[4]) + shot(out .. ("/0%d-screenpos-%s.png"):format(pos[1], pos[2])) + end + game.options.screenPos = "center" + game:applyOptions() + U.wait(8) + + -- The no-regression half: WHITE has to look exactly like it always did. + game.options.battleBg = "white" + U.wait(6) + ok("and back to white", battle:bgMode() == "white", battle:bgMode()) + U.log("09: the same battle flipped back to WHITE. paper white all the way to") + U.log("the window edge, no seam where the panel ends -- compare it with 04.") + shot(out .. "/09-battle-white.png") + + ok("back on PKMN for the white pair", pointAt(2), battle.menuIndex) + tap("a", 12) + U.log("10: the party list over the white battle, for the same comparison.") + shot(out .. "/10-party-over-white.png") + tap("b", 12) + + game.options.battleBg = "black" + U.wait(6) + + print(failures == 0 and "[battlebg] PASS gold_battle_bg_bug1709" + or ("[battlebg] FAIL gold_battle_bg_bug1709 (%d)"):format(failures)) + U.log("the battle is still up on BLACK and the controls are yours. leaving") + U.log("the OPTION screen wrote BLACK into the gold-dev options.lua, so the") + U.log("next boot starts there; set it back from OPTION if you want white.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_cycling_road_bug1718_1749_test.lua b/tests/drivers/gold_cycling_road_bug1718_1749_test.lua new file mode 100644 index 00000000..ee3d17b8 --- /dev/null +++ b/tests/drivers/gold_cycling_road_bug1718_1749_test.lua @@ -0,0 +1,274 @@ +-- #1718 / #1749: START while the Cycling Road rolls the bike downhill, and SURF +-- refused there. Assertion driver -- every line it prints is PASS or FAIL. +-- Covers the halves tests/gen2_cycling_road_test.lua cannot: Game2's joypad +-- latch, which needs a real Input, and the A press at the water. +-- ../pokegold/maps/Route17.asm:11-16, engine/overworld/events.asm:193-231. +-- Never add POKEPORT_SPEED: which fixed step a press lands on IS the subject. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_cycling_road_bug1718_1749_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-cycling-road \ +-- perl -e 'alarm 240; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +local U = require("tests.drivers.util") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Mon = require("src.battle.gen2.Mon") +local Permissions = require("src.world.gen2.Permissions") +local Screens = require("src.ui.Screens") + +-- The lane: x=9 is road, x=10 the water beside it, and Route 17's four bikers +-- stand at (4,17) (16,32) (3,53) (6,80) (../pokegold/maps/Route17.asm:147-150). +local ROAD = { map = "ROUTE_17", x = 9, y = 36 } +-- The control, a route with no ALWAYS_ON_BIKE on it: the shore south of the +-- Union Cave mouth (../pokegold/maps/Route32.asm:861 is the nearest object). +local SEA = { map = "ROUTE_32", x = 11, y = 41 } + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-cycling-road" + local fails = 0 + + local function report(ok, line) + if not ok then fails = fails + 1 end + U.log((ok and "PASS " or "FAIL ") .. line) + return ok + end + + U.wait(45) + local world = game.world + if not (world and world.map) then + U.log("FAIL gold world did not boot; nothing below ran") + while true do coroutine.yield() end + end + local save = game.save + + -- --------------------------------------------------------------- helpers + + 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 + + -- One edge then a sustained hold: re-queueing every frame would hand the + -- gate a fresh edge on the landing tick and prove nothing. + local function holdUntilMenu(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + local opened = false + for _ = 1, frames do + coroutine.yield() + if game.stack:top() then opened = true break end + end + game.input.state[button] = false + U.wait(2) + return opened + end + + local function tapAndRelease(button) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + coroutine.yield() + game.input.state[button] = false + end + + local function closeMenus() + for _ = 1, 40 do + if not game.stack:top() then return true end + tap("b", 4) + end + return game.stack:top() == nil + end + + local function dismiss() + for _ = 1, 80 do + if not (world.textbox or world.choicebox or world:busy()) then return true end + if world.choicebox then tap("b", 4) else tap("a", 4) end + end + return not (world.textbox or world.choicebox) + end + + -- Put the roll back at the top of the lane, standing, so every trial starts + -- from the same step phase and none of them drifts into a biker. + local function park(x, y, facing) + local p = world.player + p.moving = false + p.progress = 0 + p.targetX, p.targetY = nil, nil + p.turnTimer = 0 + p.cellX, p.cellY = x, y + p.px, p.py = x * 16, y * 16 + p.facing = facing or "down" + world.stepFinished = false + world.heldDir = nil + end + + -- ---- preflight: everything here fails the same silent way the bugs do ---- + + local okScreen = pcall(Screens.get, game, "Gen2StartMenu") + report(okScreen, "the Gen2StartMenu screen id resolves") + + local badges = save.player.badges or {} + save.player.badges = badges + badges[FieldMoves.BADGE.SURF] = true + report(FieldMoves.hasBadge(save, FieldMoves.BADGE.SURF), "the FOGBADGE is on") + + local swimmer = Mon.new(game.data, "LAPRAS", 30, { moves = { { id = "SURF" } } }) + report(swimmer ~= nil, "the cache builds a LAPRAS") + save.party = { swimmer } + report(FieldMoves.partyMoveUser(save.party, "SURF") ~= nil, + "and it knows SURF, so CheckPartyMove has a mon to find") + + report(world:setMap(ROAD.map, ROAD.x, ROAD.y, "down") ~= false, + "ROUTE_17 loads") + U.wait(10) + report(world.map.id == ROAD.map, "and the world is on it") + report(world:alwaysOnBike() == true, + "MAPCALLBACK_NEWMAP set ALWAYS_ON_BIKE") + report(world:downhill() == true, "and DOWNHILL") + report(FieldMoves.isBiking(world.playerState), + "CheckUpdatePlayerSprite put the player on the bike") + + for cy = ROAD.y, ROAD.y + 12 do + local road = Permissions.isLand(world.map:cellCollision(ROAD.x, cy)) + local sea = Permissions.isWater(world.map:cellCollision(ROAD.x + 1, cy)) + if not (road and sea) then + report(false, ("ROUTE_17 (%d,%d) is no longer road-beside-water") + :format(ROAD.x, cy)) + break + end + end + report(true, "the lane is road all the way down with water on its right") + + local clear = true + for _, npc in ipairs(world.npcs or {}) do + if npc.cellX == ROAD.x and npc.cellY >= ROAD.y - 2 + and npc.cellY <= ROAD.y + 14 then + clear = false + end + end + report(clear, "and no biker is parked in it") + + if fails > 0 then + U.log("preflight failed -- the trials below would be measuring the setup,") + U.log("not the fix. fix the lines above first.") + while true do coroutine.yield() end + end + + -- ---- #1718: the nine offsets in an 8-frame STEP_BIKE cell --------------- + -- Offset 0 is the one tick the gate opens; 1..8 only the latch can carry. + + local opened = 0 + for offset = 0, 8 do + park(ROAD.x, ROAD.y, "down") + U.wait(offset) + local moving = world.player.moving and "mid-step" or "standing" + local ok = holdUntilMenu("start", 24) + if ok then opened = opened + 1 end + report(ok, ("START at offset %d (%s) opened the menu"):format(offset, moving)) + if offset == 4 then U.shot(game, out .. "/01-start-mid-roll.png") end + closeMenus() + end + report(opened == 9, ("all nine offsets opened the menu (%d/9)"):format(opened)) + + -- A press RELEASED inside the step is one the frozen mirror never sees again. + park(ROAD.x, ROAD.y, "down") + U.wait(3) + report(world.player.moving == true, "three ticks in, the roll is mid-step") + tapAndRelease("start") + U.wait(24) + report(game.stack:top() == nil, + "a START tapped and released inside the step opens nothing") + closeMenus() + + -- And the dropped press must not poison the latch for the next one. + park(ROAD.x, ROAD.y, "down") + U.wait(3) + report(holdUntilMenu("start", 24), + "the next held START still opens the menu") + closeMenus() + + -- ---- #1749: SURF refused on the road ------------------------------------ + + park(ROAD.x, ROAD.y, "down") + -- Held RIGHT into the water the step is refused every tick, so the player + -- stands facing the sea instead of rolling and an A press can land at all. + game.input.pressQueue[#game.input.pressQueue + 1] = "right" + game.input.state.right = true + U.wait(20) + report(world.player.facing == "right", "facing the water off the Cycling Road") + report(world.player.moving == false, "and held there by the water it bumps") + local ctx = world:fieldContext() + report(Permissions.isWater(ctx.facingColl), "the faced cell really is water") + report(ctx.alwaysOnBike == true, + "and the field-move context carries ALWAYS_ON_BIKE (#1749)") + U.shot(game, out .. "/02-road-facing-water.png") + + -- TrySurfOW's arm is `.quit`: xor a, no script, no text + -- (engine/events/overworld.asm:513-515). Pressed through the real Input. + game.input.pressQueue[#game.input.pressQueue + 1] = "a" + game.input.state.a = true + U.wait(2) + game.input.state.a = false + U.wait(12) + game.input.state.right = false + report(world.textbox == nil, "A at the water put up no text box") + report(world.queuedFieldMove == nil, "and queued no field move") + report(FieldMoves.isBiking(world.playerState), "the player is still on the bike") + + -- SurfFunction.TrySurf's arm is .FailSurf, which TALKS + -- (engine/events/overworld.asm:350-352, :387-390). The world has to be idle + -- first or useFieldMove answers CANT_USE_HERE off its own busy gate, which + -- is a refusal for the wrong reason and would read as a pass. + dismiss() + report(not world:busy(), "the world is idle before the PACK's SURF is asked") + local menu = world:useFieldMove("SURF", swimmer) + report(menu and menu.ok == false, "the PACK's SURF refuses on the road") + report(menu and menu.text == FieldMoves.TEXT.CANT_SURF, + "with CantSurfText, not the badge line and not silence") + U.wait(6) + report(world.textbox ~= nil, "and the refusal is on screen") + U.wait(70) -- let the line finish typing so the shot is readable + U.shot(game, out .. "/03-road-cant-surf.png") + dismiss() + + -- ---- the control: plain water, ALWAYS_ON_BIKE clear --------------------- + + report(world:setMap(SEA.map, SEA.x, SEA.y, "right") ~= false, "ROUTE_32 loads") + U.wait(10) + world:applyPlayerState(FieldMoves.PLAYER_NORMAL) + report(world:alwaysOnBike() == false, "ROUTE_32 has no ALWAYS_ON_BIKE") + local sea = world:fieldContext() + report(sea.alwaysOnBike == false, "so the context reports it clear") + report(Permissions.isWater(sea.facingColl), "and the player faces its water") + + local allowed = world:useFieldMove("SURF", swimmer) + report(allowed and allowed.ok == true, "the PACK's SURF still works there") + world.queuedFieldMove = nil + + tap("a", 10) + report(world.textbox ~= nil or world.choicebox ~= nil, + "and A at the water still offers to SURF") + U.wait(70) + U.shot(game, out .. "/04-route32-offer.png") + dismiss() + + if fails == 0 then + U.log("all trials passed. shots are in " .. out) + else + U.log(fails .. " trial(s) failed -- see the FAIL lines above") + end + U.log("what a wrong fix looks like: START opening the menu on roughly one") + U.log("press in eight is the World half applied without the Game2 latch, and") + U.log("a menu from the tapped-and-released press is the latch re-pressing a") + U.log("button the player already let go of.") + + world:setMap(ROAD.map, ROAD.x, ROAD.y, "down") + U.wait(10) + U.log("parked back on the Cycling Road; the controls are yours from here") + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/gold_field_anim_shots.lua b/tests/drivers/gold_field_anim_shots.lua index a0b79c6a..54c1638b 100644 --- a/tests/drivers/gold_field_anim_shots.lua +++ b/tests/drivers/gold_field_anim_shots.lua @@ -118,7 +118,10 @@ return function(game) for _, row in ipairs(FieldMoves.FLYPOINTS) do save.engineFlags[row.flag] = true end - ok("openFlyMap opens a screen", world:openFlyMap() == true) + -- The mon that used the move: FlyFunction_InitGFX and TownMapMon both draw + -- wCurPartyMon's icon (engine/events/field_moves.asm:390). + local flyMon = (save.party and save.party[1]) or { species = "PIDGEY" } + ok("openFlyMap opens a screen", world:openFlyMap(flyMon) == true) U.wait(4) local picker = game.stack:top() ok("and it is the town-map picker, not a yes/no box", @@ -130,18 +133,26 @@ return function(game) U.wait(4) U.shot(game, out .. "/06-flymap-moved.png") - -- A takes the destination: the fade out lifts the player off the map first. + -- A takes the destination: FlyFromAnim hovers over the tile for 64 frames, + -- climbs off the top of the screen, and FlyToAnim drops back in after the warp. U.tap(game, "a") - local lifted = 0 - for frame = 1, 40 do + U.wait(4) + ok("FLY starts the take-off animation", world.flyAnim ~= nil) + U.shot(game, out .. "/07-fly-takeoff.png") + local risen, landing = 0, false + for _ = 1, 300 do U.wait(1) - lifted = math.min(lifted, (world.player and world.player.spriteYOffset) or 0) - if frame == 4 then U.shot(game, out .. "/07-fly-takeoff.png") end + local fa = world.flyAnim + if fa then + risen = math.min(risen, fa.y or 0) + if fa.phase == "to" then landing = true end + elseif landing then + break + end end - ok("FLY lifts the player under the fade", lifted < 0, lifted) - U.wait(30) - ok("and lands them back on the tile", - (world.player.spriteYOffset or 0) == 0) + ok("the mon climbs off the map", risen < 0, risen) + ok("and FlyToAnim brings it back down", landing) + ok("with the player standing again", world.flyAnim == nil) U.shot(game, out .. "/08-fly-landed.png") -- --------------------------------------------------- tilt and the void diff --git a/tests/drivers/gold_fishing_rod_bug1708_test.lua b/tests/drivers/gold_fishing_rod_bug1708_test.lua new file mode 100644 index 00000000..d3d877d7 --- /dev/null +++ b/tests/drivers/gold_fishing_rod_bug1708_test.lua @@ -0,0 +1,369 @@ +-- #1708: Gold's fishing pose and rod. The cast is reached the way a player +-- reaches it -- walk to the shore, START, PACK, KEY ITEMS, OLD ROD, USE -- once +-- per facing, and the pose row and rod tile are sampled out of the real draw. +-- FacingFishDown/Up/Left/Right ../pokegold/data/sprites/facings.asm:122-152; +-- LoadFishingGFX ../pokegold/engine/events/fishing_gfx.asm:1-24. +-- No POKEPORT_SPEED: it scales the logic clock only, so the sampled frames and +-- the rendered ones drift apart and the rod is read off the wrong frame. +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_fishing_rod_bug1708_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-fishing \ +-- perl -e 'alarm 420; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +local U = require("tests.drivers.util") + +local Assets = require("src.render.Assets") +local Map = require("src.world.gen2.Map") +local PackMenu = require("src.ui.gen2.PackMenu") +local Permissions = require("src.world.gen2.Permissions") +local SpriteRenderer = require("src.render.SpriteRenderer") +local StartMenu = require("src.ui.gen2.StartMenu") + +local ROD_ITEM = "OLD_ROD" +local FACINGS = { "down", "up", "left", "right" } +local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + +-- The loose rod OAM, `db y, x, attr, tile`, off the sprite's top-left +-- (data/sprites/facings.asm:122-152). Read here a second time so a typo in the +-- engine's copy of the table is a mismatch rather than a shared mistake. +local ROD_OAM = { + down = { dx = 0, dy = 16, tile = 0xfc, flip = false }, + up = { dx = 0, dy = -8, tile = 0xfc, flip = false }, + left = { dx = -8, dy = 5, tile = 0xfd, flip = true }, + right = { dx = 16, dy = 5, tile = 0xfd, flip = false }, +} +-- LoadFishingGFX's three destinations, vTiles $02 / $06 / $0a, which are the +-- bottom tile rows of the down / up / left standing frames; right is left +-- x-flipped (fishing_gfx.asm:13-18, facings.asm:143-152). +local POSE_ROW = { down = 0, up = 1, left = 2, right = 2 } +local POSE_FLIP = { down = false, up = false, left = false, right = true } +-- Four rows of two tiles: the three pose rows then $fc/$fd. +local SHEET_W, SHEET_H, ROD_ROW_Y = 16, 32, 24 + +-- Ponds with a shore on all four sides. Route 28's has no object events at +-- all (../pokegold/maps/Route28.asm:29), so nothing walks into a shot and no +-- trainer's line of sight crosses one; the other two are the fallbacks if its +-- blocks are ever edited (../pokegold/maps/Route28.blk). +local MAPS = { "ROUTE_28", "VIRIDIAN_CITY", "ROUTE_43" } + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-fishing" + local failures = 0 + + local function check(label, condition, detail) + if condition then + U.log("PASS", label) + else + failures = failures + 1 + U.log("FAIL", label, detail ~= nil and tostring(detail) or "") + end + return condition and true or false + end + + local function tap(btn, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = btn + game.input.state[btn] = true + U.wait(2) + game.input.state[btn] = false + U.wait(frames or 6) + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + local save = game.save + + -- ---- the halves of the drawing, before anything is on screen ------------- + -- A cache with no sheet, a sheet of the wrong size and a draw branch that + -- never fires all look identical on screen: a plain standing player. + + local sheet = world.fishingSheet + if not check("the cache carries LoadFishingGFX's sheet (world.fishingSheet)", + type(sheet) == "string", sheet) then + U.log("no emotes/fishing.png in this cache, so nothing below can draw.") + U.log("re-import the cart first:") + U.log(" POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_IMPORT_ONLY=1 \\") + U.log(" POKEPORT_IMPORT_ROM=/path/to/gold.gbc love .") + while true do coroutine.yield() end + end + + local okImg, image = pcall(Assets.image, sheet) + check("and it loads: " .. tostring(sheet), okImg and image ~= nil) + if okImg and image then + local w, h = image:getDimensions() + check(("it is %dx%d, the four rows the quads are written against") + :format(SHEET_W, SHEET_H), + w == SHEET_W and h == SHEET_H, ("%dx%d"):format(w, h)) + if h ~= SHEET_H then + U.log("a taller sheet moves the rod row, and both draws then sample") + U.log("the wrong pixels -- Gen 1 shipped exactly that as #321.") + end + end + + local Player = require("src.world.gen2.Player") + check("Player:drawFishing exists (the branch that reads player.fishing)", + type(Player.drawFishing) == "function") + check("SpriteRenderer:drawTile exists (it bakes the OBJ palette; a raw " + .. "love.graphics.draw would leave the rod in DMG greys)", + type(SpriteRenderer.drawTile) == "function") + + local rodDef = game.data and game.data.items and game.data.items[ROD_ITEM] + check("OLD ROD is a real item in this cache", rodDef ~= nil) + check("and it lives in the KEY ITEMS pocket", + rodDef ~= nil and rodDef.pocket == "KEY_ITEM", + rodDef and rodDef.pocket) + + -- ---- pick the pond ------------------------------------------------------ + -- Shores are re-derived from the generated blocks every run, so a re-extract + -- that shifts the water degrades to another cell instead of parking the + -- player at a wall. + + local function shores(mapId) + local def = world.maps and world.maps[mapId] + local tileset = def and world.tilesets and world.tilesets[def.tileset] + if not (def and tileset) then return nil end + local probe = Map.new(def, tileset) + local taken = {} + for _, obj in ipairs(def.objects or {}) do + taken[obj.x .. "," .. obj.y] = true + end + local function land(cx, cy) + if not probe:inBounds(cx, cy) then return false end + if taken[cx .. "," .. cy] then return false end + local coll = probe:cellCollision(cx, cy) + return Permissions.isWalkable(coll) and not Permissions.isWater(coll) + end + local found = {} + for _, facing in ipairs(FACINGS) do + local d = DELTA[facing] + for cy = 0, probe.heightCells - 1 do + for cx = 0, probe.widthCells - 1 do + if not found[facing] and land(cx, cy) + and probe:inBounds(cx + d[1], cy + d[2]) + and Permissions.isWater(probe:cellCollision(cx + d[1], cy + d[2])) + and land(cx - d[1], cy - d[2]) then + found[facing] = { x = cx, y = cy } + end + end + end + if not found[facing] then return nil end + end + return found + end + + local MAP, SHORE + for _, id in ipairs(MAPS) do + SHORE = shores(id) + if SHORE then MAP = id break end + end + if not check("a pond with all four shores and a step of room behind each", + MAP ~= nil, table.concat(MAPS, ", ")) then + while true do coroutine.yield() end + end + U.log("fishing on " .. MAP .. ", from " .. + ("down (%d,%d), up (%d,%d), left (%d,%d), right (%d,%d)"):format( + SHORE.down.x, SHORE.down.y, SHORE.up.x, SHORE.up.y, + SHORE.left.x, SHORE.left.y, SHORE.right.x, SHORE.right.y)) + + -- ---- the bag, and a pond that never bites ------------------------------- + -- FISHGROUP_NONE is .FishNoFish: the cast, the pose and the rod are the same + -- as a bite's, and every run ends on "Not even a nibble!" back on the map + -- rather than in a battle nobody asked for. Put back before the hand-off. + + save.inventory = { [ROD_ITEM] = 1 } + save.bagOrder = { ROD_ITEM } + local mapDef = world.maps[MAP] + local realGroup = mapDef.fishGroup + mapDef.fishGroup = "FISHGROUP_NONE" + + -- ---- what the sprite actually drew -------------------------------------- + + local watch = nil + local realDrawTile = SpriteRenderer.drawTile + SpriteRenderer.drawTile = function(self, path, x, y, flip, quad) + if watch and path == sheet then + local qx, qy, qw, qh = 0, 0, 0, 0 + if quad and quad.getViewport then qx, qy, qw, qh = quad:getViewport() end + watch[#watch + 1] = { x = x, y = y, flip = flip and true or false, + qx = qx, qy = qy, qw = qw, qh = qh } + end + return realDrawTile(self, path, x, y, flip, quad) + end + + local function lastOfSize(calls, w, h) + local hit + for _, c in ipairs(calls) do + if c.qw == w and c.qh == h then hit = c end + end + return hit + end + + -- ---- walk to a shore ---------------------------------------------------- + + local function goToShore(facing) + local cell, d = SHORE[facing], DELTA[facing] + world:setMap(MAP, cell.x - d[1], cell.y - d[2], facing) + U.wait(24) + U.hold(game, facing, 26) + U.wait(10) + local p = world.player + if p.cellX ~= cell.x or p.cellY ~= cell.y then + world:setMap(MAP, cell.x, cell.y, facing) + U.wait(24) + p = world.player + end + local fx, fy = p.cellX + d[1], p.cellY + d[2] + check(("%s: standing at (%d,%d) with water in front") + :format(facing, p.cellX, p.cellY), + p.facing == facing and world.map:inBounds(fx, fy) + and Permissions.isWater(world.map:cellCollision(fx, fy)), + ("facing %s"):format(tostring(p.facing))) + end + + -- ---- START, PACK, KEY ITEMS, OLD ROD, USE -------------------------------- + + local function castRod(facing) + tap("start") + local menu = game.stack:top() + if not check(("%s: START opened the menu"):format(facing), + getmetatable(menu) == StartMenu) then + return false + end + for _ = 1, #menu.items do + local row = menu.list and menu.list:current() + if row and row.value == "pack" then break end + tap("down", 4) + end + tap("a", 10) + local pack = game.stack:top() + if not check(("%s: and the PACK is open"):format(facing), + getmetatable(pack) == PackMenu) then + return false + end + for _ = 1, 4 do + if pack:pocket().id == "KEY_ITEM" then break end + tap("right", 6) + end + check(("%s: on the KEY ITEMS pocket"):format(facing), + pack:pocket().id == "KEY_ITEM", pack:pocket().id) + for _ = 1, 20 do + local row = pack.rows[pack.index] + if row and row.id == ROD_ITEM then break end + tap("down", 4) + end + local row = pack.rows[pack.index] + if not check(("%s: cursor on the OLD ROD"):format(facing), + row ~= nil and row.id == ROD_ITEM, row and row.id) then + return false + end + tap("a", 8) + local sub = pack.submenu + check(("%s: the item submenu opened on USE"):format(facing), + sub ~= nil and sub.rows[sub.index] == "use", + sub and sub.rows[sub.index]) + tap("a", 10) + return true + end + + -- ---- one facing, end to end --------------------------------------------- + + local function castAndSample(facing, index) + goToShore(facing) + if not castRod(facing) then return end + + for _ = 1, 60 do + if world.fishing then break end + U.wait(1) + end + check(("%s: the rod is out (world.fishing)"):format(facing), + world.fishing ~= nil) + check(("%s: the pose is up (player.fishing)"):format(facing), + world.player.fishing == true) + check(("%s: and it drew from the fishing sheet"):format(facing), + world.player.fishSheet == sheet, world.player.fishSheet) + check(("%s: the PACK closed, so the cast is on screen"):format(facing), + getmetatable(game.stack:top()) ~= PackMenu) + + watch = {} + U.wait(6) + local calls = watch + watch = nil + local pose = lastOfSize(calls, 16, 8) + local rod = lastOfSize(calls, 8, 8) + check(("%s: a 16x8 pose row was drawn"):format(facing), pose ~= nil) + check(("%s: and the 8x8 rod tile beside it"):format(facing), rod ~= nil) + if pose and rod then + check(("%s: the pose row is sheet row %d") + :format(facing, POSE_ROW[facing]), + pose.qx == 0 and pose.qy == POSE_ROW[facing] * 8, + ("%d,%d"):format(pose.qx, pose.qy)) + check(("%s: the pose is%s x-flipped") + :format(facing, POSE_FLIP[facing] and "" or " not"), + pose.flip == POSE_FLIP[facing]) + local oam = ROD_OAM[facing] + check(("%s: the rod tile is $%02x on the sheet's rod row") + :format(facing, oam.tile), + rod.qy == ROD_ROW_Y and rod.qx == (oam.tile - 0xfc) * 8, + ("%d,%d"):format(rod.qx, rod.qy)) + check(("%s: the rod tile is%s x-flipped") + :format(facing, oam.flip and "" or " not"), + rod.flip == oam.flip) + -- The pose row sits at the sprite's bottom tile row, so the OAM offsets + -- are read back relative to it. + local drop = math.max(0, (world.player.sprite.frameHeight or 16) - 8) + check(("%s: the rod sits at OAM offset %d,%d from the sprite") + :format(facing, oam.dx, oam.dy), + rod.x - pose.x == oam.dx and rod.y - pose.y == oam.dy - drop, + ("%d,%d"):format(rod.x - pose.x, rod.y - pose.y + drop)) + end + U.shot(game, ("%s/%02d-fishing-%s.png"):format(out, index, facing)) + + -- PutTheRodAway: the pose comes down with the verdict box, not later. + local down = nil + for frame = 1, 300 do + if world.textbox then tap("a", 2) else U.wait(1) end + if not down and world.player.fishing == nil then down = frame end + if down and not world.textbox then break end + end + check(("%s: the pose came down with the box"):format(facing), down ~= nil) + watch = {} + U.wait(8) + local after = #watch + watch = nil + check(("%s: and nothing draws the rod afterwards"):format(facing), + after == 0, after) + end + + for i, facing in ipairs(FACINGS) do + castAndSample(facing, i) + end + + SpriteRenderer.drawTile = realDrawTile + mapDef.fishGroup = realGroup + + -- ---- hand off on a live cast -------------------------------------------- + + goToShore("right") + castRod("right") + U.wait(20) + + U.log(("%d shot(s) in %s, one per facing (#1708)."):format(#FACINGS, out)) + U.log("in each one the player's lower half is the two-handed fishing pose,") + U.log("not the standing legs, and one thin dark rod line runs from the hands") + U.log("into the water: straight down below the feet facing down, straight up") + U.log("above the head facing up, and a short stroke starting a tile clear of") + U.log("the sprite and sloping away from it facing left or right.") + U.log("the near miss to look for is the pose swapping in with no rod, or the") + U.log("rod as a garbage block, which is the sheet and the quads disagreeing;") + U.log("the other is a rod in flat DMG greys while the player is in colour.") + U.log("the pond bit on nothing for the four shots; the group is back now, so") + U.log("this last cast can bite. the controls are yours.") + if failures > 0 then + U.log(("%d check(s) failed above -- read those before looking at the shots") + :format(failures)) + end + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/gold_fly_anim_bug1711_test.lua b/tests/drivers/gold_fly_anim_bug1711_test.lua new file mode 100644 index 00000000..d8cb70d7 --- /dev/null +++ b/tests/drivers/gold_fly_anim_bug1711_test.lua @@ -0,0 +1,305 @@ +-- #1711: FLY is an animation, not a warp. .FlyScript hides the map's objects +-- and runs FlyFromAnim before WarpToSpawnPoint and FlyToAnim after the +-- newloadmap (../pokegold/engine/events/overworld.asm:595-609, and the two +-- animations at engine/events/field_moves.asm:300 and :334). Flown the way a +-- player flies: START > POKeMON > FLY out of New Bark Town. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_fly_anim_bug1711_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-fly \ +-- perl -e 'alarm 240; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- Do not add POKEPORT_SPEED. It scales the logic clock only, and the beat +-- being judged here is SFX_FLY against the wingbeat, which is audio against +-- logic -- fast-forward pulls those two apart. +local U = require("tests.drivers.util") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Mon = require("src.battle.gen2.Mon") + +local FLY_SPECIES = "PIDGEOTTO" + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-fly" + local fails = 0 + + local function ok(cond, msg) + if cond then + print("[fly] ok " .. msg) + else + fails = fails + 1 + print("[fly] FAIL " .. msg) + end + return cond + end + + local function tap(btn) U.tap(game, btn) U.wait(3) end + local function top() return game.stack:top() end + + U.wait(45) + local world = game.world + local save, data = game.save, game.data + if not ok(world and world.map, "gold booted into the overworld") then + error("gold fly: no world") + end + + local opts = save.options or {} + local sfxVol = opts.sfxVol or 7 + if sfxVol == 0 then + U.log("SFX volume is 0, so this run is silent whether the fix works or") + U.log("not. turn it up in OPTIONS and run again before judging the sound.") + end + ok(sfxVol > 0, ("SFX volume is %d"):format(sfxVol)) + + -- The wingbeat itself: no Sfx_Fly in the cache and the whole flight is + -- silent, which sounds exactly like the bug. + local audio = data and data.audio + local flySfx = world:sfxIdNamed("Sfx_Fly", 0x18) + local flySfxName = audio and audio.sfxOrder and audio.sfxOrder[flySfx + 1] + ok(flySfxName == "Sfx_Fly" and audio.sfx and audio.sfx[flySfxName] ~= nil, + ("the cache has Sfx_Fly at id %d"):format(flySfx)) + + -- FlyFunction .TryFly is CheckBadge STORMBADGE then CheckOutdoorMap + -- (../pokegold/engine/events/overworld.asm:544-551). + save.player = save.player or {} + if type(save.player.badges) ~= "table" then save.player.badges = {} end + save.player.badges.STORM = true + save.engineFlags = save.engineFlags or {} + for _, row in ipairs(FieldMoves.FLYPOINTS) do + save.engineFlags[row.flag] = true + end + + -- Mon.learnMove refuses a fifth move (src/battle/gen2/Mon.lua:650), and a + -- level-24 PIDGEOTTO already knows four. + local flyer = Mon.new(data, FLY_SPECIES, 24) + table.remove(flyer.moves, 1) + Mon.learnMove(flyer, "FLY", data) + save.party = { flyer } + local knowsFly = false + for _, entry in ipairs(flyer.moves or {}) do + if entry.id == "FLY" then knowsFly = true end + end + ok(knowsFly, FLY_SPECIES .. " knows FLY") + + -- FlyFunction_InitGFX's GetSpeciesIcon: no icon row for the species and + -- World:startFlyAnim refuses, which is the old instant warp back again. + local icons = data and data.gen2Icons + local iconId = icons and icons.species and icons.species[FLY_SPECIES] + local entry = iconId and icons.icons and icons.icons[iconId] + ok(entry and entry.image ~= nil, + ("%s flies on %s (%s)"):format(FLY_SPECIES, tostring(iconId), + tostring(entry and entry.image))) + + -- New Bark Town: a TOWN, so CheckOutdoorMap passes, and it keeps two objects + -- in view for HideSprites to take away -- the teacher at (6,8) and the + -- fisher at (12,9), ../pokecrystal/maps/NewBarkTown.asm:301-304. Scene 1 is + -- SCENE_NEWBARKTOWN_NOOP, so the teacher's coord event stays out of the way. + world.mapScenes = world.mapScenes or {} + world.mapScenes.NEW_BARK_TOWN = 1 + world:setMap("NEW_BARK_TOWN", 9, 8, "down") + U.wait(15) + local p = world.player + local function free(cx, cy) + return world.map:isWalkable(cx, cy) and not world:npcAt(cx, cy) + end + local DIRS = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, + right = { 1, 0 } } + if not free(p.cellX, p.cellY) then + for _, d in pairs(DIRS) do + if free(9 + d[1], 8 + d[2]) then + world:setMap("NEW_BARK_TOWN", 9 + d[1], 8 + d[2], "down") + U.wait(10) + break + end + end + end + ok(world.map.id == "NEW_BARK_TOWN" and free(p.cellX, p.cellY), + ("standing free on New Bark Town (%d,%d)"):format(p.cellX, p.cellY)) + ok((world.map.def and world.map.def.environment) == "TOWN", + "which is a TOWN, so FLY is allowed here") + ok(#world.npcs > 0, + ("with %d object(s) on the map to hide"):format(#world.npcs)) + + -- One real step through the pad, on a free neighbour. + local fromX, fromY = p.cellX, p.cellY + for name, d in pairs(DIRS) do + if free(fromX + d[1], fromY + d[2]) then + U.hold(game, name, 24) + break + end + end + U.wait(8) + ok(p.cellX ~= fromX or p.cellY ~= fromY, + ("the pad walked the player from (%d,%d) to (%d,%d)") + :format(fromX, fromY, p.cellX, p.cellY)) + local takeoffX, takeoffY = p.cellX, p.cellY + + -- Count the wingbeats, and only the ones the animation itself asks for. + local sfxFrom, sfxTo = 0, 0 + local realPlaySfx = world.playSfx + world.playSfx = function(self, id) + local fa = self.flyAnim + if fa and id == flySfx then + if fa.phase == "to" then sfxTo = sfxTo + 1 else sfxFrom = sfxFrom + 1 end + end + return realPlaySfx(self, id) + end + + -- START > POKeMON. CheckMenuOW only runs while nothing else owns the frame + -- (../pokegold/engine/overworld/events.asm), so wait the map's own settling + -- out rather than pressing into it. + for _ = 1, 240 do + if not world:busy() then break end + U.wait(2) + end + local menu + for _ = 1, 10 do + tap("start") + menu = top() + if menu and menu.screenId == "Gen2StartMenu" then break end + U.wait(6) + end + if not ok(menu and menu.screenId == "Gen2StartMenu", + "START opened the menu (top is " + .. tostring(menu and (menu.screenId or "?")) .. ")") then + error("gold fly: no start menu") + end + for _ = 1, 10 do + if menu.list:current().value == "pokemon" then break end + tap("down") + end + ok(menu.list:current().value == "pokemon", "the cursor found POKeMON") + tap("a") + + local party = top() + if not ok(party and party.screenId == "Gen2PartyMenu", + "POKeMON opened the party list") then + error("gold fly: no party list") + end + tap("a") + local sub = party.submenu + if not ok(sub ~= nil, "and A opened the action submenu") then + error("gold fly: no submenu") + end + local flyRow + for index, item in ipairs(sub.items) do + if item.id == "FLY" then flyRow = index end + end + if not ok(flyRow ~= nil, "which lists FLY") then + error("gold fly: the submenu has no FLY row") + end + for _ = 1, #sub.items do + if sub.index == flyRow then break end + tap("down") + end + tap("a") + U.wait(10) + + local picker = top() + if not ok(picker and picker.screenId == "Gen2Pokegear" and picker.fly, + "FLY opened the destination map") then + error("gold fly: no picker") + end + -- _FlyMap's .HandleDPad walks the visited rows; up is the next one along, + -- so one press leaves New Bark for the town after it. + tap("up") + U.wait(6) + local destination = picker:flyRow() + -- Landmark names carry TownMap_ConvertLineBreakCharacters' own , which + -- would break a log line in half. + ok(destination ~= nil, ("picked %s (%s)"):format( + (tostring(destination and destination.name):gsub("%s+", " ")), + tostring(destination and destination.spawn))) + + -- A commits, and .FlyScript starts on this map, not the far one. + U.tap(game, "a") + U.wait(3) + if not ok(world.flyAnim ~= nil and world.flyAnim.phase == "from", + "A started FlyFromAnim over the map being left") then + error("gold fly: the flight collapsed into a warp") + end + ok(world:busy(), "and the world is frozen under it, as the callasm is") + + local hoverFrames, minY, maxSwing = 0, 0, 0 + local toAmpFirst, toAmpLast, sawTo = nil, nil, false + local shots = { hover = false, climb = false, drop = false } + for _ = 1, 900 do + local fa = world.flyAnim + if fa and fa.phase == "from" then + if (fa.hover or 0) > 0 then hoverFrames = hoverFrames + 1 end + minY = math.min(minY, fa.y or 0) + maxSwing = math.max(maxSwing, math.abs(fa.xoff or 0)) + if not shots.hover and hoverFrames >= 20 then + shots.hover = true + U.shot(game, out .. "/01-fly-hover.png") + elseif not shots.climb and (fa.y or 0) <= -40 then + shots.climb = true + U.shot(game, out .. "/02-fly-climb.png") + end + elseif fa then + sawTo = true + toAmpFirst = toAmpFirst or fa.amp + toAmpLast = fa.amp + -- Mid-drop, not the first frame of it: the swing opens at 11 * 8 px, so + -- the icon starts the descent most of a screen off to one side. + if not shots.drop and (fa.y or 0) >= -44 and (fa.y or 0) <= -16 then + shots.drop = true + U.shot(game, out .. "/03-fly-drop.png") + end + elseif sawTo then + break + end + U.wait(1) + end + + -- FlyFromAnim: 0x40 frames on the tile, then 2px a frame off the top with + -- Sprites_Cosine widening the swing (engine/events/field_moves.asm:300-333). + ok(hoverFrames >= 48, + ("the icon held the take-off tile for %d frames"):format(hoverFrames)) + ok(minY <= -84, ("and climbed %d px off it"):format(-minY)) + ok(maxSwing >= 32, ("swinging up to %d px wide"):format(maxSwing)) + -- Nine beats going up (left 128 down to 64, every eighth), one coming down. + ok(sfxFrom == 9, ("SFX_FLY flapped %d times on the way up"):format(sfxFrom)) + ok(sfxTo == 1, ("and %d time on the way down"):format(sfxTo)) + ok(sawTo, "FlyToAnim ran on the far side of the warp") + ok(toAmpFirst and toAmpLast and toAmpLast < toAmpFirst, + ("with its swing damping from %s to %s"):format(tostring(toAmpFirst), + tostring(toAmpLast))) + ok(shots.hover and shots.climb and shots.drop, + "hover, climb and drop are all shot") + + U.wait(30) + ok(world.flyAnim == nil, "the animation is over") + ok(world.map.id ~= "NEW_BARK_TOWN", + ("and the player landed on %s at (%d,%d)"):format(world.map.id, + p.cellX, p.cellY)) + ok(not world:busy(), "with the controls handed back") + U.shot(game, out .. "/04-fly-landed.png") + ok(world.player.spriteYOffset == 0 or world.player.spriteYOffset == nil, + "standing flat on the tile, not still lifted") + + U.log(("flew %s out of New Bark Town from (%d,%d) for you (#1711). the"): + format(FLY_SPECIES, takeoffX, takeoffY)) + U.log("player's own sprite and every other object on the map should vanish") + U.log("the moment A is pressed, replaced by the mon's 16x16 party icon on") + U.log("that same tile, wings beating four times a second under a chirp of") + U.log("SFX_FLY, for about a second. only then does it climb off the top of") + U.log("the screen in a widening swing, and only then does the screen fade.") + U.log("on the far side the icon falls back in from above, its swing damping") + U.log("out, one more chirp, and the player pops in standing.") + U.log("shots are in " .. out .. ".") + U.log("the player still visible on the tile while the icon flies is the near") + U.log("miss for the hide; an icon sitting dead still for two seconds and") + U.log("then jumping is the hover without the climb.") + if fails > 0 then + U.log(("%d assertion(s) failed above -- read those before the pictures.") + :format(fails)) + end + U.log("the party still holds a flyer and the badge, so FLY again from the") + U.log("menu whenever you want another look. the controls are yours.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/gold_flymap_cursor_bug1712_test.lua b/tests/drivers/gold_flymap_cursor_bug1712_test.lua new file mode 100644 index 00000000..393642f6 --- /dev/null +++ b/tests/drivers/gold_flymap_cursor_bug1712_test.lua @@ -0,0 +1,234 @@ +-- #1712: the FLY map's cursor is the flying mon's party icon, not the MAP +-- card's arrow. FlyMap's .MapHud calls TownMapMon, which reads wCurPartyMon +-- and spawns SPRITE_ANIM_OBJ_PARTY_MON on that species' icon +-- (../pokecrystal/engine/pokegear/pokegear.asm:2326, :2708-2721). Reached the +-- way a player reaches it: START > POKeMON > FLY, standing in New Bark Town. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_flymap_cursor_bug1712_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-flymap \ +-- perl -e 'alarm 240; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- Do not add POKEPORT_SPEED: the icon's two-frame beat is half of what is +-- being judged here, and the logic clock is the only thing speed scales. +local U = require("tests.drivers.util") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Mon = require("src.battle.gen2.Mon") + +local FLY_SPECIES = "PIDGEOTTO" + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-flymap" + local fails = 0 + + local function ok(cond, msg) + if cond then + print("[flymap] ok " .. msg) + else + fails = fails + 1 + print("[flymap] FAIL " .. msg) + end + return cond + end + + local function tap(btn) U.tap(game, btn) U.wait(3) end + local function top() return game.stack:top() end + + U.wait(45) + local world = game.world + local save, data = game.save, game.data + if not ok(world and world.map, "gold booted into the overworld") then + error("gold flymap: no world") + end + + -- FlyFunction .TryFly is CheckBadge STORMBADGE then CheckOutdoorMap + -- (../pokegold/engine/events/overworld.asm:544-551). + save.player = save.player or {} + if type(save.player.badges) ~= "table" then save.player.badges = {} end + save.player.badges.STORM = true + save.engineFlags = save.engineFlags or {} + for _, row in ipairs(FieldMoves.FLYPOINTS) do + save.engineFlags[row.flag] = true + end + + -- Mon.learnMove refuses a fifth move (src/battle/gen2/Mon.lua:650), and a + -- level-24 PIDGEOTTO already knows four. + local flyer = Mon.new(data, FLY_SPECIES, 24) + table.remove(flyer.moves, 1) + Mon.learnMove(flyer, "FLY", data) + save.party = { flyer } + local knowsFly = false + for _, entry in ipairs(flyer.moves or {}) do + if entry.id == "FLY" then knowsFly = true end + end + ok(knowsFly, FLY_SPECIES .. " knows FLY") + + -- The asset side, which fails exactly as silently as the bug: no icon row + -- for the species and drawFlyMonCursor gives the arrow back. + local icons = data and data.gen2Icons + local iconId = icons and icons.species and icons.species[FLY_SPECIES] + local entry = iconId and icons.icons and icons.icons[iconId] + ok(iconId ~= nil, "the cache maps " .. FLY_SPECIES .. " to an icon (" + .. tostring(iconId) .. ")") + ok(entry and entry.image ~= nil, "and that icon has a sheet (" + .. tostring(entry and entry.image) .. ")") + if entry and entry.image and love.filesystem.getInfo then + ok(love.filesystem.getInfo(entry.image) ~= nil, + "and the sheet is on disk") + end + + -- New Bark Town is a TOWN, so TryFly's CheckOutdoorMap passes, and it has + -- two wandering objects to watch -- the teacher at (6,8) and the fisher at + -- (12,9), ../pokecrystal/maps/NewBarkTown.asm:301-304. Scene 1 is + -- SCENE_NEWBARKTOWN_NOOP, so the teacher's coord event stays out of the way. + world.mapScenes = world.mapScenes or {} + world.mapScenes.NEW_BARK_TOWN = 1 + world:setMap("NEW_BARK_TOWN", 9, 8, "down") + U.wait(15) + local p = world.player + local function free(cx, cy) + return world.map:isWalkable(cx, cy) and not world:npcAt(cx, cy) + end + local DIRS = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, + right = { 1, 0 } } + if not free(p.cellX, p.cellY) then + for _, d in pairs(DIRS) do + if free(9 + d[1], 8 + d[2]) then + world:setMap("NEW_BARK_TOWN", 9 + d[1], 8 + d[2], "down") + U.wait(10) + break + end + end + end + ok(world.map.id == "NEW_BARK_TOWN" and free(p.cellX, p.cellY), + ("standing free on New Bark Town (%d,%d)"):format(p.cellX, p.cellY)) + ok((world.map.def and world.map.def.environment) == "TOWN", + "which is a TOWN, so FLY is allowed here") + + -- One real step through the pad, on a free neighbour. + local fromX, fromY = p.cellX, p.cellY + for name, d in pairs(DIRS) do + if free(fromX + d[1], fromY + d[2]) then + U.hold(game, name, 24) + break + end + end + U.wait(8) + ok(p.cellX ~= fromX or p.cellY ~= fromY, + ("the pad walked the player from (%d,%d) to (%d,%d)") + :format(fromX, fromY, p.cellX, p.cellY)) + + -- START > POKeMON. CheckMenuOW only runs while nothing else owns the frame + -- (../pokegold/engine/overworld/events.asm), so wait the map's own settling + -- out rather than pressing into it. + for _ = 1, 240 do + if not world:busy() then break end + U.wait(2) + end + local menu + for _ = 1, 10 do + tap("start") + menu = top() + if menu and menu.screenId == "Gen2StartMenu" then break end + U.wait(6) + end + if not ok(menu and menu.screenId == "Gen2StartMenu", + "START opened the menu (top is " + .. tostring(menu and (menu.screenId or "?")) .. ")") then + error("gold flymap: no start menu") + end + for _ = 1, 10 do + if menu.list:current().value == "pokemon" then break end + tap("down") + end + ok(menu.list:current().value == "pokemon", "the cursor found POKeMON") + tap("a") + + local party = top() + if not ok(party and party.screenId == "Gen2PartyMenu", + "POKeMON opened the party list") then + error("gold flymap: no party list") + end + + -- A on the flyer, then down the submenu to its FLY row. + tap("a") + local sub = party.submenu + if not ok(sub ~= nil, "and A opened the action submenu") then + error("gold flymap: no submenu") + end + local flyRow + for index, item in ipairs(sub.items) do + if item.id == "FLY" then flyRow = index end + end + if not ok(flyRow ~= nil, "which lists FLY") then + error("gold flymap: the submenu has no FLY row") + end + for _ = 1, #sub.items do + if sub.index == flyRow then break end + tap("down") + end + ok(sub.index == flyRow, "the cursor found FLY") + tap("a") + U.wait(10) + + local picker = top() + if not ok(picker and picker.screenId == "Gen2Pokegear", + "FLY opened the town map") then + error("gold flymap: FLY did not open the picker") + end + ok(picker.fly ~= nil and #picker.fly > 0, + "in _FlyMap dress, with " .. tostring(picker.fly and #picker.fly) + .. " flypoints to walk") + -- wCurPartyMon, as TownMapMon reads it (World:openFlyMap's flyMon). + ok(picker.flyMon == flyer, "and it was handed the mon that used the move") + ok(picker.icons ~= nil, "and it can see the icon table") + + U.wait(12) + ok(picker.flyMonIcon ~= nil and picker.flyMonIcon ~= false, + "the mon icon loaded for the cursor") + -- No objColors, no OBP bake, and the bake is what keys colour 0 transparent + -- (src/render/SpriteRenderer.lua:243): the icon would sit in a white box. + ok(picker.flyMonIcon and picker.flyMonIcon.objColors ~= nil, + "with an OBJ palette baked onto it") + + -- Landmark names carry TownMap_ConvertLineBreakCharacters' own , which + -- would break a log line in half. + local function flat(entry) + return (tostring(entry and entry.name):gsub("%s+", " ")) + end + + local before = picker:mapLandmark() + local player = picker:playerLandmark() + ok(before and before.x and before.y, "the cursor is on " .. flat(before)) + U.shot(game, out .. "/01-flymap-newbark.png") + + tap("up") + U.wait(8) + local after = picker:mapLandmark() + ok(after and (after.x ~= before.x or after.y ~= before.y), + ("up walked the cursor to %s at (%s,%s)"):format(flat(after), + tostring(after and after.x), tostring(after and after.y))) + ok(picker:playerLandmark() == player, + "and the player's own icon stayed where it was") + U.shot(game, out .. "/02-flymap-cherrygrove.png") + + U.log("flew the party list into the FLY map for you (#1712). the cursor over") + U.log("the highlighted town should be " .. FLY_SPECIES .. "'s 16x16 party") + U.log("icon, red like the little Chris icon parked on New Bark, swapping") + U.log("between its two frames about twice a second, and it moves with") + U.log("up/down while Chris stays put. shots are in " .. out .. ".") + U.log("the arrow cursor instead of a mon is the bug. a mon in a solid white") + U.log("box is the near miss -- the icon is right, the palette bake is not.") + if fails > 0 then + U.log(("%d assertion(s) failed above; the picture below is not worth") + :format(fails)) + U.log("reading until those are green.") + end + U.log("the picker is open and the controls are yours.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/gold_frame_seams.lua b/tests/drivers/gold_frame_seams.lua index 68526a20..e09d8639 100644 --- a/tests/drivers/gold_frame_seams.lua +++ b/tests/drivers/gold_frame_seams.lua @@ -8,22 +8,27 @@ -- Two halves, and the second is the one that matters. A hook that fires is -- easy; a hook that fires and MOVES A PIXEL is worse than no hook at all, -- because it silently changes what every existing Gold screenshot means. So --- this shoots the overworld, a menu over the overworld, CLASSIC, and CLASSIC + --- GBC FX with nothing subscribed, wraps all six hooks with pass-throughs that --- draw nothing, shoots the same four frames again, and compares the PNG bytes. +-- this shoots the overworld, CLASSIC, and a menu over the overworld with +-- nothing subscribed, wraps all six hooks with pass-throughs that draw +-- nothing, shoots the same three frames again, and compares the PNG bytes. -- Identical files are the claim; the shots are left on disk either way so a -- human can look at the picture the port is actually producing. -- --- The four frames are chosen to cover every branch of Game2:draw: no canvas at --- all, the zone pass alone, the zone pass into a texture GBC FX then reads, and --- (once the wraps are on) the render.compose path that forces a canvas even --- when no display mode wanted one. +-- The three frames are chosen to cover every branch of Game2:draw: no canvas +-- at all, the zone pass alone, and (once the wraps are on) the render.compose +-- path that forces a canvas even when no display mode wanted one. +-- +-- The zone-pass-into-a-texture-then-reread branch (`reread` in +-- Game2:drawViewportFrame, formerly exercised by CLASSIC + GBC FX before +-- GBCFX.lua's removal) is not covered here any more: +-- ShaderFX is the only thing left that triggers it, and activating it needs +-- a converted on-disk preset this headless driver does not set up. Flagged, +-- not silently dropped. local U = require("tests.drivers.util") local Runtime = require("src.mods.Runtime") local Hooks = require("src.mods.Hooks") local GbcPalette = require("src.render.GbcPalette") -local GBCFX = require("src.render.GBCFX") local OWNER = "driver_frame_seams" @@ -50,23 +55,17 @@ return function(game) U.wait(45) assert(game.world and game.world.map, "gold world did not boot") - -- The four frames, each named for the display state it exercises. `fx` is - -- requested rather than asserted: GBCFX.isSupported refuses on mobile GPUs - -- and a headless checkout may have no shader at all, in which case that - -- frame simply repeats the CLASSIC one and the comparison still means - -- something. + -- The three frames, each named for the display state it exercises. local frames = { - { name = "world", color = "gbc", fx = 0 }, - { name = "classic", color = "classic", fx = 0 }, - { name = "classicfx", color = "classic", fx = 2 }, - { name = "menu", color = "gbc", fx = 0, menu = true }, + { name = "world", color = "gbc" }, + { name = "classic", color = "classic" }, + { name = "menu", color = "gbc", menu = true }, } local function shoot(tag) local menuOpen = false for _, frame in ipairs(frames) do GbcPalette.setMode(frame.color) - GBCFX.setLevel(frame.fx) if frame.menu and not menuOpen then game:openStartMenu() menuOpen = true @@ -82,7 +81,6 @@ return function(game) U.wait(8) end GbcPalette.setMode("gbc") - GBCFX.setLevel(0) end -- POKEPORT_SEAM_BASELINE= shoots the four frames under that tag and diff --git a/tests/drivers/gold_ledge_hop_bug1713_test.lua b/tests/drivers/gold_ledge_hop_bug1713_test.lua new file mode 100644 index 00000000..10c4084e --- /dev/null +++ b/tests/drivers/gold_ledge_hop_bug1713_test.lua @@ -0,0 +1,272 @@ +-- #1713: a ledge hop advanced only every other logic frame, so the whole map +-- scrolled at 30Hz in 2px lurches and the sprite bobbed against its own arc. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_ledge_hop_bug1713_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-ledge \ +-- perl -e 'alarm 240; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- jump_step is STEP_WALK (pokegold/engine/overworld/movement.asm:595-597), so +-- StepFunction_PlayerJump runs the `db 0, 2, 8, 2` StepVectors row as two +-- 8-frame beats (engine/overworld/map_objects.asm:365-381, :1163-1200) and +-- UpdateJumpPosition walks its 16-entry arc one entry a frame (:1796-1817). +-- We render at twice that resolution, so the hop owes 1px and half an arc +-- entry on every one of its 32 frames. +-- +-- No POKEPORT_SPEED here on purpose: the whole subject is per-frame motion, +-- and fast-forward scales only the logic clock. +local U = require("tests.drivers.util") + +local Player = require("src.world.gen2.Player") +local Permissions = require("src.world.gen2.Permissions") + +-- ROUTE_30's ledge run at y=10 (data/generated/maps.lua, TILESET_JOHTO +-- collision $a3 COLL_HOP_DOWN): open ground two cells above it, a wall in +-- between, floor on the landing. Re-derived by scanning if a re-import moves. +local MAP = "ROUTE_30" +local LEDGE = { x = 4, y = 10 } + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-ledge" + local fails, lines = 0, {} + + local function claim(ok, text) + if not ok then fails = fails + 1 end + lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text + return ok + end + + local function stop() + for _, line in ipairs(lines) do U.log(line) end + while true do coroutine.yield() end + end + + U.wait(45) + local world = game.world + if not (world and world.map) then + U.log("FAIL the gold world never booted, nothing to look at") + while true do coroutine.yield() end + end + + claim(Player.STEP_FRAMES == 16, "a walk is 16 logic frames per cell") + claim(type(world.tryLedgeJump) == "function", + "World:tryLedgeJump is wired for the refused step to fall into") + + -- ---- find a ledge with room to run up to it ------------------------------ + world:setMap(MAP, LEDGE.x, LEDGE.y - 2, "down") + U.wait(10) + world.noWildEncounters = true + local map = world.map + + local function hopsDown(x, y) + if not map:inBounds(x, y) then return false end + local facings = Permissions.ledgeFacings(map:cellCollision(x, y)) + return facings ~= nil and facings.down == true + end + local function walkable(x, y) + return map:inBounds(x, y) and map:isWalkable(x, y) + end + -- A usable ledge: two free cells to walk down through, a refused cell in the + -- middle (that refusal is what becomes the jump) and a free landing. + local function usable(x, y) + return hopsDown(x, y) and not walkable(x, y + 1) and walkable(x, y + 2) + and walkable(x, y - 1) and walkable(x, y - 2) + end + + local target + if usable(LEDGE.x, LEDGE.y) then target = { x = LEDGE.x, y = LEDGE.y } end + if not target then + local def = world.maps[MAP] + for y = 0, (def.height or 0) * 2 - 1 do + for x = 0, (def.width or 0) * 2 - 1 do + if not target and usable(x, y) then target = { x = x, y = y } end + end + end + end + claim(target ~= nil, + ("%s still has a hop-down ledge with a run-up to it"):format(MAP)) + if not target then + U.log("nothing to hop off; stopping rather than parking you at a wall") + stop() + end + if target.x ~= LEDGE.x or target.y ~= LEDGE.y then + U.log(("note: using the ledge at (%d,%d), not the (%d,%d) in the header") + :format(target.x, target.y, LEDGE.x, LEDGE.y)) + end + local START = { x = target.x, y = target.y - 2 } + + local function press(dir) + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + coroutine.yield() + end + + -- Back to the top of the run-up. The settle first is load bearing: a + -- setMap on top of a hop that is still in the air carries the leftover jump + -- into the new position and the next run measures nonsense. + local function reset() + game.input.state.down = false + for _ = 1, 120 do + if not (world.player and world.player.moving) then break end + coroutine.yield() + end + world:setMap(MAP, START.x, START.y, "down") + world.noWildEncounters = true + U.wait(8) + end + + -- ---- the run-up and the hop, measured ------------------------------------ + -- + -- Nobody can count pixels per frame by eye, and this is the whole bug: the + -- per-frame travel of the camera-following position, plus the arc riding on + -- top of it. Every sample is "pixels travelled == frames elapsed", never a + -- difference between two driver frames -- the fixed-step loop can run two + -- logic steps for one yield, and a delta would read that as a lurch. + reset() + local p = world.player + local hopRows, takeoff = {}, 0 + local walkSeen, walkWrong = 0, 0 + for _ = 1, 240 do + press("down") + if p.jumping then + if #hopRows == 0 then takeoff = p.cellY * 16 end + hopRows[#hopRows + 1] = { n = p.progress, py = p.py, + off = p.spriteYOffset or 0 } + elseif #hopRows > 0 then + break + elseif p.moving and (p.progress or 0) > 0 then + walkSeen = walkSeen + 1 + if p.py - p.cellY * 16 ~= p.progress then walkWrong = walkWrong + 1 end + end + end + game.input.state.down = false + + local wrong, lurch, last = 0, 0, 0 + for _, r in ipairs(hopRows) do + if r.py - takeoff ~= r.n then wrong = wrong + 1 end + if r.n > last then last = r.n end + end + -- The old quantum, named directly: an even number of pixels on an odd frame. + for _, r in ipairs(hopRows) do + if (r.py - takeoff) % 2 == 0 and r.n % 2 == 1 then lurch = lurch + 1 end + end + + claim(#hopRows > 0, "walking down into the ledge started a hop") + claim(last >= 30, + ("the hop ran its full 32 frames (the last one sampled was %d)") + :format(last)) + claim(p.cellY == target.y + 2, + ("it landed two cells down at y=%d (the player is at y=%d)") + :format(target.y + 2, p.cellY)) + claim(p.py - takeoff == 32, + ("and 32 pixels down, which is two cells (it moved %d)") + :format(p.py - takeoff)) + claim(wrong == 0, + ("on every one of the %d frames sampled the player had moved a pixel per" + .. " frame elapsed (%d had not)"):format(#hopRows, wrong)) + claim(lurch == 0, + ("so no frame of it stood still waiting to lurch two (%d did)") + :format(lurch)) + claim(walkSeen > 0 and walkWrong == 0, + ("the two ordinary steps before it move a pixel a frame too, on all %d" + .. " frames sampled (%d wrong)"):format(walkSeen, walkWrong)) + + -- The composed screen position: the camera-following py plus the sprite + -- offset. The armed frame is skipped because the first frame after it is + -- the take-off, and rising there is the jump starting. + local air, back = {}, 0 + for _, r in ipairs(hopRows) do + if r.n >= 1 then air[#air + 1] = r.py + r.off end + end + for i = 2, #air do + if air[i] < air[i - 1] then back = back + 1 end + end + claim(back == 0, + ("after the take-off the sprite never reversed against its own arc" + .. " (%d frames did)"):format(back)) + + -- ---- one frame per run, so the strip really is consecutive --------------- + -- + -- A capture costs the driver an unknown number of frames, so shooting four + -- frames in one hop would skip some. Four identical run-ups, each shot on a + -- different frame, gives a strip that can be flipped through. + -- One run-up, ending either on the frame asked for or on the landing. The + -- fixed-step loop can occasionally run two logic steps for one driver yield, + -- so the shot is taken at the first frame AT OR PAST the one asked for, and + -- the frame it actually landed on is reported rather than assumed. + local function hopRun(frame, path) + reset() + local airborne = false + for _ = 1, 240 do + press("down") + local pl = world.player + if pl.jumping then + airborne = true + if frame and pl.progress >= frame then + local at, py = pl.progress, pl.py + game.input.state.down = false + U.shot(game, path) + return at, py + end + elseif airborne then + break + end + end + game.input.state.down = false + end + + reset() + U.shot(game, out .. "/01-standing.png") + local strip = {} + for i, frame in ipairs({ 12, 13, 14, 15 }) do + local at, py = hopRun(frame, ("%s/02-strip-%d.png"):format(out, i)) + strip[#strip + 1] = { at = at or -1, py = py or -1 } + end + hopRun(4, out .. "/03-rising.png") + hopRun(16, out .. "/04-peak.png") + hopRun(28, out .. "/05-falling.png") + hopRun() + U.wait(20) + U.shot(game, out .. "/06-landed.png") + + local consecutive = true + local shown = {} + for i, s in ipairs(strip) do + shown[i] = ("%d"):format(s.at) + if i > 1 and (s.at - strip[i - 1].at ~= 1 + or s.py - strip[i - 1].py ~= 1) then + consecutive = false + end + end + claim(consecutive, + ("the strip really is four consecutive frames, %s -- if this is FAIL the" + .. " images are not comparable"):format(table.concat(shown, ", "))) + + for _, line in ipairs(lines) do U.log(line) end + U.log(("%d checks, %d failed"):format(#lines, fails)) + if fails > 0 then + U.log("something above is FAIL, so do not spend time watching the replay") + end + + -- ---- the replay, live ---------------------------------------------------- + U.log("shots are in " .. out .. "; 02-strip-* are consecutive frames of the") + U.log("same hop, so the ground should shift one pixel between each, and") + U.log("03 / 04 / 05 are the rise, the top of the arc and the drop.") + U.log("the replay walks down into the ledge three times, then hands over.") + U.wait(120) + for _ = 1, 3 do + hopRun() + U.wait(60) + end + + U.log("two ordinary steps down, then the hop: the map should scroll at the") + U.log("same speed through all three, and the sprite should rise, hang and") + U.log("fall once. a stutter, a 2px lurch, or the sprite bobbing back up") + U.log("during the rise is the old behaviour.") + U.log("the near miss to watch for: a smooth hop that clears only one cell,") + U.log("or one that takes twice as long as it should -- that is the two-cell") + U.log("span applied without the doubled duration.") + U.log("gen 1 is the reference; a Red ledge hop already looked like this.") + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_ow_bounce_bug1748_test.lua b/tests/drivers/gold_ow_bounce_bug1748_test.lua new file mode 100644 index 00000000..da492890 --- /dev/null +++ b/tests/drivers/gold_ow_bounce_bug1748_test.lua @@ -0,0 +1,328 @@ +-- #1748: overworld Pokemon objects (SPRITEMOVEDATA_POKEMON, $16) never bounce. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_ow_bounce_bug1748_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-bounce \ +-- perl -e 'alarm 240; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- The $16 row is SPRITEMOVEFN_BOUNCE + OBJECT_ACTION_BOUNCE +-- (data/sprites/map_objects.asm:181-187). SetFacingBounce steps +-- OBJECT_STEP_FRAME once a frame and reads bit 3, swapping FACING_STEP_DOWN_0 +-- for FACING_STEP_UP_0 -- eight frames on each (map_object_action.asm:184-202). +-- Its frozen column, SetFacingFreezeBounce, pins FACING_STEP_DOWN_0. +-- +-- No POKEPORT_SPEED here on purpose: an eight-frame dwell is the whole thing +-- being watched, and fast-forward scales only the logic clock. +local U = require("tests.drivers.util") + +local NPC = require("src.world.gen2.Npc") + +-- ../pokegold/maps/PokemonFanClub.asm:311-316 -- the bouncing SPRITE_ODDISH at +-- (7,3) shares the room with a SPRITE_FAIRY doll at (2,4) on +-- SPRITEMOVEDATA_STANDING_DOWN, which is the negative control standing next to +-- it. ../pokegold/maps/PewterPokecenter1F.asm is the reporter's own room. +local ROOM = { map = "POKEMON_FAN_CLUB", x = 7, y = 3 } +local PEWTER = { map = "PEWTER_POKECENTER_1F", x = 1, y = 3 } + +local SPRITEMOVEDATA_POKEMON = 0x16 +local DELTA = { + up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 }, +} +local FACE_FROM = { up = "down", down = "up", left = "right", right = "left" } + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bounce" + local fails, lines = 0, {} + + local function claim(ok, text) + if not ok then fails = fails + 1 end + lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text + return ok + end + + local function stop(why) + for _, line in ipairs(lines) do U.log(line) end + U.log(why) + while true do coroutine.yield() end + end + + U.wait(45) + local world = game.world + if not (world and world.map) then + U.log("FAIL the gold world never booted, nothing to look at") + while true do coroutine.yield() end + end + + -- ---- the things a human's eyes cannot check ------------------------------ + -- + -- Every one of these fails as silence: a missing movement byte, a sheet with + -- no second frame and a doll that quietly took the animation all look exactly + -- like "the object sits there", which is also what the bug looked like. + claim(NPC.MOVE.POKEMON == SPRITEMOVEDATA_POKEMON, + "SPRITEMOVEDATA_POKEMON is modelled as $16") + claim(type(NPC.bounceFrame) == "function", + "NPC:bounceFrame exists for the draw call to spend") + + local function findObject(mapId, x, y) + local def = world.maps[mapId] + if not def then return nil end + local exact, any + for _, obj in ipairs(def.objects or {}) do + if obj.movement == SPRITEMOVEDATA_POKEMON then + any = any or obj + if obj.x == x and obj.y == y then exact = obj end + end + end + return exact or any + end + + local monDef = findObject(ROOM.map, ROOM.x, ROOM.y) + claim(monDef ~= nil, + ("%s still carries a $16 object"):format(ROOM.map)) + if not monDef then + stop("no bouncing object on the map; stopping rather than faking the moment") + end + if monDef.x ~= ROOM.x or monDef.y ~= ROOM.y then + U.log(("note: using the mon at (%d,%d), not the (%d,%d) in the header") + :format(monDef.x, monDef.y, ROOM.x, ROOM.y)) + end + + local sheet = type(monDef.sprite) == "string" + and world.sprites and world.sprites[monDef.sprite] + claim(sheet ~= nil, + ("its sprite %s resolves to a sheet"):format(tostring(monDef.sprite))) + claim(sheet and (sheet.frames or 1) >= 2, + ("and that sheet is two frames deep (frames = %s) -- one frame is the half" + .. " of #1748 a re-import fixes"):format(sheet and tostring(sheet.frames))) + claim(world.scripts and world.scripts[monDef.scriptKey] ~= nil, + ("its script %s is in the cache, so the A press below opens a box") + :format(tostring(monDef.scriptKey))) + + -- ---- walk in and stand in front of it ------------------------------------ + world:setMap(ROOM.map, monDef.x, monDef.y + 1, "up") + U.wait(20) + local map = world.map + + -- Stand on the mon's neighbour and face back at it; any free neighbour will + -- do, so a re-import that shuffles the room degrades instead of parking the + -- player at a wall. + local spot + for _, dir in ipairs({ "down", "left", "right", "up" }) do + local d = DELTA[dir] + local x, y = monDef.x + d[1], monDef.y + d[2] + if not spot and map:isWalkable(x, y) then + spot = { x = x, y = y, facing = FACE_FROM[dir] } + end + end + claim(spot ~= nil, "there is a free cell beside it to stand on") + if not spot then + stop("nowhere to stand; stopping rather than parking the player at a wall") + end + world:setMap(ROOM.map, spot.x, spot.y, spot.facing) + U.wait(20) + + local mon, dolls = nil, {} + for _, npc in ipairs(world.npcs or {}) do + if npc.def == monDef then mon = npc + elseif npc.def and npc.def.movement ~= SPRITEMOVEDATA_POKEMON then + dolls[#dolls + 1] = npc + end + end + claim(mon ~= nil, "the object spawned as an NPC") + if not mon then stop("the mon never spawned; nothing to watch") end + claim(mon.bouncing == true, "and it spawned bouncing") + + local stillBouncers = 0 + for _, npc in ipairs(dolls) do + if npc.bouncing then stillBouncers = stillBouncers + 1 end + end + claim(stillBouncers == 0, + ("none of the %d other objects in the room bounce -- the Clefairy doll" + .. " beside it must not move"):format(#dolls)) + + -- ---- the cycle, measured on the logic clock ------------------------------ + -- One sample per frame, and the run lengths are what SetFacingBounce's bit 3 + -- decides. Measured rather than eyeballed because four flips a second is + -- exactly the rate a human reads as "about right" when it is wrong. + local samples = {} + for i = 1, 96 do + samples[i] = mon:bounceFrame() + coroutine.yield() + end + local runs, values = {}, {} + for i = 1, #samples do + if i > 1 and samples[i] == samples[i - 1] then + runs[#runs] = runs[#runs] + 1 + else + runs[#runs + 1] = 1 + values[#values + 1] = samples[i] + end + end + claim(#runs >= 5, ("the pose changed %d times in 96 frames"):format(#runs - 1)) + local alternates, dwell, uneven = true, nil, nil + for i = 2, #runs - 1 do + dwell = dwell or runs[i] + if runs[i] ~= dwell then uneven = uneven or runs[i] end + if values[i] == values[i - 1] then alternates = false end + end + claim(alternates and values[1] ~= nil, + "it alternates between two poses and only two") + claim(uneven == nil, ("every whole dwell is the same length" + .. (uneven and (" -- saw %d and %d"):format(dwell or 0, uneven) or ""))) + claim(dwell == 8, ("and it is the eight frames bit 3 buys (measured %s)") + :format(tostring(dwell))) + + -- ---- what the renderer was actually handed ------------------------------- + -- A bounce the draw call drops looks exactly like no bounce, and a doll that + -- picked the override up is the other way to pass this by accident. + local seen, watched = {}, { { key = "mon", npc = mon } } + for i, npc in ipairs(dolls) do + watched[#watched + 1] = { key = "doll" .. i, npc = npc } + end + for _, row in ipairs(watched) do + local sprite = row.npc.sprite + row.sprite, row.real = sprite, sprite.draw + seen[row.key] = {} + sprite.draw = function(s, ...) + local args = { ... } + local log = seen[row.key] + -- "none" rather than a hole, so a sprite that was never drawn at all is + -- not mistaken for one that was drawn with no override. + log[#log + 1] = args[10] == nil and "none" or args[10] + return row.real(s, ...) + end + end + U.wait(40) + for _, row in ipairs(watched) do row.sprite.draw = row.real end + + local monPoses = {} + for _, v in ipairs(seen.mon) do monPoses[v] = true end + claim(#seen.mon > 0, "the mon was drawn") + claim(monPoses[0] and monPoses[1], + "and both icon frames reached SpriteRenderer over 40 frames") + local dollDraws, dollOverrides = 0, 0 + for i = 1, #dolls do + for _, v in ipairs(seen["doll" .. i] or {}) do + dollDraws = dollDraws + 1 + if v ~= "none" then dollOverrides = dollOverrides + 1 end + end + end + claim(dollDraws > 0, ("the other %d objects were drawn too"):format(#dolls)) + claim(dollOverrides == 0, "and not one of them was handed a frame override") + + -- ---- the pair of shots --------------------------------------------------- + -- The counter is held while each capture lands so the file is definitely one + -- pose rather than whichever the spin drifted onto; the cycle above is what + -- proves it runs on its own. + local function holdShot(path, step) + os.execute('mkdir -p "' .. path:match("^(.*)/[^/]+$") .. '" 2>/dev/null') + game.capturePath = path + for _ = 1, 120 do + mon.bounceStep = step + if not game.capturePath then break end + coroutine.yield() + end + mon.bounceStep = step + coroutine.yield() + local f = io.open(path, "rb") + if not f then return nil end + local bytes = f:read("*a") + f:close() + return bytes + end + + local down = holdShot(out .. "/01-pose-down.png", 0) + local up = holdShot(out .. "/02-pose-up.png", 8) + claim(down ~= nil and up ~= nil, "both pose shots reached disk") + claim(down and up and down ~= up, + "and the two files differ, so the screen really changes between poses") + + -- ---- talking to it holds the first pose ---------------------------------- + -- OBJECT_ACTION_BOUNCE's frozen column is SetFacingFreezeBounce, which writes + -- FACING_STEP_DOWN_0 and never touches the counter. + mon.bounceStep = 12 + local phaseBefore = mon.bounceStep + U.tap(game, "a") + U.wait(30) + claim(world.talkNpc == mon or mon.frozen, + "the A press reached the mon and froze it") + local heldWrong = nil + for _ = 1, 60 do + if mon:bounceFrame() ~= 0 then heldWrong = mon:bounceFrame() end + coroutine.yield() + end + claim(heldWrong == nil, + "and it holds its first pose for the whole conversation") + claim(mon.bounceStep == phaseBefore, + ("with the step counter left at %d, so the bounce resumes on the phase it" + .. " froze at (it is %s)"):format(phaseBefore, tostring(mon.bounceStep))) + U.shot(game, out .. "/03-frozen-talking.png") + + U.tap(game, "a") + U.wait(40) + U.tap(game, "a") + U.wait(40) + + -- ---- the reporter's own room --------------------------------------------- + local pewterDef = findObject(PEWTER.map, PEWTER.x, PEWTER.y) + if not pewterDef then + U.log("note: no $16 object on " .. PEWTER.map .. ", skipping its shots") + else + world:setMap(PEWTER.map, pewterDef.x, pewterDef.y + 2, "up") + U.wait(30) + local jiggly + for _, npc in ipairs(world.npcs or {}) do + if npc.def == pewterDef then jiggly = npc end + end + claim(jiggly ~= nil and jiggly.bouncing == true, + ("the %s at (%d,%d) bounces too"):format(PEWTER.map, + pewterDef.x, pewterDef.y)) + if jiggly then + local a, b + local function pin(path, step) + os.execute('mkdir -p "' .. path:match("^(.*)/[^/]+$") .. '" 2>/dev/null') + game.capturePath = path + for _ = 1, 120 do + jiggly.bounceStep = step + if not game.capturePath then break end + coroutine.yield() + end + coroutine.yield() + local f = io.open(path, "rb") + if not f then return nil end + local bytes = f:read("*a") + f:close() + return bytes + end + a = pin(out .. "/04-pewter-down.png", 0) + b = pin(out .. "/05-pewter-up.png", 8) + claim(a ~= nil and b ~= nil and a ~= b, + "and its two pose shots differ as well") + end + end + + for _, line in ipairs(lines) do U.log(line) end + U.log(("%d checks, %d failed"):format(#lines, fails)) + if fails > 0 then + U.log("something above says FAIL, so do not spend time watching the room.") + end + + -- ---- what to look at ----------------------------------------------------- + U.log("shots are in " .. out .. ". 01 and 02 are the two poses of the same") + U.log("mon; they should look like the two halves of its party menu icon,") + U.log("the second one sitting a pixel or two differently. 03 is the same mon") + U.log("mid-conversation, which must match 01.") + U.log("") + U.log("the run ends back in the Fan Club with the Oddish on screen at (7,3).") + U.log("it should visibly flip between its two icon poses about four times a") + U.log("second, without moving off its tile or turning to face anything.") + U.log("the Clefairy doll at (2,4) and the people in the room must stay dead") + U.log("still: a doll that bobs means the bounce got keyed off the sprite") + U.log("instead of the movement byte. a mon that keeps bobbing while its text") + U.log("box is open is the other near miss -- it must hold one pose there and") + U.log("pick the cycle back up where it left off when the box closes.") + + world:setMap(ROOM.map, spot.x, spot.y, spot.facing) + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_pack_rows_bug1695_test.lua b/tests/drivers/gold_pack_rows_bug1695_test.lua new file mode 100644 index 00000000..2efb9cf7 --- /dev/null +++ b/tests/drivers/gold_pack_rows_bug1695_test.lua @@ -0,0 +1,243 @@ +-- The PACK's TM/HM rows (#1695), the list cursor's colour (#1694) and the rows +-- a message prints on in the description box (#1725). +-- engine/items/tmhm.asm:355-403 and engine/gfx/cgb_layouts.asm:723-726 (pokegold). +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_pack_rows_bug1695_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-pack-rows love . +-- +-- No POKEPORT_SPEED: it scales the logic clock only, so every shot here would +-- race the keypress that redrew the list. +local U = require("tests.drivers.util") + +local Bag = require("src.inventory.Bag") +local GbcPalette = require("src.render.GbcPalette") +local PackMenu = require("src.ui.gen2.PackMenu") + +-- ../pokecrystal/maps/NewBarkTown.asm:287 -- the tile in front of the player's +-- own front door. An outdoor map is what makes the ESCAPE ROPE below print +-- OakThisIsntTheTimeText instead of warping the PACK shut. +local HOME = { map = "NEW_BARK_TOWN", x = 13, y = 6 } + +-- One-digit and two-digit counts side by side, both HM ends of the pocket, and +-- the two key items whose submenus reach a SEL and a USE. +local SEED = { + { "POTION", 5 }, + { "SUPER_POTION", 50 }, + { "ESCAPE_ROPE", 3 }, + { "POKE_BALL", 7 }, + { "GREAT_BALL", 12 }, + { "ITEMFINDER", 1 }, + { "BICYCLE", 1 }, + { "TM_DYNAMICPUNCH", 1 }, + { "TM_HEADBUTT", 3 }, + { "TM_THUNDER", 24 }, + { "HM_CUT", 1 }, + { "HM_SURF", 1 }, + { "HM_WATERFALL", 1 }, +} + +-- TM/HM pocket order is wTMsHMs order (engine/items/tmhm.asm:341), and each row +-- carries the number the cart prints at column 5 and the move name at column 8. +local TMHM = { + { "TM_DYNAMICPUNCH", "01", "DYNAMICPUNCH" }, + { "TM_HEADBUTT", "02", "HEADBUTT" }, + { "TM_THUNDER", "25", "THUNDER" }, + { "HM_CUT", "H1", "CUT" }, + { "HM_SURF", "H3", "SURF" }, + { "HM_WATERFALL", "H7", "WATERFALL" }, +} + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-pack-rows" + local failed = 0 + + local function pass(ok, line) + if not ok then failed = failed + 1 end + U.log((ok and "PASS " or "FAIL ") .. line) + end + + local function shot(name) + U.wait(4) + U.shot(game, ("%s/%s.png"):format(out, name)) + end + + local function tap(button, frames) + U.tap(game, button) + U.wait(frames or 5) + end + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + local world, save = game.world, game.save + + -- A later map edit must not park the player against a wall: try the door + -- tile, then any free neighbour of it. + local placed = world:setMap(HOME.map, HOME.x, HOME.y, "down") + if not placed then + for _, step in ipairs({ { 0, -1 }, { 1, 0 }, { -1, 0 }, { 0, 1 } }) do + placed = world:setMap(HOME.map, HOME.x + step[1], HOME.y + step[2], "down") + if placed then break end + end + end + U.wait(12) + + local mapId = world.map and world.map.id + local env = world.map and world.map.def and world.map.def.environment + pass(placed and env ~= "CAVE" and env ~= "DUNGEON", + ("standing in %s (environment %s), where an ESCAPE ROPE has nowhere to " .. + "pay out to"):format(tostring(mapId), tostring(env))) + + save.inventory = {} + save.bagOrder = {} + for _, entry in ipairs(SEED) do + local id, count = entry[1], entry[2] + if game.data.items and game.data.items[id] then + save.inventory[id] = count + table.insert(save.bagOrder, id) + else + pass(false, id .. " is not in this cache, so its row will be missing") + end + end + Bag.order(save, { items = game.data.items }) + + -- A probe pack for the checks, so the one on screen opens on a clean cursor. + local probe = PackMenu.new(game, { save = save, world = world, + pocket = "TM_HM" }) + + for i, want in ipairs(TMHM) do + local row = probe.rows[i] + pass(row and row.id == want[1] and row.tmhmLabel == want[2], + ("row %d is %s and its number reads %q (got %s / %s)"):format( + i, want[1], want[2], tostring(row and row.id), + tostring(row and row.tmhmLabel))) + pass(row and row.teaches == want[3], + ("row %d prints the move name %q rather than the item name %s"):format( + i, want[3], tostring(row and row.name))) + end + + pass(probe.gfx and probe.gfx:available(), + "this cache has the PACK's own tiles, so the screen is the cart's chrome " .. + "and not the fallback boxes") + pass(GbcPalette.available(), + "the palette shader is loaded, so a coloured cursor is drawable at all") + pass(GbcPalette.mode == "gbc", + ("colour mode is %q; anything else draws the cursor in DMG greys and " .. + "looks exactly like the bug"):format(tostring(GbcPalette.mode))) + + local cursorPal = probe.gfx and probe.gfx:available() + and probe.gfx:colorsAt(7, 2) + local ink = cursorPal and cursorPal[4] + pass(ink and ink[1] == 255 and ink[2] == 0 and ink[3] == 0, + ("the cursor column (7,2) resolves to a palette whose colour 3 is " .. + "255,0,0 (got %s)"):format(ink and table.concat(ink, ",") or "nil")) + local lastPal = probe.gfx and probe.gfx:available() + and probe.gfx:colorsAt(7, 10) + pass(lastPal == cursorPal, + "and the fifth list row's cursor cell (7,10) is in the same 1x9 zone") + + local finder = game.data.items and game.data.items.ITEMFINDER + pass(finder and finder.canSelect == true and world.registerItem ~= nil, + "the ITEMFINDER is registerable, so SEL prints \"Registered the\" and " .. + "not the refusal") + local rope = game.data.items and game.data.items.ESCAPE_ROPE + pass(rope and rope.fieldMenu ~= "ITEMMENU_NOUSE", + "the ESCAPE ROPE's submenu carries a USE row to reach the three-line " .. + "OAK message with") + + if failed > 0 then + U.log(("%d check(s) failed above, so the shots below cannot be read as " .. + "a pass."):format(failed)) + end + + game.packCursor = nil + local pack = PackMenu.new(game, { save = save, world = world, + onClose = function() game.stack:pop() end }) + game.stack:push(pack) + + U.log("00-items: the arrow beside POTION should be the same red as the") + U.log("pocket plaque on the left, not black. black in every pocket means") + U.log("the palette never reached it (#1694).") + shot("00-items") + + tap("select") + tap("down") + U.log("01-items-select: row 1 now carries the hollow arrow and row 2 the") + U.log("solid one, both red. \"Where should this\" / \"be moved to?\" sit on") + U.log("rows 14 and 16 with a blank row between them (#1725).") + U.log("switching row is " .. tostring(pack.switching) .. + ", cursor on " .. tostring(pack.index)) + shot("01-items-select") + tap("b") + tap("up") + + tap("right") + shot("02-balls") + tap("right") + shot("03-key-items") + U.log("02 and 03: same red arrow in POKe BALLS and KEY ITEMS.") + + tap("right") + U.log("04-tmhm-top: the rows read \"01 DYNAMICPUNCH\", \"02 HEADBUTT\",") + U.log("\"25 THUNDER\", \"H1 CUT\", \"H3 SURF\". the number is hard against the") + U.log("left edge of the item area where the blue pattern column ends, the") + U.log("arrow one tile later, the move name from column 8. the word TM or HM") + U.log("printed anywhere, or a right-aligned number, is the bug (#1695).") + U.log("pocket is " .. tostring(pack:pocket().id) .. + " with " .. tostring(#pack.rows) .. " rows") + shot("04-tmhm-top") + + for _ = 1, 5 do tap("down") end + U.log("05-tmhm-hms: scrolled to the last HM. \"H7 WATERFALL\" reads with a") + U.log("single digit and no count; \"H07\" would mean the TM branch took it.") + U.log("the three TM rows above it still carry their xNN on the line below.") + shot("05-tmhm-hms") + + tap("left") + tap("left") + tap("left") + tap("a") + tap("down") + tap("down") + tap("a") + U.log("06-toss-how-many: \"Throw away how\" on row 14 and \"many?\" on row 16,") + U.log("with a full blank row between them and the first line clear of the") + U.log("box's top border. adjacent lines mean the message path is still") + U.log("single-spaced (#1725).") + U.log("message is " .. + (pack.message and table.concat(pack.message, " / ") or "nil")) + shot("06-toss-how-many") + + tap("b") + tap("down") + tap("down") + tap("a") + tap("a") + U.log("07-oak-three-lines: a message too tall for two rows still packs one") + U.log("row apart, so \"OAK: GOLD!\" / \"This isn't the\" / \"time to use that!\"") + U.log("fill rows 13, 14 and 15. a gap here would clip the third line.") + U.log("message is " .. + (pack.message and table.concat(pack.message, " / ") or "nil")) + shot("07-oak-three-lines") + + tap("b") + tap("right") + tap("right") + tap("a") + tap("down") + tap("a") + U.log("08-registered: the case the report filed. \"Registered the\" on row 14") + U.log("and \"ITEMFINDER.\" on row 16, lining up with the item description") + U.log("that shares this box. row 13 plus row 14 is the old behaviour; row 15") + U.log("plus a clipped row 17 means the offset went the other way.") + U.log("message is " .. + (pack.message and table.concat(pack.message, " / ") or "nil")) + shot("08-registered") + + U.log("shots in " .. out .. "; the PACK is open on KEY ITEMS, controls yours") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/gold_shaderfx_menu_black_crop_test.lua b/tests/drivers/gold_shaderfx_menu_black_crop_test.lua new file mode 100644 index 00000000..0e3cbbb9 --- /dev/null +++ b/tests/drivers/gold_shaderfx_menu_black_crop_test.lua @@ -0,0 +1,90 @@ +-- Regression test: with SHADER FX active on Gen 2 (Gold), opening the +-- START menu used to render as a flat black fill +-- instead of menu content. Root cause: ShaderFX.lua's cropToGbSource() +-- samples the scene canvas via love.graphics.draw(), which multiplies by +-- the current draw color -- the menu's own drawing (black text/border) +-- left that color at (0,0,0,1) and never reset it, so cropToGbSource's own +-- draw silently multiplied its whole output to black. push("all") at the +-- top of cropToGbSource saves/restores state for its caller but does not +-- reset color, so the function must set its own white explicitly rather +-- than trust whatever the caller left active. Fixed by an explicit +-- love.graphics.setColor(1, 1, 1, 1) immediately before that draw call. +-- +-- Samples ShaderFX._lastCrop directly rather than a screenshot of the +-- final window: ShaderFX.render() always draws the correct, unmodified +-- `canvas` as a base layer before compositing the (possibly-broken) shader +-- chain output on top, so a whole-window screenshot stays mostly readable +-- even when the crop itself is fully black -- it just loses the shader +-- effect under a faint dark overlay. Only the crop canvas itself catches +-- the bug precisely. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_shaderfx_menu_black_crop_test.lua \ +-- lovec.exe . +-- +-- Skips itself (does not fail) if no ShaderFX preset is installed in the +-- save dir's shaders/ folder (see ShaderFX.presetDir()) -- this test needs +-- a real preset to exercise cropToGbSource at all. +local U = require("tests.drivers.util") +local StartMenu = require("src.ui.gen2.StartMenu") + +-- Same 4x4-grid luminance-range technique this bug was originally +-- characterized with. +local function luminanceRange(canvas) + local id = canvas:newImageData() + local w, h = id:getDimensions() + local minL, maxL = 1, 0 + for gy = 0, 3 do + for gx = 0, 3 do + local x = math.min(w - 1, math.floor((gx + 0.5) * w / 4)) + local y = math.min(h - 1, math.floor((gy + 0.5) * h / 4)) + local r, g, b = id:getPixel(x, y) + local l = 0.299 * r + 0.587 * g + 0.114 * b + if l < minL then minL = l end + if l > maxL then maxL = l end + end + end + return maxL - minL +end + +return function(game) + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + local ShaderFX = require("src.render.ShaderFX") + local entry = ShaderFX.findEntry("gameboy-color-dot-matrix.slangp") + if not entry then + print("[driver] SKIP no ShaderFX preset installed -- cannot exercise cropToGbSource") + love.event.quit(0) + return + end + if not entry.converted then + local ok, err = ShaderFX.convert(entry) + assert(ok, "ShaderFX.convert failed: " .. tostring(err)) + end + game.options = game.options or {} + game.options.shaderfx = entry.name + game:applyOptions() + assert(ShaderFX.active("main"), "ShaderFX main slot did not activate") + + -- Baseline: no menu on the stack, crop should already have real content. + U.wait(10) + local baseline = luminanceRange(ShaderFX._lastCrop) + assert(baseline > 0.05, + ("sanity check failed: baseline crop (no menu) is already near-flat " .. + "(variance %.3f) -- something else is broken, not this bug"):format(baseline)) + + -- The actual repro: push the START menu, one more frame renders through + -- it, then sample the same crop canvas again. + game.stack:push(StartMenu.new(game, { save = game.save })) + U.wait(3) + local withMenu = luminanceRange(ShaderFX._lastCrop) + game.stack:pop() + + assert(withMenu > 0.05, + ("crop is near-flat with the START menu open (variance %.3f, baseline " .. + "was %.3f) -- the ShaderFX Gen2 blank-menu bug is back"):format(withMenu, baseline)) + + print(("[driver] PASS gold shaderfx menu-blank regression, baseline=%.3f withMenu=%.3f") + :format(baseline, withMenu)) + love.event.quit(0) +end diff --git a/tests/drivers/gold_shaderfx_zoom_sizing_test.lua b/tests/drivers/gold_shaderfx_zoom_sizing_test.lua new file mode 100644 index 00000000..4914d184 --- /dev/null +++ b/tests/drivers/gold_shaderfx_zoom_sizing_test.lua @@ -0,0 +1,73 @@ +-- Regression test: on Gen 2, SHADER FX only ever shaded the small centred +-- "faithful ratio" 160x144 box, never the +-- real on-screen footprint of the live overworld -- unlike Gen 1, whose +-- Renderer.lua grows ShaderFX's rect to the real world canvas/zoom footprint +-- whenever the live overworld is what's on screen (src/render/Renderer.lua, +-- the `self.worldActive` branch around line 977). Gen2's drawViewportFrame +-- instead always built ShaderFX's rect from Chrome.fitScale's fixed +-- faithful-ratio box (src/core/Game2.lua), so on any window that is not +-- exactly 4:3 -- every real phone, and any resized desktop window -- the +-- live world already draws edge to edge past that box (Chrome.lua's own +-- comment: "The live overworld IS the background here -- it draws edge to +-- edge... with no surround to paint first"), and SHADER FX only ever shaded +-- the small box in the middle, leaving the rest of the visible map unshaded. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_shaderfx_zoom_sizing_test.lua \ +-- lovec.exe . +-- +-- Skips itself (does not fail) if no ShaderFX preset is installed in the +-- save dir's shaders/ folder (see ShaderFX.presetDir()). +local U = require("tests.drivers.util") +local GameViewport = require("src.render.GameViewport") + +return function(game) + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + local ShaderFX = require("src.render.ShaderFX") + local entry = ShaderFX.findEntry("gameboy-color-dot-matrix.slangp") + if not entry then + print("[driver] SKIP no ShaderFX preset installed -- cannot exercise ShaderFX.render") + love.event.quit(0) + return + end + if not entry.converted then + local ok, err = ShaderFX.convert(entry) + assert(ok, "ShaderFX.convert failed: " .. tostring(err)) + end + game.options = game.options or {} + game.options.shaderfx = entry.name + game:applyOptions() + assert(ShaderFX.active("main"), "ShaderFX main slot did not activate") + + -- conf.lua's bare-desktop default (1024x768) is exactly 4:3, which + -- coincidentally hides this bug -- resize to a 16:9 window, the shape of + -- every real phone/handheld this project actually ships on, where the + -- live world draws well past the small faithful-ratio box. + love.window.setMode(1280, 720, { resizable = true }) + U.wait(5) + + local w, h = GameViewport.dimensions() + local pw, ph = GameViewport.pixelDimensions() + assert(game.frameWorldActive, "expected the live overworld to be on screen") + + local rect = ShaderFX._lastRect + assert(rect, "ShaderFX._lastRect was never set -- ShaderFX.render did not run") + print(("[driver] window %dx%d (%dx%d px), faithful box would be %dx%d, " .. + "ShaderFX rect is %.1fx%.1f"):format( + w, h, pw, ph, + 160 * math.floor(math.min(w / 160, h / 144)), + 144 * math.floor(math.min(w / 160, h / 144)), + rect.w, rect.h)) + + -- Real assertion: with the live overworld on screen, ShaderFX's rect + -- should track the world's real edge-to-edge footprint (~ the full + -- window), not the small integer-multiple 4:3 box centered inside it. + assert(rect.w >= pw - 2 and rect.h >= ph - 2, + ("SHADER FX's rect only covers %.1fx%.1f of a %dx%d px window -- still " .. + "the fixed faithful-ratio box, not the live world's real on-screen " .. + "footprint"):format(rect.w, rect.h, pw, ph)) + + print("[driver] PASS gold shaderfx zoom-sizing regression") + love.event.quit(0) +end diff --git a/tests/drivers/gold_splash_skip.lua b/tests/drivers/gold_splash_skip.lua new file mode 100644 index 00000000..94f4cb04 --- /dev/null +++ b/tests/drivers/gold_splash_skip.lua @@ -0,0 +1,42 @@ +-- A button during the GS splash skips the intro movie +-- (pokegold engine/menus/intro_menu.asm:848-851 IntroSequence). +local U = require("tests.drivers.util") +local GameFreakPresents = require("src.ui.gen2.GameFreakPresents") +local GoldSilverIntro = require("src.ui.gen2.GoldSilverIntro") +local TitleState = require("src.ui.gen2.TitleState") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-splash" + + U.wait(10) + game:showGameFreak() + U.wait(5) + assert(getmetatable(game.stack:top()) == GameFreakPresents, + "showGameFreak did not open the GS splash (top " + .. tostring(game.stack:top()) .. ")") + U.wait(40) + U.shot(game, out .. "/splash.png") + U.tap(game, "b") + for _ = 1, 60 do + if getmetatable(game.stack:top()) == TitleState then break end + assert(getmetatable(game.stack:top()) ~= GoldSilverIntro, + "a skipped splash still played the intro movie") + U.wait(1) + end + assert(getmetatable(game.stack:top()) == TitleState, + "a skipped splash did not land on the title (top " + .. tostring(game.stack:top()) .. ")") + U.wait(30) + U.shot(game, out .. "/title.png") + + -- Watched through, the splash still hands off to the movie. + game:showGameFreak() + for _ = 1, 900 do + if getmetatable(game.stack:top()) == GoldSilverIntro then break end + U.wait(5) + end + assert(getmetatable(game.stack:top()) == GoldSilverIntro, + "a watched splash did not hand off to the intro movie (top " + .. tostring(game.stack:top()) .. ")") + U.log("PASS gold splash skip in " .. out) +end diff --git a/tests/drivers/gold_stats_page_tint_bug1693_test.lua b/tests/drivers/gold_stats_page_tint_bug1693_test.lua new file mode 100644 index 00000000..327d5e99 --- /dev/null +++ b/tests/drivers/gold_stats_page_tint_bug1693_test.lua @@ -0,0 +1,279 @@ +-- #1693: the mon SUMMARY's lower half never took the selected page's colour. +-- StatsScreen_LoadGFX .LoadPals farcalls LoadStatsScreenPals with the page +-- index, and that writes one gfx/stats/stats.pal colour over colour 0 of BG +-- palette 0 AND BG palette 2 (engine/gfx/color.asm:373-393); WipeAttrmap +-- leaves rows 8-17 on palette 0 with the exp strip at (10,16) on palette 2 +-- (engine/gfx/cgb_layouts.asm:219-229), so both writes land below the rule. +-- Never add POKEPORT_SPEED here: fast-forward scales the logic clock only, so +-- the cry and the page redraw stop landing in the order a reader is judging. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_stats_page_tint_bug1693_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-bug1693 \ +-- perl -e 'alarm 240; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +local U = require("tests.drivers.util") + +local GbcPalette = require("src.render.GbcPalette") +local HpBar = require("src.battle.gen2.HpBar") +local Mon = require("src.battle.gen2.Mon") +local SummaryMenu = require("src.ui.gen2.SummaryMenu") + +-- gfx/stats/stats.pal, 5-bit the way the ROM keeps it: pink, green, blue +local ROM_TINTS = { { 31, 19, 31 }, { 21, 31, 14 }, { 17, 31, 31 } } +local PAGE_NAMES = { "pink", "green", "blue" } + +local function up(v) return math.floor(v * 255 / 31 + 0.5) end +local function pct(v) return math.floor(v + 0.5) end + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1693" + local fails = 0 + + local function ok(cond, msg) + if cond then U.log("PASS", msg) else fails = fails + 1 U.log("FAIL", msg) end + return cond and true or false + end + local function tap(btn) U.tap(game, btn) U.wait(4) end + local function top() return game.stack:top() end + local function shot(name) + return ok(U.shot(game, ("%s/%s.png"):format(out, name)), + name .. " reached disk") + end + + U.wait(45) + if not ok(game.world and game.world.map ~= nil, + "gold booted into the world") then + error("gold stats tint: no world, nothing to open a summary over") + end + + -- ---- the party the pink page needs --------------------------------------- + -- Mon.new is the only Gen 2 party builder; a mon out of Gen 1's Pokemon.new + -- comes back with no moves and no growth rate, so both bars would be empty. + local save = game.save + save.player.name = "GOLD" + save.player.id = 12345 + save.party = { + Mon.new(game.data, "CYNDAQUIL", 22), + Mon.new(game.data, "TOTODILE", 18), + } + local lead = save.party[1] + lead.item = "BERRY" + lead.hp = math.max(1, math.floor((lead.maxHp or 1) * 0.45)) + local def = game.data.pokemon[lead.species] + local rates = game.data.pokemon.growthRates + local rate = rates and def and rates[def.growthRate] + if rate then + local base = Mon.experienceForLevel(rate, lead.level) + local next_ = Mon.experienceForLevel(rate, lead.level + 1) + lead.experience = base + math.floor((next_ - base) * 0.55) + end + ok(game.world.giveEgg and game.world:giveEgg(175, 5) and save.party[3] + and save.party[3].isEgg == true, "slot 3 holds an egg") + + -- ---- everything the eye cannot check ------------------------------------- + for page = 1, 3 do + local tint = SummaryMenu.PAGE_TINTS and SummaryMenu.PAGE_TINTS[page] + ok(tint ~= nil and tint[1] == up(ROM_TINTS[page][1]) + and tint[2] == up(ROM_TINTS[page][2]) + and tint[3] == up(ROM_TINTS[page][3]), + ("the %s tint is stats.pal's own colour"):format(PAGE_NAMES[page])) + local probe = setmetatable({ page = page }, { __index = SummaryMenu }) + local lower = probe.lowerColors and probe:lowerColors() + ok(lower ~= nil and lower[1] == tint and lower[4][1] == 0, + ("the %s lower palette is that tint over black ink"):format( + PAGE_NAMES[page])) + end + ok(GbcPalette.available(), "the GBC shade-remap shader compiled") + + -- COLOR is the port's own display row and DMG flattens every palette in the + -- game, so a run started on it looks exactly like the bug. + game.options = game.options or {} + local startedOn = game.options.color or "gbc" + local function setColor(mode) + game.options.color = mode + if game.save then game.save.options = game.options end + GbcPalette.applyOptions(game.options) + return GbcPalette.mode + end + if startedOn ~= "gbc" then + U.log("COLOR was on " .. GbcPalette.modeLabel(startedOn) .. ", which would") + U.log("grey every shot below for the wrong reason. forcing it to GBC.") + end + ok(setColor("gbc") == "gbc", "COLOR is GBC for the run") + + -- ---- the live screen, counted rather than admired ------------------------ + -- The summary draws at GB coordinates, so a 160x144 canvas at scale 1 makes + -- a GB pixel a pixel and the tint countable. + local function frameOf(screen) + local G = love.graphics + local canvas = G.newCanvas(160, 144) + G.setCanvas(canvas) + G.clear(0, 0, 0, 1) + G.setColor(1, 1, 1, 1) + screen:draw() + G.setCanvas() + G.setColor(1, 1, 1, 1) + return canvas:newImageData() + end + + local function near(a, b) return math.abs(a - b) <= 2 end + + -- Counted against stats.pal itself, not the port's table, so a run that has + -- no PAGE_TINTS cannot agree with itself. + local function tintFor(page) + local rom = ROM_TINTS[page] or ROM_TINTS[1] + return { up(rom[1]), up(rom[2]), up(rom[3]) } + end + + local function scan(img, want, x0, y0, x1, y1) + local hit, white, total = 0, 0, 0 + for y = y0, y1 do + for x = x0, x1 do + local r, g, b = img:getPixel(x, y) + r, g, b = r * 255, g * 255, b * 255 + total = total + 1 + if near(r, want[1]) and near(g, want[2]) and near(b, want[3]) then + hit = hit + 1 + end + if near(r, 255) and near(g, 255) and near(b, 255) then + white = white + 1 + end + end + end + return hit / total * 100, white / total * 100 + end + + -- StatsScreen_LoadGFX .ClearBox: hlcoord 0, 8 / lb bc, 10, 20 + local LOWER = { 0, 64, 159, 143 } + local function report(label, hit, white) + U.log((" %-24s %3d%% tinted %3d%% white"):format(label, pct(hit), + pct(white))) + end + + local function zone(img, tint, label, rect, wantTint) + local hit, white = scan(img, tint, rect[1], rect[2], rect[3], rect[4]) + report(label, hit, white) + ok(hit >= wantTint, label .. " carries the page tint") + ok(white <= 2, label .. " has no white left in it") + end + + local function inspect(screen, name) + local img = frameOf(screen) + local tint = tintFor(screen.page) + zone(img, tint, "rows 8-17", LOWER, 40) + if screen.page == SummaryMenu.PINK_PAGE then + -- DrawPlayerHP: "HP:" at (0,9), six bar cells from (2,9) + zone(img, tint, "the HP bar's cells", { 16, 72, 63, 79 }, 8) + -- LoadPinkPage's divider column, and FillInExpBar at (11,16) + zone(img, tint, "the divider, column 9", { 72, 64, 79, 143 }, 20) + zone(img, tint, "the exp bar", { 88, 128, 151, 135 }, 8) + elseif screen.page == SummaryMenu.BLUE_PAGE then + zone(img, tint, "the divider, column 10", { 80, 64, 87, 143 }, 20) + end + -- The upper half is on the mon palette, which the cart never tints. + local _, white = scan(img, tint, 0, 0, 159, 55) + ok(white >= 20, "the upper half stayed white") + shot(name) + end + + -- ---- walk in through the pad --------------------------------------------- + tap("start") + local menu = top() + if not ok(menu and menu.screenId == "Gen2StartMenu", + "START opened the menu") then + error("gold stats tint: no start menu, cannot reach the summary") + end + for _ = 1, 10 do + if menu.list:current().value == "pokemon" then break end + tap("down") + end + ok(menu.list:current().value == "pokemon", "the cursor found the party row") + tap("a") + local party = top() + if not ok(party and party.screenId == "Gen2PartyMenu", + "that opened the party list") then + error("gold stats tint: no party list, cannot reach the summary") + end + + local function openStats() + tap("a") + ok(party.submenu ~= nil and party.submenu.items[1].id == "STATS", + "the mon submenu leads with STATS") + tap("a") + local screen = top() + ok(screen and screen.screenId == "Gen2SummaryMenu", + "STATS opened the summary") + return screen + end + + local summary = openStats() + ok(summary.hud and summary.hud:available(), + "the HUD sheet is cached, so the bars are the cart's own tiles") + ok(summary.statsTiles and summary:statsTiles() ~= nil, + "menu_gfx.stats is cached, so the divider and caps are too") + local fraction = HpBar.expFraction(summary.mon, summary:growth(), + Mon.experienceForLevel) + ok(fraction > 0.05 and fraction < 0.95, + "the lead is part way to its next level, so the exp bar is half drawn") + ok((summary.mon.hp or 0) > 0 and summary.mon.hp < (summary.mon.maxHp or 0), + "and hurt, so the HP bar has empty cells as well as full ones") + + inspect(summary, "01-pink-page") + tap("right") + ok(summary.page == SummaryMenu.GREEN_PAGE, "right reached the green page") + inspect(summary, "02-green-page") + tap("right") + ok(summary.page == SummaryMenu.BLUE_PAGE, "right reached the blue page") + inspect(summary, "03-blue-page") + tap("right") + ok(summary.page == SummaryMenu.PINK_PAGE, "and wrapped back to pink") + + -- ---- the two screens that must stay white -------------------------------- + tap("b") + ok(top() == party, "b came back to the list") + tap("down") + tap("down") + ok(party.index == 3, "the cursor reached the egg") + local egg = openStats() + ok(egg.mon and egg.mon.isEgg == true, "on the egg") + local eggTint, eggWhite = scan(frameOf(egg), tintFor(SummaryMenu.PINK_PAGE), + LOWER[1], LOWER[2], LOWER[3], LOWER[4]) + report("the egg's rows 8-17", eggTint, eggWhite) + -- EggStatsScreen never calls LoadStatsScreenPals (stats_screen.asm:786) + ok(eggTint <= 1 and eggWhite >= 40, "the egg page stayed white") + shot("04-egg-white") + + tap("b") + tap("up") + tap("up") + ok(setColor("dmg") == "dmg", "COLOR switched to DMG") + local dmg = openStats() + local dmgTint, dmgWhite = scan(frameOf(dmg), tintFor(dmg.page), + LOWER[1], LOWER[2], LOWER[3], LOWER[4]) + report("DMG's rows 8-17", dmgTint, dmgWhite) + -- LoadStatsScreenPals opens `call CheckCGB / ret z` (color.asm:373-375) + ok(dmgTint <= 1 and dmgWhite >= 40, "DMG flattened the tint back to white") + shot("05-dmg-white") + tap("b") + ok(setColor("gbc") == "gbc", "COLOR is back on GBC for the handoff") + + -- ---- what a reader is looking at ----------------------------------------- + openStats() + U.wait(4) + + if fails > 0 then + U.log(("%d check(s) failed above. do not judge the colours -- the run is"): + format(fails)) + U.log("not showing you what it claims to.") + end + U.log("shots are in " .. out .. ". the summary is open on the pink page and") + U.log("left/right turn it: everything under the horizontal rule should be") + U.log("washed pink, then green, then cyan, matching whichever page square is") + U.log("the large one, while the pic, nickname and level stay white.") + U.log("a partial fix leaves white in one of three places -- an 8px stripe") + U.log("down the divider column, the bands above and below the HP bar's fill,") + U.log("or the exp bar's interior inside its black frame.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_tm_fanfare_bug1483_test.lua b/tests/drivers/gold_tm_fanfare_bug1483_test.lua new file mode 100644 index 00000000..0724bd4c --- /dev/null +++ b/tests/drivers/gold_tm_fanfare_bug1483_test.lua @@ -0,0 +1,338 @@ +-- #1483: the TM/HM jingle under `verbosegiveitem`, and the item jingle beside it. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_tm_fanfare_bug1483_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-tm-fanfare \ +-- perl -e 'alarm 300; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- GiveItemScript is `waitsfx / specialsound / waitbutton`, the drain sitting +-- ABOVE the sound (engine/overworld/scripting.asm:441-449). Without it PlaySFX +-- drops SFX_GET_TM ($9b) under the beep the box rang on its own press, +-- SFX_READ_TEXT_2 ($08) -- while SFX_ITEM ($01) outranks that beep and survives. +-- +-- No POKEPORT_SPEED here on purpose: audio runs on its own real-time +-- accumulator, so fast-forward slides the jingle off the press it belongs to, +-- and the ordering is the whole thing being judged. +local U = require("tests.drivers.util") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Sound = require("src.core.Sound") + +-- ../pokegold/maps/Route32.asm:866 -- Route32RoarTMGuyScript, `verbosegiveitem +-- TM_ROAR`, gated on nothing but its own EVENT_GOT_TM05_ROAR latch. +local TM_GIVER = { map = "ROUTE_32", x = 15, y = 13, item = "TM_ROAR" } +-- ../pokegold/maps/Route32Pokecenter1F.asm:109 -- the fishing guru, +-- `verbosegiveitem OLD_ROD`, one warp away and the control for the same gate. +local ITEM_GIVER = + { map = "ROUTE_32_POKECENTER_1F", x = 1, y = 4, item = "OLD_ROD" } + +-- side the player stands on, and the way they have to look from there +local SIDES = { + { dx = -1, dy = 0, face = "right" }, + { dx = 1, dy = 0, face = "left" }, + { dx = 0, dy = -1, face = "down" }, + { dx = 0, dy = 1, face = "up" }, +} + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-tm-fanfare" + local fails, lines = 0, {} + + local function claim(ok, text) + if not ok then fails = fails + 1 end + lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text + return ok + end + + local function stop() + for _, line in ipairs(lines) do U.log(line) end + while true do coroutine.yield() end + end + + U.wait(45) + local world = game.world + if not (world and world.map) then + U.log("FAIL the gold world never booted, nothing to listen to") + while true do coroutine.yield() end + end + local data = game.data + + -- ---- everything that fails as silence -------------------------------------- + local audio = data.audio or {} + local ids = {} + for i, name in ipairs(audio.sfxOrder or {}) do ids[name] = i - 1 end + for _, name in ipairs({ "Sfx_GetTm", "Sfx_Item", "Sfx_ReadText2" }) do + claim(ids[name] ~= nil and audio.sfx and audio.sfx[name] ~= nil, + ("the cache can play %s (id %s)"):format(name, tostring(ids[name]))) + end + -- The gate is a numeric comparison, so the whole bug only exists while these + -- three ids sit in this order (constants/sfx_constants.asm:4, :11). + claim((ids.Sfx_Item or 0) < (ids.Sfx_ReadText2 or 0) + and (ids.Sfx_ReadText2 or 0) < (ids.Sfx_GetTm or 0), + "and SFX_ITEM outranks the box beep, which outranks SFX_GET_TM") + claim(type(Sound.waitSfxDone) == "function", + "Sound.waitSfxDone exists for specialsound's `waitsfx` to call") + + local vol = game.save.options and game.save.options.sfxVol + claim(vol ~= 0, ("SFX VOL is %s"):format(tostring(vol))) + if vol == 0 then + U.log("SFX VOL is ZERO -- a muted run sounds exactly like the bug.") + U.log("turn it up in OPTION before trusting anything you hear here.") + end + + for _, spot in ipairs({ TM_GIVER, ITEM_GIVER }) do + local def = data.items and data.items[spot.item] + claim(def ~= nil, ("the cache names the item %s"):format(spot.item)) + spot.pocket = def and def.pocket + spot.index = def and def.index + spot.want = (spot.pocket == "TM_HM") and "Sfx_GetTm" or "Sfx_Item" + end + claim(TM_GIVER.pocket == "TM_HM", + ("TM_ROAR sits in the TM_HM pocket (got %s)"):format( + tostring(TM_GIVER.pocket))) + claim(ITEM_GIVER.pocket ~= "TM_HM", + ("OLD_ROD does not (got %s)"):format(tostring(ITEM_GIVER.pocket))) + + -- ---- the recorder ---------------------------------------------------------- + -- + -- Sound.play returns nil when the priority gate DROPS the request, which is + -- the bug itself: the sound neither rings nor ducks the music. + local calls, frame, inSpecial = {}, 0, false + local realPlay = Sound.play + Sound.play = function(d, name) + local src = realPlay(d, name) + calls[#calls + 1] = { + name = Sound.resolve(d, name), frame = frame, + started = src ~= nil, special = inSpecial, + } + return src + end + local drains = 0 + local realDrain = Sound.waitSfxDone + Sound.waitSfxDone = function() + if inSpecial then drains = drains + 1 end + return realDrain() + end + local realSpecial = world.specialSound + world.specialSound = function(self, itemIndex) + inSpecial = true + local ok, err = pcall(realSpecial, self, itemIndex) + inSpecial = false + if not ok then error(err) end + end + + local function tap(button, gap) + table.insert(game.input.pressQueue, button) + game.input.state[button] = true + frame = frame + 1 + coroutine.yield() + game.input.state[button] = false + for _ = 1, (gap or 12) do frame = frame + 1 coroutine.yield() end + end + + local function walk(button, n) + for _ = 1, n do + table.insert(game.input.pressQueue, button) + game.input.state[button] = true + frame = frame + 1 + coroutine.yield() + end + game.input.state[button] = false + U.wait(6) + end + + -- The VM finishes its last `showRaw` and stops while the boxes it queued are + -- still on the stack, so "the script ended" is not "the conversation ended": + -- the stack has to be back where it was before A was ever pressed. + local baseDepth = #game.stack.states + local function idle() + return #game.stack.states <= baseDepth + and not world.vm:running() and not world:busy() + end + + -- Press first, then look: at rest the Gold overworld holds nothing on the + -- stack at all, so a leading idle() check would return before the A that + -- starts the conversation was ever sent. + local function mash(limit, gap) + for _ = 1, (limit or 40) do + tap("a", gap) + if idle() then return true end + end + return idle() + end + + local function clear(limit) + for _ = 1, (limit or 12) do + if idle() then return end + tap("b", 8) + end + end + + -- ---- walk up to one giver and take what they hand over --------------------- + local function visit(spot, shots) + clear() -- nothing from the last giver may still be on the stack + world:applyPlayerState(FieldMoves.PLAYER_NORMAL) + -- Land on the map first so the collision under the giver is this map's. + world:setMap(spot.map, spot.x, spot.y + 1, "up") + U.wait(20) + world.noWildEncounters = true + + -- A later map edit must degrade to a different side rather than park the + -- player in a wall, so the standing tile is picked from the live map. + local side + for _, s in ipairs(SIDES) do + local sx, sy = spot.x + s.dx, spot.y + s.dy + if not side and world.map:isWalkable(sx, sy) + and not world:npcAt(sx, sy) then + side = s + end + end + if not claim(side ~= nil, + ("a free tile beside the giver at (%d,%d) on %s"):format( + spot.x, spot.y, spot.map)) then + return nil + end + + -- Start two cells back and walk in, so the approach is the player's own. + local steps, sx, sy = 0, spot.x + side.dx, spot.y + side.dy + for n = 2, 3 do + local tx, ty = spot.x + side.dx * n, spot.y + side.dy * n + if steps == n - 2 and world.map:isWalkable(tx, ty) + and not world:npcAt(tx, ty) then + steps, sx, sy = n - 1, tx, ty + end + end + spot.side, spot.steps, spot.sx, spot.sy = side, steps, sx, sy + world:setMap(spot.map, sx, sy, side.face) + U.wait(20) + world.noWildEncounters = true + if steps > 0 then walk(side.face, 20 * steps) end + U.wait(10) + + local npc = world:facingObject() + local key = npc and npc.def and npc.def.scriptKey + local script = key and world.vm.scripts and world.vm.scripts[key] + if not claim(type(script) == "table", + ("%s has a script to run at (%d,%d)"):format(spot.map, spot.x, spot.y)) then + return nil + end + + -- The give is behind a `checkevent` latch (EVENT_GOT_TM05_ROAR, + -- EVENT_GOT_OLD_ROD); those are the only flags either script reads. + local gives = nil + spot.events = {} + for _, row in ipairs(script) do + if type(row) == "table" then + if row.event then + world.events:set(row.event, false) + spot.events[#spot.events + 1] = row.event + end + if row.op == "verbosegiveitem" then gives = row.item end + end + end + claim(gives == spot.index, + ("its `verbosegiveitem` hands over item %s, wanted %s (%s)"):format( + tostring(gives), tostring(spot.index), spot.item)) + if gives ~= spot.index then return nil end + + game.save.inventory = game.save.inventory or {} + game.save.inventory[spot.item] = nil + local first = #calls + 1 + U.shot(game, shots[1]) + + -- Mash A the way a player does: through the intro line, the yes/no the + -- guru asks (YES is the resting cursor), the received line and the pocket + -- line. Nothing here waits for the jingle; that is the point. + local function rang() + for i = first, #calls do + if calls[i].name == spot.want and calls[i].special then return true end + end + return false + end + for _ = 1, 40 do + tap("a") + if rang() or idle() then break end + end + U.shot(game, shots[2]) -- the box the jingle is meant to be ringing under + local finished = mash(40) + claim(finished, ("the %s conversation ran to the end"):format(spot.item)) + U.wait(30) + + local got = (game.save.inventory[spot.item] or 0) > 0 + claim(got, ("the %s reached the pack"):format(spot.item)) + + local heard, want, blipBefore = {}, nil, false + for i = first, #calls do + local c = calls[i] + heard[#heard + 1] = ("%s@%d%s"):format(c.name, c.frame, + c.started and "" or "(dropped)") + if c.name == spot.want and c.special then want = want or c end + if c.name == "Sfx_ReadText2" and c.started and not want then + blipBefore = true + end + end + U.log(("sfx across the %s hand-over: %s"):format( + spot.item, table.concat(heard, " "))) + return { want = want, blipBefore = blipBefore, heard = heard } + end + + local tm = visit(TM_GIVER, + { out .. "/01-tm-giver.png", out .. "/02-tm-jingle.png" }) + if not tm then + U.log("could not reach the TM giver; stopping rather than faking it") + Sound.play = realPlay + Sound.waitSfxDone = realDrain + stop() + end + claim(tm.blipBefore, + "the box rang its own beep before the TM jingle was asked for") + claim(drains > 0, "specialsound drained the channels first, as `waitsfx` does") + claim(tm.want ~= nil, "Sfx_GetTm was asked for from specialsound at all") + claim(tm.want and tm.want.started, + "and the priority gate let it start instead of dropping it") + + local item = visit(ITEM_GIVER, + { out .. "/03-item-giver.png", out .. "/04-item-jingle.png" }) + if item then + claim(item.want ~= nil, "Sfx_Item was asked for from specialsound too") + claim(item.want and item.want.started, "and it started, as it always did") + end + + Sound.play = realPlay + Sound.waitSfxDone = realDrain + + for _, line in ipairs(lines) do U.log(line) end + U.log(("%d checks, %d failed"):format(#lines, fails)) + if fails > 0 then + U.log("something above is FAIL, so do not spend time listening") + end + + -- ---- the replay, live ------------------------------------------------------ + -- + -- EVENT_GOT_TM05_ROAR is the only latch the Roar guy reads, so clearing what + -- his own script checked puts the whole hand-over back. + clear() + for _, ev in ipairs(TM_GIVER.events or {}) do world.events:set(ev, false) end + game.save.inventory[TM_GIVER.item] = nil + world:setMap(TM_GIVER.map, TM_GIVER.sx, TM_GIVER.sy, TM_GIVER.side.face) + world.noWildEncounters = true + U.wait(30) + U.log("the Roar guy is a couple of steps away; the replay starts in three") + U.log("seconds and mashes A the whole way through.") + U.wait(180) + if TM_GIVER.steps > 0 then walk(TM_GIVER.side.face, 20 * TM_GIVER.steps) end + U.wait(10) + mash(40, 16) + U.wait(60) + + U.log("what right sounds like: the route music cuts out on the received line") + U.log("and the TM jingle rings under it, then the music comes back.") + U.log("dead silence with the music still playing is the drop this fixes.") + U.log("expect the jingle to be cut short by the beep on the next box -- that") + U.log("is the missing trailing WaitSFX, not this fix failing.") + U.log("the guru in the Route 32 centre is the control: same beep, but his") + U.log("OLD ROD jingle rang before the fix as well.") + U.log("the controls are yours.") + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_whirlpool_forceturn_bug1716_test.lua b/tests/drivers/gold_whirlpool_forceturn_bug1716_test.lua new file mode 100644 index 00000000..e46eae12 --- /dev/null +++ b/tests/drivers/gold_whirlpool_forceturn_bug1716_test.lua @@ -0,0 +1,213 @@ +-- #1716: a whirlpool tile force-turns the player instead of being swum through. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_whirlpool_forceturn_bug1716_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-whirlpool \ +-- perl -e 'alarm 240; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- DoPlayerMovement's .CheckTile runs CheckWhirlpoolTile above the nybble ladder +-- and answers PLAYERMOVEMENT_FORCE_TURN (engine/overworld/player_movement.asm +-- :117-123), which is Script_ForcedMovement: step_dig 16, turn_in , +-- step_dig 16, turn_head , step_end (engine/events/forced_movement.asm +-- :25-51). turn_in `jp TurningStep`, i.e. InitStep under OBJECT_ACTION_SPIN -- +-- a spinning one-cell step, not a facing change. +-- +-- No POKEPORT_SPEED here on purpose: the spin, the step and the settle are what +-- is being watched, and fast-forward scales only the logic clock. +local U = require("tests.drivers.util") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Movement = require("src.script.gen2.Movement") +local Mon = require("src.battle.gen2.Mon") +local Permissions = require("src.world.gen2.Permissions") + +-- data/generated/maps.lua, ROUTE_41: four whirlpools, the first at (22,12) with +-- plain water above and below it. Verified live below, and re-derived by +-- scanning the map if a re-import ever moves it. +local MAP = "ROUTE_41" +local WHIRL = { x = 22, y = 12 } + +local DELTA = { + up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 }, +} + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-whirlpool" + local fails, lines = 0, {} + + local function claim(ok, text) + if not ok then fails = fails + 1 end + lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text + return ok + end + + U.wait(45) + local world = game.world + if not (world and world.map) then + U.log("FAIL the gold world never booted, nothing to look at") + while true do coroutine.yield() end + end + + -- ---- the things a human's eyes cannot check ------------------------------ + -- + -- All of these fail silently and look exactly like "the fix did nothing". + claim(Movement.decodeByte(0x24).kind == "step", + "$24 turn_in decodes as a step, not a facing change") + claim(Movement.decodeByte(0x24).spin == true, + "and it carries OBJECT_ACTION_SPIN") + claim(Movement.STEP_DIG == 0x4f and Movement.STEP_DIG_FRAMES == 16, + "$4f step_dig is modelled, 16 frames") + claim(type(world.runForcedMovement) == "function", + "World:runForcedMovement exists for .CheckTile to call") + claim(type(world.player.scriptSpin) == "function", + "Player:scriptSpin exists for step_dig to drive") + + -- SURF but deliberately NOT WHIRLPOOL, and no GLACIERBADGE: TryWhirlpoolOW + -- must never get a look in, or what we would be watching is the prompt. + local badges = game.save.player.badges or {} + game.save.player.badges = badges + for _, badge in pairs(FieldMoves.BADGE) do badges[badge] = nil end + badges[FieldMoves.BADGE.SURF] = true + local swimmer = Mon.new(game.data, "LAPRAS", 30, + { moves = { { id = "SURF" } } }) + claim(swimmer ~= nil, "a LAPRAS that knows SURF and nothing else") + game.save.party = { swimmer } + claim(not FieldMoves.hasBadge(game.save, FieldMoves.BADGE.WHIRLPOOL), + "no GLACIERBADGE, so no whirlpool prompt can fire") + + -- ---- find the whirlpool -------------------------------------------------- + world:applyPlayerState(FieldMoves.PLAYER_SURF) + world:setMap(MAP, WHIRL.x, WHIRL.y - 1, "down") + U.wait(10) + world.noWildEncounters = true + local map = world.map + + local function isWhirl(x, y) + return map:inBounds(x, y) and Permissions.isWhirlpool(map:cellCollision(x, y)) + end + local function isOpenWater(x, y) + return map:inBounds(x, y) + and Permissions.isWater(map:cellCollision(x, y)) + and not Permissions.isWhirlpool(map:cellCollision(x, y)) + end + + local target, approach = nil, nil + if isWhirl(WHIRL.x, WHIRL.y) then target = { x = WHIRL.x, y = WHIRL.y } end + if not target then + -- A re-import moved it; take any whirlpool on the map instead of parking + -- the player on open sea with nothing to look at. + local def = world.maps[MAP] + for y = 0, (def.height or 0) * 2 - 1 do + for x = 0, (def.width or 0) * 2 - 1 do + if not target and isWhirl(x, y) then target = { x = x, y = y } end + end + end + end + if target then + for _, dir in ipairs({ "down", "up", "right", "left" }) do + local d = DELTA[dir] + if not approach and isOpenWater(target.x - d[1], target.y - d[2]) then + approach = { x = target.x - d[1], y = target.y - d[2], dir = dir } + end + end + end + claim(target ~= nil, ("%s still has a whirlpool tile on it"):format(MAP)) + claim(approach ~= nil, "with open water beside it to swim in from") + if not (target and approach) then + for _, line in ipairs(lines) do U.log(line) end + U.log("nothing to drive; stopping here rather than faking the moment") + while true do coroutine.yield() end + end + if target.x ~= WHIRL.x or target.y ~= WHIRL.y then + U.log(("note: using the whirlpool at (%d,%d), not the (%d,%d) in the header") + :format(target.x, target.y, WHIRL.x, WHIRL.y)) + end + + -- ---- swim into it -------------------------------------------------------- + world:setMap(MAP, approach.x, approach.y, approach.dir) + world:applyPlayerState(FieldMoves.PLAYER_SURF) + world.noWildEncounters = true + U.wait(20) + claim(FieldMoves.isSurfing(world.playerState), "on the water, on the Lapras") + U.shot(game, out .. "/01-before.png") + + local p = world.player + local d = DELTA[approach.dir] + local beyond = { x = target.x + d[1], y = target.y + d[2] } + local seen = { onWhirl = false, spun = false, busy = false, past = false } + local shot = {} + + local function press(dir) + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + coroutine.yield() + end + + for _ = 1, 300 do + press(approach.dir) + if p.cellX == target.x and p.cellY == target.y then + seen.onWhirl = true + end + if p.spinFrames then seen.spun = true end + if world:busy() then seen.busy = true end + if p.cellX == beyond.x and p.cellY == beyond.y then seen.past = true end + if not shot.spin and seen.onWhirl and p.spinFrames then + shot.spin = true + game.input.state[approach.dir] = false + U.shot(game, out .. "/02-whirling.png") + end + if shot.spin and not shot.back + and (p.cellX ~= target.x or p.cellY ~= target.y) then + shot.back = true + game.input.state[approach.dir] = false + U.shot(game, out .. "/03-thrown-back.png") + end + end + game.input.state[approach.dir] = false + -- Let whatever is still running settle before reading the resting facing. + for _ = 1, 180 do + if not world:busy() and not p.moving then break end + coroutine.yield() + end + U.shot(game, out .. "/04-settled.png") + + local BACK = { up = "down", down = "up", left = "right", right = "left" } + claim(seen.onWhirl, + ("the step onto the whirlpool at (%d,%d) happened -- the cart allows it") + :format(target.x, target.y)) + claim(seen.busy, "the forced movement took the world off the d-pad") + claim(seen.spun, "step_dig put the player under OBJECT_ACTION_SPIN") + claim(not seen.past, + ("300 frames of %s never reached (%d,%d) on the far side") + :format(approach.dir:upper(), beyond.x, beyond.y)) + claim(p.cellX == approach.x and p.cellY == approach.y, + ("the player was dragged back to (%d,%d), and is at (%d,%d)") + :format(approach.x, approach.y, p.cellX, p.cellY)) + claim(p.facing == BACK[approach.dir], + ("turn_head settled the facing to %s, and it is %s") + :format(BACK[approach.dir], tostring(p.facing))) + + for _, line in ipairs(lines) do U.log(line) end + U.log(("%d checks, %d failed"):format(#lines, fails)) + if fails > 0 then + U.log("something above is FAIL, so do not spend time watching the replay") + end + + -- ---- the replay, live ---------------------------------------------------- + U.log("shots are in " .. out .. "; 02 is the whirl, 03 the drag back out.") + U.log("the replay starts in three seconds and runs on its own.") + world:setMap(MAP, approach.x, approach.y, approach.dir) + world:applyPlayerState(FieldMoves.PLAYER_SURF) + world.noWildEncounters = true + U.wait(180) + for _ = 1, 200 do press(approach.dir) end + game.input.state[approach.dir] = false + + U.log("the Lapras swims one cell into the whirlpool, whirls on the spot,") + U.log("is dragged back out still whirling, and stops facing away from it.") + U.log("a bounce with no whirl means scriptSpin never fired; a whirl that") + U.log("never leaves the cell means $24 is still decoding as a turn.") + U.log("the controls are yours -- the d-pad cannot cross that cell.") + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_whirlpool_sound_bug1717_test.lua b/tests/drivers/gold_whirlpool_sound_bug1717_test.lua new file mode 100644 index 00000000..a6f78b50 --- /dev/null +++ b/tests/drivers/gold_whirlpool_sound_bug1717_test.lua @@ -0,0 +1,240 @@ +-- #1717: the surf wash PlayWhirlpoolSound rings as a whirlpool drains. +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_whirlpool_sound_bug1717_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-whirlpool-sfx \ +-- perl -e 'alarm 240; exec @ARGV' \ +-- python3 -c "import pty; pty.spawn(['love','.'])" +-- +-- PlayWhirlpoolSound is WaitSFX / PlaySFX SFX_SURF / WaitSFX +-- (engine/events/field_moves.asm:5-10), never a bare PlaySFX. The LEADING wait +-- is what makes it audible: SFX_SURF is $53 and PlaySFX drops any id above the +-- sound still on ch5-ch8 (home/audio.asm), so the beep that dismissed the text +-- box swallowed it. The TRAILING wait is why the water is still washing when +-- DisappearWhirlpool hands back, above `closetext`. +-- +-- No POKEPORT_SPEED here on purpose: audio runs on its own real-time +-- accumulator, so fast-forward slides the sound off the moment it belongs to, +-- and the ordering is the whole thing being judged. +local U = require("tests.drivers.util") + +local FieldMoves = require("src.world.gen2.FieldMoves") +local Mon = require("src.battle.gen2.Mon") +local Permissions = require("src.world.gen2.Permissions") +local Sound = require("src.core.Sound") + +-- data/generated/maps.lua, ROUTE_41: whirlpools at (22,12), (42,24), (6,30) and +-- (28,48). Stand one cell north of the first and face it. +local MAP = "ROUTE_41" +local WHIRL = { x = 22, y = 12 } + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-whirlpool-sfx" + local fails, lines = 0, {} + + local function claim(ok, text) + if not ok then fails = fails + 1 end + lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text + return ok + end + + U.wait(45) + local world = game.world + if not (world and world.map) then + U.log("FAIL the gold world never booted, nothing to listen to") + while true do coroutine.yield() end + end + + -- ---- everything that fails as silence ------------------------------------ + local audio = game.data.audio or {} + local hasLabel = false + for _, name in ipairs(audio.sfxOrder or {}) do + if name == "Sfx_Surf" then hasLabel = true end + end + claim(hasLabel, "the cache knows the sfx label Sfx_Surf") + claim(audio.sfx and audio.sfx.Sfx_Surf ~= nil, + "and it has a loadable definition for it") + claim(type(world.playWhirlpoolSound) == "function", + "World:playWhirlpoolSound exists for DisappearWhirlpool to call") + local vol = game.save.options and game.save.options.sfxVol + claim(vol ~= 0, ("SFX VOL is %s"):format(tostring(vol))) + if vol == 0 then + U.log("SFX VOL is ZERO -- a muted run sounds exactly like the bug.") + U.log("turn it up in OPTION before trusting anything you hear here.") + end + + -- SURF to get there and WHIRLPOOL to use, with the badges TryWhirlpoolOW + -- gates on: the gate has its own checks in tests/gen2_world_test.lua, and + -- what is under test here is the sound, not the refusal. + local badges = game.save.player.badges or {} + game.save.player.badges = badges + for _, badge in pairs(FieldMoves.BADGE) do badges[badge] = true end + local swimmer = Mon.new(game.data, "LAPRAS", 30, + { moves = { { id = "SURF" }, { id = "WHIRLPOOL" } } }) + claim(swimmer ~= nil, "a LAPRAS that knows SURF and WHIRLPOOL") + game.save.party = { swimmer } + + -- ---- stand on the water facing the whirlpool ----------------------------- + world:applyPlayerState(FieldMoves.PLAYER_SURF) + world:setMap(MAP, WHIRL.x, WHIRL.y - 1, "down") + U.wait(15) + world.noWildEncounters = true + + local ctx = world:fieldContext() + local facingWhirl = Permissions.isWhirlpool(ctx.facingColl) + if not facingWhirl then + -- A re-import moved it: take any whirlpool with open water above it rather + -- than ringing a sound at a patch of empty sea. + local def = world.maps[MAP] + for y = 1, (def.height or 0) * 2 - 1 do + for x = 0, (def.width or 0) * 2 - 1 do + if not facingWhirl + and Permissions.isWhirlpool(world.map:cellCollision(x, y)) + and Permissions.isWater(world.map:cellCollision(x, y - 1)) then + WHIRL.x, WHIRL.y = x, y + world:setMap(MAP, x, y - 1, "down") + world:applyPlayerState(FieldMoves.PLAYER_SURF) + U.wait(10) + ctx = world:fieldContext() + facingWhirl = Permissions.isWhirlpool(ctx.facingColl) + U.log(("note: using the whirlpool at (%d,%d)"):format(x, y)) + end + end + end + end + claim(facingWhirl, + ("facing the whirlpool at (%d,%d) on %s"):format(WHIRL.x, WHIRL.y, MAP)) + local blocks = world.maps[MAP] and world.maps[MAP].blocks + local index = ctx.facingBlockIndex + local before = blocks and index and blocks[index] + claim(before ~= nil, "and the block behind it is readable") + if not (facingWhirl and before) then + for _, line in ipairs(lines) do U.log(line) end + U.log("nothing to drive; stopping here rather than faking the moment") + while true do coroutine.yield() end + end + U.shot(game, out .. "/01-facing.png") + + -- ---- record every sfx the drain rings ------------------------------------ + -- + -- Sound.play returns nil when the priority gate DROPS the request, which is + -- the bug itself: the sound neither sounds nor ducks the music. + local calls, frame = {}, 0 + local realPlay = Sound.play + Sound.play = function(data, name) + local src = realPlay(data, name) + calls[#calls + 1] = { + name = name, frame = frame, started = src ~= nil, + phase = world.fieldMove and world.fieldMove.phase, + } + return src + end + + local function tap(button, gap) + table.insert(game.input.pressQueue, button) + game.input.state[button] = true + frame = frame + 1 + coroutine.yield() + game.input.state[button] = false + for _ = 1, (gap or 8) do frame = frame + 1 coroutine.yield() end + end + + -- Press A into the whirlpool, then answer the ask box. YES is the default + -- cursor, so A all the way down is what a player does. + local drainedAt, closedAt = nil, nil + for _ = 1, 24 do + if world.fieldMove and world.fieldMove.phase == "whirlpoolsfx" then break end + if blocks[index] ~= before and not closedAt then closedAt = frame end + tap("a") + end + if blocks[index] ~= before and not closedAt then closedAt = frame end + drainedAt = frame + + -- From here on nothing is pressed: the trailing WaitSFX is supposed to hold + -- the world by itself. + local busyAfter, phaseFrames = 0, 0 + for _ = 1, 300 do + if world.fieldMove and world.fieldMove.phase == "whirlpoolsfx" then + phaseFrames = phaseFrames + 1 + end + if world:busy() then busyAfter = busyAfter + 1 else break end + frame = frame + 1 + coroutine.yield() + end + Sound.play = realPlay + U.shot(game, out .. "/02-cleared.png") + + -- ---- what was rung ------------------------------------------------------- + local surf, surfStarted, surfInPhase = nil, false, false + local heard = {} + for _, c in ipairs(calls) do + heard[#heard + 1] = ("%s@%d%s"):format(c.name, c.frame, + c.started and "" or "(dropped)") + if c.name == "Sfx_Surf" then + surf = c + surfStarted = surfStarted or c.started + surfInPhase = surfInPhase or c.phase == "whirlpoolsfx" + end + end + + claim(blocks[index] ~= before, + "the whirlpool block was replaced, so the drain really ran") + claim(surf ~= nil, "Sfx_Surf was asked for at all") + claim(surfStarted, + "and the priority gate let it start instead of dropping it") + claim(surfInPhase, + "it was rung from PlayWhirlpoolSound, not from somewhere else") + claim(closedAt == nil or (surf and surf.frame >= closedAt), + "it came after the text box took its button, not before") + claim(busyAfter >= 1, + ("the world stayed shut for %d frames after the box closed"):format( + busyAfter)) + -- The phase counts down from 180 whatever happens, so 180 flat is the cap + -- firing rather than the sound ending. + claim(phaseFrames > 0 and phaseFrames < 180, + ("the wash ended on its own after %d frames, inside the 180-frame cap") + :format(phaseFrames)) + if phaseFrames >= 180 then + U.log("the 180-frame cap is doing the work: Sound.sfxBusy never cleared,") + U.log("so check curSfx bookkeeping rather than raising the cap.") + elseif phaseFrames > 165 then + U.log(("note: only %d frames of margin under the cap"):format( + 180 - phaseFrames)) + end + + for _, line in ipairs(lines) do U.log(line) end + U.log("sfx rung across the drain: " .. table.concat(heard, " ")) + U.log(("%d checks, %d failed"):format(#lines, fails)) + if fails > 0 then + U.log("something above is FAIL, so do not spend time listening") + end + + -- ---- the replay, live ---------------------------------------------------- + -- + -- LoadMapAttributes refills the block buffer from ROM, so the whirlpool is + -- back on the next map load and the whole thing can be run again by ear. + world:setMap("NEW_BARK_TOWN", 13, 6, "down") + U.wait(10) + world:applyPlayerState(FieldMoves.PLAYER_SURF) + world:setMap(MAP, WHIRL.x, WHIRL.y - 1, "down") + world.noWildEncounters = true + U.wait(30) + U.log("the whirlpool is back; the replay starts in three seconds.") + U.wait(180) + for _ = 1, 24 do + if world.fieldMove and world.fieldMove.phase == "whirlpoolsfx" then break end + tap("a", 14) + end + for _ = 1, 240 do + if not world:busy() then break end + coroutine.yield() + end + + U.log("what right sounds like: the box takes its A-press beep, then the same") + U.log("wave wash SURF plays when you get on the water, and control only comes") + U.log("back once the wash has finished.") + U.log("silence with the block gone is the drop; a wash that starts under the") + U.log("beep, or control back in the same frame, is the wrong half fixed.") + U.log("the controls are yours -- the other whirlpools are further south.") + while true do coroutine.yield() end +end diff --git a/tests/drivers/museum_back_entrance_bug1690_test.lua b/tests/drivers/museum_back_entrance_bug1690_test.lua new file mode 100644 index 00000000..c22c1ede --- /dev/null +++ b/tests/drivers/museum_back_entrance_bug1690_test.lua @@ -0,0 +1,211 @@ +-- Manual check of the Pewter museum clerk's back-entrance branches (#1690). +-- scripts/Museum1F.asm:45 reads wYCoord/wXCoord before the ticket flag, so +-- (13,4) and (12,3) behind the counter get the AMBER question and row 4 in +-- front of it gets the ¥50 ask. This walks all three by itself. +-- Do not add POKEPORT_SPEED: fast-forward scales only the logic clock, and +-- what is being judged here is page order and the choice box timing. +-- POKEPORT_DRIVER=tests/drivers/museum_back_entrance_bug1690_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red POKEPORT_IDENTITY=bug1690 SHOT_DIR=/tmp/museum1690 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local TextBox = require("src.render.TextBox") + local ChoiceBox = require("src.ui.ChoiceBox") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local MAP = "MUSEUM_1F" + local CLERK_TEXT = "TEXT_MUSEUM1F_SCIENTIST1" + -- pokered data/maps/objects/Museum1F.asm: MUSEUM1F_SCIENTIST1 at (12,4), + -- counter column x==11, back door warps in at (16,7)/(17,7) + local BEHIND_EAST = { x = 13, y = 4, facing = "left" } + local BEHIND_NORTH = { x = 12, y = 3, facing = "down" } + local IN_FRONT = { x = 10, y = 4, facing = "right" } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- every one of these failing looks exactly like the bug on screen: a + -- missing string, a renamed key or an unwalkable cell all end in silence + local t = game.data.text or {} + for _, key in ipairs({ + "_Museum1FScientist1DoYouKnowWhatAmberIsText", + "_Museum1FScientist1TheresALabSomewhereText", + "_Museum1FScientist1AmberIsFossilizedTreeSapText", + "_Museum1FScientist1GoToOtherSideText", + "_Museum1FScientist1WouldYouLikeToComeInText", + }) do + check(key .. " is in the text catalog", + type(t[key]) == "string" and t[key] ~= "") + end + + local function clerkIn(ow) + for _, n in ipairs(ow and ow.npcs or {}) do + if n.def and n.def.text == CLERK_TEXT then return n end + end + return nil + end + + local function boxText(box) + local shown = {} + for _, page in ipairs(box.pages or {}) do + for _, line in ipairs(page) do shown[#shown + 1] = line end + end + return table.concat(shown, " / ") + end + + -- talking works either straight at the clerk or across the counter tile + local function facingTheClerk() + local ow = game.overworld + local clerk = clerkIn(ow) + if not clerk then return false end + local fx, fy = ow.player:facingCell() + if ow:npcAtCell(fx, fy) == clerk then return true end + if ow.map:isCounterCell(fx, fy) then + local Collision = require("src.world.Collision") + local bx, by = Collision.target(fx, fy, ow.player.facing) + return ow:npcAtCell(bx, by) == clerk + end + return false + end + + -- teleport to `spot`, and if a map edit moved the counter fall back to any + -- free walkable neighbour of the clerk so this degrades instead of parking + -- the player at a wall + local function standAt(spot, label) + U.teleport(game, MAP, spot.x, spot.y, spot.facing) + U.wait(10) + if facingTheClerk() then return check(label .. ": in position", true) end + local ow = game.overworld + local clerk = clerkIn(ow) + if clerk then + local sides = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + for _, s in ipairs(sides) do + local cx, cy = clerk.cellX + s[1], clerk.cellY + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("(%d,%d) does not reach the clerk, standing on") + :format(spot.x, spot.y), cx, cy, "facing", s[3]) + U.teleport(game, MAP, cx, cy, s[3]) + U.wait(10) + break + end + end + end + return check(label .. ": in position", facingTheClerk()) + end + + -- press A and wait for the dialogue box to actually mount + local function talk() + U.tap(game, "a") + for _ = 1, 60 do + local top = game.stack:top() + if getmetatable(top) == TextBox then return top end + U.wait(1) + end + return nil + end + + -- page through until the YES/NO box pops (the clerk's question is two + -- pages, so one A press sits between the box opening and the choice) + local function toChoice() + for _ = 1, 20 do + if getmetatable(game.stack:top()) == ChoiceBox then + return game.stack:top() + end + U.tap(game, "a") + U.wait(20) + end + return getmetatable(game.stack:top()) == ChoiceBox and game.stack:top() or nil + end + + -- ChoiceBox starts on YES; NO is one step down + local function answer(yes) + if not yes then U.tap(game, "down") U.wait(10) end + U.tap(game, "a") + for _ = 1, 60 do + local top = game.stack:top() + if getmetatable(top) == TextBox then return top end + U.wait(1) + end + return nil + end + + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = nil + standAt(BEHIND_EAST, "east of the clerk (13,4)") + + local box = talk() + if check("A behind the counter opens a box", box ~= nil) then + U.log("box reads:", boxText(box)) + check("it is the can't-sneak-in-the-back-way line", + boxText(box):find("sneak", 1, true) ~= nil) + check("no money window rides along with it", box.money == nil) + U.shot(game, SHOT_DIR .. "/bug1690_a_sneak.png") + end + + local choice = toChoice() + if check("the AMBER question offers YES/NO", choice ~= nil) then + U.shot(game, SHOT_DIR .. "/bug1690_b_amber_choice.png") + local answered = answer(true) + if check("YES opens a follow-up box", answered ~= nil) then + U.log("YES reads:", boxText(answered)) + check("YES is the resurrection lab line", + boxText(answered):find("lab", 1, true) ~= nil) + U.shot(game, SHOT_DIR .. "/bug1690_c_lab.png") + end + end + + -- with the ticket already bought the coordinate check still wins: a + -- ticket holder round the back is told off, not thanked (asm:45 runs + -- before the CheckEvent at asm:59) + game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true + standAt(BEHIND_NORTH, "north of the clerk (12,3), ticket in hand") + box = talk() + if check("A north of the clerk opens a box", box ~= nil) then + U.log("box reads:", boxText(box)) + check("a ticket holder round the back still gets told off", + boxText(box):find("sneak", 1, true) ~= nil) + U.shot(game, SHOT_DIR .. "/bug1690_d_ticket_holder_sneak.png") + end + choice = toChoice() + if check("it still offers YES/NO", choice ~= nil) then + local answered = answer(false) + if check("NO opens a follow-up box", answered ~= nil) then + U.log("NO reads:", boxText(answered)) + check("NO is the fossilized tree sap line", + boxText(answered):find("tree sap", 1, true) ~= nil) + U.shot(game, SHOT_DIR .. "/bug1690_e_tree_sap.png") + end + end + + -- the control case: the public side of the counter is untouched + game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = nil + game.save.money = 3000 + standAt(IN_FRONT, "in front of the counter (10,4)") + box = talk() + if check("A across the counter opens a box", box ~= nil) then + U.log("box reads:", boxText(box)) + check("the front of the counter is still the ¥50 ticket ask", + boxText(box):find("50", 1, true) ~= nil) + check("and it still raises the money window", box.money ~= nil) + U.shot(game, SHOT_DIR .. "/bug1690_f_ticket_ask.png") + end + + U.log("shots are in " .. SHOT_DIR .. ", in the order they were taken.") + U.log("behind the counter the clerk should say \"You can't sneak in the back") + U.log("way!\", then \"Oh, whatever! Do you know what AMBER is?\" with YES/NO,") + U.log("and never a money window. YES talks about a lab resurrecting ancient") + U.log("POKeMON, NO says AMBER is fossilized tree sap. The box left on screen") + U.log("now is the last case: standing at (10,4) with no ticket, which must") + U.log("still be the ¥50 ask with the money window in the corner.") + U.log("the near misses to watch for: a money window sitting behind the AMBER") + U.log("question, or \"Take your time, and enjoy it all!\" at (12,3) once the") + U.log("ticket flag is set, which would mean the ticket check ran first.") + U.log("Yellow shares this script, so POKEPORT_VERSION=yellow should read") + U.log("exactly the same at all three spots.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/pet_cries_bug1687_1649_test.lua b/tests/drivers/pet_cries_bug1687_1649_test.lua new file mode 100644 index 00000000..bdcd9a8f --- /dev/null +++ b/tests/drivers/pet_cries_bug1687_1649_test.lua @@ -0,0 +1,303 @@ +-- Talks to all six pet Pokemon for you and lets each one cry: #1687 +-- (S.S. Anne WIGGLYTUFF + MACHOKE) and #1649 (Vermilion PIDGEY, Vermilion +-- City MACHOP, Fan Club PIKACHU + SEEL). pokered/scripts/*.asm, e.g. +-- VermilionPidgeyHouse.asm:15 text_far then PlayCry at :19. Never add +-- POKEPORT_SPEED: only the logic clock scales, so the cry lands in the +-- wrong place relative to the typing this run exists to judge. +-- POKEPORT_DRIVER=tests/drivers/pet_cries_bug1687_1649_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red POKEPORT_IDENTITY=petcries SHOT_DIR=/tmp/petcries love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local TextBox = require("src.render.TextBox") + local Runtime = require("src.mods.Runtime") + local GameVersion = require("src.core.GameVersion") + local init = require("data.scripts.init") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local yellow = GameVersion.isYellow() + local failures = 0 + + local function check(label, ok) + if not ok then failures = failures + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- own tick counter: the ordering claim is "cry after the last character", + -- so the cry and the end of typing have to be stamped on the same clock + local ticks = 0 + local function step() ticks = ticks + 1; coroutine.yield() end + local function waitTicks(n) for _ = 1, n do step() end end + local function tap(btn) U.tap(game, btn); ticks = ticks + 1 end + + -- sound.played is the engine's own cue feed (src/core/Sound.lua played()), + -- which is the only way a script can tell "no cry" from "a cry nobody + -- could hear"; both sound identical on a quiet machine. + local cries = {} + local heard = false + if type(Runtime.events.on) == "function" then + Runtime.events:on("sound.played", function(p) + if p and p.kind == "cry" then + heard = true + cries[#cries + 1] = { name = p.name, species = p.species, tick = ticks } + end + end, 0, "driver") + end + + -- pokered/data/maps/objects, all cell coordinates: VermilionPidgeyHouse + -- PIDGEY (3,5), VermilionCity MACHOP (29,9), PokemonFanClub PIKACHU (6,4) + -- and SEEL (1,4), SSAnne1FRooms WIGGLYTUFF (3,11), SSAnneB1FRooms MACHOKE + -- (11,12). The two S.S. Anne rooms maps slide their objects between + -- cabins at load (src/world/SsAnneLayout.lua), so every position below is + -- read back off the live map instead of trusted from the list. + local PETS = { + { map = "VERMILION_PIDGEY_HOUSE", object = "VERMILIONPIDGEYHOUSE_PIDGEY", + const = "TEXT_VERMILIONPIDGEYHOUSE_PIDGEY", species = "PIDGEY", + label = "_VermilionPidgeyHousePidgeyText", + control = "VERMILIONPIDGEYHOUSE_YOUNGSTER" }, + { map = "SS_ANNE_1F_ROOMS", object = "SSANNE1FROOMS_WIGGLYTUFF", + const = "TEXT_SSANNE1FROOMS_WIGGLYTUFF", species = "WIGGLYTUFF", + label = "_SSAnne1FRoomsWigglytuffText", + control = "SSANNE1FROOMS_LITTLE_GIRL" }, + { map = "SS_ANNE_B1F_ROOMS", object = "SSANNEB1FROOMS_MACHOKE", + const = "TEXT_SSANNEB1FROOMS_MACHOKE", species = "MACHOKE", + label = "_SSAnneB1FRoomsMachokeText" }, + { map = "POKEMON_FAN_CLUB", object = "POKEMONFANCLUB_SEEL", + const = "TEXT_POKEMONFANCLUB_SEEL", species = "SEEL", + label = "_PokemonFanClubSeelText" }, + -- two boxes, cry on the first (VermilionCity.asm:224, PlayCry at :228) + { map = "VERMILION_CITY", object = "VERMILIONCITY_MACHOP", + const = "TEXT_VERMILIONCITY_MACHOP", species = "MACHOP", + label = "_VermilionCityMachopText", extraBoxes = 1 }, + } + if yellow then + -- pokeyellow/data/maps/objects/PokemonFanClub.asm: the pet is a CLEFAIRY + PETS[#PETS + 1] = { map = "POKEMON_FAN_CLUB", + object = "POKEMONFANCLUB_CLEFAIRY", + const = "TEXT_POKEMONFANCLUB_CLEFAIRY", species = "CLEFAIRY", + label = "_PokemonFanClubClefairyText" } + else + PETS[#PETS + 1] = { map = "POKEMON_FAN_CLUB", + object = "POKEMONFANCLUB_PIKACHU", + const = "TEXT_POKEMONFANCLUB_PIKACHU", species = "PIKACHU", + label = "_PokemonFanClubPikachuText" } + end + + -- ---------------------------------------------------------------- checks + -- Everything below produces the same silence the bug did, so it is worth + -- knowing before anyone bothers listening. + local vol = game.save and game.save.options and game.save.options.sfxVol + U.log("sfx volume is", tostring(vol), "of 7") + if not vol or vol == 0 then + check("sfx volume is above zero (a muted run sounds exactly like the bug)", + false) + end + if yellow and game.save.options.pikaVol == 0 then + U.log("pikaVol is 0: Yellow's Pikachu clips are muted independently") + end + + for _, pet in ipairs(PETS) do + local tag = pet.species .. " on " .. pet.map + local script = init.talkScript(pet.map, pet.const) + if check(tag .. " has a ported talk script", type(script) == "table") then + local cryAt + for i, row in ipairs(script) do + if type(row) == "table" and row[1] == "play_cry" then cryAt = i end + end + if check(tag .. " has a play_cry row", cryAt ~= nil) then + check(tag .. " cries " .. pet.species, script[cryAt][2] == pet.species) + check(tag .. " uses the waitForButton form", script[cryAt][3] == true) + local next_ = script[cryAt + 1] + check(tag .. " arms " .. pet.label .. " on the very next row", + type(next_) == "table" and next_[1] == "show_text" + and next_[2] == pet.label) + end + end + check(tag .. " has a cry program in this cache", + game.data.audio.cries and game.data.audio.cries[pet.species] ~= nil) + check(pet.label .. " resolves to text", + type(game.data.text[pet.label]) == "string") + end + + -- Sound.playCry sends PIKACHU to the PCM clips whenever the cache carries + -- them; that voiced "Pikachuuu" is Yellow's, and hearing it in Red is the + -- failure #1649 warned about up front. + if not yellow then + check("this cache has no Pikachu voice clips (Red must get the chip cry)", + game.data.audio.pikaCries == nil) + end + + if failures > 0 then + U.log(failures, "checks failed above, so do not bother listening yet;") + U.log("a missing row or an unresolved label sounds exactly like the bug.") + end + + -- ---------------------------------------------------------------- walking + local function findNpc(ow, name) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == name then return n end + end + return nil + end + + -- {dx, dy, facing}: the offset from the pet to the cell to stand on plus + -- the direction that looks back at it, so +1 on y means stand below. + local SIDES = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + + local function approach(ow, npc) + for _, s in ipairs(SIDES) do + local cx, cy = npc.cellX + s[1], npc.cellY + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + return cx, cy, s[3] + end + end + return nil + end + + -- PIDGEY and MACHOP are WALK objects and drift off their spawn cell, so + -- pin them once the final teleport has rebuilt the npc list. + local function pin(npc) + npc.wanders = false + npc:resetToSpawn() + npc.wanders = false + end + + local function standAt(mapId, objName) + local def + for _, o in ipairs((game.data.maps[mapId] or {}).objects or {}) do + if o.name == objName then def = o end + end + if not def then return nil end + -- first hop just mounts the map so the live cells can be read + U.teleport(game, mapId, def.x, math.max(0, def.y + 1), "up") + waitTicks(6) + local ow = game.overworld + local npc = ow and findNpc(ow, objName) + if not npc then return nil end + pin(npc) + local cx, cy, facing = approach(ow, npc) + if not cx then return nil end + U.teleport(game, mapId, cx, cy, facing) + waitTicks(6) + ow = game.overworld + npc = findNpc(ow, objName) + if not npc then return nil end + pin(npc) + local fx, fy = ow.player:facingCell() + if ow:npcAtCell(fx, fy) ~= npc then return nil end + return npc + end + + -- Opens the box, holds still while it types and cries, then reports where + -- the cry landed against the last typed character. + local function talk(name) + local before = #cries + tap("a") + local box, armed, doneTick + for _ = 1, 300 do + local top = game.stack:top() + if getmetatable(top) == TextBox then + box = top + -- box.auto is cleared the moment the cry finishes, so snapshot it + if armed == nil then armed = top.auto or false end + if top.done and not doneTick then doneTick = ticks end + end + if doneTick and #cries > before then break end + step() + end + if not check(name .. ": pressing A opened a text box", box ~= nil) then + return nil + end + local lines = {} + for _, page in ipairs(box.pages or {}) do + for _, line in ipairs(page) do lines[#lines + 1] = line end + end + U.log("box reads:", table.concat(lines, " / ")) + return box, armed, doneTick, before + end + + U.log("about to walk to each pet and talk to it; turn the volume up now.") + waitTicks(180) + + for _, pet in ipairs(PETS) do + local tag = pet.species .. " on " .. pet.map + U.log("walking to the", pet.species) + local npc = standAt(pet.map, pet.object) + if not check(tag .. ": standing face to face with it", npc ~= nil) then + U.log("skipping", pet.species, "-- could not reach it") + else + local box, armed, doneTick, before = talk(tag) + if box then + -- the dropped `true` shows up here: without it the box pops itself + -- when the cry ends and never blinks its arrow + check(tag .. ": the box was armed with a cry", + type(armed) == "table" and type(armed.sound) == "function") + check(tag .. ": the cry keeps the A/B wait (waitForButton)", + type(armed) == "table" and armed.wait == true) + local fired = cries[before + 1] + if check(tag .. ": a cry actually played", fired ~= nil) then + check(tag .. ": the cry was " .. pet.species, + fired.species == pet.species) + -- near miss: a cry that fires as the line starts typing + check(tag .. ": it played after the last character, not before", + doneTick ~= nil and fired.tick >= doneTick) + if pet.species == "PIKACHU" and not yellow then + check("PIKACHU used the chip cry, not Yellow's voice clip", + fired.name == "PIKACHU") + end + check(tag .. ": exactly one cry for this box", + #cries == before + 1) + end + waitTicks(90) + U.shot(game, SHOT_DIR .. "/pet_cry_" .. pet.species:lower() .. ".png") + check(tag .. ": the box is still up waiting for A", + getmetatable(game.stack:top()) == TextBox) + for _ = 1, (pet.extraBoxes or 0) + 1 do + tap("a") + waitTicks(40) + end + if pet.extraBoxes then + U.log("the MACHOP's second box is the stomping line; it has no cry") + end + waitTicks(30) + end + end + + -- a neighbour that is meant to stay silent, so a run with no audio + -- device at all cannot pass itself off as a working cry + if pet.control then + local silent = standAt(pet.map, pet.control) + if silent then + local mark = #cries + talk(pet.control .. " (control)") + check(pet.control .. ": the silent neighbour played no cry", + #cries == mark) + tap("a") + waitTicks(30) + tap("a") + waitTicks(20) + end + end + end + + -- ---------------------------------------------------------------- verdict + U.log("talked to all", #PETS, "pets;", #cries, "cries fired.") + if not heard then + U.log("no cry reached the mixer at all, which is the #1687 / #1649 bug") + end + U.log(failures == 0 and "all checks passed" or (failures .. " checks FAILED")) + U.log("what you should have heard, once per pet: the box types the line,") + U.log("then a short cry, and only then the down arrow starts blinking.") + U.log("a cry that starts while the line is still typing is wrong, and so") + U.log("is a box that closes itself when the cry ends instead of waiting.") + U.log("in Red the Fan Club PIKACHU is a short chip cry; the long voiced") + U.log("\"Pikachuuu\" belongs to Yellow, where the pet is a CLEFAIRY.") + U.log("re-run with POKEPORT_VERSION=blue and POKEPORT_VERSION=yellow.") + U.log("screenshots are in", SHOT_DIR) + + -- parked in front of the last pet, so another A press repeats its cry + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/ss_anne_departure_bug360_test.lua b/tests/drivers/ss_anne_departure_bug360_test.lua index efa4aefe..11a1b49d 100644 --- a/tests/drivers/ss_anne_departure_bug360_test.lua +++ b/tests/drivers/ss_anne_departure_bug360_test.lua @@ -90,11 +90,11 @@ return function(game) if r[2] == "SS_Anne_Horn" then horns = horns + 1 end end check("the departure blows the horn twice", horns == 2) - local slide = 0 - for _, w in ipairs(rowsOfKind(dockRows, "wait")) do - if w[2] == 20 then slide = slide + 1 end - end - check("she sails in eight beats, not one frame", slide == 8) + check("she sails as one blocking slide, not a block shuffle", + #rowsOfKind(dockRows, "ss_anne_departs") == 1 + and #rowsOfKind(dockRows, "replace_block") == 0) + check("he keeps facing down until he walks out", + (rowsOfKind(dockRows, "face_player_dir")[1] or {})[2] == "down") check("no keepMusic override into the city", (function() for _, r in ipairs(rowsOfKind(dockRows, "play_music")) do if r[3] and r[3].keep then return false end @@ -130,10 +130,12 @@ return function(game) and game.save.flags.EVENT_SS_ANNE_LEFT == nil and game.save.flags.EVENT_BEAT_SS_ANNE_RIVAL == nil) - U.log("Watch for: she idles a couple of seconds with smoke off the funnel,") - U.log("one horn, then travels WEST a block at a time with the water closing") - U.log("in behind her, a second horn once she is gone, and the surf loop") - U.log("gives way to the Vermilion theme the moment you cross into town.") + U.log("Watch for: he turns to face DOWN and stays that way, she idles two") + U.log("seconds, one horn, then she slides WEST smoothly for about seventeen") + U.log("seconds with white smoke puffing off the front funnel and drifting") + U.log("east, the dock row he stands on never moving; a second horn once she") + U.log("is gone, the gangway stub still under his feet, then he walks up and") + U.log("the surf loop gives way to the Vermilion theme as you cross in.") U.teleport(game, DOCK, DOCK_CELL.x, DOCK_CELL.y, "up") local ow = game.overworld diff --git a/tests/drivers/wrap_anim_bug1653_test.lua b/tests/drivers/wrap_anim_bug1653_test.lua new file mode 100644 index 00000000..032025d8 --- /dev/null +++ b/tests/drivers/wrap_anim_bug1653_test.lua @@ -0,0 +1,404 @@ +-- Driver: Wrap's animation length and the blank tile-load beat that opens every +-- subanimation row (#1653). PlayAnimation calls LoadMoveAnimationTiles once per +-- row (engine/battle/animations.asm:252) and CopyVideoData blocks c/8 + 1 frames +-- (home/copy2.asm:62), so WrapAnim's three rows (data/moves/animations.asm:401) +-- cost 3 x 10 blank frames on top of the 30 the port already played. +-- No POKEPORT_SPEED: it scales only the logic clock while audio runs real-time, +-- which desyncs the sfx-against-pulse ordering this driver exists to judge. +-- POKEPORT_DRIVER=tests/drivers/wrap_anim_bug1653_test.lua POKEPORT_VERSION=red POKEPORT_TOUCH=0 POKEPORT_IDENTITY=bug1653 SHOT_DIR=/tmp/wrap love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local AnimPlayer = require("src.battle.AnimPlayer") + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- what the eye and ear cannot check --------------------------------- + U.log("#1653 Wrap animation length: machine checks") + + local ba = game.data.battle_anims + check("battle_anims is in the cache", ba ~= nil) + local anims = (ba or {}).moveAnims or {} + check("WRAP is in the move table", game.data.moves.WRAP ~= nil) + check("WRAP has an animation", anims.WRAP ~= nil) + + -- anim_tileset 79 / 79 / 64 (engine/battle/animations.asm:383): 79 tiles is + -- nine 8-tile chunks plus the tail frame, so tileset 0 blocks 10 frames + local sheet0 = (ba or {}).tilesheets and ba.tilesheets[0] + check(("tileset 0 declares %s tiles (anim_tileset 79)") + :format(tostring(sheet0 and sheet0.tiles)), + sheet0 ~= nil and sheet0.tiles == 79) + + -- WrapAnim is three identical rows: SUBANIM_0_BIND on tileset 0, delay 4 + local seq = (anims.WRAP or {}).seq or {} + check(("WRAP is %d subanimation rows (want 3)"):format(#seq), #seq == 3) + local rowsOk = #seq == 3 + for _, row in ipairs(seq) do + rowsOk = rowsOk and row.effect == nil and row.tileset == 0 + and row.delay == 4 and row.sound == "WRAP" + end + check("every WRAP row is a tileset-0 subanimation with sound WRAP", rowsOk) + + -- The move id in the row resolves to a real sfx program through moves.lua + local sfxName = (game.data.moves.WRAP or {}).anim + and game.data.moves.WRAP.anim.sound + check("WRAP's row sound resolves to a program (" .. tostring(sfxName) .. ")", + sfxName ~= nil and ((game.data.audio or {}).sfx or {})[sfxName] ~= nil) + + -- ---- the compiled timeline --------------------------------------------- + local function compile(move) + if not (ba and anims[move]) then return nil end + local p = AnimPlayer.new(ba) + local ok = pcall(p.start, p, move, false) + if not ok then return nil end + return p + end + + local function total(p) + local n = 0 + for _, s in ipairs(p.steps) do n = n + s.dur end + return n + end + + local function shapeOf(p) + local out = {} + for _, s in ipairs(p.steps) do + out[#out + 1] = ("%d/%d"):format(s.dur, #s.sprites) + end + return table.concat(out, " ") + end + + -- which step is on screen at `elapsed`, mirroring AnimPlayer:update + local function stepAt(p, elapsed) + local acc = 0 + for _, s in ipairs(p.steps) do + acc = acc + s.dur + if elapsed < acc then return s end + end + return nil + end + + local wrap = compile("WRAP") + check("WRAP compiles into a timeline", wrap ~= nil) + + if wrap then + U.log(" WRAP steps: " .. shapeOf(wrap)) + check(("WRAP is %d frames long (want 60, half-length bug gives 30)") + :format(total(wrap)), total(wrap) == 60) + check(("WRAP compiles to %d steps (want 9: three rows of load + 2 blocks)") + :format(#wrap.steps), #wrap.steps == 9) + + local shapeOk = #wrap.steps == 9 + for i = 1, 9 do + local s = wrap.steps[i] + if not s then shapeOk = false break end + if i % 3 == 1 then + shapeOk = shapeOk and s.dur == 10 and #s.sprites == 0 + else + shapeOk = shapeOk and s.dur == 5 and #s.sprites == 8 + end + end + check("steps 1, 4 and 7 are 10 blank frames and the other six are 5 frames " + .. "of 8 sprites", shapeOk) + + -- Near-miss guard. Paying the 10 frames AFTER a row's blocks also totals + -- 60, but it opens on sprites and ends on a blank tail, and every sound + -- lands 10 frames early. + local first, last = wrap.steps[1], wrap.steps[#wrap.steps] + check("the blank beat OPENS the animation (step 1 is blank, 10 frames)", + first ~= nil and #first.sprites == 0 and first.dur == 10) + check("...and does not close it (the last step draws sprites)", + last ~= nil and #last.sprites > 0) + + local sounds = {} + for _, ev in ipairs(wrap.events) do + if ev.sound then sounds[#sounds + 1] = ev end + end + check(("WRAP queues %d row sounds (want 3)"):format(#sounds), #sounds == 3) + local want = { 10, 30, 50 } + for i = 1, 3 do + local ev = sounds[i] + check(("sound %d fires at frame %s (want %d, after that row's tile load)") + :format(i, ev and tostring(ev.frame) or "nil", want[i]), + ev ~= nil and ev.frame == want[i] and ev.sound == "WRAP") + if ev then + local st = stepAt(wrap, ev.frame) + check(("sound %d lands on a frame with sprites on screen, not on the " + .. "blank beat"):format(i), + st ~= nil and #st.sprites > 0) + end + end + end + + -- ---- the same load, elsewhere: this was never Wrap-specific ------------ + -- POUND is one tileset-0 row, so it gains the same 10 frames. + local pound = compile("POUND") + if pound then + local s = pound.steps[1] + check(("POUND also opens on a 10-frame blank load (%s)") + :format(s and ("%d/%d"):format(s.dur, #s.sprites) or "nil"), + s ~= nil and s.dur == 10 and #s.sprites == 0) + end + + -- Tileset 2 declares 64 tiles, which is eight chunks plus the tail: 9 frames. + local ball = compile("TRADE_BALL_DROP_ANIM") + if ball then + local s = ball.steps[1] + check(("a tileset-2 animation opens on 9 blank frames instead of 10 (%s)") + :format(s and ("%d/%d"):format(s.dur, #s.sprites) or "nil"), + s ~= nil and s.dur == 9 and #s.sprites == 0) + end + + -- Control: TACKLE's animation is two special-effect rows and no subanimation, + -- so PlayAnimation never reaches LoadMoveAnimationTiles and nothing changes. + local tackle = compile("TACKLE") + if tackle then + check(("TACKLE, which has no subanimation row, is still %d frames " + .. "(the load is not charged blindly)"):format(total(tackle)), + total(tackle) == 6) + end + + -- ---- options that would fake a failure --------------------------------- + local opts = game.save.options or {} + check("battle animations are on in OPTION", opts.animations ~= false) + if opts.animations == false then + U.log(" ANIMATION is off, which skips the whole queued row: turn it on") + U.log(" in OPTION before judging anything below") + end + local vol = opts.sfxVol + if vol == 0 then + U.log(" sfxVol is 0: all three WRAP sfx will be SILENT, which sounds") + U.log(" exactly like the bug. Raise SFX in OPTION before listening") + else + U.log(" sfxVol " .. tostring(vol) .. ", so the three squeezes should be audible") + end + + U.log(("machine checks: %d passed, %d failed"):format(pass, fail)) + if fail > 0 then + U.log("something above says FAIL, so do not spend time watching the screen") + end + if not game.data.moves.WRAP then + U.log("no WRAP in the move table, so there is nothing to put on screen") + while true do coroutine.yield() end + end + + -- ---- put a Wrap on screen ---------------------------------------------- + -- data/generated/maps.lua ROUTE_1 is 10x18 blocks of open route, and pokered + -- data/maps/objects/Route1.asm parks its two youngsters at (5, 24) and + -- (15, 13), so the north end is empty. (5, 5) sits in the fence line in the + -- current cache, so the neighbour fallback below is the one that runs; the + -- battle is pushed straight in either way. + local MAP, SX, SY = "ROUTE_1", 5, 5 + + local ekans = Pokemon.new(game.data, "EKANS", 30) + ekans.moves = { { id = "WRAP", pp = game.data.moves.WRAP.pp } } + game.save.party = { ekans } + game.save.player.name = "RED" + + U.teleport(game, MAP, SX, SY, "down") + U.wait(12) + local ow = game.overworld + if ow and not ow.map:isWalkableCell(SX, SY) then + -- a map edit blocked the cell: take the nearest free neighbour instead + for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do + local cx, cy = SX + d[1], SY + d[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("(%d, %d) is blocked, standing on %d, %d"):format(SX, SY, cx, cy)) + U.teleport(game, MAP, cx, cy, "down") + U.wait(12) + ow = game.overworld + break + end + end + end + check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP) + + local battle = BattleState.newWild(game, "PIDGEY", 20) + battle.onFinish = function() end + ow:pushBattle(battle) + for _ = 1, 400 do + if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end + U.wait(1) + end + check("the battle reached the screen", game.stack:top() == battle) + + -- a foe that survives the wrap chain, so the animation can repeat + battle.enemy.mon.stats.hp = 500 + battle.enemy.mon.hp = 500 + battle.enemy.shownHP = 500 + -- a wild PIDGEY that knows WHIRLWIND ends the fight the moment it picks it + for i = #battle.enemy.mon.moves, 1, -1 do + local id = battle.enemy.mon.moves[i].id + if id == "WHIRLWIND" or id == "ROAR" or id == "TELEPORT" then + table.remove(battle.enemy.mon.moves, i) + end + end + + -- ---- watch the live playback ------------------------------------------- + -- every frame the player is playing, record whether sprites are on screen, + -- so the blank load beats show up as runs in the trace + local runs, cur = {}, nil + if battle.animPlayer then + local realStart = battle.animPlayer.start + battle.animPlayer.start = function(self, moveId, isPlayer, o) + local r = realStart(self, moveId, isPlayer, o) + cur = { move = moveId, marks = {} } + runs[#runs + 1] = cur + return r + end + end + + -- U.shot spins frames of its own, which would punch a hole in the trace, so + -- the measuring turn runs with `shooting` off and a later turn takes the shot + local shooting, shot = false, false + local function sample() + if not (battle.animPlaying and cur and battle.animPlayer) then return end + local ap = battle.animPlayer + local st = ap.steps[ap.stepIndex] + if not st then return end + local lit = #st.sprites > 0 + cur.marks[#cur.marks + 1] = lit and 1 or 0 + if shooting and not shot and lit and cur.move == "WRAP" then + shot = true + U.shot(game, DIR .. "/bug1653_wrap_squeeze.png") + end + end + + local function runLengths(marks) + local out, run, val = {}, 0, nil + for _, m in ipairs(marks) do + if m ~= val then + if val ~= nil then + out[#out + 1] = ("%s %d"):format(val == 1 and "sprites" or "blank", run) + end + val, run = m, 0 + end + run = run + 1 + end + if val ~= nil then + out[#out + 1] = ("%s %d"):format(val == 1 and "sprites" or "blank", run) + end + return table.concat(out, ", ") + end + + -- step n frames, sampling each one, pressing A only while a box is up: an A + -- on the FIGHT menu opens the move list and fires a move behind our back + local function pump(n, mash, stop) + for i = 1, n do + if mash and i % mash == 0 and battle.phase == "messages" then + table.insert(game.input.pressQueue, "a") + end + U.wait(1) + game.input.state.a = false + sample() + if stop and stop() then return end + end + end + + local function toMenu() + pump(1200, 6, function() return battle.phase == "menu" and #battle.queue == 0 end) + return battle.phase == "menu" + end + + -- FIGHT is menuIndex 1 of the 2x2 grid, and WRAP is the only move on the list + local function useWrap() + for _ = 1, 80 do + if battle.phase == "moveSelect" then break end + if battle.phase == "menu" then + if battle.menuIndex ~= 1 then + U.tap(game, battle.menuIndex > 2 and "up" or "left") + else + U.tap(game, "a") + end + else + U.tap(game, "a") + end + for _ = 1, 3 do U.wait(1) sample() end + end + if battle.phase ~= "moveSelect" then return false end + for _ = 1, 20 do + if battle.phase ~= "moveSelect" then return true end + U.tap(game, "a") + for _ = 1, 3 do U.wait(1) sample() end + end + return false + end + + -- the foe animates too, so pick the player's WRAP row out of the list + local function wrapRun() + for i = #runs, 1, -1 do + if runs[i].move == "WRAP" and #runs[i].marks > 0 then return runs[i] end + end + return nil + end + + check("the battle reached its FIGHT menu", toMenu()) + + local sent = false + for _ = 1, 6 do + -- WRAP is 85% accurate and the foe leans on SAND-ATTACK, so hand the + -- accuracy back each try rather than reading a whiff as a regression + battle.player.stages.accuracy = 0 + sent = useWrap() + if not sent then break end + pump(900, 8, function() + return wrapRun() ~= nil and not battle.animPlaying + end) + if wrapRun() then break end + U.log(" WRAP did not connect that turn; using it again") + toMenu() + end + check("chose WRAP from the move menu", sent) + + local played = wrapRun() + check("WRAP's animation actually played on screen", played ~= nil) + if played then + U.log((" live playback: %d frames, %s") + :format(#played.marks, runLengths(played.marks))) + -- the sampler runs once per fixed step and can slip a frame against the + -- battle's own update, so the compiled timeline above is the exact + -- authority; this only has to rule out the 30-frame version + check(("live playback is around 60 frames, not 30 (measured %d)") + :format(#played.marks), #played.marks >= 50) + end + -- WRAP traps the foe, so the next turn replays it: measurement is done, so + -- now let the sampler stop and catch a squeeze mid-coil for the record + shooting = true + for _ = 1, 4 do + if shot then break end + if battle.phase == "menu" then + battle.player.stages.accuracy = 0 + if not useWrap() then break end + end + pump(600, 8, function() return shot end) + end + if not shot then U.log(" no lit WRAP frame to capture") end + U.shot(game, DIR .. "/bug1653_wrap_after.png") + + -- ---- hand off ---------------------------------------------------------- + U.log("the pad is yours in a battle where the EKANS knows only WRAP (#1653),") + U.log("so pick FIGHT then WRAP and watch. the coils should squeeze the PIDGEY") + U.log("three separate times, each squeeze about a sixth of a second, with a") + U.log("clear blank beat of the same length before each one -- roughly a second") + U.log("end to end. the old bug ran the three squeezes together as one half-") + U.log("second smear with no gaps.") + U.log("listen as well as look: each of the three WRAP sfx should land as the") + U.log("coils appear. an sfx that fires while the screen is still blank, with") + U.log("the pause moved to the end of the animation, is the near miss that") + U.log("still measures 60 frames.") + U.log("wrap traps the foe, so the animation repeats over the next few turns") + U.log("and you can watch it more than once. any other move works the same") + U.log("way: every animation in the game now opens on that blank beat.") + U.log("screenshots: " .. DIR .. "/bug1653_*.png") + + while true do + coroutine.yield() + end +end diff --git a/tests/engine/bag_bicycle_no_options_bug1705.lua b/tests/engine/bag_bicycle_no_options_bug1705.lua new file mode 100644 index 00000000..772144aa --- /dev/null +++ b/tests/engine/bag_bicycle_no_options_bug1705.lua @@ -0,0 +1,244 @@ +-- The BICYCLE is used straight off the item list, with no USE/TOSS box +-- (#1705). +-- +-- StartMenu_Item's .choseItem reads wCurItem and, before it ever loads +-- USE_TOSS_MENU_TEMPLATE into wTextBoxID, does `cp BICYCLE / jp z, +-- .useOrTossItem` (engine/menus/start_sub_menus.asm:340-342). So the bike +-- mounts, dismounts or refuses on one A press, and -- having no TOSS row to +-- reach -- can never be thrown away from the bag at all. Every other item, +-- key items included, still gets the option box. +-- +-- The port pushed the USE/TOSS Menu for every field item, so the bike took +-- two presses and offered a TOSS the cart does not. +-- luajit tests/engine/bag_bicycle_no_options_bug1705.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end } +local music = {} +package.loaded["src.core.Music"] = { + playMap = function(_, mapId, biking) music[#music + 1] = { mapId, biking } end, +} +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { textBox = true, text = text, done = done } end, + soundOpts = function() return nil end, +} +package.loaded["src.ui.BagMenu"] = nil +local BagMenu = require("src.ui.BagMenu") +local Menu = require("src.ui.Menu") +require("src.ui.Screens").invalidate() + +local Fixtures = require("tests.modkit.fixtures") +local Bag = require("src.inventory.Bag") + +local Data = Fixtures.fresh() +-- the fixture item table carries neither, and BagMenu reads only +-- name/keyItem/machine off a def; the branches under test key on the id +Data.items.BICYCLE = { id = "BICYCLE", index = 6, name = "BICYCLE", price = 0, + keyItem = true } +Data.items.TOWN_MAP = { id = "TOWN_MAP", index = 5, name = "TOWN MAP", + price = 0, keyItem = true } +Data.items.FIX_POTION = Data.items.FIX_POTION + or { id = "FIX_POTION", index = 20, name = "FIX POTION", price = 300 } + +local BIKE_MAP = { id = "FIX_TOWN", def = { tileset = "OVERWORLD" } } + +local function freshGame(extras) + local game = { + data = Data, + save = { + party = {}, + player = { name = "RED", id = 1 }, + inventory = {}, + options = {}, + flags = {}, + money = 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 = { pressed = nil } + function game.input:wasPressed(b) return self.pressed == b end + function game.input:isDown() return false end + -- IsBikeRidingAllowed reads the tileset off the loaded map, and the + -- mount/dismount lines read the player's name off the save + game.overworld = { map = BIKE_MAP, player = { surfing = false } } + Bag.add(game.save, "FIX_POTION", 3) + Bag.add(game.save, "BICYCLE", 1) + Bag.add(game.save, "TOWN_MAP", 1) + for k, v in pairs(extras or {}) do game.save[k] = v end + return game +end + +local function rowFor(list, id) + for i, r in ipairs(list.items) do + if r.value == id then return i end + end + return nil +end + +local function isMenu(s) return getmetatable(s) == Menu end +local function isBox(s) return type(s) == "table" and s.textBox == true end + +local function inStack(game, pred) + for _, s in ipairs(game.stack.states) do + if pred(s) then return true end + end + return false +end + +-- Open the bag, put the cursor on `id` and press A once. Returns the list +-- and every state the press left on the stack above it, so a USE/TOSS box +-- that appears and is then replaced is still caught. +local function chooseOnce(game, id) + local list = BagMenu.new(game, {}) + game.stack:push(list) + local row = rowFor(list, id) + if not row then return nil, nil, "no " .. id .. " row in the bag" end + list.index = row + local seen = {} + local realPush = game.stack.push + game.stack.push = function(self, s) + seen[#seen + 1] = s + return realPush(self, s) + end + game.input.pressed = "a" + list:update(1 / 60) + game.input.pressed = nil + game.stack.push = realPush + return list, seen +end + +local function sawMenu(seen) + for _, s in ipairs(seen or {}) do + if isMenu(s) then return true end + end + return false +end + +local function boxText(game) + local top = game.stack:top() + return isBox(top) and top.text or nil +end + +-- on foot, somewhere cycling is allowed: one A press mounts +do + music = {} + local game = freshGame() + local list, seen, why = chooseOnce(game, "BICYCLE") + if check(list ~= nil, "the bag opened on the BICYCLE row: " .. tostring(why)) then + check(not sawMenu(seen), + "no USE/TOSS Menu was pushed, not even for a frame (:340-342)") + eq(game.save.onBike, true, "the single press mounted the bike") + local said = boxText(game) + if check(said ~= nil, "and printed a line") then + check(said:find("got on", 1, true) ~= nil, + "which is the mount text: " .. tostring(said)) + end + eq(game.save.inventory.BICYCLE, 1, "the bike is still in the bag") + eq(#music, 1, "the bike theme was cued once") + check(music[1] and music[1][2] == true, "as the riding track") + end +end + +-- already riding: the same single press dismounts +do + music = {} + local game = freshGame({ onBike = true }) + local list, seen = chooseOnce(game, "BICYCLE") + if check(list ~= nil, "the bag opened while riding") then + check(not sawMenu(seen), "still no option box on the way off the bike") + eq(game.save.onBike, false, "one press dismounted") + local said = boxText(game) + if check(said ~= nil, "and printed a line") then + check(said:find("got off", 1, true) ~= nil, + "which is the dismount text: " .. tostring(said)) + end + check(music[1] and music[1][2] == false, "and the map theme came back") + end +end + +-- Cycling Road: the refusal is reached on the same single press, and the +-- item list stays open behind it (`jp ItemMenuLoop`, #513) +do + local game = freshGame({ onBike = true, forcedBike = true }) + local list, seen = chooseOnce(game, "BICYCLE") + if check(list ~= nil, "the bag opened on the Cycling Road") then + check(not sawMenu(seen), "the refusal is not behind an option box either") + eq(game.save.onBike, true, "and the player is still riding") + local said = boxText(game) + if check(said ~= nil, "the refusal printed") then + check(said:find("get off", 1, true) ~= nil, + "with _CannotGetOffHereText: " .. tostring(said)) + end + check(inStack(game, function(s) return s == list end), + "the bag list is still on the stack under it (#513)") + end +end + +-- somewhere cycling is not allowed: still one press, still no box +do + local game = freshGame() + game.overworld.map = { id = "FIX_INDOORS", def = { tileset = "FIX_HOUSE" } } + local list, seen = chooseOnce(game, "BICYCLE") + if check(list ~= nil, "the bag opened indoors") then + check(not sawMenu(seen), "the no-cycling refusal skips the box too") + eq(game.save.onBike, nil, "and nothing was mounted") + local said = boxText(game) + check(said ~= nil and said:find("cycling", 1, true) ~= nil, + "NoCyclingAllowedHere printed: " .. tostring(said)) + end +end + +-- the control cases: everything else still gets USE/TOSS, key items included +do + local game = freshGame() + local list, seen = chooseOnce(game, "FIX_POTION") + if check(list ~= nil, "the bag opened on a plain item") then + check(sawMenu(seen), "an ordinary item still opens the option box") + check(isMenu(game.stack:top()), "and it is what the A press left on top") + end +end + +do + local game = freshGame() + local list, seen = chooseOnce(game, "TOWN_MAP") + if check(list ~= nil, "the bag opened on the TOWN MAP") then + check(sawMenu(seen), + "another key item still gets the box: pokered special-cases the " + .. "BICYCLE by id, not key items as a class") + end +end + +-- the box the bike no longer opens is the only route to TOSS, so the bike +-- cannot be thrown away from the bag at all +do + local game = freshGame() + local list, seen = chooseOnce(game, "BICYCLE") + if check(list ~= nil, "the bag opened on the BICYCLE row") then + local rows = {} + for _, s in ipairs(seen or {}) do + for _, it in ipairs((isMenu(s) and s.items) or {}) do + rows[#rows + 1] = tostring(it.label) + end + end + eq(#rows, 0, "the press offered no menu rows at all, TOSS included") + eq(game.save.inventory.BICYCLE, 1, "and the bike survives the press") + end +end + +package.loaded["src.core.Sound"] = nil +package.loaded["src.core.Music"] = nil +package.loaded["src.render.TextBox"] = nil +package.loaded["src.ui.BagMenu"] = nil +require("src.ui.Screens").invalidate() + +T.finish() diff --git a/tests/engine/bag_cancel_row_bug1685.lua b/tests/engine/bag_cancel_row_bug1685.lua new file mode 100644 index 00000000..ad33646f --- /dev/null +++ b/tests/engine/bag_cancel_row_bug1685.lua @@ -0,0 +1,253 @@ +-- The bag list ends on the terminator's CANCEL row (#1685). +-- +-- pokered's item lists are $ff-terminated. PrintListMenuEntries walks four +-- entries and, on the terminator, `jp z, .printCancelMenuItem` -- which is +-- `ld de, ListMenuCancelText / jp PlaceString`, a TAIL jump that RETURNS +-- from PrintListMenuEntries (home/list_menu.asm:371-372, 523-528). The '▼' +-- at :518-522 is only reached by the fall-through, so any page showing +-- CANCEL shows no down arrow. DisplayListMenuIDLoop compares the selection +-- against wListCount and takes ExitListMenu when it is the row past the last +-- item (:105-110), which is the same exit B takes. +-- +-- The port emitted one row per bag entry and stopped, so the list had no +-- CANCEL to walk onto and printed an invented "Nothing here." when the bag +-- was empty. +-- luajit tests/engine/bag_cancel_row_bug1685.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +-- Font wants a real atlas and this suite is about what lands on which row, +-- so it records the calls instead (the shape tests/engine/ +-- bag_item_box_bug1521.lua uses). ListMenu and Theme bind Font at require +-- time, and BagMenu binds ListMenu and TextBox at require time. +local calls = {} +package.loaded["src.render.Font"] = { + BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 }, + draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end, + drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end, + drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end, + width = function(text) return #tostring(text) * 8 end, + -- Menu.new sizes its box off split(); reaching it at all is the failure + -- this suite reports, so the stub answers instead of erroring + split = function(text) + local spans = {} + for i = 1, #tostring(text) do spans[i] = { from = i, to = i, code = 0 } end + return spans + end, +} +package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end } +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { textBox = true, text = text, done = done } end, + soundOpts = function() return nil end, +} +package.loaded["src.ui.ListMenu"] = nil +package.loaded["src.ui.Theme"] = nil +package.loaded["src.ui.BagMenu"] = nil +local ListMenu = require("src.ui.ListMenu") +local Theme = require("src.ui.Theme") +local BagMenu = require("src.ui.BagMenu") +require("src.ui.Screens").invalidate() + +local Fixtures = require("tests.modkit.fixtures") +local Bag = require("src.inventory.Bag") +local Strings = require("src.core.Strings") + +local CANCEL = Strings("CANCEL") + +local Data = Fixtures.fresh() +for i = 1, 6 do + local id = "FIXITEM_" .. i + Data.items[id] = { id = id, index = 100 + i, name = "FIX ITEM " .. i, + price = 100, tossable = true } +end + +local function freshGame(count) + local game = { + data = Data, + save = { + party = {}, + player = { name = "RED", id = 1 }, + inventory = {}, + options = {}, + flags = {}, + money = 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, + } + -- one button edge per update, the way Input reports a fixed step + game.input = { pressed = nil } + function game.input:wasPressed(b) return self.pressed == b end + function game.input:isDown() return false end + for i = 1, count do Bag.add(game.save, "FIXITEM_" .. i, i) end + return game +end + +local function openBag(game, opts) + local list = BagMenu.new(game, opts) + game.stack:push(list) + return list +end + +local function found(kind, pred) + for _, c in ipairs(calls) do + if c[1] == kind and pred(c) then return c end + end + return nil +end + +local function drawnAt(text, x, y) + return found("draw", function(c) + return c[2] == text and c[3] == x and (y == nil or c[4] == y) + end) +end + +local function press(list, btn) + list.game.input.pressed = btn + list:update(1 / 60) + list.game.input.pressed = nil +end + +-- the row itself: one CANCEL after the last bag entry, carrying no id and no +-- quantity (home/list_menu.asm:523-528) +do + local game = freshGame(6) + local list = openBag(game) + eq(#list.items, 7, "six bag entries plus the terminator's CANCEL row") + local last = list.items[#list.items] + eq(last.label, CANCEL, "the last row is CANCEL") + eq(last.cancel, true, "flagged as the terminator row, not an item") + eq(last.value, nil, "with no item id behind it") + eq(last.right, nil, "and no 'xN' count (PrintListMenuEntries never gets " + .. "that far for the terminator)") + eq(CANCEL, "CANCEL", "ListMenuCancelText reads CANCEL") + for i = 1, 6 do + eq(list.items[i].value, "FIXITEM_" .. i, "row " .. i .. " is still its item") + end +end + +-- page one of a long list: four real names, the '▼', no CANCEL yet +do + local game = freshGame(6) + local list = openBag(game) + calls = {} + list:draw() + for row = 1, 4 do + local y = 32 + (row - 1) * 16 + check(drawnAt("FIX ITEM " .. row, 48, y) ~= nil, + "name " .. row .. " sits at (48, " .. y .. ")") + end + check(found("draw", function(c) return c[2] == CANCEL end) == nil, + "CANCEL is below the fold on page one, not printed on it") + check(found("code", function(c) + return c[2] == Theme.moreArrow and c[3] == 144 and c[4] == 88 + end) ~= nil, "four real rows fill the page, so the '▼' prints (:518-522)") +end + +-- walking down: the cursor reaches CANCEL and stops there, and the page it +-- lands on drops the '▼' +do + local game = freshGame(6) + local list = openBag(game) + for _ = 1, 6 do press(list, "down") end + eq(list.index, 7, "six downs put the cursor on the CANCEL row") + eq(list.scroll, 4, "with the scroll capped at wListCount - 2") + press(list, "down") + eq(list.index, 7, "and a seventh down goes nowhere: CANCEL is the last row") + eq(list.scroll, 4, "the list does not scroll past it either") + + calls = {} + list:draw() + -- rows 5, 6, CANCEL, then the terminator's return + check(drawnAt("FIX ITEM 5", 48, 32) ~= nil, "FIX ITEM 5 leads the last page") + check(drawnAt(CANCEL, 48, 64) ~= nil, + "CANCEL sits one row below the last item, at the item names' x") + check(found("code", function(c) + return c[2] == Theme.cursor and c[3] == 40 and c[4] == 64 + end) ~= nil, "the cursor is on it, in wTopMenuItemX's column") + check(found("code", function(c) return c[2] == Theme.moreArrow end) == nil, + "and the '▼' is gone: .printCancelMenuItem returns before it") +end + +-- the subtle one: three items plus CANCEL fills all four printed rows, so +-- `shown == rows` alone would still paint the arrow next to CANCEL +do + local game = freshGame(3) + local list = openBag(game) + eq(#list.items, 4, "three items and CANCEL exactly fill the four rows") + calls = {} + list:draw() + check(drawnAt(CANCEL, 48, 80) ~= nil, "CANCEL is the fourth printed row") + check(found("code", function(c) return c[2] == Theme.moreArrow end) == nil, + "a full page whose last row is CANCEL still prints no '▼' (:371-372)") +end + +-- an empty bag is a box with CANCEL alone, which is what the cart shows +do + local game = freshGame(0) + local list = openBag(game) + eq(#list.items, 1, "no items: the terminator is the whole list") + eq(list.items[1] and list.items[1].cancel, true, "and that row is CANCEL") + calls = {} + list:draw() + check(drawnAt(CANCEL, 48, 32) ~= nil, "printed on the first row") + check(found("draw", function(c) + return tostring(c[2]):find("Nothing here", 1, true) ~= nil + end) == nil, "the port's invented \"Nothing here.\" line is gone") + check(found("code", function(c) return c[2] == Theme.moreArrow end) == nil, + "one row, no '▼'") +end + +-- A on CANCEL is ExitListMenu: the same exit B takes (:105-110) +do + local game = freshGame(6) + local cancels = 0 + local list = openBag(game, { onCancel = function() cancels = cancels + 1 end }) + for _ = 1, 6 do press(list, "down") end + eq(list.index, 7, "cursor on CANCEL") + press(list, "a") + check(game.stack:top() ~= list, "A on CANCEL leaves the item list") + eq(#game.stack.states, 0, "with nothing pushed over it: no USE/TOSS box, " + .. "no message") + eq(cancels, 1, "and the caller's onCancel ran, exactly as for B") +end + +do + local game = freshGame(6) + local cancels = 0 + local list = openBag(game, { onCancel = function() cancels = cancels + 1 end }) + press(list, "b") + check(game.stack:top() ~= list, "B still exits the list") + eq(cancels, 1, "through the same callback") +end + +-- SELECT swaps two bag entries; the terminator is not one +-- (engine/menus/swap_items.asm:19-22) +do + local game = freshGame(6) + local list = openBag(game) + for _ = 1, 6 do press(list, "down") end + press(list, "select") + eq(list.swapIndex, nil, "SELECT on the CANCEL row starts no swap") + press(list, "up") + press(list, "select") + eq(list.swapIndex, 6, "while SELECT on a real row still does") +end + +package.loaded["src.render.Font"] = nil +package.loaded["src.core.Sound"] = nil +package.loaded["src.render.TextBox"] = nil +package.loaded["src.ui.ListMenu"] = nil +package.loaded["src.ui.Theme"] = nil +package.loaded["src.ui.BagMenu"] = nil +require("src.ui.Screens").invalidate() + +T.finish() diff --git a/tests/engine/cart_launcher.lua b/tests/engine/cart_launcher.lua index 7eeda51d..40c0adad 100644 --- a/tests/engine/cart_launcher.lua +++ b/tests/engine/cart_launcher.lua @@ -96,6 +96,38 @@ eq(#imp:_ensureCarts("blue"), 1, "blue lists only its own cart") eq(imp:_ensureCarts("blue")[1].id, "johto_lite", "and that one is Johto Lite") eq(#imp:_ensureCarts("yellow"), 0, "a game with no carts lists none") +-- A .g1rcart dropped into the carts folder by hand, with no registry entry: +-- the launcher must adopt it, not read past it. _refreshCarts used to call +-- CartStore.index (registry only) and never saw one. +do + local stray = CartManifest.parse(cartTable({ + id = "dropped_in", title = "Dropped In", version = "2.0.0" })) + check(stray ~= nil, "the stray fixture parses") + local opts = SaveData.loadOptions() + opts[CartStore.OPTIONS_KEY] = nil + SaveData.saveOptions(opts) + love.filesystem.createDirectory(CartStore.DIR) + love.filesystem.write(CartStore.fileFor("dropped_in"), + CartManifest.encode(stray)) + + local fresh = freshLauncher() + local found + for _, row in ipairs(fresh:_ensureCarts("red")) do + if row.id == "dropped_in" then found = row end + end + check(found ~= nil, "the launcher lists a cart dropped into the folder") + if found then + eq(found.title, "Dropped In", "and reads its title from the file") + eq(found.base, "red", "and its base game") + end + eq(CartStore.index()[1] ~= nil, true, + "listing healed the registry so the cheap index sees it too") + + -- Put the fixture set back: later blocks assert on the picker's own layout, + -- and a third red cart changes its height. + CartStore.uninstall("dropped_in") +end + imp.tab = "red" imp.ready.red = true imp._cartPopup = "red" @@ -109,8 +141,9 @@ check(picker:find("Johto Lite", 1, true) == nil, check(picker:find("v1.2.0", 1, true) ~= nil, "a cart row carries its version") check(picker:find("sealed", 1, true) ~= nil, "a cart row carries its seal state") check(picker:find("open", 1, true) ~= nil, "including an open one") -check(picker:find("Get more carts", 1, true) ~= nil, - "the last row is the browse placeholder") +check(picker:find("Import a cart", 1, true) ~= nil + or picker:find("Get more carts", 1, true) ~= nil, + "the last row imports a cart, by picker or by folder") imp._cartPopup = nil local vanillaColors = drawColors(imp) @@ -305,14 +338,14 @@ local maker = freshLauncher() maker.tab = "red" maker.ready.red = true maker:_setModScope("red") -eq(maker:_cartCaptureCount("red"), 2, - "the control counts only the mods enabled for this game") +eq(maker:_cartCaptureCount("red"), 3, + "the control counts every mod the capture would pin, on or off") maker:_beginCartSave("red") check(maker._cartSave ~= nil, "Save as cart opens a form") -eq(maker._cartSave.count, 2, "the form reports the captured mod count") +eq(maker._cartSave.count, 3, "the form reports the captured mod count") eq(maker._cartSave.version, "red", "scoped to the game the panel is showing") -eq(#maker._cartSave.unresolved, 1, "capture reports one pin it could not resolve") +eq(#maker._cartSave.unresolved, 2, "capture reports the pins it could not resolve") eq(maker._cartSave.unresolved[1].id, "wide_gym", "naming the mod it belongs to") check(tostring(maker._cartSave.unresolved[1].reason):find("semantic", 1, true) ~= nil, "and why it could only be pinned locally") @@ -345,7 +378,13 @@ eq(made.base, "red", "based on the game the panel was showing") eq(made.version, "1.0.0", "at the default cart version") eq(made.seal, "sealed", "sealed by default") eq(made.shell, "#ff3c48", "wearing the base game's rail colour") -eq(#made.mods, 2, "pinning exactly the enabled mods") +eq(#made.mods, 3, "pinning every installed mod, on or off") +local madeOff +for _, entry in ipairs(made.mods) do + if entry.id == "off_mod" then madeOff = entry end +end +check(madeOff ~= nil, "including the one the player has switched off") +eq(madeOff.enabled, false, "which is pinned switched off rather than dropped") local listedNow = false for _, row in ipairs(maker:_ensureCarts("red")) do @@ -472,9 +511,513 @@ eq(SaveData.slotSealBroken("kanto_plus", freshSlot), false, "starts sealed again") eq(gapCart:cartPlan("red").refused, true, "so the cart refuses that one") +install({ id = "plus_cart", title = "Plus Cart", seal = "sealed+", + shell = "#445566" }) +LauncherMods.list = function() return FULL_MODS end +local plusCart = freshLauncher() +plusCart.tab = "red" +plusCart.ready.red = true +plusCart._cartPopup = "red" +local plusPicker = drawAndCapture(plusCart) +check(plusPicker:find("sealed+", 1, true) ~= nil, + "the picker spells a sealed+ cart's seal out") +plusCart._cartPopup = nil +plusCart:_selectCart("red", "plus_cart") +local plusPlan = plusCart:cartPlan("red") +eq(plusPlan.seal, "sealed+", "the plan carries the sealed+ seal") +eq(plusPlan.sealed, true, "sealed+ is a sealed cart") +eq(plusPlan.enforced, true, "and enforces its mod set") +local plusText = drawAndCapture(plusCart) +check(plusText:find("Sealed", 1, true) ~= nil, "the page says it is sealed") +check(plusText:find("switch any of them on or off", 1, true) ~= nil, + "and that the player may switch the mods it pins") +check(plusText:find("Break the seal", 1, true) ~= nil, + "while still offering the escape hatch") +eq(SaveData.slotSealBroken("plus_cart", + plusCart.activeSlot[plusCart:slotScope("red")] or "slot1"), false, + "and reading the page breaks nothing") + +-- ------- installing the mods a cart pins, instead of defeating its seal + +local ModUpdate = require("src.mods.ModUpdate") +local realFetchBegin, realFetchPump = + ModUpdate.beginFetchReleases, ModUpdate.pumpFetchReleases +local realDlBegin, realDlPump = + ModUpdate.beginDownloadZip, ModUpdate.pumpDownloadZip +local realInstallDownloaded = LauncherMods.installDownloadedZip + +love.data = love.data or {} +local savedData = { hash = love.data.hash, encode = love.data.encode } +-- The archive's digest IS its bytes here, so a fixture writes the hash it +-- wants the downloaded zip to have. +love.data.hash = function(_, data) return tostring(data) end +love.data.encode = function(_, _, digest) return tostring(digest) end + +local SHA_ONE = ("0123abcd"):rep(8) +local SHA_TWO = ("dead9876"):rep(8) + +local net = { versions = {}, bytes = {}, err = nil, repos = {} } +ModUpdate.beginFetchReleases = function(repo, modId) + net.repos[modId] = repo + return { modId = modId, repo = repo } +end +ModUpdate.pumpFetchReleases = function(h) + if net.err then return true, nil, net.err end + local out = {} + for _, v in ipairs(net.versions[h.modId] or {}) do + out[#out + 1] = { version = v, tag = "v" .. v, + zip = { url = h.modId .. "@" .. v, name = h.modId .. ".zip" } } + end + return true, out +end +ModUpdate.beginDownloadZip = function(url, destName) + love.filesystem.write(destName, net.bytes[url] or "not-the-pinned-archive") + return { path = destName } +end +ModUpdate.pumpDownloadZip = function(h) return true, h.path end + +local haveMods = {} +LauncherMods.installDownloadedZip = function(modId, localPath, version) + haveMods[modId] = version or "?" + pcall(love.filesystem.remove, localPath) + return true, haveMods[modId] +end +LauncherMods.list = function() + local rows = {} + for id, v in pairs(haveMods) do + rows[#rows + 1] = fakeRow({ id = id, name = id, version = v }) + end + return rows +end + +install({ id = "fill_cart", title = "Fill Cart", shell = "#227744", + mods = { + { id = "fill_one", source = "github", repo = "ren/fill-one", + version = "1.0.0", sha256 = SHA_ONE }, + { id = "fill_two", source = "github", repo = "ren/fill-two", + version = "2.1.0", sha256 = SHA_TWO }, + }, + load_order = { "fill_one", "fill_two" } }) +install({ id = "odd_cart", title = "Odd Cart", shell = "#772244", + mods = { + { id = "banana_mod", source = "gamebanana", mod = 4821, file = 99123, + md5 = ("ab"):rep(16) }, + { id = "here_mod", source = "local", version = "0.0.0" }, + }, + load_order = { "banana_mod", "here_mod" } }) + +local function fillPage(cartId) + local page = freshLauncher() + page.tab = "red" + page.ready.red = true + page:_selectCart("red", cartId) + return page +end + +local function runFill(page) + page:pressInstallCartMods("red") + local guard = 0 + while page._cartFill and guard < 500 do + page:_pumpModInstall() + page:_pumpCartFill() + guard = guard + 1 + end + check(page._cartFill == nil, "the install run finishes") + return page.cartFillNotice or {} +end + +local function failureText(notice) + return table.concat(notice.failures or {}, " | ") +end + +net.versions.fill_one = { "1.0.0", "0.9.0" } +net.versions.fill_two = { "2.1.0" } +net.bytes["fill_one@1.0.0"] = SHA_ONE +net.bytes["fill_two@2.1.0"] = SHA_TWO + +-- A cart whose pins are all installed offers nothing to install. +haveMods = { fill_one = "1.0.0", fill_two = "2.1.0" } +local readyFill = fillPage("fill_cart") +eq(readyFill:cartPlan("red").refused, false, "every pin installed, so it plays") +eq(#readyFill:cartFillRows("red"), 0, "with nothing left to install") +local readyText = drawAndCapture(readyFill) +check(readyText:find("Install required mods", 1, true) == nil + and readyText:find("required mods", 1, true) == nil, + "so a ready cart never offers the install button") +check(readyText:find("Break the seal", 1, true) ~= nil, + "while the seal control is where it always was") + +-- Nothing installed: the button appears, named for the count, beside the seal. +haveMods = {} +local gapFill = fillPage("fill_cart") +eq(#gapFill:cartFillRows("red"), 2, "both uninstalled pins queue up") +local gapFillText = drawAndCapture(gapFill) +check(gapFillText:find("Install 2 required mods", 1, true) ~= nil, + "a refused cart offers to install what it pins, counted") +check(gapFillText:find("the way its author built it", 1, true) ~= nil, + "saying plainly that this is the way to play it as built") +check(gapFillText:find("Break the seal", 1, true) ~= nil, + "with breaking the seal still there as the fallback") + +-- A pin installed at the WRONG version is a gap too: the cart wants its own. +haveMods = { fill_one = "0.9.0", fill_two = "2.1.0" } +local skewFill = fillPage("fill_cart") +eq(skewFill:cartPlan("red").mismatched[1].id, "fill_one", + "a pin at another version is a mismatch") +eq(#skewFill:cartFillRows("red"), 1, "which queues for install like a gap") +eq(skewFill:cartFillRows("red")[1].version, "1.0.0", + "at the version the cart pins, not the one on disk") +local skewText = drawAndCapture(skewFill) +check(skewText:find("Install required mods", 1, true) ~= nil, + "a single gap drops the count from the label") + +-- THE HASH GATE. An archive that is not the one the cart recorded installs +-- nothing, however well the id and version line up. +haveMods = {} +net.bytes["fill_one@1.0.0"] = "some other build entirely" +local badHash = fillPage("fill_cart") +local badNotice = runFill(badHash) +eq(haveMods.fill_one, nil, "a hash mismatch installs nothing at all") +eq(badNotice.ok, false, "and the run reports as failed") +check(failureText(badNotice):find("fill_one", 1, true) ~= nil, + "naming the mod whose archive did not match") +check(failureText(badNotice):find("sha256", 1, true) ~= nil, + "and saying it was the hash: " .. failureText(badNotice)) +eq(badHash:cartPlan("red").refused, true, "so the cart still refuses") +net.bytes["fill_one@1.0.0"] = SHA_ONE + +-- Partial: one archive verifies, the other does not. +haveMods = {} +net.bytes["fill_two@2.1.0"] = "wrong bytes for the second mod" +local partial = fillPage("fill_cart") +local partialNotice = runFill(partial) +eq(haveMods.fill_one, "1.0.0", "the pin that verified is installed") +eq(haveMods.fill_two, nil, "the pin that did not is not") +eq(partialNotice.ok, false, "a partial run reads as a failure") +check(tostring(partialNotice.text):find("1 of 2", 1, true) ~= nil, + "counting what landed: " .. tostring(partialNotice.text)) +check(failureText(partialNotice):find("fill_two", 1, true) ~= nil, + "and naming only the mod that failed") +check(failureText(partialNotice):find("fill_one", 1, true) == nil, + "not the one that worked") +local partialText = drawAndCapture(partial) +check(partialText:find("fill_two", 1, true) ~= nil, + "the card reports the failure where the player pressed the button") +net.bytes["fill_two@2.1.0"] = SHA_TWO + +-- The whole run, verified, with the seal untouched at the end of it. +haveMods = {} +local good = fillPage("fill_cart") +local goodScope = good:slotScope("red") +good:_ensureSlots(goodScope) +local goodSlot = good.activeSlot[goodScope] +eq(good:cartPlan("red").refused, true, "the cart refuses before the run") +local goodNotice = runFill(good) +eq(goodNotice.ok, true, "the run reports success: " .. tostring(goodNotice.text)) +eq(haveMods.fill_one, "1.0.0", "the first pin lands at its pinned version") +eq(haveMods.fill_two, "2.1.0", "and the second at its own") +eq(net.repos.fill_one, "ren/fill-one", "each pin was looked up in its own repo") +eq(net.repos.fill_two, "ren/fill-two", "including the second") +eq(good:cartPlan("red").refused, false, + "and the cart is ready to play with no further presses") +eq(SaveData.slotSealBroken("fill_cart", goodSlot or "slot1"), false, + "installing never breaks the cart's seal") +eq(SaveData.isSealBroken(), false, "nor the session's") +local goodText = drawAndCapture(good) +check(goodText:find("Sealed", 1, true) ~= nil, + "the card flips to ready without another click") +check(goodText:find("required mods", 1, true) == nil, + "and stops offering the install button") + +-- No release carrying that version. +haveMods = {} +net.versions.fill_one = { "0.9.0" } +local noRel = fillPage("fill_cart") +local noRelNotice = runFill(noRel) +eq(haveMods.fill_one, nil, "a version the repo does not publish installs nothing") +check(failureText(noRelNotice):find("v1.0.0", 1, true) ~= nil, + "and the failure names the version it wanted: " .. failureText(noRelNotice)) +net.versions.fill_one = { "1.0.0", "0.9.0" } + +-- No .zip on the matching release. +haveMods = {} +local savedPump = ModUpdate.pumpFetchReleases +ModUpdate.pumpFetchReleases = function(h) + return true, { { version = "1.0.0", tag = "v1.0.0" }, + { version = "2.1.0", tag = "v2.1.0" } } +end +local noZip = fillPage("fill_cart") +local noZipNotice = runFill(noZip) +eq(haveMods.fill_one, nil, "a release with no archive installs nothing") +check(failureText(noZipNotice):find(".zip", 1, true) ~= nil, + "and says so: " .. failureText(noZipNotice)) +ModUpdate.pumpFetchReleases = savedPump + +-- Network down. +haveMods = {} +net.err = "no network transport on this platform" +local offline = fillPage("fill_cart") +local offlineNotice = runFill(offline) +eq(haveMods.fill_one, nil, "an offline run installs nothing") +eq(offlineNotice.ok, false, "and reports as failed") +check(failureText(offlineNotice):find("no network transport", 1, true) ~= nil, + "carrying the transport's own words: " .. failureText(offlineNotice)) +net.err = nil + +-- Sources the launcher cannot fetch are refused per mod, not as one silence. +haveMods = {} +local odd = fillPage("odd_cart") +eq(#odd:cartFillRows("red"), 2, "both odd pins are gaps") +local oddText = drawAndCapture(odd) +check(oddText:find("Install 2 required mods", 1, true) ~= nil, + "the button is offered even when a pin may not be fetchable") +local oddNotice = runFill(odd) +eq(oddNotice.ok, false, "neither can be installed") +eq(#(oddNotice.failures or {}), 2, "and each is reported on its own line") +check(failureText(oddNotice):find("banana_mod", 1, true) ~= nil + and failureText(oddNotice):find("GameBanana", 1, true) ~= nil, + "the gamebanana pin says what it is pinned to: " .. failureText(oddNotice)) +check(failureText(oddNotice):find("here_mod", 1, true) ~= nil + and failureText(oddNotice):find("nothing to download", 1, true) ~= nil, + "and the local pin says there is nothing to fetch") +eq(next(haveMods), nil, "with nothing installed either way") +local oddAfter = drawAndCapture(odd) +check(oddAfter:find("Break the seal", 1, true) ~= nil, + "and the seal is still the fallback it was") + +-- Breaking the seal still works exactly as it did, install button or not. +local sealStill = fillPage("odd_cart") +local oddScope = sealStill:slotScope("red") +sealStill:_newSlot(oddScope) +local oddSlot = sealStill.activeSlot[oddScope] +check(type(oddSlot) == "string", "the cart page has a loaded save slot") +eq(sealStill:pressBreakSeal("red"), false, "the first press still only arms") +eq(SaveData.slotSealBroken("odd_cart", oddSlot), false, "breaking nothing") +eq(sealStill:pressBreakSeal("red"), true, "and the second still breaks it") +eq(SaveData.slotSealBroken("odd_cart", oddSlot), true, "on that slot") +eq(sealStill:cartPlan("red").refused, false, "so the cart plays") + +love.data.hash, love.data.encode = savedData.hash, savedData.encode +ModUpdate.beginFetchReleases, ModUpdate.pumpFetchReleases = + realFetchBegin, realFetchPump +ModUpdate.beginDownloadZip, ModUpdate.pumpDownloadZip = + realDlBegin, realDlPump +LauncherMods.installDownloadedZip = realInstallDownloaded +-- Later blocks audit the picker's layout, which counts the carts on red. +CartStore.uninstall("fill_cart") +CartStore.uninstall("odd_cart") + LauncherMods.list = realModList window(1280, 720) +-- ------- the MODS tab under an active cart + +-- the rows the base game's own list would hand back, so the panel with no +-- cart active has something real to disagree with the carts about +local PIN_MODS = { + fakeRow({ id = "pin_on", name = "Pin On", version = "1.0.0" }), + fakeRow({ id = "pin_off", name = "Pin Off", version = "1.0.0", + enabled = false, + enabledByVersion = { red = false, blue = false, yellow = false, + gold = false, silver = false } }), +} + +local function localPin(id, off) + local entry = { id = id, source = "local", version = "1.0.0" } + if off then entry.enabled = false end + return entry +end + +local PIN_SET = { localPin("pin_on"), localPin("pin_off", true) } +local PIN_ORDER = { "pin_on", "pin_off" } + +install({ id = "plus_mods", title = "Plus Mods", seal = "sealed+", + shell = "#2b8a3e", mods = PIN_SET, load_order = PIN_ORDER }) +install({ id = "hard_mods", title = "Hard Mods", seal = "sealed", + shell = "#8a2b3e", mods = PIN_SET, load_order = PIN_ORDER }) +install({ id = "gap_mods", title = "Gap Mods", seal = "sealed+", + shell = "#3e2b8a", + mods = { localPin("pin_on"), localPin("ghost_mod") }, + load_order = { "pin_on", "ghost_mod" } }) + +local realSetEnabled, realSetAllEnabled = LauncherMods.setEnabled, LauncherMods.setAllEnabled +local perGameWrites = 0 +LauncherMods.setEnabled = function(...) + perGameWrites = perGameWrites + 1 + return realSetEnabled(...) +end +LauncherMods.setAllEnabled = function(...) + perGameWrites = perGameWrites + 1 + return realSetAllEnabled(...) +end +LauncherMods.list = function() return PIN_MODS end + +-- the base game's own answer for the same mod, which nothing below may move +local seeded = SaveData.loadOptions() +SaveData.setModEnabled(seeded, "pin_off", false, "red") +SaveData.setModEnabled(seeded, "pin_on", true, "red") +SaveData.saveOptions(seeded) + +local function rowsById(imp) + local byId = {} + for _, row in ipairs(imp.mods or {}) do byId[row.id] = row end + return byId +end + +local plain = freshLauncher() +plain.tab = "mods" +plain.ready.red = true +plain:_selectCart("red", nil) +plain:_setModScope("red") +eq(#plain.mods, 2, "with no cart the panel lists the installed mods") +eq(plain.mods[1].cartPin, nil, "which are not marked as any cart's") +check(type(plain.mods[1].enabledByVersion) == "table", + "and still carry their per-game answers") +perGameWrites = 0 +plain:_toggleMod("pin_on", nil, "red") +eq(perGameWrites, 1, "a toggle with no cart still writes the per-game flag") +eq(SaveData.modEnabled(SaveData.loadOptions(), "pin_on", "red"), false, + "which is what changed") +eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "pin_on"), nil, + "and no cart scope was touched") +SaveData.setModEnabled(seeded, "pin_on", true, "red") +SaveData.saveOptions(seeded) + +local pins = freshLauncher() +pins.tab = "mods" +pins.ready.red = true +pins:_selectCart("red", "plus_mods") +pins:_setModScope("red") +eq(#pins.mods, 2, "an active cart makes the panel list its pins") +local pinRows = rowsById(pins) +eq(pinRows.pin_on.cartPin, true, "each row is marked as the cart's") +eq(pinRows.pin_on.cartId, "plus_mods", "naming the cart it came from") +eq(pinRows.pin_on.enabled, true, "a pin shipped switched on shows on") +eq(pinRows.pin_off.enabled, false, "a pin shipped switched off shows off") +eq(pinRows.pin_off.cartTogglable, true, "sealed+ hands every pin's switch over") +eq(pinRows.pin_on.enabledByVersion, nil, + "and a cart row carries no per-game answer, because a cart is one game") + +local pinText = drawAndCapture(pins) +check(pinText:find("PINNED", 1, true) ~= nil, + "the list says on every row that these are the cart's mods") +check(pinText:find("Plus Mods", 1, true) ~= nil, "and names the cart above them") +check(pinText:find("In this cart:", 1, true) ~= nil, + "with a switch that answers the cart, not the game") + +perGameWrites = 0 +pins:_toggleMod("pin_off", nil, "red") +local afterOn = SaveData.loadOptions() +eq(SaveData.cartModEnabled(afterOn, "plus_mods", "pin_off"), true, + "a sealed+ toggle writes the player's answer into the cart's scope") +eq(perGameWrites, 0, "and never through the per-game path") +eq(SaveData.modEnabled(afterOn, "pin_off", "red"), false, + "so the base game's flag for that mod is untouched") +eq(SaveData.modEnabled(afterOn, "pin_on", "red"), true, "as is every other") +eq(rowsById(pins).pin_off.enabled, true, "the row follows the new answer") + +local pinScope = pins:slotScope("red") +pins:_ensureSlots(pinScope) +local pinSlot = pins.activeSlot[pinScope] +eq(SaveData.slotSealBroken("plus_mods", pinSlot or "slot1"), false, + "switching a sealed+ pin does not break the cart's seal") +eq(SaveData.isSealBroken(), false, "nor the session's") +eq(pins:cartPlan("red").broken, false, "and the plan still reads it as intact") + +pins:_toggleMod("pin_off", nil, "red") +eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "pin_off"), false, + "pressing again switches it back off, still in the cart's scope") + +perGameWrites = 0 +pins:_setAllMods(true) +eq(perGameWrites, 0, "Enable all cannot reach a cart's mod set") +eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "pin_off"), false, + "so no pin moved") +check(tostring(pins.modNotice.text):find("Plus Mods", 1, true) ~= nil, + "and the panel says which cart is deciding") +eq(pins.modNotice.ok, false, "as a refusal") +pins:_setAllMods(false) +eq(perGameWrites, 0, "Disable all cannot either") +eq(SaveData.modEnabled(SaveData.loadOptions(), "pin_on", "red"), true, + "and the base game's list is where it was") + +pins:_toggleMod("wide_gym", nil, "red") +check(tostring(pins.modNotice.text):find("cannot be added to", 1, true) ~= nil, + "a mod the cart does not pin cannot be switched on from here") +eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "wide_gym"), nil, + "and nothing is written for it") + +pins.safeMode = true +pins:_toggleMod("pin_off", nil, "red") +check(tostring(pins.modNotice.text):find("Safe mode", 1, true) ~= nil, + "safe mode still refuses a cart toggle first") +eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "pin_off"), false, + "and writes nothing") +pins.safeMode = false + +local sealedPins = freshLauncher() +sealedPins.tab = "mods" +sealedPins.ready.red = true +sealedPins:_selectCart("red", "hard_mods") +sealedPins:_setModScope("red") +local hardRows = rowsById(sealedPins) +eq(hardRows.pin_off.enabled, false, "a sealed cart's off pin shows off") +eq(hardRows.pin_off.cartTogglable, false, "and hands no switch over") +eq(hardRows.pin_on.cartTogglable, false, "nor does the pin it ships on") +perGameWrites = 0 +sealedPins:_toggleMod("pin_off", nil, "red") +eq(perGameWrites, 0, "a sealed pin never reaches the per-game path either") +eq(SaveData.cartModEnabled(SaveData.loadOptions(), "hard_mods", "pin_off"), nil, + "and a press under a sealed cart writes nothing") +eq(sealedPins.modNotice.ok, false, "the press is refused") +check(tostring(sealedPins.modNotice.text):find("sealed", 1, true) ~= nil, + "with the panel saying why rather than doing nothing") +check(tostring(sealedPins.modNotice.text):find("Break the seal", 1, true) ~= nil, + "and pointing at the one way to change it") +local hardScope = sealedPins:slotScope("red") +sealedPins:_ensureSlots(hardScope) +eq(SaveData.slotSealBroken("hard_mods", + sealedPins.activeSlot[hardScope] or "slot1"), false, + "a refused press breaks no seal of its own") +local hardText = drawAndCapture(sealedPins) +check(hardText:find("Pinned, sealed:", 1, true) ~= nil, + "and the row itself reads as locked") + +local gapPins = freshLauncher() +gapPins.tab = "mods" +gapPins.ready.red = true +gapPins:_selectCart("red", "gap_mods") +gapPins:_setModScope("red") +local gapRows = rowsById(gapPins) +eq(#gapPins.mods, 2, "a pin that is not installed is still listed") +eq(gapRows.ghost_mod.status, "missing", "as missing") +eq(gapRows.ghost_mod.enabled, false, "and switched off") +gapPins:_toggleMod("ghost_mod", nil, "red") +check(tostring(gapPins.modNotice.text):find("not installed", 1, true) ~= nil, + "switching it says so instead of writing an answer for a mod that is absent") +eq(SaveData.cartModEnabled(SaveData.loadOptions(), "gap_mods", "ghost_mod"), nil, + "and writes nothing") + +pins:_selectCart("red", nil) +pins:_setModScope("red") +local backRows = rowsById(pins) +eq(#pins.mods, 2, "choosing the base game lists the player's own mods again") +eq(backRows.pin_on.cartPin, nil, "with no cart marks left on them") +check(type(backRows.pin_on.enabledByVersion) == "table", + "and their per-game answers back") +eq(backRows.pin_off.enabled, false, + "reading exactly what the base game's flags say") +perGameWrites = 0 +pins:_toggleMod("pin_off", nil, "red") +eq(perGameWrites, 1, "and a toggle is a per-game write once more") +eq(SaveData.modEnabled(SaveData.loadOptions(), "pin_off", "red"), true, + "which lands in the game's own flags") +eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "pin_off"), false, + "leaving the cart's answer where the player left it") + +LauncherMods.setEnabled, LauncherMods.setAllEnabled = realSetEnabled, realSetAllEnabled +LauncherMods.list = realModList + local function clipped(r) local x1, y1, x2, y2 = r.x, r.y, r.x + r.w, r.y + r.h if r.clip then @@ -540,6 +1083,22 @@ for _, size in ipairs(SIZES) do if ok then auditFrame(("%dx%d %s"):format(W, H, cart and "cart" or "vanilla"), "Custom Carts") + -- The refused card carries two chips now, so both have to stay on it. + if cart then + local sawFill = false + for _, r in ipairs(Kit.audit or {}) do + local label = tostring(r.label) + if label == "Install required mods" or label == "Break the seal" then + sawFill = sawFill or label == "Install required mods" + check(r.x >= -0.5 and r.x + r.w <= W + 0.5 + and r.y >= -0.5 and r.y + r.h <= H + 0.5, + ("%dx%d cart card: %q stays inside the window") + :format(W, H, label)) + end + end + check(sawFill, + ("%dx%d cart card: drew Install required mods"):format(W, H)) + end end Kit.audit = nil @@ -595,6 +1154,303 @@ for _, size in ipairs(SIZES) do end Kit.audit = nil end +LauncherMods.list = function() return PIN_MODS end +for _, size in ipairs(SIZES) do + local W, H = size[1], size[2] + window(W, H) + for _, cart in ipairs({ "plus_mods", "hard_mods" }) do + local panel = freshLauncher() + panel.tab = "mods" + panel.ready.red = true + panel:_selectCart("red", cart) + panel:_setModScope("red") + LauncherView.draw(panel) + Kit.audit = {} + local ok, err = pcall(LauncherView.draw, panel) + Kit.audit = ok and Kit.audit or nil + check(ok, ("%dx%d %s mods draws: %s"):format(W, H, cart, tostring(err))) + if ok then auditFrame(("%dx%d %s mods"):format(W, H, cart)) end + Kit.audit = nil + end +end LauncherMods.list = realModList +-- ------- FIND tab: browsing the index's carts instead of its mods +-- +-- The feed carries carts beside mods at the same schema_version, so the panel +-- has a Mods / Carts switch. Installing a cart from a listing goes through +-- CartStore.install with the downloaded bytes, never the mod installer. + +local ModIndex = require("src.mods.ModIndex") +local ModUpdate = require("src.mods.ModUpdate") +local Json = require("src.link.Json") + +local INDEX_CART = { + id = "indexed_cart", title = "Indexed Cart", author = "Ren", + version = "1.4.0", base = "silver", seal = "sealed", + repo = "https://github.com/ren/indexed-cart", + github = "ren/indexed-cart", + mods = { { id = "rare_soda", source = "github", repo = "ren/rare-soda", + version = "0.4.1", sha256 = SHA } }, + update_check = "ok", + latest = { version = "1.4.0", tag = "v1.4.0", + zip = { name = "indexed_cart-1.4.0.zip", + url = "https://example.test/indexed_cart-1.4.0.zip" } }, +} + +local INDEX_FEED = ModIndex.parse(Json.encode({ + schema_version = 1, + categories = { "GAMEPLAY" }, + 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" }, + { id = "true_colour", title = "True Colour", author = "Sam", + version = "1.0.0", categories = { "ART" }, update_check = "off" }, + }, + carts = { + INDEX_CART, + { id = "gold_rush", title = "Gold Rush", author = "Sam", version = "3.0.0", + base = "gold", seal = "open", repo = "https://github.com/sam/gold-rush", + mods = { { id = "steps", source = "github", repo = "sam/steps", + version = "1.0.0", sha256 = SHA } }, + update_check = "off" }, + }, +})) +check(INDEX_FEED ~= nil, "the find-tab fixture feed parses") + +window(1280, 720) + +local find = freshLauncher() +eq(find.findKind, "mods", "the Find tab browses mods by default") +check(find.findBase == nil, "with no base-game filter armed") +find.tab = "find" +find.modScope = nil +find.findLoaded = true +find.findSources = { { feed = "https://example.test/data/index.json", + base = "https://example.test/", + label = "example/index" } } +find.findIndex = { mods = INDEX_FEED.mods, carts = INDEX_FEED.carts, + categories = { "GAMEPLAY", "ART" }, + baseGames = ModIndex.baseGamesIn(INDEX_FEED) } + +eq(#find:_findRows(), 2, "the default rows are the feed's mods") +eq(find:_findRows()[1].id, "rare_soda", "and they are the mod entries") + +local modsFind = drawAndCapture(find) +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") + +find:_setFindKind("carts") +eq(find.findKind, "carts", "the switch flips to carts") +eq(#find:_findRows(), 2, "and the rows become the feed's carts") +check(ModIndex.isCart(find:_findRows()[1]), "which are cart entries") + +local cartsFind = drawAndCapture(find) +check(cartsFind:find("Indexed Cart", 1, true) ~= nil, + "a cart listing is drawn on the Carts half") +check(cartsFind:find("Rare Soda", 1, true) == nil, + "and the mod listings are gone") +check(cartsFind:find("Search carts", 1, true) ~= nil, + "the search field asks for carts") + +-- search spans the same fields it does for a mod +find.findQuery = "gold" +eq(#find:_findRows(), 1, "searching a cart title narrows the list") +eq(find:_findRows()[1].id, "gold_rush", "to that cart") +find.findQuery = "Ren" +eq(find:_findRows()[1].id, "indexed_cart", "searching by author works too") +find.findQuery = "" + +-- the cart-side filter is by base game, and the popup offers only the base +-- games the feed's carts actually play as +find.findBase = "gold" +eq(#find:_findRows(), 1, "filtering by base game keeps that game's carts") +eq(find:_findRows()[1].id, "gold_rush", "and only those") +local filterText +do + find._filterPopup = true + filterText = drawAndCapture(find) + find._filterPopup = nil +end +check(filterText:find("Filter by base game", 1, true) ~= nil, + "the filter popup filters carts by base game") +check(filterText:find("Filter by category", 1, true) == nil, + "not by a category no cart has") +find.findBase = nil + +-- MODS-tab scope still applies: a cart plays as exactly one game +find.modScope = "silver" +eq(#find:_findRows(), 1, "a scoped launcher lists only that game's carts") +eq(find:_findRows()[1].id, "indexed_cart", "the one based on silver") +find.modScope = nil + +find:_setFindKind("mods") +eq(#find:_findRows(), 2, "switching back restores the mod rows") +find:_setFindKind("carts") + +-- ------- installing a cart from a listing + +local INDEX_CART_BYTES = CartManifest.encode(CartManifest.parse(cartTable({ + id = "indexed_cart", title = "Indexed Cart", version = "1.4.0", + base = "silver", shell = "#d8c24a" }))) + +eq(#find:_ensureCarts("silver"), 0, "silver has no carts before the install") + +local entry +for _, row in ipairs(find:_findRows()) do + if row.id == "indexed_cart" then entry = row end +end +check(entry ~= nil, "the cart listing is on screen to install") + +local realBegin, realPump = ModUpdate.beginDownloadZip, ModUpdate.pumpDownloadZip +local asked = {} +ModUpdate.beginDownloadZip = function(url, name) + asked[#asked + 1] = { url = url, name = name } + return { name = name } +end +ModUpdate.pumpDownloadZip = function(h) + love.filesystem.write(h.name, INDEX_CART_BYTES) + return true, h.name +end + +find:_findConfirmInstall(entry) +check(find._modConfirm ~= nil, "installing a cart arms a confirm first") +eq(find._modConfirm.title, "Install cart", "and it says it is a cart") +eq(find._modConfirm.indexEntry, entry, "carrying the listing it will install") +do + local lines = table.concat(find._modConfirm.lines or {}, "\n") + check(lines:find("Pins 1 mod", 1, true) ~= nil, + "the confirm says how many mods the cart pins") + check(lines:find("installed separately", 1, true) ~= nil, + "and that installing the cart does not install them") +end +find._modConfirm = nil + +find:_findInstall(entry) +check(find._cartInstall ~= nil, "the cart download is in flight") +check(find._modInstall == nil, "and it is not a mod install") +eq(asked[1].url, "https://example.test/indexed_cart-1.4.0.zip", + "the release the listing resolves is what gets downloaded") +check(asked[1].name:find(".g1rcart", 1, true) ~= nil, + "into a cart file, not a mod zip") + +-- single in flight: a mod install cannot start on top of a cart download +find:_beginModInstall({ modId = "rare_soda", name = "Rare Soda", notice = "find", + release = { version = "0.4.1", + zip = { url = "https://example.test/rare_soda.zip" } } }) +check(find._modInstall == nil, "a mod install cannot race a cart install") +eq(#asked, 1, "and nothing else was downloaded") + +find:_pumpCartInstall() +check(find._cartInstall == nil, "the cart install completes") +check(find.findNotice ~= nil and find.findNotice.ok, + "and reports success on the Find tab") + +local installedCart = CartStore.get("indexed_cart") +check(installedCart ~= nil, "the cart is installed through CartStore") +if installedCart then + eq(installedCart.title, "Indexed Cart", "with its own title") + eq(installedCart.base, "silver", "and the game it plays as") +end + +-- the per-version cache is the whole point: without invalidating it the new +-- cart would not show in Custom Carts until a relaunch +eq(#find.carts["silver"], 1, + "the cached cart list for that game is refreshed in place") +eq(find.carts["silver"][1].id, "indexed_cart", "with the new cart in it") +eq(find:_findInstalledCarts()["indexed_cart"], "1.4.0", + "and the Find rows now read it as installed") + +-- the Custom Carts picker draws the new cart in the same session, with no +-- refresh call from here and no relaunch +do + local picker = freshLauncher() + picker.tab = "silver" + picker.ready.silver = true + picker._cartPopup = "silver" + local pickerText = drawAndCapture(picker) + check(pickerText:find("Indexed Cart", 1, true) ~= nil, + "the Custom Carts picker lists the freshly installed cart") +end +do + local same = drawAndCapture(find) + check(same ~= nil, "the Find tab still draws after an install") +end + +-- ------- the pins prompt +-- +-- Installing a cart never installs its mods behind the player's back, but a +-- cart whose pins are missing will not start, so it asks once and routes a +-- yes at the existing hash-verified fill queue. + +check(find._modConfirm ~= nil, "a cart with missing pins prompts after install") +eq(find._modConfirm.kind, "cartPins", "through the launcher's confirm modal") +eq(find._modConfirm.version, "silver", "for the game the cart plays as") +eq(find._modConfirm.id, "indexed_cart", "naming the cart just installed") +do + local lines = table.concat(find._modConfirm.lines or {}, "\n") + check(lines:find("Indexed Cart pins 1 mod", 1, true) ~= nil, + "the prompt names the cart and how many pins are missing") +end +check(find.activeCart["silver"] == nil, + "and asking does not select the cart on its own") + +-- yes routes at pressInstallCartMods, which owns the hash check +do + local realFetchRel = ModUpdate.beginFetchReleases + local realPumpRel = ModUpdate.pumpFetchReleases + local asked_repos = {} + ModUpdate.beginFetchReleases = function(repo) + asked_repos[#asked_repos + 1] = repo + return { repo = repo } + end + ModUpdate.pumpFetchReleases = function() return true, nil, "no releases" end + find._modConfirm = nil + find:_installCartPins("silver", "indexed_cart") + eq(find.activeCart["silver"], "indexed_cart", + "saying yes selects the cart the pins belong to") + for _ = 1, 8 do find:_pumpCartFill() end + eq(asked_repos[1], "ren/rare-soda", + "and the fill queue resolves the cart's own pin") + check(find.cartFillNotice ~= nil and not find.cartFillNotice.ok, + "reporting per-mod failures through the existing notice") + find:_selectCart("silver", nil) + find.cartFillNotice = nil + ModUpdate.beginFetchReleases = realFetchRel + ModUpdate.pumpFetchReleases = realPumpRel +end + +-- a cart whose pins are all installed must not prompt at all +do + local realList = LauncherMods.list + LauncherMods.list = function() + return { { id = "rare_soda", version = "0.4.1", + manifest = { id = "rare_soda", version = "0.4.1" } } } + end + local satisfied = freshLauncher() + eq(#satisfied:_cartPinsMissing("silver", "indexed_cart"), 0, + "a cart with every pin installed is missing none") + satisfied:_offerCartPins({ id = "indexed_cart", title = "Indexed Cart", + base = "silver" }) + check(satisfied._modConfirm == nil, "so it does not prompt") + LauncherMods.list = realList +end + +-- a download that is not a cart at all fails loudly instead of installing +ModUpdate.pumpDownloadZip = function(h) + love.filesystem.write(h.name, "not a cart at all") + return true, h.name +end +find.findNotice = nil +find:_findInstall(entry) +find:_pumpCartInstall() +check(find._cartInstall == nil, "a junk download ends the job") +check(find.findNotice ~= nil and not find.findNotice.ok, + "and says so rather than installing anything") + +ModUpdate.beginDownloadZip, ModUpdate.pumpDownloadZip = realBegin, realPump + T.finish("cart launcher") diff --git a/tests/engine/cart_manifest.lua b/tests/engine/cart_manifest.lua index 493fdc9a..177ff024 100644 --- a/tests/engine/cart_manifest.lua +++ b/tests/engine/cart_manifest.lua @@ -198,8 +198,102 @@ rejects(function(c) c.load_order = { "rare_soda", "rare_soda" } end, "twice", rejects(function(c) c.load_order = { "rare_soda", "master_ball" } end, "does not pin", "a load_order naming an unpinned mod") +rejects(function(c) c.mods[1].enabled = "no" end, "enabled must be", + "a string enabled flag") +rejects(function(c) c.mods[1].enabled = 0 end, "enabled must be", + "a numeric enabled flag") + +rejects(function(c) c.options = "fast" end, "cart options must be a table", + "a non-table cart options block") +rejects(function(c) c.options = { [2] = 1 } end, "cart option keys", + "a numeric cart option key") +rejects(function(c) c.options = { textSpeed = { 1 } } end, + "must be a string, number or boolean", "a table cart option value") + T.eq(CartManifest.parse(nil), nil, "parse refuses a non-table") +-- ------- a pin the cart ships switched off + +T.eq(cart.mods[1].enabled, nil, "a pin that says nothing carries no enabled flag") +T.eq(CartManifest.modEnabled(cart.mods[1]), true, "and defaults to enabled") +T.eq(CartManifest.modEnabled(nil), false, "modEnabled refuses a non-entry") + +local offRaw = baseCart() +offRaw.mods[1].enabled = false +local offCart = CartManifest.parse(offRaw) +T.check(offCart ~= nil, "a pin switched off parses") +T.eq(offCart.mods[1].enabled, false, "and keeps the flag") +T.eq(CartManifest.modEnabled(offCart.mods[1]), false, "which reads back as off") +T.eq(offCart.mods[1].sha256, SHA, "a switched-off pin is pinned like any other") +T.eq(offCart.mods[1].options.flavour, "grape", "with its options captured") +T.same(CartManifest.decode(CartManifest.encode(offCart)), offCart, + "a switched-off pin survives the file round trip") +T.neq(CartManifest.hash(offCart), CartManifest.hash(cart), + "and is part of the cart hash") + +local onRaw = baseCart() +onRaw.mods[1].enabled = true +T.eq(CartManifest.canonical(CartManifest.parse(onRaw)), + CartManifest.canonical(cart), + "an explicit enabled = true is the same cart as saying nothing") + +-- ------- settings the cart ships + +local optRaw = baseCart() +optRaw.options = { textSpeed = 1, animations = false, ruleset = "gen1_faithful" } +local optCart, optErr = CartManifest.parse(optRaw) +T.check(optCart ~= nil, "a cart that ships settings parses: " .. tostring(optErr)) +T.eq(optCart.options.textSpeed, 1, "the shipped number survives") +T.eq(optCart.options.animations, false, "the shipped boolean survives") +T.eq(optCart.options.ruleset, "gen1_faithful", "the shipped string survives") +T.eq(cart.options, nil, "a cart that ships none carries no options table") +T.same(CartManifest.decode(CartManifest.encode(optCart)), optCart, + "shipped settings survive the file round trip") +T.neq(CartManifest.hash(optCart), CartManifest.hash(cart), + "and are part of the cart hash") +T.neq(optCart.options, optCart.mods[1].options, + "a cart's own settings are not a mod's") + +-- ------- the sealed+ seal + +local plusRaw = baseCart() +plusRaw.seal = "sealed+" +local plusCart, plusErr = CartManifest.parse(plusRaw) +T.check(plusCart ~= nil, "a sealed+ cart parses: " .. tostring(plusErr)) +T.eq(plusCart.seal, "sealed+", "the seal survives") +T.same(CartManifest.decode(CartManifest.encode(plusCart)), plusCart, + "a sealed+ cart round trips through a file") +T.neq(CartManifest.hash(plusCart), CartManifest.hash(cart), + "and sealed+ is part of the cart hash") +T.eq(CartManifest.SEALS["sealed+"], true, "sealed+ is a known seal") + +-- ------- the launcher fields the file round trip used to drop + +local dressed = baseCart() +dressed.finish = "sparkle+holo" +dressed.speeds = { 1, 2 } +local dressedCart = CartManifest.parse(dressed) +local reread = CartManifest.decode(CartManifest.encode(dressedCart)) +T.eq(reread.finish, "sparkle+holo", "a cart's finish survives the file round trip") +T.same(reread.speeds, { 1, 2 }, "and so does its speed ladder") +T.eq(CartManifest.hash(reread), CartManifest.hash(dressedCart), + "so an installed copy hashes the same as the one that was written") + +-- ------- a cart that uses none of the above serializes exactly as it always did + +T.eq(CartManifest.canonical(cart), + "[cart].6:author$3:Ren.4:base$3:red.6:engine$7:>=1.4.0.2:id$10:kanto_plus" + .. ".5:label$13:art/label.png.4:repo$14:ren/kanto-plus.4:seal$6:sealed" + .. ".5:shell$7:#3fa9f5.7:summary$20:A sealed set of five.5:title$10:Kanto Plus" + .. ".7:version$5:1.2.0[mods]@9:rare_soda.2:id$9:rare_soda" + .. ".4:repo$13:ren/rare-soda.6:sha256$64:" .. SHA + .. ".6:source$6:github.7:version$5:0.4.1[options].5:fizzyT.7:flavour$5:grape" + .. ".9:sweetness#3@9:hard_mode.4:file#99123.2:id$9:hard_mode.3:md5$32:" .. MD5 + .. ".3:mod#4821.6:source$10:gamebanana[options][order]@9:rare_soda@9:hard_mode", + "the canonical string of a cart using no new field is byte for byte the old one") +T.eq(CartManifest.hash(cart), "2a8fbbacbd1df3f349b6a6ed387fa036", + "so its hash, and every save stamped with it, is unchanged") + local open = baseCart() open.seal = "open" open.repo = nil diff --git a/tests/engine/cart_saves.lua b/tests/engine/cart_saves.lua index 3b14225a..42b94e5b 100644 --- a/tests/engine/cart_saves.lua +++ b/tests/engine/cart_saves.lua @@ -264,6 +264,90 @@ do T.eq(again.meta.cartHash, nil, "with no cart stamp on it") end +-- ------- cart-scoped settings + +do + local files = fresh() + local base = SaveData.loadOptions() + base.textSpeed = 5 + base.musicVol = 2 + base.speedBattle = 1 + T.check(SaveData.saveOptions(base), "the base game writes its own settings") + local plain = files["options.lua"] + + SaveData.setCart("nuzlocke", "hash1") + local inCart = SaveData.loadOptions() + T.eq(inCart.textSpeed, 5, "a cart starts from the global value") + T.eq(inCart.musicVol, 2, "for every key, scoped or not") + inCart.textSpeed = 1 + inCart.musicVol = 7 + inCart.speedBattle = 4 + T.check(SaveData.saveOptions(inCart), "change some settings inside the cart") + + local stored = options(files) + T.eq(stored.textSpeed, 5, "the global text speed is untouched") + T.eq(stored.cartOptions.nuzlocke.textSpeed, 1, "the cart keeps its own") + T.eq(stored.cartOptions.nuzlocke.speedBattle, 4, "and its own battle speed") + T.eq(stored.musicVol, 7, "an unscoped setting is written globally") + T.eq(stored.cartOptions.nuzlocke.musicVol, nil, "and never lands in the cart") + + T.eq(SaveData.loadOptions().textSpeed, 1, "the cart reads its value back") + T.eq(SaveData.loadOptions().musicVol, 7, "and the machine's shared volume") + + SaveData.setCart("marathon", "hash2") + T.eq(SaveData.loadOptions().textSpeed, 5, + "another cart does not see the first cart's setting") + local other = SaveData.loadOptions() + other.textSpeed = 3 + T.check(SaveData.saveOptions(other), "the second cart sets its own") + T.eq(options(files).cartOptions.nuzlocke.textSpeed, 1, + "which leaves the first cart's alone") + + SaveData.setCart(nil) + T.eq(SaveData.loadOptions().textSpeed, 5, "and the base game keeps the global") + T.eq(SaveData.loadOptions().speedBattle, 1, "for every scoped key") + + T.check(plain:find("cartOptions", 1, true) ~= nil, + "the options file declares the overlay") + T.check(plain:find("nuzlocke", 1, true) == nil, + "but an install that never ran a cart names no cart in it") + SaveData.setCart(nil) + local vanilla = SaveData.loadOptions() + vanilla.textSpeed = 4 + T.check(SaveData.saveOptions(vanilla), "a base-game write after playing carts") + T.eq(options(files).cartOptions.nuzlocke.textSpeed, 1, + "leaves every cart's overlay intact") + T.eq(options(files).cartOptions.marathon.textSpeed, 3, "for every cart") +end + +do + local files = fresh() + SaveData.setCart("author_cart", "hash3") + T.eq(SaveData.seedCartOptions({ textSpeed = 1, animations = false, + musicVol = 0, nonesuch = 3 }), true, "a cart seeds its shipped settings") + local seeded = SaveData.loadOptions() + T.eq(seeded.textSpeed, 1, "the author's text speed applies") + T.eq(seeded.animations, false, "and the author's battle animation choice") + T.eq(seeded.musicVol, 7, "a machine setting is never seeded") + T.eq(options(files).cartOptions.author_cart.musicVol, nil, "not even into the cart") + T.eq(options(files).cartOptions.author_cart.nonesuch, nil, + "and a key outside the cart-scoped set is ignored") + T.eq(options(files).cartOptionsSeeded.author_cart, true, "the seed is recorded") + + local player = SaveData.loadOptions() + player.textSpeed = 5 + T.check(SaveData.saveOptions(player), "the player then picks their own speed") + T.eq(SaveData.seedCartOptions({ textSpeed = 1, animations = false }), false, + "a second boot re-seeds nothing") + T.eq(SaveData.loadOptions().textSpeed, 5, "so the player's change survives") + + SaveData.setCart(nil) + T.eq(SaveData.loadOptions().textSpeed, 3, + "and the base game still has the shipped default of its own") + T.eq(SaveData.seedCartOptions({ textSpeed = 1 }), false, + "seeding with no cart active does nothing") +end + love.filesystem = realFS T.finish("cart_saves") diff --git a/tests/engine/cart_seal.lua b/tests/engine/cart_seal.lua index d0edfa0e..35866c86 100644 --- a/tests/engine/cart_seal.lua +++ b/tests/engine/cart_seal.lua @@ -98,6 +98,12 @@ local function pin(id, version, options) options = options } end +local function offPin(id, version, options) + local p = pin(id, version, options) + p.enabled = false + return p +end + local function boot(files) local data = { pokemon = {} } local loader = Loader.new({ fs = memfs(files) }) @@ -175,6 +181,171 @@ do T.eq(loader:cartStatus().enforced, false, "an open cart enforces nothing") end +-- ------- a pin the cart ships switched off + +local function withFlags(files, flags) + local opts = SaveSerializer.decode(files["options.lua"]) or {} + opts.mods = opts.mods or {} + for id, on in pairs(flags) do opts.mods[id] = on end + files["options.lua"] = SaveSerializer.encode(opts) + return files +end + +local function withCartFlags(files, cartId, flags) + local opts = SaveSerializer.decode(files["options.lua"]) or {} + opts.cartMods = opts.cartMods or {} + opts.cartMods[cartId] = opts.cartMods[cartId] or {} + for id, on in pairs(flags) do opts.cartMods[cartId][id] = on end + files["options.lua"] = SaveSerializer.encode(opts) + return files +end + +do + local files = install() + writeCart(files, cartTable("shipped_off", "sealed", + { pin("alpha"), offPin("gamma", "1.0.0", { tint = "cart" }) }, + { "alpha", "gamma" })) + SaveData.setCart("shipped_off", "hashA") + + local loader, data, ok = boot(files) + T.check(ok, "a sealed cart that ships a mod switched off loads") + T.eq(names(loader.order), "alpha", "and runs only the pins it ships switched on") + T.eq(data.pokemon.GAMMA, nil, "the switched-off pin does not run") + T.eq(loader.mods.gamma.enabled, false, "and is reported as inactive") + T.eq(loader.mods.gamma.state, "disabled", "with the disabled row state") + T.check(loader.mods.gamma ~= nil, "while still being installed") + T.eq(loader:cartStatus().pins.gamma.options.tint, "cart", + "carrying the settings the cart shipped it with") +end + +do + local files = withCartFlags(install(), "welded", { gamma = true }) + writeCart(files, cartTable("welded", "sealed", + { pin("alpha"), offPin("gamma") }, { "alpha", "gamma" })) + SaveData.setCart("welded", "hashB") + + local loader, data, ok = boot(files) + T.check(ok, "a sealed cart loads with the player asking for the off pin") + T.eq(data.pokemon.GAMMA, nil, "but sealed refuses the toggle") + T.eq(loader.mods.gamma.enabled, false, "the pin stays exactly as the cart shipped it") +end + +-- ------- sealed+ : the same fixed set, switchable + +do + local files = withCartFlags(install(), "plus", { gamma = true }) + writeCart(files, cartTable("plus", "sealed+", + { pin("alpha"), offPin("gamma", "1.0.0", { tint = "cart" }) }, + { "alpha", "gamma" })) + SaveData.setCart("plus", "hashC") + + local loader, data, ok = boot(files) + T.check(ok, "a sealed+ cart loads") + local report = loader:cartStatus() + T.eq(report.seal, "sealed+", "the report carries the new seal") + T.eq(report.sealed, true, "which is a sealed cart") + T.eq(report.enforced, true, "and is enforced like one") + T.eq(names(loader.order), "alpha,gamma", + "the player switched the cart's off pin on and it runs") + T.eq(data.pokemon.GAMMA.name, "cart", + "still with the cart's frozen option value, not the player's") + T.eq(data.pokemon.BETA, nil, "a mod the cart does not pin cannot be added") + T.eq(loader.mods.beta.enabled, false, "and stays switched off") +end + +do + local files = withCartFlags(install(), "plus_off", { alpha = false }) + writeCart(files, cartTable("plus_off", "sealed+", + { pin("alpha"), pin("gamma") }, { "alpha", "gamma" })) + SaveData.setCart("plus_off", "hashD") + + local loader, data, ok = boot(files) + T.check(ok, "a sealed+ cart loads with a pin the player switched off") + T.eq(names(loader.order), "gamma", "which does not run") + T.eq(data.pokemon.ALPHA, nil, "so its content is absent") + T.eq(loader.mods.alpha.enabled, false, "and the row reads as off") +end + +do + local files = install() + writeCart(files, cartTable("plus_gap", "sealed+", + { pin("alpha"), pin("delta", "2.0.0") }, { "alpha", "delta" })) + SaveData.setCart("plus_gap", "hashE") + + local loader, _, ok = boot(files) + T.check(not ok, "sealed+ still refuses a pin that is not installed") + T.eq(#loader.order, 0, "and plays no subset of itself") + T.eq(loader:cartStatus().refused, true, "the report refuses it") +end + +do + local files = install() + love.filesystem = memfs(files) + SaveData.resetSlotState() + GameVersion.set("red") + writeCart(files, cartTable("plus_seal", "sealed+", + { pin("alpha"), offPin("gamma") }, { "alpha", "gamma" })) + SaveData.setCart("plus_seal", "hashF") + local slot = SaveData.createCartSlot("plus_seal") + SaveData.setActiveCartSlot("plus_seal", slot) + + local loader = boot(files) + T.check(loader:setEnabled("gamma", true), "switch a sealed+ pin on") + T.eq(SaveData.isSealBroken(), false, "which does not break the session seal") + T.eq(SaveData.slotSealBroken("plus_seal", slot), false, + "nor mark the save slot modified") + T.eq(SaveData.adoptCartSeal("plus_seal"), false, + "so the next boot still adopts an intact seal") + + local again, data, ok = boot(files) + T.check(ok, "and the cart loads again") + T.eq(names(again.order), "alpha,gamma", "with the mod the player switched on") + T.eq(data.pokemon.BETA, nil, "and still nothing the cart does not pin") +end + +do + local files = withFlags(install(), { gamma = false }) + writeCart(files, cartTable("plus_split", "sealed+", + { pin("alpha"), offPin("gamma") }, { "alpha", "gamma" })) + SaveData.setCart("plus_split", "hashI") + + local loader = boot(files) + T.eq(loader.mods.gamma.enabled, false, "a cart's off pin starts off") + T.check(loader:setEnabled("gamma", true), "and the player switches it on") + local opts = options(files) + T.eq(opts.cartMods.plus_split.gamma, true, + "the answer is stored under the cart that asked for it") + T.eq(SaveData.modEnabled(opts, "gamma", "red"), false, + "and the base game's own flag for that mod is untouched") +end + +-- ------- an open cart hands back only the pins it ships switched off + +do + local files = withCartFlags(install(), "open_off", { gamma = true }) + writeCart(files, cartTable("open_off", "open", + { pin("beta"), offPin("gamma") }, { "beta", "gamma" })) + SaveData.setCart("open_off", "hashG") + + local loader, data, ok = boot(files) + T.check(ok, "an open cart with a switched-off pin loads") + T.check(data.pokemon.GAMMA ~= nil, "the player switched it on, so it runs") + T.eq(loader.mods.beta.enabled, true, + "while a pin the cart says nothing about is still forced on") +end + +do + local files = install() + writeCart(files, cartTable("open_quiet", "open", + { pin("beta"), offPin("gamma") }, { "beta", "gamma" })) + SaveData.setCart("open_quiet", "hashH") + + local loader, data, ok = boot(files) + T.check(ok, "an open cart whose off pin the player never touched loads") + T.eq(data.pokemon.GAMMA, nil, "with that pin off by default") + T.eq(loader.mods.gamma.enabled, false, "and reported off") +end + -- ------- a missing pin: refusal when sealed, warning when open do diff --git a/tests/engine/cart_store.lua b/tests/engine/cart_store.lua index 6eea56b0..47713dd9 100644 --- a/tests/engine/cart_store.lua +++ b/tests/engine/cart_store.lua @@ -224,6 +224,33 @@ local empty = memfs() T.eq(#CartStore.list(empty), 0, "a fresh install lists no carts") T.eq(#CartStore.listFor("red", empty), 0, "listFor is empty on a fresh install") +-- ------- the fields the registry rows have to carry through the store + +local dressedFs = memfs() +local dressed = select(2, bytesOf({ id = "dressed", title = "Dressed", + finish = "holo", speeds = { 1, 2 }, seal = "sealed+", + options = { textSpeed = 1 } })) +local dressedCart, dressedHash = CartStore.install(CartManifest.encode(dressed), + dressedFs) +T.check(dressedCart ~= nil, "a cart with a finish, speeds and settings installs") +T.eq(dressedHash, CartManifest.hash(dressed), "hashing the same as it was written") +T.same(CartStore.get("dressed", dressedFs), dressed, + "and coming back off disk unchanged") + +local dressedReg = SaveData.loadOptions(dressedFs).carts.dressed +T.eq(dressedReg.finish, "holo", "the registry row carries the finish") +T.same(dressedReg.speeds, { 1, 2 }, "and the speed ladder") +T.eq(dressedReg.seal, "sealed+", "and the seal, spelled in full") + +local dressedRow = CartStore.list(dressedFs)[1] +T.eq(dressedRow.finish, "holo", "the list row carries the finish") +T.same(dressedRow.speeds, { 1, 2 }, "and the speed ladder") +T.eq(dressedRow.seal, "sealed+", "and the seal") +T.eq(dressedRow.cart.options.textSpeed, 1, + "and the settings the cart ships, on the parsed cart") +T.eq(SaveData.loadOptions(dressedFs).carts.dressed.hash, dressedHash, + "listing does not rewrite the registry it already agrees with") + local function rowSet() return { { id = "hard_mode", name = "Hard Mode", version = "2.0.0", enabled = true, @@ -258,26 +285,33 @@ T.eq(captured.title, "My Cart", "the captured cart keeps the title") T.eq(captured.shell, "#ff8800", "the captured shell normalises") T.eq(captured.seal, "open", "the captured seal is the author's choice") T.eq(captured.base, "red", "the captured base is the identity's") -T.eq(#captured.mods, 3, "only the enabled mods are pinned") +T.eq(#captured.mods, 4, "every installed mod is pinned, switched on or off") T.eq(captured.load_order[1], "hard_mode", "load order follows the row order") -T.eq(captured.load_order[2], "rare_soda", "load order follows the row order") -T.eq(captured.load_order[3], "sprite_pack", "load order follows the row order") -for _, entry in ipairs(captured.mods) do - T.neq(entry.id, "off_mode", "a disabled mod is never pinned") -end +T.eq(captured.load_order[2], "off_mode", "including the row that is switched off") +T.eq(captured.load_order[3], "rare_soda", "load order follows the row order") +T.eq(captured.load_order[4], "sprite_pack", "load order follows the row order") T.eq(captured.mods[1].source, "github", "a mod with repo, version and hash pins to github") T.eq(captured.mods[1].repo, "ren/hard-mode", "the github pin keeps the repo") T.eq(captured.mods[1].sha256, SHA, "the github pin keeps the recorded hash") -T.eq(captured.mods[2].source, "local", "a mod with no archive hash pins locally") -T.eq(captured.mods[2].version, "0.4.1", "the local pin keeps the installed version") -T.eq(captured.mods[2].repo, nil, "a local pin carries no repo") -T.eq(captured.mods[2].sha256, nil, "a local pin carries no hash") -T.eq(captured.mods[2].options.flavour, "grape", "the author's option values are frozen in") -T.eq(captured.mods[2].options.sweetness, 3, "every scalar option is frozen in") -T.eq(captured.mods[2].options.nested, nil, "a table option value is dropped") -T.eq(captured.mods[3].source, "local", "a mod with no repo pins locally") -T.eq(captured.mods[3].version, "0.0.0", "an unparsable version pins as 0.0.0") +T.eq(captured.mods[1].enabled, nil, "an enabled mod pins with no enabled field") +T.eq(CartManifest.modEnabled(captured.mods[1]), true, "and reads back as enabled") +T.eq(captured.mods[2].id, "off_mode", "the switched-off mod is pinned in place") +T.eq(captured.mods[2].enabled, false, "and is pinned switched off") +T.eq(CartManifest.modEnabled(captured.mods[2]), false, "which reads back as off") +T.eq(captured.mods[2].source, "github", "a disabled pin still resolves its source") +T.eq(captured.mods[2].sha256, SHA2, "and still carries its archive hash") +T.eq(captured.mods[2].options.unused, true, + "a disabled mod's options are captured like any other") +T.eq(captured.mods[3].source, "local", "a mod with no archive hash pins locally") +T.eq(captured.mods[3].version, "0.4.1", "the local pin keeps the installed version") +T.eq(captured.mods[3].repo, nil, "a local pin carries no repo") +T.eq(captured.mods[3].sha256, nil, "a local pin carries no hash") +T.eq(captured.mods[3].options.flavour, "grape", "the author's option values are frozen in") +T.eq(captured.mods[3].options.sweetness, 3, "every scalar option is frozen in") +T.eq(captured.mods[3].options.nested, nil, "a table option value is dropped") +T.eq(captured.mods[4].source, "local", "a mod with no repo pins locally") +T.eq(captured.mods[4].version, "0.0.0", "an unparsable version pins as 0.0.0") T.eq(captured.mods[1].options, nil, "a mod with no options freezes none") T.eq(#unresolved, 2, "capture reports every locally pinned mod") @@ -309,12 +343,18 @@ pinned[4] = nil local full, fullUnresolved = CartStore.capture(identity, pinned, modOptions) T.check(full ~= nil, "a fully pinned capture builds a cart") T.eq(#fullUnresolved, 0, "a fully pinned capture reports nothing unresolved") -T.eq(full.mods[2].source, "github", "a recorded hash promotes the pin to github") -T.eq(full.mods[2].sha256, SHA2, "the promoted pin uses the recorded hash") +T.eq(full.mods[3].source, "github", "a recorded hash promotes the pin to github") +T.eq(full.mods[3].sha256, SHA2, "the promoted pin uses the recorded hash") T.eq(CartManifest.publishable(full), true, "a fully pinned cart is publishable") -local noMods, noModsErr = CartStore.capture(identity, { rowSet()[2] }, modOptions) -T.eq(noMods, nil, "a capture with nothing enabled is refused") +local offOnly = CartStore.capture(identity, { rowSet()[2] }, modOptions) +T.check(offOnly ~= nil, "a capture of nothing but switched-off mods still builds") +T.eq(#offOnly.mods, 1, "pinning the one mod it was given") +T.eq(offOnly.mods[1].enabled, false, "switched off") +T.eq(offOnly.mods[1].options.unused, true, "with its settings kept") + +local noMods, noModsErr = CartStore.capture(identity, {}, modOptions) +T.eq(noMods, nil, "a capture with no mods at all is refused") T.check(type(noModsErr) == "string" and noModsErr:find("cart must pin", 1, true) ~= nil, "the empty capture says why (got " .. tostring(noModsErr) .. ")") T.eq(CartStore.capture({ id = "bad id" }, rowSet(), modOptions), nil, diff --git a/tests/engine/game_speed_categories_test.lua b/tests/engine/game_speed_categories_test.lua index 57bcd86f..6303fd42 100644 --- a/tests/engine/game_speed_categories_test.lua +++ b/tests/engine/game_speed_categories_test.lua @@ -193,4 +193,31 @@ do eq(g.save.options.speedMenu, 2, "cycling in a menu bumps speedMenu") end +-- A cart may narrow the ladder (CartManifest's `speeds`), and returning to +-- the launcher must put it back. +do + local GameSpeed = require("src.core.GameSpeed") + eq(#GameSpeed.allowed(), #GameSpeed.LEVELS, "no cart means the full ladder") + check(not GameSpeed.isLocked(), "and nothing is pinned") + + GameSpeed.setAllowed({ 1, 2 }) + eq(#GameSpeed.allowed(), 2, "a cart narrows the ladder") + eq(GameSpeed.cycle(1, 1), 2, "cycling stays inside the cart's levels") + eq(GameSpeed.cycle(2, 1), 1, "and wraps within them") + eq(GameSpeed.clamp(100), 2, "a value past the cart's top clamps into it") + check(not GameSpeed.isLocked(), "two levels is narrowed, not pinned") + + GameSpeed.setAllowed({ 1 }) + check(GameSpeed.isLocked(), "one level reads as pinned") + eq(GameSpeed.cycle(1, 1), 1, "and cycling cannot leave it") + + GameSpeed.setAllowed({ 1, 7 }) + eq(#GameSpeed.allowed(), 1, "a level that is not on the ladder is dropped") + + GameSpeed.setAllowed(nil) + eq(#GameSpeed.allowed(), #GameSpeed.LEVELS, + "leaving the cart restores the full ladder") + eq(GameSpeed.cycle(4, 1), 10, "and cycling reaches the levels again") +end + T.finish("game_speed_categories") diff --git a/tests/engine/gen2_stats_tiles_bug1558.lua b/tests/engine/gen2_stats_tiles_bug1558.lua index de6fe03c..79ff10da 100644 --- a/tests/engine/gen2_stats_tiles_bug1558.lua +++ b/tests/engine/gen2_stats_tiles_bug1558.lua @@ -21,6 +21,21 @@ for p = 1, 3 do end end +-- gfx/stats/stats.pal, the colour LoadStatsScreenPals drops into colour 0 of +-- BG palettes 0 and 2 (engine/gfx/color.asm:386-390) +local ROM_TINTS = { { 31, 19, 31 }, { 21, 31, 14 }, { 17, 31, 31 } } +for p = 1, 3 do + for ch = 1, 3 do + T.eq(SummaryMenu.PAGE_TINTS[p][ch], up(ROM_TINTS[p][ch]), + ("stats.pal tint %d channel %d"):format(p, ch)) + end + local page = setmetatable({ page = p }, { __index = SummaryMenu }) + local lower = page:lowerColors() + T.eq(lower[1][1], SummaryMenu.PAGE_TINTS[p][1], + ("lower half palette %d takes the page tint as colour 0"):format(p)) + T.eq(lower[4][1], 0, ("lower half palette %d keeps black ink"):format(p)) +end + -- the quads: 17 tiles from $31, one 8x8 cell each off the 136x8 sheet love.graphics = love.graphics or {} local realNewQuad = love.graphics.newQuad @@ -48,4 +63,4 @@ local old = setmetatable({ menuGfx = {} }, { __index = SummaryMenu }) T.check(old:statsTiles() == nil, "no menu_gfx.stats falls back, not crashes") love.graphics.newQuad = realNewQuad -T.finish("gen2 stats tiles and page palettes (#1558)") +T.finish("gen2 stats tiles and page palettes (#1558, #1693)") diff --git a/tests/engine/launcher_skins_ux.lua b/tests/engine/launcher_skins_ux.lua index 3ce36f51..9ee14f87 100644 --- a/tests/engine/launcher_skins_ux.lua +++ b/tests/engine/launcher_skins_ux.lua @@ -265,4 +265,32 @@ check(update and update:find("_pumpSkinFetch", 1, true) ~= nil, check(TouchSkin.ARCHIVE_EXTS.deltaskin == true, "and the installer accepts the extension") +-- Turning skins off must leave the pad on. `enabled` is the pad's own switch, +-- and the button that calls this only exists while a skin is active, which +-- already requires the pad to be enabled. +do + local SaveData = require("src.core.SaveData") + local TouchControls = require("src.core.TouchControls") + + local opts = SaveData.loadOptions() + opts.touchControls = { enabled = true, skin = "some_skin" } + SaveData.saveOptions(opts) + + local imp = RomImporter.new(function() end, { launcher = true }) + imp:_disableSkins() + + local after = SaveData.loadOptions().touchControls + eq(after.skin, nil, "turning skins off clears the selected skin") + check(after.enabled ~= false, "and leaves the touch pad enabled") + + local cfg = TouchControls.normalizeConfig(after) + check(cfg.enabled, "so the pad is on after normalizing") + eq(cfg.skin, nil, "with no skin behind it") + + check(imp._skinNotice ~= nil and imp._skinNotice.ok == true, + "and the notice reports success") + check(tostring(imp._skinNotice.text):find("built-in pad", 1, true) ~= nil, + "promising the built-in pad, which is now what happens") +end + T.finish("launcher_skins_ux") diff --git a/tests/engine/mod_index_tests.lua b/tests/engine/mod_index_tests.lua index 93e1a455..b05d040f 100644 --- a/tests/engine/mod_index_tests.lua +++ b/tests/engine/mod_index_tests.lua @@ -385,4 +385,152 @@ do eq(cats[1], "GAMEPLAY", "and they keep the feed's declared order") end +-- ------- carts: a second array on the same feed, at the same schema_version +-- +-- The published index added carts without bumping schema_version, so an older +-- build has to keep reading the feed and this one has to read both halves. +-- The fixture below is the shape carts/@/meta.json validates to. + +local OMEGA = { + folder = "bryanthaboi@omega_random_competition", + id = "omega_random_competition", + title = "OMEGA RANDOM COMPETITION", + author = "bryanthaboi", + summary = "Bois Club Randomizer as its own cartridge.", + version = "1.0.0", + base = "red", + seal = "sealed", + shell = "#7B1B22", + finish = "holo", + speeds = { 1, 2 }, + tags = { "randomizer", "competition" }, + repo = "https://github.com/bryanthaboi/omega-random-competition", + github = "bryanthaboi/omega-random-competition", + automatic_version_check = true, + mods = { { id = "bcr", source = "github", repo = "bryanthaboi/bcr", + version = "1.0.0", sha256 = ("83a111b4"):rep(8) } }, + load_order = { "bcr" }, + license = "MIT", + description_url = "data/carts/bryanthaboi@omega_random_competition/description.md", + update_check = "pending", +} + +local JOHTO_CART = { + id = "johto_run", title = "Johto Run", author = "Ren", version = "2.1.0", + base = "gold", seal = "open", + repo = "https://github.com/ren/johto-run", + github = "ren/johto-run", + mods = { { id = "steps", source = "gamebanana", mod = 42, file = 99, + md5 = ("ab"):rep(16), enabled = false, + options = { pace = "fast" } } }, + update_check = "ok", + latest = { version = "2.1.0", tag = "v2.1.0", + zip = { name = "johto_run-2.1.0.zip", + url = "https://example.test/johto_run-2.1.0.zip" } }, +} + +local function cartFeed(carts, overrides) + local doc = { schema_version = 1, count = 1, cart_count = #carts, + categories = { "GAMEPLAY" }, + base_games = { "red", "blue", "yellow", "gold", "silver" }, + mods = { NUZLOCKE }, carts = carts } + for k, v in pairs(overrides or {}) do doc[k] = v end + return Json.encode(doc) +end + +do + local index, err = ModIndex.parse(cartFeed({ OMEGA, JOHTO_CART })) + check(index ~= nil, "a feed carrying carts parses: " .. tostring(err)) + eq(index.schemaVersion, 1, "carts arrive at schema_version 1, unbumped") + eq(#index.mods, 1, "the mods array still parses") + eq(#index.carts, 2, "and the carts array parses beside it") + local c = index.carts[1] + eq(c.id, "omega_random_competition", "cart id") + eq(c.title, "OMEGA RANDOM COMPETITION", "cart title") + eq(c.base, "red", "the game the cart plays as") + eq(c.seal, "sealed", "its seal") + eq(c.shell, "#7B1B22", "its shell colour") + eq(c.finish, "holo", "its finish") + eq(c.speeds[2], 2, "its speed ladder") + eq(c.tags[1], "randomizer", "its tags") + eq(c.license, "MIT", "its license") + eq(c.load_order[1], "bcr", "its load order") + eq(#c.mods, 1, "its pinned mod set") + eq(c.mods[1].source, "github", "a github pin keeps its source") + eq(c.mods[1].repo, "bryanthaboi/bcr", "with the repo it comes from") + eq(c.mods[1].version, "1.0.0", "the exact pinned version") + eq(#c.mods[1].sha256, 64, "and the digest that gates it") + check(ModIndex.isCart(c), "a parsed cart is marked as one") + check(not ModIndex.isCart(index.mods[1]), "a mod is not") + + local g = index.carts[2] + eq(g.mods[1].source, "gamebanana", "a gamebanana pin keeps its source") + eq(g.mods[1].mod, 42, "with its mod page id") + eq(g.mods[1].file, 99, "and its file id") + eq(#g.mods[1].md5, 32, "and the digest GameBanana reports") + eq(g.mods[1].enabled, false, "a pin shipped switched off stays off") + eq(g.mods[1].options.pace, "fast", "frozen options survive") + eq(ModIndex.installUrl(g), "https://example.test/johto_run-2.1.0.zip", + "a cart resolves its release asset the same way a mod does") +end + +-- the old-feed case: no carts key at all +do + local index, err = ModIndex.parse(feed({ NUZLOCKE })) + check(index ~= nil, "a feed with no carts key still parses: " .. tostring(err)) + eq(#index.mods, 1, "its mods are unaffected") + eq(type(index.carts), "table", "and carts is a list, never nil") + eq(#index.carts, 0, "an absent carts array is an empty one") +end + +-- a broken cart row costs itself, not the whole feed +do + local noBase = {} + for k, v in pairs(OMEGA) do noBase[k] = v end + noBase.base = nil + local noMods = {} + for k, v in pairs(JOHTO_CART) do noMods[k] = v end + noMods.id, noMods.mods = "empty_pins", {} + local index, err = ModIndex.parse(cartFeed({ + noBase, "not even an object", { id = "bare" }, noMods, OMEGA })) + check(index ~= nil, "a feed with malformed carts still parses: " .. tostring(err)) + eq(#index.carts, 1, "only the well-formed cart is listed") + eq(index.carts[1].id, "omega_random_competition", "and it is the intact one") + eq(#index.mods, 1, "the mods array is untouched by a bad cart") +end + +-- search and filter: matches() already spans title / author / summary / id, +-- so the cart half reuses it wholesale. The category filter does not apply +-- (a cart has none); base does. +do + local index = ModIndex.parse(cartFeed({ OMEGA, JOHTO_CART })) + local carts = index.carts + eq(#ModIndex.filter(carts, {}), 2, "no filter keeps every cart") + eq(ModIndex.filter(carts, { query = "omega" })[1].id, + "omega_random_competition", "search matches a cart by title") + eq(ModIndex.filter(carts, { query = "Ren" })[1].id, "johto_run", + "search matches a cart by author") + eq(ModIndex.filter(carts, { query = "randomizer" })[1].id, + "omega_random_competition", "and by summary") + eq(#ModIndex.filter(carts, { query = "omega johto" }), 0, + "cart search terms are ANDed too") + eq(ModIndex.filter(carts, { base = "gold" })[1].id, "johto_run", + "filtering by base game keeps the carts for that game") + eq(#ModIndex.filter(carts, { base = "RED" }), 1, + "and compares case-insensitively") + eq(#ModIndex.filter(carts, { base = "silver" }), 0, + "a base no cart plays as filters everything out") + eq(#ModIndex.filter(carts, { category = "GAMEPLAY" }), 0, + "a cart carries no categories, so a category filter never matches one") + eq(ModIndex.filter(carts, { tag = "competition" })[1].id, + "omega_random_competition", "cart tags filter") + + local bases = ModIndex.baseGamesIn(index) + eq(#bases, 2, "only base games a cart actually plays as are offered") + eq(bases[1], "red", "and they keep the feed's declared base_games order") + eq(bases[2], "gold", "in that order") + eq(#ModIndex.baseGamesIn(ModIndex.parse(feed({ NUZLOCKE }))), 0, + "a cartless feed offers no base games") +end + print("ok mod_index_tests") diff --git a/tests/engine/museum_clerk_back_entrance_bug1690.lua b/tests/engine/museum_clerk_back_entrance_bug1690.lua new file mode 100644 index 00000000..ebced384 --- /dev/null +++ b/tests/engine/museum_clerk_back_entrance_bug1690.lua @@ -0,0 +1,222 @@ +-- The Pewter museum ticket clerk dispatches on the player's coordinates +-- before it looks at the ticket flag (#1690). scripts/Museum1F.asm:45 +-- reads wYCoord/wXCoord first: (13,4) and (12,3) are behind the counter +-- (the AMBER question), Y==4 otherwise is the ticket path, and anything +-- else is the "go to the other side" brush-off. The port only ever had +-- the middle branch, so two thirds of the NPC never fired. +-- +-- tests/engine/museum_money_box_bug1335.lua and museum_1f_clerk_ +-- translation_test.lua both call the clerk with ow == nil, which stays on +-- the ticket path either way, so neither can see the coordinate branches. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +package.loaded["src.render.TextBox"] = { + new = function(_, text, onDone, opts) + return { text = text, onDone = onDone, opts = opts } + end, +} + +local M = assert(loadfile("data/scripts/story2.lua"))() +local clerk = M.MUSEUM_1F.talk.TEXT_MUSEUM1F_SCIENTIST1 + +-- data/maps/objects/Museum1F.asm: MUSEUM1F_SCIENTIST1 stands at (12,4) +-- with the counter column at x==11. (13,4) is the cell east of him and +-- (12,3) the cell north of him, both reachable only from the back door; +-- (10,4) talks to him across the counter from the public side. +local BEHIND = { { 13, 4 }, { 12, 3 } } +local FRONT = { 10, 4 } + +local pushed +local function mkGame(cash) + pushed = {} + return { + data = { text = {} }, + save = { money = cash, flags = {} }, + stack = { push = function(_, box) pushed[#pushed + 1] = box end }, + } +end + +local function mkOw(x, y) + return { player = { cellX = x, cellY = y } } +end + +-- nil-tolerant: a branch that pushes nothing at all is a failure to +-- report, not a crash that hides every check after it +local function has(box, needle) + return box ~= nil and tostring(box.text):find(needle, 1, true) ~= nil +end + +-- answering a box that never offered a choice is the failure mode itself, +-- so report it rather than dying on a nil index and hiding the rest +local function answer(box, yes) + if box and box.opts and box.opts.choice then box.opts.choice(yes) end +end + +-- ------------------------------------------------ behind the counter +for _, cell in ipairs(BEHIND) do + local where = ("(%d,%d)"):format(cell[1], cell[2]) + + local g = mkGame(3000) + clerk(g, mkOw(cell[1], cell[2]), nil, function() end) + local ask = pushed[1] + T.check(has(ask, "sneak"), + where .. " opens the can't-sneak-in-the-back-way box") + T.check(ask.opts ~= nil and ask.opts.choice ~= nil, + where .. " carries the AMBER yes/no choice") + T.check(ask.opts.money == nil, + where .. " raises no money window behind the AMBER question") + T.check(ask.onDone == nil, where .. " chains through the choice, not onDone") + + -- YES: .TheresALabSomewhereText + answer(ask, true) + T.check(has(pushed[2], "lab"), where .. " YES gives the resurrection lab line") + + -- NO: .AmberIsFossilizedTreeSapText + g = mkGame(3000) + clerk(g, mkOw(cell[1], cell[2]), nil, function() end) + answer(pushed[1], false) + T.check(has(pushed[2], "tree sap"), where .. " NO explains AMBER is tree sap") + + T.eq(g.save.money, 3000, where .. " never charges the player") + T.check(not g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET, + where .. " never hands out a ticket") +end + +-- the coordinate check runs BEFORE the ticket check, so a ticket holder +-- who wanders round the back still gets told off (asm:45 precedes asm:59) +for _, cell in ipairs(BEHIND) do + local where = ("(%d,%d)"):format(cell[1], cell[2]) + local g = mkGame(3000) + g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true + clerk(g, mkOw(cell[1], cell[2]), nil, function() end) + T.check(has(pushed[1], "sneak"), + where .. " with a ticket is still the back-way box, not take-your-time") + answer(pushed[1], false) + T.check(has(pushed[2], "tree sap"), + where .. " with a ticket still answers NO with the tree sap line") +end + +-- the done callback reaches the second box on both answers +local g = mkGame(3000) +local doneFired = false +clerk(g, mkOw(13, 4), nil, function() doneFired = true end) +answer(pushed[1], true) +T.check(pushed[2].onDone ~= nil, "the AMBER answer box carries the done callback") +pushed[2].onDone() +T.check(doneFired, "and it chains back out of the conversation") + +-- ------------------------------------------------ the brush-off +-- scripts/Museum1F.asm:58: no ticket and not on row 4 means "other side" +local OFF_ROW = { { 12, 5 }, { 13, 3 }, { 12, 2 }, { 11, 3 } } +for _, cell in ipairs(OFF_ROW) do + local where = ("(%d,%d)"):format(cell[1], cell[2]) + local gg = mkGame(3000) + clerk(gg, mkOw(cell[1], cell[2]), nil, function() end) + T.check(has(pushed[1], "other side"), where .. " gets the brush-off line") + T.check(pushed[1].opts == nil, where .. " raises no money window") + T.eq(gg.save.money, 3000, where .. " never charges the player") +end + +-- with a ticket, off-row talk falls through to take-your-time instead +-- (asm:59 CheckEvent gates the brush-off) +local g2 = mkGame(3000) +g2.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true +clerk(g2, mkOw(12, 5), nil, function() end) +T.check(has(pushed[1], "Take your time"), + "(12,5) with a ticket gets take-your-time, not the brush-off") + +-- ------------------------------------------------ the ticket path holds +-- across the counter from the public side is row 4: the money box +local g3 = mkGame(3000) +clerk(g3, mkOw(FRONT[1], FRONT[2]), nil, function() end) +T.check(has(pushed[1], "50"), "(10,4) still opens the child's ticket ask") +T.check(pushed[1].opts ~= nil and pushed[1].opts.money ~= nil, + "and the ask still raises the money window") +answer(pushed[1], true) +T.eq(g3.save.money, 2950, "buying from the front still costs 50") +T.check(g3.save.flags.EVENT_BOUGHT_MUSEUM_TICKET, "and still sets the ticket flag") + +local g4 = mkGame(3000) +g4.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true +clerk(g4, mkOw(FRONT[1], FRONT[2]), nil, function() end) +T.check(has(pushed[1], "Take your time"), + "(10,4) with a ticket still gets take-your-time") +T.check(pushed[1].opts == nil, "and no money window on that branch") + +-- a caller with no overworld (the two older suites, and any script that +-- talks to the clerk out of band) must keep the pre-#1690 ticket path +local g5 = mkGame(3000) +clerk(g5, nil, nil, function() end) +T.check(has(pushed[1], "50"), "ow == nil still opens the ticket ask") +T.check(pushed[1].opts ~= nil and pushed[1].opts.money ~= nil, + "ow == nil still raises the money window") + +-- ------------------------------------------------ the rope onStep path +-- Museum1FDefaultScript calls the clerk over when the player steps onto +-- (9,4) or (10,4); OverworldController passes the player's own cell, so +-- the brush-off must not swallow the ask there +for _, x in ipairs({ 9, 10 }) do + local where = ("(%d,4)"):format(x) + local g6 = mkGame(3000) + local moved = false + local ow = mkOw(x, 4) + ow.scriptMove = function() moved = true end + local handled = M.MUSEUM_1F.onStep(g6, ow, x, 4) + T.check(handled, where .. " on the rope is handled by onStep") + T.check(has(pushed[1], "50"), where .. " on the rope opens the ticket ask") + T.check(pushed[1].opts ~= nil and pushed[1].opts.money ~= nil, + where .. " on the rope keeps the money window") + answer(pushed[1], false) + T.check(has(pushed[2], "Come again"), where .. " declining says come again") + pushed[2].onDone() + T.check(moved, where .. " declining still shoves the player back south (#151)") +end + +-- a ticket holder walking the rope is not stopped at all +local g7 = mkGame(3000) +g7.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true +local ow7 = mkOw(9, 4) +ow7.scriptMove = function() end +T.check(not M.MUSEUM_1F.onStep(g7, ow7, 9, 4), + "a ticket holder crosses the rope without being stopped") +T.eq(#pushed, 0, "and no box is pushed") + +-- ------------------------------------------------ translation reaches it +-- the two new branches must read game.data.text, not bare literals +local TRANSLATED = { + _Museum1FScientist1DoYouKnowWhatAmberIsText = "Pas par derriere !\nL'AMBRE, tu connais ?", + _Museum1FScientist1TheresALabSomewhereText = "Un labo essaie de\nles ressusciter.", + _Museum1FScientist1AmberIsFossilizedTreeSapText = "C'est de la resine\nfossilisee.", + _Museum1FScientist1GoToOtherSideText = "Passe de l'autre\ncote !", +} +local function mkTranslated() + pushed = {} + return { + data = { text = TRANSLATED }, + save = { money = 3000, flags = {} }, + stack = { push = function(_, box) pushed[#pushed + 1] = box end }, + } +end + +local g8 = mkTranslated() +clerk(g8, mkOw(13, 4), nil, function() end) +T.eq(pushed[1].text, TRANSLATED._Museum1FScientist1DoYouKnowWhatAmberIsText, + "the back-way box uses the translated AMBER question") +answer(pushed[1], true) +T.eq(pushed[2].text, TRANSLATED._Museum1FScientist1TheresALabSomewhereText, + "YES uses the translated lab line") + +g8 = mkTranslated() +clerk(g8, mkOw(12, 3), nil, function() end) +answer(pushed[1], false) +T.eq(pushed[2].text, TRANSLATED._Museum1FScientist1AmberIsFossilizedTreeSapText, + "NO uses the translated tree sap line") + +g8 = mkTranslated() +clerk(g8, mkOw(12, 5), nil, function() end) +T.eq(pushed[1].text, TRANSLATED._Museum1FScientist1GoToOtherSideText, + "the brush-off uses the translated other-side line") + +T.finish("museum_clerk_back_entrance_bug1690") diff --git a/tests/engine/performance_tiers.lua b/tests/engine/performance_tiers.lua index 153d414b..1f494743 100644 --- a/tests/engine/performance_tiers.lua +++ b/tests/engine/performance_tiers.lua @@ -84,12 +84,14 @@ T.eq(Performance.label(nil), "AUTO", "label nil -> AUTO") -- -------------------------------------------------------------------- caps local hi, lo = Performance.CAPS.high, Performance.CAPS.low -T.check(hi.tilt and hi.gbcfx and hi.survey and not hi.fpsMax, "high caps: all on, no fps ceiling") -T.check((not lo.tilt) and (not lo.gbcfx) and (not lo.survey), "low caps: heavy extras off") +T.check(hi.tilt and hi.survey and hi.shaderfx and not hi.fpsMax, + "high caps: all on, no fps ceiling") +T.check((not lo.tilt) and (not lo.survey) and (not lo.shaderfx), + "low caps: heavy extras off") T.eq(lo.fpsMax, 60, "low caps: FPS ceiling of 60") local bal = Performance.CAPS.balanced -T.check((not bal.tilt) and (not bal.gbcfx) and bal.survey and not bal.fpsMax, - "balanced caps: no tilt/gbcfx, survey kept, no fps ceiling") +T.check((not bal.tilt) and bal.survey and (not bal.shaderfx) and not bal.fpsMax, + "balanced caps: no tilt, no shaderfx, survey kept, no fps ceiling") device("Linux", "arm64", 4) T.eq(Performance.caps("auto"), Performance.CAPS.low, "caps(auto) resolves through detect") diff --git a/tests/gen2_audio_test.lua b/tests/gen2_audio_test.lua index 9d0031fa..e9f50e88 100644 --- a/tests/gen2_audio_test.lua +++ b/tests/gen2_audio_test.lua @@ -74,6 +74,12 @@ if ok then peak = math.max(peak, math.abs(l), math.abs(r)) end check(peak > 0.01, "TitleScreen renders audible samples (peak=" .. peak .. ")") + -- audio/drumkits.asm kit 5: the NR42 envelope rings on past the noise_note + -- script, so a snare must outlast the one frame its script occupies. + local frame = ChipSynth.SAMPLE_RATE / 60 + local snare = engine:drumInstrumentGen2(5, 1) + check(snare[#snare].endSample > frame * 2, + "kit 5 snare rings past its script (" .. snare[#snare].endSample .. " samples)") end local bark = audio.songs.Music_NewBarkTown diff --git a/tests/gen2_battle_cursor_test.lua b/tests/gen2_battle_cursor_test.lua new file mode 100644 index 00000000..0b77c7df --- /dev/null +++ b/tests/gen2_battle_cursor_test.lua @@ -0,0 +1,200 @@ +-- The battle main menu's 2x2 cursor clamps at every edge instead of wrapping +-- to the opposite command (#1706). BattleMenuHeader's .MenuData flag byte is +-- `db STATICMENU_CURSOR | STATICMENU_DISABLE_B` (engine/battle/menu.asm:31-33) +-- with no STATICMENU_WRAP, so Init2DMenuCursorPosition leaves both wrap bits +-- clear (engine/menus/menu.asm:156-166) and _2DMenuInterpretJoypad's +-- .check_wrap_around_* arms answer `xor a / ret`: the cursor does not move. +-- ContestBattleMenuHeader (menu.asm:70-77) is the same grid, same flag byte. +-- +-- luajit tests/gen2_battle_cursor_test.lua +-- +-- ROM-free: the fixtures below are the extractor's shapes, and every press +-- goes through the real Input edge detector and the real screen. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 battle cursor") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Battle = require("src.battle.gen2.Battle") +local BattleState = require("src.ui.gen2.BattleState") +local Input = require("src.core.Input") +local Mon = require("src.battle.gen2.Mon") + +-- ---------------------------------------------------------------- fixtures + +local TYPES = { NORMAL = { id = "NORMAL", index = 0, category = "physical" } } + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" }, +} + +local GROWTH = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, +} + +local POKEMON = { + growthRates = GROWTH, + CYNDAQUIL = { + id = "CYNDAQUIL", index = 155, name = "CYNDAQUIL", + baseStats = { hp = 39, attack = 52, defense = 43, speed = 65, + specialAttack = 60, specialDefense = 50 }, + types = { "NORMAL", "NORMAL" }, catchRate = 45, baseExp = 65, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 31, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + PIDGEY = { + id = "PIDGEY", index = 16, name = "PIDGEY", + baseStats = { hp = 40, attack = 45, defense = 40, speed = 56, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 55, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, + items = {}, +} + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +-- The smallest roll that neither crits nor misses. +local function detRandom(n) + if (n or 1) <= 1 then return 0 end + return 1 +end + +local function newScreen(opts) + opts = opts or {} + Input:init() + local player = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local save = { party = { player }, inventory = {}, player = { name = "GOLD" } } + local pushed = {} + local game = { + data = DATA, save = save, input = Input, options = {}, + stack = { + push = function(_, screen) pushed[#pushed + 1] = screen end, + pop = function() table.remove(pushed) end, + top = function() return pushed[#pushed] end, + clear = function(self) while #pushed > 0 do self:pop() end end, + }, + } + local battle = Battle.new({ data = DATA, party = { player }, wild = wild, + save = save, random = detRandom }) + local screen = BattleState.new(game, { battle = battle, save = save, + contest = opts.contest }) + game.stack:push(screen) + return screen, pushed +end + +-- Battle lines end in `prompt`, and PromptButton waits on A or B with no frame +-- countdown (home/joypad.asm:383-412), so the drain presses like a player. +local function runToMenu(screen, cap) + for _ = 1, (cap or 3000) do + local waiting = (screen.messageTimer or 0) > 0 + if waiting then Input:overlayPressed("a") end + Input:step() + screen:update(1 / 60) + if waiting then Input:overlayReleased("a") end + if screen.phase == "menu" then return true end + end + return false +end + +local function press(screen, button) + Input:overlayPressed(button) + Input:step() + screen:update(1 / 60) + Input:overlayReleased(button) + Input:step() +end + +-- One press from a known cursor position, answering where it landed. +local function pressAt(screen, index, button) + screen.menuIndex = index + press(screen, button) + return screen.menuIndex +end + +-- ---- the whole 2x2 transition table --------------------------------------- +-- +-- Row-major, FIGHT / PkMn over PACK / RUN (BattleMenuHeader's menu_coords). +-- The eight edge rows are the bug; the eight interior rows are the guard +-- against a fix that clamps the raw index instead of the column and the row, +-- which would hold every edge and still break FIGHT-down and RUN-left. +local GRID = { + { 1, "left", 1 }, { 2, "left", 1 }, { 3, "left", 3 }, { 4, "left", 3 }, + { 1, "right", 2 }, { 2, "right", 2 }, { 3, "right", 4 }, { 4, "right", 4 }, + { 1, "up", 1 }, { 2, "up", 2 }, { 3, "up", 1 }, { 4, "up", 2 }, + { 1, "down", 3 }, { 2, "down", 4 }, { 3, "down", 3 }, { 4, "down", 4 }, +} + +local LABEL = { "FIGHT", "PkMn", "PACK", "RUN" } + +do + local screen = newScreen() + check(runToMenu(screen), "the intro drains to the battle menu") + eq(screen.menuIndex, 1, "which opens on FIGHT") + + for _, row in ipairs(GRID) do + local from, button, want = row[1], row[2], row[3] + local note = from == want + and ("%s holds against %s"):format(LABEL[from], button) + or ("%s goes %s to %s"):format(LABEL[from], button, LABEL[want]) + eq(pressAt(screen, from, button), want, note) + end + + -- Whatever the cursor lands on has to stay a command the menu can run; + -- an off-by-one clamp would index MENU past its end and choose nothing. + for _, row in ipairs(GRID) do + local landed = pressAt(screen, row[1], row[2]) + check(BattleState.MENU[landed] ~= nil, + ("%s %s leaves the cursor on a real command"):format( + LABEL[row[1]], row[2])) + end +end + +-- ---- the same grid inside the bug-catching contest ------------------------- +-- +-- ContestBattleMenuHeader only moves the box out to menu_coords 2, 12 +-- (engine/battle/menu.asm:70-77); flag byte and cells are BattleMenuHeader's. +do + local screen = newScreen({ contest = true }) + check(runToMenu(screen), "a contest battle drains to its menu") + eq(screen.contest, true, "with the contest menu's coordinates") + eq(pressAt(screen, 2, "right"), 2, "PkMn holds against right in the contest") + eq(pressAt(screen, 3, "down"), 3, "PARKBALL holds against down") + eq(pressAt(screen, 1, "down"), 3, "and FIGHT still drops onto it") +end + +-- ---- the D-pad is silent --------------------------------------------------- +-- +-- MenuClickSound / PlayClickSFX play SFX_READ_TEXT_2 for A and B only +-- (home/menu.asm:746-762), so a rebuilt branch must not click on a move. +do + local screen = newScreen() + check(runToMenu(screen), "reached the menu again") + local played = {} + screen.playSfx = function(_, name) played[#played + 1] = name end + for _, button in ipairs({ "left", "right", "up", "down" }) do + press(screen, button) + end + eq(#played, 0, "no click sound on any of the four directions") + screen.menuIndex = 1 + press(screen, "a") + eq(played[1], "Sfx_ReadText2", "A on FIGHT is what clicks") +end + +S.finish() diff --git a/tests/gen2_battle_options_test.lua b/tests/gen2_battle_options_test.lua new file mode 100644 index 00000000..51c59947 --- /dev/null +++ b/tests/gen2_battle_options_test.lua @@ -0,0 +1,262 @@ +-- 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. +-- +-- What colour those bands actually come out is not a claim this file makes; +-- tests/drivers/gold_battle_bg_bug1709_test.lua is where a human judges that. + +package.path = "./?.lua;" .. package.path + +love = love or {} +love.graphics = love.graphics or { + getColor = function() return 1, 1, 1, 1 end, + setColor = function() end, + rectangle = function() end, + print = function() end, + printf = function() end, + draw = function() end, + newQuad = function() return {} end, + newImage = function() return nil end, + getShader = function() return nil end, + setShader = function() end, + newShader = function() error("no shaders in this harness") end, + getDimensions = function() return 160, 144 end, + push = function() end, pop = function() end, + translate = function() end, scale = function() end, + circle = function() end, clear = function() end, +} +love.math = love.math or { random = function(a, b) return b and a or 0.5 end } +love.image = love.image or {} +love.timer = love.timer or { getTime = function() return 0 end } +love.filesystem = love.filesystem or { + load = function() return nil end, + getInfo = function() return nil end, + read = function() return nil end, + write = function() return true end, + remove = function() return true end, +} +require("src.core.Logger").warn = function() end + +local BattleState = require("src.ui.gen2.BattleState") +local Chrome = require("src.ui.gen2.Chrome") +local Game2 = require("src.core.Game2") +local Gen2Compat = require("src.mods.Gen2Compat") +local LauncherSettings = require("src.import.LauncherSettings") +local OptionsMenu = require("src.ui.gen2.OptionsMenu") +local Save = require("src.core.gen2.Save") +local ScreenPosition = require("src.core.ScreenPosition") + +local checks, failures = 0, 0 +local function check(label, got, want) + checks = checks + 1 + if got ~= want then + failures = failures + 1 + print(("FAIL %s: got %s want %s"):format(label, tostring(got), + tostring(want))) + end +end + +-- --------------------------------------------------------------- the key + +check("the default surround is the cart's paper white", + Save.DEFAULT_OPTIONS.battleBg, "white") +check("and a fresh options table carries it", + Save.defaultOptions().battleBg, "white") + +-- --------------------------------------------------------------- the row + +local function rowNamed(label) + for i, row in ipairs(OptionsMenu.ROWS) do + if row.label == label then return i, row end + end +end + +local bgIndex, bgRow = rowNamed("BATTLE BG") +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("stored lowercase, shown WHITE", bgRow.display.white, "WHITE") + check("and BLACK", bgRow.display.black, "BLACK") + -- Appended at the tail, so nothing the other suites index by position moves. + check("it follows MAX FPS", OptionsMenu.ROWS[bgIndex - 1].label, "MAX FPS") + check("and CANCEL still ends the list", + OptionsMenu.ROWS[bgIndex + 1].cancel, true) + check("CANCEL is last", bgIndex + 1, #OptionsMenu.ROWS) +end +check("the cart's rows are unmoved", OptionsMenu.ROWS[7].key, "frame") +check("the rebind screen is unmoved", OptionsMenu.ROWS[8].id, "controls") +check("the port's audio group is unmoved", OptionsMenu.ROWS[9].key, "musicVol") + +-- The screen is built from ROWS, so the row has to survive buildRows too. +local menu = OptionsMenu.new({ options = Save.defaultOptions() }, + { options = Save.defaultOptions() }) +local built +for _, row in ipairs(menu.rows) do + if row.key == "battleBg" then built = row end +end +check("buildRows keeps it", built ~= nil, true) +if built then + check("and gives it the mod-facing id battleBg", built.id, "battleBg") + check("the menu starts on WHITE", menu.options.battleBg, "white") + menu:cycle(built, 1) + 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") + menu:cycle(built, -1) + check("left walks the ladder the other way", menu.options.battleBg, "black") + menu:cycle(built, -1) + check("and back", menu.options.battleBg, "white") +end + +-- ------------------------------------------------------ the launcher gear + +local model = LauncherSettings.open(nil, "gold") +local gearRow +for _, section in ipairs(model.sections) do + for _, row in ipairs(section.rows) do + if row.label == "BATTLE BG" then gearRow = row end + end +end +check("the gold gear offers BATTLE BG", gearRow ~= nil, true) +if gearRow then + model.opts.gold.battleBg = nil + check("an options.lua with no battleBg reads WHITE", gearRow.value(), "WHITE") + gearRow.step(1) + check("stepping writes the gold block, not the flat Gen 1 one", + model.opts.gold.battleBg, "black") + check("and the row reads BLACK", gearRow.value(), "BLACK") + gearRow.step(1) + check("stepping again returns to white", model.opts.gold.battleBg, "white") +end + +-- ---------------------------------------------------------- the battle end + +local bgMode = BattleState.bgMode +check("the Gold battle screen answers bgMode", type(bgMode), "function") +if type(bgMode) == "function" then + check("black when the option says so", + bgMode({ game = { options = { battleBg = "black" } } }), "black") + check("white when it says white", + bgMode({ game = { options = { battleBg = "white" } } }), "white") + 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") +end + +check("and mods are told bgMode is backed on this side", + Gen2Compat.memberStatus("src.battle.BattleState", "bgMode"), "backed") + +-- ------------------------------------------------------------- the bands +-- +-- Game2 paints the surround, not the battle screen: the widescreen layer +-- resolves to the TOP state, so a party menu or text box opened over the +-- battle would otherwise repaint the void white under itself. + +local G = love.graphics +local realRect, realSetColor = G.rectangle, G.setColor +local painter = Game2.paintBattleSurround +check("Game2 owns the surround repaint", type(painter), "function") + +local function paint(states, w, h) + if type(painter) ~= "function" then return {} end + local rects, pen = {}, { 1, 1, 1, 1 } + G.setColor = function(r, g, b, a) + pen = { r or 0, g or 0, b or 0, a or 1 } + end + G.rectangle = function(_, x, y, rw, rh) + rects[#rects + 1] = { x = x, y = y, w = rw, h = rh, pen = pen } + end + local ok, err = pcall(painter, { stack = { states = states } }, w, h) + G.rectangle, G.setColor = realRect, realSetColor + if not ok then + check("paintBattleSurround ran: " .. tostring(err), false, true) + end + return rects +end + +local blackBattle = { bgMode = function() return "black" end } +local whiteBattle = { bgMode = function() return "white" end } +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("the overworld alone paints nothing", + #paint({ overworld, plainMenu }, 1024, 768), 0) + +-- A menu over the battle keeps the battle's void: the walk goes down the +-- 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) + +local function panelSafe(states, w, h, label) + local scale = Chrome.fitScale(w, h) + local ox, oy = Chrome.fitOrigin(w, h, scale) + local pw, ph = 160 * scale, 144 * scale + local rects = paint(states, w, h) + local covered, overlap, offColour = 0, 0, 0 + for _, r in ipairs(rects) do + covered = covered + r.w * r.h + if r.pen[1] ~= 0 or r.pen[2] ~= 0 or r.pen[3] ~= 0 then + offColour = offColour + 1 + end + local ix = math.max(0, math.min(r.x + r.w, ox + pw) - math.max(r.x, ox)) + local iy = math.max(0, math.min(r.y + r.h, oy + ph) - math.max(r.y, oy)) + overlap = overlap + ix * iy + end + -- The band maths has to tile the void exactly: any overlap with the panel is + -- a black frame eating the HUD or the message box, and any shortfall is a + -- strip of white left behind on one edge. + check(label .. ": no band touches the battle panel", overlap, 0) + check(label .. ": the void is covered edge to edge", covered, w * h - pw * ph) + check(label .. ": every band is black", offColour, 0) +end + +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])) +end + +-- SCREEN POS lifts the panel off centre, which is the other way the four +-- bands can stop agreeing with where the panel actually landed. +for _, mode in ipairs({ "center", "upper", "top" }) do + ScreenPosition.setMode(mode) + panelSafe({ blackBattle, plainMenu }, 1280, 840, "screen pos " .. mode) +end +ScreenPosition.setMode("center") + +-- A window smaller than the GB screen has no void to paint; the bands must +-- not wrap around to the far edge on the negative origin. +check("nothing to paint under 160x144", #paint({ blackBattle }, 100, 90), 0) + +-- ----------------------------------------------------- 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) +end + +print(("gen2 battle options: %d checks, %d failures"):format(checks, failures)) +if failures > 0 then + error(("%d assertion(s) failed"):format(failures), 0) +end diff --git a/tests/gen2_battle_test.lua b/tests/gen2_battle_test.lua index ff60c311..3fb4c794 100644 --- a/tests/gen2_battle_test.lua +++ b/tests/gen2_battle_test.lua @@ -689,15 +689,16 @@ statusBattle.player.moves[1].pp = 20 statusBattle:takeTurn({ kind = "move", move = "THUNDER_WAVE" }) check("a second status fails", statusBattle.enemy.status, "paralyze") --- Sleep lands with a turn counter, and canAct spends it. Asserted on the move --- rather than a whole turn: with this deterministic random the roll is the --- minimum 1 turn, and the slower foe's own turn later in the same round then --- wakes it -- which is what the cart does as well. +-- Sleep lands with a turn counter, and canAct spends it. The counter opens at +-- 2, so the target cannot wake in the round it was slept +-- (engine/battle/effect_commands.asm:3591-3598, #1707). local sleepBattle = newBattle() sleepBattle.player.moves = { { id = "SPORE", pp = 15, maxPp = 15 } } sleepBattle:useMove(sleepBattle.player, sleepBattle.enemy, "SPORE") check("spore slept the target", sleepBattle.enemy.status, "sleep") -check("sleep has turns", (sleepBattle.enemy.statusTurns or 0) >= 1, true) +check("sleep never opens shorter than two turns", + (sleepBattle.enemy.statusTurns or 0) >= 2, true) +check("the lowest roll is exactly two", sleepBattle.enemy.statusTurns, 2) -- A sleeping mon cannot act, and the counter runs down to a wake-up. sleepBattle.enemy.statusTurns = 2 check("asleep cannot act", sleepBattle:canAct(sleepBattle.enemy), false) @@ -1386,12 +1387,25 @@ check("Ground is immune", Effects.sandstormHits({ "NORMAL", "GROUND" }), false) check("Steel is immune", Effects.sandstormHits({ "STEEL" }), false) check("Flying is not", Effects.sandstormHits({ "NORMAL", "FLYING" }), true) --- BattleCommand_Heal's .Weather ladder: a half normally, two thirds in sun, --- a quarter in rain or sandstorm. -check("Morning Sun heals half in clear weather", - Effects.weatherHealFraction(nil), 1 / 2) -checkNear("...two thirds in sun", Effects.weatherHealFraction("sun"), 2 / 3, 0.001) -check("...a quarter in rain", Effects.weatherHealFraction("rain"), 1 / 4) +-- BattleCommand_TimeBasedHealContinue's .Multipliers ladder, walked by the +-- time of day and the weather (engine/battle/effect_commands.asm:6388-6454). +local DAY_F = Effects.SUN_HEAL.EFFECT_SYNTHESIS +check("Synthesis heals half in the day in clear weather", + Effects.timeBasedHealFraction(nil, DAY_F, 1), 1 / 2) +check("...the lot in sun", Effects.timeBasedHealFraction("sun", DAY_F, 1), 1) +check("...a quarter in rain", + Effects.timeBasedHealFraction("rain", DAY_F, 1), 1 / 4) +check("...a quarter at night in clear weather", + Effects.timeBasedHealFraction(nil, DAY_F, 2), 1 / 4) +check("...a half at night in sun", + Effects.timeBasedHealFraction("sun", DAY_F, 2), 1 / 2) +check("...an eighth at night in a sandstorm", + Effects.timeBasedHealFraction("sandstorm", DAY_F, 2), 1 / 8) +check("Moonlight wants NITE instead", + Effects.timeBasedHealFraction(nil, Effects.SUN_HEAL.EFFECT_MOONLIGHT, 2), + 1 / 2) +check("a battle with no clock still heals half", + Effects.timeBasedHealFraction(nil, DAY_F, nil), 1 / 2) -- ProtectChance halves for every consecutive use and gives up after eight. check("first Protect always works", Effects.protectChance(0), 0xff) diff --git a/tests/gen2_billspc_dpad_test.lua b/tests/gen2_billspc_dpad_test.lua new file mode 100644 index 00000000..29976170 --- /dev/null +++ b/tests/gen2_billspc_dpad_test.lua @@ -0,0 +1,171 @@ +-- Bill's PC d-pad: only MOVE W/O MAIL walks the boxes (#1710). +-- +-- luajit tests/gen2_billspc_dpad_test.lua +-- +-- ROM-free. Withdraw_UpDown is the entire joypad handler for the WITHDRAW +-- list and the DEPOSIT list (engine/pokemon/bills_pc.asm:806-820) and reads +-- PAD_UP and PAD_DOWN and nothing else. BillsPC_PressLeft / PressRight +-- (:909-931) are reachable only through MoveMonWithoutMail_DPad and +-- MoveMonWithoutMail_DPad_2 (:822-869), whose one caller is +-- _MovePKMNWithoutMail (:480) -- so left and right belong to the MOVE screen +-- alone, both while it is choosing a mon and while the insert cursor is up. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 bills pc dpad") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local BoxMenu = require("src.ui.gen2.BoxMenu") +local Boxes = require("src.core.gen2.Boxes") + +local function mon(species) + return { species = species, nickname = species, name = species, + level = 5, hp = 20, maxHp = 20 } +end + +local function newInput() + local input = { pressed = {} } + function input:press(...) + for _, button in ipairs({ ... }) do self.pressed[button] = true end + end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input +end + +local function newGame(save) + local input = newInput() + return { + input = input, + save = save, + data = { audio = {}, pokemon = {} }, + stack = { _items = {}, + push = function(self, s) self._items[#self._items + 1] = s end, + pop = function(self) return table.remove(self._items) end, + top = function(self) return self._items[#self._items] end, + }, + }, input +end + +-- Three party mons and two boxes with something in them, so every list under +-- test has rows and the last-healthy rule never gets in the way. +local function newSave() + local save = { party = { mon("CYNDAQUIL"), mon("TOTODILE"), mon("PIDGEY") }, + boxes = {}, boxNames = {}, currentBox = 3 } + Boxes.box(save, 3)[1] = mon("GEODUDE") + Boxes.box(save, 3)[2] = mon("ZUBAT") + Boxes.box(save, 4)[1] = mon("ONIX") + return save +end + +local function press(screen, input, ...) + for _, button in ipairs({ ... }) do + input:press(button) + screen:update(0) + end +end + +local function open(mode) + local save = newSave() + local game, input = newGame(save) + local menu = BoxMenu.new(game, { save = save, mode = mode, + onClose = function() end }) + return menu, input, save +end + +-- ---- the withdraw list ---------------------------------------------------- +do + local menu, input = open("withdraw") + eq(menu.boxIndex, 3, "the withdraw list opens on the current box") + eq(menu:title(), "BOX3", "and the header names it") + + press(menu, input, "left") + eq(menu.boxIndex, 3, "LEFT on the withdraw list does not change the box") + eq(menu:title(), "BOX3", "the header still reads BOX3") + eq(menu.index, 1, "and the cursor has not been reset by a box step") + + -- Two more LEFTs, so a screen that steps and then steps back cannot pass by + -- landing on 3 again. + press(menu, input, "left", "left") + eq(menu.boxIndex, 3, "and neither do two more") + eq(#menu:list(), 2, "the list is still BOX3's two mons") + + -- RIGHT from a fresh screen, for the same reason. + menu, input = open("withdraw") + press(menu, input, "right", "right") + eq(menu.boxIndex, 3, "RIGHT does not change the box either") + eq(menu:title(), "BOX3", "the header is unmoved") + + -- Withdraw_UpDown DOES read up and down, so the fix must not have taken the + -- whole d-pad away. + press(menu, input, "down") + eq(menu.index, 2, "DOWN still walks the list") + press(menu, input, "up") + eq(menu.index, 1, "and UP walks back") +end + +-- ---- the deposit list ----------------------------------------------------- +-- +-- _DepositPKMN's .HandleJoypad calls the same Withdraw_UpDown, and its list is +-- the party (wBillsPC_LoadedBox is zeroed at bills_pc.asm:17-18), so there is +-- no box for left and right to walk to in the first place. +do + local menu, input = open("deposit") + eq(menu:title(), "PARTY ", "the deposit list browses the party") + local before = menu.boxIndex + press(menu, input, "left", "right", "left") + eq(menu.boxIndex, before, "the deposit list ignores left and right") + eq(menu:title(), "PARTY ", "and stays on the party") + eq(#menu:list(), 3, "with all three party mons") + press(menu, input, "down") + eq(menu.index, 2, "up and down still work here too") +end + +-- ---- the move screen ------------------------------------------------------ +-- +-- The half that is supposed to have box stepping. Gating this on the wrong +-- mode string ("withdraw") would leave the reported bug in place and break +-- this instead, which is why it is asserted next to the other two. +do + local menu, input, save = open("move") + eq(menu.boxIndex, 3, "the move screen opens on the current box") + + press(menu, input, "right") + eq(menu.boxIndex, 4, "RIGHT walks to the next box") + eq(menu:title(), "BOX4", "and the header follows") + eq(#menu:list(), 1, "showing BOX4's one mon") + + press(menu, input, "left", "left", "left", "left") + eq(menu.boxIndex, 0, "four LEFTs from BOX4 reach the PARTY, box 0") + eq(menu:title(), "PARTY ", "BillsPC_BoxName's .party arm") + eq(#menu:list(), 3, "and the list is the party") + + press(menu, input, "left") + eq(menu.boxIndex, Boxes.NUM_BOXES, + "LEFT off box 0 wraps to the last box (BillsPC_PressLeft)") + press(menu, input, "right") + eq(menu.boxIndex, 0, "and RIGHT wraps back through it") + + -- The insert cursor is the separate .Joypad2 arm, and it walks boxes too: + -- choose the first party mon, MOVE, then drive the cursor to BOX1. + press(menu, input, "a", "a") + eq(menu.phase, "insert", "MOVE opens the insert cursor") + press(menu, input, "right") + eq(menu.boxIndex, 1, "RIGHT walks the insert cursor to BOX1") + eq(menu:title(), "BOX1", "and the header names the destination") + press(menu, input, "left") + eq(menu.boxIndex, 0, "LEFT walks it back to the party") + press(menu, input, "right", "a") + eq(#save.party, 2, "A there really does move the mon") + eq(Boxes.box(save, 1)[1].nickname, "CYNDAQUIL", "into the box it named") +end + +S.finish() diff --git a/tests/gen2_box_intake_test.lua b/tests/gen2_box_intake_test.lua new file mode 100644 index 00000000..37e9d5d9 --- /dev/null +++ b/tests/gen2_box_intake_test.lua @@ -0,0 +1,280 @@ +-- Everything that lands a mon in a box lands it healed (#1696). +-- +-- luajit tests/gen2_box_intake_test.lua +-- +-- ROM-free. box_struct ends at Level and has no Status, no HP and no MaxHP +-- at all -- those three belong to party_struct alone (macros/ram.asm:7-40) -- +-- so a boxed mon has nowhere to keep damage, and every screen that reads one +-- goes through CalcTempmonStats, whose .not_egg arm copies MON_MAXHP over +-- MON_HP and whose .zero_status arm blanks MON_STATUS before anything is +-- drawn (engine/pokemon/tempmon.asm:39-83). The four intakes are the catch +-- that overflows to the PC (`.SendToPC` / SendMonIntoBox, +-- engine/items/item_effects.asm:604, engine/pokemon/move_mon.asm:942-1065), +-- the PC's own deposit, MOVE W/O MAIL, and the bug contest's +-- full-party arm (engine/pokemon/caught_nickname.asm:72-90). +-- +-- The asymmetry at the bottom is real cart behaviour and is the near-miss to +-- watch: a catch that fits in the PARTY goes through TryAddMonToParty +-- (item_effects.asm:551-556) and KEEPS its damage, so a fix that heals every +-- capture is as wrong as one that heals none. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 box intake") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Battle = require("src.battle.gen2.Battle") +local BattleState = require("src.ui.gen2.BattleState") +local BoxMenu = require("src.ui.gen2.BoxMenu") +local Boxes = require("src.core.gen2.Boxes") +local BugContest = require("src.core.gen2.BugContest") +local Input = require("src.core.Input") +local Mon = require("src.battle.gen2.Mon") + +-- A mon hurt and statused exactly the way a ball lands on one. +local function hurt(species) + return { species = species, nickname = species, name = species, level = 5, + hp = 1, maxHp = 20, status = "sleep", statusTurns = 3, + moves = { { id = "TACKLE", pp = 2, maxPp = 35 } } } +end + +local function healthy(species) + return { species = species, nickname = species, name = species, level = 5, + hp = 20, maxHp = 20, + moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } } +end + +local function newSave(party) + return { party = party or {}, boxes = {}, boxNames = {}, currentBox = 1 } +end + +-- ---- Boxes.enterBox, the shared seam -------------------------------------- +do + local mon = hurt("PIDGEY") + Boxes.enterBox(mon) + eq(mon.hp, 20, "a mon entering a box is at full HP") + eq(mon.status, nil, "with no status byte to carry it") + eq(mon.statusTurns, nil, "and no sleep counter either") + eq(mon.moves[1].pp, 35, "RestorePPOfDepositedPokemon refilled its PP") + + -- .not_egg is skipped for an EGG: CalcTempmonStats writes 0 over MON_HP + -- instead, which is what keeps a stored egg from reading as hatchable. + local egg = hurt("TOGEPI") + egg.isEgg = true + Boxes.enterBox(egg) + eq(egg.hp, 0, "an EGG stays at 0 HP") + eq(egg.status, nil, "and still loses the status byte") + + -- A mon with no maxHp recorded must not come out with hp nil, which would + -- read as fainted everywhere downstream. + local odd = { species = "MISSINGNO", hp = 4 } + Boxes.enterBox(odd) + eq(odd.hp, 4, "a mon with no maxHp keeps the HP it had") + eq(Boxes.enterBox(nil), nil, "and nil is handed straight back") +end + +-- ---- the PC's own deposit ------------------------------------------------- +-- +-- Read while the mon is STILL IN THE BOX: this is what the PC's STATS screen +-- shows, and healing only on the way back out looks right in the party while +-- the storage screen still reports 1 HP and SLP. +do + local save = newSave({ healthy("CYNDAQUIL"), hurt("PIDGEY"), + healthy("GEODUDE") }) + local ok = Boxes.deposit(save, 2, 1) + check(ok, "the hurt mon deposits") + local boxed = Boxes.box(save, 1)[1] + eq(boxed.species, "PIDGEY", "and it is the one in the box") + eq(boxed.hp, 20, "sitting there at full HP") + eq(boxed.status, nil, "with the status gone") + eq(boxed.moves[1].pp, 35, "and its PP back") + + -- ...and the withdraw arm still hands back the same whole mon. + local took + ok, took = Boxes.withdraw(save, 1, 1) + check(ok, "withdrawing it works") + eq(took.hp, 20, "and it is still whole on the way out") +end + +-- ---- MOVE W/O MAIL ---------------------------------------------- +do + local function newInput() + local input = { pressed = {} } + function input:press(...) + for _, button in ipairs({ ... }) do self.pressed[button] = true end + end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input + end + + local function press(screen, input, ...) + for _, button in ipairs({ ... }) do + input:press(button) + screen:update(0) + end + end + + local function openMove(save) + local input = newInput() + local game = { + input = input, save = save, data = { audio = {}, pokemon = {} }, + stack = { _items = {}, + push = function(self, s) self._items[#self._items + 1] = s end, + pop = function(self) return table.remove(self._items) end, + top = function(self) return self._items[#self._items] end, + }, + } + return BoxMenu.new(game, { save = save, mode = "move", + onClose = function() end }), input + end + + -- party -> box: .CopyToBox writes a box_struct, so the damage cannot follow. + local save = newSave({ hurt("PIDGEY"), healthy("CYNDAQUIL"), + healthy("GEODUDE") }) + local menu, input = openMove(save) + press(menu, input, "left") + eq(menu.boxIndex, 0, "the move screen walks left to the PARTY") + press(menu, input, "a", "a") + eq(menu.phase, "insert", "MOVE opens the insert cursor") + press(menu, input, "right", "a") + eq(#save.party, 2, "the hurt mon left the party") + local boxed = Boxes.box(save, 1)[1] + eq(boxed and boxed.nickname, "PIDGEY", "and landed in BOX1") + eq(boxed and boxed.hp, 20, "at full HP") + eq(boxed and boxed.status, nil, "with no status") + eq(boxed and boxed.moves[1].pp, 35, "and full PP") + + -- party -> party is the same screen's reorder, and party_struct HAS the + -- three fields, so this arm must leave the damage exactly where it was. + save = newSave({ hurt("PIDGEY"), healthy("CYNDAQUIL"), healthy("GEODUDE") }) + menu, input = openMove(save) + press(menu, input, "left") + eq(menu.boxIndex, 0, "back on the party") + press(menu, input, "a", "a", "down", "down", "a") + eq(#save.party, 3, "the party still has three") + local moved + for _, mon in ipairs(save.party) do + if mon.nickname == "PIDGEY" then moved = mon end + end + eq(moved and moved.hp, 1, "a party-to-party move keeps the damage") + eq(moved and moved.status, "sleep", "and keeps the status") +end + +-- ---- the bug contest's full-party arm ------------------------------------- +do + local save = newSave({ healthy("A"), healthy("B"), healthy("C"), + healthy("D"), healthy("E"), healthy("F") }) + save.currentBox = 2 + save.playerName = "GOLD" + BugContest.start(save) + BugContest.switchCaught(save, hurt("SCYTHER")) + local result = BugContest.collectCaughtMon(save, 6) + eq(result, BugContest.BOXED_MON, "a full party sends the contest catch to a box") + local boxed = Boxes.box(save, 2)[1] + eq(boxed and boxed.nickname, "SCYTHER", "it is in the current box") + eq(boxed and boxed.hp, 20, "at full HP") + eq(boxed and boxed.status, nil, "with no status") + eq(boxed and boxed.moves[1].pp, 35, "and its PP restored") +end + +-- ---- the catch that overflows to the PC ----------------------------------- +-- +-- The whole chain, through the real BattleState: a ball that lands while the +-- party is full runs `.SendToPC`, and what the PC's STATS screen then reads +-- is the box_struct SendMonIntoBox wrote. +do + local TYPES = { NORMAL = { id = "NORMAL", index = 0, + category = "physical" } } + local MOVES = { TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, + type = "NORMAL", accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" } } + local POKEMON = { + growthRates = { GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, + squared = 0, linear = 0, constant = 0 } }, + CYNDAQUIL = { + id = "CYNDAQUIL", index = 155, name = "CYNDAQUIL", + baseStats = { hp = 39, attack = 52, defense = 43, speed = 65, + specialAttack = 60, specialDefense = 50 }, + types = { "NORMAL", "NORMAL" }, catchRate = 45, baseExp = 65, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 31, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + PIDGEY = { + id = "PIDGEY", index = 16, name = "PIDGEY", + baseStats = { hp = 40, attack = 45, defense = 40, speed = 56, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 55, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + } + local DATA = { + pokemon = POKEMON, moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, + items = { POKE_BALL = { id = "POKE_BALL", name = "POKe BALL", + pocket = "BALL" } }, + } + local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } + perfect.hp = Mon.hpDV(perfect) + + -- partySize 6 fills the party so the catch overflows; 5 leaves it room. + local function catch(partySize) + Input:init() + local party = {} + for i = 1, partySize do + local mon = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + mon.nickname = "MON" .. i + mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + party[i] = mon + end + local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 4, maxPp = 35 } } + -- one HP and asleep: what a ball usually lands on + wild.hp = 1 + wild.status = "sleep" + wild.statusTurns = 3 + local save = { party = party, inventory = { POKE_BALL = 1 }, + currentBox = 3, boxes = {}, boxNames = {} } + local game = { + data = DATA, save = save, input = Input, options = {}, + stack = { push = function() end, pop = function() end, + top = function() return nil end }, + } + -- random 0: the catch roll always lands. + local battle = Battle.new({ data = DATA, party = party, wild = wild, + save = save, random = function() return 0 end }) + local screen = BattleState.new(game, { battle = battle, save = save }) + screen:useItem("POKE_BALL") + return battle, save, wild + end + + local battle, save, wild = catch(6) + eq(battle.outcome, "caught", "the ball lands on a full party") + eq(#save.party, 6, "the party did not grow") + local boxed = Boxes.box(save, 3)[1] + eq(boxed, wild, "SendMonIntoBox put the catch in slot 1 of the current box") + eq(boxed and boxed.hp, boxed and boxed.maxHp, + "and the PC holds it at full HP") + eq(boxed and boxed.status, nil, "with no status on the STATS screen") + eq(boxed and boxed.statusTurns, nil, "and no sleep counter behind it") + eq(boxed and boxed.moves[1].pp, 35, "PP refilled with the rest") + + -- The other half of the cart's asymmetry. + battle, save, wild = catch(5) + eq(battle.outcome, "caught", "the ball lands with room in the party") + eq(save.party[6], wild, "and TryAddMonToParty keeps it in the party") + eq(wild.hp, 1, "at the 1 HP it was caught on") + eq(wild.status, "sleep", "still asleep") + eq(wild.moves[1].pp, 4, "and with the PP it had left") + eq(Boxes.count(save, 3), 0, "nothing went to the PC") +end + +S.finish() diff --git a/tests/gen2_contest_test.lua b/tests/gen2_contest_test.lua index 4d01b880..5d253ced 100644 --- a/tests/gen2_contest_test.lua +++ b/tests/gen2_contest_test.lua @@ -1108,7 +1108,7 @@ do "function World:bugContestBattleOver", "self:bugContestBattleOver()", "BugContestResultsWarpScript", - "BugContest.ENGINE_BUG_CONTEST_TIMER", + "FieldMoves.BUG_CONTEST_FLAG", -- Kurt's menu and the item pair the ladder needs. "scriptMenu = function(header, onChoose)", "itemIndex = function(id)", diff --git a/tests/gen2_cycling_road_test.lua b/tests/gen2_cycling_road_test.lua new file mode 100644 index 00000000..ef159cf7 --- /dev/null +++ b/tests/gen2_cycling_road_test.lua @@ -0,0 +1,279 @@ +-- The Cycling Road: the START poll the forced roll used to swallow (#1718) and +-- the ALWAYS_ON_BIKE flag SURF never saw (#1749). +-- +-- luajit tests/gen2_cycling_road_test.lua +-- +-- Route17AlwaysOnBikeCallback sets ENGINE_ALWAYS_ON_BIKE and ENGINE_DOWNHILL on +-- MAPCALLBACK_NEWMAP (maps/Route17.asm:13-16), and DOWNHILL is what makes +-- .GetDPad read an empty pad as DOWN: the player is stepping on every single +-- frame of that road. +-- +-- #1718: the port gated START on "not moving", which on Route 17 is never -- +-- stepBody lands the step and queues the next one inside the SAME tick. The +-- cart refuses only on PLAYERMOVEMENT_CONTINUE; a QUEUED step answers +-- PLAYERMOVEMENT_FINISH (player_movement.asm:459-461), whose arm is `xor a / +-- ld c, a / ret` (events.asm:761-779), so CheckMenuOW still runs there +-- (events.asm:485-498). That landing frame comes round once in eight, so the +-- other half of the fix is Game2's joypad latch (events.asm:193-199, :215-231); +-- it needs a real Input and is driven by +-- tests/drivers/gold_cycling_road_bug1718_1749_test.lua. +-- +-- #1749: both surf entry points already refused on ctx.alwaysOnBike +-- (engine/events/overworld.asm:350-352, :513-515), but World:fieldContext never +-- set the field, so neither refusal was reachable. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 cycling road") +local check, eq = S.check, S.eq + +local Bike = require("src.world.gen2.Bike") +local FieldMoves = require("src.world.gen2.FieldMoves") +local Permissions = require("src.world.gen2.Permissions") +local Player = require("src.world.gen2.Player") +local World = require("src.world.gen2.World") + +-- constants/collision_constants.asm +local COLL_LAND, COLL_WATER = 0x00, 0x29 + +check(Permissions.isLand(COLL_LAND), "COLL_FLOOR $00 is a LAND_TILE") +check(Permissions.isWater(COLL_WATER), "COLL_WATER $29 is a WATER_TILE") + +-- ---- the gate: World:acceptsMenuInput, OWPlayerInput's preamble (#1718) ---- +-- +-- The shipped methods, over a world carrying only the fields they read. +local function gate(w) + w.busy = World.busy + w.playerCollision = World.playerCollision + return World.acceptsMenuInput(w) +end + +eq(gate({ player = { moving = false } }), true, + "standing still: PLAYERMOVEMENT_FINISH, so CheckMenuOW runs") +eq(gate({ player = { moving = true }, stepFinished = false }), + false, "mid-step: PLAYERMOVEMENT_CONTINUE, and OWPlayerInput returns") +eq(gate({ player = { moving = true }, stepFinished = true }), + true, "the frame the step lands reads it again, whatever was queued on top") + +-- stepFinished opens ONE arm and must not reach past it. +eq(gate({ player = { moving = true }, stepFinished = true, battleActive = true }), + false, "a battle still refuses on the landing frame") +eq(gate({ player = { moving = true }, stepFinished = true, textbox = {} }), + false, "an open text box still refuses on the landing frame") +eq(gate({ player = { moving = true }, stepFinished = true, mapSetup = {} }), + false, "and so does a map setup script") +eq(gate({ player = { moving = true }, stepFinished = true, fieldMove = {} }), + false, "and a field move's tail") +eq(gate({ player = { moving = false }, + vm = { running = function() return true end } }), + false, "wScriptRunning refuses even standing still (events.asm:238-243)") + +-- CheckStandingOnIce's carry, the arm that sits below the movement one. +do + local COLL_ICE = 0x23 + check(Permissions.isIce(COLL_ICE), "COLL_ICE $23") + local iced = { + player = { moving = false, cellX = 1, cellY = 1 }, + stepFinished = true, + turningDirection = "down", + map = { cellCollision = function() return COLL_ICE end }, + } + eq(gate(iced), false, "a latched slide on ice refuses") + iced.turningDirection = nil + eq(gate(iced), true, "and lets go once the latch clears") +end + +-- ---- .GetDPad and .DoStep on a DOWNHILL map -------------------------------- + +eq(Bike.forcedDirection(nil, true), "down", + "no direction held on the Cycling Road is a step DOWN") +eq(Bike.forcedDirection("up", true), "up", "a held direction still wins") +eq(Bike.forcedDirection(nil, false), nil, "and off the road, nothing") +eq(Bike.stepFrames(FieldMoves.PLAYER_BIKE, "down", true, Player.STEP_FRAMES), 8, + "the roll is a STEP_BIKE cell: half a walk") +eq(Bike.stepFrames(FieldMoves.PLAYER_BIKE, "left", true, Player.STEP_FRAMES), 16, + "and every other direction gets the walking duration back") + +-- ---- the roll: 24 ticks of Route 17, #1718's premise ----------------------- +-- +-- One iteration of Game2's fixed step over a real Player. + +local openRoad = { + inBounds = function() return true end, + isWalkable = function() return true end, +} + +local function tick(w) + -- Game2's fixed step asks first, with last tick's stepFinished still up. + local accepts = gate(w) + -- What the port asked before the fix, counted off the same run. + local standing = not w.player.moving + local p = w.player + w.stepFinished = false + w.stepFinished = p:update() + if not p.moving then + local dir = Bike.forcedDirection(nil, true) + p.stepFrames = Bike.stepFrames( + FieldMoves.PLAYER_BIKE, dir, true, Player.STEP_FRAMES) + p:tryMove(dir, openRoad, nil) + end + return accepts, standing +end + +do + local w = { player = Player.new(9, 36, "down") } + local polled, idle, gaps, run = {}, 0, 0, 0 + for n = 1, 24 do + local accepts, standing = tick(w) + if accepts then + polled[#polled + 1] = n + if run > gaps then gaps = run end + run = 0 + else + run = run + 1 + end + if standing then idle = idle + 1 end + end + + -- The direct proof of the premise. + eq(idle, 1, "the forced roll leaves the player standing on tick 1 and never again") + eq(w.player.moving, true, "and it is still rolling 24 ticks later") + eq(w.player.cellY > 36, true, "having actually travelled down the road") + + eq(#polled, 3, "the landing frame comes round three times in 24 ticks") + eq(table.concat(polled, ","), "1,10,18", + "tick 1, then one poll per 8-frame bike step") + eq(gaps, 8, "eight refused ticks between polls -- one press in eight lands") +end + +-- ---- ALWAYS_ON_BIKE reaches the field-move context (#1749) ----------------- +-- +-- The bug is the wiring, so the flag comes through the shipped +-- World:alwaysOnBike -> World:engineFlag pair off a real save table. + +local function surfMon() + return { species = "LAPRAS", moves = { "SURF" } } +end + +-- A road cell facing water: with the flag clear this ctx surfs. +local function fieldSelf(onBike, badge) + local coll = {} + local map = { + width = 8, height = 8, + def = { blocks = {}, environment = "ROUTE", tileset = "TILESET_KANTO" }, + cellCollision = function(_, cx, cy) return coll[cy * 16 + cx] or COLL_LAND end, + } + for i = 1, 64 do map.def.blocks[i] = 1 end + -- water in the cell the player faces, land under their feet + coll[36 * 16 + 10] = COLL_WATER + local save = { + party = { surfMon() }, + player = { badges = { FOG = badge ~= false or nil } }, + engineFlags = { [Bike.ENGINE_ALWAYS_ON_BIKE] = onBike or nil }, + } + return { + player = Player.new(9, 36, "right"), + map = map, + playerState = FieldMoves.PLAYER_BIKE, + game = { save = save }, + engineFlags = World.engineFlags, + engineFlag = World.engineFlag, + alwaysOnBike = World.alwaysOnBike, + blockIndexAt = World.blockIndexAt, + escapeRopeTarget = function() return nil end, + facingObject = function() return nil end, + hour = function() return 12 end, + } +end + +eq(World.fieldContext(fieldSelf(true)).alwaysOnBike, true, + "fieldContext carries ALWAYS_ON_BIKE onto the road") +eq(World.fieldContext(fieldSelf(false)).alwaysOnBike, false, + "and reports it clear everywhere else") + +do + -- The ctx really is surfable, or the refusals below pass for the wrong reason. + local ctx = World.fieldContext(fieldSelf(false), surfMon()) + eq(ctx.facing, "right", "facing the water") + check(Permissions.isWater(ctx.facingColl), "and the faced cell is water") + local menu = FieldMoves.surfFromMenu(ctx) + eq(menu.ok, true, "flag clear: the PACK's SURF goes through") + local ow = FieldMoves.trySurfOW(ctx) + eq(ow.ok, true, "flag clear: the A press offers to SURF") + eq(ow.ask, FieldMoves.TEXT.ASK_SURF, "with the usual prompt") +end + +do + local ctx = World.fieldContext(fieldSelf(true), surfMon()) + -- .FailSurf: MenuTextboxBackup on CantSurfText, so the menu refusal TALKS. + local menu = FieldMoves.surfFromMenu(ctx) + eq(menu.ok, false, "on the road the menu refuses") + eq(menu.text, FieldMoves.TEXT.CANT_SURF, "with CantSurfText") + -- TrySurfOW's arm is `.quit`: xor a, no script and no text at all. + local ow = FieldMoves.trySurfOW(ctx) + eq(ow.ok, false, "and the A press refuses") + eq(ow.took, nil, "silently -- .quit queues no script") + eq(ow.text, nil, "and prints nothing") +end + +do + -- CheckBadge runs ABOVE the ALWAYS_ON_BIKE test. + local ctx = World.fieldContext(fieldSelf(true, false), surfMon()) + local menu = FieldMoves.surfFromMenu(ctx) + eq(menu.text, FieldMoves.TEXT.BADGE_REQUIRED, + "no FOGBADGE still outranks the road (engine/events/overworld.asm:347-352)") +end + +-- ---- the real Route 17, when a cache is around ----------------------------- +-- +-- Pins the coordinates the driver walks to, so a map edit fails here instead +-- of parking a human at a wall. + +local cache = os.getenv("GOLD_CACHE") +if not cache then + cache = (os.getenv("HOME") or "") + .. "/Library/Application Support/LOVE/gold-dev/gold" +end +local mapsPath = cache .. "/data/generated/maps.lua" +local mf = io.open(mapsPath, "r") +if not mf then + check(true, "gold cache absent : fixture checks only (SKIP cache facts)") + S.finish() + return +end +mf:close() + +local Map = require("src.world.gen2.Map") +local maps = assert(loadfile(mapsPath))() +local tilesets = assert(loadfile(cache .. "/data/generated/tilesets.lua"))() +local scripts = assert(loadfile(cache .. "/data/generated/scripts.lua"))() + +do + local def = maps.ROUTE_17 + check(def ~= nil, "the cache carries ROUTE_17") + local ops = {} + for _, cb in ipairs(def.callbacks or {}) do + if cb.callback == "MAPCALLBACK_NEWMAP" then + for _, cmd in ipairs(scripts[cb.scriptKey] or {}) do + ops[#ops + 1] = cmd.op + end + end + end + eq(table.concat(ops, ","), "setflag,setflag,endcallback", + "Route17AlwaysOnBikeCallback is the two unconditional setflags") + + local map = Map.new(def, tilesets[def.tileset]) + -- The lane the driver rides: x=9 is road, x=10 is the water beside it, and + -- the four bikers stand at (4,17) (16,32) (3,53) (6,80), well clear of it. + for cy = 36, 48 do + check(Permissions.isLand(map:cellCollision(9, cy)), + "ROUTE_17 (9," .. cy .. ") is road") + check(Permissions.isWater(map:cellCollision(10, cy)), + "ROUTE_17 (10," .. cy .. ") is water") + end + for _, obj in ipairs(def.objects or {}) do + check(not (obj.x == 9 and obj.y >= 34 and obj.y <= 50), + "no object parked in the driver's lane") + end +end + +S.finish() diff --git a/tests/gen2_dex_gift_test.lua b/tests/gen2_dex_gift_test.lua new file mode 100644 index 00000000..73c4eca9 --- /dev/null +++ b/tests/gen2_dex_gift_test.lua @@ -0,0 +1,152 @@ +-- A script-gifted mon registers in the #DEX. GiveShuckle +-- (engine/events/shuckle.asm:14) hands its mon to TryAddMonToParty, whose +-- PARTYMON arm falls into .registerpokedex / SetSeenAndCaughtMon +-- (engine/pokemon/move_mon.asm:179-197), so Mania's SHUCKIE arrives already +-- ticked off. #1719. ROM-free: +-- luajit tests/gen2_dex_gift_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 dex gift") +local check, eq, same = S.check, S.eq, S.same + +love = require("tests.love_stub") + +local Vm = require("src.script.gen2.Vm") +local Events = require("src.world.gen2.Events") +local Specials = require("src.script.gen2.Specials") +local Breeding = require("src.core.gen2.Breeding") +local Save = require("src.core.gen2.Save") + +-- constants/pokemon_constants.asm: SHUCKLE is 213. +local SHUCKLE_INDEX = 213 + +-- Just enough of data.pokemon / data.moves for Mon.new to build a SHUCKLE, +-- plus one other species so the "index, not name" mistake has something to +-- collide with. +local function fixture() + return { + pokemon = { + SHUCKLE = { name = "SHUCKLE", index = SHUCKLE_INDEX, + growthRate = "MEDIUM_FAST", + types = { "BUG", "ROCK" }, + baseStats = { hp = 20, attack = 10, defense = 230, speed = 5, + specialAttack = 10, specialDefense = 230 }, + levelMoves = { { level = 1, move = "CONSTRICT" } } }, + TOTODILE = { name = "TOTODILE", index = 158, + growthRate = "MEDIUM_SLOW", + types = { "WATER", "WATER" }, + baseStats = { hp = 50, attack = 65, defense = 64, speed = 43, + specialAttack = 44, specialDefense = 48 }, + levelMoves = { { level = 1, move = "SCRATCH" } } }, + }, + moves = { + CONSTRICT = { name = "CONSTRICT", pp = 35 }, + SCRATCH = { name = "SCRATCH", pp = 35 }, + }, + items = {}, + } +end + +-- The three hooks GiveShuckle reads out of World:specialHooks: the party it +-- appends to, the save it flags the dex on, and the data Mon.new builds from. +local function giftVm(record) + record.party = record.party or {} + return Vm.new({}, {}, Events.new(), { specials = { + party = function() return record.party end, + save = function() return record end, + data = fixture, + } }) +end + +-- ---- the gift itself ------------------------------------------------------ +local giftedDex +do + local record = { party = {} } + Specials.HANDLERS.GiveShuckle(giftVm(record)) + + eq(#record.party, 1, "Mania's SHUCKIE joins the party") + local mon = record.party[1] + eq(mon.nickname, "SHUCKIE", "under its own nickname") + + local dex = record.pokedex or {} + eq((dex.seen or {})[mon.species], true, "and it is SEEN in the #DEX") + eq((dex.caught or {})[mon.species], true, + "and CAUGHT: SetSeenAndCaughtMon sets both flags, not just the one") + eq((dex.caught or {})[SHUCKLE_INDEX], nil, + "keyed by the species name PokedexMenu:rebuild looks up, not by dex number") + local summary = Save.summary(record) + eq(summary and summary.caught, 1, + "so the CONTINUE panel's own count agrees (Save.lua:910-913)") + giftedDex = record.pokedex +end + +-- The handler must leave wScriptVar TRUE, because ManiasHouse.asm branches on +-- it to pick the "took it" text over the "your party is full" one. +do + local record = { party = {} } + local vm = giftVm(record) + Specials.HANDLERS.GiveShuckle(vm) + eq(vm.scriptVar, 1, "GiveShuckle answers TRUE when it hands one over") +end + +-- ---- a dex that already has entries in it --------------------------------- +do + local record = { party = {}, + pokedex = { seen = { TOTODILE = true }, caught = { TOTODILE = true } } } + Specials.HANDLERS.GiveShuckle(giftVm(record)) + eq(record.pokedex.caught.TOTODILE, true, "the starter's entry survives") + eq(record.pokedex.caught.SHUCKLE, true, "and SHUCKIE joins it") + eq(Save.summary(record).caught, 2, "two owned species, counted once each") +end + +-- The shape a loaded save actually has, rather than a hand-built one: +-- Save.normalize seeds pokedex.seen/caught, so the handler is writing into +-- tables that already exist. +do + local record = Save.normalize({ party = {} }) or { party = {} } + record.party = record.party or {} + Specials.HANDLERS.GiveShuckle(giftVm(record)) + eq(record.pokedex.caught.SHUCKLE, true, "a normalized save takes the flag too") +end + +-- ---- the arms that must NOT flag the dex ---------------------------------- +-- .full returns before _AddPartyMon runs, so nothing reaches .registerpokedex +-- and the player who never received the mon has no entry for it. +do + local full = {} + for i = 1, Breeding.PARTY_SIZE do full[i] = { species = "TOTODILE", level = i } end + local record = { party = full } + local vm = giftVm(record) + Specials.HANDLERS.GiveShuckle(vm) + eq(vm.scriptVar, 0, "a full party refuses the gift") + eq(#record.party, Breeding.PARTY_SIZE, "and nothing is appended") + eq(record.pokedex, nil, "a refused gift leaves no #DEX entry behind") +end + +-- Mon.new failing (no such species in the cache) is the other early out. +do + local record = { party = {} } + local vm = Vm.new({}, {}, Events.new(), { specials = { + party = function() return record.party end, + save = function() return record end, + data = function() return { pokemon = {}, moves = {} } end, + } }) + Specials.HANDLERS.GiveShuckle(vm) + eq(vm.scriptVar, 0, "a mon that cannot be built is refused") + eq(record.pokedex, nil, "and still flags nothing") +end + +-- ---- parity with the engine's other SetSeenAndCaughtMon ports ------------- +-- Breeding.markPokedex is the shared port of the same routine the hatch, the +-- NPC trade and the givepoke opcode all run. A gift that flags the dex its +-- own way instead of matching that state is drift, and drift is what leaves +-- one screen showing the entry and another not. +do + local sibling = { party = {} } + check(Breeding.markPokedex(sibling, "SHUCKLE"), + "the shared SetSeenAndCaughtMon port still takes a species") + same(giftedDex, sibling.pokedex, + "GiveShuckle leaves the identical #DEX state the shared port does") +end + +S.finish() diff --git a/tests/gen2_field_anim_test.lua b/tests/gen2_field_anim_test.lua index 3513c46b..50b77d6e 100644 --- a/tests/gen2_field_anim_test.lua +++ b/tests/gen2_field_anim_test.lua @@ -71,9 +71,14 @@ check("$30 jump_step DOWN is a jump", Movement.decodeByte(0x30).kind, "jump") check("and it keeps its facing", Movement.decodeByte(0x33).dir, "right") check("$2c slow_jump_step is one too", Movement.decodeByte(0x2c).kind, "jump") check("$34 fast_jump_step as well", Movement.decodeByte(0x34).kind, "jump") --- The neighbouring families are still plain steps / turns. -check("$28 turn_waterfall is untouched", - Movement.decodeByte(0x28).kind, "turn") +-- $20 turn_away / $24 turn_in / $28 turn_waterfall all `jp TurningStep` +-- (engine/overworld/movement.asm:483-513), which is InitStep with +-- OBJECT_ACTION_SPIN (:693-715): one cell crossed, spinning, not a facing +-- change. Script_ForcedMovement's bounce out of a whirlpool is that step. +check("$28 turn_waterfall is a spinning step", + Movement.decodeByte(0x28).kind, "step") +check("$24 turn_in is one too", Movement.decodeByte(0x24).kind, "step") +check("and it carries the spin", Movement.decodeByte(0x24).spin, true) check("$10 big_step is untouched", Movement.decodeByte(0x10).kind, "step") -- ------------------------------------------ sliding, fixed facing, tree shake diff --git a/tests/gen2_ledge_hop_test.lua b/tests/gen2_ledge_hop_test.lua new file mode 100644 index 00000000..408b3b17 --- /dev/null +++ b/tests/gen2_ledge_hop_test.lua @@ -0,0 +1,270 @@ +-- A ledge hop advances every logic frame, the way every other step does. +-- +-- luajit tests/gen2_ledge_hop_test.lua +-- +-- A hop is the engine's only two-cell move (World:tryLedgeJump, STEP_LEDGE). +-- On the cart it is jump_step, i.e. STEP_WALK (pokegold/engine/overworld/ +-- movement.asm:595-597), so GetStepVector hands StepFunction_PlayerJump the +-- `db 0, 2, 8, 2` row (map_objects.asm:365-381) and .stepjump / .stepland run +-- it as two 8-frame beats: 16 frames, 2px EVERY frame, and UpdateJumpPosition +-- (:1796-1817) adds the speed to OBJECT_JUMP_HEIGHT and indexes its 16-entry +-- arc at height/2, one entry per frame. +-- +-- We render Gen 2 at twice that temporal resolution (Player.STEP_FRAMES = 16 +-- for one cell), so the hop is 32 frames and has to move 1px and half an arc +-- entry on every one of them. It used to compute the sub-cell offset per CELL +-- and then scale it by the cell delta, so half the hop's frames moved nothing +-- and the other half jumped 2px -- and since the camera follows px/py, the +-- whole map scrolled at 30Hz. The arc had the matching quantum with the +-- opposite sign, which dragged the sprite back UP the screen five times. #1713 +-- +-- Self-contained: no cache, no love. tests/drivers/gold_ledge_hop_bug1713_ +-- test.lua is the half a person watches. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 ledge hop") +local check, eq = S.check, S.eq + +local Player = require("src.world.gen2.Player") +local Permissions = require("src.world.gen2.Permissions") +local World = require("src.world.gen2.World") + +local DELTA = { up = { 0, -1 }, down = { 0, 1 }, + left = { -1, 0 }, right = { 1, 0 } } + +-- ---- one move, frame by frame ---------------------------------------------- +-- +-- What the renderer reads each frame: the position the camera follows, and the +-- OBJECT_SPRITE_Y_OFFSET added on top of it. Row 1 is the standing frame +-- before the move starts, so row i+1 is frame i. +local function playMove(dir, opts) + opts = opts or {} + local p = Player.new(5, 5, dir) + local d = DELTA[dir] + local cells = opts.jump and 2 or 1 + p.targetX, p.targetY = p.cellX + d[1] * cells, p.cellY + d[2] * cells + p.moving = true + p.progress = 0 + p.jumping = opts.jump or nil + p.stepFrames = opts.stepFrames + or (opts.jump and Player.STEP_FRAMES * 2 or Player.STEP_FRAMES) + local rows = { { px = p.px, py = p.py, off = 0 } } + local frames = 0 + while p.moving and frames < 200 do + p:update() + frames = frames + 1 + rows[#rows + 1] = { px = p.px, py = p.py, off = p.spriteYOffset or 0 } + end + return p, rows, frames +end + +-- Per-frame change in one recorded column. +local function steps(rows, key) + local out = {} + for i = 2, #rows do out[i - 1] = rows[i][key] - rows[i - 1][key] end + return out +end + +local function countIf(list, fn) + local n = 0 + for _, v in ipairs(list) do if fn(v) then n = n + 1 end end + return n +end + +-- ---- the control: an ordinary one-cell walk -------------------------------- +-- +-- The hop's fix must not have been bought by moving this. +do + local p, rows, frames = playMove("down") + eq(frames, Player.STEP_FRAMES, "a walk is 16 logic frames") + local dy = steps(rows, "py") + eq(countIf(dy, function(v) return v ~= 1 end), 0, + "and every one of them moves the player exactly one pixel down") + eq(countIf(steps(rows, "px"), function(v) return v ~= 0 end), 0, + "with no sideways drift") + eq(p.cellY, 6, "it lands one cell on") + eq(p.py, 6 * 16, "on the pixel the cell says") + + -- .DoStep's STEP_BIKE arm: the same one cell in half the frames, so the + -- per-frame advance doubles. This is what a span applied to the wrong + -- denominator breaks first. + local bike, bikeRows, bikeFrames = + playMove("right", { stepFrames = Player.STEP_FRAMES / 2 }) + eq(bikeFrames, 8, "a bike step is 8 logic frames") + eq(countIf(steps(bikeRows, "px"), function(v) return v ~= 2 end), 0, + "each moving two pixels, so a bike still covers one cell") + eq(bike.cellX, 6, "and lands one cell on, not two") +end + +-- ---- the hop --------------------------------------------------------------- +-- +-- The bug, stated as numbers: half these frames used to move nothing and the +-- other half used to move two pixels. +local hopP, hopRows, hopFrames = playMove("down", { jump = true }) +eq(hopFrames, Player.STEP_FRAMES * 2, "a ledge hop is 32 logic frames") +local hopDy = steps(hopRows, "py") +eq(countIf(hopDy, function(v) return v ~= 1 end), 0, + "every frame of a ledge hop moves the player a pixel") +eq(countIf(hopDy, function(v) return math.abs(v) >= 2 end), 0, + "and no frame of it moves two") +eq(countIf(steps(hopRows, "px"), function(v) return v ~= 0 end), 0, + "a hop straight down never moves sideways") +-- The near miss: span math applied without the matching frames scaling covers +-- one cell in 32 frames instead of two. +eq(hopRows[#hopRows].py - hopRows[1].py, 32, + "the 32 frames add up to 32 pixels, which is two cells") +eq(hopP.cellY, 7, "and the grid agrees the player crossed two cells") +eq(hopP.py, 7 * 16, "landing on the landing cell's own pixel") +eq(countIf(hopRows, function(r) + return r.px % 1 ~= 0 or r.py % 1 ~= 0 +end), 0, "and no frame leaves the player on a half pixel") + +-- ---- the arc --------------------------------------------------------------- +-- +-- UpdateJumpPosition's .y_offsets: -4 up to -12 and back to 0, one entry per +-- cart frame, so across our doubled step it is half an entry per frame. +do + local off = {} + for i = 2, #hopRows do off[#off + 1] = hopRows[i].off end + local peak = 0 + for i = 1, #off do if off[i] < peak then peak = off[i] end end + -- The arc rises, then falls, and changes its mind exactly once: any extra + -- sign change in the offsets is the sprite wobbling. + local moves = {} + for i = 2, #off do + local v = off[i] - off[i - 1] + if v ~= 0 then moves[#moves + 1] = v end + end + local turns = 0 + for i = 2, #moves do + if (moves[i] > 0) ~= (moves[i - 1] > 0) then turns = turns + 1 end + end + eq(off[1], -4, "the arc opens on the table's first entry") + eq(peak, -12, "peaks 12 pixels up, the table's own peak") + eq(off[#off], 0, "and is back on the ground for the landing frame") + eq(turns, 1, "rising then falling, once, with no wobble in between") + eq(countIf(off, function(v) return v > 0 or v < -12 end), 0, + "and never leaves the range the table covers") + + -- The composed screen position, which is what an eye actually tracks: the + -- camera-following py plus the sprite offset. The first frame lifts the + -- sprite -- that is the take-off -- and after it a hop DOWN may never go + -- backwards. Before the fix it did so on five frames. + local screen, back = {}, 0 + for i = 2, #hopRows do + screen[#screen + 1] = hopRows[i].py + hopRows[i].off + end + for i = 2, #screen do + if screen[i] < screen[i - 1] then back = back + 1 end + end + eq(back, 0, "the arc never drags the sprite back up the screen") + check(screen[1] < hopRows[1].py + hopRows[1].off, + "the one time it does rise is the take-off, on the first frame") + eq(countIf(steps(hopRows, "py"), function(v) return v ~= 1 end), 0, + "while the map underneath scrolls one pixel a frame throughout") +end + +-- A sideways hop is the same move on the other axis: Gold's $a0 is HOP_RIGHT. +for _, dir in ipairs({ "left", "right" }) do + local p, rows, frames = playMove(dir, { jump = true }) + local want = DELTA[dir][1] + eq(frames, 32, ("a %s hop is 32 logic frames too"):format(dir)) + eq(countIf(steps(rows, "px"), function(v) return v ~= want end), 0, + ("and moves one pixel %s on every one of them"):format(dir)) + eq(countIf(steps(rows, "py"), function(v) return v ~= 0 end), 0, + ("a %s hop never slides the player up or down a row"):format(dir)) + eq(p.cellX, 5 + want * 2, ("it lands two cells %s"):format(dir)) +end + +-- The step ends clean: nothing about the jump is left set for the next walk. +eq(hopP.moving, false, "the hop ends the step") +eq(hopP.jumping, nil, "and clears the jump") +eq(hopP.spriteYOffset, 0, "and puts the sprite back on its feet") + +-- ---- the same hop, through World ------------------------------------------- +-- +-- Player alone cannot prove the wiring: World:tryLedgeJump is what picks the +-- two-cell target and the doubled duration (World.lua's STEP_LEDGE arm), and a +-- fix that only touched one of the two would still stutter in the game. +local COLL_FLOOR, COLL_WALL, COLL_HOP_DOWN = 0x00, 0x07, 0xa3 + +local function ledgeWorld(px, py, dir, coll) + local game = { + data = { items = {}, moves = {}, pokemon = {} }, + save = { player = { name = "GOLD", badges = {} }, party = {}, + inventory = {} }, + } + local world = World.new(game) + game.world = world + local cells = {} + cells[py * 100 + px] = coll or COLL_HOP_DOWN + local d = DELTA[dir] + -- The refused single step, which is what turns the walk into a jump. + cells[(py + d[2]) * 100 + (px + d[1])] = COLL_WALL + local map + map = { + id = "ROUTE_29", + width = 10, height = 10, + def = { objects = {}, bgEvents = {}, environment = "ROUTE", + tileset = "TILESET_JOHTO", width = 10, height = 10 }, + cellCollision = function(_, x, y) + return cells[y * 100 + x] or COLL_FLOOR + end, + inBounds = function(_, x, y) + return x >= 0 and y >= 0 and x < 20 and y < 20 + end, + isWalkable = function(_, x, y) + return map:inBounds(x, y) + and Permissions.isWalkable(map:cellCollision(x, y)) + end, + warpAt = function() return nil end, + connection = function() return nil end, + } + world.map = map + world.maps = { ROUTE_29 = map.def } + world.player = Player.new(px, py, dir) + world.player.turnArmed = false + world.entities = { world.player } + world.npcs = {} + world.encounters = {} + world.noWildEncounters = true + world.pollTimeOfDay = function() end + world.updatePeople = function() end + return world +end + +check(Permissions.isLedge(COLL_HOP_DOWN), "$a3 is a ledge") +check((Permissions.ledgeFacings(COLL_HOP_DOWN) or {}).down, + "and its row hops DOWN") + +do + local world = ledgeWorld(4, 5, "down") + local p = world.player + local rows, airborne = {}, false + for _ = 1, 120 do + world.heldDir = (not airborne) and "down" or nil + local wasAirborne = airborne + world:step() + if p.jumping then airborne = true end + if airborne then + rows[#rows + 1] = { px = p.px, py = p.py, off = p.spriteYOffset or 0 } + end + if wasAirborne and not p.moving then break end + end + check(#rows > 0, "holding down on a ledge starts a hop") + eq(#rows - 1, 32, "which runs the doubled 32-frame duration") + eq(p.cellY, 7, "and lands two cells down, on the far side of the wall") + local dy = steps(rows, "py") + eq(countIf(dy, function(v) return v ~= 1 end), 0, + "every frame of the in-game hop scrolls the map exactly one pixel") + eq(rows[#rows].py - rows[1].py, 32, + "so the whole hop is 32 pixels of travel, not 16") + local screen, back = {}, 0 + for i = 2, #rows do screen[#screen + 1] = rows[i].py + rows[i].off end + for i = 2, #screen do + if screen[i] < screen[i - 1] then back = back + 1 end + end + eq(back, 0, "and after the take-off the sprite never reverses") +end + +S.finish() diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua index 8c0934b2..8843026d 100644 --- a/tests/gen2_menus_test.lua +++ b/tests/gen2_menus_test.lua @@ -356,10 +356,11 @@ local optionsGame, optionsInput = newGame(Save.newGame()) local options = OptionsMenu.new(optionsGame, { options = Save.defaultOptions(), }) --- The cart's seven rows, then the port's: CONTROLS, audio, speed, display, --- video mode, screen position, the mobile-gated touch three (buildRows), --- MAX FPS and CANCEL. -check("twenty-four rows", #OptionsMenu.ROWS, 24) +-- The cart's seven rows, then the port's: CONTROLS, audio, PERFORMANCE, +-- speed, display, SHADER FX + SHADER FX 2 (the second slot added alongside +-- the dual-shader feature), video mode, screen position, the mobile-gated +-- touch three (buildRows), MAX FPS, BATTLE BG and CANCEL. +check("twenty-seven rows", #OptionsMenu.ROWS, 27) 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") @@ -955,20 +956,24 @@ check("GBC leaves a palette alone", 1)[1], 1) check("and has no present pass", GbcPalette.presentColors(), nil) -local zoomIndex, gbcfxIndex +local zoomIndex, tiltIndex for i, row in ipairs(OptionsMenu.ROWS) do if row.label == "ZOOM" then zoomIndex = i end - if row.label == "GBC FX" then gbcfxIndex = i end + if row.label == "TILT" then tiltIndex = i end end check("VOID FILL follows ZOOM", OptionsMenu.ROWS[zoomIndex + 1].label, "VOID FILL") check("and TILT follows VOID FILL", OptionsMenu.ROWS[zoomIndex + 2].label, "TILT") -check("VIDEO MODE follows GBC FX", OptionsMenu.ROWS[gbcfxIndex + 1].label, +check("SHADER FX follows COLOR follows TILT", OptionsMenu.ROWS[tiltIndex + 2].label, + "SHADER FX") +check("SHADER FX 2 follows SHADER FX", OptionsMenu.ROWS[tiltIndex + 3].label, + "SHADER FX 2") +check("VIDEO MODE follows SHADER FX 2", OptionsMenu.ROWS[tiltIndex + 4].label, "VIDEO MODE") -check("and SCREEN POS follows it", OptionsMenu.ROWS[gbcfxIndex + 2].label, +check("and SCREEN POS follows it", OptionsMenu.ROWS[tiltIndex + 5].label, "SCREEN POS") -check("and TOUCH PAD follows that", OptionsMenu.ROWS[gbcfxIndex + 3].label, +check("and TOUCH PAD follows that", OptionsMenu.ROWS[tiltIndex + 6].label, "TOUCH PAD") local videoRow = select(2, rowNamed("VIDEO MODE")) diff --git a/tests/gen2_obedience_test.lua b/tests/gen2_obedience_test.lua index c256a726..e027b8cc 100644 --- a/tests/gen2_obedience_test.lua +++ b/tests/gen2_obedience_test.lua @@ -180,7 +180,7 @@ do random = rolls({ 39, 39, 5, 2 }) }) eq(battle:checkObedience("ROCK_THROW"), true, "napped instead") eq(mon.status, "sleep", "BeganToNap writes sleep straight in") - eq(mon.statusTurns, 3, "1-7 turns, from the same roll shape as sleep") + eq(mon.statusTurns, 3, "1-7 turns, written straight into the status byte") check(findText(battle.events, "ROCKY began to nap!"), "with its own line") -- Self-hit: the margin roll in the second band. diff --git a/tests/gen2_ow_bounce_test.lua b/tests/gen2_ow_bounce_test.lua new file mode 100644 index 00000000..211e56fc --- /dev/null +++ b/tests/gen2_ow_bounce_test.lua @@ -0,0 +1,311 @@ +-- SPRITEMOVEDATA_POKEMON ($16), the row every overworld mon object stands on. +-- +-- luajit tests/gen2_ow_bounce_test.lua (ROM-free; the cache section SKIPs +-- without a gold cache) +-- +-- data/sprites/map_objects.asm:181-187 gives the row SPRITEMOVEFN_BOUNCE and +-- OBJECT_ACTION_BOUNCE. SetFacingBounce increments OBJECT_STEP_FRAME once a +-- frame, masks it to four bits and reads bit 3: set means FACING_STEP_UP_0, +-- clear falls into SetFacingFreezeBounce and FACING_STEP_DOWN_0 +-- (engine/overworld/map_object_action.asm:184-202). Those are tiles $00..$03 +-- and $04..$07 of the mon's menu icon (data/sprites/facings.asm:43-72), i.e. +-- the two 16x16 halves of the 16x32 sheet extractIcons already writes. +-- +-- Both halves of #1748 are pinned here: the animation itself, and the sheet +-- being two frames deep so the animation has a second pose to reach. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 ow bounce") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local NPC = require("src.world.gen2.Npc") +local SpriteRenderer = require("src.render.SpriteRenderer") +local World = require("src.world.gen2.World") + +-- constants/map_object_constants.asm:135-151 +local SPRITEMOVEDATA_STILL = 0x01 +local SPRITEMOVEDATA_STANDING_DOWN = 0x06 +local SPRITEMOVEDATA_POKEMON = 0x16 + +-- trueColor short-circuits SpriteRenderer past the OBP bake, so :draw here +-- needs no canvases (the same trick tests/gen2_big_object_test.lua uses). +local MON_SHEET = { + id = "SPRITE_JIGGLYPUFF", frames = 2, trueColor = true, + spriteType = "POKEMON_SPRITE", walker = false, + image = "assets/generated/icons/gen2/jigglypuff.png", +} +local DOLL_SHEET = { + id = "SPRITE_FAIRY", frames = 6, trueColor = true, + spriteType = "WALKING_SPRITE", walker = true, + image = "assets/generated/sprites/fairy.png", +} + +local function build(movement, sheet) + return NPC.new("TEST_MAP", + { index = 1, movement = movement, x = 4, y = 4 }, sheet or MON_SHEET) +end + +-- ---- the movement byte ----------------------------------------------------- +eq(NPC.MOVE.POKEMON, SPRITEMOVEDATA_POKEMON, + "SPRITEMOVEDATA_POKEMON is $16 in map_object_constants.asm") + +-- Without the seam every block below it raises rather than reporting, which +-- turns a whole suite of answers into one traceback. +local HAS_BOUNCE = check(type(NPC.bounceFrame) == "function", + "NPC:bounceFrame is the seam OBJECT_ACTION_BOUNCE draws through") +if not HAS_BOUNCE then + NPC.bounceFrame = function() return nil end +end + +do + local mon = build(SPRITEMOVEDATA_POKEMON) + check(mon.bouncing == true, "a $16 object is built bouncing") + check(mon.fixedFacing == true, + "and keeps FIXED_FACING, which the $16 row's flags1 byte carries") + eq(mon.facing, "down", "its `db DOWN ; facing` column is DOWN") + eq(select(1, NPC.patternFor(SPRITEMOVEDATA_POKEMON)), "stand", + "MovementFunction_Bouncing parks it on STEP_TYPE_STANDING: no walking") +end + +-- ---- eight frames down, eight frames up ------------------------------------ +-- `inc a / and %00001111 / ld [hl], a / and %00001000` is a sixteen-frame +-- cycle with an eight-frame dwell on each pose, and the counter is stepped +-- BEFORE the bit is read -- so the spawn frame is pose 0 and so are the seven +-- after it. +do + local mon = build(SPRITEMOVEDATA_POKEMON) + eq(mon.bounceStep, 0, "OBJECT_STEP_FRAME starts at zero") + local seen, wrong = { mon:bounceFrame() }, nil + for i = 1, 31 do + mon:update() + seen[i + 1] = mon:bounceFrame() + end + for i = 0, 31 do + local want = math.floor(i / 8) % 2 + if seen[i + 1] ~= want and not wrong then + wrong = ("frame %d drew pose %s, wanted %d") + :format(i, tostring(seen[i + 1]), want) + end + end + check(wrong == nil, "two full cycles run 0,0..0,1,1..1 eight frames apiece" + .. (wrong and (" -- " .. wrong) or "")) + eq(seen[1], 0, "the spawn frame is FacingStepDown0, the icon's first half") + eq(seen[9], 1, "the ninth is FacingStepUp0, its second half") + eq(seen[17], 0, "and the seventeenth is back to the first") + eq(mon.bounceStep, 31 % 16, "the counter wrapped at sixteen, not at eight") +end + +-- ---- the frozen column ----------------------------------------------------- +-- OBJECT_ACTION_BOUNCE's second entry is SetFacingFreezeBounce, which writes +-- FACING_STEP_DOWN_0 and never touches OBJECT_STEP_FRAME. So a mon held for a +-- conversation pins its FIRST pose and resumes on the phase it left. +do + local mon = build(SPRITEMOVEDATA_POKEMON) + for _ = 1, 10 do mon:update() end + eq(mon:bounceFrame(), 1, "ten frames in, the mon is on the up pose") + eq(mon.bounceStep, 10, "with OBJECT_STEP_FRAME at 10") + + mon.frozen = true + local pinned = true + for _ = 1, 40 do + mon:update() + if mon:bounceFrame() ~= 0 then pinned = false end + end + check(pinned, "frozen, it holds FacingStepDown0 for the whole conversation") + eq(mon.bounceStep, 10, + "and SetFacingFreezeBounce leaves the step counter where it was") + + mon.frozen = false + mon:update() + eq(mon.bounceStep, 11, "released, the counter carries on from 10") + eq(mon:bounceFrame(), 1, "so the bounce resumes on the phase it froze at") +end + +-- ---- every other movement byte is still ------------------------------------ +-- Only the $16 row carries OBJECT_ACTION_BOUNCE; the Clefairy, Charizard and +-- Pidgeot dolls are SPRITEMOVEDATA_STANDING_DOWN and take +-- OBJECT_ACTION_STAND (data/sprites/map_objects.asm:53-59). +do + for _, movement in ipairs({ SPRITEMOVEDATA_STILL, SPRITEMOVEDATA_STANDING_DOWN, + NPC.MOVE.WANDER, NPC.MOVE.SPINRANDOM_SLOW, NPC.MOVE.BIGDOLL }) do + local npc = build(movement, DOLL_SHEET) + check(not npc.bouncing, + ("movement $%02x does not bounce"):format(movement)) + check(npc:bounceFrame() == nil, + ("and $%02x asks for no frame override"):format(movement)) + end + + -- The other half of it: a mon SHEET on a still row stays still. Gold has no + -- such object, but a mod or a variablesprite slot can make one. + local still = build(SPRITEMOVEDATA_STILL, MON_SHEET) + for _ = 1, 40 do still:update() end + check(still:bounceFrame() == nil, + "a POKEMON_SPRITE sheet on a STILL row never bounces either") +end + +-- ---- the frame reaches the renderer ---------------------------------------- +-- NPC:draw is where the pose is spent: SpriteRenderer:draw's trailing +-- frameOverride argument. A bounce the draw call drops looks exactly like no +-- bounce at all. +do + local mon = build(SPRITEMOVEDATA_POKEMON) + local doll = build(SPRITEMOVEDATA_STANDING_DOWN, DOLL_SHEET) + local seen = {} + local function recorder(who) + return { draw = function(_, _, _, _, _, _, _, _, _, _, frameOverride) + seen[who] = { override = frameOverride, count = (seen[who] + and seen[who].count or 0) + 1 } + end } + end + mon.sprite, doll.sprite = recorder("mon"), recorder("doll") + + mon:draw(0, 0, 1) + doll:draw(0, 0, 1) + eq(seen.mon.override, 0, "the mon's first draw overrides to frame 0") + check(seen.doll.override == nil, "and the doll's overrides to nothing") + + for _ = 1, 8 do mon:update() end + mon:draw(0, 0, 1) + eq(seen.mon.override, 1, "eight frames later it overrides to frame 1") +end + +-- ---- a one-frame sheet has nowhere to bounce to ---------------------------- +-- SpriteRenderer builds one quad per frame and :draw only honours an override +-- it has a quad for, so the extractor half of #1748 is load bearing: with +-- `frames = 1` the up pose is simply not on the sheet. +do + local one = SpriteRenderer.new({ + id = "ONE", frames = 1, trueColor = true, image = MON_SHEET.image }, "one") + eq(one.frameCount, 1, "a frames = 1 def builds one quad") + check(one.frames[1] == nil, "so there is no second frame to override to") + + local two = SpriteRenderer.new(MON_SHEET, "two") + eq(two.frameCount, 2, "a frames = 2 def builds both") + check(two.frames[1] ~= nil, "and frame 1 is a real quad") + eq(two:getFrameGeometry(1).y, two.frameHeight, + "which is the lower 16x16 of the 16x32 icon sheet") +end + +-- ---- World:load repairs a stale cache -------------------------------------- +-- The extractor fix only reaches a player who re-imports, and there is no +-- non-file lever that would force one. World:load back-fills the SpriteMons +-- rows on the way past, keyed on `source` so a mod's own POKEMON_SPRITE row is +-- left exactly as the mod wrote it. +do + local sprites = { + SPRITE_JIGGLYPUFF = { id = "SPRITE_JIGGLYPUFF", frames = 1, + source = "ROM:SpriteMons[20]", spriteType = "POKEMON_SPRITE", + image = "assets/generated/icons/gen2/jigglypuff.png" }, + SPRITE_CHRIS = { id = "SPRITE_CHRIS", frames = 12, walker = true, + source = "ROM:OverworldSprites[0]", + image = "assets/generated/sprites/chris.png" }, + SPRITE_MOD_MON = { id = "SPRITE_MOD_MON", frames = 1, + source = "MOD:pocket-monsters", spriteType = "POKEMON_SPRITE", + image = "mods/pocket-monsters/mon.png" }, + } + local world = World.new({ data = { + gen2Maps = { TEST_MAP = { width = 1, height = 1 } }, + gen2Tilesets = {}, + gen2Sprites = sprites, + } }) + -- load goes on to tilesets, palettes and map art this stub has none of; the + -- back-fill sits in its first dozen lines, so let the rest fall over. + pcall(world.load, world) + eq(sprites.SPRITE_JIGGLYPUFF.frames, 2, + "a pre-#1748 SpriteMons row is back-filled to two frames on load") + eq(sprites.SPRITE_CHRIS.frames, 12, + "an OverworldSprites row is not touched") + eq(sprites.SPRITE_MOD_MON.frames, 1, + "and a mod's own POKEMON_SPRITE row keeps the frame count it declared") +end + +-- ---- the day-care pair ----------------------------------------------------- +-- GetMonSprite's .BreedMon1 / .BreedMon2 arms have no sprites.lua row to +-- back-fill (engine/overworld/overworld.asm:279-305), so the def World builds +-- by hand has to carry the second frame itself. +do + local world = World.new({ data = { gen2Icons = { + species = { PIKACHU = "ICON_PIKACHU" }, + icons = { ICON_PIKACHU = { + image = "assets/generated/icons/gen2/pikachu.png" } }, + } } }) + local def = world:breedmonSpriteDef("PIKACHU") + check(def ~= nil, "a deposited PIKACHU builds a day-care sprite def") + eq(def and def.frames, 2, "two frames, like every other SpriteMons row") + eq(def and def.spriteType, "POKEMON_SPRITE", "and it is a mon sheet") +end + +-- ---- the cache ------------------------------------------------------------- +-- Same default every other gen2 suite uses, so a run with no GOLD_CACHE set +-- still reads the cache instead of skipping silently. +local cache = os.getenv("GOLD_CACHE") + or ((os.getenv("HOME") or "") .. "/Library/Application Support/LOVE/gold-dev/gold") +local function loadCache(name) + local chunk = loadfile(cache .. "/data/generated/" .. name .. ".lua") + return chunk and chunk() or nil +end + +local sprites = loadCache("sprites") +if not sprites then + check(true, "no GOLD_CACHE: the extracted rows are not checked (SKIP)") +else + local monRows, thin = 0, {} + for id, def in pairs(sprites) do + if type(def) == "table" and type(def.source) == "string" + and def.source:find("^ROM:SpriteMons") then + monRows = monRows + 1 + if (def.frames or 1) < 2 then thin[#thin + 1] = id end + end + end + check(monRows > 0, "the cache carries SpriteMons rows at all") + if #thin > 0 then + check(true, ("cache predates #1748 (%d thin rows, first %s) : World:load" + .. " back-fills them, a re-import writes them (SKIP)") + :format(#thin, thin[1])) + else + check(true, ("all %d SpriteMons rows are two frames deep"):format(monRows)) + end + + -- The objects the reporter was looking at: every $16 object on every map has + -- to name a sheet that the bounce can actually flip. + local maps = loadCache("maps") + if not maps then + check(true, "no maps.lua: the $16 objects are not checked (SKIP)") + else + local bouncers, slots, unresolved, oneFrame = 0, 0, {}, {} + for mapId, def in pairs(maps) do + if type(def) == "table" then + for _, obj in ipairs(def.objects or {}) do + if obj.movement == SPRITEMOVEDATA_POKEMON then + bouncers = bouncers + 1 + if type(obj.sprite) == "number" then + -- The day-care pair: GetMonSprite's .BreedMon arms resolve those + -- two bytes to the deposited species' icon at spawn time, so + -- there is no sprites.lua row for them to name. + slots = slots + 1 + elseif not sprites[obj.sprite] then + unresolved[#unresolved + 1] = mapId + elseif (sprites[obj.sprite].frames or 1) < 2 then + oneFrame[#oneFrame + 1] = mapId + end + end + end + end + end + check(bouncers > 0, ("the cache carries %d SPRITEMOVEDATA_POKEMON objects") + :format(bouncers)) + check(#unresolved == 0, "every named one of them names a sheet the cache has" + .. (unresolved[1] and (" (%s does not)"):format(unresolved[1]) or "")) + check(slots == 2, ("and %d of them are the two day-care slots"):format(slots)) + if #oneFrame > 0 then + check(true, ("%d of them sit on a pre-#1748 row : World:load back-fills" + .. " them (SKIP)"):format(#oneFrame)) + else + check(true, "and every one of those sheets is two frames deep") + end + end +end + +S.finish() diff --git a/tests/gen2_pack_rows_test.lua b/tests/gen2_pack_rows_test.lua new file mode 100644 index 00000000..1d812ba2 --- /dev/null +++ b/tests/gen2_pack_rows_test.lua @@ -0,0 +1,404 @@ +-- The PACK's list row and description box, as coordinates. +-- +-- Most of what these three bugs broke is an integer the ASM names outright: +-- TMHMPocket_GetCurrentLineCoord's column 5 and the `ld bc, 3` that puts the +-- move name at column 8 (engine/items/tmhm.asm:355-403, #1695), the 1x9 cursor +-- column at (7,2) whose palette carries the red (engine/gfx/cgb_layouts.asm: +-- 723-726, #1694), and TEXTBOX_INNERY for anything printed in the description +-- box (#1725). Chrome is swapped for a recorder here, so what is asserted is +-- the write list rather than the pixels -- whether the cursor comes out RED on +-- the real screen is tests/drivers/gold_pack_rows_bug1695_test.lua's job. + +package.path = "./?.lua;" .. package.path + +-- The UI modules require love-side helpers at load time. Stub the pieces they +-- touch during construction and logic; nothing here draws. +love = love or {} +love.graphics = love.graphics or { + getColor = function() return 1, 1, 1, 1 end, + setColor = function() end, + rectangle = function() end, + print = function() end, + printf = function() end, + draw = function() end, + newQuad = function() return {} end, + newImage = function() return nil end, + getShader = function() return nil end, + setShader = function() end, + newShader = function() error("no shaders in this harness") end, + getDimensions = function() return 160, 144 end, + push = function() end, pop = function() end, + translate = function() end, scale = function() end, + circle = function() end, clear = function() end, +} +love.math = love.math or { + random = function(a, b) + if b then return a end + return a and 1 or 0.5 + end, +} +love.image = love.image or {} +love.filesystem = love.filesystem or { + load = function() return nil end, + getInfo = function() return nil end, + read = function() return nil end, + write = function() return true end, + remove = function() return true end, +} +love.timer = love.timer or { getTime = function() return 0 end } + +-- No font is loaded here, so Font.encode would warn once per unknown glyph. +require("src.core.Logger").warn = function() end + +local Chrome = require("src.ui.gen2.Chrome") +local PackGfx = require("src.ui.gen2.PackGfx") +local PackMenu = require("src.ui.gen2.PackMenu") +local Save = require("src.core.gen2.Save") + +local failures, checks = 0, 0 +local function check(name, got, want) + checks = checks + 1 + if got ~= want then + failures = failures + 1 + print(("FAIL %s: got %s, want %s"):format( + name, tostring(got), tostring(want))) + end +end + +-- ---------------------------------------------------------------- fixtures + +local ITEMS = { + POTION = { id = "POTION", name = "POTION", pocket = "ITEM", index = 17, + canToss = true, canSelect = false, fieldMenu = "ITEMMENU_PARTY", + description = "Restores HPby 20." }, + ITEMFINDER = { id = "ITEMFINDER", name = "ITEMFINDER", pocket = "KEY_ITEM", + index = 55, canToss = false, canSelect = true, + fieldMenu = "ITEMMENU_CLOSE", + description = "Checks for unseenitems in the area." }, + TM_DYNAMICPUNCH = { id = "TM_DYNAMICPUNCH", name = "TM01", pocket = "TM_HM", + index = 191, tmNumber = 1, tmLabel = "TM01", teaches = "DYNAMICPUNCH" }, + TM_HEADBUTT = { id = "TM_HEADBUTT", name = "TM02", pocket = "TM_HM", + index = 192, tmNumber = 2, tmLabel = "TM02", teaches = "HEADBUTT" }, + TM_NIGHTMARE = { id = "TM_NIGHTMARE", name = "TM50", pocket = "TM_HM", + index = 240, tmNumber = 50, tmLabel = "TM50", teaches = "NIGHTMARE" }, + HM_CUT = { id = "HM_CUT", name = "HM01", pocket = "TM_HM", index = 243, + tmNumber = 51, tmLabel = "HM01", teaches = "CUT" }, + HM_WATERFALL = { id = "HM_WATERFALL", name = "HM07", pocket = "TM_HM", + index = 249, tmNumber = 57, tmLabel = "HM07", teaches = "WATERFALL" }, + -- A cache old enough to carry neither tmLabel nor a numbered name: the row + -- has no number to print and must fall back to the item's own name. + TM_LEGACY = { id = "TM_LEGACY", name = "TM??", pocket = "TM_HM", index = 300, + teaches = "SWIFT" }, +} + +local MOVES = {} +for _, name in ipairs({ "DYNAMICPUNCH", "HEADBUTT", "NIGHTMARE", "CUT", + "WATERFALL", "SWIFT" }) do + MOVES[name] = { id = name, name = name, description = "Movetext." } +end + +local function newInput() + local input = { pressed = {} } + function input:press(...) + for _, button in ipairs({ ... }) do self.pressed[button] = true end + end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input +end + +local function newGame(save) + return { + input = newInput(), + save = save, + options = save and save.options or Save.defaultOptions(), + data = { audio = {}, pokemon = {}, items = ITEMS, moves = MOVES }, + stack = { _items = {}, + push = function(self, s) self._items[#self._items + 1] = s end, + pop = function(self) return table.remove(self._items) end, + top = function(self) return self._items[#self._items] end, + }, + } +end + +-- ------------------------------------------------------------- the recorder +-- +-- PackMenu reaches Chrome through the module table on every call, so swapping +-- the four entry points it draws through collects the screen as a write list. + +local writes, cursors +local real = { + print = Chrome.print, cursor = Chrome.cursor, + cursorThrough = Chrome.cursorThrough, box = Chrome.box, clear = Chrome.clear, +} + +local function install() + writes, cursors = {}, {} + Chrome.print = function(text, tx, ty) + writes[#writes + 1] = { text = text, x = tx, y = ty } + end + Chrome.cursor = function(tx, ty, hollow) + cursors[#cursors + 1] = + { x = tx, y = ty, hollow = hollow or false, palette = nil } + end + Chrome.cursorThrough = function(tx, ty, palette, _invert, hollow) + cursors[#cursors + 1] = + { x = tx, y = ty, hollow = hollow or false, palette = palette } + end + Chrome.box = function() end + Chrome.clear = function() end +end + +local function restore() + Chrome.print, Chrome.cursor = real.print, real.cursor + Chrome.cursorThrough, Chrome.box, Chrome.clear = + real.cursorThrough, real.box, real.clear +end + +-- Where a string landed, as "x,y", so a miss reads as the coordinate it wanted. +local function at(text) + for _, write in ipairs(writes) do + if write.text == text then return write.x .. "," .. write.y end + end + return "absent" +end + +local function drew(text) + for _, write in ipairs(writes) do + if write.text == text then return true end + end + return false +end + +-- The cart's own PACK chrome, stubbed: PackGfx needs a real PNG to answer +-- available(), and none of that matters to where the writes land. +local RED = { { 255, 255, 255 }, { 123, 123, 255 }, { 0, 0, 255 }, { 255, 0, 0 } } +local function withPackGfx(pack) + pack.gfx = { + available = function() return true end, + draw = function() end, + colorsAt = function(_self, tx, ty) + if tx == 7 and ty >= 2 and ty <= 10 then return RED end + return nil + end, + } + return pack +end + +local function openPack(inventory, order, pocket) + local save = Save.newGame() + save.player.name = "GOLD" + save.inventory = inventory + save.bagOrder = order + local game = newGame(save) + local pack = PackMenu.new(game, { save = save, pocket = pocket }) + return withPackGfx(pack), save, game +end + +-- ------------------------------------------- the TM/HM row's number (#1695) +-- +-- TMHM_DisplayPocketItems writes the number itself: PRINTNUM_LEADINGZEROS on a +-- two-digit field for a TM, and the literal 'H' plus a PRINTNUM_LEFTALIGN +-- ordinal for an HM. The item's own name is never printed in this pocket. + +do + local pack = openPack({ + TM_DYNAMICPUNCH = 1, TM_HEADBUTT = 3, TM_NIGHTMARE = 24, + HM_CUT = 1, HM_WATERFALL = 1, TM_LEGACY = 1, + }, { + "HM_WATERFALL", "TM_NIGHTMARE", "HM_CUT", "TM_HEADBUTT", + "TM_DYNAMICPUNCH", "TM_LEGACY", + }, "TM_HM") + + check("TM01 prints as 01", pack.rows[1].tmhmLabel, "01") + check("TM02 prints as 02", pack.rows[2].tmhmLabel, "02") + check("TM50 keeps both digits", pack.rows[3].tmhmLabel, "50") + -- HM01 is TM/HM 51: PrintNum sees 51 - NUM_TMS = 1, left aligned. + check("HM01 prints as H1", pack.rows[4].tmhmLabel, "H1") + check("HM07 prints as H7", pack.rows[5].tmhmLabel, "H7") + check("a numberless TM row has no number to print", + pack.rows[6].tmhmLabel, nil) + + install() + pack.index = 1 + pack:drawPanel() + restore() + + -- hlcoord 5 for the number, `ld bc, 3` on to column 8 for the move name, and + -- the cursor gutter between them at LIST_X - 1. + check("the number sits at column 5", at("01"), "5,2") + check("the move name sits at column 8", at("DYNAMICPUNCH"), "8,2") + check("the item's own name is never printed", drew("TM01"), false) + check("nor is an invented HM prefix", drew("HM01"), false) + check("the cursor is one tile left of the name", cursors[1].x, 7) + check("and level with the row it marks", cursors[1].y, 2) + -- engine/items/tmhm.asm:392-403 -- SCREEN_WIDTH + 9 from the number's coord. + check("a TM's count is at column 17 on the row below", at("\xc3\x97 1"), "17,3") + check("the fourth row is the first HM", at("H1"), "5,8") + check("its move name shares that row", at("CUT"), "8,8") + check("and an HM prints no count", drew("\xc3\x97 1"), true) + + -- The fifth row is HM07, whose count would be the only other "× 1". + local ones = 0 + for _, write in ipairs(writes) do + if write.text == "\xc3\x97 1" then ones = ones + 1 end + end + check("exactly one count on screen, the TM's", ones, 1) +end + +-- A row with no number to print still has to draw something a person can read, +-- so the item's own name stands in rather than the row going blank. +do + local pack = openPack({ TM_LEGACY = 1 }, { "TM_LEGACY" }, "TM_HM") + install() + pack.index = 1 + pack:drawPanel() + restore() + check("a numberless row falls back to the item name", at("TM??"), "8,2") + check("and prints nothing in the number column", drew("SWIFT"), false) +end + +-- ------------------------------------------------- an ordinary pocket's row + +do + local pack = openPack({ POTION = 5 }, { "POTION" }, "ITEM") + install() + pack.index = 1 + pack:drawPanel() + restore() + check("an ITEM row starts at column 8", at("POTION"), "8,2") + check("with no number beside it", drew("01"), false) + check("and its count in the same column a TM's uses", + at("\xc3\x97 5"), "17,3") +end + +-- ------------------------------------------------ the cursor's palette (#1694) +-- +-- _CGB_PackPals fills (7,2) 1x9 with palette $3, whose colour 3 is red, and +-- that rectangle is exactly the five list rows' cursor gutter. + +do + local pack = openPack({ POTION = 5, ESCAPE_ROPE = 1 }, { "POTION" }, "ITEM") + install() + pack.index = 1 + pack:drawPanel() + restore() + check("the cursor asks the pack for its cell's palette", + cursors[1] and cursors[1].palette, RED) + local ink = cursors[1] and cursors[1].palette and cursors[1].palette[4] + check("which is the one whose colour 3 is red", + ink and table.concat(ink, ","), "255,0,0") + + -- SELECT arms a row: ScrollingMenu_PlaceCursor's hollow arrow takes the same + -- palette, so `hollow` has to survive the call. + pack.rows[2] = { id = "ESCAPE_ROPE", count = 1, name = "ESCAPE ROPE", + showCount = true } + pack.switching = 1 + pack.index = 2 + install() + pack:drawPanel() + restore() + local armed + for _, cursor in ipairs(cursors) do + if cursor.hollow then armed = cursor end + end + check("the armed row's hollow arrow is drawn", armed ~= nil, true) + check("in the cursor column", armed and armed.x, 7) + check("and through the same red palette", armed and armed.palette, RED) + + -- No pack tiles in the cache: the fallback must still draw a cursor rather + -- than nothing at all. + pack.gfx = { available = function() return false end, draw = function() end } + pack.switching = nil + pack.index = 1 + install() + pack:drawPanel() + restore() + check("a cache with no pack tiles falls back to the plain arrow", + cursors[1] and cursors[1].palette, nil) +end + +-- PackGfx flattens paletteZones to a per-cell lookup; the cursor zone is the +-- one PackMenu reads, so check the rectangle resolves over its whole height. +do + local pals = { { { 1, 1, 1 } }, { { 2, 2, 2 } }, { { 3, 3, 3 } }, RED } + local gfx = PackGfx.new({ pack = { + palettes = pals, + paletteZones = { { 0, 0, 10, 1, 2 }, { 7, 2, 1, 9, 4 } }, + } }) + check("the cursor column starts at row 2", gfx:colorsAt(7, 2), RED) + check("and runs nine rows down to row 10", gfx:colorsAt(7, 10), RED) + check("row 11 is outside it", gfx:colorsAt(7, 11), pals[1]) + check("so is the name column", gfx:colorsAt(8, 2), pals[1]) + check("the header keeps its own zone", gfx:colorsAt(0, 0), pals[2]) +end + +-- ------------------------------------------ the description box's rows (#1725) +-- +-- Every text box in the game starts at TEXTBOX_INNERY and steps two rows a +-- line. The item description already did; a message printed over the same box +-- did not. + +do + local pack = openPack({ ITEMFINDER = 1 }, { "ITEMFINDER" }, "KEY_ITEM") + + install() + pack.index = 1 + pack:drawPanel() + restore() + check("an item description starts on row 14", + at("Checks for unseen"), "1,14") + check("and its second line is row 16", at("items in the area."), "1,16") + + -- RegisteredItemText, the case the report filed. + pack.message = { "Registered the", "ITEMFINDER." } + install() + pack:drawPanel() + restore() + check("a message's first line shares the description's row", + at("Registered the"), "1,14") + check("and its second line lands on row 16", at("ITEMFINDER."), "1,16") + + -- _AskThrowAwayText, the other two-line message in this box. + pack.message = { "Throw away how", "many?" } + install() + pack:drawPanel() + restore() + check("the toss question starts on row 14", at("Throw away how"), "1,14") + check("with a blank row under it", at("many?"), "1,16") + + -- OakThisIsntTheTimeText is three lines, which two double-spaced rows cannot + -- hold: those pack one row apart so the last is inside the box. + pack.message = { "OAK: {PLAYER}!", "This isn't the", "time to use that!" } + install() + pack:drawPanel() + restore() + check("a three-line message starts a row higher", at("OAK: GOLD!"), "1,13") + check("its second line is row 14", at("This isn't the"), "1,14") + check("and its third clears the bottom border", + at("time to use that!"), "1,15") + + -- The yes/no prompt is printed in the same box by the same path. + pack.message = nil + pack.confirm = { prompt = { "Throw away 1", "POTION(S)?" }, choice = 1 } + install() + pack:drawPanel() + restore() + check("the toss confirmation uses the same two rows", + at("Throw away 1"), "1,14") + check("and the same gap", at("POTION(S)?"), "1,16") +end + +print(("gen2 pack rows: %d checks, %d failures"):format(checks, failures)) +-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an +-- exit here takes the whole tier down with it and silently skips every +-- suite listed after this one (see tests/harness.lua's T.suite note). +if failures > 0 then + error(("%d assertion(s) failed"):format(failures), 0) +end diff --git a/tests/gen2_sleep_counter_test.lua b/tests/gen2_sleep_counter_test.lua new file mode 100644 index 00000000..20cc97d6 --- /dev/null +++ b/tests/gen2_sleep_counter_test.lua @@ -0,0 +1,213 @@ +-- The counter a sleep MOVE writes: BattleCommand_SleepTarget's .random_loop +-- (engine/battle/effect_commands.asm:3591-3598), which masks with SLP_MASK, +-- rerolls 0 and SLP_MASK and only then does `inc a` -- so 2-7, never 1 (#1707). +-- Rest and the disobedience nap keep their own counters and are the controls. +-- +-- luajit tests/gen2_sleep_counter_test.lua -- ROM-free + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 sleep counter") +local check, eq = S.check, S.eq + +local Battle = require("src.battle.gen2.Battle") +local Mon = require("src.battle.gen2.Mon") + +-- ---------------------------------------------------------------- fixtures + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + FLYING = { id = "FLYING", index = 2, category = "physical" }, + FIRE = { id = "FIRE", index = 20, category = "special" }, + GRASS = { id = "GRASS", index = 22, category = "special" }, + PSYCHIC = { id = "PSYCHIC", index = 24, category = "special" }, +} + +local MATCHUPS = {} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + -- data/moves/moves.asm: SPORE and HYPNOSIS are both EFFECT_SLEEP. + SPORE = { id = "SPORE", name = "SPORE", power = 0, type = "GRASS", + accuracy = 100, pp = 15, effect = "EFFECT_SLEEP" }, + HYPNOSIS = { id = "HYPNOSIS", name = "HYPNOSIS", power = 0, + type = "PSYCHIC", accuracy = 100, pp = 20, effect = "EFFECT_SLEEP" }, + REST = { id = "REST", name = "REST", power = 0, type = "PSYCHIC", + accuracy = 100, pp = 10, effect = "EFFECT_HEAL" }, +} + +local GROWTH = { + GROWTH_MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15, + linear = 100, constant = 140 }, +} + +local POKEMON = { + growthRates = GROWTH, + -- Fast enough to move first against the PIDGEY below at these levels. + CYNDAQUIL = { + id = "CYNDAQUIL", index = 155, name = "CYNDAQUIL", + baseStats = { hp = 39, attack = 52, defense = 43, speed = 65, + specialAttack = 60, specialDefense = 50 }, + types = { "FIRE", "FIRE" }, catchRate = 45, baseExp = 65, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + PIDGEY = { + id = "PIDGEY", index = 16, name = "PIDGEY", + baseStats = { hp = 40, attack = 45, defense = 40, speed = 56, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "FLYING" }, catchRate = 255, baseExp = 55, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = MATCHUPS }, + items = {}, +} + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +-- BattleRandom(n) -> 0..n-1, pinned to one value. +local function zeroRandom() return 0 end +local function fixedRandom(value) + return function(n) return math.min(value, (n or 1) - 1) end +end + +local function newBattle(opts) + opts = opts or {} + local player = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + player.moves = { + { id = "SPORE", pp = 15, maxPp = 15 }, + { id = "TACKLE", pp = 35, maxPp = 35 }, + } + local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local battle = Battle.new({ + data = DATA, + party = { player }, + wild = wild, + random = opts.random or zeroRandom, + }) + battle:takeEvents() + return battle, player, wild +end + +local function texts(events) + local out = {} + for _, event in ipairs(events or {}) do + if event.text then out[#out + 1] = event.text end + end + return out +end + +local function found(events, needle) + for _, text in ipairs(texts(events)) do + if text:find(needle, 1, true) then return true end + end + return false +end + +-- ------------------------------------------------------- the roll's range + +-- The whole roll space against .random_loop's window: 2..7 inclusive, six +-- outcomes, no 1 and no 8 (effect_commands.asm:3591-3598). +do + local seen, low, high = {}, math.huge, -math.huge + for roll = 0, 7 do + local battle, _, wild = newBattle({ random = fixedRandom(roll) }) + battle:useMove(battle.player, wild, "SPORE") + eq(wild.status, "sleep", "roll " .. roll .. " slept the target") + local turns = wild.statusTurns or 0 + seen[turns] = true + low, high = math.min(low, turns), math.max(high, turns) + end + eq(low, 2, "the shortest sleep a sleep move can roll is two turns") + eq(high, 7, "the longest is seven, SLP_MASK itself never being written") + check(not seen[1], "one turn is unreachable, so no free-action sleep") + check(not seen[0], "and zero is unreachable") + local count = 0 + for _ in pairs(seen) do count = count + 1 end + eq(count, 6, "six distinct counters, the width of the cart's window") +end + +do + local battle, _, wild = newBattle() + battle:useMove(battle.player, wild, "SPORE") + eq(wild.statusTurns, 2, "the lowest roll writes two, not one") +end + +-- ------------------------------------------- slept by a faster foe (#1707) + +-- CheckPlayerTurn decrements before the sleeper acts, so the round the sleep +-- landed in is the one a 1 would have cost nothing. +do + local battle, player, wild = newBattle() + local hpBefore = player.hp + local events = battle:takeTurn({ kind = "move", move = "SPORE" }) + eq(wild.status, "sleep", "the slower foe is asleep") + check(found(events, "is fast asleep!"), + "and spends the same round's action asleep") + check(not found(events, "woke up!"), + "it cannot wake in the round it was slept") + eq(wild.statusTurns, 1, "one turn of the counter was spent") + eq(player.hp, hpBefore, "so the sleeper landed no attack of its own") +end + +-- The counter still runs out on the next round. +do + local battle, _, wild = newBattle() + battle:takeTurn({ kind = "move", move = "SPORE" }) + local events = battle:takeTurn({ kind = "move", move = "TACKLE" }) + check(found(events, "woke up!"), "the next round wakes it") + eq(wild.status, nil, "and clears the status byte") + eq(wild.statusTurns, nil, "and the counter with it") +end + +-- Hypnosis shares EFFECT_SLEEP, so it shares the window. +do + local battle, _, wild = newBattle() + battle.player.moves[1] = { id = "HYPNOSIS", pp = 20, maxPp = 20 } + battle:useMove(battle.player, wild, "HYPNOSIS") + eq(wild.status, "sleep", "hypnosis slept the target") + eq(wild.statusTurns, 2, "off the same .random_loop") +end + +-- ------------------------------------------------------------- the controls + +-- Rest writes REST_SLEEP_TURNS + 1 straight into the status byte, no roll +-- (effect_commands.asm:6015-6027). +do + local battle, player, wild = newBattle() + player.moves = { { id = "REST", pp = 10, maxPp = 10 }, + { id = "TACKLE", pp = 35, maxPp = 35 } } + player.hp = 1 + battle:useMove(player, wild, "REST") + eq(player.status, "sleep", "rest slept the user") + eq(player.statusTurns, 3, "rest is still exactly three") + eq(player.hp, player.maxHp, "and still a full heal") + + local events = battle:takeTurn({ kind = "move", move = "TACKLE" }) + check(found(events, "is fast asleep!"), "the first turn after rest is lost") + events = battle:takeTurn({ kind = "move", move = "TACKLE" }) + check(found(events, "is fast asleep!"), "so is the second") + events = battle:takeTurn({ kind = "move", move = "TACKLE" }) + check(found(events, "woke up!"), "and it acts on the third") +end + +-- The disobedience nap rerolls only 0 and has no `inc a`, so 1 is legal +-- there (effect_commands.asm:778-785) and the two rolls are not shared. +do + local record = Battle.STATUSES.sleep + check(type(record.onInflict) == "function", "sleep still has an onInflict") + local mon = {} + record.onInflict({ random = zeroRandom }, mon) + eq(mon.statusTurns, 2, "the status record's own floor is two") +end + +S.finish() diff --git a/tests/gen2_sprites_test.lua b/tests/gen2_sprites_test.lua index 78c77ab0..6fecfc95 100644 --- a/tests/gen2_sprites_test.lua +++ b/tests/gen2_sprites_test.lua @@ -172,10 +172,13 @@ else "which ReadMonMenuIcon turns into PIKACHU's menu icon") eq(doll.image, "assets/generated/icons/gen2/pikachu.png", "so it draws from the icon sheet extractIcons already writes") - -- FacingStepDown0 is the only OAM set a still mon object ever uses, and it - -- is tiles $00..$03: the icon's first frame. The second is the party - -- menu's bob and never reaches the map. - eq(doll.frames, 1, "one frame: _DoesSpriteHaveFacings sends a mon down-only") + -- OBJECT_ACTION_BOUNCE swaps FacingStepDown0's tiles $00..$03 for + -- FacingStepUp0's $04..$07, so both icon frames reach the map (#1748). + if doll.frames == 1 then + check(true, "cache predates #1748 : re-import for the mon bounce (SKIP)") + else + eq(doll.frames, 2, "two frames: SetFacingBounce swaps the icon's pair") + end check(not doll.walker, "and a doll does not walk") eq(doll.paletteId, 0, "_GetSpritePalette answers 0 for every mon sprite") eq(doll.palette, "PAL_OW_RED", "which is PAL_OW_RED in the MapObjectPals set") diff --git a/tests/gen2_timed_heal_test.lua b/tests/gen2_timed_heal_test.lua new file mode 100644 index 00000000..361b8fc8 --- /dev/null +++ b/tests/gen2_timed_heal_test.lua @@ -0,0 +1,315 @@ +-- Morning Sun / Synthesis / Moonlight: BattleCommand_TimeBasedHealContinue +-- (engine/battle/effect_commands.asm:6374) walks a four-rung .Multipliers +-- table from the half rung -- the wrong wTimeOfDay steps down, sun up, rain +-- and sandstorm down (#1751). World:startBattle is where the hour comes from. +-- +-- luajit tests/gen2_timed_heal_test.lua -- ROM-free + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 timed heal") +local check, eq = S.check, S.eq + +local Battle = require("src.battle.gen2.Battle") +local Effects = require("src.battle.gen2.Effects") +local Mon = require("src.battle.gen2.Mon") + +-- constants/ram_constants.asm:137-140, wTimeOfDay: MORN_F 0, DAY_F 1, NITE_F 2, +-- DARKNESS_F 3. +local MORN, DAY, NITE, DARK = 0, 1, 2, 3 + +-- ---------------------------------------------------------------- fixtures + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + FLYING = { id = "FLYING", index = 2, category = "physical" }, + GRASS = { id = "GRASS", index = 22, category = "special" }, + WATER = { id = "WATER", index = 21, category = "special" }, +} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + -- data/moves/moves.asm: three 5 PP status moves that differ only in effect. + SYNTHESIS = { id = "SYNTHESIS", name = "SYNTHESIS", power = 0, + type = "GRASS", accuracy = 100, pp = 5, effect = "EFFECT_SYNTHESIS" }, + MORNING_SUN = { id = "MORNING_SUN", name = "MORNING SUN", power = 0, + type = "NORMAL", accuracy = 100, pp = 5, effect = "EFFECT_MORNING_SUN" }, + MOONLIGHT = { id = "MOONLIGHT", name = "MOONLIGHT", power = 0, + type = "NORMAL", accuracy = 100, pp = 5, effect = "EFFECT_MOONLIGHT" }, + SUNNY_DAY = { id = "SUNNY_DAY", name = "SUNNY DAY", power = 0, + type = "FIRE", accuracy = 100, pp = 5, effect = "EFFECT_SUNNY_DAY" }, + RAIN_DANCE = { id = "RAIN_DANCE", name = "RAIN DANCE", power = 0, + type = "WATER", accuracy = 100, pp = 5, effect = "EFFECT_RAIN_DANCE" }, +} + +local GROWTH = { + GROWTH_MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15, + linear = 100, constant = 140 }, +} + +local POKEMON = { + growthRates = GROWTH, + CHIKORITA = { + id = "CHIKORITA", index = 152, name = "CHIKORITA", + baseStats = { hp = 45, attack = 49, defense = 65, speed = 45, + specialAttack = 49, specialDefense = 65 }, + types = { "GRASS", "GRASS" }, catchRate = 45, baseExp = 64, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + PIDGEY = { + id = "PIDGEY", index = 16, name = "PIDGEY", + baseStats = { hp = 40, attack = 45, defense = 40, speed = 56, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "FLYING" }, catchRate = 255, baseExp = 55, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, + items = {}, +} + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +local function zeroRandom() return 0 end + +local function newBattle(opts) + opts = opts or {} + local player = Mon.new(DATA, "CHIKORITA", 20, { dvs = perfect }) + player.moves = { + { id = "SYNTHESIS", pp = 5, maxPp = 5 }, + { id = "MORNING_SUN", pp = 5, maxPp = 5 }, + { id = "MOONLIGHT", pp = 5, maxPp = 5 }, + { id = "SUNNY_DAY", pp = 5, maxPp = 5 }, + } + local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local battle = Battle.new({ + data = DATA, + party = { player }, + wild = wild, + random = zeroRandom, + timeOfDay = opts.timeOfDay, + }) + battle:takeEvents() + return battle, player, wild +end + +-- ------------------------------------------------------------- the ladder + +local frac = Effects.timeBasedHealFraction +check(type(frac) == "function", + "Effects models the whole command, not just the weather half") + +-- .Multipliers itself (effect_commands.asm:6450-6454). +local RUNGS = { 1 / 8, 1 / 4, 1 / 2, 1 } +eq(Effects.HEAL_MULTIPLIERS[1], RUNGS[1], "rung 0 is an eighth") +eq(Effects.HEAL_MULTIPLIERS[2], RUNGS[2], "rung 1 is a quarter") +eq(Effects.HEAL_MULTIPLIERS[3], RUNGS[3], "rung 2 is a half, the default") +eq(Effects.HEAL_MULTIPLIERS[4], RUNGS[4], "rung 3 is the whole bar") + +-- The `ld b, MORN_F / DAY_F / NITE_F` each of the three entry points loads +-- (effect_commands.asm:6362-6371). +eq(Effects.SUN_HEAL.EFFECT_MORNING_SUN, MORN, "Morning Sun wants MORN") +eq(Effects.SUN_HEAL.EFFECT_SYNTHESIS, DAY, "Synthesis wants DAY") +eq(Effects.SUN_HEAL.EFFECT_MOONLIGHT, NITE, "Moonlight wants NITE") + +-- The six cells that matter, read as Synthesis: its own hour on the top row, +-- any other hour on the bottom. +eq(frac(nil, DAY, DAY), 1 / 2, "right hour, clear sky: a half") +eq(frac("sun", DAY, DAY), 1, "right hour in sun: the whole bar") +eq(frac("rain", DAY, DAY), 1 / 4, "right hour in rain: a quarter") +eq(frac(nil, DAY, NITE), 1 / 4, "wrong hour, clear sky: a quarter") +eq(frac("sun", DAY, NITE), 1 / 2, "wrong hour in sun: a half") +eq(frac("rain", DAY, NITE), 1 / 8, "wrong hour in rain: an eighth") + +-- Sandstorm takes the same `dec c / dec c` arm rain does. +eq(frac("sandstorm", DAY, DAY), 1 / 4, "sandstorm is rain's rung") +eq(frac("sandstorm", DAY, NITE), 1 / 8, "at either hour") + +-- Every hour that is not the move's own is the wrong one, DARKNESS included. +eq(frac(nil, DAY, MORN), 1 / 4, "morning is the wrong hour for Synthesis") +eq(frac(nil, DAY, DARK), 1 / 4, "and so is DARKNESS") +eq(frac(nil, MORN, MORN), 1 / 2, "Morning Sun at dawn is the top row") +eq(frac(nil, MORN, DAY), 1 / 4, "Morning Sun in the afternoon is not") +eq(frac(nil, NITE, NITE), 1 / 2, "Moonlight at night is the top row") +eq(frac(nil, NITE, DAY), 1 / 4, "Moonlight at noon is not") + +-- wTimeOfDay by name, the spelling World keeps. +eq(frac(nil, DAY, "DAY"), 1 / 2, "the DAY spelling reads as DAY_F") +eq(frac(nil, DAY, "NITE"), 1 / 4, "and NITE as NITE_F") +eq(frac(nil, NITE, "NITE"), 1 / 2, "Moonlight likewise") + +-- `ld a, [wLinkMode] / and a / jr nz, .Weather` (effect_commands.asm:6396-6399): +-- a battle with no clock skips the time term rather than failing the hour. +eq(frac(nil, DAY, nil), 1 / 2, "no clock, no time term") +eq(frac("sun", DAY, nil), 1, "the weather half still walks") + +-- Gen 3's 2/3 is not on this ladder anywhere. +do + local offLadder = {} + for _, weather in ipairs({ "none", "sun", "rain", "sandstorm" }) do + for _, wants in ipairs({ MORN, DAY, NITE }) do + for hour = MORN, DARK do + local got = frac(weather ~= "none" and weather or nil, wants, hour) + local onLadder = false + for _, rung in ipairs(RUNGS) do + if got == rung then onLadder = true end + end + if not onLadder then + offLadder[#offLadder + 1] = ("%s/%d/%d=%s") + :format(weather, wants, hour, tostring(got)) + end + end + end + end + eq(#offLadder, 0, + "every cell lands on a .Multipliers rung: " .. table.concat(offLadder, " ")) +end +check(Effects.weatherHealFraction == nil + or math.abs(Effects.weatherHealFraction("sun") - 2 / 3) > 0.0001, + "and nothing still answers Gen 3's two thirds in sun") + +-- ------------------------------------------------------- through a battle + +-- wTimeOfDay reaches the handler (effect_commands.asm:6401-6404). +do + local battle = newBattle({ timeOfDay = NITE }) + eq(battle.timeOfDay, NITE, "Battle.new carries wTimeOfDay") +end + +local function healedBy(move, timeOfDay, weather) + local battle, player = newBattle({ timeOfDay = timeOfDay }) + battle.weather = weather + battle.weatherTurns = weather and 5 or nil + player.hp = math.floor(player.maxHp / 2) + local before = player.hp + battle:useMove(player, battle.enemy, move) + return player.hp - before, player.maxHp, battle:takeEvents() +end + +do + local healed, maxHp = healedBy("SYNTHESIS", NITE) + eq(healed, math.floor(maxHp / 4), "Synthesis at night restores a quarter") + check(healed ~= math.floor(maxHp / 2), + "which is not the flat half the unfixed move gave") +end + +do + local healed, maxHp, events = healedBy("SYNTHESIS", DAY) + eq(healed, math.floor(maxHp / 2), "Synthesis in the day restores a half") + local said = false + for _, event in ipairs(events) do + if event.text and event.text:find("regained health!", 1, true) then + said = true + end + end + check(said, "and still says so") +end + +-- Sun on the top rung is GetMaxHP, not a bigger fraction, so the bar fills +-- from wherever it started rather than from half. +do + local battle, player = newBattle({ timeOfDay = DAY }) + battle.weather, battle.weatherTurns = "sun", 5 + player.hp = 1 + battle:useMove(player, battle.enemy, "SYNTHESIS") + eq(player.hp, player.maxHp, + "Synthesis in the day under Sunny Day fills the bar from 1 HP") + + battle, player = newBattle({ timeOfDay = DAY }) + battle.weather, battle.weatherTurns = "sun", 5 + player.hp = math.floor(player.maxHp / 2) + battle:useMove(player, battle.enemy, "SYNTHESIS") + eq(player.hp, player.maxHp, "and from half") +end + +do + local healed, maxHp = healedBy("SYNTHESIS", NITE, "rain") + eq(healed, math.floor(maxHp / 8), "Synthesis at night in rain is an eighth") +end + +do + local healed, maxHp = healedBy("MOONLIGHT", NITE) + eq(healed, math.floor(maxHp / 2), "Moonlight at night restores a half") + healed = healedBy("MOONLIGHT", DAY) + eq(healed, math.floor(maxHp / 4), "and a quarter at noon") + healed = healedBy("MORNING_SUN", MORN) + eq(healed, math.floor(maxHp / 2), "Morning Sun at dawn restores a half") + healed = healedBy("MORNING_SUN", NITE) + eq(healed, math.floor(maxHp / 4), "and a quarter at night") +end + +-- At NITE only Moonlight is on its top rung. +do + local night = {} + for _, move in ipairs({ "MORNING_SUN", "SYNTHESIS", "MOONLIGHT" }) do + night[move] = healedBy(move, NITE) + end + check(night.MOONLIGHT > night.SYNTHESIS, + "Moonlight beats Synthesis at night") + eq(night.MORNING_SUN, night.SYNTHESIS, + "and the two daytime moves tie below it") +end + +-- effect_commands.asm:6443-6448: a full-HP user gets HPIsFullText and no heal, +-- whatever the clock says. +do + local battle, player = newBattle({ timeOfDay = NITE }) + battle:useMove(player, battle.enemy, "SYNTHESIS") + eq(player.hp, player.maxHp, "a full-HP user is not healed") + local said = false + for _, event in ipairs(battle:takeEvents()) do + if event.text and event.text:find("HP is full", 1, true) then said = true end + end + check(said, "and hears HPIsFullText instead") +end + +-- ------------------------------------------------- the overworld's clock + +-- World:timeOfDayId is wTimeOfDay off the RTC hour +-- (engine/tilesets/timeofday_pals.asm:5-11), and startBattle hands it over. +do + local World = require("src.world.gen2.World") + local Screens = require("src.ui.Screens") + + local pushed + local realPush = Screens.push + Screens.push = function(_, id, opts) + if id == "Gen2BattleState" then pushed = opts end + end + + local ok, err = pcall(function() + for _, hour in ipairs({ { "MORN", MORN }, { "DAY", DAY }, + { "NITE", NITE }, { "DARK", DARK } }) do + local mon = Mon.new(DATA, "CHIKORITA", 20, { dvs = perfect }) + local game = { + data = DATA, + save = { version = "gold", player = { name = "GOLD", id = 1234 }, + party = { mon }, inventory = {}, boxes = {} }, + } + game.stack = { pop = function() end } + local world = World.new(game) + world.tod = hour[1] + -- No map, so DoBattleTransition has nothing to wipe and the battle + -- screen comes straight in (World:pushBattleTransition). + world.map = nil + pushed = nil + world:startBattle({ wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) }) + eq(pushed and pushed.battle and pushed.battle.timeOfDay, hour[2], + "a battle started at " .. hour[1] .. " carries that wTimeOfDay") + end + end) + + Screens.push = realPush + check(ok, "the World seam ran: " .. tostring(err)) +end + +S.finish() diff --git a/tests/gen2_vm_test.lua b/tests/gen2_vm_test.lua index b1b92761..79ffc201 100644 --- a/tests/gen2_vm_test.lua +++ b/tests/gen2_vm_test.lua @@ -1768,8 +1768,10 @@ end -- Shuckle you caught yourself is the thing the routine refuses. do local list = {} + local record = { party = list } local vm = specialVm(0, { order = { "x" }, hooks = { party = function() return list end, + save = function() return record end, data = function() return { pokemon = { SHUCKLE = { name = "SHUCKLE", index = 213, growthRate = "MEDIUM_FAST", @@ -1785,6 +1787,9 @@ do eq(list[1].otId, Specials.MANIA_OT_ID, "with MANIA's trainer ID") eq(list[1].item, "BERRY", "holding a BERRY") eq(list[1].level, 15, "at level 15") + local dex = record.pokedex or {} + eq((dex.seen or {}).SHUCKLE, true, "seen in the #DEX") + eq((dex.caught or {}).SHUCKLE, true, "and caught in the #DEX") local hooks2 = { party = function() return list end, selectPartyMon = function(_, done) done(1, list[1]) end } diff --git a/tests/gen2_whirlpool_test.lua b/tests/gen2_whirlpool_test.lua new file mode 100644 index 00000000..40dd4e76 --- /dev/null +++ b/tests/gen2_whirlpool_test.lua @@ -0,0 +1,229 @@ +-- Whirlpool tiles force-turn the player: DoPlayerMovement's .CheckTile, the +-- CheckWhirlpoolTile arm that sits ABOVE the nybble ladder. +-- +-- luajit tests/gen2_whirlpool_test.lua +-- +-- COLL_WHIRLPOOL is tested first and takes PLAYERMOVEMENT_FORCE_TURN +-- (engine/overworld/player_movement.asm:117-123), which runs Script_ForcedMovement +-- (engine/events/forced_movement.asm:1-51): step_dig 16, turn_in , +-- step_dig 16, turn_head , step_end. So a whirlpool is entered, whirled +-- on, and left again the way the player came, with the d-pad ignored throughout. +-- +-- The port had no arm at all -- only the HI_NYBBLE_CURRENT and HI_NYBBLE_WARPS +-- ones -- so a whirlpool cell was plain water and a surfer swam straight over +-- it, past the HM06 + GLACIERBADGE gate on Route 41 and Route 27. Two decoding +-- gaps sat under that: turn_away / turn_in / turn_waterfall all `jp TurningStep` +-- (engine/overworld/movement.asm:483-513), which is a real one-cell STEP under +-- OBJECT_ACTION_SPIN, and $4f step_dig was not modelled at all. #1716 +-- +-- The whirl itself is drawing, so what is asserted here is the state the +-- drawing reads (Player.spinFrames) and the grid the player ends on. +-- tests/drivers/gold_whirlpool_forceturn_bug1716_test.lua is the other half. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 whirlpool") +local check, eq, same = S.check, S.eq, S.same + +local Movement = require("src.script.gen2.Movement") +local Permissions = require("src.world.gen2.Permissions") +local World = require("src.world.gen2.World") +local Player = require("src.world.gen2.Player") +local FieldMoves = require("src.world.gen2.FieldMoves") + +-- ---- the tile -------------------------------------------------------------- +-- +-- CheckWhirlpoolTile is the two-entry compare COLL_WHIRLPOOL $24 / COLL_WHIRLPOOL_1 $2c. +local COLL_WATER, COLL_WHIRLPOOL, COLL_WHIRLPOOL_1 = 0x29, 0x24, 0x2c + +check(Permissions.isWhirlpool(COLL_WHIRLPOOL), "COLL_WHIRLPOOL $24") +check(Permissions.isWhirlpool(COLL_WHIRLPOOL_1), "COLL_WHIRLPOOL_1 $2c") +check(not Permissions.isWhirlpool(COLL_WATER), "plain water is not one") +check(not Permissions.isWhirlpool(0x33), "and neither is a waterfall") +-- The arm is above the nybble ladder, not part of it: the tile's PERMISSION is +-- still WATER_TILE, which is what lets a surfing player step onto it at all. +check(Permissions.isWater(COLL_WHIRLPOOL), + "a whirlpool is still WATER_TILE, so .CheckSurfable lets the step happen") +eq(Permissions.currentDirection(COLL_WHIRLPOOL), nil, + "and it is not a HI_NYBBLE_CURRENT tile") + +-- ---- Script_ForcedMovement's stream ---------------------------------------- +-- +-- .MovementData_up is `step_dig 16 / turn_in DOWN / step_dig 16 / +-- turn_head DOWN / step_end`, i.e. every byte points BACK the way the player +-- came. turn_head is the $00 family, turn_in the $24 one, step_dig $4f with +-- its frame count in the byte after it (macros/scripts/movement.asm:163-167). +eq(Movement.STEP_DIG, 0x4f, "$4f step_dig") +eq(Movement.STEP_DIG_FRAMES, 16, "and the 16 frames the stream asks it for") +same(Movement.forcedMovementBytes("down"), + { 0x4f, 16, 0x24, 0x4f, 16, 0x00, 0x47 }, + "thrown DOWN: step_dig 16, turn_in DOWN, step_dig 16, turn_head DOWN, end") +same(Movement.forcedMovementBytes("up"), + { 0x4f, 16, 0x25, 0x4f, 16, 0x01, 0x47 }, "thrown UP") +same(Movement.forcedMovementBytes("left"), + { 0x4f, 16, 0x26, 0x4f, 16, 0x02, 0x47 }, "thrown LEFT") +same(Movement.forcedMovementBytes("right"), + { 0x4f, 16, 0x27, 0x4f, 16, 0x03, 0x47 }, "thrown RIGHT") + +-- The middle byte is the one the port used to read as a facing change, which +-- is why the bounce could not exist: turn_in never moved anybody. +for byte, dir in pairs({ [0x24] = "down", [0x25] = "up", + [0x26] = "left", [0x27] = "right" }) do + local act = Movement.decodeByte(byte) + eq(act.kind, "step", ("$%02x turn_in crosses a cell"):format(byte)) + eq(act.dir, dir, ("$%02x turn_in direction"):format(byte)) + eq(act.spin, true, ("$%02x turn_in spins on the way"):format(byte)) +end +eq(Movement.decodeByte(0x20).kind, "step", "$20 turn_away is a step too") +eq(Movement.decodeByte(0x28).kind, "step", "$28 turn_waterfall as well") +-- The neighbours must not have picked the spin up. +eq(Movement.decodeByte(0x0c).spin, nil, "$0c step is a plain step") +eq(Movement.decodeByte(0x10).spin, nil, "$10 big_step too") +eq(Movement.decodeByte(0x00).kind, "turn", "$00 turn_head is still a turn") + +-- ---- the step -------------------------------------------------------------- +-- +-- One whirlpool at (5,5) in an open pond, the way Route 41's four sit: water on +-- every side of them (data/generated/maps.lua, ROUTE_41 (22,12)). +local WHIRL_X, WHIRL_Y = 5, 5 + +local function whirlWorld(px, py, facing, coll) + local game = { + data = { items = {}, moves = {}, pokemon = {} }, + save = { player = { name = "GOLD", badges = {} }, party = {}, + inventory = {} }, + } + local world = World.new(game) + game.world = world + local cells = {} + for y = 0, 9 do + for x = 0, 9 do cells[y * 100 + x] = COLL_WATER end + end + cells[WHIRL_Y * 100 + WHIRL_X] = coll or COLL_WHIRLPOOL + local map + map = { + id = "ROUTE_41", + width = 5, height = 5, + def = { objects = {}, bgEvents = {}, environment = "WATER", + tileset = "TILESET_JOHTO", width = 5, height = 5 }, + cellCollision = function(_, x, y) + return cells[y * 100 + x] or COLL_WATER + end, + inBounds = function(_, x, y) + return x >= 0 and y >= 0 and x < 10 and y < 10 + end, + isWalkable = function(_, x, y) + return Permissions.isWalkable(map:cellCollision(x, y)) + end, + warpAt = function() return nil end, + connection = function() return nil end, + } + world.map = map + world.maps = { ROUTE_41 = map.def } + world.player = Player.new(px, py, facing) + world.entities = { world.player } + world.npcs = {} + world.playerState = FieldMoves.PLAYER_SURF + world.encounters = {} + world.pollTimeOfDay = function() end + -- Nothing here draws, and nothing here is about the wild roll. + world.noWildEncounters = true + world.updatePeople = function() end + return world, game +end + +-- Swim at the whirlpool from `dir` for `frames` with the d-pad held the whole +-- way, the way a player leaning on it does, and report what happened. +local function swimInto(dir, frames) + local DELTA = { up = { 0, -1 }, down = { 0, 1 }, + left = { -1, 0 }, right = { 1, 0 } } + local d = DELTA[dir] + -- Two cells short of the whirlpool, so the approach is an ordinary swim. + local world = whirlWorld(WHIRL_X - d[1] * 2, WHIRL_Y - d[2] * 2, dir) + local p = world.player + local r = { onWhirlpool = false, spun = false, busy = false, past = false } + for _ = 1, frames or 220 do + world.heldDir = dir + world:step() + if p.cellX == WHIRL_X and p.cellY == WHIRL_Y then r.onWhirlpool = true end + if p.spinFrames then r.spun = true end + if world:busy() then r.busy = true end + -- One cell past the whirlpool, on the far side. + if p.cellX == WHIRL_X + d[1] and p.cellY == WHIRL_Y + d[2] then + r.past = true + end + end + r.x, r.y, r.facing = p.cellX, p.cellY, p.facing + return r, world +end + +-- The cart lets the player land on the whirlpool: FORCE_TURN is answered from +-- the tile UNDERFOOT, so the step onto it happens and the bounce follows. +local BACK = { up = "down", down = "up", left = "right", right = "left" } +for _, dir in ipairs({ "up", "down", "left", "right" }) do + local r = swimInto(dir) + check(r.onWhirlpool, + ("swimming %s reaches the whirlpool cell"):format(dir)) + check(not r.past, + ("holding %s never carries the player through it"):format(dir)) + check(r.busy, + ("the forced movement owns the world while it runs (%s)"):format(dir)) + check(r.spun, + ("step_dig put the player under OBJECT_ACTION_SPIN (%s)"):format(dir)) + eq(r.facing, BACK[dir], + ("turn_head leaves the player facing away from it (%s)"):format(dir)) +end + +-- COLL_WHIRLPOOL_1 is the same arm, not a second tile that fell through. +do + local world = whirlWorld(WHIRL_X, WHIRL_Y + 2, "up", COLL_WHIRLPOOL_1) + local p = world.player + local past = false + for _ = 1, 220 do + world.heldDir = "up" + world:step() + if p.cellY < WHIRL_Y then past = true end + end + check(not past, "$2c force-turns exactly like $24") +end + +-- The negative control, and the reason the bug was invisible: the same cell as +-- plain water is swum straight over. +do + local world = whirlWorld(WHIRL_X, WHIRL_Y + 2, "up", COLL_WATER) + local p = world.player + for _ = 1, 220 do + world.heldDir = "up" + world:step() + end + check(p.cellY < WHIRL_Y, "plain water in the same cell still lets us through") + eq(p.spinFrames, nil, "and nothing spins over it") +end + +-- The turn is answered from the tile the player is STANDING on, not from a +-- neighbour: swimming past a whirlpool one cell to the side is untouched. +do + local world = whirlWorld(WHIRL_X - 1, WHIRL_Y + 2, "up") + local p = world.player + for _ = 1, 220 do + world.heldDir = "up" + world:step() + end + eq(p.cellX, WHIRL_X - 1, "the column beside the whirlpool is ordinary water") + check(p.cellY < WHIRL_Y, "and it is swum up without a bounce") +end + +-- The stream is Script_ForcedMovement's own, byte for byte, and it is queued +-- through the same World:beginMovement every applymovement uses -- so the +-- freeze, the follower and the step-end bookkeeping all come along with it. +do + local world = whirlWorld(WHIRL_X, WHIRL_Y, "up") + check(world:runForcedMovement(), "standing on one starts the stream") + check(world.moveState ~= nil, "through an applymovement, not a bespoke path") + same(world.moveState.bytes, Movement.forcedMovementBytes("down"), + "and the bytes are the ones facing UP asks for") + check(world:busy(), "which is what closes the overworld to input") + -- It does not start a second copy over itself. + check(not world:runForcedMovement(), "one stream at a time") +end + +S.finish() diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index 5b9ff89d..1df7cb49 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -134,9 +134,7 @@ love.graphics = { -- Fresh copies of the modules that cache a compiled shader or a page set -- at first use, so they see the recorder above -- and so the originals --- every other suite holds never see it. GBCFX is in the list because --- Renderer:endFrame reaches it and parity_gbcfx asserts on its unset --- shader cache. +-- every other suite holds never see it. -- The tile/sprite renderers join them because they report trueColor rects -- to PaletteFX and resolve through Assets, and the loader because it holds -- Assets as an upvalue: an earlier suite's copy of any of the three would @@ -144,7 +142,7 @@ love.graphics = { local savedLoaded = {} for _, name in ipairs({ "src.render.PaletteFX", "src.render.Renderer", "src.render.Font", "src.render.Assets", - "src.render.GBCFX", "src.render.SpriteRenderer", + "src.render.SpriteRenderer", "src.render.TileRenderer", "src.mods.Loader" }) do savedLoaded[name] = package.loaded[name] package.loaded[name] = nil diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 29a81a83..4cb7576b 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -285,8 +285,8 @@ local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout", "battleFit", "battleHud", "battleBg", "uiLayout", "ruleset", "musicVol", "sfxVol", "musicFilter", "performance", "colors", - "tilt", "gbcfx", "zoom", "voidFill", "videoMode", - "faithfulRes", "screenPos", "fpsCap", + "tilt", "shaderfx", "shaderfx2", "zoom", "voidFill", + "videoMode", "faithfulRes", "screenPos", "fpsCap", "speedOverworld", "speedBattle", "speedMenu", "mods", "controls", "dateFormat", "timeFormat" } local function orow(menu, id) @@ -331,7 +331,9 @@ check(om.game.save.options.musicVol == 6, "music volume steps down") for _ = 1, 10 do orow(om, "musicVol").step(om.game, -1) end check(om.game.save.options.musicVol == 0, "music volume clamps at 0") --- ZOOM / VOID FILL rows (looked up by id; WANT_IDS above pins the order) +-- ZOOM / VOID FILL rows (looked up by id; WANT_IDS above pins the order, +-- with SHADER FX / SHADER FX 2 right after TILT now that GBCFX.lua and +-- its row are gone) local Zoom = require("src.render.Zoom") local TileRenderer = require("src.render.TileRenderer") om.game.save.options.zoom = 0 @@ -359,6 +361,39 @@ check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK") orow(om, "voidFill").step(om.game, 1) check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES") +-- the SHADER FX row now pushes a real ShaderFXScreen list instead of +-- cycling in place. With no presets under ShaderFX.presetDir() (nothing +-- is dropped in for this stub love.filesystem), the pushed screen must +-- show OFF plus the permanent DOWNLOAD SHADERS row and stay a safe +-- no-op rather than crash. SHADER FX 2 (the dual-shader secondary slot) +-- mirrors it one row down, opening the same shared screen on +-- "secondary" instead. +local ShaderFX = require("src.render.ShaderFX") +check(om.rows[16].id == "shaderfx", "row 16 is the SHADER FX row") +check(om.rows[16].value(om.game) == "OFF", "SHADER FX shows OFF with no presets") +check(om.rows[16].step == nil, "SHADER FX row has no step() any more") +om.rows[16].activate(om.game) +local sfxScreen = om.game.stack:top() +check(sfxScreen and sfxScreen.title == "SHADER FX", + "SHADER FX row.activate() pushes a ShaderFXScreen") +check(#sfxScreen.items == 2 and sfxScreen.items[1].label == "OFF" + and sfxScreen.items[2].download == true, + "ShaderFXScreen shows OFF + DOWNLOAD SHADERS with zero presets found") +sfxScreen.onChoose(sfxScreen.items[1]) +check(ShaderFX.active("main") == false, "choosing OFF on an empty list stays a safe no-op") +check(om.game.stack:top() == nil, "ShaderFXScreen pops itself after onChoose") + +check(om.rows[17].id == "shaderfx2", "row 17 is the SHADER FX 2 row") +check(om.rows[17].value(om.game) == "OFF", "SHADER FX 2 shows OFF with no presets") +om.rows[17].activate(om.game) +local sfx2Screen = om.game.stack:top() +check(sfx2Screen and sfx2Screen.title == "SHADER FX 2", + "SHADER FX 2 row.activate() pushes the shared ShaderFXScreen on the secondary slot") +sfx2Screen.onChoose(sfx2Screen.items[1]) +check(ShaderFX.active("secondary") == false, "choosing OFF on the secondary slot is a safe no-op") +check(ShaderFX.active() == false, "neither slot active means ShaderFX.active() is false") +check(om.game.stack:top() == nil, "the secondary ShaderFXScreen pops itself after onChoose") + -- the MAX FPS row cycles the render-cap steps and shows the value plain om.game.save.options.fpsCap = nil check(orow(om, "fpsCap").value(om.game) == "60", diff --git a/tests/mod_world_tests.lua b/tests/mod_world_tests.lua index ed4b4fbe..f1ea223c 100644 --- a/tests/mod_world_tests.lua +++ b/tests/mod_world_tests.lua @@ -976,8 +976,10 @@ do check(battle.leveledUp and battle.leveledUp[caterpie], "awardExp records the level-up for EvolveAfterBattle") - game.stack:pop() - battle.onFinish("win") + -- EndOfBattle evolves on the battle screen (end_of_battle.asm:42-45), + -- so drive finish() rather than calling onFinish by hand + battle.result = "win" + battle:finish() -- the "is evolving!" box types out and holds DelayFrames 50 before the -- movie (evos_moves.asm:120-134) (#1596), so drive frames to reach it game.input.wasPressed = function() return false end diff --git a/tests/modkit/record.lua b/tests/modkit/record.lua index b573116b..3b7cf6ff 100644 --- a/tests/modkit/record.lua +++ b/tests/modkit/record.lua @@ -61,8 +61,8 @@ function Record.events(target, opts) return capture end --- the sanctioned draw-capture pattern from tests/parity_gbcfx.lua, lifted --- so sprite/tileset mods can assert on what reached the screen +-- the sanctioned love.graphics.draw-capture pattern, lifted so sprite/ +-- tileset mods can assert on what reached the screen function Record.draw() local original = love.graphics.draw local capture = { draws = {} } diff --git a/tests/parity_cycling_road_brake.lua b/tests/parity_cycling_road_brake.lua index 3b1bffc6..de8b9052 100644 --- a/tests/parity_cycling_road_brake.lua +++ b/tests/parity_cycling_road_brake.lua @@ -132,6 +132,9 @@ do o:handleInput() check(not o.player.moving, "and holding A there changes nothing") Input:reset() + -- a scripted step now reads the live bike state (#1754), so do not leak + -- the mount into whatever suite runs next + Game.save.onBike = false end S.finish() diff --git a/tests/parity_escort_lockstep.lua b/tests/parity_escort_lockstep.lua index 4fc567df..2c98f206 100644 --- a/tests/parity_escort_lockstep.lua +++ b/tests/parity_escort_lockstep.lua @@ -50,7 +50,9 @@ local function runEscort(sync, steps) npc.cellX, npc.cellY, npc.px, npc.py = 10, 4, 160, 64 npc.moving, npc.progress, npc.targetX, npc.targetY = false, 0, nil, nil ow.scriptMoves = {} - npc.stepFrames = sync and (p.stepFramesCur or p.stepFrames) or nil + -- a scripted step re-derives its length from the live walk/bike state + -- (home/overworld.asm:276, #1754), so sync off that, not a stale latch + npc.stepFrames = sync and p:stepLength() or nil local worstDrift, done, i = 0, false, 0 local function tick() i = i + 1 diff --git a/tests/parity_gbcfx.lua b/tests/parity_gbcfx.lua deleted file mode 100644 index 891008e6..00000000 --- a/tests/parity_gbcfx.lua +++ /dev/null @@ -1,171 +0,0 @@ --- Parity test, GBC FX ladder (Pixel Transparency shader). --- Unit-tests the Lua-side API of src/render/GBCFX.lua headless under the --- love stub: level clamping, the OFF→1→2→3→4→OFF cycle, options plumbing, --- level labels, and that active()/present() degrade gracefully when the --- stub offers no love.graphics.newShader (shader() returns nil). -package.path = "./?.lua;./?/init.lua;" .. package.path -if not _G.love then _G.love = require("tests.love_stub") end -local S = require("tests.harness").suite("parity gbcfx") -local check, eq = S.check, S.eq - --- === assertions === - -local GBCFX = require("src.render.GBCFX") - --- defaults -eq(GBCFX.level, 0, "gbcfx starts at level 0 (OFF)") -eq(#GBCFX.LABELS, 5, "five labels (OFF + 4 levels)") -eq(GBCFX.LABELS[1], "OFF", "first label is OFF") - --- setLevel clamps and floors -GBCFX.setLevel(2) -eq(GBCFX.level, 2, "setLevel stores an in-range level") -GBCFX.setLevel(-3) -eq(GBCFX.level, 0, "setLevel clamps below to 0") -GBCFX.setLevel(99) -eq(GBCFX.level, 4, "setLevel clamps above to 4") -GBCFX.setLevel(2.9) -eq(GBCFX.level, 2, "setLevel floors fractional levels") -GBCFX.setLevel("3") -eq(GBCFX.level, 3, "setLevel accepts numeric strings") -GBCFX.setLevel(nil) -eq(GBCFX.level, 0, "setLevel(nil) resets to OFF") -GBCFX.setLevel("junk") -eq(GBCFX.level, 0, "setLevel(non-numeric) resets to OFF") - --- cycle wraps OFF→1→2→3→4→OFF and returns the new level -GBCFX.setLevel(0) -eq(GBCFX.cycle(), 1, "cycle OFF -> 1") -eq(GBCFX.cycle(), 2, "cycle 1 -> 2") -eq(GBCFX.cycle(), 3, "cycle 2 -> 3") -eq(GBCFX.cycle(), 4, "cycle 3 -> 4") -eq(GBCFX.cycle(), 0, "cycle 4 wraps to OFF") -eq(GBCFX.level, 0, "cycle leaves the wrapped level stored") - --- applyOptions reads opts.gbcfx -GBCFX.applyOptions({ gbcfx = 3 }) -eq(GBCFX.level, 3, "applyOptions reads opts.gbcfx") -GBCFX.applyOptions({}) -eq(GBCFX.level, 0, "applyOptions without gbcfx resets to OFF") -GBCFX.setLevel(2) -GBCFX.applyOptions(nil) -eq(GBCFX.level, 0, "applyOptions(nil) resets to OFF") - --- labels -eq(GBCFX.levelLabel(0), "OFF", "label for level 0") -eq(GBCFX.levelLabel(1), "1", "label for level 1") -eq(GBCFX.levelLabel(4), "4", "label for level 4") -GBCFX.setLevel(3) -eq(GBCFX.levelLabel(), "3", "levelLabel() defaults to the current level") -eq(GBCFX.levelLabel(42), "OFF", "out-of-range label falls back to OFF") - --- headless: the love stub has no newShader, so the shader never compiles -eq(GBCFX.shader(), nil, "shader() is nil headless") -GBCFX.setLevel(4) -check(not GBCFX.active(), "active() is false headless even at level 4") - --- present() falls back to a plain draw when the shader is unavailable -local drawn = nil -local g = love.graphics -local oldDraw, oldSetColor = g.draw, g.setColor -g.draw = function(c, x, y) drawn = { c, x, y } end -g.setColor = g.setColor or function() end -local canvas = {} -local ok, err = pcall(GBCFX.present, canvas, 5) -g.draw = oldDraw -g.setColor = oldSetColor -check(ok, "present() does not error headless (" .. tostring(err) .. ")") -check(drawn and drawn[1] == canvas and drawn[2] == 0 and drawn[3] == 0, - "present() falls back to a plain draw at (0,0)") - -GBCFX.setLevel(0) - --- issue #136: Android/iOS refuse GBC FX (shader soft-bricks the APK) -check(GBCFX.isSupported(), "desktop / headless stub supports GBC FX") -local prevSystem = love.system -love.system = { getOS = function() return "Android" end } -check(not GBCFX.isSupported(), "Android reports GBC FX unsupported") -GBCFX.setLevel(3) -eq(GBCFX.level, 0, "setLevel forces OFF on Android") -eq(GBCFX.cycle(), 0, "cycle stays OFF on Android") -local opts = { gbcfx = 4 } -check(GBCFX.applyOptions(opts) == true, - "applyOptions reports a cleared persisted level on Android") -eq(opts.gbcfx, 0, "applyOptions clears opts.gbcfx on Android") -eq(GBCFX.level, 0, "applyOptions leaves level OFF on Android") -check(not GBCFX.active(), "active() is false on Android") -eq(GBCFX.shader(), nil, "shader() is nil on Android") -love.system = { getOS = function() return "iOS" end } -check(not GBCFX.isSupported(), "iOS reports GBC FX unsupported") -love.system = prevSystem -check(GBCFX.isSupported(), "support restores when OS stub is removed") --- desktop path still applies a level after leaving the mobile gate -GBCFX.applyOptions({ gbcfx = 2 }) -eq(GBCFX.level, 2, "applyOptions still sets levels on desktop") -GBCFX.setLevel(0) - --- Options menu hides the GBC FX row on Android -local OptionsMenu = require("src.ui.OptionsMenu") -love.system = { getOS = function() return "Android" end } -local om = OptionsMenu.new({ - data = { rulesets = {}, constants = {} }, - save = { options = {} }, - stack = { pop = function() end }, - input = { wasPressed = function() return false end }, - modStatus = { available = {} }, -}) -local hasGbc = false -for _, row in ipairs(om.rows) do - if row.id == "gbcfx" then hasGbc = true end -end -check(not hasGbc, "Options menu omits GBC FX on Android") -love.system = prevSystem - --- POKEPORT_GBCFX overrides the platform default both ways. Handheld packs --- (build-rg34xxsp.sh) export 0 because their getOS() says "Linux" while the --- GPU is phone class; 1 is the escape hatch if a device turns out to cope. -local prevEnv = os.getenv("POKEPORT_GBCFX") -local realGetenv = os.getenv -local stubEnv -os.getenv = function(name) - if name == "POKEPORT_GBCFX" then return stubEnv end - return realGetenv(name) -end - -stubEnv = "0" -check(not GBCFX.isSupported(), "POKEPORT_GBCFX=0 refuses GBC FX on desktop") -GBCFX.setLevel(3) -eq(GBCFX.level, 0, "setLevel forces OFF under POKEPORT_GBCFX=0") -local handheldOpts = { gbcfx = 4 } -check(GBCFX.applyOptions(handheldOpts) == true, - "applyOptions reports a cleared level under POKEPORT_GBCFX=0") -eq(handheldOpts.gbcfx, 0, "applyOptions heals a persisted level on a handheld") -check(not GBCFX.active(), "active() is false under POKEPORT_GBCFX=0") - -local omHandheld = OptionsMenu.new({ - data = { rulesets = {}, constants = {} }, - save = { options = {} }, - stack = { pop = function() end }, - input = { wasPressed = function() return false end }, - modStatus = { available = {} }, -}) -hasGbc = false -for _, row in ipairs(omHandheld.rows) do - if row.id == "gbcfx" then hasGbc = true end -end -check(not hasGbc, "Options menu omits GBC FX under POKEPORT_GBCFX=0") - --- "1" wins over the Android gate, so the override is a real two-way door. -stubEnv = "1" -love.system = { getOS = function() return "Android" end } -check(GBCFX.isSupported(), "POKEPORT_GBCFX=1 forces GBC FX on despite Android") -love.system = prevSystem - -stubEnv = nil -check(GBCFX.isSupported(), "unset POKEPORT_GBCFX falls back to the OS check") -os.getenv = realGetenv -eq(os.getenv("POKEPORT_GBCFX"), prevEnv, "os.getenv restored") -GBCFX.setLevel(0) - --- === summary === -S.finish() diff --git a/tests/parity_rare_candy_menu.lua b/tests/parity_rare_candy_menu.lua index e6c936d6..d9e188da 100644 --- a/tests/parity_rare_candy_menu.lua +++ b/tests/parity_rare_candy_menu.lua @@ -175,7 +175,7 @@ do check(isBox(box), "the last candy levels too") finishLevelUp(game, box) eq(game.stack:top(), list, "the bag stays open after the last candy") - eq(#list.items, 0, "the emptied row left the list") + eq(#list.items, 1, "the emptied row left the list, leaving only CANCEL") eq(game.save.inventory.RARE_CANDY, nil, "no candies left in the inventory") end diff --git a/tests/parity_ss_anne_departure.lua b/tests/parity_ss_anne_departure.lua index f7a196d3..41f42c24 100644 --- a/tests/parity_ss_anne_departure.lua +++ b/tests/parity_ss_anne_departure.lua @@ -83,9 +83,10 @@ do for _, r in ipairs(rowsOfKind(rows, "show_text")) do said[#said + 1] = r[2] end + -- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers, so the + -- defeat line prints in battle, not on the map (scripts/SSAnne2F.asm:199) local order = { "_SSAnne2FRivalText", - "_SSAnne2FRivalDefeatedText", "_SSAnne2FRivalCutMasterText", } local pi = 1 @@ -93,7 +94,10 @@ do if pi <= #order and id == order[pi] then pi = pi + 1 end end eq(pi, #order + 1, - ("x=%d text order: greeting, defeated, CUT master"):format(x)) + ("x=%d text order: greeting, then CUT master"):format(x)) + local armed = rowsOfKind(rows, "save_end_battle_text")[1] + check(armed ~= nil and armed[2] == "_SSAnne2FRivalDefeatedText", + ("x=%d arms the defeat line for the battle screen (#1688)"):format(x)) local walk = rowsOfKind(rows, "walk_npc")[1] check(walk ~= nil, ("x=%d exit is a walk_npc list"):format(x)) @@ -123,13 +127,9 @@ local function dockBlock(bx, by) end local function sail(cellX, cellY) - local rows, puffs = nil, 0 + local rows = nil local ow = { player = { cellX = cellX, cellY = cellY }, - startDustAnim = function(_, _, _, done) - puffs = puffs + 1 - if done then done() end - end, queueScript = function(_, r) rows = r end, } local game = { @@ -139,10 +139,15 @@ local function sail(cellX, cellY) story3.VERMILION_DOCK.onEnter(game, ow) check(rows ~= nil, "stepping off the gangway queues the departure") check(game.save.flags.EVENT_SS_ANNE_LEFT == true, "EVENT_SS_ANNE_LEFT set") - eq(puffs, 3, "three funnel smoke puffs (LoadSmokeTileFourTimes)") return rows end +local function kindIndex(rows, kind, from) + for i = from or 1, #rows do + if rows[i][1] == kind then return i end + end +end + do -- the hull ids the slide reuses are the map's own blocks, so a data -- rebuild that renumbered the tileset would be caught here @@ -165,52 +170,25 @@ do local waits = rowsOfKind(rows, "wait") eq(waits[1][2], 120, "120 frames before the first horn") eq(waits[#waits][2], 120, "EraseSSAnne's 120 frames before the walk out") - local slide = 0 - for _, w in ipairs(waits) do - if w[2] == 20 then slide = slide + 1 end - end - eq(slide, 8, "eight column shifts, .shift_columns_up's ld e, $8") - -- the bug was the whole hull blinking to water in a single frame with no - -- travel at all: her bow block has to be written one column further west - -- each step, and the water has to close in astern behind her - local bow, wake = {}, {} - for _, r in ipairs(rowsOfKind(rows, "replace_block")) do - if r[3] == 1 and r[4] == dockBlock(DOCK_HULL.x0, 1) then - bow[#bow + 1] = r[2] - elseif r[3] == 1 and r[4] == 1 then - wake[#wake + 1] = r[2] - end - end - check(dirsEqual(bow, { 4, 3, 2, 1 }), "the bow sails west a column a step") - check(dirsEqual(wake, { 8, 7, 6, 5, 4, 3, 2, 1 }), - "water closes in astern, stern column first") + -- scripts/VermilionDock.asm:50 zeroes wSpritePlayerStateData1ImageIndex and + -- :77 freezes sprite updates: he faces DOWN until he walks out (#1689) + eq(rows[1][1], "face_player_dir", "he is turned before the first delay") + eq(rows[1][2], "down", "and he is turned to face DOWN") - for _, r in ipairs(rowsOfKind(rows, "replace_block")) do - check(r[2] >= 1 and r[2] <= DOCK_HULL.x1, - "the slide stays inside the dock's water, off the pier column 0") - end + -- she used to be shuffled west one whole 32px block per beat, which read as + -- teleporting; .shift_columns_up is a 1px-per-8-frames slide (#1689) + eq(#rowsOfKind(rows, "replace_block"), 0, + "no block shuffle: the hull slides, it does not jump") + eq(#rowsOfKind(rows, "ss_anne_departs"), 1, "one blocking sail-away beat") - -- scripts/VermilionDock.asm:182-203: the tile fill covers the whole ship, - -- the gangway block under the player included (#1211) - local final = {} - for _, r in ipairs(rowsOfKind(rows, "replace_block")) do - final[r[2] .. "," .. r[3]] = r[4] - end - for bx = DOCK_HULL.x0, DOCK_HULL.x1 do - for by = DOCK_HULL.y0, DOCK_HULL.y1 do - check(WATER[final[bx .. "," .. by]], - ("hull block (%d,%d) ends as open water"):format(bx, by)) - end - end - - -- and she has to have travelled: the westmost water column of the map - -- carried hull blocks partway through - local sawWest = false - for _, r in ipairs(rowsOfKind(rows, "replace_block")) do - if r[2] == 1 and not WATER[r[4]] then sawWest = true end - end - check(sawWest, "the hull reaches the west edge of the water before it goes") + local horn1 = kindIndex(rows, "play_sound") + local sailIdx = kindIndex(rows, "ss_anne_departs") + local horn2 = kindIndex(rows, "play_sound", (horn1 or 0) + 1) + check(horn1 and sailIdx and horn2 and horn1 < sailIdx and sailIdx < horn2, + "horn, then she sails, then the horn again") + check(kindIndex(rows, "warp") > sailIdx, + "the walk out only starts once she has gone") end do diff --git a/tests/parity_ss_anne_guard.lua b/tests/parity_ss_anne_guard.lua index 8ad004c5..30cc7efb 100644 --- a/tests/parity_ss_anne_guard.lua +++ b/tests/parity_ss_anne_guard.lua @@ -66,6 +66,7 @@ local function gameWith(opts) _VermilionCitySailor1YouNeedATicketText = "You need a ticket\nto get aboard.", _VermilionCitySailor1ShipSetSailText = "The ship set sail.", + _VermilionCitySailor1WelcomeToSSAnneText = "Welcome to S.S.\nANNE!", _SSAnneCaptainsRoomRubCaptainsBackText = "Rub-rub...", _SSAnneCaptainsRoomCaptainIFeelMuchBetterText = "I feel better!", _SSAnneCaptainsRoomCaptainReceivedHM01Text = "Got HM01!", @@ -143,25 +144,36 @@ do check(#pushed == 0, "no spurious dialog off the check") end --- Sailor talk after ship left: ShipSetSail branch (row script). +-- Sailor talk: once she has sailed he only reports it gone, and before +-- that an A press is the plain greeting, never the ticket check (#1651). +-- scripts/VermilionCity.asm:158-198 do - local ScriptRunner = require("src.script.ScriptRunner") - local game = gameWith({ flags = { EVENT_SS_ANNE_LEFT = true } }) - local rows = city.talk.TEXT_VERMILIONCITY_SAILOR1 - local runner = ScriptRunner.new(game, nil) - runner:run(rows, {}) - local guard = 0 - while runner:isRunning() and guard < 200 do - guard = guard + 1 - if game.stack and game._pushed then - local box = game._pushed[#game._pushed] - if box and box.done then box.done() end - end - runner:update() - end - local last = game._pushed[#game._pushed] - eq(last and last.text, "The ship set sail.", + local talk = city.talk.TEXT_VERMILIONCITY_SAILOR1 + local game, pushed = gameWith({ flags = { EVENT_SS_ANNE_LEFT = true } }) + talk(game, owWith({}), nil, function() end) + eq(pushed[#pushed] and pushed[#pushed].text, "The ship set sail.", "talk after ship left shows ShipSetSail") + + game, pushed = gameWith({}) + local ow = owWith({}) + ow.player.cellX, ow.player.cellY, ow.player.facing = 18, 30, "right" + talk(game, ow, nil, function() end) + eq(pushed[#pushed] and pushed[#pushed].text, "Welcome to S.S.\nANNE!", + "facing him from the west is the plain greeting, no ticket check") + + game, pushed = gameWith({}) + ow = owWith({}) + ow.player.cellX, ow.player.cellY, ow.player.facing = 19, 29, "down" + talk(game, ow, nil, function() end) + eq(pushed[#pushed] and pushed[#pushed].text, "Welcome to S.S.\nANNE!", + "talking from in front of him is the plain greeting too") + + game, pushed = gameWith({}) + ow = owWith({}) + ow.player.cellX, ow.player.cellY, ow.player.facing = 19, 31, "up" + talk(game, ow, nil, function() end) + eq(pushed[#pushed] and pushed[#pushed].text, "Welcome to S.S.\nANNE!", + "and from behind him") end -- Departure cutscene: Music_Surfing + EVENT_SS_ANNE_LEFT via Flags.set. @@ -180,22 +192,22 @@ do check(ow._queued ~= nil, "departure queues the sail-away script") -- #360: the surf override must NOT ride into Vermilion City, and she -- sails west block by block - local kept, horns, slid, underPlayer = false, 0, 0, false + local kept, horns, sails, shuffled, faced = false, 0, 0, 0, false for _, row in ipairs(ow._queued or {}) do if row[1] == "play_music" and row[3] and row[3].keep then kept = true end if row[1] == "play_sound" and row[2] == "SS_Anne_Horn" then horns = horns + 1 end - if row[1] == "replace_block" then - slid = slid + 1 - if row[2] == 7 and row[3] == 1 then underPlayer = true end - end + if row[1] == "ss_anne_departs" then sails = sails + 1 end + if row[1] == "replace_block" then shuffled = shuffled + 1 end + if row[1] == "face_player_dir" and row[2] == "down" then faced = true end end eq(kept, false, "departure lets VERMILION_CITY's own theme take the warp") eq(horns, 2, "the horn blows before and after she sails") - check(slid > 8, "she sails west block by block instead of vanishing") - -- scripts/VermilionDock.asm:182-203 (#1211) - eq(underPlayer, true, "the gangway block under the player is erased too") + eq(sails, 1, "she slides west as one animation, not a block shuffle") + eq(shuffled, 0, "and no hull block is stamped across the dock any more") + -- scripts/VermilionDock.asm:50 (#1689) + eq(faced, true, "he keeps facing down until he walks out") end -- Captain rub jingle: play_once Music_PkmnHealed sits after the rub text. diff --git a/tests/parity_trainer_evolution_order.lua b/tests/parity_trainer_evolution_order.lua index d457f4bc..ba4a7af0 100644 --- a/tests/parity_trainer_evolution_order.lua +++ b/tests/parity_trainer_evolution_order.lua @@ -1,6 +1,5 @@ --- A trainer script's post-battle text must finish before a level evolution. --- Its start_battle row resumes the script after OverworldState:afterBattle, --- so this covers the same handoff used by trainer and Rocket encounters. +-- EndOfBattle evolves on the battle screen, before the map comes back +-- (engine/battle/end_of_battle.asm:42-45) (#1656) package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end @@ -77,13 +76,9 @@ check(#Game.stack.states == 1, Game.stack:pop() moreText.onDone() --- The evolution runs as the EvolutionState cutscene screen now (the --- evolve_mon.asm sequence lives in src/ui/EvolutionState.lua), not a bare --- "evolving!" text box, so assert the screen itself took the stack. -local evolution = Game.stack:top() -check(evolution ~= nil - and (evolution.screenId == "EvolutionState" - or stateHas(evolution, "evolving")), - "the level evolution starts after trainer after-text closes") +-- The evolution belongs to the battle screen (BattleState:finish -> +-- Evolution.checkParty), so afterBattle leaves nothing behind the text. +check(#Game.stack.states == 0, + "afterBattle pushes no evolution; the battle screen already ran it") S.finish() diff --git a/tests/parity_trashcans.lua b/tests/parity_trashcans.lua index 3abda268..a8d063f5 100644 --- a/tests/parity_trashcans.lua +++ b/tests/parity_trashcans.lua @@ -59,6 +59,7 @@ local textBoxStub = { end, } local realTextBox = getUpvalue(OW.trashCanSwitch, "TextBox") +textBoxStub.soundOpts = realTextBox.soundOpts local fakeGame = { data = Data, save = SaveData.newGame(), stack = { push = function() end } } check(setUpvalue(OW.trashCanSwitch, "TextBox", textBoxStub), "TextBox upvalue rewired") check(setUpvalue(OW.trashCanSwitch, "Game", fakeGame), "Game upvalue rewired") diff --git a/tests/pet_cries_test.lua b/tests/pet_cries_test.lua new file mode 100644 index 00000000..89f07aaa --- /dev/null +++ b/tests/pet_cries_test.lua @@ -0,0 +1,157 @@ +-- Pet-NPC cries: #1687 (S.S. Anne WIGGLYTUFF + MACHOKE) and #1649 +-- (Vermilion PIDGEY, Vermilion City MACHOP, Fan Club PIKACHU + SEEL). +-- Commands.play_cry only arms the very next show_text, so what is asserted +-- here is placement: one play_cry row, the right species, the waitForButton +-- form, sitting immediately before the box it belongs to. The sound itself +-- is a driver's job (tests/drivers/pet_cries_bug1687_1649_test.lua). +-- Self-contained: `luajit tests/pet_cries_test.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end +local init = require("data.scripts.init") +local GameVersion = require("src.core.GameVersion") +local S = require("tests.harness").suite("pet cries") +local check, eq = S.check, S.eq + +local yellow = GameVersion.isYellow() + +-- pokered/scripts/*.asm, PlayCry line in brackets: +-- SSAnne1FRooms.asm:66 [:70], SSAnneB1FRooms.asm:82 [:86], +-- VermilionPidgeyHouse.asm:15 [:19], VermilionCity.asm:224 [:228], +-- PokemonFanClub.asm:71 [:76] and :84 [:89]. +local PETS = { + { map = "SS_ANNE_1F_ROOMS", const = "TEXT_SSANNE1FROOMS_WIGGLYTUFF", + object = "SSANNE1FROOMS_WIGGLYTUFF", species = "WIGGLYTUFF", + label = "_SSAnne1FRoomsWigglytuffText" }, + { map = "SS_ANNE_B1F_ROOMS", const = "TEXT_SSANNEB1FROOMS_MACHOKE", + object = "SSANNEB1FROOMS_MACHOKE", species = "MACHOKE", + label = "_SSAnneB1FRoomsMachokeText" }, + { map = "VERMILION_PIDGEY_HOUSE", const = "TEXT_VERMILIONPIDGEYHOUSE_PIDGEY", + object = "VERMILIONPIDGEYHOUSE_PIDGEY", species = "PIDGEY", + label = "_VermilionPidgeyHousePidgeyText" }, + -- the cry belongs to the first box; the stomping line is a second box + -- with no cry of its own (VermilionCity.asm:233 .StompingTheLandFlatText) + { map = "VERMILION_CITY", const = "TEXT_VERMILIONCITY_MACHOP", + object = "VERMILIONCITY_MACHOP", species = "MACHOP", + label = "_VermilionCityMachopText", + after = "_VermilionCityMachopStompingTheLandFlatText" }, + { map = "POKEMON_FAN_CLUB", const = "TEXT_POKEMONFANCLUB_SEEL", + object = "POKEMONFANCLUB_SEEL", species = "SEEL", + label = "_PokemonFanClubSeelText" }, +} + +if yellow then + PETS[#PETS + 1] = { + map = "POKEMON_FAN_CLUB", const = "TEXT_POKEMONFANCLUB_CLEFAIRY", + object = "POKEMONFANCLUB_CLEFAIRY", species = "CLEFAIRY", + label = "_PokemonFanClubClefairyText", + } +else + PETS[#PETS + 1] = { + map = "POKEMON_FAN_CLUB", const = "TEXT_POKEMONFANCLUB_PIKACHU", + object = "POKEMONFANCLUB_PIKACHU", species = "PIKACHU", + label = "_PokemonFanClubPikachuText", + } +end + +local function rowsOf(script) + local plays, texts = {}, {} + for i, row in ipairs(script) do + if type(row) == "table" then + if row[1] == "play_cry" then plays[#plays + 1] = i end + if row[1] == "show_text" then texts[#texts + 1] = i end + end + end + return plays, texts +end + +for _, pet in ipairs(PETS) do + local tag = pet.map .. "/" .. pet.const + local script = init.talkScript(pet.map, pet.const) + if check(type(script) == "table", tag .. " has a ported talk script") then + local plays = rowsOf(script) + if eq(#plays, 1, tag .. " carries exactly one play_cry row") then + local cry = script[plays[1]] + eq(cry[2], pet.species, tag .. " cries " .. pet.species) + -- the waitForButton form: without it the box pops itself when the + -- cry ends instead of holding for A/B (Commands.play_cry, #247/#251) + eq(cry[3], true, tag .. " uses the waitForButton play_cry form") + -- play_cry arms ctx.pendingCry for the NEXT show_text only, so the + -- box it belongs to has to be the very next row + local next_ = script[plays[1] + 1] + check(type(next_) == "table" and next_[1] == "show_text" + and next_[2] == pet.label, + tag .. " arms " .. pet.label .. " on the next row") + end + if pet.after then + local _, texts = rowsOf(script) + eq(#texts, 2, tag .. " still shows both of its boxes") + local last = texts[#texts] + eq(script[last][2], pet.after, tag .. " keeps " .. pet.after .. " last") + end + end + + check(Data.pokemon[pet.species] ~= nil, pet.species .. " is a known species") + check(Data.audio.cries and Data.audio.cries[pet.species] ~= nil, + pet.species .. " has a cry program in the mounted cache") + local text = Data.text[pet.label] + check(type(text) == "string" and text ~= "", + pet.label .. " resolves to text") + + -- a script nobody can reach is the same silence as a missing cry + local objects = (Data.maps[pet.map] or {}).objects or {} + local found + for _, o in ipairs(objects) do + if o.name == pet.object then found = o end + end + if check(found ~= nil, pet.map .. " still lists " .. pet.object) then + eq(found.text, pet.const, pet.object .. " talks through " .. pet.const) + end +end + +-- Yellow swaps the Fan Club pet for a CLEFAIRY on its own text constant +-- (pokeyellow/data/maps/objects/PokemonFanClub.asm), so the extra row has +-- to be version-gated in both directions. +if yellow then + check(init.talkScript("POKEMON_FAN_CLUB", "TEXT_POKEMONFANCLUB_CLEFAIRY") ~= nil, + "Yellow registers the CLEFAIRY pet") +else + check(init.talkScript("POKEMON_FAN_CLUB", "TEXT_POKEMONFANCLUB_CLEFAIRY") == nil, + "Red/Blue do not register Yellow's CLEFAIRY pet") +end + +-- The Yellow half of that guard, checked from any version: re-read the +-- module with isYellow forced on, then put package.loaded back exactly as +-- it was so the registry keeps pointing at the table it merged. +do + local key = "data.scripts.flavor.pokemon_fan_club" + local cached = package.loaded[key] + local realIsYellow = GameVersion.isYellow + GameVersion.isYellow = function() return true end + package.loaded[key] = nil + local ok, module = pcall(require, key) + GameVersion.isYellow = realIsYellow + package.loaded[key] = cached + if check(ok, "the Fan Club script loads with isYellow forced on") then + local row = module.POKEMON_FAN_CLUB.talk.TEXT_POKEMONFANCLUB_CLEFAIRY + if check(type(row) == "table", "the Yellow branch adds the CLEFAIRY pet") then + eq(row[1][1], "play_cry", "Yellow CLEFAIRY leads with play_cry") + eq(row[1][2], "CLEFAIRY", "Yellow CLEFAIRY cries CLEFAIRY, not PIKACHU") + eq(row[1][3], true, "Yellow CLEFAIRY uses the waitForButton form") + eq(row[2][2], "_PokemonFanClubClefairyText", + "Yellow CLEFAIRY arms its own text") + end + end +end + +-- Sound.playCry hands PIKACHU to the PCM clips whenever the cache has +-- them, which is Yellow's voiced cry. Red/Blue must never reach that +-- branch: audio.pikaCries is the only thing gating it. +if not yellow then + check(Data.audio.pikaCries == nil, + "a non-Yellow cache has no Pikachu voice clips for playCry to find") +end + +S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 2b271766..b778adaf 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -1223,8 +1223,8 @@ do local mhc = BattleState.newWild(Game, "SNORLAX", 40) mhc.rng = mkseq({ 0, 255 }) -- acc, damage; crit from the hook mhc:performMove(mhc.player, mhc.enemy, { id = "DOUBLE_KICK", pp = 10 }) - eq(countText(mhc, "Critical hit!"), 2, - "multi-hit prints Critical hit! once per strike") + eq(countText(mhc, "Critical hit!"), 1, + "PrintCriticalOHKOText clears wCriticalHitOrOHKO, so the crit line prints once") local cseq = {} for _, r in ipairs(mhc.queue) do if r.anim == "DOUBLE_KICK" then cseq[#cseq + 1] = "anim" @@ -1233,8 +1233,8 @@ do elseif r.text == "It's super\neffective!" then cseq[#cseq + 1] = "se" end end - eq(table.concat(cseq, ","), "anim,drain,crit,se,anim,drain,crit,se", - "crit then effectiveness follow each multi-hit drain") + eq(table.concat(cseq, ","), "anim,drain,crit,se,anim,drain,se", + "the crit line prints once, effectiveness after every drain") unsub() Runtime.install(savedE, savedH) end @@ -1658,7 +1658,7 @@ do rep.options = { textSpeed = 3, animations = false, battleStyle = "SET", ruleset = "gen1_faithful", musicVol = 4, sfxVol = 2, musicFilter = 2, - colors = "og", tilt = 2, gbcfx = 3, + colors = "og", tilt = 2, } rep.defeatedTrainers = { ["OPP_BROCK:1"] = true } rep.pokedex = { seen = { PIKACHU = true, CATERPIE = true }, @@ -1687,7 +1687,6 @@ do eq(back.options.animations, false, "options.lua round-trips animations") eq(back.options.colors, "og", "options.lua round-trips colors") eq(back.options.tilt, 2, "options.lua round-trips tilt") - eq(back.options.gbcfx, 3, "options.lua round-trips gbcfx") -- zoom / voidFill ride the same options.lua path when present rep.options.zoom = -2 rep.options.voidFill = "water" @@ -2315,6 +2314,14 @@ do press("down") press("down") press("a") -- QUIT + check(not quitCalled, "QUIT says goodbye before it returns (pokemart.asm:220)") + local goodbye = StateStack:top() + check(goodbye ~= shop and goodbye.isTextBox, + "QUIT prints _PokemartThankYouText") + for _ = 1, 600 do + if quitCalled then break end + press("a") + end check(quitCalled, "QUIT fires onQuit (script runner resume)") eq(#StateStack.states, depth0, "mart menu unwound cleanly") end @@ -2635,14 +2642,14 @@ do -- option boxes (the port rows plus the MODS/CONTROLS entries) through a 4-box -- viewport with a $EE ▼ marker; MUSIC VOL / SFX VOL clamp at 0..7 like -- the text-speed cursor clamps at its ends (.pressedLeftInTextSpeed), --- MUSIC FILTER cycles OFF/1X/2X/3X, and COLORS / TILT / GBC FX / VIDEO MODE --- cycle their display modes. +-- MUSIC FILTER cycles OFF/1X/2X/3X, and COLORS / TILT / VIDEO MODE +-- cycle their display modes (SHADER FX activates a pushed screen instead). do local OptionsMenu = require("src.ui.OptionsMenu") local OInput = require("src.core.Input") local PaletteFX = require("src.render.PaletteFX") local Tilt = require("src.render.Tilt") - local GBCFX = require("src.render.GBCFX") + local ShaderFX = require("src.render.ShaderFX") local GameSpeed = require("src.core.GameSpeed") local FrameCap = require("src.core.FrameCap") local SD = require("src.core.SaveData") @@ -2681,7 +2688,6 @@ do "new saves default to MEDIUM text (InitOptions TEXT_DELAY_MEDIUM)") eq(og.save.options.colors, "gbc", "new saves default COLORS to GBC") eq(og.save.options.tilt, 0, "new saves default TILT to OFF") - eq(og.save.options.gbcfx, 0, "new saves default GBC FX to OFF") eq(og.save.options.zoom, 0, "new saves default ZOOM to FIT") eq(og.save.options.voidFill, "trees", "new saves default VOID FILL to TREES") eq(og.save.options.videoMode, "windowed", @@ -2725,12 +2731,22 @@ do eq(Tilt.level, 1, "Tilt level tracks TILT option") press("a"); press("a"); press("a") eq(og.save.options.tilt, 0, "TILT wraps back to OFF") - check(seek("gbcfx"), "cursor reaches GBC FX") - press("a") - eq(og.save.options.gbcfx, 1, "A cycles GBC FX to 1") - eq(GBCFX.level, 1, "GBCFX level tracks GBC FX option") - for _ = 1, 4 do press("a") end - eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF") + check(seek("shaderfx"), "cursor reaches SHADER FX") + -- this row activates a pushed ShaderFXScreen rather than cycling in + -- place like the rest of this suite's rows; `og.stack` above only + -- stubs `pop`, not a real push/top stack, so activate() is not + -- called here -- tests/mod_ui_tests.lua exercises it end to end against + -- a real stack. + check(om.rows[om.index].step == nil, "SHADER FX row has no step()") + check(type(om.rows[om.index].activate) == "function", + "SHADER FX row has an activate()") + check(seek("shaderfx2"), "cursor reaches SHADER FX 2") + -- the dual-shader secondary slot: same shared ShaderFXScreen, opened on + -- "secondary" instead -- see the SHADER FX row above for why activate() + -- isn't exercised against this stub stack either. + check(om.rows[om.index].step == nil, "SHADER FX 2 row has no step() either") + check(type(om.rows[om.index].activate) == "function", + "SHADER FX 2 row has an activate()") check(seek("zoom"), "cursor reaches ZOOM") local ZoomOpt = require("src.render.Zoom") press("a") @@ -2806,7 +2822,7 @@ do require("src.core.Sound").applyOptions(og.save.options) PaletteFX.applyOptions(og.save.options) Tilt.applyOptions(og.save.options) - GBCFX.applyOptions(og.save.options) + ShaderFX.applyOptions(og.save.options) require("src.render.Zoom").applyOptions(og.save.options) require("src.render.TileRenderer").applyOptions(og.save.options) require("src.core.VideoMode").applyOptions(og.save.options) @@ -3354,7 +3370,7 @@ end -- ---------------------------------------------- suite discovery -- The chains below used to be hard-coded arrays, so adding a suite meant -- editing a list and forgetting to meant the suite silently never ran. --- They are globbed now (21-testing-and-ci §CI). +-- They're globbed now instead. -- -- Order still matters: these suites share one process and one Data, and -- the sequence they were chained in is the sequence they are known to @@ -3395,10 +3411,6 @@ end -- back. Nothing notices until a LATER file reads the leftover, and then the -- failure lands nowhere near its cause: -- --- * the Android ROM-importer suites pin getOS to "Android", so --- GBCFX.isSupported() answers false for the rest of the run and --- parity_gbcfx fails "setLevel stores an in-range level (got 0, want 2)" --- -- while passing perfectly on its own; -- * suites that swap in a minimal love.filesystem drop -- getDirectoryItems, so three rom_importer suites then die on -- "attempt to call field 'getDirectoryItems' (a nil value)". @@ -3621,6 +3633,19 @@ runSuites(orderedGlob( "tests/gen2_crystal_anim_test.lua", "tests/gen2_crystal_caught_data_test.lua", "tests/gen2_crystal_gender_test.lua", + -- Pinned in the order the glob already ran them in, alphabetically last. + "tests/gen2_battle_cursor_test.lua", + "tests/gen2_battle_options_test.lua", + "tests/gen2_billspc_dpad_test.lua", + "tests/gen2_box_intake_test.lua", + "tests/gen2_cycling_road_test.lua", + "tests/gen2_dex_gift_test.lua", + "tests/gen2_ledge_hop_test.lua", + "tests/gen2_ow_bounce_test.lua", + "tests/gen2_pack_rows_test.lua", + "tests/gen2_sleep_counter_test.lua", + "tests/gen2_timed_heal_test.lua", + "tests/gen2_whirlpool_test.lua", }, LEAKS_SAVE_SLOT_STATE)) -- ---------------------------------------------- Android second ROM pick (#167) @@ -3638,6 +3663,8 @@ runSuites({ "tests/rom_importer_choose_version_test.lua" }) -- platform_nx_* / rom_importer_nx_* live in tests/engine/ (ROM-free T2) so -- CI's headless lane runs them without data/generated/. runSuites({ "tests/launcher_mods_install_zip_test.lua" }) +-- ---------------------------------------------- pet Pokemon cries (#1687, #1649) +runSuites({ "tests/pet_cries_test.lua" }) -- ---------------------------------------------- parity workstream tests -- Each tests/parity_*.lua is a self-contained file (own bootstrap + check, -- error()s if any assertion fails). Globbed, so dropping a new parity @@ -3656,7 +3683,6 @@ runSuites(orderedGlob("tests/parity_*.lua", { "tests/parity_yellow_bills_pikachu.lua", "tests/parity_trainer_evolution_order.lua", "tests/parity_intro.lua", "tests/parity_tilt.lua", - "tests/parity_gbcfx.lua", })) -- ---------------------------------------------- the globbed tiers diff --git a/tests/switch_transfer_docs_test.lua b/tests/switch_transfer_docs_test.lua index 6dbe1198..71596684 100644 --- a/tests/switch_transfer_docs_test.lua +++ b/tests/switch_transfer_docs_test.lua @@ -66,7 +66,6 @@ mustContain(install, "Select + **A**", "install") mustContain(install, "Select + **L**", "install") mustContain(install, "COLORS", "install") mustContain(install, "TILT", "install") -mustContain(install, "GBC FX", "install") mustContain(install, "PERFORMANCE", "install") mustContain(install, "Stock engine effect", "install") mustContain(install, "## Limitations", "install") diff --git a/tools/cartkit.py b/tools/cartkit.py index b1febf74..0e1db64a 100644 --- a/tools/cartkit.py +++ b/tools/cartkit.py @@ -58,7 +58,10 @@ BUNDLE_VERSION = 1 CART_SCHEMA = 1 BASES = ("red", "blue", "yellow", "gold", "silver", "crystal") -SEALS = ("sealed", "open") +SEALS = ("sealed", "sealed+", "open") +FINISHES = ("sparkle", "holo", "sparkle+holo") +# GameSpeed.LEVELS (src/core/GameSpeed.lua) +SPEED_LEVELS = (1, 2, 3, 4, 10, 20, 30, 50, 75, 100, 200) SOURCES = ("github", "gamebanana") MAX_MODS = 64 MAX_OPTIONS = 64 @@ -67,9 +70,10 @@ LABEL_WARN_BYTES = 256 * 1024 LABEL_MAX_BYTES = 1024 * 1024 CART_KEYS = ("schema", "id", "title", "version", "author", "repo", "summary", - "shell", "label", "base", "engine", "seal", "mods", "load_order") + "shell", "finish", "label", "base", "engine", "seal", "speeds", + "mods", "load_order") MOD_KEYS = ("id", "source", "repo", "version", "sha256", "mod", "file", "md5", - "options") + "enabled", "options") ID_RE = re.compile(r"[A-Za-z0-9_-]{1,64}") SEMVER_RE = re.compile( @@ -338,7 +342,21 @@ def check_identity(cart, findings): seal = cart.get("seal", "sealed") if seal not in SEALS: - findings.append(err("CK002", '"seal" must be "sealed" or "open"')) + findings.append(err("CK002", '"seal" must be one of: ' + + ", ".join(SEALS))) + finish = cart.get("finish") + if finish is not None and finish not in FINISHES: + findings.append(err("CK002", '"finish" must be one of: ' + + ", ".join(FINISHES))) + speeds = cart.get("speeds") + if speeds is not None: + if not isinstance(speeds, list) or not speeds: + findings.append(err("CK002", + '"speeds" must be a non-empty array')) + elif any(v not in SPEED_LEVELS for v in speeds): + findings.append(err("CK002", '"speeds" entries must be game-speed ' + 'levels: ' + + ", ".join(str(v) for v in SPEED_LEVELS))) for key in cart: if key not in CART_KEYS: @@ -424,6 +442,9 @@ def check_mods(cart, findings): check_github_pin(entry, label, findings) else: check_gamebanana_pin(entry, label, findings) + if "enabled" in entry and not isinstance(entry["enabled"], bool): + findings.append(err("CK004", f"{label} enabled must be true or " + "false; omit it to ship the mod on")) if "options" in entry and entry["options"] is not None: for problem in option_problems(entry["options"], label): findings.append(err("CK004", problem)) diff --git a/tools/crystal_movie_symbols.py b/tools/crystal_movie_symbols.py index 82d8a12e..8fec9ce5 100644 --- a/tools/crystal_movie_symbols.py +++ b/tools/crystal_movie_symbols.py @@ -62,10 +62,12 @@ TITLE_SYMBOLS = [ "TitleScreenPalettes", ] -# engine/movie/splash.asm:344 -- replaces Gold's GameFreakLogoStarsGFX +# engine/movie/splash.asm:344 -- replaces Gold's GameFreakLogoStarsGFX. +# The Ditto OBJ palette is a local label (engine/gfx/cgb_layouts.asm:876-893). SPLASH_SYMBOLS = [ "GameFreakDittoGFX", "GameFreakDittoPaletteFade", + "_CGB_GamefreakLogo.GamefreakDittoPalette", ] MOVIE_SYMBOLS = ( diff --git a/tools/crystal_symbol_deltas.py b/tools/crystal_symbol_deltas.py index 800da88d..047dc1e3 100644 --- a/tools/crystal_symbol_deltas.py +++ b/tools/crystal_symbol_deltas.py @@ -1,6 +1,6 @@ """REQUIRED_SYMBOLS delta between pokegold.sym and pokecrystal.sym. -make_gold_manifest.REQUIRED_SYMBOLS names 340 symbols by hand. 25 of them do +make_gold_manifest.REQUIRED_SYMBOLS names 341 symbols by hand. 25 of them do not exist in pokecrystal.sym: Crystal renamed the credits mons, split the trainer-card / Pokegear / pack-pals blocks by player gender, split the two FontsExtra tiles apart, and replaced the whole Gold/Silver intro movie and @@ -54,6 +54,8 @@ ADD_SYMBOLS = frozenset({ "MalePokegearPals", "FemalePokegearPals", # engine/gfx/cgb_layouts.asm:818,821 "_CGB_PackPals.ChrisPackPals", "_CGB_PackPals.KrisPackPals", + # engine/events/fishing_gfx.asm:41 -- Kris' half of the fishing pose. + "KrisFishingGFX", # gfx/font.asm:51,63 -- black is 1bpp at tile $60, up_arrow 2bpp at $61. "FontsExtra_SolidBlackGFX", "FontsExtra2_UpArrowGFX", # main.asm:425-448 -- the pic-animation pointer tables, absent from Gold. diff --git a/tools/make_gold_manifest.py b/tools/make_gold_manifest.py index 5f48fb3b..e9564f9e 100644 --- a/tools/make_gold_manifest.py +++ b/tools/make_gold_manifest.py @@ -840,6 +840,9 @@ REQUIRED_SYMBOLS = { # who just spotted the player, and the other faces scripts use. "ShockEmote", "QuestionEmote", "HappyEmote", "SadEmote", "HeartEmote", "BoltEmote", "SleepEmote", "FishEmote", + # gfx/overworld/chris_fish.2bpp (engine/events/fishing_gfx.asm:23): the + # fishing pose rows and the rod tiles FacingFish* parks by the player. + "FishingGFX", # The Pokecenter heal machine's OBJ art (engine/events/ # heal_machine_anim.asm): two tiles -- the machine's light ($7c) and the # ball ($7d) -- plus the CGB palette .LoadPalettes copies over diff --git a/tools/rom_manifest_crystal.json b/tools/rom_manifest_crystal.json index e1b7609d..260b93f7 100644 --- a/tools/rom_manifest_crystal.json +++ b/tools/rom_manifest_crystal.json @@ -15796,6 +15796,10 @@ 36, 25736 ], + "FishingGFX": [ + 46, + 17650 + ], "FlaaffyBackpic": [ 85, 16753 @@ -16908,6 +16912,10 @@ 34, 17813 ], + "KrisFishingGFX": [ + 46, + 17794 + ], "KrisPic": [ 34, 19385 @@ -20940,6 +20948,10 @@ 2, 20822 ], + "_CGB_GamefreakLogo.GamefreakDittoPalette": [ + 2, + 21793 + ], "_CGB_PackPals.ChrisPackPals": [ 2, 21561 diff --git a/tools/rom_manifest_gold.json b/tools/rom_manifest_gold.json index 333445da..e42afb81 100644 --- a/tools/rom_manifest_gold.json +++ b/tools/rom_manifest_gold.json @@ -14072,6 +14072,10 @@ 36, 27127 ], + "FishingGFX": [ + 20, + 17792 + ], "FlaaffyBackpic": [ 25, 23889 diff --git a/tools/rom_manifest_silver.json b/tools/rom_manifest_silver.json index 0b869a25..789fb014 100644 --- a/tools/rom_manifest_silver.json +++ b/tools/rom_manifest_silver.json @@ -14072,6 +14072,10 @@ 36, 27127 ], + "FishingGFX": [ + 20, + 17792 + ], "FlaaffyBackpic": [ 25, 24656 diff --git a/tools/shaderfx-bridge/.gitignore b/tools/shaderfx-bridge/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/tools/shaderfx-bridge/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tools/shaderfx-bridge/Cargo.lock b/tools/shaderfx-bridge/Cargo.lock new file mode 100644 index 00000000..3f3652bc --- /dev/null +++ b/tools/shaderfx-bridge/Cargo.lock @@ -0,0 +1,955 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "glslang" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0db73ac0e8bd6f9c253b70205f2bb1c63845b6e263665ac8063316ce16f408c8" +dependencies = [ + "bitflags", + "glslang-sys", + "rustc-hash 2.1.3", + "smartstring", + "thiserror 2.0.20", +] + +[[package]] +name = "glslang-sys" +version = "0.8.1+275822a" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec80baca8831e8414db40c562fd76ca6a32a792c1db990451d4e7d3cb246e1ed" +dependencies = [ + "cc", + "glob", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "serde", + "zerocopy", +] + +[[package]] +name = "halfbrown" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7ed2f2edad8a14c8186b847909a41fbb9c3eafa44f88bd891114ed5019da09" +dependencies = [ + "hashbrown 0.16.1", + "serde", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "librashader-common" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75fcb7b016f592e08241eb627ab94d8b2e435769b99f2705a2ba11c063a1b5fc" +dependencies = [ + "bitflags", + "halfbrown", + "num-traits", + "rustc-hash 2.1.3", + "serde", + "strumbra", +] + +[[package]] +name = "librashader-pack" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e1405d8ae8996621cacbc5d1f9220486eda4cafaf229da02c02eecfbbe2cf" +dependencies = [ + "image", + "librashader-common", + "librashader-preprocess", + "librashader-presets", + "rayon", +] + +[[package]] +name = "librashader-preprocess" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62507ee532a6f3320acac509e3d3b32e8828c8bd1161a74bc576351729c820e2" +dependencies = [ + "encoding_rs", + "librashader-common", + "nom", + "serde", + "thiserror 2.0.20", +] + +[[package]] +name = "librashader-presets" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d70306212493fbb4be617cdb98b7048cb896b16182951aeb17856bee3d14aeff" +dependencies = [ + "librashader-common", + "librashader-preprocess", + "nom", + "nom_locate", + "num-traits", + "once_cell", + "regex", + "serde", + "thiserror 2.0.20", + "vec_extract_if_polyfill", +] + +[[package]] +name = "librashader-reflect" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923a7ea3724e2fca281a08598217fe2daa5b6a245a278448251c851f19c0b290" +dependencies = [ + "bitflags", + "bytemuck", + "glslang", + "librashader-common", + "librashader-pack", + "librashader-preprocess", + "librashader-presets", + "naga", + "rspirv", + "rustc-hash 2.1.3", + "spirv", + "spirv-cross2", + "thiserror 2.0.20", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23bf0a141a9ab6f07dbb492db53245e464bc9db42f407772d9ae03d83a2c1033" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "petgraph", + "rustc-hash 1.1.0", + "serde", + "spirv", + "thiserror 2.0.20", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658200ddc25c6c7b860747516d132d1b284c0fafb7a380233acee9a72fb30e11" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "serde", + "thiserror 2.0.20", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rspirv" +version = "0.13.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091dca2e1d6fd3098417b5ec88e77e80d1ba5945750943419dc976858082c296" +dependencies = [ + "rustc-hash 1.1.0", + "spirv", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + +[[package]] +name = "spike" +version = "0.1.0" +dependencies = [ + "librashader-common", + "librashader-preprocess", + "librashader-presets", + "librashader-reflect", + "serde", + "serde_json", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "spirv-cross-sys" +version = "0.7.2+38681a3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9291b270f65ee1e61842d0a4dadc707d3b1669d8001e0900433e341c897d5d1b" +dependencies = [ + "bytemuck", + "cc", + "num-derive", + "num-traits", +] + +[[package]] +name = "spirv-cross2" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e01f93f6f1637a1a72b43d2ca50ebbcd94c3f4c4e94eda8e27144834671f008" +dependencies = [ + "bitflags", + "bytemuck", + "memchr", + "spirv", + "spirv-cross-sys", + "spirv-cross2-derive", + "thiserror 1.0.69", +] + +[[package]] +name = "spirv-cross2-derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18017a288e6ce64dd5d56510166baeabb01849483555c031f573c091b6934a64" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strumbra" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b498044485e2789b38e047fdb4c7ac827bd72d66407af9e5d15626f9c45364e" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "vec_extract_if_polyfill" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c9cb5fb67c2692310b6eb3fce7dd4b6e4c9a75be4f2f46b27f0b2b7799759c" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/tools/shaderfx-bridge/Cargo.toml b/tools/shaderfx-bridge/Cargo.toml new file mode 100644 index 00000000..d9671c35 --- /dev/null +++ b/tools/shaderfx-bridge/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "spike" +version = "0.1.0" +edition = "2024" + +[lib] +name = "librashader_bridge" +crate-type = ["cdylib", "rlib"] + +[dependencies] +librashader-preprocess = "0.12.0" +librashader-presets = "0.12.0" +librashader-reflect = "0.12.0" +librashader-common = "0.12.0" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/tools/shaderfx-bridge/src/lib.rs b/tools/shaderfx-bridge/src/lib.rs new file mode 100644 index 00000000..2cd175f2 --- /dev/null +++ b/tools/shaderfx-bridge/src/lib.rs @@ -0,0 +1,1009 @@ +// Plain FFI surface: call a function, get strings/a manifest back. No +// Librashader runtime backend (GL/Vulkan/D3D/Metal) is linked in and no +// live graphics context is ever touched here -- this is +// librashader-presets + librashader-reflect only, the translation step, +// exactly what spike/src/main.rs already proved works as a standalone +// binary. This just gives that same logic a stable C ABI so a LuaJIT +// `ffi.load`/`ffi.cdef` caller can reach it. + +use std::collections::HashMap; +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::path::Path; + +use librashader_common::{FilterMode, WrapMode}; +use librashader_presets::{Scaling, ScaleFactor, ScaleType, ShaderFeatures, ShaderPreset}; +use librashader_preprocess::ShaderSource; +use librashader_reflect::back::glsl::GlslVersion; +use librashader_reflect::back::targets::GLSL; +use librashader_reflect::back::{CompileShader, FromCompilation}; +use librashader_reflect::front::{Glslang, ShaderInputCompiler}; +use librashader_reflect::reflect::semantics::{ + Semantic, ShaderSemantics, TextureSemanticMap, TextureSemantics, UniformSemantic, + UniqueSemanticMap, +}; +use librashader_reflect::reflect::ReflectShader; +use serde::Serialize; + +fn filter_str(f: FilterMode) -> &'static str { + match f { + FilterMode::Linear => "linear", + FilterMode::Nearest => "nearest", + } +} + +fn wrap_str(w: WrapMode) -> &'static str { + match w { + WrapMode::ClampToBorder => "clamp_to_border", + WrapMode::ClampToEdge => "clamp_to_edge", + WrapMode::Repeat => "repeat", + WrapMode::MirroredRepeat => "mirrored_repeat", + } +} + +fn texture_semantics_str(t: TextureSemantics) -> &'static str { + match t { + TextureSemantics::Original => "Original", + TextureSemantics::Source => "Source", + TextureSemantics::OriginalHistory => "OriginalHistory", + TextureSemantics::PassOutput => "PassOutput", + TextureSemantics::PassFeedback => "PassFeedback", + TextureSemantics::User => "User", + } +} + +/// Every `LIBRA_TEXTURE_` sampler2D uniform this pass's *already +/// compiled* GLSL declares -- scraped from the same compiler output +/// `love-test/main.lua`'s old `findSamplers` regexed directly, just done +/// once here instead of by every Lua consumer. +fn extract_texture_uniform_names(glsl: &str) -> Vec { + const PREFIX: &str = "LIBRA_TEXTURE_"; + let mut names = Vec::new(); + for line in glsl.lines() { + let t = line.trim(); + if !t.starts_with("uniform ") || !t.contains("sampler2D") { + continue; + } + let Some(pos) = t.find(PREFIX) else { continue }; + let rest = &t[pos + PREFIX.len()..]; + let end = rest + .find(|c: char| !(c.is_alphanumeric() || c == '_')) + .unwrap_or(rest.len()); + let name = &rest[..end]; + if !name.is_empty() && !names.iter().any(|n: &String| n == name) { + names.push(name.to_string()); + } + } + names +} + +/// Classifies every declared `LIBRA_TEXTURE_` sampler using the same +/// `librashader_reflect::reflect::semantics` resolution librashader itself +/// uses at runtime (`ShaderSemantics::create_pass_semantics` + the +/// `TextureSemanticMap` name-lookup fallback: explicit alias/LUT-name +/// entries first, then the built-in `Source`/`Original`/`OriginalHistoryN`/ +/// `PassOutputN`/`PassFeedbackN` literal-name conventions) -- replaces the +/// Lua-side `passOutputAlias`/`originalHistory`/hardcoded +/// `"COLOR_PALETTE"`/`"BACKGROUND"` name matching: a pass reachable only +/// by its real `.slangp` alias (not a +/// literal `PassOutputN` name), or a preset with differently-named LUTs, +/// could never be resolved by that pattern matching but resolves correctly +/// here because this is the actual API meant for it, not a reimplication +/// of its naming convention. +/// `ShaderSemantics::create_pass_semantics` (the per-pass convenience API +/// this file uses instead of upstream's real `compile_preset_passes`) +/// only registers the alias of the pass being +/// compiled, not every other pass's alias -- upstream's real preset-wide +/// compile builds one shared semantics map covering every pass's alias +/// before compiling any of them (`librashader_reflect::reflect::presets:: +/// compile_preset_passes`'s own `insert_pass_semantics` loop over ALL +/// passes). That gap is invisible for a preset where every cross-pass +/// texture reference uses the literal `PassOutputN` name (matched by +/// `TextureSemanticMap`'s own prefix-strip fallback regardless of this +/// map), which is all the earlier per-pass approach was ever validated +/// against -- but a real +/// preset can and does reference another pass's output purely by its own +/// `aliasN` (`ds-hybrid-scalefx.slangp`'s `scalefx-pass2.slang` samples +/// `scalefx_pass0`, pass 1's alias, with no `PassOutput1`-shaped name +/// anywhere), which the per-pass map can never resolve. Mirrors upstream's +/// own `insert_pass_semantics` (alias -> PassOutput, `Size` -> +/// PassOutput's size semantic, `Feedback`/`FeedbackSize` -> +/// PassFeedback) since that function itself isn't exported publicly. +fn insert_extra_pass_semantics(semantics: &mut ShaderSemantics, alias: &str, index: usize) { + if alias.trim().is_empty() { + return; + } + semantics.texture_semantics.insert( + alias.into(), + Semantic { + semantics: TextureSemantics::PassOutput, + index, + }, + ); + semantics.uniform_semantics.insert( + format!("{alias}Size").into(), + UniformSemantic::Texture(Semantic { + semantics: TextureSemantics::PassOutput, + index, + }), + ); + semantics.texture_semantics.insert( + format!("{alias}Feedback").into(), + Semantic { + semantics: TextureSemantics::PassFeedback, + index, + }, + ); + semantics.uniform_semantics.insert( + format!("{alias}FeedbackSize").into(), + UniformSemantic::Texture(Semantic { + semantics: TextureSemantics::PassFeedback, + index, + }), + ); +} + +fn classify_samplers( + preset: &ShaderPreset, + semantics: &ShaderSemantics, + fragment: &str, +) -> Vec { + let names = extract_texture_uniform_names(fragment); + names + .into_iter() + .map(|name| match semantics.texture_semantics.texture_semantic(&name) { + Some(sem) => { + let user_name = if matches!(sem.semantics, TextureSemantics::User) { + preset.textures.get(sem.index).map(|t| t.meta.name.to_string()) + } else { + None + }; + SamplerOut { + name, + semantic: texture_semantics_str(sem.semantics).to_string(), + index: sem.index, + user_name, + } + } + None => SamplerOut { + name, + semantic: "Unknown".to_string(), + index: 0, + user_name: None, + }, + }) + .collect() +} + +/// Every member name a given struct (named `struct_name`) declares in this +/// pass's *already compiled* GLSL -- scraped the same way +/// `ShaderFixup.lua`'s `flattenStruct` does (`struct { +/// ; ... };`), before that struct gets flattened away into packed +/// uniforms. Includes every member of whichever struct is named: `*Size` +/// uniforms (`OutputSize`, `SourceSize`, `OriginalHistorySize1`, ...), +/// ordinary `#pragma parameter`s (`response_time`, `color_toggle`, ...), and +/// (when scanning a `LIBRA_UBO_*` struct) `MVP`/motion uniforms +/// (`Gyroscope`, `Accelerometer`, `AccelerometerRest`) alike -- +/// `classify_size_uniforms` below is what tells them apart. +fn extract_struct_member_names(glsl: &str, struct_name: &str) -> Vec { + let marker = format!("struct {struct_name}"); + let Some(start) = glsl.find(marker.as_str()) else { + return Vec::new(); + }; + let Some(open) = glsl[start..].find('{') else { + return Vec::new(); + }; + let body_start = start + open + 1; + let Some(close) = glsl[body_start..].find('}') else { + return Vec::new(); + }; + let body = &glsl[body_start..body_start + close]; + body.split(';') + .filter_map(|member| { + let member = member.trim(); + if member.is_empty() { + return None; + } + member.split_whitespace().last().map(|s| s.to_string()) + }) + .collect() +} + +/// Classifies every `LIBRA_PUSH_FRAGMENT` struct member using the same +/// `librashader_reflect::reflect::semantics` name-resolution +/// `classify_samplers` above uses for texture samplers, applied to the +/// pass's own `uniform_semantics` map instead of `texture_semantics`: +/// `unique_semantic` resolves the fixed built-in names (`OutputSize`, +/// `FinalViewportSize`, `MVP`, `FrameCount`, ...), `texture_semantic` +/// resolves the `*Size` convention `TextureSemantics::size_uniform_name` +/// defines (`SourceSize`, `OriginalSize`, `OriginalHistorySizeN`, +/// `PassOutputSizeN`, `PassFeedbackSizeN`, `UserSizeN`). This replaces +/// `love-test/main.lua`'s `sizeTable()`'s own `OriginalHistorySize(%d+)`/ +/// `PassOutputSize(%d+)` Lua regex -- the same class of gap the sampler- +/// wiring fix closed: a member that isn't one of those two literal name +/// shapes (a `PassFeedbackSizeN`/`UserSizeN`, which no preset under +/// `references/` happens to use but which `size_uniform_name`'s convention +/// covers just as well) could never be classified correctly by pattern +/// matching alone. Confirmed empirically (throwaway `main.rs` print block, +/// added/inspected/reverted, against the real preset): an ordinary +/// `#pragma parameter` (`response_time`, `color_toggle`, ...) is NOT an +/// unresolved name here -- `ShaderSemantics::create_pass_semantics` itself +/// registers every one of a pass's own declared parameters into +/// `uniform_semantics` as `UniqueSemantics::FloatParameter` +/// (`librashader_reflect::reflect::presets::create_pass_semantics`), so +/// `unique_semantic` resolves them too, as `kind = "unique", semantic = +/// "FloatParameter"` -- real classification, not a guess. `kind = +/// "parameter"` (semantic/index left empty) is the fallback for a name +/// that resolves to neither map, which no known preset's push struct ever +/// contains -- callers already have a separate, working mechanism for +/// actual parameter defaults (`ALL_DEFAULTS`), so this fallback only exists +/// to fail informatively rather than silently misclassify something odd as +/// a size uniform. +/// Structs classified, paired with whether to scan them from the vertex +/// (true) or fragment (false) GLSL text. `LIBRA_PUSH_FRAGMENT`/ +/// `LIBRA_PUSH_VERTEX` mirror each other (same #pragma parameters, per +/// ShaderFixup.lua's own documented convention) so scanning both is +/// redundant but harmless -- names are deduped below. +/// `LIBRA_UBO_FRAGMENT`/`LIBRA_UBO_VERTEX` are new here: previously only +/// ever carried a special-cased `MVP` (substituted to `transform_projection` +/// in ShaderFixup.lua, never needing a real classified value), but a real +/// preset (pixel_transparency.slang) also declares `Gyroscope`/ +/// `Accelerometer`/`AccelerometerRest` there once +/// `ShaderFeatures::SENSOR_UNIFORMS` is set -- those need real +/// classification too, the same as any other built-in. +const CLASSIFIED_STRUCTS: [(&str, bool); 4] = [ + ("LIBRA_PUSH_FRAGMENT", false), + ("LIBRA_PUSH_VERTEX", true), + ("LIBRA_UBO_FRAGMENT", false), + ("LIBRA_UBO_VERTEX", true), +]; + +fn classify_size_uniforms( + semantics: &ShaderSemantics, + vertex: &str, + fragment: &str, +) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for (struct_name, is_vertex) in CLASSIFIED_STRUCTS { + let glsl = if is_vertex { vertex } else { fragment }; + for name in extract_struct_member_names(glsl, struct_name) { + if !seen.insert(name.clone()) { + continue; // already classified from an earlier (mirrored) struct + } + out.push(if let Some(sem) = semantics.uniform_semantics.unique_semantic(&name) { + SizeUniformOut { + name, + kind: "unique".to_string(), + semantic: sem.semantics.as_str().to_string(), + index: 0, + } + } else if let Some(sem) = semantics.uniform_semantics.texture_semantic(&name) { + SizeUniformOut { + name, + kind: "texture".to_string(), + semantic: texture_semantics_str(sem.semantics).to_string(), + index: sem.index, + } + } else { + SizeUniformOut { + name, + kind: "parameter".to_string(), + semantic: String::new(), + index: 0, + } + }); + } + } + out +} + +fn scale_type_str(t: ScaleType) -> &'static str { + match t { + ScaleType::Input => "source", + ScaleType::Absolute => "absolute", + ScaleType::Viewport => "viewport", + ScaleType::Original => "original", + } +} + +#[derive(Serialize)] +pub struct ScalingOut { + pub scale_type: String, + /// "float": `factor` is a multiplier on the reference size (the normal + /// case). "absolute": `factor` is itself the size in pixels, verbatim + /// (only meaningful when `scale_type == "absolute"`). + pub factor_kind: String, + pub factor: f32, +} + +fn scaling_out(s: &Scaling) -> ScalingOut { + let (factor_kind, factor) = match s.factor { + ScaleFactor::Float(f) => ("float", f), + ScaleFactor::Absolute(i) => ("absolute", i as f32), + }; + ScalingOut { + scale_type: scale_type_str(s.scale_type).to_string(), + factor_kind: factor_kind.to_string(), + factor, + } +} + +/// A single `#pragma parameter` as declared in a pass's own shader source: +/// name, human-readable label, and the slider's default/min/max/step. This +/// is the real source of truth this project's Lua harnesses previously hand +/// -copied into a shared `ALL_DEFAULTS` table from reading `gb-params.inc` +/// by eye. +#[derive(Serialize)] +pub struct ParameterOut { + pub id: String, + pub description: String, + pub initial: f32, + pub minimum: f32, + pub maximum: f32, + pub step: f32, +} + +/// A preset-level parameter override (the `.slangp` file's own `param = value` +/// lines, e.g. `gameboy-color-dot-matrix.slangp`'s `color_toggle = "1.000000"`) +/// -- applied on top of each pass's own `ParameterOut.initial` default. +#[derive(Serialize)] +pub struct ParameterOverrideOut { + pub name: String, + pub value: f32, +} + +/// One of the preset's lookup textures (e.g. `COLOR_PALETTE`/`BACKGROUND` for +/// `gameboy-color-dot-matrix.slangp`) -- name to bind it as, the real path to +/// load, and its sampling settings. Previously hardcoded per-preset in Lua as +/// `paletteImg`/`backgroundImg`. +#[derive(Serialize)] +pub struct TextureOut { + pub name: String, + pub path: String, + pub wrap_mode: String, + pub filter_mode: String, + pub mipmap: bool, +} + +/// One `LIBRA_TEXTURE_` sampler this pass's compiled GLSL declares, +/// classified via real `librashader_reflect` semantics (see +/// `classify_samplers`) instead of Lua-side name-pattern matching. +/// `semantic` is one of `"Original"`/`"Source"`/`"OriginalHistory"`/ +/// `"PassOutput"`/`"PassFeedback"`/`"User"`/`"Unknown"` (the last only if +/// this pass declares a sampler no semantic could resolve at all -- a +/// genuine translation bug, not an expected case). `index` is meaningful +/// for every semantic except `Original`/`Source`/`Unknown` (always 0 there). +/// `user_name` is only set for `"User"`: the real preset texture name +/// (matches one of `TranslateResult.textures[].name`) to bind, resolved +/// from the same LUT list, not a hardcoded per-preset name. +#[derive(Serialize)] +pub struct SamplerOut { + pub name: String, + pub semantic: String, + pub index: usize, + pub user_name: Option, +} + +/// One `LIBRA_PUSH_FRAGMENT` struct member, classified via real +/// `librashader_reflect` semantics (see `classify_size_uniforms`) instead +/// of Lua-side `*Size` name-pattern matching. `kind` is `"unique"` (a fixed +/// built-in like `OutputSize`/`MVP`/`FrameCount` -- `semantic` names which +/// one, `index` always 0), `"texture"` (a `*Size` uniform tied to a texture +/// semantic -- `semantic` is one of `Original`/`Source`/`OriginalHistory`/ +/// `PassOutput`/`PassFeedback`/`User`, `index` meaningful for every +/// semantic except `Original`/`Source`), or `"parameter"` (an ordinary +/// `#pragma parameter`, not a size uniform at all -- `semantic` empty, +/// `index` 0). +#[derive(Serialize)] +pub struct SizeUniformOut { + pub name: String, + pub kind: String, + pub semantic: String, + pub index: usize, +} + +#[derive(Serialize)] +pub struct PassOutput { + pub id: String, + pub alias: Option, + pub vertex: String, + pub fragment: String, + pub filter: String, + pub wrap_mode: String, + pub frame_count_mod: u32, + pub srgb_framebuffer: bool, + pub float_framebuffer: bool, + pub mipmap_input: bool, + pub scale_x: ScalingOut, + pub scale_y: ScalingOut, + /// This pass's own `#pragma parameter` declarations (id/description/ + /// initial/min/max/step) -- every pass in the preset contributes to one + /// flat namespace (matching how `#pragma parameter` works upstream: + /// shared uniform names across passes are the same parameter), so a + /// consumer merges these across all passes, then layers the preset-level + /// `TranslateResult.parameter_overrides` on top by name. + pub parameters: Vec, + /// Every texture sampler this pass's fragment shader declares, real- + /// semantics-classified. See `SamplerOut`. + pub samplers: Vec, + /// Every `LIBRA_PUSH_FRAGMENT` struct member this pass declares + /// (`*Size` uniforms and `#pragma parameter`s alike), real-semantics- + /// classified. See `SizeUniformOut`. + pub size_uniforms: Vec, +} + +#[derive(Serialize)] +pub struct TranslateResult { + pub pass_count: usize, + pub passes: Vec, + pub textures: Vec, + pub parameter_overrides: Vec, + pub error: Option, +} + +/// The convert-time ESSL 100 gap: `textureSize`/`texelFetchOffset`/ +/// `textureOffset` are all GLSL ES 3.00+-only -- spirv-cross refuses to +/// *emit* them for an ES 1.00 target and the whole pass fails to compile +/// (`UnsupportedSpirv("textureSize is not supported in ESSL 100.")` etc, +/// captured for real across 14 real buildbot presets by `shaderfix-repro/`). +/// Unlike every other ES-1.00 fixup this +/// project does (`ShaderFixup.lua`, all post-compile GLSL-text patches), +/// this one has to happen on the RAW `.slang` source text *before* SPIRV +/// compilation -- spirv-cross's error means no GLSL text is ever produced +/// for the pass to patch afterward. Only ever called for the ES 1.00 +/// target; desktop GLSL 1.20 supports all three calls natively and is left +/// untouched. Rewrites, textually, into ES-1.00-legal equivalents: +/// - `textureSize(, )` -> a literal `ivec2(w, h)`, for a `` +/// that's one of this preset's own declared static textures (a LUT/ +/// border PNG with fixed, load-time-known dimensions -- `lut_sizes`, +/// read directly from each PNG's IHDR chunk). `` must be a +/// non-negative integer literal (true of every real occurrence found so +/// far -- always `0`); a `` not in `lut_sizes`, or a non-literal/ +/// negative ``, is left unrewritten so it fails exactly as loudly +/// as before instead of silently guessing wrong. +/// - `texelFetchOffset(, , , )` / +/// `textureOffset(, , )` -> `texture(, ...)` (NOT +/// `texture2D` -- the source here is still `#version 450` desktop GLSL +/// at this stage, before spirv-cross's own ES 1.00 backend does its +/// usual automatic `texture()` -> `texture2D()` downgrade during +/// emission, same as it already does for every other ordinary `texture()` +/// call in these shaders) sampling at the equivalent texel-center UV, +/// using ``'s own `Size` uniform's `.zw` (reciprocal size -- +/// every pass-graph texture Source/PassOutputN/OriginalHistoryN/etc +/// already declares one, per the slang spec's own convention, confirmed +/// directly against `lcd-grid-v2.slang`'s existing `texelSize = +/// global.SourceSize.zw` line). The uniform's containing block instance +/// name (`params`/`global`/etc) is discovered by scanning the source's +/// own push-constant/UBO block bodies for a member named `Size`, +/// never assumed to be one fixed block. Honest limit, not fixable +/// without a much bigger rewrite: `texture()` sampling honors the +/// sampler's configured wrap mode at out-of-range coordinates, which is +/// not necessarily identical to `texelFetch`'s implementation-defined +/// out-of-bounds behavior -- a possible edge-of-image discrepancy for +/// passes whose fetch offsets can go out of range, not measured, flagged +/// here for whoever verifies these presets' rendered output next. +fn rewrite_essl100_gaps(text: &mut String, lut_sizes: &HashMap) { + rewrite_texture_size_calls(text, lut_sizes); + rewrite_offset_fetch_calls(text, "texelFetchOffset", true); + rewrite_offset_fetch_calls(text, "textureOffset", false); +} + +fn rewrite_texture_size_calls(text: &mut String, lut_sizes: &HashMap) { + for (start, end, args) in find_calls(text, "textureSize").into_iter().rev() { + let (Some(tex), Some(lod)) = (args.first(), args.get(1)) else { + continue; + }; + let Some(&(w, h)) = lut_sizes.get(tex.trim()) else { + continue; + }; + let Ok(lod) = lod.trim().parse::() else { + continue; + }; + text.replace_range(start..end, &format!("ivec2({}, {})", w >> lod, h >> lod)); + } +} + +fn rewrite_offset_fetch_calls(text: &mut String, fn_name: &str, has_lod: bool) { + let expected_args = if has_lod { 4 } else { 3 }; + + // A pass can sample another pass's PassOutput/alias purely through + // texelFetchOffset/textureOffset with no textureSize() call of its own + // (`ds-hybrid-scalefx.slangp`'s scalefx-pass2.slang samples its + // `scalefx_pass0` alias this way) -- the raw shader source then never + // declares that texture's own `Size` uniform at all, since the + // author never needed it directly. Ensure one exists, injected into an + // existing uniform block, before computing any call's byte offsets + // below (injecting text shifts every later offset, so this must fully + // finish first, not interleave with the rewrite loop). + let mut tex_names: Vec = find_calls(text, fn_name) + .into_iter() + .filter(|(_, _, args)| args.len() == expected_args) + .map(|(_, _, args)| args[0].trim().to_string()) + .collect(); + tex_names.sort(); + tex_names.dedup(); + for tex in &tex_names { + ensure_size_uniform_declared(text, tex); + } + + for (start, end, args) in find_calls(text, fn_name).into_iter().rev() { + if args.len() != expected_args { + continue; + } + let tex = args[0].trim(); + let Some(prefix) = find_uniform_block_instance(text, &format!("{tex}Size")) else { + continue; + }; + let replacement = if has_lod { + format!( + "texture({tex}, (vec2(({coord}) + ({off})) + vec2(0.5)) * {prefix}.{tex}Size.zw)", + tex = tex, + coord = args[1].trim(), + off = args[3].trim(), + prefix = prefix + ) + } else { + format!( + "texture({tex}, ({uv}) + vec2({off}) * {prefix}.{tex}Size.zw)", + tex = tex, + uv = args[1].trim(), + off = args[2].trim(), + prefix = prefix + ) + }; + text.replace_range(start..end, &replacement); + } +} + +/// Finds every top-level call of `name(...)` in `src` (not preceded by an +/// identifier char, so a longer name that happens to end in `name` can't +/// false-match), returning `(start_byte, end_byte_exclusive, args)` per +/// call -- `args` are the call's comma-separated argument texts, split +/// respecting nested parens/brackets so an argument like `(coord)` or +/// `ivec2(0, 0)` doesn't get sliced apart. A plain regex can't do this +/// safely since GLSL call arguments nest arbitrarily. +fn find_calls(src: &str, name: &str) -> Vec<(usize, usize, Vec)> { + let bytes = src.as_bytes(); + let pat = format!("{name}("); + let mut out = Vec::new(); + let mut search_from = 0usize; + while let Some(rel) = src[search_from..].find(pat.as_str()) { + let start = search_from + rel; + let open = start + pat.len() - 1; + let preceded_by_ident = start > 0 && { + let c = bytes[start - 1] as char; + c.is_alphanumeric() || c == '_' + }; + if preceded_by_ident { + search_from = open + 1; + continue; + } + let mut depth = 0i32; + let mut i = open; + let mut close = None; + while i < bytes.len() { + match bytes[i] { + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + close = Some(i); + break; + } + } + _ => {} + } + i += 1; + } + let Some(close) = close else { break }; + let args = split_top_level_args(&src[open + 1..close]); + out.push((start, close + 1, args)); + search_from = close + 1; + } + out +} + +fn split_top_level_args(s: &str) -> Vec { + let mut args = Vec::new(); + let mut depth = 0i32; + let mut cur = String::new(); + for c in s.chars() { + match c { + '(' | '[' => { + depth += 1; + cur.push(c); + } + ')' | ']' => { + depth -= 1; + cur.push(c); + } + ',' if depth == 0 => { + args.push(cur.trim().to_string()); + cur = String::new(); + } + _ => cur.push(c), + } + } + if !cur.trim().is_empty() || !args.is_empty() { + args.push(cur.trim().to_string()); + } + args +} + +/// One `[layout(...)] uniform { ... } ;` declaration +/// found by `find_uniform_blocks` -- `body_start`/`body_end` bound the +/// member-declaration text between (not including) the braces. +struct UniformBlockSpan { + instance: String, + body_start: usize, + body_end: usize, +} + +/// Scans `src` for every `[layout(...)] uniform { ... } ;` +/// declaration (push-constant or UBO alike, `params`/`global`/etc -- never +/// one fixed instance name, confirmed by both shapes existing in the real +/// corpus: `lcd-grid-v2.slang`'s `global.SourceSize` vs. +/// `scalefx-pass0.slang`'s `params.SourceSize`). Skips plain non-block +/// uniform declarations (e.g. `uniform sampler2D Source;`) by requiring a +/// `{` immediately (past whitespace) after the block-name identifier. +fn find_uniform_blocks(src: &str) -> Vec { + let bytes = src.as_bytes(); + let mut out = Vec::new(); + let mut search_from = 0usize; + while let Some(rel) = src[search_from..].find("uniform ") { + let kw_start = search_from + rel; + let after_kw = kw_start + "uniform ".len(); + let rest = &src[after_kw..]; + let ident_end = rest + .find(|c: char| !(c.is_alphanumeric() || c == '_')) + .unwrap_or(rest.len()); + let after_ident = rest[ident_end..].trim_start(); + if !after_ident.starts_with('{') { + search_from = after_kw; + continue; + } + let open = after_kw + (rest.len() - after_ident.len()); + let mut depth = 0i32; + let mut i = open; + let mut close = None; + while i < bytes.len() { + match bytes[i] { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + close = Some(i); + break; + } + } + _ => {} + } + i += 1; + } + let Some(close) = close else { break }; + let after = src[close + 1..].trim_start(); + let instance = after + .split(|c: char| c == ';' || c.is_whitespace()) + .next() + .unwrap_or(""); + if !instance.is_empty() { + out.push(UniformBlockSpan { + instance: instance.to_string(), + body_start: open + 1, + body_end: close, + }); + } + search_from = close + 1; + } + out +} + +/// The accessor `` for the block (of `find_uniform_blocks`) whose +/// body declares a member named exactly `member` -- used to correctly +/// qualify a `Size` reference once it's known to exist somewhere. +fn find_uniform_block_instance(src: &str, member: &str) -> Option { + find_uniform_blocks(src) + .into_iter() + .find(|b| member_declared(&src[b.body_start..b.body_end], member)) + .map(|b| b.instance) +} + +/// Guarantees `Size` is declared in some uniform block of `text`, +/// injecting `vec4 Size;` into one if it isn't already there, and +/// returns that block's accessor either way. Needed for +/// `rewrite_offset_fetch_calls`: a pass can reference another pass's +/// PassOutput/alias purely through texelFetchOffset/textureOffset with no +/// textureSize() call of its own (`ds-hybrid-scalefx.slangp`'s +/// scalefx-pass2.slang samples its `scalefx_pass0` alias exactly this way), +/// so the raw shader source never declares that texture's own `*Size` +/// uniform at all -- the author never needed it directly, but our rewrite +/// does. Prefers a block that already declares some other `*Size` member +/// (matching the real convention every corpus shader already follows of +/// keeping every `*Size` uniform in one block alongside `SourceSize`/ +/// `OutputSize`), falling back to the first uniform block found at all; +/// `None` only if the shader declares no uniform block whatsoever, which no +/// known real corpus shader does. +fn ensure_size_uniform_declared(text: &mut String, tex: &str) -> Option { + let member = format!("{tex}Size"); + if let Some(instance) = find_uniform_block_instance(text, &member) { + return Some(instance); + } + let blocks = find_uniform_blocks(text); + let target = blocks + .iter() + .find(|b| { + text[b.body_start..b.body_end] + .split(';') + .any(|stmt| stmt.trim_end().ends_with("Size")) + }) + .or_else(|| blocks.first())?; + let instance = target.instance.clone(); + let insert_at = target.body_end; + text.insert_str(insert_at, &format!("\tvec4 {member};\n")); + Some(instance) +} + +fn member_declared(body: &str, member: &str) -> bool { + body.split(';').any(|stmt| { + stmt.split_whitespace() + .last() + .map(|last| last == member) + .unwrap_or(false) + }) +} + +/// Reads a PNG's real pixel width/height straight from its IHDR chunk +/// (bytes 16..20 = width, 20..24 = height, big-endian u32, always the +/// first chunk right after the 8-byte signature per the PNG spec) -- no +/// image-decoding crate needed for just the header, consistent with this +/// project's preference for minimal dependencies. `None` on any read/parse +/// failure; `rewrite_texture_size_calls` treats a missing entry as "leave +/// unrewritten," so this fails the same loud, pre-existing way rather than +/// silently. +fn png_dimensions(path: &Path) -> Option<(u32, u32)> { + let bytes = std::fs::read(path).ok()?; + if bytes.len() < 24 || &bytes[0..8] != b"\x89PNG\r\n\x1a\n" || &bytes[12..16] != b"IHDR" { + return None; + } + let w = u32::from_be_bytes(bytes[16..20].try_into().ok()?); + let h = u32::from_be_bytes(bytes[20..24].try_into().ok()?); + Some((w, h)) +} + +pub fn compile_pass_glsl( + source: &ShaderSource, + version: GlslVersion, +) -> Result<(String, String), Box> { + let compiled = Glslang::compile(source)?; + let mut reflect = GLSL::from_compilation(compiled)?; + let semantics = ShaderSemantics { + uniform_semantics: Default::default(), + texture_semantics: Default::default(), + }; + // Best-effort, matching main.rs: don't fail the whole pass if reflect() + // can't resolve semantics we never populated. + let _ = reflect.reflect(0, &semantics); + let output = reflect.compile(version)?; + Ok((output.vertex, output.fragment)) +} + +/// Parses a `.slangp` preset and compiles every pass to GLSL text at the +/// requested dialect. No fixup (precision header, struct flattening, +/// main()->effect() rewrite) happens here -- that stays a Lua-side stage, +/// run against the returned GLSL text. +pub fn translate_preset(preset_path: &Path, version: GlslVersion) -> TranslateResult { + // SENSOR_UNIFORMS only defines `_HAS_SENSOR_UNIFORMS` in preprocessing so + // a preset's own `#ifdef` guard (e.g. pixel_transparency.slang's + // Gyroscope/Accelerometer/AccelerometerRest block) compiles in -- the + // resulting uniforms are then already classified generically by + // classify_size_uniforms below via the real `unique_semantic` lookup, + // same as every other built-in (OutputSize, MVP, FrameCount, ...), no + // further special-casing needed here. + let preset = match ShaderPreset::try_parse(preset_path, ShaderFeatures::SENSOR_UNIFORMS) { + Ok(p) => p, + Err(e) => { + return TranslateResult { + pass_count: 0, + passes: Vec::new(), + textures: Vec::new(), + parameter_overrides: Vec::new(), + error: Some(format!("preset parse failed: {e}")), + }; + } + }; + + let textures = preset + .textures + .iter() + .map(|t| TextureOut { + name: t.meta.name.to_string(), + path: t.path.to_string_lossy().into_owned(), + wrap_mode: wrap_str(t.meta.wrap_mode).to_string(), + filter_mode: filter_str(t.meta.filter_mode).to_string(), + mipmap: t.meta.mipmap, + }) + .collect(); + + let parameter_overrides = preset + .parameters + .iter() + .map(|p| ParameterOverrideOut { + name: p.name.to_string(), + value: p.value, + }) + .collect(); + + // Only needed for `rewrite_essl100_gaps`'s `textureSize()` case -- + // real load-time dimensions for this preset's own declared static + // textures (LUTs, borders), read once, reused for every pass. + let lut_sizes: HashMap = preset + .textures + .iter() + .filter_map(|t| png_dimensions(&t.path).map(|dim| (t.meta.name.to_string(), dim))) + .collect(); + + let mut passes = Vec::new(); + for (pass_index, pass) in preset.passes.iter().enumerate() { + let mut source = match ShaderSource::load(&pass.path, preset.features) { + Ok(s) => s, + Err(e) => { + return TranslateResult { + pass_count: preset.pass_count as usize, + passes, + textures, + parameter_overrides, + error: Some(format!( + "pass {} source load failed: {e}", + pass.meta.id + )), + }; + } + }; + // ES 1.00 can't emit textureSize/texelFetchOffset/ + // textureOffset at all (spirv-cross errors before any GLSL text + // exists to patch post-compile) -- rewrite the raw source first. + // Desktop GLSL 1.20 supports all three natively; left untouched. + if matches!(version, GlslVersion::Glsl100Es) { + rewrite_essl100_gaps(&mut source.vertex, &lut_sizes); + rewrite_essl100_gaps(&mut source.fragment, &lut_sizes); + } + let mut parameters: Vec = source + .parameters + .values() + .map(|p| ParameterOut { + id: p.id.to_string(), + description: p.description.clone(), + initial: p.initial, + minimum: p.minimum, + maximum: p.maximum, + step: p.step, + }) + .collect(); + // FastHashMap iteration order isn't stable -- sort so the JSON (and + // anything downstream that diffs it) doesn't churn run to run. + parameters.sort_by(|a, b| a.id.cmp(&b.id)); + + match compile_pass_glsl(&source, version) { + Ok((vertex, fragment)) => { + let mut semantics = match ShaderSemantics::create_pass_semantics::< + Box, + >(&preset, pass_index) + { + Ok(s) => s, + Err(e) => { + return TranslateResult { + pass_count: preset.pass_count as usize, + passes, + textures, + parameter_overrides, + error: Some(format!( + "pass {} semantics failed: {e}", + pass.meta.id + )), + }; + } + }; + // See insert_extra_pass_semantics: create_pass_semantics only + // knows this pass's own alias; a later pass can still sample + // an earlier pass's output purely by that earlier pass's own + // alias, which needs registering here too. + for (other_index, other_pass) in preset.passes.iter().enumerate() { + if other_index == pass_index { + continue; + } + if let Some(alias) = other_pass.meta.alias.as_ref() { + insert_extra_pass_semantics(&mut semantics, alias, other_index); + } + } + let samplers = classify_samplers(&preset, &semantics, &fragment); + let size_uniforms = classify_size_uniforms(&semantics, &vertex, &fragment); + passes.push(PassOutput { + id: pass.meta.id.to_string(), + alias: pass.meta.alias.as_ref().map(|a| a.to_string()), + vertex, + fragment, + filter: filter_str(pass.meta.filter).to_string(), + wrap_mode: wrap_str(pass.meta.wrap_mode).to_string(), + frame_count_mod: pass.meta.frame_count_mod, + srgb_framebuffer: pass.meta.srgb_framebuffer, + float_framebuffer: pass.meta.float_framebuffer, + mipmap_input: pass.meta.mipmap_input, + scale_x: scaling_out(&pass.meta.scaling.x), + scale_y: scaling_out(&pass.meta.scaling.y), + parameters, + samplers, + size_uniforms, + }) + } + Err(e) => { + return TranslateResult { + pass_count: preset.pass_count as usize, + passes, + textures, + parameter_overrides, + error: Some(format!("pass {} compile failed: {e}", pass.meta.id)), + }; + } + } + } + + TranslateResult { + pass_count: preset.pass_count as usize, + passes, + textures, + parameter_overrides, + error: None, + } +} + +/// `es != 0` targets GLSL ES 1.00 (mobile/LÖVE's ES dialect); `es == 0` +/// targets GLSL 1.20 (LÖVE's desktop dialect). Returns a JSON-encoded +/// `TranslateResult`, always -- even the error case is valid JSON with an +/// `error` string and empty `passes`, so a caller never has to distinguish +/// "call failed" from "call succeeded but returned an error" at the ABI +/// boundary, only by reading the JSON. +/// +/// Ownership: the returned pointer is heap-allocated by Rust and must be +/// freed with `librashader_free_string`, never with the caller's own +/// allocator. +#[unsafe(no_mangle)] +pub extern "C" fn librashader_translate_preset(preset_path: *const c_char, es: i32) -> *mut c_char { + let path_str = unsafe { + if preset_path.is_null() { + return CString::new("{\"error\":\"null preset_path\",\"pass_count\":0,\"passes\":[],\"textures\":[],\"parameter_overrides\":[]}") + .unwrap() + .into_raw(); + } + CStr::from_ptr(preset_path).to_string_lossy().into_owned() + }; + let version = if es != 0 { + GlslVersion::Glsl100Es + } else { + GlslVersion::Glsl120 + }; + let result = translate_preset(Path::new(&path_str), version); + let json = serde_json::to_string(&result).unwrap_or_else(|e| { + format!("{{\"error\":\"serialize failed: {e}\",\"pass_count\":0,\"passes\":[],\"textures\":[],\"parameter_overrides\":[]}}") + }); + CString::new(json) + .unwrap_or_else(|_| { + CString::new("{\"error\":\"nul byte in translated output\",\"pass_count\":0,\"passes\":[],\"textures\":[],\"parameter_overrides\":[]}") + .unwrap() + }) + .into_raw() +} + +/// Frees a string returned by `librashader_translate_preset`. Must be +/// called exactly once per returned pointer, from the same allocator +/// (this library) that produced it -- never `free()` the pointer directly +/// from the caller's side. +#[unsafe(no_mangle)] +pub extern "C" fn librashader_free_string(s: *mut c_char) { + if s.is_null() { + return; + } + unsafe { + drop(CString::from_raw(s)); + } +} diff --git a/tools/shaderfx-bridge/src/main.rs b/tools/shaderfx-bridge/src/main.rs new file mode 100644 index 00000000..8ef0eec7 --- /dev/null +++ b/tools/shaderfx-bridge/src/main.rs @@ -0,0 +1,123 @@ +use std::path::PathBuf; + +use librashader_presets::{ShaderFeatures, ShaderPreset}; +use librashader_preprocess::ShaderSource; +use librashader_reflect::back::glsl::GlslVersion; +use librashader_bridge::compile_pass_glsl; + +fn count_uniform_vectors(glsl: &str) -> (usize, Vec) { + let mut total = 0usize; + let mut lines = Vec::new(); + for line in glsl.lines() { + let t = line.trim(); + if !t.starts_with("uniform ") { + continue; + } + // crude: skip sampler uniforms, they don't cost a fragment uniform *vector* slot + if t.contains("sampler") { + lines.push(format!("(sampler, not counted) {t}")); + continue; + } + let cost = if t.contains("mat4") { + 4 + } else if t.contains("mat3") { + 3 + } else if t.contains("mat2") { + 2 + } else { + 1 // scalar, vec2, vec3, vec4 all cost one vec4 slot each + }; + // arrays: uniform float foo[8]; costs 8 slots + let mut n = cost; + if let Some(br) = t.find('[') { + if let Some(close) = t[br..].find(']') { + if let Ok(count) = t[br + 1..br + close].trim().parse::() { + n = cost * count; + } + } + } + total += n; + lines.push(format!("({n} slot{}) {t}", if n == 1 { "" } else { "s" })); + } + (total, lines) +} + +fn main() -> Result<(), Box> { + let preset_path = PathBuf::from( + "../references/slang-shaders-gameboy/handheld/gameboy-color-dot-matrix.slangp", + ); + println!("=== parsing preset: {} ===", preset_path.display()); + let preset = ShaderPreset::try_parse(&preset_path, ShaderFeatures::empty())?; + println!("pass_count = {}", preset.pass_count); + for pass in &preset.passes { + println!( + " pass {} alias={:?} -> {}", + pass.meta.id, + pass.meta.alias, + pass.path.display() + ); + } + for tex in &preset.textures { + println!( + " texture {} -> {} (linear={:?})", + tex.meta.name, + tex.path.display(), + tex.meta.filter_mode + ); + } + println!(); + + std::fs::create_dir_all("out")?; + + for pass in &preset.passes { + let label = format!( + "pass{}{}", + pass.meta.id, + pass.meta + .alias + .as_ref() + .map(|a| format!("_{a}")) + .unwrap_or_default() + ); + println!("=== {label}: {} ===", pass.path.display()); + + let source = match ShaderSource::load(&pass.path, preset.features) { + Ok(s) => s, + Err(e) => { + println!(" !! ShaderSource::load failed: {e}"); + continue; + } + }; + println!(" parameters: {}", source.parameters.len()); + for (name, p) in &source.parameters { + println!( + " {name} = {} (min {} max {} step {})", + p.initial, p.minimum, p.maximum, p.step + ); + } + + for (tag, version) in [("100es", GlslVersion::Glsl100Es), ("120", GlslVersion::Glsl120)] { + match compile_pass_glsl(&source, version) { + Ok((vert, frag)) => { + let (slots, lines) = count_uniform_vectors(&frag); + println!(" -- GLSL {tag}: fragment uniform-vector cost = {slots}"); + for l in &lines { + println!(" {l}"); + } + let out_path = format!("out/{label}_{tag}.frag.glsl"); + std::fs::write(&out_path, &frag)?; + println!(" written to {out_path}"); + let vert_path = format!("out/{label}_{tag}.vert.glsl"); + std::fs::write(&vert_path, &vert)?; + println!(" written to {vert_path}"); + } + Err(e) => { + println!(" !! GLSL {tag} compile failed: {e}"); + } + } + } + println!(); + } + + Ok(()) +}