diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0132afb3..390c5bfc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -403,10 +403,19 @@ jobs: cargo -V >> "$fingerprint" printf '%s\n' "${ImageOS:-}" "${ImageVersion:-}" >> "$fingerprint" if [ "$PLAT" = android ]; then - ndk="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-}}" + ndk_ver="$(sed -n "s/^[[:space:]]*ndkVersion[[:space:]]*['\"]\([0-9.]*\)['\"].*/\1/p" mobile/android/app/build.gradle)" + test -n "$ndk_ver" + sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" + test -n "$sdk" + ndk="$sdk/ndk/$ndk_ver" + if [ ! -f "$ndk/source.properties" ]; then + (yes || true) | "$sdk/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$ndk_ver" >/dev/null + fi test -f "$ndk/source.properties" + grep -Eq "^Pkg\.Revision[[:space:]]*=[[:space:]]*$ndk_ver\$" "$ndk/source.properties" cat "$ndk/source.properties" >> "$fingerprint" echo "ANDROID_NDK_HOME=$ndk" >> "$GITHUB_ENV" + echo "ANDROID_NDK_ROOT=$ndk" >> "$GITHUB_ENV" fi if command -v sha256sum >/dev/null 2>&1; then hash="$(sha256sum "$fingerprint")" @@ -488,6 +497,12 @@ jobs: if [ "$PLAT" = android ]; then for abi in arm64-v8a armeabi-v7a; do test -s "dist/native/$PLAT/$abi/$LIB" + case "$abi" in + arm64-v8a) triple=aarch64-linux-android ;; + armeabi-v7a) triple=arm-linux-androideabi ;; + esac + libcxx="$(ls "$ANDROID_NDK_HOME"/toolchains/llvm/prebuilt/*/sysroot/usr/lib/$triple/libc++_shared.so)" + bash scripts/android_bridge_link_check.sh "dist/native/$PLAT/$abi/$LIB" "$libcxx" done else test -s "dist/native/$PLAT/$LIB" diff --git a/README.md b/README.md index d70bb6b4..f3424e16 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,7 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend. | `--launcher` | open the launcher anyway, so you can edit a shortcut you already made | | `--no-sync` | skip the save sync a linked device otherwise runs before the game boots (`POKEPORT_LAUNCH_SYNC=0`) | | `--update` | check for a release first, and restart once into it if one is ready (`POKEPORT_LAUNCH_UPDATE=1`; `-update` works too) | +| `--update-mods` | run the MODS tab's Update all (mods and custom carts) without the confirm, then boot; stays on the launcher if an update fails (`POKEPORT_LAUNCH_UPDATE_MODS=1`; `--updatemods` works too) | If this device is linked for save sync, a shortcut now syncs before it boots so CONTINUE never loads a save another device has already moved past. The @@ -271,6 +272,7 @@ The URL parameters correspond to the desktop options: | `gen1recomp++://launch?game=red&launcher=1` | open the launcher on Red instead | | `gen1recomp++://launch?game=red&sync=0` | skip save sync | | `gen1recomp++://launch?game=red&update=1` | check for an update before booting | +| `gen1recomp++://launch?game=red&update_mods=1` | update mods and carts before booting | The `game` value accepts the same full names and aliases as `--game`. Boolean parameters accept `1`/`0`, `true`/`false`, `yes`/`no`, and `on`/`off`. Percent-encode diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index e7ef2b94..fef8ca53 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -768,7 +768,12 @@ local function jigglypuffDance(game, npc, ow) return { sound = function() Music.stop() -- SFX_STOP_ALL_MUSIC - return { isPlaying = function() return phase ~= "done" end } + local budget = (JIGGLYPUFF_SILENCE + Music.ONE_SHOT_CEILING + + JIGGLYPUFF_STEP + JIGGLYPUFF_TAIL) / 60 + 1 + return { + isPlaying = function() return phase ~= "done" end, + getDuration = function() return budget end, + } end, tick = function() frames = frames + 1 @@ -802,8 +807,17 @@ local function jigglypuffDance(game, npc, ow) end if frames >= JIGGLYPUFF_TAIL then phase = "done" + local PikachuFollower = require("src.world.PikachuFollower") + local starter + for _, mon in ipairs(game.save.party or {}) do + if PikachuFollower.isStarterPikachu(game.save, mon) then + starter = mon + break + end + end + -- scripts/PewterPokecenter_2.asm:65 if require("src.core.GameVersion").isYellow() - and require("src.world.PikachuFollower").starterInParty(game.save) then + and starter and not starter.status then ow.pikachuPewterSleepScene = true end end diff --git a/data/scripts/yellow_jessie_james.lua b/data/scripts/yellow_jessie_james.lua index 7379012e..558948e0 100644 --- a/data/scripts/yellow_jessie_james.lua +++ b/data/scripts/yellow_jessie_james.lua @@ -22,6 +22,52 @@ local M = {} local baseMtMoonStep = require("data.scripts.story2").MT_MOON_B2F.onStep local baseSilph11Step = require("data.scripts.story").SILPH_CO_11F.onStep +-- engine/overworld/movement.asm:932 +local FAST = { stepFrames = 16 } +-- engine/overworld/movement.asm:349 +local HOLD = { hold = 510 } + +local function flat(list) + local out = {} + for _, r in ipairs(list) do + if type(r[1]) == "table" then + for _, sub in ipairs(r) do out[#out + 1] = sub end + else + out[#out + 1] = r + end + end + return out +end + +-- scripts/MtMoonB2F.asm:446 +local function mottoRows(ow, textId, faceDir) + local t = 0 + return { + { "text_opts", { auto = { delay = 91, tick = function() + t = t + 1 + local p = ow.player + if t == 11 then + -- engine/overworld/emotion_bubbles.asm:60 + if p then ow.emote = { npc = p, frames = 60, bubble = 1 } end + elseif t == 71 then + ow.emote = nil + if p then p.facing = faceDir end + end + end } } }, + { "show_text", textId }, + } +end + +-- scripts/MtMoonB2F.asm:356 +local function partingRows(textId, a, b) + return { + { "face_object", a, "down", HOLD }, + { "face_object", b, "down", HOLD }, + { "text_opts", { auto = { delay = 64 } } }, + { "show_text", textId }, + } +end + M.MT_MOON_B2F = { talk = { TEXT_MTMOONB2F_JESSIE = { @@ -42,14 +88,12 @@ M.MT_MOON_B2F = { if not (f.EVENT_GOT_DOME_FOSSIL or f.EVENT_GOT_HELIX_FOSSIL) then return false end - ow.runner:run({ + ow.runner:run(flat({ { "stop_music" }, { "play_music", "Music_MeetJessieJames" }, { "show_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" }, { "show_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" }, - { "show_text", "_MtMoonJessieJamesText1" }, - { "face_player_dir", "up" }, - { "emote", "player", "shock", 30 }, + mottoRows(ow, "_MtMoonJessieJamesText1", "up"), -- MtMoonB2FScript_49e15 simulates PAD_UP for one player step, then -- Script6/Script9 walk Jessie (object 2, MovementData_f9e65: six -- $06) and James (object 6, f9e66: five $06); both objects' movement @@ -57,10 +101,10 @@ M.MT_MOON_B2F = { -- Jessie (9,3)->(3,3) above the player at (3,4), James (9,4)->(4,4) -- beside him. #423 { "walk_npc", "player", { "up" } }, - { "walk_npc", 2, { "left", "left", "left", "left", "left", "left" } }, - { "face_object", 2, "down" }, - { "walk_npc", 6, { "left", "left", "left", "left", "left" } }, - { "face_object", 6, "left" }, + { "walk_npc", 2, { "left", "left", "left", "left", "left", "left" }, FAST }, + { "face_object", 2, "down", HOLD }, + { "walk_npc", 6, { "left", "left", "left", "left", "left" }, FAST }, + { "face_object", 6, "left", HOLD }, { "show_text", "_MtMoonJessieJamesText2" }, -- MtMoonB2FScript12 arms _MtMoonJessieJamesText3 with -- SaveEndBattleTextPointers before it sets wCurOpponent, so @@ -71,7 +115,7 @@ M.MT_MOON_B2F = { { "start_battle", "trainer", "OPP_ROCKET", 42 }, { "check_battle_result", "win" }, { "jump_if_false", "end" }, - { "show_text", "_MtMoonJessieJamesText4" }, + partingRows("_MtMoonJessieJamesText4", 2, 6), { "stop_music" }, { "play_music", "Music_MeetJessieJames" }, { "fade", "out" }, @@ -80,7 +124,7 @@ M.MT_MOON_B2F = { { "fade", "in" }, { "play_default_music" }, { "set_flag", "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" }, - }, {}) + }), {}) return true end, } @@ -114,12 +158,10 @@ M.ROCKET_HIDEOUT_B4F = { -- RocketHideoutB4FJessieJamesMovementData_45605/45606 swap so the -- column-mate walks 3, the other 4. local onLeft = (x == 25) - ow.runner:run({ + ow.runner:run(flat({ { "stop_music" }, { "play_music", "Music_MeetJessieJames" }, - { "show_text", "_RocketHideoutJessieJamesText1" }, - { "face_player_dir", "up" }, - { "emote", "player", "shock", 30 }, + mottoRows(ow, "_RocketHideoutJessieJamesText1", "up"), { "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" }, { "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" }, -- James (object 2) then Jessie (object 3), Script4..Script9 order. @@ -133,11 +175,11 @@ M.ROCKET_HIDEOUT_B4F = { -- Reading _45605 as a single step stranded whoever was off-column three -- tiles away, so James never reached the player (#865). { "walk_npc", 2, onLeft and { "down", "down", "down" } - or { "down", "down", "down", "down" } }, - { "face_object", 2, onLeft and "down" or "left" }, + or { "down", "down", "down", "down" }, FAST }, + { "face_object", 2, onLeft and "down" or "left", HOLD }, { "walk_npc", 3, onLeft and { "down", "down", "down", "down" } - or { "down", "down", "down" } }, - { "face_object", 3, onLeft and "right" or "down" }, + or { "down", "down", "down" }, FAST }, + { "face_object", 3, onLeft and "right" or "down", HOLD }, { "show_text", "_RocketHideoutJessieJamesText2" }, -- RocketHideoutB4FScript10 saves _RocketHideoutJessieJamesText3 as the -- end-battle text, so it prints as "ROCKET: Such a dreadful twerp!" on @@ -146,7 +188,7 @@ M.ROCKET_HIDEOUT_B4F = { { "start_battle", "trainer", "OPP_ROCKET", 43 }, { "check_battle_result", "win" }, { "jump_if_false", "lost" }, - { "show_text", "_RocketHideoutJessieJamesText4" }, + partingRows("_RocketHideoutJessieJamesText4", 2, 3), { "stop_music" }, { "play_music", "Music_MeetJessieJames" }, { "fade", "out" }, @@ -159,7 +201,7 @@ M.ROCKET_HIDEOUT_B4F = { { "label", "lost" }, { "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" }, { "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" }, - }, {}) + }), {}) return true end, } @@ -187,14 +229,12 @@ M.POKEMON_TOWER_7F = { if y ~= 12 or (x ~= 10 and x ~= 11) then return false end if f.EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES then return false end local onLeft = (x == 11) -- EVENT_POKEMONTOWER_7_JESSIE_JAMES_ON_LEFT - ow.runner:run({ + ow.runner:run(flat({ { "stop_music" }, { "play_music", "Music_MeetJessieJames" }, { "show_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JESSIE" }, { "show_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JAMES" }, - { "show_text", "_PokemonTowerJessieJamesText1" }, - { "face_player_dir", "up" }, - { "emote", "player", "shock", 30 }, + mottoRows(ow, "_PokemonTowerJessieJamesText1", "up"), -- Jessie (object 1) then James (object 2), Script1..Script6 order. -- Same fall-through blob as the hideout: PokemonTower7FMovementData_60d7a -- is a lone $4 running into _60d7b ($4 $4 $4 $FF), so _60d7a is FOUR @@ -203,11 +243,11 @@ M.POKEMON_TOWER_7F = { -- full four to his side; the single-step reading is why James only -- "moved a bit" here (#865). { "walk_npc", 1, onLeft and { "down", "down", "down", "down" } - or { "down", "down", "down" } }, - { "face_object", 1, onLeft and "right" or "down" }, + or { "down", "down", "down" }, FAST }, + { "face_object", 1, onLeft and "right" or "down", HOLD }, { "walk_npc", 2, onLeft and { "down", "down", "down" } - or { "down", "down", "down", "down" } }, - { "face_object", 2, onLeft and "down" or "left" }, + or { "down", "down", "down", "down" }, FAST }, + { "face_object", 2, onLeft and "down" or "left", HOLD }, { "show_text", "_PokemonTowerJessieJamesText2" }, -- PokemonTower7FScript7 saves _PokemonTowerJessieJamesText3 as the -- end-battle text: "ROCKET: You will regret this!" on the battle screen, @@ -216,7 +256,7 @@ M.POKEMON_TOWER_7F = { { "start_battle", "trainer", "OPP_ROCKET", 44 }, { "check_battle_result", "win" }, { "jump_if_false", "end" }, - { "show_text", "_PokemonTowerJessieJamesText4" }, + partingRows("_PokemonTowerJessieJamesText4", 1, 2), { "stop_music" }, { "play_music", "Music_MeetJessieJames" }, { "fade", "out" }, @@ -225,7 +265,7 @@ M.POKEMON_TOWER_7F = { { "fade", "in" }, { "play_default_music" }, { "set_flag", "EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES" }, - }, {}) + }), {}) return true end, } @@ -273,17 +313,15 @@ M.SILPH_CO_11F = { jessieDirs = { "up", "up", "up", "left", "up", "up" } jessieFace = "left" end - ow.runner:run({ + ow.runner:run(flat({ { "stop_music" }, { "play_music", "Music_MeetJessieJames" }, - { "show_text", "_SilphCoJessieJamesText1" }, - { "face_player_dir", "down" }, - { "emote", "player", "shock", 30 }, + mottoRows(ow, "_SilphCoJessieJamesText1", "down"), -- James (object 4) then Jessie (object 6), Script5..Script10 order - { "walk_npc", 4, jamesDirs }, - { "face_object", 4, jamesFace }, - { "walk_npc", 6, jessieDirs }, - { "face_object", 6, jessieFace }, + { "walk_npc", 4, jamesDirs, FAST }, + { "face_object", 4, jamesFace, HOLD }, + { "walk_npc", 6, jessieDirs, FAST }, + { "face_object", 6, jessieFace, HOLD }, { "show_text", "_SilphCoJessieJamesText2" }, -- SilphCo11FScript11 saves _SilphCoJessieJamesText3 (SilphCo11FText_624c2) -- as the end-battle text: "ROCKET: Like always..." before the money (#866). @@ -291,7 +329,7 @@ M.SILPH_CO_11F = { { "start_battle", "trainer", "OPP_ROCKET", 45 }, { "check_battle_result", "win" }, { "jump_if_false", "end" }, - { "show_text", "_SilphCoJessieJamesText4" }, + partingRows("_SilphCoJessieJamesText4", 4, 6), { "stop_music" }, { "play_music", "Music_MeetJessieJames" }, { "fade", "out" }, @@ -300,7 +338,7 @@ M.SILPH_CO_11F = { { "fade", "in" }, { "play_default_music" }, { "set_flag", "EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES" }, - }, {}) + }), {}) return true end, } diff --git a/docs/modding.md b/docs/modding.md index 3b69efb7..88d107c1 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -83,7 +83,7 @@ Every mod contains a root `manifest.json` defining its metadata, supported games | `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). | | `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, `["silver"]`, or `["all"]`. | | `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). | -| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). | +| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). The player can override it from the launcher MODS tab (Sort -> Load order) or the in-game manager; dependencies and a cart's `load_order` still win. | | `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. | | `optional_dependencies` | `array` | Soft dependencies. Guarantees that if the target mod is present and active, it loads *before* this mod without blocking load if absent. | | `required_imports` | `array` | User-supplied files required by this mod. The launcher validates and copies each file into this mod's `baseroms/` directory; the mod does not load while one is missing. | diff --git a/docs/new-features.md b/docs/new-features.md index 45211009..080b1ab9 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -27,7 +27,10 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Rebindable GAME SPEED shortcuts**, SPEED - / SPEED + rows in CONTROLS that move the shoulder hotkeys to any pad button or switch them off * **Key bar on the touch pad**, a corner toggle that slides out SAVE, LOAD, SPEED, COLOR, TILT and ZOOM for phones with no keyboard * **Save editor item verbs**, sorting the bag and PC by item number or name, filling one stack or every stack to x99, and a coin editor on every game +* **Save editor bag / PC move**, a PC / BAG button on every item row that moves the stack across, clamped to the stack and slot caps * **Shortcuts sync before they boot**, a `--game` launch syncing saves (and, with `--update`, taking a release) first, skippable with any button +* **`--update-mods`**, a launch flag that runs Update all before the game boots +* **Mod load order**, a Load order sort in the launcher MODS tab and LOAD EARLIER / LATER in the in-game manager, no manifest editing ## Gen 2 Specifics diff --git a/main.lua b/main.lua index eb435c87..6d57b8e0 100644 --- a/main.lua +++ b/main.lua @@ -399,6 +399,8 @@ local function makeLauncher(launcherOpts) require("src.import.LauncherWindow").observe(0) require("src.import.LauncherWindow").flush() Importer = nil + local onBoot = launcherOpts and launcherOpts.onBoot + if onBoot and onBoot(version, cartId, opts) then return end bootGame(version, cartId, opts) end, { launcher = true, @@ -569,8 +571,35 @@ local function showLauncher(version) end end +local function wantsModUpdate(request) + if type(request) ~= "table" then return false end + if type(request.tasks) == "table" and request.tasks.mods ~= nil then + return request.tasks.mods == true + end + return request.updateMods == true +end + +local function autoUpdateMods(request, tab) + if not wantsModUpdate(request) then return end + if Importer and Importer.autoUpdateAll then + Importer:autoUpdateAll(function() end, { tab = tab }) + end +end + +local deferredLaunchRequest + +local function launcherBusy() + return Importer ~= nil and (Importer._updateAll ~= nil + or Importer._modInstall ~= nil or Importer._cartInstall ~= nil + or Importer._autoUpdateAll ~= nil) +end + local function startLaunchRequest(request) if type(request) ~= "table" then return false end + if launcherBusy() then + deferredLaunchRequest = request + return true + end local version = request.game if request.launcher or not version then @@ -579,6 +608,7 @@ local function startLaunchRequest(request) else showLauncher(version) end + autoUpdateMods(request, not version and "mods" or nil) return true end @@ -594,6 +624,7 @@ local function startLaunchRequest(request) end) if not ok or type(cart) ~= "table" or cart.base ~= version then showLauncher(version) + autoUpdateMods(request) return true end cartId = request.cart @@ -601,6 +632,7 @@ local function startLaunchRequest(request) if not RomImporter.isReady(version) then showLauncher(version) + autoUpdateMods(request) return true end @@ -610,6 +642,23 @@ local function startLaunchRequest(request) bootGame(version, cartId) end + local function bootAfterMods() + if not wantsModUpdate(request) then return bootShortcut() end + Importer = makeLauncher({ initialTab = "mods", + onBoot = function(v, c, opts) + if v ~= version or c ~= cartId or opts ~= nil then return false end + bootShortcut() + return true + end }) + Importer:autoUpdateAll(function(result) + if not (result.ok or result.cancelled or result.skipped) then return end + require("src.import.LauncherWindow").observe(0) + require("src.import.LauncherWindow").flush() + Importer = nil + bootShortcut() + end) + end + Prelaunch = require("src.core.Prelaunch").new({ version = version, tasks = request.tasks or {}, @@ -620,10 +669,10 @@ local function startLaunchRequest(request) showLauncher(version) return end - bootShortcut() + bootAfterMods() end, }) - if not Prelaunch then bootShortcut() end + if not Prelaunch then bootAfterMods() end return true end @@ -784,6 +833,10 @@ function love.load(args) -- goes to its own service owner, src/core/Game2.lua -- docs/gold-phase1.md). -- Edit on a save row opens the bundled editor on that slot (openEditor). Importer = makeLauncher() + if not relaunched then + autoUpdateMods(resolvedLaunch, + not resolvedLaunch.game and "mods" or nil) + end end function love.update(dt) @@ -797,6 +850,11 @@ function love.update(dt) if Studio then return Studio.update(dt) end local launchURI = LaunchOptions.pollURI() if launchURI then love.handlers.intent_uri(launchURI) end + if deferredLaunchRequest and not launcherBusy() then + local request = deferredLaunchRequest + deferredLaunchRequest = nil + startLaunchRequest(request) + end if Prelaunch then return Prelaunch:update(dt) end local client = onlineClientModule() if client then pcall(client.update, dt) end diff --git a/scripts/android_bridge_link_check.sh b/scripts/android_bridge_link_check.sh new file mode 100755 index 00000000..71173e3f --- /dev/null +++ b/scripts/android_bridge_link_check.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail +export LC_ALL=C + +if [ $# -ne 2 ]; then + echo "usage: $0 " >&2 + exit 2 +fi +bridge="$1" +libcxx="$2" +if [ ! -f "$bridge" ]; then + echo "no bridge at $bridge" >&2 + exit 2 +fi + +nm="${LLVM_NM:-}" +if [ -z "$nm" ]; then + for ndk in "${ANDROID_NDK_HOME:-}" "${ANDROID_NDK_ROOT:-}"; do + [ -n "$ndk" ] || continue + for cand in "$ndk"/toolchains/llvm/prebuilt/*/bin/llvm-nm; do + if [ -x "$cand" ]; then + nm="$cand" + break 2 + fi + done + done +fi +if [ -z "$nm" ]; then + nm="$(command -v llvm-nm || true)" +fi +if [ -z "$nm" ] || [ ! -x "$nm" ]; then + echo "llvm-nm not found (set LLVM_NM or ANDROID_NDK_HOME)" >&2 + exit 2 +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +"$nm" -D --undefined-only "$bridge" > "$tmp/undefined" +awk '$(NF-1) == "U" { s = $NF; sub(/@.*/, "", s); if (s ~ /^_Z/) print s }' \ + "$tmp/undefined" | sort -u > "$tmp/need" + +: > "$tmp/have" +if [ -f "$libcxx" ]; then + "$nm" -D --defined-only "$libcxx" > "$tmp/defined" + awk '{ s = $NF; sub(/@.*/, "", s); print s }' "$tmp/defined" | sort -u > "$tmp/have" +fi + +comm -23 "$tmp/need" "$tmp/have" > "$tmp/missing" +if [ -s "$tmp/missing" ]; then + count="$(wc -l < "$tmp/missing" | tr -d ' ')" + echo "$(basename "$bridge") needs $count C++ runtime symbols that $libcxx does not define, so dlopen fails:" + head -n 5 "$tmp/missing" | sed 's/^/ /' + exit 1 +fi +echo "$(basename "$bridge") links against $(basename "$libcxx")" diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 5c279aab..39d2f75d 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -566,12 +566,76 @@ shader_bridge_absent() { warn "$SHADER_BRIDGE_LIB: $1; this build can run converted presets but not CONVERT new ones" } -shader_bridge_verify_apk() { - local apk="$1" abi missing="" apk_entries - apk_entries="$(unzip -Z1 "$apk")" - for abi in $SHADER_BRIDGE_ABIS; do - grep -qxF "lib/$abi/$SHADER_BRIDGE_LIB" <<< "$apk_entries" || missing="$missing $abi" +shader_bridge_ndk_triple() { + case "$1" in + arm64-v8a) printf 'aarch64-linux-android' ;; + armeabi-v7a) printf 'arm-linux-androideabi' ;; + x86_64) printf 'x86_64-linux-android' ;; + x86) printf 'i686-linux-android' ;; + *) printf '' ;; + esac +} + +shader_bridge_gradle_ndk() { + printf '%s' "${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Library/Android/sdk}}/ndk/$NDK_VERSION" +} + +shader_bridge_gradle_libcxx() { + local abi="$1" triple cand + triple="$(shader_bridge_ndk_triple "$abi")" + for cand in "$(shader_bridge_gradle_ndk)"/toolchains/llvm/prebuilt/*/sysroot/usr/lib/"$triple"/libc++_shared.so; do + if [ -f "$cand" ]; then + printf '%s' "$cand" + return 0 + fi done + printf '' +} + +shader_bridge_check_links() { + local bridge="$1" libcxx="$2" label="$3" out rc=0 + if [ -z "$libcxx" ]; then + out="no libc++_shared.so under $(shader_bridge_gradle_ndk)" + rc=2 + else + out="$(ANDROID_NDK_HOME="$(shader_bridge_gradle_ndk)" \ + bash "$ROOT/scripts/android_bridge_link_check.sh" "$bridge" "$libcxx" 2>&1)" || rc=$? + fi + [ "$rc" -eq 0 ] && return 0 + if [ "$rc" -eq 1 ]; then + if [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then + fail "$label: $out + Build the bridge with NDK $NDK_VERSION (the one gradle packs libc++_shared.so from)." + fi + warn "$label: $out" + return 0 + fi + if [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then + fail "$label: cannot check that $SHADER_BRIDGE_LIB links: $out" + fi + warn "$label: cannot check that $SHADER_BRIDGE_LIB links: $out" +} + +shader_bridge_verify_apk() { + local apk="$1" abi missing="" apk_entries tmp + apk_entries="$(unzip -Z1 "$apk")" + tmp="$(mktemp -d)" + trap "rm -rf '$tmp'" EXIT + for abi in $SHADER_BRIDGE_ABIS; do + if grep -qxF "lib/$abi/$SHADER_BRIDGE_LIB" <<< "$apk_entries"; then + mkdir -p "$tmp/$abi" + unzip -o -q -j "$apk" "lib/$abi/$SHADER_BRIDGE_LIB" -d "$tmp/$abi" + if grep -qxF "lib/$abi/libc++_shared.so" <<< "$apk_entries"; then + unzip -o -q -j "$apk" "lib/$abi/libc++_shared.so" -d "$tmp/$abi" + fi + shader_bridge_check_links "$tmp/$abi/$SHADER_BRIDGE_LIB" "$tmp/$abi/libc++_shared.so" \ + "$(basename "$apk") lib/$abi" + else + missing="$missing $abi" + fi + done + rm -rf "$tmp" + trap - EXIT [ -z "$missing" ] && return 0 if [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then fail "$(basename "$apk") is missing lib//$SHADER_BRIDGE_LIB for:$missing" @@ -594,6 +658,8 @@ bundle_shader_bridge_android() { if [ -f "$prebuilt/$abi/$SHADER_BRIDGE_LIB" ]; then mkdir -p "$jni/$abi" cp "$prebuilt/$abi/$SHADER_BRIDGE_LIB" "$jni/$abi/$SHADER_BRIDGE_LIB" + shader_bridge_check_links "$jni/$abi/$SHADER_BRIDGE_LIB" "$(shader_bridge_gradle_libcxx "$abi")" \ + "SHADERFX_BRIDGE_ANDROID_DIR $abi" else warn "SHADERFX_BRIDGE_ANDROID_DIR has no $abi/$SHADER_BRIDGE_LIB" fi diff --git a/scripts/test.sh b/scripts/test.sh index 8ca30903..c5bdce3c 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -205,6 +205,7 @@ if [ "$HAVE_RED_DATA" = "1" ]; then run_tier "T3 save editor: gold / gen2" "$LUA" tests/save_editor_gen2_tests.lua run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua run_tier "T3 save editor: pad / NX input" "$LUA" tests/save_editor_pad_input_test.lua + run_tier "T3 save editor: bag / PC move" "$LUA" tests/save_editor_item_move_bug1951_test.lua run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua # The oversize-save vendor oracle (tests/save_oversize_vendor_test.lua) # cross-checks the launcher's footer-truncation import against the diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 5863919a..67a82ce3 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -4449,10 +4449,8 @@ end function BattleState:continueBide(user, target) user.bideTurns = user.bideTurns - 1 - if user.bideTurns > 0 then - self:sayNext(Strings("%s\nis storing energy!", displayName(user))) - return - end + -- engine/battle/core.asm:3499 + if user.bideTurns > 0 then return end self:sayNext(self:romText("_UnleashedEnergyText", "%s\nunleashed energy!", displayName(user))) local dmg = (user.bideDamage or 0) * 2 user.bideTurns, user.bideDamage = nil, nil @@ -4955,6 +4953,8 @@ function BattleState:learnMove(mon, moveId) end if #mon.moves < 4 then table.insert(mon.moves, { id = moveId, pp = mdef.pp }) + require("src.world.PikachuFollower") + .onMoveLearned(self.game.save, mon, moveId) Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) self:sayNextWaitSfx(self:romText("_MimicLearnedMoveText", "%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name, mdef.name), function() @@ -5050,6 +5050,7 @@ function BattleState:playerMonFainted() require("src.core.Sound").play(self.data, "Run") self:say(self:romText("_GotAwayText", "Got away safely!")) self.result = "run" + self.playerRan = true self.afterQueue = "finish" else self:say(self:romText("_CantEscapeText", "Can't escape!")) @@ -5122,6 +5123,7 @@ function BattleState:safariAction(choice) require("src.core.Sound").play(self.data, "Run") self:say(self:romText("_GotAwayText", "Got away safely!")) self.result = "run" + self.playerRan = true self.afterQueue = "finish" return end @@ -5268,6 +5270,7 @@ function BattleState:tryRun() require("src.core.Sound").play(self.data, "Run") self:say(self:romText("_GotAwayText", "Got away safely!")) self.result = "run" + self.playerRan = true self.afterQueue = "finish" else self:say(self:romText("_CantEscapeText", "Can't escape!")) @@ -5470,6 +5473,11 @@ function BattleState:storeCaughtMon() -- engine/menus/pokedex.asm:581-582 self:actNext(function() self.fieldCleared = true end) end + -- engine/items/item_effects.asm:564 + if require("src.core.GameVersion").isYellow() then + game.save.pikachuEmotionModifier = 1 + game.save.pikachuMood = 0x85 + end local function askCaughtNickname() self:offerNickname(self.enemy.mon, self.enemy.name) end @@ -5708,6 +5716,14 @@ function BattleState:finish() function() self:finish() end, self.leveledUp) return end + -- engine/battle/end_of_battle.asm:49 + if not self.pikachuMoodChecked then + self.pikachuMoodChecked = true + if self.kind == "link" or (not self.demo and (self.result == "win" + or (self.result == "run" and not self.playerRan))) then + require("src.world.PikachuFollower").moodAfterBattle(self.game.save) + end + 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. diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 881ad9a1..ba7f91b2 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -721,7 +721,6 @@ MoveEffects.full = { ctx.battle:animBeforeMove( user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM", user.isPlayer) - ctx.say(Strings("%s\nis storing energy!", displayName(user))) end, }, SWITCH_AND_TELEPORT_EFFECT = { diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 250edcde..635ae3e6 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -1235,6 +1235,7 @@ function Battle:hitOnce(attacker, defender, def, opts) Damage.isPhysical(def.type, types)), -- BattleCommand_DamageCalc's `srl c` (effect_commands.asm:2905-2913). defenseHalved = def.effect == "EFFECT_SELFDESTRUCT", + reflectOverflowFixed = self:reflectOverflowFixed(), random = self.random, } -- battle.damage, the same hook BattleState:computeDamage calls on Gen 1 and @@ -1447,6 +1448,12 @@ function Battle:markMissed() if self.moveEvent then self.moveEvent.missed = true end end +-- engine/battle/effect_commands.asm:2646 +function Battle:reflectOverflowFixed() + if self.linkBattle then return false end + return nil +end + -- engine/battle/effect_commands.asm:3615 Battle.AI_FAIL_STATUSES = { sleep = true, poison = true, toxic = true, paralyze = true, @@ -2510,6 +2517,7 @@ Battle.MOVE_EFFECTS.EFFECT_FUTURE_SIGHT = function(self, attacker, defender, def }, types = self.data.type_chart and self.data.type_chart.types, matchups = self:matchupsAgainst(defender), + reflectOverflowFixed = self:reflectOverflowFixed(), random = self.random, }) state.futureSight = Effects.FUTURE_SIGHT_TURNS @@ -2580,6 +2588,7 @@ Battle.MOVE_EFFECTS.EFFECT_BEAT_UP = function(self, attacker, defender, def) }, types = self.data.type_chart and self.data.type_chart.types, matchups = self.data.type_chart and self.data.type_chart.matchups, + reflectOverflowFixed = self:reflectOverflowFixed(), random = self.random, }) self:emit({ kind = "message", diff --git a/src/core/FilePicker.lua b/src/core/FilePicker.lua index ec087ced..9153fb6c 100644 --- a/src/core/FilePicker.lua +++ b/src/core/FilePicker.lua @@ -1,5 +1,6 @@ local Platform = require("src.core.Platform") local HostShell = require("src.core.HostShell") +local HostPicker = require("src.core.HostPicker") local FilePicker = {} @@ -69,12 +70,11 @@ function FilePicker.open(prompt, kind) :format(title, appleTypes(kind.exts))) elseif platform == "Windows" then local script = table.concat({ - "Add-Type -AssemblyName System.Windows.Forms;", - "$d=New-Object System.Windows.Forms.OpenFileDialog;", + HostPicker.WIN_OPEN_DIALOG, "$d.Title='" .. title .. "';", "$d.Filter='" .. kind.label .. " (" .. windowsPatterns(kind.exts) .. ")|" .. windowsPatterns(kind.exts) .. "|All files (*.*)|*.*';", - "if($d.ShowDialog() -eq 'OK'){", + HostPicker.WIN_SHOW, "$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';", "$t=Join-Path $env:TEMP $n;", "Copy-Item -LiteralPath $d.FileName -Destination $t -Force;", diff --git a/src/core/Game.lua b/src/core/Game.lua index d2054e8c..b75c4939 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -495,8 +495,7 @@ end -- Asked of the WHOLE stack, not just the top. For BATTLE SIZE "fill" that is -- because the party menu, bag and text boxes a battle opens must not snap the -- surface back to the fixed scale for a frame; the title screen and intro want --- it unconditionally, since neither has a world behind it and neither has any --- reason to sit in a small box in the middle of a large window. +-- it unless a skin or FAITHFUL RATIO frames the picture. function Game.fillScaleInStack(stack) for i = #(stack and stack.states or {}), 1, -1 do local state = stack.states[i] diff --git a/src/core/HostPicker.lua b/src/core/HostPicker.lua new file mode 100644 index 00000000..f5fa6238 --- /dev/null +++ b/src/core/HostPicker.lua @@ -0,0 +1,84 @@ +local HostShell = require("src.core.HostShell") + +local HostPicker = {} + +HostPicker.RESULT = "host_picker_result" +HostPicker.WORKER = "src/core/host_picker_worker.lua" + +HostPicker.WIN_OPEN_DIALOG = table.concat({ + "Add-Type -AssemblyName System.Windows.Forms;", + "$o=New-Object System.Windows.Forms.Form;", + "$o.TopMost=$true;", + "$o.ShowInTaskbar=$false;", + "$d=New-Object System.Windows.Forms.OpenFileDialog;", +}) +HostPicker.WIN_SHOW = "if($d.ShowDialog($o) -eq 'OK'){" + +local jobs = {} +local nextId = 0 +local resCh + +function HostPicker.available() + return (love and love.thread and love.thread.newThread + and love.thread.getChannel) and true or false +end + +function HostPicker.start(commands) + if type(commands) == "string" then commands = { commands } end + if type(commands) ~= "table" or #commands == 0 then return nil end + if not HostPicker.available() then return nil end + local okCh, ch = pcall(love.thread.getChannel, HostPicker.RESULT) + if not (okCh and ch) then return nil end + resCh = ch + local okTh, th = pcall(love.thread.newThread, HostPicker.WORKER) + if not (okTh and th) then return nil end + HostShell.releasePointerGrab() + nextId = nextId + 1 + local id = nextId + if not pcall(function() th:start(id, commands, HostPicker.RESULT) end) then + return nil + end + jobs[id] = { thread = th } + return id +end + +local function drain() + if not resCh then return end + local msg = resCh:pop() + while msg ~= nil do + if type(msg) == "table" and msg.id and jobs[msg.id] then + jobs[msg.id].done = true + jobs[msg.id].output = msg.output + end + msg = resCh:pop() + end +end + +function HostPicker.poll(id) + local job = jobs[id] + if not job then return "done", nil end + drain() + if job.done then + jobs[id] = nil + return "done", job.output + end + local th = job.thread + local err = th and th.getError and th:getError() + if err then + jobs[id] = nil + return "done", nil, err + end + return "pending" +end + +function HostPicker.pending() + for _ in pairs(jobs) do return true end + return false +end + +function HostPicker._reset() + jobs = {} + resCh = nil +end + +return HostPicker diff --git a/src/core/LaunchOptions.lua b/src/core/LaunchOptions.lua index 2504b159..36cd2098 100644 --- a/src/core/LaunchOptions.lua +++ b/src/core/LaunchOptions.lua @@ -180,6 +180,7 @@ function LaunchOptions.parseURI(uri) request.launcher = booleanValue(values.launcher) request.sync = booleanValue(values.sync) request.update = booleanValue(values.update) + request.updateMods = booleanValue(values.update_mods) return request end @@ -261,11 +262,16 @@ function LaunchOptions.forceLauncher(argv) return LaunchOptions.resolveRequest(argv, nil).launcher end -local function taskFlag(argv, rawArgv, name, env, uriValue) - if argFlag(argv, "no-" .. name) or argFlag(rawArgv, "no-" .. name) then - return false +local function taskFlag(argv, rawArgv, names, env, uriValue) + if type(names) ~= "table" then names = { names } end + for _, name in ipairs(names) do + if argFlag(argv, "no-" .. name) or argFlag(rawArgv, "no-" .. name) then + return false + end + end + for _, name in ipairs(names) do + if argFlag(argv, name) or argFlag(rawArgv, name) then return true end end - if argFlag(argv, name) or argFlag(rawArgv, name) then return true end if uriValue ~= nil then return uriValue end local v = os.getenv(env) if v == "1" then return true end @@ -280,6 +286,8 @@ function LaunchOptions.tasks(argv, rawArgv, uri) uri and uri.sync), update = taskFlag(argv, rawArgv, "update", "POKEPORT_LAUNCH_UPDATE", uri and uri.update) == true, + mods = taskFlag(argv, rawArgv, { "update-mods", "updatemods" }, + "POKEPORT_LAUNCH_UPDATE_MODS", uri and uri.updateMods) == true, } end @@ -299,6 +307,7 @@ function LaunchOptions.fromGame(game) if not normalized then return nil end local tasks = LaunchOptions.tasks({}, {}) tasks.update = false + tasks.mods = false return { source = "intent", game = normalized, @@ -328,6 +337,9 @@ function LaunchOptions.uriFor(version, options) if options.update ~= nil then query[#query + 1] = "update=" .. (options.update and "1" or "0") end + if options.updateMods ~= nil then + query[#query + 1] = "update_mods=" .. (options.updateMods and "1" or "0") + end return "gen1recomp++://launch?" .. table.concat(query, "&") end @@ -363,6 +375,7 @@ function LaunchOptions.commandFor(version, slot, tasks) if slot then cmd = cmd .. " --slot " .. tostring(slot) end if type(tasks) == "table" then if tasks.update then cmd = cmd .. " --update" end + if tasks.mods then cmd = cmd .. " --update-mods" end if tasks.sync == false then cmd = cmd .. " --no-sync" end end return cmd diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 201187f5..2a650343 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -359,6 +359,7 @@ function SaveData.defaultOptions() -- migrated into PROFILE 1, so deleting every profile does not re-seed one. modProfiles = {}, modProfilesSeeded = false, + modOrder = {}, -- GitHub release checks for mods with a manifest "github" field -- (src/mods/ModUpdate.lua). Keyed by owner/repo; TTL is six hours. modUpdateCache = {}, @@ -849,6 +850,25 @@ local function forcedGenerations(entry) return entry == true and 2 or nil end +function SaveData.modOrder(options) + local raw = type(options) == "table" and options.modOrder or nil + local out, seen = {}, {} + if type(raw) ~= "table" then return out end + for _, id in ipairs(raw) do + if type(id) == "string" and id ~= "" and not seen[id] then + seen[id] = true + out[#out + 1] = id + end + end + return out +end + +function SaveData.setModOrder(options, ids) + if type(options) ~= "table" then return options end + options.modOrder = SaveData.modOrder({ modOrder = ids }) + return options +end + function SaveData.modForced(options, id, version, generation) local entry = type(options) == "table" and type(options.modsGen2) == "table" and options.modsGen2[id] diff --git a/src/core/Sound.lua b/src/core/Sound.lua index e4a3189b..317f780f 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -62,6 +62,7 @@ local FANFARES = { Pokedex_Rating = true, Dex_Page_Added = true, Pokeflute = true, + Pokeflute_In_Battle = true, } -- which mod put this key in the registry, for attributed failure logs @@ -469,6 +470,50 @@ function Sound.play(data, name) return src end +-- audio/poke_flute.asm:1 +local POKEFLUTE_IN_BATTLE = { + redblue = { 0x6322, 0x6325, 0x449b }, + yellow = { 0x59eb, 0x59ee, 0x444b }, +} + +local function pokefluteInBattleDef(data) + local sfx = data and data.audio and data.audio.sfx + local base = sfx and sfx.Caught_Mon + if type(base) ~= "table" or base.chip ~= nil or base.bank ~= 8 + or base.address ~= 0x41CE then + return nil + end + local alt = require("src.core.GameVersion").isYellow() + and POKEFLUTE_IN_BATTLE.yellow or POKEFLUTE_IN_BATTLE.redblue + local def = {} + for key, value in pairs(base) do def[key] = value end + def.startChannels = { + { number = 5, address = alt[1] }, + { number = 6, address = alt[2] }, + { number = 7, address = alt[3] }, + } + def.fanfare = true + return def +end +Sound._pokefluteInBattleDef = pokefluteInBattleDef + +-- engine/items/item_effects.asm:1739 +function Sound.playPokefluteInBattle(data) + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx.Pokeflute_In_Battle then + return Sound.play(data, "Pokeflute_In_Battle") + end + local field = sfx and sfx.Pokeflute + -- audio/headers/sfxheaders1.asm:467 + if field ~= nil and (type(field) ~= "table" or field.chip ~= nil + or field.bank ~= 2 or field.address ~= 0x4228) then + return Sound.play(data, "Pokeflute") + end + local def = pokefluteInBattleDef(data) + if not def then return Sound.play(data, "Pokeflute") end + return startSfx(data, "Pokeflute_In_Battle", def) +end + -- PlayStereoSFX (audio/engine.asm), the battle animation path: same sound, -- same fanfare duck, but no CheckSFX/wCurSFX gate, and it never writes -- wCurSFX either -- so an animation sound can neither be dropped by, nor diff --git a/src/core/game3/trainer_sight.lua b/src/core/game3/trainer_sight.lua index a1bac7cd..76a831dd 100644 --- a/src/core/game3/trainer_sight.lua +++ b/src/core/game3/trainer_sight.lua @@ -92,6 +92,13 @@ function TrainerSight.getTrainerId(eo) return nil end +-- pokefirered/src/trainer_see.c:97 +function TrainerSight.isTrainerType(eo) + if not eo then return false end + local tt = tonumber(eo.trainerType or (eo.def and eo.def.trainerType)) or 0 + return tt == 1 or tt == 3 +end + --- Check if trainer has already been defeated. function TrainerSight.isDefeated(eo, store, ctx) if not eo then return true end @@ -343,7 +350,8 @@ function TrainerSight.check(game, specificTrainer) local eo = specificTrainer if eo.visible and not eo.hidden and not eo.moving and not eo.scriptBusy and not eo.frozen then local sight = tonumber(eo.sight or (eo.def and (eo.def.sight or eo.def.trainerRange))) or 0 - if sight > 0 and not TrainerSight.isDefeated(eo, store, ctx) then + if sight > 0 and TrainerSight.isTrainerType(eo) + and not TrainerSight.isDefeated(eo, store, ctx) then local spotted, dist = TrainerSight.checkLineOfSight(eo, P, game) if spotted and not TrainerSight.blockedByDoubles(eo) then TrainerSight.engage(game, eo, dist) @@ -360,7 +368,8 @@ function TrainerSight.check(game, specificTrainer) local eo = Objs.find(lid) if eo and eo ~= P and eo.visible and not eo.hidden and not eo.moving and not eo.scriptBusy and not eo.frozen then local sight = tonumber(eo.sight or (eo.def and (eo.def.sight or eo.def.trainerRange))) or 0 - if sight > 0 and not TrainerSight.isDefeated(eo, store, ctx) then + if sight > 0 and TrainerSight.isTrainerType(eo) + and not TrainerSight.isDefeated(eo, store, ctx) then local spotted, dist = TrainerSight.checkLineOfSight(eo, P, game) if spotted and not TrainerSight.blockedByDoubles(eo) then -- Immediately engage and break iterator to suppress any other simultaneous spots diff --git a/src/core/host_picker_worker.lua b/src/core/host_picker_worker.lua new file mode 100644 index 00000000..0b55eb21 --- /dev/null +++ b/src/core/host_picker_worker.lua @@ -0,0 +1,35 @@ +require("love.thread") +require("love.filesystem") +require("love.system") + +local id, commands, channelName = ... +local out = love.thread.getChannel(channelName or "host_picker_result") + +local HostShell +do + local ok, chunk = pcall(love.filesystem.load, "src/core/HostShell.lua") + if ok and type(chunk) == "function" then + local ok2, mod = pcall(chunk) + if ok2 then HostShell = mod end + end +end + +local output +if HostShell and type(commands) == "table" then + for _, command in ipairs(commands) do + local pipe = HostShell.popen(command) + if pipe then + local ok, result = pcall(pipe.read, pipe, "*a") + HostShell.pclose(pipe) + if ok and type(result) == "string" then + result = result:gsub("^%s+", ""):gsub("%s+$", "") + if result ~= "" then + output = result + break + end + end + end + end +end + +out:push({ id = id, output = output }) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 6d02ea3e..3b1bdb8a 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -2338,6 +2338,9 @@ local function sortDefs(scope) end defs[#defs + 1] = { key = "release", label = Strings("Release date") } defs[#defs + 1] = { key = "updated", label = Strings("Last updated") } + if scope == "mods" then + defs[#defs + 1] = { key = "order", label = Strings("Load order") } + end return defs end @@ -2401,6 +2404,7 @@ local function currentSort(imp, scope) imp.modSort = sortKey end if sortKey == "trending" and scope ~= "find" then return "popularity" end + if sortKey == "order" and scope ~= "mods" then return "popularity" end return sortKey end @@ -2579,7 +2583,8 @@ local function buildModsPanel(imp, x, y, w, availH, m) -- per frame (with lowercased-string allocations in the comparator) fed the -- GC for nothing. Cache the sorted array, keyed on the list identity, the -- sort mode, and the update-info revision the fetch pump bumps. - local statsSort = sortKey ~= "name" + local orderSort = sortKey == "order" + local statsSort = sortKey ~= "name" and not orderSort local rev = statsSort and (imp._modUpdateRev or 0) or 0 local cache = imp._modSortCache if cache and cache.n == #mods @@ -2591,6 +2596,7 @@ local function buildModsPanel(imp, x, y, w, availH, m) local n = decorate(scratch, mods, function(mod, tie) if sortKey == "name" then return tie end + if orderSort then return tonumber(mod.loadRank) or math.huge end local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id) if sortKey == "popularity" then return info and info.downloads and info.downloads.total or -1 @@ -2600,13 +2606,14 @@ local function buildModsPanel(imp, x, y, w, availH, m) return date and date.latest or "0000-00-00" end, function(mod) return (mod.name or ""):lower() end) - sortAsc = sortKey == "name" + sortAsc = sortKey == "name" or orderSort table.sort(scratch, decCompare) local sorted = undecorate(scratch, n) imp._modSortCache = { src = mods, n = #mods, key = sortKey, - rev = rev, at = Kit.time, list = sorted } + rev = rev, at = Kit.time, list = sorted, names = {} } mods = sorted end + local orderNames = imp._modSortCache and imp._modSortCache.names or {} -- A mod row is a fixed height: its details first, then a dedicated second -- line of per-game checkboxes. Fixed row heights are what make the @@ -2678,13 +2685,49 @@ local function buildModsPanel(imp, x, y, w, availH, m) else imp._modGames = { id = mod.id, scroll = 0 } end end, }) - btn(imp, px, gamesY + togH + math.floor(8 * m.s), - inner, m.btnH, + local detailsY = gamesY + togH + math.floor(8 * m.s) + local detailsW = inner + if orderSort then + local canMove = not safeMode and cartId == nil + local mgap = math.floor(6 * m.s) + local upLabel, downLabel = Strings("Up"), Strings("Down") + local ob = imp._modOrderBtn + if not ob or ob.s ~= m.s or ob.up ~= upLabel or ob.down ~= downLabel then + ob = { s = m.s, up = upLabel, down = downLabel, keys = {}, + w = math.max(Kit.textWidth("small", upLabel), + Kit.textWidth("small", downLabel)) + math.floor(24 * m.s) } + imp._modOrderBtn = ob + end + local keys = ob.keys[mod.id] + if not keys then + keys = { "mod-up-" .. mod.id, "mod-down-" .. mod.id } + ob.keys[mod.id] = keys + end + local mw = math.min(ob.w, math.floor((inner - 2 * mgap) / 4)) + detailsW = inner - 2 * (mw + mgap) + local mx = px + detailsW + mgap + btn(imp, mx, detailsY, mw, m.btnH, keys[1], upLabel, { + font = "small", enabled = canMove and i > 1, + action = function() imp:_moveMod(mod.id, -1) end }) + btn(imp, mx + mw + mgap, detailsY, mw, m.btnH, keys[2], + downLabel, { font = "small", enabled = canMove and i < #mods, + action = function() imp:_moveMod(mod.id, 1) end }) + end + btn(imp, px, detailsY, + detailsW, m.btnH, rowKey, Strings("Details"), { font = "small", icon = "folder", action = function() imp._modActions = mod.id end, }) local textW = inner - Kit.text("button", Kit.ellipsize("button", mod.name, textW - 16 * m.s), px, ly, + local shownName = mod.name + if orderSort then + shownName = orderNames[i] + if not shownName then + shownName = "#" .. i .. " " .. tostring(mod.name) + orderNames[i] = shownName + end + end + Kit.text("button", Kit.ellipsize("button", shownName, textW - 16 * m.s), px, ly, isFullyDisabled and PAL.muted or PAL.heading) ly = ly + Kit.textHeight("button") + math.floor(4 * m.s) local tags = (mod.badge or "MOD") .. (mod.targets and (" / " .. mod.targets) or "") @@ -2726,6 +2769,9 @@ local function buildModsPanel(imp, x, y, w, availH, m) segs[#segs + 1] = { (dl and " - " or "") .. dates, PAL.detail } end segLine("small", segs, px, ly, textW) + elseif orderSort and mod.orderNote then + Kit.text("small", Kit.ellipsize("small", Strings(mod.orderNote), textW), + px, ly, PAL.yellow) elseif (mod.description or "") ~= "" then Kit.text("small", Kit.ellipsize("small", mod.description, textW), px, ly, PAL.detail) @@ -4362,8 +4408,10 @@ local function buildSortModal(imp, m) local pad = math.floor(18 * m.s) local w = math.floor(360 * m.s) local gap = math.floor(8 * m.s) + local canReset = scope == "mods" and imp._resetModOrder ~= nil local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + #defs * (m.btnH + gap) + m.btnH + pad + + (canReset and (m.btnH + gap) or 0) local px, py, pw = modalPanel(m, w, h) local cy = py + pad Kit.text("button", Strings("Sort by"), px + pad, cy, PAL.heading) @@ -4385,6 +4433,17 @@ local function buildSortModal(imp, m) end }) cy = cy + m.btnH + gap end + if canReset then + local cartOn = imp.modCartPlan and imp:modCartPlan() or nil + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "sortpop-reset-order", + Strings("Reset load order"), { kind = "warn", font = "small", + enabled = not imp.safeMode and cartOn == nil, + action = function() + imp._sortPopup = nil + imp:_resetModOrder() + end }) + cy = cy + m.btnH + gap + end btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "sortpop-close", Strings("Close"), { font = "small", action = function() imp._sortPopup = nil end }) @@ -4922,8 +4981,10 @@ local function buildModActionsModal(imp, m) local pad = math.floor(18 * m.s) local w = math.floor(440 * m.s) local gap = math.floor(8 * m.s) + local cartOn = imp.modCartPlan and imp:modCartPlan() or nil + local canOrder = not imp.safeMode and cartOn == nil and #(imp.mods or {}) > 1 local nBtns = (hasGit and 2 or 0) + (hasDeps and 1 or 0) - + (hasImports and 1 or 0) + 2 + + (hasImports and 1 or 0) + 2 + (canOrder and 2 or 0) local h = pad + Kit.textHeight("button") + math.floor(4 * m.s) + Kit.textHeight("small") + math.floor(12 * m.s) + nBtns * (m.btnH + gap) - gap + pad @@ -4943,6 +5004,31 @@ local function buildModActionsModal(imp, m) px + pad, cy, statusCol) cy = cy + Kit.textHeight("small") + math.floor(12 * m.s) local id = mod.id + if canOrder then + local mine = tonumber(mod.loadRank) or math.huge + local at, n = 1, #(imp.mods or {}) + for _, mm in ipairs(imp.mods or {}) do + local r = tonumber(mm.loadRank) or math.huge + if mm.id ~= id and (r < mine or (r == mine and mm.id < id)) then + at = at + 1 + end + end + local half = math.floor((pw - 2 * pad - gap) / 2) + local moves = { + { "modact-up", Strings("Move up"), -1, at and at > 1 }, + { "modact-down", Strings("Move down"), 1, at and at < n }, + { "modact-top", Strings("Move to top"), -math.huge, at and at > 1 }, + { "modact-bottom", Strings("Move to bottom"), math.huge, at and at < n }, + } + for k, mv in ipairs(moves) do + local bx = px + pad + ((k % 2 == 0) and (half + gap) or 0) + local delta = mv[3] + btn(imp, bx, cy, half, m.btnH, mv[1], mv[2], { + font = "small", enabled = mv[4] == true, + action = function() imp:_moveMod(id, delta) end }) + if k % 2 == 0 then cy = cy + m.btnH + gap end + end + end if hasGit then local updLabel, updKind = Strings("Check for updates"), "ghost" if info and info.status == "available" then @@ -5187,18 +5273,34 @@ local function buildFindEntryModal(imp, m) cy = cy + Kit.textHeight("small") end cy = cy + math.floor(12 * m.s) + local cartHave = ModIndex.isCart(entry) + and imp:_findInstalledCarts()[entry.id] ~= nil + local instW = cartHave and math.floor((pw - 2 * pad - gap) / 2) + or (pw - 2 * pad) if action then - btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "findpop-inst", action, { + btn(imp, px + pad, cy, instW, m.btnH, "findpop-inst", action, { kind = "primary", font = "small", action = function() imp._findEntry = nil imp:_findConfirmInstall(entry) end }) else - btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "findpop-inst", + btn(imp, px + pad, cy, instW, m.btnH, "findpop-inst", Strings("Not installable from this index"), { font = "small", enabled = false }) end + if cartHave then + local cartId, cartBase = entry.id, entry.base + btn(imp, px + pad + instW + gap, cy, instW, m.btnH, "findpop-del", + DELETE_LABEL(deleteArmed(imp, "cart", cartId, cartBase)), { + kind = "danger", font = "small", icon = "trash", keepArm = true, + action = function() + imp:pressDelete("cart", cartId, cartBase, function() + imp._findEntry = nil + imp:deleteCart(cartBase, cartId, { from = "find" }) + end) + end }) + end cy = cy + m.btnH + gap local half = entry.repo and math.floor((pw - 2 * pad - gap) / 2) or (pw - 2 * pad) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 70c815c0..22111153 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -3,6 +3,7 @@ local GamepadMap = require("src.core.GamepadMap") local Logger = require("src.core.Logger") local Strings = require("src.core.Strings") local HostShell = require("src.core.HostShell") +local HostPicker = require("src.core.HostPicker") local Platform = require("src.core.Platform") local SafeArea = require("src.core.SafeArea") @@ -1138,8 +1139,7 @@ local function chooseRom(promptName) :format(prompt)) elseif platform == "Windows" then local script = table.concat({ - "Add-Type -AssemblyName System.Windows.Forms;", - "$d=New-Object System.Windows.Forms.OpenFileDialog;", + HostPicker.WIN_OPEN_DIALOG, "$d.Title='" .. prompt .. "';", "$d.Filter='Game Boy / GBA ROM (*.gb;*.gbc;*.gba)|*.gb;*.gbc;*.gba|All files (*.*)|*.*';", -- copy the pick to a plain-ASCII temp name and answer with that: @@ -1147,7 +1147,7 @@ local function chooseRom(promptName) -- (Pokémon -> Pok\x82mon) and io.open on Windows needs ANSI bytes, -- so returning the original name both crashed the notice draw and -- could never have opened the file (#325, #665) - "if($d.ShowDialog() -eq 'OK'){", + HostPicker.WIN_SHOW, "$t=Join-Path $env:TEMP 'pokeport_rom_pick.gb';", "Copy-Item -LiteralPath $d.FileName -Destination $t -Force;", "[Console]::OutputEncoding=[Text.Encoding]::UTF8;", @@ -1183,12 +1183,11 @@ local function chooseImporterFile(promptName, exts) :format(prompt, table.concat(quoted, ", "))) elseif platform == "Windows" then local script = table.concat({ - "Add-Type -AssemblyName System.Windows.Forms;", - "$d=New-Object System.Windows.Forms.OpenFileDialog;", + HostPicker.WIN_OPEN_DIALOG, "$d.Title='" .. prompt .. "';", "$d.Filter='Cartridge dump (" .. table.concat(semis, ";") .. ")|" .. table.concat(semis, ";") .. "|All files (*.*)|*.*';", - "if($d.ShowDialog() -eq 'OK'){", + HostPicker.WIN_SHOW, "[Console]::OutputEncoding=[Text.Encoding]::UTF8;", "[Console]::Write($d.FileName)}", }) @@ -1206,20 +1205,16 @@ local function chooseImporterFile(promptName, exts) return nil end --- Open a native picker for a mod .zip (mirrors chooseRom's per-OS dialogs). --- Returns the chosen absolute path or nil. Android uses love.system.pickFile --- ("mod") instead -- see RomImporter:chooseMod. -local function chooseZip() +local function zipPickCommands() local prompt = shellSafe(Strings("Choose a mod .zip")) local platform = love.system.getOS() if platform == "OS X" then - return commandOutput( + return { ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"zip"})' 2>/dev/null]]) - :format(prompt)) + :format(prompt) } elseif platform == "Windows" then local script = table.concat({ - "Add-Type -AssemblyName System.Windows.Forms;", - "$d=New-Object System.Windows.Forms.OpenFileDialog;", + HostPicker.WIN_OPEN_DIALOG, "$d.Title='" .. prompt .. "';", "$d.Filter='Mod archive (*.zip)|*.zip|All files (*.*)|*.*';", -- copy the pick to a plain-ASCII temp name and answer with that: @@ -1227,25 +1222,33 @@ local function chooseZip() -- (Pokémon -> Pok\x82mon) and io.open on Windows needs ANSI bytes, -- so returning the original name both crashed the notice draw and -- could never have opened the file (#325) - "if($d.ShowDialog() -eq 'OK'){", + HostPicker.WIN_SHOW, "$t=Join-Path $env:TEMP 'pokeport_mod_pick.zip';", "Copy-Item -LiteralPath $d.FileName -Destination $t -Force;", "[Console]::OutputEncoding=[Text.Encoding]::UTF8;", "[Console]::Write($t)}", }) - return commandOutput( - 'powershell -NoProfile -STA -Command "' .. script .. '"') + return { 'powershell -NoProfile -STA -Command "' .. script .. '"' } elseif platform == "Linux" then - local path = commandOutput( + return { ([[zenity --file-selection --title="%s" --file-filter="Mod archive | *.zip" 2>/dev/null]]) - :format(prompt)) - if path then return path end - return commandOutput( - [[kdialog --getopenfilename "$HOME" "*.zip|Mod archive" 2>/dev/null]]) + :format(prompt), + [[kdialog --getopenfilename "$HOME" "*.zip|Mod archive" 2>/dev/null]], + } end return nil end +local function chooseZip() + for _, command in ipairs(zipPickCommands() or {}) do + local path = commandOutput(command) + if path then return path end + end + return nil +end + +RomImporter._zipPickCommands = zipPickCommands + local function chooseSkinZip() local prompt = shellSafe(Strings("Choose a skin .zip or .deltaskin")) local platform = love.system.getOS() @@ -1255,11 +1258,10 @@ local function chooseSkinZip() :format(prompt)) elseif platform == "Windows" then local script = table.concat({ - "Add-Type -AssemblyName System.Windows.Forms;", - "$d=New-Object System.Windows.Forms.OpenFileDialog;", + HostPicker.WIN_OPEN_DIALOG, "$d.Title='" .. prompt .. "';", "$d.Filter='Skin archive (*.zip;*.deltaskin)|*.zip;*.deltaskin|All files (*.*)|*.*';", - "if($d.ShowDialog() -eq 'OK'){", + HostPicker.WIN_SHOW, "$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';", "$t=Join-Path $env:TEMP $n;", "Copy-Item -LiteralPath $d.FileName -Destination $t -Force;", @@ -1291,14 +1293,13 @@ local function chooseSav() :format(prompt)) elseif platform == "Windows" then local script = table.concat({ - "Add-Type -AssemblyName System.Windows.Forms;", - "$d=New-Object System.Windows.Forms.OpenFileDialog;", + HostPicker.WIN_OPEN_DIALOG, "$d.Title='" .. prompt .. "';", "$d.Filter='Game Boy save (*.sav)|*.sav|All files (*.*)|*.*';", -- copy the pick to a plain-ASCII temp name: io.open on Windows -- needs ANSI bytes, so a non-ASCII path (Pokémon -> Pok\x82mon) -- could never have been opened (#325, #665) - "if($d.ShowDialog() -eq 'OK'){", + HostPicker.WIN_SHOW, "$t=Join-Path $env:TEMP 'pokeport_sav_pick.sav';", "Copy-Item -LiteralPath $d.FileName -Destination $t -Force;", "[Console]::OutputEncoding=[Text.Encoding]::UTF8;", @@ -1329,15 +1330,14 @@ local function chooseRequiredFile() :format(prompt)) elseif platform == "Windows" then local script = table.concat({ - "Add-Type -AssemblyName System.Windows.Forms;", - "$d=New-Object System.Windows.Forms.OpenFileDialog;", + HostPicker.WIN_OPEN_DIALOG, "$d.Title='" .. prompt .. "';", "$d.Filter='All files (*.*)|*.*';", -- Required imports can be multi-gigabyte optical-disc images. Do NOT -- stage them through %TEMP%: that doubles free-space requirements and a -- failed Copy-Item can leave a plausible-looking truncated temp file. -- Stream the selected source directly into the mod-owned destination. - "if($d.ShowDialog() -eq 'OK'){", + HostPicker.WIN_SHOW, "[Console]::OutputEncoding=[Text.Encoding]::UTF8;", "[Console]::Write($d.FileName)}", }) @@ -1670,6 +1670,7 @@ end -- _pollPickedFiles must stay armed so it consumes the file when it lands -- moments later (it clears pickPending itself once something is found). function RomImporter:focus(f) + self._modPickOpenWait = nil if not f then self._activeTouch = nil self._pagePress = nil @@ -2185,12 +2186,12 @@ function RomImporter:_deleteMod(id) end -- "Import mod .zip" button: open a native picker and install the pick. --- 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 +-- Android: consume 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. function RomImporter:chooseMod() if self.workState == "working" then return end + if self._hostPick or self._modInboxInstall then return end if self.isNX then self:ensureModsInboxDir() self:rescanModsAction() @@ -2205,19 +2206,30 @@ function RomImporter:chooseMod() return end if self.android then - local name = findPendingMod(true, self.pickSkip) + local name = findPendingMod(false, self.pickSkip) if name then self:_installMod(name) consumePick(self, name, "picked_mod.zip", self.modNotice and self.modNotice.ok) return end + if self._modPickStalled and #self:scanModsInbox() > 0 then + self._modPickStalled = nil + self._modInboxInstall = "armed" + self.tab = "mods" + self.modNotice = { ok = true, + text = Strings("Installing mods from %s/ ...", MODS_INBOX_DIR) } + return + end + self._modPickOpenWait = nil if not pickFile("mod") then + self._modPickStalled = true self.modNotice = { ok = false, - text = "Could not open the file picker. Copy a mod .zip via USB." } + text = self:_modInboxHint(Strings("Could not open the file picker.")) } else self.pickPending = true self.pickTimer = 0 + if love.system.getOS() == "Android" then self._modPickOpenWait = 0 end end return end @@ -2240,11 +2252,27 @@ function RomImporter:chooseMod() end end + if love.system.getOS() == "Windows" and Platform.canSpawnProcess() then + local id = HostPicker.start(zipPickCommands()) + if id then + local notice = { ok = true, text = Strings( + "Choose a .zip in the file dialog. The launcher waits until it closes.") } + self._hostPick = { id = id, kind = "mod", notice = notice } + self.tab = "mods" + self.modNotice = notice + return + end + end + local path = chooseZip() if path then self:_installMod(path) return end + self:_openModBrowser() +end + +function RomImporter:_openModBrowser() local okKit, Kit = pcall(require, "src.ui.kit.Kit") if okKit and Kit.FileBrowser then self._padCursorActive = false @@ -2255,8 +2283,51 @@ function RomImporter:chooseMod() self:_installMod(pickedPath) end, }) + return true + end + return false +end + +function RomImporter:_modInboxHint(lead) + return Strings("%s Copy the .zip into this folder, then tap %s again:\n%s/%s/", + lead, self:_modsImportButtonLabel(), love.filesystem.getSaveDirectory(), + MODS_INBOX_DIR) +end + +function RomImporter:_pumpHostPick() + local job = self._hostPick + if not job then return end + local state, output, err = HostPicker.poll(job.id) + if state == "pending" then return end + self._hostPick = nil + if self.modNotice == job.notice then self.modNotice = nil end + output = trim(output) + if output ~= "" then + self:_installMod(output) + elseif err then + self:_openModBrowser() + end +end + +function RomImporter:_pumpModInboxInstall() + local step = self._modInboxInstall + if not step then return end + if step == "armed" then + self._modInboxInstall = "shown" return end + self._modInboxInstall = nil + self:rescanModsAction() +end + +function RomImporter:_pumpModPickOpen(dt) + if not self._modPickOpenWait then return end + self._modPickOpenWait = self._modPickOpenWait + math.min(dt or 0, 0.1) + if self._modPickOpenWait < 4 then return end + self._modPickOpenWait = nil + self._modPickStalled = true + self.modNotice = { ok = false, + text = self:_modInboxHint(Strings("The file picker did not open.")) } end local function requiredManifest(self, modId) @@ -3027,7 +3098,11 @@ function RomImporter:update(dt) self:_pumpCartInstall() self:_pumpCartFill() self:_pumpUpdateAll() + if self:_fireAutoUpdateAll() then return end self:_pumpExtract() + self:_pumpHostPick() + self:_pumpModPickOpen(dt) + self:_pumpModInboxInstall() -- Dev harness: POKEPORT_LAUNCHER_SHOT=/path.png resizes the window from -- POKEPORT_WIN=WxH, lets the view settle, then captures one frame and -- quits, so a scripted run can see the real launcher at any window shape @@ -3497,6 +3572,7 @@ end function RomImporter:gamepadpressed(_, button) if Transition.active() then return end + if self._hostPick then return end local action =(GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button local okKit, Kit = pcall(require, "src.ui.kit.Kit") if okKit then @@ -3796,6 +3872,7 @@ end -- Player pressed Play on a game whose ROM is imported: hand off to boot. function RomImporter:play(version, fade) if self.workState == "working" then return end + if self._hostPick then return end if not self.ready[version] then return end if fade then if not self._launchFade then @@ -3949,6 +4026,7 @@ end -- Delete selected the slot and wiped the arm it had just set, so a secondary -- slot became the loaded one and could never be deleted (#780). function RomImporter:runActions(queue) + if self._hostPick then return end for i = 1, #queue do local entry = queue[i] local key = type(entry.key) == "string" and entry.key or "" @@ -4894,6 +4972,7 @@ end function RomImporter:keypressed(key) if Transition.active() then return end + if self._hostPick then return end local okKit, Kit = pcall(require, "src.ui.kit.Kit") if okKit then if Kit.FileBrowser and Kit.FileBrowser.active then @@ -5583,15 +5662,46 @@ function RomImporter:_commitCartSave() self._cartNotice = Strings("Saved %s. It is in this list now.", title) end -function RomImporter:deleteCart(version, id) - if self.workState == "working" then return end - local ok, err = require("src.carts.CartStore").uninstall(id) - if not ok then self._cartNotice = tostring(err); return end +function RomImporter:deleteCart(version, id, opts) + local fromFind = type(opts) == "table" and opts.from == "find" + if self.workState == "working" then + local busy = Strings("Wait for the current import to finish, then delete the cart.") + if fromFind then + self.findNotice = { ok = false, text = busy } + else + self._cartNotice = busy + end + return false + end + local CartStore = require("src.carts.CartStore") + local installed = fromFind and CartStore.get(id) or nil + if installed and installed.base then version = installed.base end + local ok, err = CartStore.uninstall(id) + if not ok then + if fromFind then + self.findNotice = { ok = false, text = tostring(err) } + else + self._cartNotice = tostring(err) + end + return false + end if self.activeCart[version] == id then self:_selectCart(version, nil) end self:_refreshCarts(version) + self._cartPlan = nil + local cartKey = "cart:" .. tostring(id) + for _, cache in ipairs({ self._cartridgeLabels or {}, self._gbaLabels or {} }) do + cache[cartKey] = nil + cache["gba:" .. cartKey] = nil + end + if fromFind then + self.findNotice = { ok = true, + text = Strings("Cart deleted. Its saves and installed mods were kept.") } + return true + end self._cartPicker = nil self._cartPopup = version self._cartNotice = Strings("Cart deleted. Its saves and installed mods were kept.") + return true end function RomImporter:exportCart(id) @@ -5797,6 +5907,7 @@ function RomImporter:_commitRename() end function RomImporter:textinput(text) + if self._hostPick then return end if self._syncModal and self._syncFocus then self:_syncTypeInto(self._syncFocus, text) return @@ -6308,6 +6419,51 @@ function RomImporter:_setAllMods(want, confirmed) or Strings("Disabled %d mods.", #ids) } end +function RomImporter:_moveMod(id, delta) + if self.safeMode then + self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." } + return false + end + local cartId, cartReport = self:modCartPlan() + if cartId then + self.modNotice = { ok = false, text = self:cartPinsNote(cartId, cartReport) } + return false + end + local LauncherMods = require("src.mods.LauncherMods") + local visible, name = {}, id + for _, m in ipairs(self.mods or {}) do + visible[#visible + 1] = m.id + if m.id == id then name = m.name or id end + end + local ok, at = LauncherMods.moveMod(id, delta, visible) + if not ok then + self.modNotice = { ok = true, text = (tonumber(delta) or 0) < 0 + and Strings("%s already loads first.", name) + or Strings("%s already loads last.", name) } + return false + end + self._modSortCache = nil + self:_refreshMods() + self.modNotice = { ok = true, + text = Strings("Moved %s to load position %d.", name, at or 0) } + return true +end + +function RomImporter:_resetModOrder() + if self.safeMode then + self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." } + return false + end + local LauncherMods = require("src.mods.LauncherMods") + local ok = LauncherMods.resetOrder() + self._modSortCache = nil + self:_refreshMods() + self.modNotice = { ok = true, text = ok + and Strings("Load order reset to each mod's own priority.") + or Strings("Load order already follows each mod's own priority.") } + return ok +end + -- GitHub Update / Check for updates / Versions. Soft-fails into modNotice. -- Update button: when a newer release is known, confirm then install; when -- already current, force-refresh the 6h cache and report / offer update. @@ -6965,7 +7121,7 @@ function RomImporter:updateAllAvailable() return true end -function RomImporter:pressUpdateAllMods() +function RomImporter:pressUpdateAllMods(opts) if not self:updateAllAvailable() then return false end if not Platform.canFetchRemote() then self.modNotice = { ok = false, @@ -6973,9 +7129,10 @@ function RomImporter:pressUpdateAllMods() return false end self._updateAll = { stage = "check", index = 0, updated = 0, - updatedIds = {}, updatedCarts = {}, failures = {} } + updatedIds = {}, updatedCarts = {}, failures = {}, + auto = (opts and opts.auto) and true or nil } self.modNotice = nil - self:_syncModUpdateInfo(true) + self:_syncModUpdateInfo(not self._updateAll.auto) self:_setBusy(Strings("Checking for updates"), nil, function() self:_cancelUpdateAll() end) self:_pumpUpdateAll() @@ -7034,20 +7191,24 @@ function RomImporter:_pumpUpdateAll() if job.cancelled or job.total == 0 then return self:_finishUpdateAll(job.cancelled) end - job.stage = "confirm" - local lines = { Strings("Update %d items?", job.total) } - for i = 1, math.min(3, job.total) do - local row = job.rows[i] - lines[#lines + 1] = tostring(row.name or row.id) + if job.auto then + job.stage = "next" + else + job.stage = "confirm" + local lines = { Strings("Update %d items?", job.total) } + for i = 1, math.min(3, job.total) do + local row = job.rows[i] + lines[#lines + 1] = tostring(row.name or row.id) + end + if job.total > 3 then + lines[#lines + 1] = Strings("and %d more", job.total - 3) + end + job.confirm = { kind = "updateAllRun", title = Strings("Update all"), + yesLabel = Strings("Update all"), lines = lines } + self._modConfirm = job.confirm + self:_clearBusy() + return end - if job.total > 3 then - lines[#lines + 1] = Strings("and %d more", job.total - 3) - end - job.confirm = { kind = "updateAllRun", title = Strings("Update all"), - yesLabel = Strings("Update all"), lines = lines } - self._modConfirm = job.confirm - self:_clearBusy() - return end if job.stage == "confirm" then @@ -7146,6 +7307,7 @@ function RomImporter:_finishUpdateAll(cancelled) "%s now pins %d mod(s) you do not have. Open it in Custom Carts to install them.", tostring(cart.name), missing) self.modNotice.failures = lines + job.pinsMissing = true end end end @@ -7157,6 +7319,48 @@ function RomImporter:_finishUpdateAll(cancelled) break end end + if job.auto and self._autoUpdateAll then + self._autoUpdateAll.result = { + ok = not cancelled and #job.failures == 0 and not job.pinsMissing + and self._modDepResolver == nil, + cancelled = cancelled and true or false, + updated = job.updated, + total = job.total or 0, + failures = job.failures, + } + end +end + +function RomImporter:autoUpdateAll(onDone, opts) + if self._autoUpdateAll then return false end + self._autoUpdateAll = { onDone = onDone } + local tab = opts and opts.tab + if tab and self.tab ~= tab then self:_switchTab(tab) end + self:_ensureMods() + if self:pressUpdateAllMods({ auto = true }) then return true end + if self.safeMode and not self.modNotice then + self.modNotice = { ok = false, + text = Strings("Safe mode is on, so mods were not updated.") } + end + if self._autoUpdateAll then + self._autoUpdateAll.result = { ok = false, skipped = true, updated = 0, + total = 0, failures = {} } + end + return false +end + +function RomImporter:_fireAutoUpdateAll() + local auto = self._autoUpdateAll + if not (auto and auto.result) then return false end + self._autoUpdateAll = nil + local notice = self.modNotice + print("update-mods: " .. tostring(notice and notice.text + or (auto.result.skipped and "skipped" or "done"))) + for _, line in ipairs(notice and notice.failures or {}) do + print("update-mods: " .. tostring(line)) + end + if type(auto.onDone) == "function" then auto.onDone(auto.result) end + return true end diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 9b5c0d0f..72e7ffc6 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -64,7 +64,7 @@ local STONES = { -- STAT_LABEL (#811) -- Strings(stat:upper()) alone is a dynamic argument -- the harvester can't discover. local STAT_LABEL = { - hp = Strings.source("HP"), attack = Strings.source("ATTACK"), + hp = Strings.source("HEALTH"), attack = Strings.source("ATTACK"), defense = Strings.source("DEFENSE"), speed = Strings.source("SPEED"), special = Strings.source("SPECIAL"), accuracy = Strings.source("ACCURACY"), } @@ -507,21 +507,23 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) if STONES[itemId] then if not target then return "failed", { noEffect(data) } end - -- Yellow's starter Pikachu never evolves: ItemUseEvoStone runs - -- IsThisPartyMonStarterPikachu (OT identity match) before - -- TryEvolvingMon and bails with the voiced cry + RefusingText. - -- The stone is NOT consumed on the refuse path. - if target.species == "PIKACHU" - and require("src.core.GameVersion").isYellow() - and target.ot == save.player.name - and target.otId == save.player.id then - require("src.core.Sound").playCry(data, "PIKACHU") - return "failed", { romText(data, "_RefusingText", - "%s\nis refusing!", monName(data, target)) } - end local speciesDef = data.pokemon[target.species] for _, evo in ipairs(speciesDef.evolutions) do if evo.method == "ITEM" and evo.item == itemId then + -- engine/items/item_effects.asm:810 + -- Yellow's starter Pikachu never evolves: ItemUseEvoStone runs + -- IsThisPartyMonStarterPikachu (OT identity match) before + -- TryEvolvingMon and bails with the voiced cry + RefusingText. + -- The stone is NOT consumed on the refuse path. + if require("src.core.GameVersion").isYellow() + and require("src.world.PikachuFollower").isStarterPikachu(save, target) then + require("src.core.Sound").playCry(data, "PIKACHU") + -- engine/items/item_effects.asm:821 + save.pikachuEmotionModifier = 4 + save.pikachuMood = 0x82 + return "failed", { romText(data, "_RefusingText", + "%s\nis refusing!", monName(data, target)) } + end -- (engine/items/item_effects.asm:779-781) require("src.core.Sound").play(data, "Heal_Ailment") return "consumed", nil, { evolveTo = evo.species } @@ -553,8 +555,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) -- Spanish ROM puts the stat before the name), so the extracted line -- cannot be filled positionally; the engine wording stands -- engine/items/item_effects.asm:1313 - return "consumed", { Strings("%s's %s\nrose!", monName(data, target), - Strings(STAT_LABEL[vitaminStat])) }, { useJingle = true } + require("src.core.Sound").play(data, "Heal_Ailment") + -- data/text/text_6.asm:71 + return "consumed", { Strings("%s's\n%s rose.", monName(data, target), + Strings(STAT_LABEL[vitaminStat])) } end -- PP UP boosts the move the player picked (ItemUsePPUp's move menu) @@ -569,6 +573,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) return "consumed", { romText(data, "_PPIncreasedText", "%s's PP\nincreased!", mdef.name) } end + -- engine/items/item_effects.asm:2001-2006 + if mdef then + return "ppmaxed", { romText(data, "_PPMaxedOutText", + "%s's PP\nis maxed out.", mdef.name) } + end return "failed", { noEffect(data) } end diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index 5950c77c..2fee0c2e 100644 --- a/src/link/Fingerprint.lua +++ b/src/link/Fingerprint.lua @@ -261,7 +261,7 @@ end -- skipped here so the species id space -- which Fingerprint.records hands to -- Protocol.eligibleParty as "the mons the peer can rebuild" -- never carries an -- id no party slot could hold. -local NON_SPECIES = { growthRates = true } +local NON_SPECIES = { growthRates = true, tmhmMoves = true, tutorMoves = true } local function writeRecords(out, map, label, fields, skip) if map == nil then return end @@ -275,6 +275,19 @@ local function writeRecords(out, map, label, fields, skip) end end +-- data/items/attributes.asm:149 +local function writeHeldRecords(out, map) + if map == nil then return end + out[#out + 1] = "[held_items]" + for _, id in ipairs(sortedIds(map)) do + local record = map[id] + if type(record) == "table" and record.heldEffect ~= "HELD_NONE" then + out[#out + 1] = "@" .. id + writeFields(out, record, GEN2_HELD_FIELDS) + end + end +end + local function writeSection(out, data, kind) writeRecords(out, data[kind], kind, RECORD_FIELDS[kind]) end @@ -413,7 +426,7 @@ local function surfaceGen2(data, mods) writeGen2TypeChart(out, data) writeRecords(out, data.gen2Statuses, "statuses", GEN2_STATUS_FIELDS) writeRecords(out, data.gen2MoveEffects, "move_effects", GEN2_EFFECT_FIELDS) - writeRecords(out, data.gen2HeldItems, "held_items", GEN2_HELD_FIELDS) + writeHeldRecords(out, data.gen2HeldItems) out[#out + 1] = "[mods]" .. modKey(mods) return table.concat(out) end diff --git a/src/link/Wire.lua b/src/link/Wire.lua index 9d73e499..18489728 100644 --- a/src/link/Wire.lua +++ b/src/link/Wire.lua @@ -2,6 +2,7 @@ local Wire = {} local MAX_INT = 2147483647 local MAX_STRING = 64 +local MAX_DETAIL = 160 local MAX_NAME = 40 local MAX_LIST = 64 local MAX_PARTY = 32 @@ -237,7 +238,7 @@ SCHEMAS.peer_gone = function() return {} end SCHEMAS.join_error = function(m) return { reason = Wire.str(m.reason, "", MAX_STRING), field = Wire.str(m.field, nil, MAX_STRING), - detail = Wire.str(m.detail, nil, MAX_STRING), + detail = Wire.str(m.detail, nil, MAX_DETAIL), code = Wire.str(m.code, nil, MAX_NAME) } end diff --git a/src/mods/Gen2Compat.lua b/src/mods/Gen2Compat.lua index 2dc72ee3..fd3619e0 100644 --- a/src/mods/Gen2Compat.lua +++ b/src/mods/Gen2Compat.lua @@ -1161,8 +1161,8 @@ local function buildOverworld() local p = world.player local d = Map2.DELTA[p.facing] if not d then return false end - return Permissions.surfable( - world.map:cellCollision(p.cellX + d[1], p.cellY + d[2])) ~= nil + return Permissions.surfable(world:cellCollisionAcross(world.map, + p.cellX + d[1], p.cellY + d[2])) ~= nil end function ow.facingIsLandDismount() @@ -1172,8 +1172,8 @@ local function buildOverworld() if not p.surfing then return false end local d = Map2.DELTA[p.facing] if not d then return false end - return Permissions.isLand( - world.map:cellCollision(p.cellX + d[1], p.cellY + d[2])) + return Permissions.isLand(world:cellCollisionAcross(world.map, + p.cellX + d[1], p.cellY + d[2])) end function ow.trySurf(_fx, _fy, onClose) diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index f73e3573..e627edaa 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -39,6 +39,7 @@ local SaveData = require("src.core.SaveData") local GameVersion = require("src.core.GameVersion") local CacheFs = require("src.import.CacheFs") local RequiredImports = require("src.mods.RequiredImports") +local LoadOrder = require("src.mods.LoadOrder") local LauncherMods = {} @@ -358,9 +359,40 @@ function LauncherMods.deriveList(manifests, options, version) safeMode = safeMode, } end + local full = LoadOrder.materialize(SaveData.modOrder(options), ordered) + local rank = LoadOrder.rank(full) + local generation = version and GameVersion.generation(version) or nil + for _, row in ipairs(out) do + row.loadRank = rank[row.id] or (#full + 1) + local m = byId[row.id] + local after + local function consider(spec) + local dep = spec and spec.id + if dep and dep ~= row.id and rank[dep] and rank[dep] > row.loadRank + and ModTargets.specApplies(spec, version, generation) then + if not after or rank[dep] > rank[after] then after = dep end + end + end + for _, spec in ipairs(m.dependencySpecs or {}) do consider(spec) end + for _, spec in ipairs(m.optionalSpecs or {}) do consider(spec) end + if after then + local dep = byId[after] + row.orderAfter = after + row.orderNote = "Loads after " .. tostring(dep and dep.name or after) + .. " (dependency)" + end + end return out end +function LauncherMods.orderedIds(rows, options) + return LoadOrder.materialize(SaveData.modOrder(options), rows) +end + +function LauncherMods.moveInOrder(list, id, delta) + return (LoadOrder.move(list, id, delta)) +end + -- locateRoot(paths) -> the mod-root prefix inside a mounted archive, pure. -- paths is a shallow listing: top-level file names as-is, and for a top-level -- directory a "/manifest.json" entry when it holds one. Returns "" when @@ -669,6 +701,44 @@ function LauncherMods.setAllEnabled(ids, enabled, version) return true end +function LauncherMods.moveMod(id, delta, visible) + if type(id) ~= "string" or id == "" then return false end + local options = SaveData.loadOptions() + if SaveData.isSafeMode(options) then return false end + local manifests = discover() + local known = false + for _, m in ipairs(manifests) do + if m.id == id then known = true break end + end + if not known then return false end + local full = LoadOrder.materialize(SaveData.modOrder(options), manifests) + local list, changed = LoadOrder.moveWithin(full, visible, id, delta) + if not changed then return false end + SaveData.setModOrder(options, list) + SaveData.saveOptions(options) + LauncherMods.syncActiveProfile(options) + local shown = {} + for _, v in ipairs(visible or list) do shown[v] = true end + local n = 0 + for _, v in ipairs(list) do + if shown[v] then + n = n + 1 + if v == id then return true, n end + end + end + return true, n +end + +function LauncherMods.resetOrder() + local options = SaveData.loadOptions() + if SaveData.isSafeMode(options) then return false end + if #SaveData.modOrder(options) == 0 then return false end + SaveData.setModOrder(options, {}) + SaveData.saveOptions(options) + LauncherMods.syncActiveProfile(options) + return true +end + -- ------- install (love.filesystem) -- Read a .zip source into bytes. Save-dir-relative paths (inbox / @@ -1246,6 +1316,7 @@ function LauncherMods.applyProfile(profileName, options) end if not targetProfile then return false end ModProfile.restoreVersions(targetProfile, options) + ModProfile.restoreOrder(targetProfile, options) options.activeProfile = profileName SaveData.saveOptions(options) return true @@ -1255,7 +1326,8 @@ function LauncherMods.saveProfile(profileName, options) options = options or SaveData.loadOptions() local manifests = discover() options.modProfiles = options.modProfiles or {} - local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion) + local snap = ModProfile.capture(manifests, options.modOptions, + options.modsByVersion, options.modOrder) snap.name = profileName local existingIdx for i, p in ipairs(options.modProfiles) do @@ -1285,7 +1357,8 @@ function LauncherMods.syncActiveProfile(options) local activeName = options.activeProfile or "PROFILE 1" local profiles = options.modProfiles or {} local manifests = discover() - local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion) + local snap = ModProfile.capture(manifests, options.modOptions, + options.modsByVersion, options.modOrder) snap.name = activeName local found = false @@ -1330,6 +1403,7 @@ function LauncherMods.duplicateProfile(sourceName, options) options = copyTable(sourceProfile.options), slots = copyTable(sourceProfile.slots), enabledByVersion = copyTable(sourceProfile.enabledByVersion), + order = copyTable(sourceProfile.order), } profiles[#profiles + 1] = snap options.modProfiles = profiles diff --git a/src/mods/LoadOrder.lua b/src/mods/LoadOrder.lua new file mode 100644 index 00000000..c90bac66 --- /dev/null +++ b/src/mods/LoadOrder.lua @@ -0,0 +1,109 @@ +local SaveData = require("src.core.SaveData") + +local LoadOrder = {} + +local function priorityOf(entry) + local p = entry and (entry.priority or (entry.manifest and entry.manifest.priority)) + return tonumber(p) or 0 +end + +function LoadOrder.byPriority(entries) + local list = {} + for _, e in ipairs(entries or {}) do + if type(e) == "table" and type(e.id) == "string" then list[#list + 1] = e end + end + table.sort(list, function(a, b) + local pa, pb = priorityOf(a), priorityOf(b) + if pa == pb then return a.id < b.id end + return pa < pb + end) + local ids = {} + for i, e in ipairs(list) do ids[i] = e.id end + return ids +end + +function LoadOrder.materialize(saved, entries) + local listed = SaveData.modOrder({ modOrder = saved }) + local seen, prio = {}, {} + for _, id in ipairs(listed) do seen[id] = true end + for _, e in ipairs(entries or {}) do + if type(e) == "table" and type(e.id) == "string" then prio[e.id] = priorityOf(e) end + end + local tail = {} + for _, id in ipairs(LoadOrder.byPriority(entries)) do + if not seen[id] then + seen[id] = true + tail[#tail + 1] = id + end + end + local out, t = {}, 1 + for _, id in ipairs(listed) do + local p = prio[id] + if p then + while tail[t] and prio[tail[t]] < p do + out[#out + 1] = tail[t] + t = t + 1 + end + end + out[#out + 1] = id + end + for i = t, #tail do out[#out + 1] = tail[i] end + return out +end + +function LoadOrder.rank(list) + local rank = {} + local clean = SaveData.modOrder({ modOrder = list }) + for i, id in ipairs(clean) do rank[id] = i end + return rank, #clean + 1 +end + +function LoadOrder.move(list, id, delta) + local out, at = {}, nil + for i, v in ipairs(list or {}) do + out[i] = v + if v == id then at = i end + end + if not at or #out < 2 then return out, false end + local to = at + (tonumber(delta) or 0) + if to ~= to then return out, false end + to = math.max(1, math.min(#out, to)) + if to == at then return out, false end + table.remove(out, at) + table.insert(out, to, id) + return out, true +end + +function LoadOrder.moveWithin(full, visible, id, delta) + local shown = {} + for _, v in ipairs(visible or full or {}) do shown[v] = true end + local sub = {} + for _, v in ipairs(full or {}) do + if shown[v] then sub[#sub + 1] = v end + end + local moved, changed = LoadOrder.move(sub, id, delta) + if not changed then + local copy = {} + for i, v in ipairs(full or {}) do copy[i] = v end + return copy, false + end + local out, k = {}, 0 + for i, v in ipairs(full) do + if shown[v] then + k = k + 1 + out[i] = moved[k] + else + out[i] = v + end + end + return out, true +end + +function LoadOrder.position(list, id) + for i, v in ipairs(list or {}) do + if v == id then return i end + end + return nil +end + +return LoadOrder diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 74b65802..e705ce01 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -28,6 +28,7 @@ local Runtime = require("src.mods.Runtime") local Steps = require("src.mods.Steps") local Net = require("src.mods.Net") local Job = require("src.mods.Job") +local LoadOrder = require("src.mods.LoadOrder") local Loader = {} Loader.__index = Loader @@ -353,11 +354,14 @@ function Loader:_loadState() Runtime.safeMode = false self.gen2Forced = {} self.modOptions = {} + self.playerSaved, self.playerRank, self.playerFloor = nil, {}, 1 return end local options = SaveData.loadOptions(self.fs) self.safeMode = SaveData.isSafeMode(options) Runtime.safeMode = self.safeMode + self.playerSaved = SaveData.modOrder(options) + self.playerRank, self.playerFloor = LoadOrder.rank(self.playerSaved) local scope = self:_enableScope() local ids = {} for id in pairs(options.mods or {}) do ids[id] = true end @@ -775,6 +779,12 @@ function Loader:_cartRank(id) return report.rank[id] or report.floor end +function Loader:_playerRank(id) + local rank = self.playerRank + if not rank then return 1 end + return rank[id] or self.playerFloor or 1 +end + -- ------- validate and resolve -- a failed mod keeps the user's enable flag (the manager still shows it as @@ -1040,6 +1050,14 @@ function Loader:_order() local targetVersion = self:_targetVersion() local generation = self.generation local pending, indegree, dependents = {}, {}, {} + if self.playerSaved and #self.playerSaved > 0 then + local entries = {} + for id, mod in pairs(self.mods) do + entries[#entries + 1] = { id = id, priority = mod.manifest and mod.manifest.priority } + end + self.playerRank, self.playerFloor = + LoadOrder.rank(LoadOrder.materialize(self.playerSaved, entries)) + end for _, id in ipairs(orderedIds(self.mods, isActive)) do pending[id], indegree[id] = true, 0 end @@ -1072,9 +1090,11 @@ function Loader:_order() best = id else local ra, rb = self:_cartRank(id), self:_cartRank(best) + local ua, ub = self:_playerRank(id), self:_playerRank(best) local pa, pb = self.mods[id].manifest.priority, self.mods[best].manifest.priority - if ra < rb or (ra == rb and (pa < pb or (pa == pb and id < best))) then + if ra < rb or (ra == rb and (ua < ub or (ua == ub + and (pa < pb or (pa == pb and id < best))))) then best = id end end diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua index 5dd87ef2..7df16abe 100644 --- a/src/mods/ManagerState.lua +++ b/src/mods/ManagerState.lua @@ -17,6 +17,7 @@ local Theme = require("src.ui.Theme") local OptionRows = require("src.ui.OptionRows") local Strings = require("src.core.Strings") local ModProfile = require("src.mods.ModProfile") +local LoadOrder = require("src.mods.LoadOrder") local ManagerState = {} ManagerState.__index = ManagerState @@ -43,6 +44,7 @@ local TAB_LINE = { "[MODS] PROF ERRS", "MODS [PROF] ERRS", "MODS PROF [ERRS]" } local LIST_TOP = 3 -- first content row (tile y) local LIST_ROWS = 11 -- single-line rows in the scroll region +local DETAIL_TOP, DETAIL_ROWS = 11, 4 -- what the mod declared it does, shown before the player enables it local PERMISSION_ROWS = { @@ -219,6 +221,7 @@ function ManagerState:refresh() -- gen2Pending is not in stagedList: the Gen 2 override is not an enable flag -- and there is nothing in `available` to diff it against self.restartPending = #self:stagedList() > 0 or self.gen2Pending == true + or self.orderPending == true -- a live set that drifted off the named profile reverts to ad-hoc local opts = self:optionsTable() if opts.activeProfile then @@ -393,9 +396,7 @@ function ManagerState:detailRows(m) label = m.gen2Forced and Strings("DON'T TRY HERE") or Strings("TRY HERE ANYWAY"), action = function() self:toggleGen2Force(m) end } end - -- which games the mod says it is for, in the one place the player is - -- already looking when they wonder why it did not run - rows[#rows + 1] = { inert = true, label = "FOR " .. ModTargets.chip(m) } + for _, row in ipairs(self:orderRows(m)) do rows[#rows + 1] = row end if m.github then rows[#rows + 1] = { inert = true, label = "GH " .. m.github } end @@ -410,6 +411,85 @@ function ManagerState:detailRows(m) return rows end +function ManagerState:installedIds() + local ids = {} + for _, m in ipairs(self.status.available or {}) do ids[#ids + 1] = m.id end + return ids +end + +function ManagerState:loadOrderList() + local opts = self:optionsTable() + local saved, available = opts.modOrder, self.status.available + local c = self._orderCache + if c and c.saved == saved and c.available == available then + return c.shown, c.full + end + local full = LoadOrder.materialize(SaveData.modOrder(opts), available or {}) + local installed = {} + for _, id in ipairs(self:installedIds()) do installed[id] = true end + local shown = {} + for _, id in ipairs(full) do + if installed[id] then shown[#shown + 1] = id end + end + self._orderCache = { saved = saved, available = available, shown = shown, full = full } + return shown, full +end + +function ManagerState:orderRows(m) + local rows = {} + if Runtime.safeMode then return rows end + local loader = self.game.mods + local report = loader and loader.cartReport + if report and report.rank and report.rank[m.id] then + rows[#rows + 1] = { inert = true, label = Strings("CART SETS ORDER") } + return rows + end + local shown = self:loadOrderList() + local at = LoadOrder.position(shown, m.id) + if not at then return rows end + if #shown < 2 then + rows[#rows + 1] = { inert = true, label = "LOAD #" .. at .. " OF " .. #shown } + return rows + end + rows[#rows + 1] = { label = "< LOAD #" .. at .. "/" .. #shown .. " >", + adjust = function(dir) self:moveOrder(m, dir) end, + action = function() self:notify("LEFT/RIGHT: MOVE") end } + return rows +end + +local function sameList(a, b) + if #a ~= #b then return false end + for i, v in ipairs(a) do + if b[i] ~= v then return false end + end + return true +end + +function ManagerState:moveOrder(m, delta) + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return false + end + local opts = self:optionsTable() + local shown, full = self:loadOrderList() + local list, changed = LoadOrder.moveWithin(full, shown, m.id, delta) + if not changed then + self:notify(delta < 0 and "ALREADY FIRST" or "ALREADY LAST") + return false + end + if not self.bootOrder then + self.bootOrder = SaveData.modOrder(opts) + self.bootOrderShown = shown + end + SaveData.setModOrder(opts, list) + self:persistOptions() + local now = self:loadOrderList() + self.orderPending = not sameList(now, self.bootOrderShown) + self:refresh() + self:notify("LOAD #" .. (LoadOrder.position(now, m.id) or "?") .. " ON RESTART") + return true +end + function ManagerState:applyRows() local rows = {} rows[#rows + 1] = { label = Strings("APPLY & RESTART"), action = function() @@ -503,10 +583,11 @@ function ManagerState:moveCursor(dir) end end -- keep the cursor inside the scroll window + local window = self.screen == "detail" and DETAIL_ROWS or LIST_ROWS if self.cursor < self.scroll then self.scroll = self.cursor - elseif self.cursor > self.scroll + LIST_ROWS - 1 then - self.scroll = self.cursor - LIST_ROWS + 1 + elseif self.cursor > self.scroll + window - 1 then + self.scroll = self.cursor - window + 1 end end @@ -516,6 +597,8 @@ function ManagerState:adjustOrTab(dir) self.cursor, self.scroll = 1, 1 self:snapCursor() elseif self.screen == "detail" then + local row = self:focusedRow() + if row and row.adjust then return row.adjust(dir) end self.descScroll = math.max(1, self.descScroll + dir) else for _ = 1, LIST_ROWS do self:moveCursor(dir) end @@ -786,6 +869,11 @@ function ManagerState:discardChanges() if loader and loader.setEnabled then loader:setEnabled(m.id, en) end SaveData.setModEnabled(opts, m.id, en, scope) end + if self.bootOrder then + SaveData.setModOrder(opts, self.bootOrder) + self.bootOrder, self.bootOrderShown, self.orderPending = nil, nil, false + self:persistOptions() + end if loader and loader.status then self.game.modStatus = loader:status() end self:refresh() self:notify("CHANGES DISCARDED") @@ -816,6 +904,7 @@ function ManagerState:matchesProfile(p) -- the per-game answers count too, or a profile that only differs on Gold -- would read as still active after the player changed it return ModProfile.matchesVersions(p, self:optionsTable()) + and ModProfile.matchesOrder(p, self:optionsTable()) end function ManagerState:persistOptions() @@ -860,6 +949,16 @@ function ManagerState:applyProfile(p) end -- the per-game half of the setup, restored beside the shared enable set ModProfile.restoreVersions(p, self:optionsTable()) + do + local before = self:loadOrderList() + if not self.bootOrder then + self.bootOrder = SaveData.modOrder(self:optionsTable()) + self.bootOrderShown = before + end + ModProfile.restoreOrder(p, self:optionsTable()) + self.orderPending = not sameList(self:loadOrderList(), self.bootOrderShown) + self.restartPending = self.restartPending or self.orderPending + end self:optionsTable().activeProfile = p.name self:persistOptions() local missing = ModProfile.missingIds(p, self.byId) @@ -879,12 +978,13 @@ function ManagerState:saveCurrentAs() local opts = self:optionsTable() opts.modProfiles = opts.modProfiles or {} local snap = ModProfile.capture(self.status.available, - self:modOptionsTable(), opts.modsByVersion) + self:modOptionsTable(), opts.modsByVersion, opts.modOrder) local existing = self:findProfile(name) if existing then existing.enabled, existing.options, existing.slots = snap.enabled, snap.options, snap.slots existing.enabledByVersion = snap.enabledByVersion + existing.order = snap.order else snap.name = name opts.modProfiles[#opts.modProfiles + 1] = snap @@ -1261,6 +1361,7 @@ function ManagerState:drawDetail() drawTruncated(statusLine, 16, 3 * 8, 17) drawTruncated((m.category or "OTHER") .. " / " .. (m.profile or "content"), 16, 4 * 8, 17) + drawTruncated("FOR " .. ModTargets.chip(m), 16, 5 * 8, 17) local lines = wrap(m.error and ("FAILED: " .. m.error) or (m.note and ("SKIPPED: " .. m.note)) or m.description, 16) local visible = 5 @@ -1273,12 +1374,18 @@ function ManagerState:drawDetail() Font.drawCode(Theme.moreArrow, 17 * 8, 10 * 8) end local rows = self:rowsForScreen() - local y = 11 - for i, row in ipairs(rows) do - drawTruncated(row.label, 32, y * 8, 15) + local first = math.max(1, math.min(self.scroll or 1, #rows - DETAIL_ROWS + 1)) + if self.cursor >= 1 and self.cursor < first then first = self.cursor end + local last = math.min(#rows, first + DETAIL_ROWS - 1) + local y = DETAIL_TOP + for i = first, last do + drawTruncated(rows[i].label, 32, y * 8, 15) if i == self.cursor then Font.drawCode(Theme.cursor, 24, y * 8) end y = y + 1 end + if #rows > last then + Font.drawCode(Theme.moreArrow, 19 * 8, (DETAIL_TOP + DETAIL_ROWS - 1) * 8) + end self:drawFooter("A:CHOOSE B:BACK") end diff --git a/src/mods/ModProfile.lua b/src/mods/ModProfile.lua index 860fee65..047cde25 100644 --- a/src/mods/ModProfile.lua +++ b/src/mods/ModProfile.lua @@ -53,7 +53,14 @@ end -- loader's status manifests, m.enabled = the desired set including staged -- flips); `modOptions` is options.modOptions; `byVersion` is -- options.modsByVersion, the per-game answers that differ from it. -function ModProfile.capture(available, modOptions, byVersion) +local function copyOrder(list) + if type(list) ~= "table" then return nil end + local clean = SaveData.modOrder({ modOrder = list }) + if #clean == 0 then return nil end + return clean +end + +function ModProfile.capture(available, modOptions, byVersion, order) local enabled, options = {}, {} for _, m in ipairs(available or {}) do enabled[m.id] = m.enabled and true or false @@ -75,7 +82,23 @@ function ModProfile.capture(available, modOptions, byVersion) if flags then perVersion[id] = flags end end return { enabled = enabled, options = options, slots = slots, - enabledByVersion = perVersion } + enabledByVersion = perVersion, order = copyOrder(order) } +end + +function ModProfile.restoreOrder(p, options) + if type(options) ~= "table" then return end + local wanted = copyOrder(type(p) == "table" and p.order or nil) + SaveData.setModOrder(options, wanted or {}) +end + +function ModProfile.matchesOrder(p, options) + local wanted = copyOrder(type(p) == "table" and p.order or nil) or {} + local live = SaveData.modOrder(options) + if #live ~= #wanted then return false end + for i, id in ipairs(wanted) do + if live[i] ~= id then return false end + end + return true end -- Write a profile's per-game answers back into an options table, replacing @@ -147,7 +170,8 @@ function ModProfile.encode(p) formatVersion = ModProfile.FORMAT_VERSION, profile = { name = p.name, enabled = p.enabled, options = p.options, slots = p.slots, - enabledByVersion = p.enabledByVersion }, + enabledByVersion = p.enabledByVersion, + order = copyOrder(p.order) }, }) end @@ -191,6 +215,7 @@ function ModProfile.decode(body) if flags then p.enabledByVersion[version] = flags end end end + p.order = copyOrder(raw.order) return p end diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index 9dd64d69..cc91ef88 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -142,6 +142,8 @@ function Evolution.learnEvolutionMoves(game, mon, onDone) local name = mon.nickname or def.name if #mon.moves < 4 then table.insert(mon.moves, { id = moveId, pp = mdef.pp }) + require("src.world.PikachuFollower") + .onMoveLearned(game.save, mon, moveId) Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) -- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton -- (learn_move.asm), so the jingle rides the box diff --git a/src/render/ShaderFX.lua b/src/render/ShaderFX.lua index 22f9c031..2a7a4f9c 100644 --- a/src/render/ShaderFX.lua +++ b/src/render/ShaderFX.lua @@ -414,8 +414,10 @@ local function ensureLib() local ok, loaded = pcall(ffi.load, path) disarmProbe() if ok then lib = loaded; return lib end + tried[#tried + 1] = path .. " (" .. tostring(loaded) .. ")" + else + tried[#tried + 1] = path end - tried[#tried + 1] = path end local okSym, sym = pcall(function() return ffi.C and ffi.C.librashader_translate_preset diff --git a/src/save_convert/GenSave.lua b/src/save_convert/GenSave.lua index 4e7ae242..518a6a6f 100644 --- a/src/save_convert/GenSave.lua +++ b/src/save_convert/GenSave.lua @@ -127,6 +127,8 @@ O.playTimeFrames = O.mainData + 1870 -- 1B (0-59, 1/60s tic -- offset is identical between pokered and pokeyellow (same sram.asm, same -- wMainData field spacing per both symbol files). O.pikachuHappiness = O.mainData + 377 +O.pikachuMood = O.mainData + 378 -- engine/menus/save.asm:260 +O.pikachuEmotionModifier = O.mainData + 421 -- ram/wram.asm:2090 O.mainDataSize = 1929 -- wMainDataEnd - wMainDataStart O.spriteData = O.mainData + O.mainDataSize @@ -870,6 +872,9 @@ function GenSave.decode(bytes, data, opts) -- O.pikachuHappiness) (#763, #838). if data.gameVersion == "yellow" then save.pikachuHappiness = u8(bytes, O.pikachuHappiness) + save.pikachuMood = u8(bytes, O.pikachuMood) + local modifier = u8(bytes, O.pikachuEmotionModifier) + save.pikachuEmotionModifier = modifier ~= 0 and modifier or nil end save.warnings = warnings @@ -1196,6 +1201,11 @@ function GenSave.encode(save, data, template) if data.gameVersion == "yellow" then local h = tonumber(save.pikachuHappiness) or 90 setByte(buf, O.pikachuHappiness, math.max(0, math.min(255, math.floor(h)))) + -- engine/movie/oak_speech/init_player_data.asm:17 + local mood = tonumber(save.pikachuMood) or 0x80 + setByte(buf, O.pikachuMood, math.max(0, math.min(255, math.floor(mood)))) + local modifier = tonumber(save.pikachuEmotionModifier) or 0 + setByte(buf, O.pikachuEmotionModifier, math.max(0, math.min(255, math.floor(modifier)))) end local out = table.concat(buf) diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 8a1b7055..37abbb44 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -492,10 +492,12 @@ function Commands.face(ctx, dir) end -- face an arbitrary map object (by object_event index) -function Commands.face_object(ctx, objIndex, dir) +function Commands.face_object(ctx, objIndex, dir, opts) local npc = stampSpriteIndex(ctx, ctx.overworld and ctx.overworld:npcByIndex(objIndex)) if npc then npc.facing = dir end + -- pokeyellow engine/overworld/movement.asm:349 + if npc and opts and opts.hold then npc.timer = opts.hold end end -- Instantly relocate an NPC (OaksLabCalcRivalMovementScript / SetSpritePosition1). @@ -1201,12 +1203,17 @@ function Commands.walk_npc(ctx, objIndex, dirs, opts) claimMove(ctx, entity) local runner = ctx.runner local wait = not (opts and opts.wait == false) + -- pokeyellow engine/overworld/movement.asm:932 + local fast = opts and opts.stepFrames and entity ~= ow.player + local prevStep = entity.stepFrames + if fast then entity.stepFrames = opts.stepFrames end local yielded, finished = false, false local i = 0 local function step() i = i + 1 if not dirs[i] then finished = true + if fast then entity.stepFrames = prevStep end if wait and yielded then runner:resume() end return end diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 64e5af38..f50aadd2 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -245,8 +245,9 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) local function teach() -- PIKAHAPPY_USEDTMHM on a successful teach (item_effects.asm:2500) local function taught() - require("src.world.PikachuFollower") - .modifyHappiness(game.save, "USEDTMHM", target) + local PikachuFollower = require("src.world.PikachuFollower") + PikachuFollower.modifyHappiness(game.save, "USEDTMHM", target) + PikachuFollower.onMoveLearned(game.save, target, moveId) end if #target.moves < 4 then table.insert(target.moves, { id = moveId, pp = mdef.pp }) @@ -311,8 +312,9 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) local alarm = battle.lowHealthAlarmActive and battle:lowHealthAlarmActive() local opts = nil if not alarm then - opts = TextBox.soundOpts(game, "Pokeflute", - { auto = { wait = false, delay = 0, promptFirst = true } }) + opts = TextBox.soundOpts(game, function() + return require("src.core.Sound").playPokefluteInBattle(game.data) + end, { auto = { wait = false, delay = 0, promptFirst = true } }) end showMessages(game, head, function() showMessages(game, tail, function() spent({}) end) @@ -436,6 +438,8 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) local mdef = game.data.moves[moveId] if #target.moves < 4 then table.insert(target.moves, { id = moveId, pp = mdef.pp }) + require("src.world.PikachuFollower") + .onMoveLearned(game.save, target, moveId) local name = target.nickname or def.name showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) }, nextStep, TextBox.soundOpts(game, "Get_Item1")) @@ -475,6 +479,19 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) return end + -- engine/items/item_effects.asm:2004-2006 + if result == "ppmaxed" then + local menu = game.stack:top() + if not (menu and picker and menu.owner == picker and menu.held + and menu.ask) then + menu = nil + end + showMessages(game, payload, function() + if menu then menu:ask() else closePicker() end + end) + 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] @@ -526,7 +543,7 @@ local function pickTargetAndUse(game, battle, id, list) require("src.ui.Screens").push(game, "MoveSelectMenu", mon, prompt, function(moveIndex) useOn(game, battle, id, mon, list, moveIndex, picker) - end) + end, nil, picker) end, } -- TM/HM: open the party menu in Gen 1's TM/HM display mode so each mon diff --git a/src/ui/IntroMovie.lua b/src/ui/IntroMovie.lua index 63e55194..85b38318 100644 --- a/src/ui/IntroMovie.lua +++ b/src/ui/IntroMovie.lua @@ -7,6 +7,8 @@ local Music = require("src.core.Music") local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") local Timing = require("src.core.Timing") +local FaithfulRes = require("src.core.FaithfulRes") +local TouchSkin = require("src.core.TouchSkin") local IntroMovie = {} IntroMovie.__index = IntroMovie @@ -14,14 +16,22 @@ IntroMovie.isOpaque = true -- Same as the title screen: full-bleed art, no world behind it, no player zoom -- to respect, so fill the window rather than sit at the fixed integer scale. -function IntroMovie:wantsFillScale() return true end +function IntroMovie:wantsFillScale() + if FaithfulRes.locked then return false end + if TouchSkin.drawable() then return false end + return true +end -- SGB intro palettes: the splash uses PalPacket_GameFreakIntro (logo -- GAMEFREAK, falling star columns RED/VIRIDIAN/BLUEMON), the attract -- fight PalPacket_NidorinoIntro (PURPLEMON letterbox, BLACK bars) function IntroMovie:sgbPalettes(game) local P = require("src.render.PaletteFX") - if self.phase == 2 then + if self.phase == 1 and (self.studio.card or self.studio.credit) then + return nil + end + -- engine/movie/intro.asm:306 + if self.phase == 1 or self.phase == 2 then local logo = P.pal(game.data, "GAMEFREAK") if not logo then return nil end return { @@ -39,7 +49,7 @@ function IntroMovie:sgbPalettes(game) P.zone(P.pal(game.data, "BLACK"), 0, 14, 19, 17), } end - return nil -- the copyright card stays plain + return nil end local COPYRIGHT_FRAMES = 180 -- ld c, 180 (intro.asm:311-312) @@ -65,6 +75,29 @@ local POST_FADE_WHITE = 1 + 3 + 3 local LOGO_X, LOGO_Y = 72, 56 local TEXT_X, TEXT_Y = 40, 80 +-- engine/movie/splash.asm:72 +local LOGO_OBP0 = { [0] = 0xF9, 0x7E, 0x9F, 0xE7 } +-- engine/movie/splash.asm:4 +local STAR_OBP1 = 0xA4 + +local rampCache = setmetatable({}, { __mode = "k" }) + +local function obpRamp(obp, ramp) + local byRamp = rampCache[ramp] + if not byRamp then byRamp = {}; rampCache[ramp] = byRamp end + local c = byRamp[obp] + if not c then + c = { ramp[1] } + for i = 1, 3 do c[i + 1] = ramp[math.floor(obp / 4 ^ i) % 4 + 1] end + byRamp[obp] = c + end + return c +end + +local function romArt(path) + return path and not path:find("^mods/") and path or nil +end + -- CopyrightTextString (engine/movie/title.asm). Red/Blue years are -- (c)'95.'96.'98; Yellow's sheet spells (c)1995-1999 and finishes the -- last digit with NineTile (title screen). Intro originally overflows @@ -123,6 +156,15 @@ local FIGHT_SCRIPT = { { sfx = "Intro_Lunge" }, { frame = 3 }, { anim = 7 }, -- :135-141 { fade = 24 }, -- GBFadeOutToWhite: 3 pals x 8 frames (home/fade.asm:26-40) } +local FIGHT_FADE_INDEX = #FIGHT_SCRIPT + +-- home/fade.asm:71-73 +local FADE_TO_WHITE = { + { bgp = 0x90, obp = 0x80 }, + { bgp = 0x40, obp = 0x40 }, + { bgp = 0x00, obp = 0x00 }, +} +local BGP_NORMAL = 0xE4 local function tryImage(path) if not path then return nil end @@ -173,7 +215,10 @@ function IntroMovie.new(game, onDone) end self.logo = img(intro.gamefreakLogo) self.gfText = img(intro.gamefreakText) + self.logoPath = self.logo and romArt(intro.gamefreakLogo.path) + self.gfTextPath = self.gfText and romArt(intro.gamefreakText.path) self.bigStar = img(intro.bigStar) + self.bigStarPath = self.bigStar and romArt(intro.bigStar.path) self.smallStar = img(intro.fallingStar) self.smallStarBlink = img(intro.fallingStarBlink) self.gengarFrames, self.nidoFrames = {}, {} @@ -194,7 +239,7 @@ function IntroMovie.new(game, onDone) self.nidoX, self.nidoY = -8, 72 self.gengarPose, self.nidoFrame = 1, 1 self.opIndex, self.opTimer = 1, 0 - self.fade = 0 + self.fadeStep = nil return self end @@ -213,7 +258,6 @@ function IntroMovie:exitToTitle() if self.finished or self.phase == 4 then return end self.phase = 4 self.timer = 0 - self.fade = 1 end function IntroMovie:startPhase(phase) @@ -231,6 +275,26 @@ function IntroMovie:startPhase(phase) end -- one frame of the fight script (see FIGHT_SCRIPT) +function IntroMovie:endScene() + self.opIndex = FIGHT_FADE_INDEX + self.opTimer = 0 +end + +-- home/overworld.asm:2395 +function IntroMovie:pollInterrupt() + local input = self.game.input + if not input then return false end + local function down(b) + return (input.isDown and input:isDown(b)) or input:wasPressed(b) + end + local last = self.lastPollHeld or {} + local held = { a = down("a"), start = down("start") } + self.lastPollHeld = held + if (held.a and not last.a) or (held.start and not last.start) then return true end + return down("up") and down("select") and down("b") + and not (held.a or held.start or down("down") or down("left") or down("right")) +end + function IntroMovie:fightStep() while true do local op = FIGHT_SCRIPT[self.opIndex] @@ -259,6 +323,13 @@ function IntroMovie:fightStep() end end self.opTimer = self.opTimer + 1 + if self:pollInterrupt() then + -- engine/movie/intro.asm:264 + if op.move == "scrollIn" then self:endScene() return end + self.opIndex = self.opIndex + 1 + self.opTimer = 0 + return + end if self.opTimer < (op.px or math.abs(op.dx)) then return end timed = true elseif op.anim then @@ -273,11 +344,12 @@ function IntroMovie:fightStep() timed = true elseif op.wait then self.opTimer = self.opTimer + 1 + if self:pollInterrupt() then self:endScene() return end if self.opTimer < op.wait then return end timed = true elseif op.fade then self.opTimer = self.opTimer + 1 - self.fade = self.opTimer / op.fade + self.fadeStep = math.floor((self.opTimer - 1) / 8) + 1 if self.opTimer >= op.fade then self:exitToTitle() end return end @@ -297,10 +369,6 @@ function IntroMovie:update(dt) if self.timer >= POST_FADE_WHITE then self:finish() end return end - local input = self.game.input - -- CheckForUserInterruption (home/overworld.asm:2395) returns carry only - -- on a fresh START or A -- B alone never skips the intro. - local skip = input:wasPressed("a") or input:wasPressed("start") self.timer = self.timer + 1 if self.phase == 1 then -- the copyright card is a bare DelayFrames, deaf to input (intro.asm:311) @@ -310,18 +378,12 @@ function IntroMovie:update(dt) Sound.play(self.game.data, "Shooting_Star") -- splash.asm:29-30 end -- intro.asm:325 `jr c, .next` - if skip and self.timer >= STAR_START and self.timer < WAVES_END then + if self.timer >= STAR_START and self.timer < WAVES_END and self:pollInterrupt() then self:startPhase(3) return end if self.timer >= SPLASH_FRAMES then self:startPhase(3) end else - -- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the - -- white hold stands in for that beat before the title is built. - if skip then - self:exitToTitle() - return - end -- PlayShootingStar ends `jp Delay3` once Music_IntroBattle is playing -- (intro.asm:337), so PlayIntroScene's first op is not on the music's -- own frame @@ -333,28 +395,53 @@ end -- (IntroDrawBlackBars, intro.asm:343-357); drawn AFTER the sprites since -- both Nidorino and the small stars carry OAM_PRIO (intro.asm:195, -- splash.asm:149) so the bars cover them. -local function drawBars() - love.graphics.setColor(0, 0, 0, 1) +local function drawBars(bgp) + local g = (3 - math.floor((bgp or BGP_NORMAL) / 64) % 4) / 3 + love.graphics.setColor(g, g, g, 1) love.graphics.rectangle("fill", 0, 0, 160, 32) love.graphics.rectangle("fill", 0, 112, 160, 32) love.graphics.setColor(1, 1, 1, 1) end +function IntroMovie:logoObp0() + local t = self.timer - FLASH_START + if t < 0 then return LOGO_OBP0[0] end + return LOGO_OBP0[math.min(3, math.floor(t / 10) + 1)] +end + +function IntroMovie:drawObp0(img, path, x, y, obp) + if not img then return end + if not path then + love.graphics.draw(img, x, y) + return + end + local P = require("src.render.PaletteFX") + local SR = require("src.render.SpriteRenderer") + love.graphics.draw(SR.obpImage(path, obpRamp(obp, P.GRAYS), "gfobp" .. obp), x, y) + if P.usesSpriteObp() then + local blue = GameVersion.isBlue() + P.markUiSpriteRedraw(SR.obpImage(path, + obpRamp(obp, blue and P.GBC_OBJ_BLUE or P.GBC_OBJ), + (blue and "gfobj_blue" or "gfobj") .. obp), nil, x, y) + end +end + function IntroMovie:drawSplash() local t = self.timer if t >= STAR_START then -- logo + GAME FREAK letters appear with the star OAM -- (LoadShootingStarGraphics, splash.asm:18-25); the logo palette -- rotates during the 3-flash loop (splash.asm:72-82) - local flashing = t >= FLASH_START and t < FLASH_START + FLASH_FRAMES - local dim = flashing and math.floor((t - FLASH_START) / 5) % 2 == 0 - love.graphics.setColor(1, 1, 1, dim and 0.35 or 1) if self.studioLogo then + local flashing = t >= FLASH_START and t < FLASH_START + FLASH_FRAMES + local dim = flashing and math.floor((t - FLASH_START) / 5) % 2 == 0 + love.graphics.setColor(1, 1, 1, dim and 0.35 or 1) love.graphics.draw(self.studioLogo, self.studioX, self.studioY, 0, self.studioScale, self.studioScale) else - if self.logo then love.graphics.draw(self.logo, LOGO_X, LOGO_Y) end - if self.gfText then love.graphics.draw(self.gfText, TEXT_X, TEXT_Y) end + local obp = self:logoObp0() + self:drawObp0(self.logo, self.logoPath, LOGO_X, LOGO_Y, obp) + self:drawObp0(self.gfText, self.gfTextPath, TEXT_X, TEXT_Y, obp) end love.graphics.setColor(1, 1, 1, 1) end @@ -362,15 +449,16 @@ function IntroMovie:drawSplash() -- small stars: wave w spawns at y=88 every 24 frames, everything falls -- +1px per 3-frame substep until the wave loop ends; the lower star in -- the tile blinks every substep (splash.asm:97-146, 186-209) - local substep = math.floor((math.min(t, WAVES_END) - WAVES_START) / 3) - local blink = substep % 2 == 0 + -- engine/movie/splash.asm:186 + local moves = math.min(math.floor((t - WAVES_START) / 3) + 1, 6 * 8) + local lit = moves % 2 == 1 for w, xs in ipairs(STAR_WAVES) do local spawn = (w - 1) * 8 -- in substeps - if substep >= spawn then - local y = 88 + (substep - spawn) + if moves > spawn then + local y = 88 + (moves - spawn) if y < 144 then - local img = blink and self.smallStar - or (self.smallStarBlink or self.smallStar) + local img = lit and (self.smallStarBlink or self.smallStar) + or self.smallStar for _, x in ipairs(xs) do if img then love.graphics.draw(img, x, y) @@ -389,7 +477,19 @@ function IntroMovie:drawSplash() -- ..(engine/movie/splash.asm ln 32) local n = t - STAR_START + 1 local sx, sy = 152 - 4 * n, -16 + 4 * n - if self.bigStar then + if self.bigStar and self.bigStarPath then + local P = require("src.render.PaletteFX") + local SR = require("src.render.SpriteRenderer") + love.graphics.draw(SR.obpImage(self.bigStarPath, + obpRamp(STAR_OBP1, P.GRAYS), "gfobp" .. STAR_OBP1), sx, sy) + if P.usesSpriteObp() then + -- engine/movie/splash.asm:22 + local blue = GameVersion.isBlue() + P.markUiSpriteRedraw(SR.obpImage(self.bigStarPath, + obpRamp(STAR_OBP1, P.ogBg()), + (blue and "gfobj1_blue" or "gfobj1") .. STAR_OBP1), nil, sx, sy) + end + elseif self.bigStar then love.graphics.draw(self.bigStar, sx, sy) else love.graphics.setColor(0, 0, 0, 1) @@ -403,13 +503,30 @@ function IntroMovie:drawFight() -- Gengar: a 56x56 BG-tile pose recomposed from gengar_N.tilemap, moved -- by scrolling SCX (intro.asm:32-33, 235-269) -- Nidorino: 6x6 OAM sprite, one of the three red_nidorino poses + local fade = self.fadeStep and FADE_TO_WHITE[self.fadeStep] + local bgp = fade and fade.bgp or BGP_NORMAL + local obp = fade and fade.obp or BGP_NORMAL + local P = require("src.render.PaletteFX") + local SR = require("src.render.SpriteRenderer") local nido = self.nidoFrames[self.nidoFrame] + local nidoPath = self.nidoPaths and self.nidoPaths[self.nidoFrame] if nido then - love.graphics.draw(nido, self.nidoX, self.nidoY) + if fade and nidoPath then + love.graphics.draw(SR.obpImage(nidoPath, obpRamp(obp, P.GRAYS), + "fadeobp" .. obp), self.nidoX, self.nidoY) + else + love.graphics.draw(nido, self.nidoX, self.nidoY) + end end local gengar = self.gengarFrames[self.gengarPose] + local gengarPath = self.gengarPaths and self.gengarPaths[self.gengarPose] if gengar then - love.graphics.draw(gengar, self.gengarX, self.gengarY) + if fade and gengarPath then + love.graphics.draw(SR.obpImage(gengarPath, obpRamp(bgp, P.GRAYS), + "fadebgp" .. bgp), self.gengarX, self.gengarY) + else + love.graphics.draw(gengar, self.gengarX, self.gengarY) + end end if not gengar and not nido then @@ -417,45 +534,34 @@ function IntroMovie:drawFight() Font.draw(Strings("GENGAR VS NIDORINO"), (160 - 18 * 8) / 2, 64) love.graphics.setColor(1, 1, 1, 1) end - drawBars() - if self.fade > 0 then - love.graphics.setColor(1, 1, 1, math.min(1, self.fade)) - love.graphics.rectangle("fill", 0, 0, 160, 144) - love.graphics.setColor(1, 1, 1, 1) - end - self:replayObjLayer(nido, gengar) + drawBars(bgp) + self:replayObjLayer(nido, gengar, fade) end -local whitePixel - local FIGHT_CLIP = { 0, 32, 160, 80 } -function IntroMovie:replayObjLayer(nido, gengar) +function IntroMovie:replayObjLayer(nido, gengar, fade) local P = require("src.render.PaletteFX") local nidoPath = self.nidoPaths and self.nidoPaths[self.nidoFrame] if not (nido and nidoPath and P.usesSpriteObp()) then return end local SR = require("src.render.SpriteRenderer") local blue = GameVersion.isBlue() local opts = { clip = FIGHT_CLIP } + local objColors = blue and P.GBC_OBJ_BLUE or P.GBC_OBJ + local objGroup = blue and "gbcobj_blue" or "gbcobj" + local bgColors, bgGroup = P.ogBg(), blue and "ogbg_blue" or "ogbg" + if fade then + objColors, objGroup = obpRamp(fade.obp, objColors), objGroup .. fade.obp + bgColors, bgGroup = obpRamp(fade.bgp, bgColors), bgGroup .. fade.bgp + end -- engine/movie/intro.asm:28 - P.markUiSpriteRedraw(SR.obpImage(nidoPath, - blue and P.GBC_OBJ_BLUE or P.GBC_OBJ, blue and "gbcobj_blue" or "gbcobj"), + P.markUiSpriteRedraw(SR.obpImage(nidoPath, objColors, objGroup), nil, self.nidoX, self.nidoY, opts) local gengarPath = self.gengarPaths[self.gengarPose] if gengar and gengarPath then -- engine/movie/intro.asm:195 - P.markUiSpriteRedraw(SR.obpImage(gengarPath, P.ogBg(), - blue and "ogbg_blue" or "ogbg"), nil, self.gengarX, self.gengarY, opts) - end - if self.fade > 0 then - if not whitePixel then - local id = love.image.newImageData(1, 1) - id:setPixel(0, 0, 1, 1, 1, 1) - whitePixel = love.graphics.newImage(id) - end - P.markUiSpriteRedraw(whitePixel, nil, FIGHT_CLIP[1], FIGHT_CLIP[2], - { clip = FIGHT_CLIP, sx = FIGHT_CLIP[3], sy = FIGHT_CLIP[4], - color = { 1, 1, 1, math.min(1, self.fade) } }) + P.markUiSpriteRedraw(SR.obpImage(gengarPath, bgColors, bgGroup), + nil, self.gengarX, self.gengarY, opts) end end diff --git a/src/ui/MoveLearnMenu.lua b/src/ui/MoveLearnMenu.lua index d2578f66..c602ffdd 100644 --- a/src/ui/MoveLearnMenu.lua +++ b/src/ui/MoveLearnMenu.lua @@ -55,7 +55,7 @@ function MoveLearnMenu:enter() nil, { choice = function(yes) if yes then - self.selecting = true + self:askForget() else self:confirmAbandon() end @@ -63,6 +63,26 @@ function MoveLearnMenu:enter() })) end +-- engine/pokemon/learn_move.asm:119-123 +function MoveLearnMenu:askForget() + local TextBox = require("src.render.TextBox") + local game = self.game + self.selecting = false + local text = romText(game.data, "_WhichMoveToForgetText", + "Which move should\nbe forgotten?") + self.forgetPrompt = TextBox.strip(text) + local box + box = TextBox.new(game, text, nil, { stay = { + onShown = function() + if game.stack:top() == box then game.stack:pop() end + -- engine/pokemon/learn_move.asm:142 + self.index = 1 + self.selecting = true + end, + } }) + game.stack:push(box) +end + function MoveLearnMenu:update(dt) if not self.selecting then return end local input = self.game.input @@ -79,10 +99,13 @@ function MoveLearnMenu:update(dt) local old = self.mon.moves[self.index] if HM_MOVES[old.id] then -- HMCantDeleteText, then back to the forget list + -- engine/pokemon/learn_move.asm:177-181 local TextBox = require("src.render.TextBox") + self.selecting = false self.game.stack:push(TextBox.new(self.game, romText(self.game.data, "_HMCantDeleteText", - "HM techniques\ncan't be deleted!"))) + "HM techniques\ncan't be deleted!"), + function() self:askForget() end)) return end local mdef = self.game.data.moves[self.newMoveId] @@ -119,6 +142,8 @@ function MoveLearnMenu:finish(learned) local msg local opts if learned then + require("src.world.PikachuFollower") + .onMoveLearned(game.save, self.mon, self.newMoveId) -- pokered pages this as four texts in a row; _ForgotAndText carries -- the "And..." tail. Each text_pause holds the box, and the first one -- is followed by SFX_SWAP (engine/pokemon/learn_move.asm:208-222). @@ -146,16 +171,20 @@ function MoveLearnMenu:draw() if not self.selecting then return end -- TextBoxBorder 4,7 b=4 c=14; PlaceString 6,8; cursor 5,8 -- engine/pokemon/learn_move.asm:123-140 (#1686) + -- engine/pokemon/learn_move.asm:121 + Font.drawBox(0, 12, 20, 6) + love.graphics.setColor(0, 0, 0, 1) + local prompt = self.forgetPrompt or Strings("Which move should\nbe forgotten?") + local first, rest = prompt:match("^(.-)\n(.*)$") + Font.draw(first or prompt, 8, 14 * 8) + if rest then Font.draw(rest, 8, 16 * 8) end + love.graphics.setColor(1, 1, 1, 1) 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, (7 + i) * 8) end 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) - Font.draw(Strings("be forgotten?"), 8, 16 * 8) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/MoveSelectMenu.lua b/src/ui/MoveSelectMenu.lua index 0c92ddc1..a5489f85 100644 --- a/src/ui/MoveSelectMenu.lua +++ b/src/ui/MoveSelectMenu.lua @@ -7,20 +7,53 @@ MoveSelectMenu.__index = MoveSelectMenu local CURSOR = 0xED -function MoveSelectMenu.new(game, mon, prompt, onChoose, onCancel) +function MoveSelectMenu.new(game, mon, prompt, onChoose, onCancel, owner) local self = setmetatable({}, MoveSelectMenu) self.game = game self.mon = mon + self.rawPrompt = prompt or "" -- pokered engine/items/item_effects.asm:2136 self.prompt = require("src.render.TextBox").strip(prompt or "") self.onChoose = onChoose self.onCancel = onCancel + self.owner = owner -- pokered engine/battle/core.asm:2542-2543 self.index = 1 + self.ready = false + self.held = false + self.boxShown = false return self end +function MoveSelectMenu:enter() + self:ask() +end + +-- engine/items/item_effects.asm:1979 +function MoveSelectMenu:ask(text) + local TextBox = require("src.render.TextBox") + local game = self.game + self.ready, self.held = false, false + local box + box = TextBox.new(game, text or self.rawPrompt, nil, { stay = { + onShown = function() + if game.stack:top() == box then game.stack:pop() end + -- engine/battle/core.asm:2542-2543 + self.index = 1 + self.boxShown = true + self.ready = true + end, + } }) + game.stack:push(box) +end + +function MoveSelectMenu:release() + self.held = false + if self.game.stack:top() == self then self.game.stack:pop() end +end + function MoveSelectMenu:update() + if not self.ready then return end local input = self.game.input local n = #self.mon.moves -- engine/battle/core.asm:2692 @@ -30,16 +63,28 @@ function MoveSelectMenu:update() self.index = self.index < n and self.index + 1 or 1 elseif input:wasPressed("b") or (n < 1 and input:wasPressed("a")) then -- engine/items/item_effects.asm:1985 + self.ready = false self.game.stack:pop() if self.onCancel then self.onCancel() end elseif input:wasPressed("a") then - local picked = self.index - self.game.stack:pop() - if self.onChoose then self.onChoose(picked) end + self.ready = false + self.held = true + if self.onChoose then self.onChoose(self.index, self) end + if self.held and self.game.stack:top() == self then self:release() end end end function MoveSelectMenu:draw() + if not self.boxShown then return end + if self.ready then + -- engine/items/item_effects.asm:1979 + Font.drawBox(0, 12, 20, 6) + love.graphics.setColor(0, 0, 0, 1) + local first, rest = self.prompt:match("^(.-)\n(.*)$") + Font.draw(first or self.prompt, 8, 14 * 8) + if rest then Font.draw(rest, 8, 16 * 8) end + love.graphics.setColor(1, 1, 1, 1) + end -- engine/battle/core.asm:2526 Font.drawBox(4, 7, 16, 6) love.graphics.setColor(0, 0, 0, 1) @@ -50,11 +95,6 @@ function MoveSelectMenu:draw() Font.draw(mv and (mdef and mdef.name or mv.id) or "-", 48, (7 + i) * 8) end Font.drawCode(CURSOR, 40, (7 + self.index) * 8) - -- engine/items/item_effects.asm:1979 - Font.drawBox(0, 12, 20, 6) - local first, rest = self.prompt:match("^(.-)\n(.*)$") - Font.draw(first or self.prompt, 8, 14 * 8) - if rest then Font.draw(rest, 8, 16 * 8) end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 2211727f..acf53f90 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -402,6 +402,10 @@ end -- that already popped themselves, and stops a double close eating the bag -- underneath. #252 function PartyMenu:close() + local top = self.game.stack:top() + if top ~= self and top and top.owner == self and top.held then + self.game.stack:pop() + end if self.game.stack:top() == self then self.game.stack:pop() end end @@ -465,6 +469,7 @@ function PartyMenu:update(dt) end -- home/window.asm:119 self.cursorsErased = nil + self.chosenHollow = nil local input = self.game.input local party = self.party or self.game.save.party @@ -502,6 +507,7 @@ function PartyMenu:update(dt) elseif action == "battle_switch" then -- engine/battle/core.asm:2396 if self.keepOpen then + self.chosenHollow = true self.onSwitch(mon, self) else self.game.stack:pop() @@ -752,6 +758,8 @@ function PartyMenu:update(dt) -- else keeps the old pop-then-call order. Popping first is what made -- a POTION snap the picker shut before the item had even run (#252). if not self.keepOpen then self.game.stack:pop() end + -- home/pokemon.asm:246 + if self.keepOpen then self.chosenHollow = true end self.onSwitch(mon, self) else self.submenu = true @@ -973,7 +981,9 @@ function PartyMenu:draw() -- entryY returns. Drawing it at y put it a tile too high (#278). local cursorY = y + 8 if i == self.index and not self.cursorsErased then - Font.drawCode(Theme.cursor, 0, cursorY) + Font.drawCode((self.chosenHollow or self.submenu) and Theme.cursorHollow + or Theme.cursor, + 0, cursorY) end -- the unfilled swap arrow; the filled cursor replaces it in the tilemap -- when they share a row (PlaceMenuCursor, home/window.asm:184-185) (#814) diff --git a/src/ui/ShaderFXScreen.lua b/src/ui/ShaderFXScreen.lua index a273fd8a..6ce8d7ca 100644 --- a/src/ui/ShaderFXScreen.lua +++ b/src/ui/ShaderFXScreen.lua @@ -119,7 +119,12 @@ function ShaderFXScreen.new(game, slot) -- the screen stays open either way, since a convert is not a selection. if item.entry and not item.entry.converted then if not canConvert then - self.footer = Strings("Reinstall the app") + local bridgeErr = ShaderFX.bridgeError and ShaderFX.bridgeError() + if type(bridgeErr) == "string" and bridgeErr:find("cannot locate symbol", 1, true) then + self.footer = Strings("Update the app") + else + self.footer = Strings("Reinstall the app") + end return end ShaderFX.clearBridgeQuarantine() diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua index aca8d7b2..c966e366 100644 --- a/src/ui/ShopMenu.lua +++ b/src/ui/ShopMenu.lua @@ -8,7 +8,6 @@ -- price confirm. Key items and HMs can't be sold (.unsellableItem). 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") @@ -30,8 +29,9 @@ local function anythingElse(game) end -- .returnToMainPokemartMenu -- pokered engine/events/pokemart.asm:199-206 -local function refuse(game, menu, list, text) +local function refuse(game, menu, list, text, under) game.stack:push(TextBox.new(game, text, function() + if under and game.stack:top() == under then game.stack:pop() end if list then list:close() end menu.footer = anythingElse(game) end)) @@ -70,45 +70,60 @@ local function buy(game, stock, menu) menu.footer = anythingElse(game) return end + -- home/list_menu.asm:89-91 + list.hollowIndex = list.index local def = game.data.items[item.value] + local qtyBox + local function dropQty() + if game.stack:top() == qtyBox then game.stack:pop() end + end -- engine/events/pokemart.asm:152-156 - game.stack:push(QuantityBox.new(game, { + qtyBox = QuantityBox.new(game, { max = 99, unitPrice = def.price, + keepOpen = true, onDone = function(qty) if not qty then + dropQty() list.footer = greet return end local cost = qty * def.price - -- _PokemartTellBuyPriceText + yes/no confirm - list.footer = romText(game.data, "_PokemartTellBuyPriceText", - "%s?\nThat will be\n¥%d. OK?", def.name, cost) - game.stack:push(ChoiceBox.new(game, function(yes) - if not yes then - list.footer = greet - return - end - if game.save.money < cost then - refuse(game, menu, list, notEnough) - return - end - if not Bag.add(game.save, item.value, qty, game.data) then - refuse(game, menu, list, bagFull) - return - end - game.save.money = game.save.money - cost - -- 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)) + -- engine/events/pokemart.asm:159-169 + game.stack:push(TextBox.new(game, + romText(game.data, "_PokemartTellBuyPriceText", + "%s?\nThat will be\n¥%d. OK?", def.name, cost), + nil, + { choice = function(yes) + if not yes then + dropQty() + list.footer = greet + return + end + if game.save.money < cost then + refuse(game, menu, list, notEnough, qtyBox) + return + end + if not Bag.add(game.save, item.value, qty, game.data) then + refuse(game, menu, list, bagFull, qtyBox) + return + end + game.save.money = game.save.money - cost + -- 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() + dropQty() + list.footer = greet + end, + { preSound = function() + return require("src.core.Sound").play(game.data, "Purchase") + end })) + end })) end, - })) + }) + game.stack:push(qtyBox) end, }) game.stack:push(list) @@ -174,6 +189,8 @@ local function sell(game, menu) menu.footer = anythingElse(game) return end + -- home/list_menu.asm:89-91 + list.hollowIndex = list.index local def = game.data.items[item.value] -- only key items and HMs are unsellable (pokemart.asm IsKeyItem / -- IsItemHM); zero-price items like ETHER sell for ¥0. An unknown id @@ -185,37 +202,47 @@ local function sell(game, menu) return end local unit = math.floor(def.price / 2) - game.stack:push(QuantityBox.new(game, { + local qtyBox + local function dropQty() + if game.stack:top() == qtyBox then game.stack:pop() end + end + qtyBox = QuantityBox.new(game, { max = game.save.inventory[item.value] or 1, unitPrice = unit, + keepOpen = true, onDone = function(qty) if not qty then + dropQty() list.footer = greet return end - -- _PokemartTellSellPriceText + yes/no confirm - list.footer = romText(game.data, "_PokemartTellSellPriceText", - "I can pay you\n¥%d for that.", unit * qty) - game.stack:push(ChoiceBox.new(game, function(yes) - if not yes then - list.footer = greet - 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 - item.count = left - else - list:removeCurrent() - end - -- a sale prints nothing -- engine/events/pokemart.asm:112 - list.footer = greet - end)) + -- engine/events/pokemart.asm:84-91 + game.stack:push(TextBox.new(game, + romText(game.data, "_PokemartTellSellPriceText", + "I can pay you\n¥%d for that.", unit * qty), + nil, + { choice = function(yes) + dropQty() + if not yes then + list.footer = greet + return + end + game.save.money = game.save.money + unit * qty + -- home/inventory.asm:15 + 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 + item.count = left + else + list:removeCurrent() + end + -- a sale prints nothing -- engine/events/pokemart.asm:112 + list.footer = greet + end })) end, - })) + }) + game.stack:push(qtyBox) end, }) game.stack:push(list) diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index a4b2506d..821dbf8f 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -9,6 +9,8 @@ local GameVersion = require("src.core.GameVersion") local Strings = require("src.core.Strings") local Runtime = require("src.mods.Runtime") local Logger = require("src.core.Logger") +local FaithfulRes = require("src.core.FaithfulRes") +local TouchSkin = require("src.core.TouchSkin") local TitleState = {} TitleState.__index = TitleState @@ -18,7 +20,11 @@ TitleState.isOpaque = true -- at the fixed integer scale. The title screen is a full-bleed picture with no -- world behind it, so a small centred box in a large window is just wasted -- glass -- and unlike the overworld it has no zoom the player chose to respect. -function TitleState:wantsFillScale() return true end +function TitleState:wantsFillScale() + if FaithfulRes.locked then return false end + if TouchSkin.drawable() then return false end + return true +end -- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2, -- the version-ribbon band LOGO1, the rest MEWMON. diff --git a/src/ui/game3/pc_menu.lua b/src/ui/game3/pc_menu.lua index 6f625d80..21aebb2f 100644 --- a/src/ui/game3/pc_menu.lua +++ b/src/ui/game3/pc_menu.lua @@ -53,13 +53,21 @@ local function se(id) pcall(function() require("src.core.game3.audio").playSe(id) end) end -local function someone_or_bill_name(session) - local flags = session and (session.flags or session.eventFlags) or {} - -- FLAG_SYS_NOT_SOMEONES_PC = 0x828 (2088) - local isBill = flags[0x828] or flags["FLAG_SYS_NOT_SOMEONES_PC"] or false - return isBill and Strings("BILL's PC") or Strings("SOMEONE's PC") +local FLAG_SYS_NOT_SOMEONES_PC = 0x834 -- pokefirered/include/constants/flags.h:1386 + +local function script_store(session) + local Space = package.loaded["src.core.game3.scripting.space"] + return (Space and Space.store) or (session and session.store) or session end +-- pokefirered/src/script_menu.c:1027 +local function someone_or_bill_name(session) + local Flags = require("src.core.game3.scripting.flags") + local isBill = Flags.getFlag(script_store(session), nil, FLAG_SYS_NOT_SOMEONES_PC) + return isBill and Strings("BILL'S PC") or Strings("SOMEONE'S PC") +end +PcMenu.storageLabel = someone_or_bill_name + local function player_pc_name(session) local name = (session and (session.name or session.playerName)) or "RED" return Strings("%s's PC", name) diff --git a/src/ui/game3/save_menu.lua b/src/ui/game3/save_menu.lua index 3b6f978d..4548b4fb 100644 --- a/src/ui/game3/save_menu.lua +++ b/src/ui/game3/save_menu.lua @@ -10,6 +10,8 @@ local Chrome = require("src.ui.game3.chrome") local FrlgFont = require("src.ui.game3.frlg_font") local MapSectionsExtract = require("src.import.gba.map_sections_extract") local Strings = require("src.core.Strings") +local Flags = require("src.core.game3.scripting.flags") +local Dex = require("src.core.game3.dex") local SaveMenu = {} @@ -25,39 +27,38 @@ local function se(id) pcall(function() require("src.core.game3.audio").playSe(id) end) end -local function count_badges(session) - if not session then return 0 end - local count = 0 - if session.badges then - if type(session.badges) == "table" then - for i = 1, 8 do - if session.badges[i] == true or (tonumber(session.badges[i]) or 0) > 0 then - count = count + 1 - end - end - elseif type(session.badges) == "number" then - for i = 1, 8 do - local mask = bit and bit.lshift(1, i - 1) or math.pow(2, i - 1) - if bit and bit.band(session.badges, mask) ~= 0 then - count = count + 1 - end - end - end - else - for i = 1, 8 do - if session["badge" .. i] == true then count = count + 1 end - end +function SaveMenu.flagStore(session) + local Space = package.loaded["src.core.game3.scripting.space"] + local live = Space and Space.getStore and Space.getStore() + if type(live) == "table" and type(live.flags) == "table" then return live end + if type(session) ~= "table" then return { flags = {} } end + if type(session.store) == "table" and type(session.store.flags) == "table" then + return session.store end - return count + return { flags = type(session.flags) == "table" and session.flags or {} } end -local function count_caught(dex) - if not dex then return 0 end - local n = 0 - for sp, on in pairs(dex.caught or {}) do - if on then n = n + 1 end +-- pokefirered/src/save_menu_util.c:44 +function SaveMenu.countBadges(session) + return Flags.countBadges(SaveMenu.flagStore(session)) +end + +-- pokefirered/src/save_menu_util.c:25 +function SaveMenu.countDex(session) + local dex = type(session) == "table" and session.dex or nil + if type(dex) ~= "table" then return tonumber(session and session.caughtMonsCount) or 0 end + local national = false + local okP, PokedexData = pcall(require, "src.core.game3.pokedex_data") + if okP and PokedexData and PokedexData.isNationalUnlocked then + local ok, on = pcall(PokedexData.isNationalUnlocked, session, dex) + national = ok and on == true end - return n + return Dex.countCaught(dex, national and "national" or "kanto") +end + +-- pokefirered/src/start_menu.c:984 +function SaveMenu.hasDex(session) + return Flags.getFlag(SaveMenu.flagStore(session), nil, Flags.IDS.SYS_POKEDEX_GET or 0x829) == true end function SaveMenu.show(opts) @@ -224,8 +225,8 @@ function SaveMenu.draw() local map = Strings(SaveMenu.locationName(session)) local labels = { Strings("PLAYER"), Strings("BADGES"), Strings("POKéDEX"), Strings("TIME") } local valueX = 1 * 8 + SaveMenu.valueX(labels) - local badges = count_badges(session) - local caught = count_caught(session.dex) or tonumber(session.caughtMonsCount) or 0 + local badges = SaveMenu.countBadges(session) + local hasDex = SaveMenu.hasDex(session) local hours = tonumber(session.playTimeHours or session.hours) or 0 local mins = tonumber(session.playTimeMinutes or session.minutes) or 0 @@ -245,11 +246,15 @@ function SaveMenu.draw() FrlgFont.draw(labels[2], 1 * 8 + 4, 1 * 8 + 32, { colors = FrlgFont.COLOR.NORMAL }) FrlgFont.draw(tostring(badges), valueX, 1 * 8 + 32, { colors = FrlgFont.COLOR.NORMAL }) -- POKéDEX - FrlgFont.draw(labels[3], 1 * 8 + 4, 1 * 8 + 46, { colors = FrlgFont.COLOR.NORMAL }) - FrlgFont.draw(tostring(caught), valueX, 1 * 8 + 46, { colors = FrlgFont.COLOR.NORMAL }) + local timeY = 1 * 8 + 46 + if hasDex then + FrlgFont.draw(labels[3], 1 * 8 + 4, 1 * 8 + 46, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(tostring(SaveMenu.countDex(session)), valueX, 1 * 8 + 46, { colors = FrlgFont.COLOR.NORMAL }) + timeY = 1 * 8 + 60 + end -- TIME - FrlgFont.draw(labels[4], 1 * 8 + 4, 1 * 8 + 60, { colors = FrlgFont.COLOR.NORMAL }) - FrlgFont.draw(string.format("%d:%02d", hours, mins), valueX, 1 * 8 + 60, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(labels[4], 1 * 8 + 4, timeY, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(string.format("%d:%02d", hours, mins), valueX, timeY, { colors = FrlgFont.COLOR.NORMAL }) -- 2. Bottom Dialogue Window (pret WindowFunc_DrawDialogueFrame at (2, 15, 26, 4)) Chrome.dialogueFrame() diff --git a/src/ui/gen2/HeldItemMenu.lua b/src/ui/gen2/HeldItemMenu.lua index ac165e5c..c75259f4 100644 --- a/src/ui/gen2/HeldItemMenu.lua +++ b/src/ui/gen2/HeldItemMenu.lua @@ -218,6 +218,8 @@ end function HeldItemMenu:giveItem(itemId) local mon = self:mon() if not (mon and itemId) then return self:close() end + local inv = self.save and self.save.inventory + if not (inv and (inv[itemId] or 0) > 0) then return self:close() end if not self:canHold(itemId) then return self:say(self.TEXT.cantHold, function() self:openPack() end) end diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index 1ebbd610..c61d2a40 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -726,7 +726,9 @@ function PackMenu:giveToSlot(slot, row) save = self.save, slot = slot, items = self.items, + -- engine/items/pack.asm:606 onClose = function() + game.stack:pop() game.stack:pop() self:rebuild() end, diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index c73d5fb5..11538e58 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -93,6 +93,7 @@ local TELEPORT_IN_FRAMES = 43 -- engine/overworld/player_animations.asm:22-25 local TELEPORT_IN_PAD_HOLDS = { 3, 3, 3, 3, 3, 3, 0 } local TELEPORT_IN_PAD_FRAMES = 18 +local BOLT_WHITE_VEIL = { 1, 0.8 } local SPIN_DOWN_STEPS = 6 -- engine/overworld/player_animations.asm:41-45 local HOLE_IN_HOLDS = { 3, 3, 3, 3, 3, 0 } @@ -582,6 +583,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts) -- crossConnection re-arms this after setMap; clear so a warp/reload -- cannot leave a stale deferred PlayMapMusic pending self.pendingSeamMusic = nil + self.seamPalette = nil self.entities = { self.player } for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end -- Yellow's companion Pikachu trails the player (never in @@ -869,6 +871,14 @@ function OverworldState:paletteNameFor(map) return Runtime.call("map.palette", samePalette, name, map, { tod = tod }) end +-- home/overworld.asm:675 +function OverworldState:screenPaletteName() + if self.seamPalette and not OverworldState.perMapWorldPalettes() then + return self.seamPalette + end + return self:paletteNameFor(self.map) +end + -- UI-pass palette (text boxes and menus tint with the current map). OG RED -- resolves every name to the one global red BG palette inside PaletteFX.pal, -- so this needs no mode-specific branch. @@ -880,7 +890,7 @@ end -- that zone the pic wears the route/town palette and looks washed out. function OverworldState:sgbPalettes() local PaletteFX = require("src.render.PaletteFX") - local mapName = self:paletteNameFor(self.map) + local mapName = self:screenPaletteName() if self.emote and self.emote.pikaPic then local base = PaletteFX.pal(Game.data, mapName) if not base then return nil end @@ -919,7 +929,7 @@ function OverworldState:sgbWorldZones() if PaletteFX.usesGbcPack() and self.map.renderer and self.map.renderer.gbcAtlas then return {} end - local base = PaletteFX.pal(Game.data, self:paletteNameFor(self.map)) + local base = PaletteFX.pal(Game.data, self:screenPaletteName()) if not base then return nil end local vw, vh = Game.renderer:worldViewSize() local cam = self.camera @@ -1468,6 +1478,9 @@ function OverworldState:update(dt) -- the emotion-bubble pause holds the world for a beat if self.emote then + if self.emote.boltAt then + require("src.world.PikachuFollower").tickBolt(Game, self, self.emote) + end self.emote.frames = self.emote.frames - 1 -- PikaPicAnimTimerAndJoypad (engine/pikachu/pikachu_pic_animation.asm) -- cuts a pikapic beat short on A or B; the "!" bubble hold has no such @@ -1547,6 +1560,8 @@ function OverworldState:update(dt) if entry and (self.player.cellX ~= entry.x or self.player.cellY ~= entry.y) then self.warpEntryCell = nil end + -- home/overworld.asm:679 + if stepped then self.seamPalette = nil end -- deferred PlayMapMusic from crossConnection (issue #93) if stepped and self.pendingSeamMusic then local mapId = self.pendingSeamMusic @@ -2043,9 +2058,11 @@ function OverworldState:crossConnection(dir, conn) local PikachuFollower = require("src.world.PikachuFollower") local pika = PikachuFollower.current(self) local fromX, fromY = p.cellX, p.cellY + local seamPal = self:paletteNameFor(self.map) self:setMap(conn.map, x, y, p.facing, { seamless = true, keepMusic = true, keepPikachu = pika }) self.pendingSeamMusic = conn.map + self.seamPalette = seamPal -- place the player one cell before the seam (their old world spot, -- which the neighbor strip renders identically) and start the step -- into the new map RIGHT NOW so there is no one-frame stall at the @@ -4975,7 +4992,6 @@ function OverworldState:boulderIntoHole(npc) for _, entry in ipairs(self:seafoamHolesFor(self.map.id)) do local h = entry.hole if npc.cellX == h.x and npc.cellY == h.y then - require("src.core.Sound").play(Game.data, "Faint_Thud") Flags.set(Game.save, h.boulderEvent) local toggles = Game.save.objectToggles or {} Game.save.objectToggles = toggles @@ -4999,7 +5015,6 @@ function OverworldState:boulderIntoHole(npc) for i = #self.entities, 1, -1 do if self.entities[i] == npc then table.remove(self.entities, i) end end - Game.stack:push(TextBox.new(Game, Strings("The boulder fell\nthrough the hole!"))) return true end end @@ -5814,6 +5829,9 @@ function OverworldState:drawWorld() local fadeObp = fade.obp0 and fade:obp0() or fadeBgp PaletteFX.setFadeObp(Transition.shadeMapFor(fadeObp)) fade.paletteStepped = true + elseif self.emote and self.emote.bgp and not battleOverWorld then + -- engine/pikachu/pikachu_pic_animation.asm:847 + PaletteFX.setShadeMap(Transition.shadeMapFor(self.emote.bgp)) else PaletteFX.setShadeMap((self.dark and not battleOverWorld) and PaletteFX.DARK_BGP or self:poisonShadeMap()) @@ -6428,6 +6446,13 @@ function OverworldState:drawUI() love.graphics.setColor(1, 1, 1, 1) end end + + -- engine/pikachu/pikachu_pic_animation.asm:847 + local bolt = self.emote and self.emote.bgp + if bolt and bolt ~= 0xE4 and Game and Game.renderer + and (self:bakedWorldColors() or not PaletteFX.shader()) then + Game.renderer.screenVeil = BOLT_WHITE_VEIL + end end function OverworldState:captureSave(save) diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index f970ad6a..bd3d67f3 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -123,6 +123,34 @@ function PikachuFollower.onStep(save) elseif mood > 128 then save.pikachuMood = mood - 1 end + -- engine/events/poison.asm:153 + if (save.pikachuMood or 128) == 128 then + save.pikachuEmotionModifier = nil + end +end + +-- engine/pikachu/pikachu_status.asm:117 +function PikachuFollower.moodAfterBattle(save) + if not GameVersion.isYellow() then return end + local starter + for _, mon in ipairs(save.party or {}) do + if PikachuFollower.isStarterPikachu(save, mon) then starter = mon break end + end + -- engine/pikachu/pikachu_status.asm:1 + if not (starter and (starter.hp or 0) > 0) then return end + if (save.pikachuMood or 128) < 0x82 then + save.pikachuMood = 0x82 + end +end + +-- engine/items/item_effects.asm:2509 +-- engine/pokemon/evos_moves.asm:375 +function PikachuFollower.onMoveLearned(save, mon, moveId) + if not GameVersion.isYellow() then return end + if moveId ~= "THUNDERBOLT" and moveId ~= "THUNDER" then return end + if not PikachuFollower.isStarterPikachu(save, mon) then return end + save.pikachuEmotionModifier = 5 + save.pikachuMood = 0x85 end -- ShouldPikachuSpawn, approximated: Yellow, the lab gift happened, and a @@ -775,6 +803,14 @@ local PIKAPIC_LIFT = 4 -- px the stand-in pic rises on an overlay run -- pikachu_emotions.asm). local PIKAPIC_SCRIPT = { [29] = 10, [30] = 20, [31] = 23, [32] = 23 } +-- data/pikachu/pikachu_pic_animation.asm:1 +local THUNDERBOLT_PALS = {} +for i = 1, 20 do THUNDERBOLT_PALS[i] = (i % 2 == 1) and 0xC0 or 0xE4 end +local THUNDERBOLT_ROW = 4 +local THUNDERBOLT_SOUND_CAP = 600 +PikachuFollower.THUNDERBOLT_PALS = THUNDERBOLT_PALS +PikachuFollower.THUNDERBOLT_ROW = THUNDERBOLT_ROW + -- Per script: pikapic_setduration's tick count, and for the scripts whose -- overlay is a whole second pose, that frameset's run lengths in ticks -- (data/pikachu/pikachu_pic_objects.asm PikaPicAnimBGFrames_*, which script @@ -811,7 +847,7 @@ local PIKAPIC = { [22] = { dur = 40, seq = { 8, 100 } }, [23] = { dur = 70, seq = { 16, 16, 16, 16 } }, [24] = { dur = 60, seq = { 6, 6, 6, 6, 100 } }, - [25] = { dur = 50, seq = { 6, 106 } }, + [25] = { dur = 50, seq = { 6, 106 }, bolt = 13 }, [26] = { dur = 100, seq = { 20, 8, 20, 116 } }, [27] = { dur = 30, seq = { 4, 100 } }, [28] = { dur = 64, seq = { 12, 12, 12, 100 } }, @@ -830,6 +866,7 @@ local function moodEmotion(save) end return row[column] end +PikachuFollower.moodEmotion = moodEmotion -- MapSpecificPikachuExpression + TalkToPikachu's selection order local function selectEmotion(game, ow, save) @@ -858,7 +895,6 @@ local function selectEmotion(game, ow, save) if mapId:find("POKEMON_TOWER_", 1, true) == 1 then return 22 end local modifier = save.pikachuEmotionModifier if modifier and MODIFIER_EMOTIONS[modifier] then - save.pikachuEmotionModifier = nil return MODIFIER_EMOTIONS[modifier] end return moodEmotion(save) @@ -924,6 +960,11 @@ function playEmotion(game, ow, npc, emotion, opts) pikaSeq = anim.seq, pikaTotal = hold, skippable = opts.skippable, onDone = done, } + if anim.bolt then + -- engine/pikachu/pikachu_pic_animation.asm:520 + ow.emote.boltAt = (anim.bolt + 2) * PIKAPIC_TICK + ow.emote.boltT = 0 + end return ow.emote end @@ -984,6 +1025,39 @@ function PikachuFollower.picLift(emote) return 0 end +-- engine/pikachu/pikachu_pic_animation.asm:790 +function PikachuFollower.tickBolt(game, ow, emote) + if not (emote and emote.boltAt) or emote.boltDone then return end + emote.boltT = (emote.boltT or 0) + 1 + local s = emote.boltT - emote.boltAt + if s < 1 then return end + emote.frames = emote.frames + 1 + if s == 1 then + emote.skippable = false + emote.boltMute = { + isPlaying = function() return ow.emote == emote and not emote.boltDone end, + } + require("src.core.Music").duckForFanfare(emote.boltMute) + return + end + local k = s - 2 + local Sound = require("src.core.Sound") + if k == 0 then + local moves = game.data and game.data.moves + local def = moves and moves.THUNDERBOLT + if def and def.anim then Sound.playMove(game.data, def.anim) end + end + local strobe = #THUNDERBOLT_PALS * THUNDERBOLT_ROW + if k < strobe then + emote.bgp = THUNDERBOLT_PALS[math.floor(k / THUNDERBOLT_ROW) + 1] + return + end + -- engine/pikachu/pikachu_pic_animation.asm:802 + if Sound.moveSfxBusy() and k < strobe + THUNDERBOLT_SOUND_CAP then return end + emote.boltDone = true + emote.frames = PIKAPIC_TICK + 1 +end + -- Bill's House has three map-scripted Yellow companion beats -- (BillsHouseScript0/2/5): Pikachu walks over to investigate Bill, waits at -- the cell separator, then reacts when Bill reappears. Keep it at the diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index edb4fd66..a4284763 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -4773,7 +4773,9 @@ function World:rollFishing(rod) -- they stand on. local d = Map.DELTA[player.facing or "down"] or Map.DELTA.down local cx, cy = player.cellX + d[1], player.cellY + d[2] - if not Permissions.isWater(map:cellCollision(cx, cy)) then return "nowhere" end + if not Permissions.isWater(self:cellCollisionAcross(map, cx, cy)) then + return "nowhere" + end -- GetFishingGroup (home/map.asm) is MAP_FISHGROUP off the map header, and -- .facingwater's `and a / jr nz` sends FISHGROUP_NONE to .FishNoFish. The -- Encounter helper defaults an unknown map to the pond, so the header is @@ -5465,7 +5467,7 @@ end -- Returns true when the event took the A press. function World:tryHeadbuttOW(cx, cy) if not (self.map and self.player) then return false end - if not World.isHeadbuttTree(self.map:cellCollision(cx, cy)) then + if not World.isHeadbuttTree(self:cellCollisionAcross(self.map, cx, cy)) then return false end local mon = self:partyMoveUser(MOVE_HEADBUTT) @@ -5771,12 +5773,12 @@ function World:fieldContext(mon) mon = mon, facing = facing, facingX = fx, facingY = fy, - facingColl = map:cellCollision(fx, fy), + facingColl = self:cellCollisionAcross(map, fx, fy), playerColl = map:cellCollision(p.cellX, p.cellY), -- Crystal's SurfFunction.TrySurf is the only field move that asks -- (../pokecrystal/engine/events/overworld.asm:364). facingObject = self:facingObject(), - upColl = map:cellCollision(p.cellX, p.cellY - 1), + upColl = self:cellCollisionAcross(map, p.cellX, p.cellY - 1), tileset = map.def and map.def.tileset, facingBlock = blockId, facingBlockIndex = blockIndex, @@ -8557,7 +8559,8 @@ function World:facingObjectCell() if not p then return nil end local d = Map.DELTA[p.facing] or Map.DELTA.down local fx, fy = p.cellX + d[1], p.cellY + d[2] - if self.map and Permissions.isCounter(self.map:cellCollision(fx, fy)) then + if self.map + and Permissions.isCounter(self:cellCollisionAcross(self.map, fx, fy)) then return p.cellX + d[1] * 2, p.cellY + d[2] * 2 end return fx, fy @@ -8676,7 +8679,7 @@ function World:interactBody() -- run through the VM like any map script -- PCScript is `opentext / -- special PokemonCenterPC / closetext / end`. local std = TILE_COLLISION_STD_SCRIPTS[ - self.map and self.map:cellCollision(fx, fy)] + self.map and self:cellCollisionAcross(self.map, fx, fy)] if std then local entry = self.stdScripts and self.stdScripts.scripts and self.stdScripts.scripts[std] diff --git a/tests/drivers/battle_flute_short_bug2305_test.lua b/tests/drivers/battle_flute_short_bug2305_test.lua new file mode 100644 index 00000000..76316ec2 --- /dev/null +++ b/tests/drivers/battle_flute_short_bug2305_test.lua @@ -0,0 +1,141 @@ +-- engine/items/item_effects.asm:1731-1745 +-- audio/poke_flute.asm:1 +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") + or "/tmp/shots" + local Bag = require("src.inventory.Bag") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1; print("PASS " .. label) + else fail = fail + 1; print("FAIL " .. label) end + return ok + end + local function finish() + U.log(("RESULT pass=%d fail=%d"):format(pass, fail)) + love.event.quit(fail == 0 and 0 or 1) + while true do coroutine.yield() end + end + + local lead = Pokemon.new(game.data, "CHARIZARD", 50) + game.save.party = { lead } + Bag.add(game.save, "POKE_FLUTE", 1) + U.teleport(game, "ROUTE_1", 5, 5, "down") + U.wait(20) + + local battle = BattleState.newWild(game, "PIDGEY", 8) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + for _ = 1, 120 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(4) + end + if not check("2305 wild battle reached its menu", battle.phase == "menu") then + finish() + end + + lead.status = "SLP" + local turns = 0 + local realItemUsed = battle.itemUsed + battle.itemUsed = function(self, ...) + turns = turns + 1 + return realItemUsed(self, ...) + end + + local bag + for _ = 1, 20 do + local top = game.stack:top() + if top and top.screenId == "BagMenu" then bag = top break end + U.tap(game, "down"); U.wait(4) + U.tap(game, "left"); U.wait(4) + U.tap(game, "a"); U.wait(14) + end + if not check("2305 ITEM opened the bag", bag ~= nil) then finish() end + + local row + for i, item in ipairs(bag.items or {}) do + if item.value == "POKE_FLUTE" then row = i end + end + if not check("2305 POKE FLUTE in the battle bag", row ~= nil) then finish() end + for _ = 1, 40 do + if bag.index == row then break end + U.tap(game, bag.index < row and "down" or "up") + U.wait(3) + end + U.tap(game, "a") + U.wait(20) + + local box + for _ = 1, 240 do + local top = game.stack:top() + if top and top.isTextBox then box = top break end + U.wait(1) + end + if not check("2305 played-flute box opened", box ~= nil) then finish() end + + for _ = 1, 240 do + if box.done then break end + U.tap(game, "a") + end + check("2305 line typed out", box.done == true) + check("2305 no tune before the prompt", + box.autoStarted ~= true and box.autoSrc == nil) + U.shot(game, DIR .. "/2305_01_played_flute_prompt.png") + + U.tap(game, "a") + U.wait(2) + check("2305 prompt answered", box.autoPrompted == true) + local src = box.autoSrc + if not check("2305 tune started", src ~= nil) then finish() end + + local okd, dur = pcall(src.getDuration, src, "seconds") + dur = okd and dur or -1 + U.log(("tune source runs %.3f s (in-battle 4.267, field 9.600)"):format(dur)) + check("2305 in-battle phrase length (4.27 s, not 9.6 s)", + math.abs(dur - 256 / 60) < 0.1) + check("2305 derived def played, not the field Pokeflute", + require("src.core.Sound").isPlaying("Pokeflute_In_Battle") + and not require("src.core.Sound").isPlaying("Pokeflute")) + + local t0 = love.timer.getTime() + local shot, brokeOut = false, false + for _ = 1, 900 do + local ok, playing = pcall(src.isPlaying, src) + if not (ok and playing) then break end + if game.stack:top() ~= box then brokeOut = true break end + if not shot and love.timer.getTime() - t0 > 1.5 then + U.shot(game, DIR .. "/2305_02_short_tune_playing.png") + shot = true + else + U.tap(game, "a") + end + end + local held = love.timer.getTime() - t0 + U.log(("the box stayed up %.2f s under the tune"):format(held)) + check("2305 A never cut the tune short", not brokeOut) + check("2305 box held about 4.3 s", held > 3.6 and held < 5.2) + check("2305 turn did not resolve over the tune", turns == 0) + + U.wait(20) + local woke = game.stack:top() + check("2305 FluteWokeUpText follows the tune", + woke ~= nil and woke ~= box and woke.isTextBox == true) + for _ = 1, 60 do + if woke.done then break end + U.wait(2) + end + U.shot(game, DIR .. "/2305_03_all_woke_up.png") + + for _ = 1, 200 do + if turns > 0 then break end + U.tap(game, "a") + U.wait(4) + end + check("2305 dismissing it spends the turn", turns == 1) + check("2305 lead is awake", lead.status == nil) + finish() +end diff --git a/tests/drivers/bide_anim_bug375_test.lua b/tests/drivers/bide_anim_bug375_test.lua index 64d19608..2c1eef04 100644 --- a/tests/drivers/bide_anim_bug375_test.lua +++ b/tests/drivers/bide_anim_bug375_test.lua @@ -17,8 +17,10 @@ return function(game) local MAP = "ROUTE_1" local STAND = { x = 5, y = 5, facing = "down" } + local fails = 0 local function check(label, ok) U.log(ok and "PASS" or "FAIL", label) + if not ok then fails = fails + 1 end return ok end @@ -229,13 +231,10 @@ return function(game) U.log("pick FIGHT then BIDE and watch turn one: the screen palette flashes") U.log("white and balls spiral inward, silently. Then, still in silence, the") U.log("whole battle screen creeps sideways 1 px at a time out to 3 px and") - U.log("back, twice, and only then does it say storing energy. On the foe's") + U.log("back, twice, and no text follows before the foe moves. On the foe's") U.log("turn the same creep goes out to 6 px and takes twice as long.") U.log("No flash on the RATTATA and no BIDE thud until the turn it") U.log("unleashes, where the thud and its animation come after the text and") U.log("before the enemy HP bar slides down. The foe's spiral looks the same.") - - while true do - coroutine.yield() - end + love.event.quit(fails == 0 and 0 or 1) end diff --git a/tests/drivers/bide_silent_bug2338.lua b/tests/drivers/bide_silent_bug2338.lua new file mode 100644 index 00000000..d6564514 --- /dev/null +++ b/tests/drivers/bide_silent_bug2338.lua @@ -0,0 +1,220 @@ +-- engine/battle/effects.asm:764-789 +-- engine/battle/core.asm:3481-3506 +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + + local fails = 0 + local function check(label, ok) + if ok then + print("PASS " .. label) + else + print("FAIL " .. label) + fails = fails + 1 + end + return ok + end + + local function flat(s) return (tostring(s or ""):gsub("[\n\v\f]", " ")) end + + game.save.options = game.save.options or {} + game.save.options.animations = true + + local lead = Pokemon.new(game.data, "CHARMANDER", 20) + lead.moves = { { id = "BIDE", pp = 10 } } + game.save.party = { lead } + + U.teleport(game, "ROUTE_1", 5, 5, "down") + U.wait(15) + local ow = game.overworld + check("standing on ROUTE_1", ow and ow.map and ow.map.id == "ROUTE_1") + + local function watcher(battle) + local w = { shown = {}, anims = {}, queued = {} } + local markQ, lastCur = {}, nil + function w.poll() + for _, row in ipairs(battle.queue) do + if not markQ[row] then + markQ[row] = true + if row.text then w.queued[#w.queued + 1] = flat(row.text) end + if row.anim then w.anims[#w.anims + 1] = row.anim end + end + end + local cur = battle.current + if cur ~= lastCur then + lastCur = cur + if cur and cur.text then + w.shown[#w.shown + 1] = flat(cur.text) + return flat(cur.text) + end + end + return nil + end + return w + end + + local function has(list, needle) + for _, s in ipairs(list) do + if s:find(needle, 1, true) then return true end + end + return false + end + + local function waitPhase(battle, phase, tries, w) + for _ = 1, tries do + if battle.phase == phase then return true end + U.tap(game, "a") + for _ = 1, 6 do w.poll() U.wait(1) end + end + return battle.phase == phase + end + + local function pickFirstMove(battle, w) + U.tap(game, "a") + if not waitPhase(battle, "moveSelect", 10, w) then return false end + U.tap(game, "a") + return true + end + + local function settledShot(watch, path) + for _ = 1, 100 do watch.poll() U.wait(1) end + return U.shot(game, path) + end + + local function leaveBattle() + for _ = 1, 10 do + if game.stack:top() == ow then break end + game.stack:pop() + end + end + + local battle = BattleState.newWild(game, "RATTATA", 8) + battle.onFinish = function() end + battle.enemy.mon.moves = { { id = "TACKLE", pp = 35 } } + battle.enemy.curMoves = battle.enemy.mon.moves + ow:pushBattle(battle) + local w = watcher(battle) + check("player battle reached the menu", waitPhase(battle, "menu", 80, w)) + local sinceShown, sinceQueued = #w.shown, #w.queued + check("BIDE picked", pickFirstMove(battle, w)) + + local tackles, unleashed = 0, false + local lockedSeen, sawSpiral = false, false + local shots = {} + for _ = 1, 3000 do + local line = w.poll() + if has(w.anims, "XSTATITEM_ANIM") then sawSpiral = true end + if line then + if line:find("TACKLE", 1, true) then + tackles = tackles + 1 + if tackles == 1 and not shots.store then + shots.store = settledShot(w, DIR .. "/2338_01_store_turn_spiral_then_foe_tackle.png") + elseif tackles >= 2 and not shots.locked then + lockedSeen = true + shots.locked = settledShot(w, DIR .. "/2338_02_locked_turn_only_foe_text.png") + end + elseif line:find("unleashed energy", 1, true) then + unleashed = true + shots.release = settledShot(w, DIR .. "/2338_03_release_unleashed_energy.png") + break + end + end + if game.stack:top() ~= battle then + if not game.stack:top() or battle.player.mon.hp <= 0 then break end + end + if U.frame() % 6 == 0 then U.tap(game, "a") else U.wait(1) end + end + + local pShown, pQueued = {}, {} + for i = sinceShown + 1, #w.shown do pShown[#pShown + 1] = w.shown[i] end + for i = sinceQueued + 1, #w.queued do pQueued[#pQueued + 1] = w.queued[i] end + local stray = {} + for _, s in ipairs(pShown) do + if s:find("CHARMANDER", 1, true) and not s:find("used BIDE", 1, true) + and not s:find("unleashed energy", 1, true) then + stray[#stray + 1] = s + end + if s:find("unleashed energy", 1, true) then break end + end + U.log("player battle shown: " .. table.concat(pShown, " | ")) + check("storing turn played the X-item spiral", sawSpiral) + check("no storing-energy line shown on the player's side", + not has(pShown, "storing energy")) + check("no storing-energy line queued on the player's side", + not has(pQueued, "storing energy")) + check("foe acted on at least one locked turn (" .. tackles .. " TACKLE lines)", + lockedSeen) + check("no CHARMANDER line besides used BIDE before the release: " + .. (#stray > 0 and table.concat(stray, " | ") or "(none)"), #stray == 0) + check("release printed unleashed energy", unleashed) + for _ = 1, 30 do w.poll() U.wait(1) end + check("release played BIDE's own animation", has(w.anims, "BIDE")) + + leaveBattle() + + lead.hp = lead.stats.hp + lead.status = nil + lead.moves = { { id = "SCRATCH", pp = 35 } } + local foeBattle = BattleState.newWild(game, "RATTATA", 25) + foeBattle.onFinish = function() end + foeBattle.enemy.mon.moves = { { id = "BIDE", pp = 10 } } + foeBattle.enemy.curMoves = foeBattle.enemy.mon.moves + ow:pushBattle(foeBattle) + local fw = watcher(foeBattle) + check("foe battle reached the menu", waitPhase(foeBattle, "menu", 80, fw)) + local fSince = #fw.shown + local fQSince = #fw.queued + check("SCRATCH picked", pickFirstMove(foeBattle, fw)) + + local scratches, foeUnleashed, foeSpiral = 0, false, false + local foeStoreShot = false + for _ = 1, 3000 do + local line = fw.poll() + if has(fw.anims, "XSTATITEM_DUPLICATE_ANIM") then foeSpiral = true end + if line then + if line:find("SCRATCH", 1, true) then + scratches = scratches + 1 + if foeSpiral and not foeStoreShot then + foeStoreShot = true + settledShot(fw, DIR .. "/2338_04_foe_store_turn_then_player_move.png") + end + elseif line:find("unleashed energy", 1, true) then + foeUnleashed = true + break + end + end + if game.stack:top() ~= foeBattle then + if not game.stack:top() or foeBattle.enemy.mon.hp <= 0 then break end + end + if U.frame() % 6 == 0 then U.tap(game, "a") else U.wait(1) end + end + + local fShown, fQueued = {}, {} + for i = fSince + 1, #fw.shown do fShown[#fShown + 1] = fw.shown[i] end + for i = fQSince + 1, #fw.queued do fQueued[#fQueued + 1] = fw.queued[i] end + local fStray = {} + for _, s in ipairs(fShown) do + if s:find("RATTATA", 1, true) and not s:find("used BIDE", 1, true) + and not s:find("unleashed energy", 1, true) then + fStray[#fStray + 1] = s + end + if s:find("unleashed energy", 1, true) then break end + end + U.log("foe battle shown: " .. table.concat(fShown, " | ")) + check("foe's storing turn played XSTATITEM_DUPLICATE_ANIM", foeSpiral) + check("no storing-energy line shown on the foe's side", + not has(fShown, "storing energy")) + check("no storing-energy line queued on the foe's side", + not has(fQueued, "storing energy")) + check("player moved through the foe's locked turns (" .. scratches + .. " SCRATCH lines)", scratches >= 2) + check("no RATTATA line besides used BIDE before the release: " + .. (#fStray > 0 and table.concat(fStray, " | ") or "(none)"), #fStray == 0) + check("foe's release printed unleashed energy", foeUnleashed) + + leaveBattle() + print(fails == 0 and "PASS bide_silent_bug2338" or "FAIL bide_silent_bug2338") + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_goldenrod_edge_fish_bug2352.lua b/tests/drivers/crystal_goldenrod_edge_fish_bug2352.lua new file mode 100644 index 00000000..15e5be2f --- /dev/null +++ b/tests/drivers/crystal_goldenrod_edge_fish_bug2352.lua @@ -0,0 +1,114 @@ +-- ../pokecrystal/engine/events/overworld.asm:1444 +-- ../pokecrystal/data/maps/attributes.asm:139 +local U = require("tests.drivers.util") +local Map = require("src.world.gen2.Map") +local Permissions = require("src.world.gen2.Permissions") +local WorldAPI = require("src.world.gen2.WorldAPI") + +local SHOTS = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots/2352" +local TOWN = "GOLDENROD_CITY" +local POND = "ROUTE_34" +local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + +return function(game) + local fails = 0 + local function say(line) print("[edge2352] " .. line) end + local function ok(cond, label, detail) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. label + .. (detail ~= nil and (" (" .. tostring(detail) .. ")") or "")) + return cond + end + + U.wait(60) + local world = game.world + if not (world and world.map and game.save) then + say("FAIL the crystal world did not boot") + love.event.quit(1) + return + end + world.noWildEncounters = true + game.save.inventory = game.save.inventory or {} + game.save.inventory.OLD_ROD = 1 + local api = WorldAPI.new(game, "driver2352") + + local function offersFish() + for _, row in ipairs((api:availableFieldActions())) do + if row.id == "fish" then return true end + end + return false + end + + local function settle() + for _ = 1, 240 do + if not world.mapSetup and not world:busy() then break end + U.wait(1) + end + U.wait(10) + end + + local function mouth(x, y, facing, label, shot) + world:warpToMapId(TOWN, x, y, facing) + settle() + world.player.facing = facing + U.wait(2) + ok(world.map and world.map.id == TOWN and world.player.cellX == x + and world.player.cellY == y, label .. ": standing at " .. x .. "," .. y) + local ctx = world:fieldContext() + ok(not Permissions.isWater(ctx.facingColl), + label .. ": the faced cell is the neighbour's road, not water", + ("coll $%02x"):format(ctx.facingColl or 0xff)) + ok(not offersFish(), label .. ": no FISH shortcut offered") + ok(world:useFieldItem("OLD_ROD") == "nowhere", + label .. ": OLD ROD answers nowhere (PACK stays open, not the time)") + ok(not world.fishing and not world:busy(), + label .. ": and no cast started") + if shot then U.shot(game, SHOTS .. "/" .. shot) end + end + + for x = 18, 21 do + mouth(x, 35, "down", "south mouth x=" .. x, + x == 19 and "2352_01_south_mouth_no_fish.png" or nil) + end + for x = 24, 27 do + mouth(x, 0, "up", "north mouth x=" .. x, + x == 25 and "2352_02_north_mouth_no_fish.png" or nil) + end + + local def = world.maps[POND] + local tileset = def and world.tilesets[def.tileset] + local shore + if def and tileset then + local probe = Map.new(def, tileset) + local taken = {} + for _, obj in ipairs(def.objects or {}) do taken[obj.x .. "," .. obj.y] = true end + for cy = 0, probe.heightCells - 1 do + for cx = 0, probe.widthCells - 1 do + for _, facing in ipairs({ "up", "down", "left", "right" }) do + local d = DELTA[facing] + if not shore and not taken[cx .. "," .. cy] + and Permissions.isWalkable(probe:cellCollision(cx, cy)) + and not Permissions.isWater(probe:cellCollision(cx, cy)) + and probe:inBounds(cx + d[1], cy + d[2]) + and Permissions.isWater(probe:cellCollision(cx + d[1], cy + d[2])) then + shore = { x = cx, y = cy, facing = facing } + end + end + end + end + end + if ok(shore ~= nil, "found a real Route 34 shore cell") then + world:warpToMapId(POND, shore.x, shore.y, shore.facing) + settle() + world.player.facing = shore.facing + U.wait(2) + ok(offersFish(), "control: the FISH shortcut is offered at a real pond") + local outcome = world:useFieldItem("OLD_ROD") + ok(outcome ~= "nowhere", "control: OLD ROD casts into real water", outcome) + U.wait(40) + U.shot(game, SHOTS .. "/2352_03_control_real_pond_cast.png") + end + + say(fails == 0 and "ALL PASS" or (fails .. " FAILURES")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/game3_bills_pc_label_bug2404.lua b/tests/drivers/game3_bills_pc_label_bug2404.lua new file mode 100644 index 00000000..ae338c30 --- /dev/null +++ b/tests/drivers/game3_bills_pc_label_bug2404.lua @@ -0,0 +1,128 @@ +-- pokefirered/src/script_menu.c:1027 +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp" + +local FLAG_SYS_POKEMON_GET = 0x828 -- pokefirered/include/constants/flags.h:1374 +local FLAG_SYS_NOT_SOMEONES_PC = 0x834 -- pokefirered/include/constants/flags.h:1386 +local PC_X, PC_Y = 11, 2 + +local failures = 0 +local function result(ok, label) + if ok then + print("PASS " .. label) + else + failures = failures + 1 + print("FAIL " .. label) + end + return ok +end + +local function finish() + if failures == 0 then + print("PASS game3_bills_pc_label_bug2404") + love.event.quit(0) + else + print("FAIL game3_bills_pc_label_bug2404 failures=" .. failures) + love.event.quit(1) + end +end + +local function waitFor(pred, n) + for _ = 1, n do + if pred() then return true end + U.wait(1) + end + return pred() +end + +local function PcMenu() return package.loaded["src.ui.game3.pc_menu"] end +local function Space() return package.loaded["src.core.game3.scripting.space"] end + +local function drawnStorageRow() + local Window = require("src.ui.game3.window") + local real = Window.printPx + local rows = {} + Window.printPx = function(text, ...) + rows[#rows + 1] = text + return real(text, ...) + end + local ok = pcall(PcMenu().draw) + Window.printPx = real + return ok and rows[1] or nil +end + +local function openRoot(game) + U.tap(game, "a") + for _ = 1, 8 do + U.wait(20) + local M = PcMenu() + if M and M.isOpen and M.isOpen() and M.mode == "root" then break end + U.tap(game, "a") + end + U.wait(10) + local M = PcMenu() + return M and M.isOpen and M.isOpen() and M.mode == "root" +end + +local function logOff(game) + local M = PcMenu() + for _ = 1, 5 do + if M.cursor == 5 then break end + U.tap(game, "down") + U.wait(8) + end + U.tap(game, "a") + local Message = package.loaded["src.ui.game3.message"] + return waitFor(function() + local S = Space() + local running = S and S.vm and S.vm.isRunning and S.vm:isRunning() + return not M.isOpen() and not running and not (Message and Message.isOpen and Message.isOpen()) + end, 180) +end + +local function checkLabel(game, want, tag) + local M = PcMenu() + local row = drawnStorageRow() + result(M.storageLabel and M.storageLabel(M._session or game.session) == want and row == want, + string.format("%s: root menu first row is %s (got %s)", tag, want, tostring(row))) +end + +return function(game) + U.wait(30) + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(120) + local Map = require("src.core.game3.map") + local Flags = require("src.core.game3.scripting.flags") + Map.load(nil, game, "FR_VIRIDIAN_CITY_POKEMON_CENTER_1F", { x = PC_X, y = PC_Y, facing = "up" }) + game.session.x, game.session.y, game.session.facing = PC_X, PC_Y, "up" + U.wait(90) + + local store = Space() and Space().store + if not result(store ~= nil, "FireRed script flag store is live") then return finish() end + Flags.setFlag(store, nil, FLAG_SYS_POKEMON_GET, true) + Flags.setFlag(store, nil, FLAG_SYS_NOT_SOMEONES_PC, false) + + if not result(openRoot(game), "before Bill: Center PC opens the root menu") then return finish() end + checkLabel(game, "SOMEONE'S PC", "before Bill (starter flag 0x828 set)") + U.shot(game, DIR .. "/2404_01_someones_pc_before_bill.png") + result(logOff(game), "before Bill: LOG OFF releases the player") + + Flags.setFlag(Space().store, nil, FLAG_SYS_NOT_SOMEONES_PC, true) + U.wait(10) + if not result(openRoot(game), "after Bill: Center PC opens the root menu") then return finish() end + checkLabel(game, "BILL'S PC", "after Bill (0x834 set)") + U.shot(game, DIR .. "/2404_02_bills_pc_after_sea_cottage.png") + result(logOff(game), "after Bill: LOG OFF releases the player") + + local S = Space() + S.persistSession(nil, game) + local live = S.store + S.store = nil + local M = PcMenu() + local label = M.storageLabel and M.storageLabel(game.session) + S.store = live + result(label == "BILL'S PC", + string.format("saved session snapshot still reads BILL'S PC (got %s)", tostring(label))) + + finish() +end diff --git a/tests/drivers/game3_save_badges_bug2405.lua b/tests/drivers/game3_save_badges_bug2405.lua new file mode 100644 index 00000000..c7d856f8 --- /dev/null +++ b/tests/drivers/game3_save_badges_bug2405.lua @@ -0,0 +1,96 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp" + +return function(game) + local fails = 0 + local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then fails = fails + 1 end + end + + for _ = 1, 600 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Flags = require("src.core.game3.scripting.flags") + local Space = require("src.core.game3.scripting.space") + local StartMenu = require("src.ui.game3.start_menu") + local SaveMenu = require("src.ui.game3.save_menu") + local TrainerCard = require("src.ui.game3.trainer_card") + local FrlgFont = require("src.ui.game3.frlg_font") + + local store = Space.getStore() + result(store ~= nil, "2405 live flag store present") + if not store then love.event.quit(1) return end + Flags.setBadge(store, 1, true) + Flags.setBadge(store, 2, true) + + local drawn = {} + local realDraw = FrlgFont.draw + FrlgFont.draw = function(text, x, y, opts) + drawn[#drawn + 1] = { text = text, x = x, y = y } + return realDraw(text, x, y, opts) + end + local function valueAt(y) + for i = #drawn, 1, -1 do + local s = drawn[i] + if s.y == y and s.x > 1 * 8 + 4 then return s.text end + end + end + local function labelY(label) + for i = #drawn, 1, -1 do + local s = drawn[i] + if s.text == label and s.x == 1 * 8 + 4 then return s.y end + end + end + + local function openSave() + U.tap(game, "start") + U.wait(30) + for _ = 1, 12 do + local e = StartMenu.ENTRIES and StartMenu.ENTRIES[StartMenu.cursor] + if e and e.id == "save" then break end + U.tap(game, "down") + U.wait(8) + end + U.tap(game, "a") + U.wait(40) + return SaveMenu.isOpen() + end + + local function closeSave() + for _ = 1, 4 do + if not SaveMenu.isOpen() and not StartMenu.isOpen() then break end + U.tap(game, "b") + U.wait(20) + end + end + + result(openSave(), "2405 START > SAVE opened the save stats window") + drawn = {} + U.wait(2) + result(valueAt(1 * 8 + 32) == "2", "2405 save stats BADGES reads 2 (" .. tostring(valueAt(1 * 8 + 32)) .. ")") + result(labelY("POKéDEX") == nil, "2405 no POKéDEX row before FLAG_SYS_POKEDEX_GET") + result(labelY("TIME") == 1 * 8 + 46, "2405 TIME sits on the POKéDEX row (" .. tostring(labelY("TIME")) .. ")") + U.shot(game, DIR .. "/2405_01_save_stats_two_badges_no_dex.png") + result(TrainerCard.countBadges() == 2 or TrainerCard.countBadges(require("src.core.game3.runtime").getSession()) == 2, + "2405 trainer card agrees on 2 badges") + closeSave() + + Flags.setFlag(store, nil, Flags.IDS.SYS_POKEDEX_GET or 0x829, true) + Flags.setBadge(store, 3, true) + result(openSave(), "2405 START > SAVE reopened with the POKéDEX flag") + drawn = {} + U.wait(2) + result(valueAt(1 * 8 + 32) == "3", "2405 save stats BADGES reads 3 (" .. tostring(valueAt(1 * 8 + 32)) .. ")") + result(labelY("POKéDEX") == 1 * 8 + 46, "2405 POKéDEX row drawn once the dex is obtained") + result(labelY("TIME") == 1 * 8 + 60, "2405 TIME drops below the POKéDEX row (" .. tostring(labelY("TIME")) .. ")") + U.shot(game, DIR .. "/2405_02_save_stats_three_badges_with_dex.png") + closeSave() + + FrlgFont.draw = realDraw + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/game3_ssanne_woman_bug2403.lua b/tests/drivers/game3_ssanne_woman_bug2403.lua new file mode 100644 index 00000000..9c8e6c0b --- /dev/null +++ b/tests/drivers/game3_ssanne_woman_bug2403.lua @@ -0,0 +1,124 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/bug2403" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + if failures == 0 then + print("PASS game3_ssanne_woman_bug2403") + love.event.quit(0) + else + print("FAIL game3_ssanne_woman_bug2403 failures=" .. failures) + love.event.quit(1) + end +end + +return function(game) + for _ = 1, 600 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(180) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Space = require("src.core.game3.scripting.space") + local Party = require("src.core.game3.party") + local Objects = require("src.core.game3.objects") + local Field = require("src.core.game3.field") + local TrainerSight = require("src.core.game3.trainer_sight") + local Message = package.loaded["src.ui.game3.message"] or require("src.ui.game3.message") + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the field") then return finish() end + session.party = {} + Party.giveMon(session, 7, 30) + + local engaged = {} + local origEngage = TrainerSight.engage + TrainerSight.engage = function(g, eo, dist) + engaged[#engaged + 1] = eo and eo.localId + return origEngage(g, eo, dist) + end + + local okC, MapCatalog = pcall(require, "src.import.gba.map_catalog") + local mapId = (okC and MapCatalog.pretToEngine and MapCatalog.pretToEngine("SSAnne_1F_Room2")) + or "FR_SSANNE_1F_ROOM2" + local function place(x, y, facing) + Map.load(nil, game, mapId, { x = x, y = y, facing = facing }) + game.session.x, game.session.y, game.session.facing = x, y, facing + U.wait(30) + end + + local function vmBusy() + return Space.vm and Space.vm.isRunning and Space.vm:isRunning() + end + local function msgOpen() + return Message.isOpen and Message.isOpen() + end + + place(2, 5, "down") + local woman = Objects.find(3) + if not result(woman ~= nil, "SS Anne 1F Room2 Woman (localId 3) loaded") then return finish() end + result(tonumber(woman.trainerType) == 0 and tonumber(woman.sight) == 1, + "Woman carries TRAINER_TYPE_NONE with leftover sight 1 (type=" .. tostring(woman.trainerType) + .. " sight=" .. tostring(woman.sight) .. ")") + + U.tap(game, "a") + local opened = false + for _ = 1, 120 do + if msgOpen() then opened = true break end + U.wait(1) + end + if not result(opened, "talking to the Woman opens her line") then return finish() end + U.wait(20) + result(woman.facing == "up", "Woman turned to face the player (facing=" .. tostring(woman.facing) .. ")") + result(U.shot(game, DIR .. "/2403_01_woman_cruising_text.png"), "screenshot 2403_01_woman_cruising_text") + + for _ = 1, 300 do + if not msgOpen() and not vmBusy() and not Field.locked then break end + if msgOpen() then U.tap(game, "a") end + U.wait(3) + end + result(not msgOpen() and not vmBusy(), "Woman's msgbox closed and the VM went idle") + + local looped, reopened, locked = false, false, false + for _ = 1, 240 do + U.wait(1) + if #engaged > 0 then looped = true end + if msgOpen() then reopened = true end + if Field.locked then locked = true end + end + result(not looped, "Woman never engages as a trainer after her line (engages=" .. #engaged .. ")") + result(not reopened, "her line does not reopen on its own") + result(not locked, "field stays unlocked for 240 frames after the text") + result(woman.facing == "up" and Objects.find(3) == woman, "Woman still faces the player one tile away") + result(U.shot(game, DIR .. "/2403_02_after_text_no_loop.png"), "screenshot 2403_02_after_text_no_loop") + + engaged = {} + place(5, 5, "up") + local lass = Objects.find(1) + if not result(lass ~= nil and TrainerSight.isTrainerType(lass), "Lass Ann (localId 1) is a TRAINER_TYPE_NORMAL") then + return finish() + end + lass.facing = "down" + local spotted = false + for _ = 1, 300 do + if #engaged > 0 then spotted = true break end + U.wait(1) + end + result(spotted and engaged[1] == 1, "control: Lass Ann still spots the player 2 tiles below her") + if spotted then + U.wait(20) + result(U.shot(game, DIR .. "/2403_03_lass_ann_spots_player.png"), "screenshot 2403_03_lass_ann_spots_player") + end + + return finish() +end diff --git a/tests/drivers/gen2_arena_cross_version_bug2382.lua b/tests/drivers/gen2_arena_cross_version_bug2382.lua new file mode 100644 index 00000000..2edfba7d --- /dev/null +++ b/tests/drivers/gen2_arena_cross_version_bug2382.lua @@ -0,0 +1,72 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local ArenaData = require("src.online.ArenaData") + local OnlinePanel = require("src.import.OnlinePanel") + local Protocol2 = require("src.online.Protocol2") + local Trade = require("src.online.Trade") + local Wire = require("src.link.Wire") + local RomImporter = require("src.import.RomImporter") + + local failed = false + local function expect(cond, label, why) + if cond then + print("PASS " .. label) + else + failed = true + print("FAIL " .. label .. (why and (": " .. tostring(why)) or "")) + end + end + + U.wait(30) + + local running = os.getenv("POKEPORT_VERSION") or "crystal" + local order = {} + for _, v in ipairs({ "gold", "silver", "crystal" }) do + if v ~= running and RomImporter.isReady(v) then order[#order + 1] = v end + end + order[#order + 1] = running + + Trade.hostIsLive = function() return false end + ArenaData.forget() + + local profiles = {} + for _, v in ipairs(order) do + local profile, reason = ArenaData.profile(v, "vanilla", nil, { partySize = 3 }) + expect(profile ~= nil, "2382_profile_" .. v, reason) + if profile then + profiles[v] = profile + print(("[driver] %s fingerprint %s engineVersion %s"):format( + v, tostring(profile.fingerprint), tostring(profile.engineVersion))) + end + end + require("src.import.CacheFs").mountVersion(running) + + local crystal, gold, silver = profiles.crystal, profiles.gold, profiles.silver + expect(crystal and gold, "2382_both_imported", + "the identity needs gold and crystal caches") + if crystal and gold then + expect(crystal.fingerprint == gold.fingerprint, "2382_gold_crystal_same_fingerprint", + tostring(gold.fingerprint) .. " vs " .. tostring(crystal.fingerprint)) + local room = { profile = crystal } + expect(OnlinePanel.joinReason(room, gold) == nil, + "2382_gold_can_join_crystal_room", OnlinePanel.joinReason(room, gold)) + expect(OnlinePanel.joinReason({ profile = gold }, crystal) == nil, + "2382_crystal_can_join_gold_room", + OnlinePanel.joinReason({ profile = gold }, crystal)) + end + if silver and crystal then + expect(silver.fingerprint == crystal.fingerprint, "2382_silver_crystal_same_fingerprint", + tostring(silver.fingerprint) .. " vs " .. tostring(crystal.fingerprint)) + end + + local detail = "dataset fingerprint differs: the room has 6f577c0127a1e94d, " + .. "you have c444a8971640c763" + local msg = Wire.sanitize({ type = "join_error", reason = "profile_mismatch", + field = "fingerprint", detail = detail }) + local text = Protocol2.joinErrorText(msg) + print("[driver] join_error text: " .. tostring(text)) + expect(text:find("you have c444a8971640c763", 1, true) ~= nil, + "2382_join_error_detail_whole") + + love.event.quit(failed and 1 or 0) +end diff --git a/tests/drivers/gold_pack_give_dup_bug2378.lua b/tests/drivers/gold_pack_give_dup_bug2378.lua new file mode 100644 index 00000000..bcc9dc20 --- /dev/null +++ b/tests/drivers/gold_pack_give_dup_bug2378.lua @@ -0,0 +1,154 @@ +-- ../pokegold/engine/items/pack.asm:562 +-- ../pokegold/engine/pokemon/mon_menu.asm:270 +-- POKEPORT_VERSION=gold POKEPORT_IDENTITY= POKEPORT_TOUCH=0 \ +-- POKEPORT_SHOT_DIR= \ +-- POKEPORT_DRIVER=tests/drivers/gold_pack_give_dup_bug2378.lua love . +local U = require("tests.drivers.util") +local Typer = require("src.ui.gen2.Typer") + +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +return function(game) + local fails = 0 + local function say(line) print("[2378] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + return cond + end + local function finish() + say(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) + while true do U.wait(60) 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 4) + end + local function top() return game.stack:top() end + local function topId() local s = top() return s and s.screenId or nil end + local function waitFor(id, limit) + for _ = 1, limit or 120 do + if topId() == id then return true end + U.wait(1) + end + return false + end + local function waitTyped(state, limit) + for _ = 1, limit or 600 do + if not Typer.typing(state) then return true end + U.wait(1) + end + return false + end + local function drainHeld(held) + for _ = 1, 8 do + if top() ~= held or not (held.message or held.confirm) then return end + waitTyped(held) + tap("a", 6) + end + end + + U.wait(60) + local world = game.world + if not ok(world and world.map, "the gold world booted") then finish() end + world:warpToMapId("NEW_BARK_TOWN", 13, 6, "down") + U.wait(60) + + local Mon = require("src.battle.gen2.Mon") + local save = game.save + save.party = { Mon.new(game.data, "CYNDAQUIL", 12), + Mon.new(game.data, "TOTODILE", 12) } + save.party[1].item = nil + save.party[2].item = nil + save.inventory.BERRY = nil + local Bag = require("src.inventory.Bag") + ok(Bag.add(save, "BERRY", 1, game.data), "one BERRY is in the bag") + if save.options then save.options.textSpeed = "FAST" end + + local function berries() + local n = save.inventory.BERRY or 0 + for _, mon in ipairs(save.party) do + if mon.item == "BERRY" then n = n + 1 end + end + return n + end + + tap("start", 8) + local menu = top() + if not ok(menu and menu.screenId == "Gen2StartMenu", "START opened the menu") then + finish() + end + for _ = 1, 10 do + if menu.list:current().value == "pack" then break end + tap("down") + end + tap("a") + if not ok(waitFor("Gen2PackMenu", 90), "PACK opened") then finish() end + local pack = top() + for _ = 1, 4 do + if pack:pocket().id == "ITEM" then break end + tap("right") + end + ok(pack:pocket().id == "ITEM", "on the ITEM pocket") + + local function giveBerryTo(slot) + local rowIndex + for i, row in ipairs(pack.rows) do + if row.id == "BERRY" then rowIndex = i end + end + if not ok(rowIndex ~= nil, "the BERRY is listed") then finish() end + pack.index = rowIndex + pack:openSubmenu() + for i, id in ipairs(pack.submenu.rows) do + if id == "give" then pack.submenu.index = i end + end + tap("a") + if not ok(waitFor("Gen2PartyMenu", 60), "GIVE asked To which PKMN?") then + finish() + end + local party = top() + party.index = slot + tap("a", 1) + if not ok(waitFor("Gen2HeldItemMenu", 10), "the give text opened") then + finish() + end + drainHeld(top()) + U.wait(10) + end + + giveBerryTo(2) + ok(save.party[2].item == "BERRY", "slot 2 holds the BERRY") + ok(topId() == "Gen2PackMenu", + "empty-hand give lands back on the PACK (top: " .. tostring(topId()) .. ")") + local listed = false + for _, row in ipairs(pack.rows) do + if row.id == "BERRY" then listed = true end + end + ok(not listed, "and the PACK no longer lists a BERRY") + ok(berries() == 1, "one BERRY in total (" .. berries() .. ")") + U.shot(game, SHOT_DIR .. "/2378_01_pack_back_after_give.png") + + save.party[1].item = "BERRY" + Bag.add(save, "BERRY", 1, game.data) + pack:rebuild() + local before = berries() + giveBerryTo(1) + ok(topId() == "Gen2PackMenu", + "swap give lands back on the PACK (top: " .. tostring(topId()) .. ")") + ok(save.party[1].item == "BERRY", "slot 1 still holds a BERRY") + ok((save.inventory.BERRY or 0) == 1, "the bag got the old BERRY back") + ok(berries() == before, "no BERRY was minted (" .. berries() .. " of " + .. before .. ")") + U.shot(game, SHOT_DIR .. "/2378_02_pack_back_after_swap.png") + + for _ = 1, 10 do + if top() == game.overworld or topId() == nil then break end + tap("b", 8) + end + finish() +end diff --git a/tests/drivers/intro_og_obj_palette_bug2264_test.lua b/tests/drivers/intro_og_obj_palette_bug2264_test.lua index 68250d62..2c8d3eab 100644 --- a/tests/drivers/intro_og_obj_palette_bug2264_test.lua +++ b/tests/drivers/intro_og_obj_palette_bug2264_test.lua @@ -163,15 +163,41 @@ return function(game) botBar.black .. "/" .. botBar.total) end - check(tag .. " reached the fade", waitFor(function() - return movie.fade >= 0.5 or movie.phase == 4 - end, 3000), tostring(movie.fade)) - local shot2 = SHOT_DIR .. "/" .. tag .. "_02_fade_to_white_mid.png" - if movie.phase == 3 and check(tag .. " shot 02", U.shot(game, shot2) and load(shot2)) then - local botBar = census(shot2, 0, 112, 160, 144) - local topBar = census(shot2, 0, 0, 160, 32) - check(tag .. " fade wash never paints OBJ pixels into the bars", - botBar.obj == 0 and topBar.obj == 0, botBar.obj .. "/" .. topBar.obj) + local function near1(path, c, bx1, by1, bx2, by2) + local img = load(path) + if not img then return 0, 1 end + local ox, oy, scale = letterbox(img) + local hit, total = 0, 0 + for y = oy + math.floor(by1 * scale) + 1, oy + math.floor(by2 * scale) - 2 do + for x = ox + math.floor(bx1 * scale) + 1, ox + math.floor(bx2 * scale) - 2 do + local r, g, b = img:getPixel(x, y) + total = total + 1 + if near(math.floor(r * 255 + 0.5), math.floor(g * 255 + 0.5), + math.floor(b * 255 + 0.5), c) then hit = hit + 1 end + end + end + return hit, total + end + + for step, want in ipairs({ { name = "fadepal6_bars_bg_shade2", c = bg[3] }, + { name = "fadepal7_bars_bg_shade1", c = bg[2] } }) do + check(tag .. " reached fade step " .. step, waitFor(function() + return movie.fadeStep == step or movie.phase == 4 + end, 3000), tostring(movie.fadeStep)) + if movie.phase ~= 3 then break end + local upd = movie.update + movie.update = function() end + waitRenderedFrames(2) + local shot = SHOT_DIR .. "/" .. tag .. "_0" .. (step + 1) .. "_" .. want.name .. ".png" + if check(tag .. " shot fade step " .. step, U.shot(game, shot) and load(shot)) then + local hit, total = near1(shot, want.c, 0, 112, 160, 144) + check(tag .. " fade step " .. step .. " bottom bar is BG shade " .. (3 - step), + hit == total, hit .. "/" .. total) + hit, total = near1(shot, want.c, 0, 0, 160, 32) + check(tag .. " fade step " .. step .. " top bar is BG shade " .. (3 - step), + hit == total, hit .. "/" .. total) + end + movie.update = upd end finish() diff --git a/tests/drivers/intro_splash_palette_bug2385_test.lua b/tests/drivers/intro_splash_palette_bug2385_test.lua new file mode 100644 index 00000000..bd8ece62 --- /dev/null +++ b/tests/drivers/intro_splash_palette_bug2385_test.lua @@ -0,0 +1,222 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local P = require("src.render.PaletteFX") + local GameVersion = require("src.core.GameVersion") + local IntroMovie = require("src.ui.IntroMovie") + local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local version = GameVersion.get() + local tag = "2385_" .. tostring(version) + + local STAR_START, FLASH_START, WAVES_START = 64, 104, 134 + + local fails = 0 + local function check(label, ok, detail) + U.log(ok and "PASS" or "FAIL", label, detail or "") + if not ok then fails = fails + 1 end + return ok + end + + local function finish() + P.applyOptions({ colors = "ogred" }) + if fails > 0 then + U.log("FAIL " .. tag .. ": " .. fails .. " check(s), shots in " .. SHOT_DIR) + love.event.quit(1) + else + U.log("PASS " .. tag .. ": all checks, shots in " .. SHOT_DIR) + love.event.quit(0) + end + while true do coroutine.yield() end + end + + local function waitFor(pred, limit) + for _ = 1, limit or 3000 do + if pred() then return true end + U.wait(1) + end + return false + end + + local function near(R, G, B, c, tol) + tol = tol or 12 + return math.abs(R - c[1]) <= tol and math.abs(G - c[2]) <= tol + and math.abs(B - c[3]) <= tol + end + + local function letterbox(img) + local W, H = img:getWidth(), img:getHeight() + local x1, y1, x2, y2 = W, H, -1, -1 + for y = 0, H - 1 do + for x = 0, W - 1 do + local r, g, b = img:getPixel(x, y) + if r > 0.02 or g > 0.02 or b > 0.02 then + if x < x1 then x1 = x end + if x > x2 then x2 = x end + if y < y1 then y1 = y end + if y > y2 then y2 = y end + end + end + end + local bw, bh = x2 - x1 + 1, y2 - y1 + 1 + if x2 >= x1 and math.abs(bw / 160 - bh / 144) < 0.05 then + return x1, y1, bh / 144 + end + local s = math.min(W / 160, H / 144) + return math.floor((W - 160 * s) / 2), math.floor((H - 144 * s) / 2), s + end + + local function load(path) + local f = io.open(path, "rb") + if not f then return false end + local bytes = f:read("*a") + f:close() + local ok, img = pcall(function() + return love.image.newImageData(love.filesystem.newFileData(bytes, "shot.png")) + end) + return ok and img or false + end + + local function census(img, frame, bx1, by1, bx2, by2, colors) + local W, H = img:getWidth(), img:getHeight() + local ox, oy, scale = frame[1], frame[2], frame[3] + local x1 = ox + math.floor(bx1 * scale + 0.5) + 1 + local y1 = oy + math.floor(by1 * scale + 0.5) + 1 + local x2 = ox + math.floor(bx2 * scale + 0.5) - 2 + local y2 = oy + math.floor(by2 * scale + 0.5) - 2 + local c = { black = 0 } + for k in pairs(colors) do c[k] = 0 end + for y = math.max(0, y1), math.min(H - 1, y2) do + for x = math.max(0, x1), math.min(W - 1, x2) do + local r, g, b = img:getPixel(x, y) + local R = math.floor(r * 255 + 0.5) + local G = math.floor(g * 255 + 0.5) + local B = math.floor(b * 255 + 0.5) + if R < 20 and G < 20 and B < 20 then c.black = c.black + 1 end + for k, col in pairs(colors) do + if near(R, G, B, col) then c[k] = c[k] + 1 end + end + end + end + local d = scale * scale + for k, v in pairs(c) do c[k] = math.floor(v / d + 0.5) end + return c + end + + local movie + check(tag .. " intro is on the stack", waitFor(function() + movie = game.stack:top() + return getmetatable(movie) == IntroMovie + end, 600)) + if getmetatable(movie) ~= IntroMovie then finish() end + + game.save.options.colors = "ogred" + P.applyOptions(game.save.options) + check(tag .. " COLORS is ogred", P.mode == "ogred", P.mode) + local blue = GameVersion.isBlue() + local obj = blue and P.GBC_OBJ_BLUE or P.GBC_OBJ + local bg = P.ogBg() + local cols = { + obj1 = obj[2], obj2 = obj[3], bg1 = bg[2], bg2 = bg[3], + gray1 = { 170, 170, 170 }, gray2 = { 85, 85, 85 }, + } + + local function freeze() movie.update = function() end end + local function thaw() movie.update = nil end + + local function shootAt(phase, timer, name) + if not waitFor(function() + return movie.phase > phase or (movie.phase == phase and movie.timer >= timer) + end, 1200) or movie.phase ~= phase then + check(tag .. " reached phase " .. phase .. " t" .. timer, false, + movie.phase .. "/" .. movie.timer) + return nil + end + freeze() + local path = SHOT_DIR .. "/" .. tag .. "_" .. name .. ".png" + local ok = U.shot(game, path) + thaw() + local img = ok and load(path) + if not check(tag .. " shot " .. name, img and true or false) then return nil end + return img, { letterbox(img) } + end + + local LOGO = { 72, 56, 88, 80 } + local TEXT = { 40, 80, 120, 88 } + + local img, fr = shootAt(1, 90, "01_copyright") + if img then + local c = census(img, fr, 16, 56, 144, 96, cols) + U.log(string.format("copyright bg1=%d bg2=%d gray1=%d gray2=%d black=%d", + c.bg1, c.bg2, c.gray1, c.gray2, c.black)) + check(tag .. " copyright shadows wear the boot-ROM BG ramp", c.bg1 + c.bg2 > 20, + c.bg1 + c.bg2) + check(tag .. " copyright card has no raw DMG grays", c.gray1 + c.gray2 == 0, + c.gray1 + c.gray2) + end + + img, fr = shootAt(2, STAR_START + 5, "02_logo_black") + if img then + local l = census(img, fr, LOGO[1], LOGO[2], LOGO[3], LOGO[4], cols) + local t = census(img, fr, TEXT[1], TEXT[2], TEXT[3], TEXT[4], cols) + U.log(string.format("logo black=%d obj=%d bg=%d | text black=%d obj=%d", + l.black, l.obj1 + l.obj2, l.bg1 + l.bg2, t.black, t.obj1 + t.obj2)) + check(tag .. " logo starts black ($F9)", l.black > 60, l.black) + check(tag .. " logo carries no BG ramp", l.bg1 + l.bg2 < 5, l.bg1 + l.bg2) + check(tag .. " logo carries no OBJ ramp before the flash", l.obj1 + l.obj2 < 5, + l.obj1 + l.obj2) + check(tag .. " text starts black", t.black > 60, t.black) + end + + img, fr = shootAt(2, FLASH_START + 5, "03_flash1_text_light") + if img then + local l = census(img, fr, LOGO[1], LOGO[2], LOGO[3], LOGO[4], cols) + local t = census(img, fr, TEXT[1], TEXT[2], TEXT[3], TEXT[4], cols) + check(tag .. " flash 1 ($7E): text goes OBJ0 light", t.obj1 > 60, t.obj1) + check(tag .. " flash 1 ($7E): logo stays black", l.black > 60 and l.obj1 + l.obj2 < 5, + l.black .. "/" .. (l.obj1 + l.obj2)) + end + + img, fr = shootAt(2, FLASH_START + 15, "04_flash2_logo_light") + if img then + local l = census(img, fr, LOGO[1], LOGO[2], LOGO[3], LOGO[4], cols) + local t = census(img, fr, TEXT[1], TEXT[2], TEXT[3], TEXT[4], cols) + check(tag .. " flash 2 ($9F): logo goes OBJ0 light", l.obj1 > 60, l.obj1) + check(tag .. " flash 2 ($9F): text goes OBJ0 dark", t.obj2 > 60, t.obj2) + end + + img, fr = shootAt(2, WAVES_START + 30, "05_settled_logo_dark") + if img then + local l = census(img, fr, LOGO[1], LOGO[2], LOGO[3], LOGO[4], cols) + local t = census(img, fr, TEXT[1], TEXT[2], TEXT[3], TEXT[4], cols) + local s = census(img, fr, 0, 90, 160, 112, cols) + U.log(string.format("settled logo obj2=%d | text black=%d obj=%d | stars bg=%d obj=%d", + l.obj2, t.black, t.obj1 + t.obj2, s.bg1 + s.bg2, s.obj1 + s.obj2)) + check(tag .. " settles on $E7: logo OBJ0 dark", l.obj2 > 60, l.obj2) + check(tag .. " settles on $E7: text black", t.black > 60 and t.obj1 + t.obj2 < 5, + t.black .. "/" .. (t.obj1 + t.obj2)) + check(tag .. " falling stars keep the BG ramp", s.bg1 + s.bg2 > 0 and s.obj1 + s.obj2 == 0, + (s.bg1 + s.bg2) .. "/" .. (s.obj1 + s.obj2)) + end + + if movie.phase == 2 then + freeze() + game.save.options.colors = "gbc" + P.applyOptions(game.save.options) + movie.timer = STAR_START + 5 + local gf = P.pal(game.data, "GAMEFREAK") + local path = SHOT_DIR .. "/" .. tag .. "_06_sgb_logo_black.png" + local ok = U.shot(game, path) + img = ok and load(path) + if check(tag .. " shot 06_sgb_logo_black", img and gf and true or false) then + fr = { letterbox(img) } + local l = census(img, fr, LOGO[1], LOGO[2], LOGO[3], LOGO[4], + { c3 = gf[3], c4 = gf[4] }) + check(tag .. " SGB logo starts on PAL_GAMEFREAK colour 4", l.c4 > 60 and l.c3 < 5, + l.c4 .. "/" .. l.c3) + end + thaw() + else + check(tag .. " still on the splash for the SGB shot", false, movie.phase) + end + + finish() +end diff --git a/tests/drivers/jessie_james_mtmoon_bug2340_test.lua b/tests/drivers/jessie_james_mtmoon_bug2340_test.lua new file mode 100644 index 00000000..12b792c3 --- /dev/null +++ b/tests/drivers/jessie_james_mtmoon_bug2340_test.lua @@ -0,0 +1,228 @@ +-- scripts/MtMoonB2F.asm:225 +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local TextBox = require("src.render.TextBox") + local GameVersion = require("src.core.GameVersion") + local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") + or "/tmp/shots/2340" + pcall(function() io.stdout:setvbuf("no") end) + + local MAP = "MT_MOON_B2F" + local TRIGGER = { x = 3, y = 5 } + local JESSIE, JAMES = "MTMOONB2F_JESSIE", "MTMOONB2F_JAMES" + local BEAT = "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" + local failed = false + + local function check(label, ok) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failed = true end + return ok + end + + local function finish() + print(failed and "FAIL 2340 overall" or "PASS 2340 overall") + love.event.quit(failed and 1 or 0) + while true do coroutine.yield() end + end + + local function npcNamed(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 + + local function autoBox(delay) + local top = game.stack:top() + if getmetatable(top) == TextBox and top.auto and top.auto.delay == delay then + return top + end + return nil + end + + if not check("2340 running Yellow", GameVersion.isYellow()) then finish() end + + local mon = Pokemon.new(game.data, "MEWTWO", 100) + mon.moves = { { id = "TACKLE", pp = 35 } } + game.save.party = { mon } + game.save.flags[BEAT] = nil + game.save.flags.EVENT_GOT_HELIX_FOSSIL = true + + local ow + local approaches = { + { TRIGGER.x, TRIGGER.y - 1, "down" }, + { TRIGGER.x + 1, TRIGGER.y, "left" }, + { TRIGGER.x - 1, TRIGGER.y, "right" }, + { TRIGGER.x, TRIGGER.y + 1, "up" }, + } + local before + for _, a in ipairs(approaches) do + U.teleport(game, MAP, a[1], a[2], a[3]) + ow = game.overworld + if ow.map:isWalkableCell(a[1], a[2]) and not ow:npcAtCell(a[1], a[2]) then + U.hold(game, a[3], 20) + U.wait(2) + if ow.runner:isRunning() then before = a[3] break end + end + end + if not check("2340 the ambush fired on (3,5)", ow.runner:isRunning()) then + finish() + end + U.log("pre-trigger facing", before) + + local box + for _ = 1, 300 do + box = autoBox(91) + if box then break end + U.wait(1) + end + if not check("2340 motto box is armed no-wait (auto delay 91)", box ~= nil) then + finish() + end + for _ = 1, 300 do + if box.done then break end + U.wait(1) + end + local typedAt = U.frame() + U.shot(game, DIR .. "/2340_01_motto_typed_no_arrow.png") + check("2340 motto box shows no blinking arrow", not box:arrowVisible()) + + local sawBubble, bubbleFacingOk, bubbleShot = false, true, false + local sawTurnUnderBox, turnShot = false, false + local poppedAt + for _ = 1, 400 do + if game.stack:top() ~= box then poppedAt = U.frame() break end + if ow.emote and ow.emote.npc == ow.player then + sawBubble = true + if ow.player.facing ~= before then bubbleFacingOk = false end + if not bubbleShot then + bubbleShot = true + U.shot(game, DIR .. "/2340_02_bubble_over_motto_box.png") + end + elseif sawBubble and ow.player.facing == "up" then + sawTurnUnderBox = true + if not turnShot then + turnShot = true + U.shot(game, DIR .. "/2340_03_bubble_gone_facing_up_box_up.png") + end + end + U.wait(1) + end + check("2340 motto box closed with no button press", poppedAt ~= nil) + local held = poppedAt and (poppedAt - typedAt) or -1 + U.log("motto box held", held, "frames after typing") + check("2340 motto box held ~91 frames after typing (got " .. held .. ")", + held >= 85 and held <= 100) + check("2340 exclamation bubble shown over the player while the box is up", + sawBubble) + check("2340 player keeps facing " .. tostring(before) .. " under the bubble", + bubbleFacingOk) + check("2340 player turns up after the bubble, box still up", sawTurnUnderBox) + + local jessie, james = npcNamed(ow, JESSIE), npcNamed(ow, JAMES) + if not check("2340 both of the duo are on the map", jessie and james) then + finish() + end + + local jStart, jEnd, mStart, mEnd + local midShot = false + local facedDownOk, jessieArrived = true, false + local challenge + for _ = 1, 900 do + local top = game.stack:top() + if getmetatable(top) == TextBox then + challenge = top + if mStart and not mEnd and not james.moving + and james.cellX == TRIGGER.x + 1 then + mEnd = U.frame() + end + break + end + if jessie.moving and not jStart then jStart = U.frame() end + if jStart and not jEnd and not jessie.moving and jessie.cellX == TRIGGER.x then + jEnd = U.frame() + jessieArrived = true + end + if james.moving and not mStart then mStart = U.frame() end + if mStart and not mEnd and not james.moving and james.cellX == TRIGGER.x + 1 then + mEnd = U.frame() + end + if jessieArrived and jessie.facing ~= "down" then facedDownOk = false end + if jStart and not jEnd and not midShot and jessie.cellX <= 6 then + midShot = true + U.shot(game, DIR .. "/2340_04_jessie_mid_walk.png") + end + U.wait(1) + end + local jFrames = (jStart and jEnd) and (jEnd - jStart) or -1 + local mFrames = (mStart and mEnd) and (mEnd - mStart) or -1 + U.log("jessie walk", jFrames, "james walk", mFrames) + check("2340 Jessie's six steps take ~96 frames (got " .. jFrames .. ")", + jFrames >= 90 and jFrames <= 104) + check("2340 James's five steps take ~80 frames (got " .. mFrames .. ")", + mFrames >= 74 and mFrames <= 88) + check("2340 Jessie faces down from arrival to the challenge line", facedDownOk) + check("2340 the challenge box opened", challenge ~= nil) + if challenge then + check("2340 Jessie still faces down under the challenge box", + jessie.facing == "down") + for _ = 1, 300 do + if challenge.done then break end + U.wait(1) + end + U.shot(game, DIR .. "/2340_06_challenge_box.png") + end + + local battle + for _ = 1, 3000 do + local top = game.stack:top() + if getmetatable(top) == BattleState then battle = top end + if battle and autoBox(64) then break end + U.tap(game, "a") + U.wait(2) + end + local parting = autoBox(64) + check("2340 the parting box is armed no-wait (auto delay 64)", parting ~= nil) + if parting then + check("2340 Jessie faces down for the parting line", jessie.facing == "down") + check("2340 James faces down for the parting line", james.facing == "down") + local closed, partingShot, partingTyped, sawCont = false, false, nil, false + for _ = 1, 1500 do + if game.stack:top() ~= parting then closed = true break end + if parting.waiting and not parting.done and not sawCont + and (parting.preWait or 0) <= 0 and not parting:sfxHeld() then + sawCont = true + U.shot(game, DIR .. "/2340_07_parting_box_cont_wait.png") + U.tap(game, "a") + end + if parting.done and not partingTyped then partingTyped = U.frame() end + if parting.done and not partingShot then + partingShot = true + U.shot(game, DIR .. "/2340_08_parting_last_page_no_arrow.png") + end + U.wait(1) + end + local partHeld = (closed and partingTyped) and (U.frame() - partingTyped) or -1 + U.log("parting box held", partHeld, "frames after the last page typed") + check("2340 the parting box waits for A at its cont", sawCont) + check("2340 the parting box closes with no press after the last page", closed) + check("2340 parting box held ~64 frames after typing (got " .. partHeld .. ")", + partHeld >= 58 and partHeld <= 72) + end + + local settled = false + for _ = 1, 1200 do + if game.stack:top() == ow and not ow.runner:isRunning() + and #ow.scriptMoves == 0 and game.save.flags[BEAT] then + settled = true + break + end + U.wait(1) + end + check("2340 the scene ran to its end", settled) + check("2340 the duo are gone", + npcNamed(ow, JESSIE) == nil and npcNamed(ow, JAMES) == nil) + finish() +end diff --git a/tests/drivers/jigglypuff_bug249_test.lua b/tests/drivers/jigglypuff_bug249_test.lua index b30190b4..6c4476a7 100644 --- a/tests/drivers/jigglypuff_bug249_test.lua +++ b/tests/drivers/jigglypuff_bug249_test.lua @@ -19,8 +19,10 @@ return function(game) local RING = { "down", "left", "up", "right" } local SILENCE, STEP, TAIL = 32, 24, 48 -- the three DelayFrames counts + local failures = 0 local function check(label, ok) U.log(ok and "PASS" or "FAIL", label) + if not ok then failures = failures + 1 end return ok end @@ -190,7 +192,7 @@ return function(game) gap >= SILENCE - 4 and gap <= SILENCE + 8) end - check("the JIGGLYPUFF turned at least four times", #turns >= 4) + check("the JIGGLYPUFF turned at least 14 quarter turns (about four spins)", #turns >= 14) local ringOk, spacingOk = true, true for n, t in ipairs(turns) do if t.to ~= nextInRing(t.from) then @@ -209,14 +211,14 @@ return function(game) end end check("every turn is one clockwise quarter turn (DOWN->LEFT->UP->RIGHT)", - ringOk and #turns >= 4) + ringOk and #turns >= 14) check(("the turns are %d frames apart"):format(STEP), spacingOk and #turns >= 2) -- not just "it eventually closed": an A-dismissable box closes too, only far -- too early, and that is the bug check("mashing A never closed the box early", - (closedAt or #log) >= SILENCE + 4 * STEP) + (closedAt or #log) >= SILENCE + 14 * STEP) if check("the box closed itself with no button press", closedAt ~= nil) then check("it stayed up for the whole song, A mashing and all", songEnd ~= nil and closedAt > songEnd) @@ -229,8 +231,7 @@ return function(game) end end - -- ---- 2. do it again, screenshot each quarter turn, hand off mid-song --- - U.log("--- run 2: screenshots, then the pad is yours -------------------") + U.log("--- run 2: screenshots ----------------------------------------") U.wait(30) puff = game.overworld and puffIn(game.overworld) check("still standing against the JIGGLYPUFF", facingThePuff()) @@ -253,11 +254,8 @@ return function(game) end check("captured four quarter turns", shots >= 4) - U.log("The JIGGLYPUFF has been talked to and is mid-song. Mash A: the box has no") - U.log("arrow and must not close. The fairy should turn a quarter turn clockwise") - U.log("(down, left, up, right) every 24 frames until the song ends, then close") - U.log("itself (#249). The Center theme returns just before it does; expected.") - + U.log(failures == 0 and "ALL PASS" or ("DONE " .. failures .. " check(s) failed")) + love.event.quit(failures == 0 and 0 or 1) while true do coroutine.yield() end diff --git a/tests/drivers/launch_update_mods_bug2037.lua b/tests/drivers/launch_update_mods_bug2037.lua new file mode 100644 index 00000000..59f6124a --- /dev/null +++ b/tests/drivers/launch_update_mods_bug2037.lua @@ -0,0 +1,187 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local LaunchOptions = require("src.core.LaunchOptions") + local RomImporter = require("src.import.RomImporter") + local ModUpdate = require("src.mods.ModUpdate") + local LauncherMods = require("src.mods.LauncherMods") + local Platform = require("src.core.Platform") + + local dir = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") + or "/tmp/updatemods2037" + os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') + love.window.setMode(1024, 768, { resizable = true, highdpi = true }) + U.wait(2) + + local failed = false + local function expect(cond, label, why) + if cond then + print("PASS " .. label) + else + failed = true + print("FAIL " .. label .. (why and (": " .. tostring(why)) or "")) + end + end + + local t = LaunchOptions.tasks({ "--game=red", "--update-mods" }, nil, false) + expect(t.mods == true and t.update == false, "flag_parsed", + tostring(t.mods) .. "/" .. tostring(t.update)) + expect(LaunchOptions.tasks({ "--updatemods" }, nil, false).mods == true, + "flag_alias_parsed") + + local realBeginZip = ModUpdate.beginDownloadZip + local realPumpZip = ModUpdate.pumpDownloadZip + local realInstall = LauncherMods.installDownloadedZip + local realRemote = Platform.canFetchRemote + local realDeps = LauncherMods.checkDependencies + + local failId = nil + local installs = {} + local onDisk = {} + ModUpdate.beginDownloadZip = function() return { frames = 0 } end + ModUpdate.pumpDownloadZip = function(h) + h.frames = h.frames + 1 + if h.frames < 90 then return false, nil, nil, h.frames / 90 end + love.filesystem.write("update_mods_2037.zip", "BYTES") + return true, "update_mods_2037.zip" + end + LauncherMods.installDownloadedZip = function(id, _, version) + installs[#installs + 1] = id + if id == failId then return nil, "the archive had no manifest" end + onDisk[id] = version + return true, version + end + LauncherMods.checkDependencies = function() return { hasIssues = false } end + Platform.canFetchRemote = function() return true end + + local RELEASE = { version = "2.0.0", + zip = { url = "https://example.invalid/fake.zip" } } + local function newLauncher() + local imp = RomImporter.new(function() end, { launcher = true }) + imp.safeMode = false + onDisk = {} + imp._refreshMods = function(self) + for _, m in ipairs(self.mods or {}) do + if onDisk[m.id] then m.version = onDisk[m.id] end + end + self._modUpdateRev = (self._modUpdateRev or 0) + 1 + end + imp._refreshFindSources = function(self) self.findSources = {} end + imp._refreshFind = function() end + imp._pumpFindFetch = function() end + imp._findFetch = nil + imp.findLoaded = true + imp.findIndex = { mods = {}, carts = {} } + imp.mods = { + { id = "fake_mod", name = "Fake Mod", version = "1.0.0", + github = "example/fake-mod", enabled = true, status = "ok" }, + } + imp._syncModUpdateInfo = function(self) + self.modUpdateInfo = { fake_mod = { status = "available", + latest = "2.0.0", best = RELEASE, releases = { RELEASE } } } + self._modUpdateRev = (self._modUpdateRev or 0) + 1 + end + return imp + end + + local imp + local pending = nil + love.draw = function() + if imp then imp:draw() end + if pending then + local path = pending + pending = nil + love.graphics.captureScreenshot(function(imagedata) + local f = io.open(path, "wb") + if f then f:write(imagedata:encode("png"):getString()) f:close() end + end) + end + end + local confirmSeen = false + local function step(n) + for _ = 1, n do + if imp then + imp:update(1 / 60) + if imp._modConfirm then confirmSeen = true end + end + coroutine.yield() + end + end + local function shot(name) + pending = dir .. "/" .. name + for _ = 1, 30 do + if not pending then break end + step(1) + end + step(2) + local f = io.open(dir .. "/" .. name, "rb") + U.log(f and "shot" or "FAIL shot", name) + if f then f:close() end + end + + imp = newLauncher() + local results = {} + local started = imp:autoUpdateAll(function(r) + results[#results + 1] = r + end, { tab = "mods" }) + expect(started, "auto_sweep_started") + expect(imp.tab == "mods", "launcher_on_mods_tab", imp.tab) + step(30) + expect(imp._modInstall ~= nil and imp._busy ~= nil, "overlay_updating_row", + imp._busy and imp._busy.title) + expect(not confirmSeen, "no_confirm_modal") + shot("2037_01_updating_overlay_no_confirm.png") + for _ = 1, 200 do + if #results > 0 then break end + step(1) + end + expect(#results == 1 and results[1].ok == true, "on_done_ok_boot_handoff", + results[1] and tostring(results[1].ok)) + expect(installs[1] == "fake_mod", "mod_installed", installs[1]) + expect(not confirmSeen, "no_confirm_whole_run") + expect(imp.modNotice and imp.modNotice.text == "Updated 1 items.", + "notice_updated", imp.modNotice and imp.modNotice.text) + local row = imp.mods[1] + local rowInfo = imp:_modUpdateInfo("fake_mod") + expect(row.version == "2.0.0" and rowInfo and rowInfo.status == "current", + "row_shows_updated_version", + tostring(row.version) .. "/" .. tostring(rowInfo and rowInfo.status)) + step(20) + shot("2037_02_update_done_notice.png") + + imp = newLauncher() + failId = "fake_mod" + installs, results, confirmSeen = {}, {}, false + imp:autoUpdateAll(function(r) results[#results + 1] = r end, { tab = "mods" }) + for _ = 1, 300 do + if #results > 0 then break end + step(1) + end + failId = nil + expect(#results == 1 and results[1].ok == false, "failure_reported_not_ok") + expect(imp.modNotice and imp.modNotice.ok == false + and #(imp.modNotice.failures or {}) == 1, "failure_stays_on_launcher", + imp.modNotice and imp.modNotice.text) + step(20) + shot("2037_03_failed_update_stays_on_launcher.png") + + imp = newLauncher() + confirmSeen = false + imp:_switchTab("mods") + expect(imp:pressUpdateAllMods(), "button_press_starts") + step(10) + expect(confirmSeen and (imp._updateAll or {}).stage == "confirm", + "button_still_confirms") + step(10) + shot("2037_04_button_still_confirms.png") + imp._modConfirm = nil + step(3) + + ModUpdate.beginDownloadZip = realBeginZip + ModUpdate.pumpDownloadZip = realPumpZip + LauncherMods.installDownloadedZip = realInstall + LauncherMods.checkDependencies = realDeps + Platform.canFetchRemote = realRemote + imp = nil + U.log("done") + love.event.quit(failed and 1 or 0) +end diff --git a/tests/drivers/launcher_cart_delete_bug2367.lua b/tests/drivers/launcher_cart_delete_bug2367.lua new file mode 100644 index 00000000..49e02e3b --- /dev/null +++ b/tests/drivers/launcher_cart_delete_bug2367.lua @@ -0,0 +1,174 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local RomImporter = require("src.import.RomImporter") + local CartManifest = require("src.carts.CartManifest") + local CartStore = require("src.carts.CartStore") + local ModIndex = require("src.mods.ModIndex") + local Json = require("src.link.Json") + local Kit = require("src.ui.kit.Kit") + + local dir = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") + or "/tmp/cartdel2367" + os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') + love.window.setMode(1024, 768, { resizable = true, highdpi = true }) + U.wait(2) + + local failed = false + local function pass(label) print("PASS " .. label) end + local function fail(label, why) + failed = true + print("FAIL " .. label .. (why and (": " .. tostring(why)) or "")) + end + local function expect(cond, label, why) + if cond then pass(label) else fail(label, why) end + end + + local SHA = ("a1b2c3d4"):rep(8) + local function pin(id, repo) + return { id = id, source = "github", repo = repo, version = "1.0.0", + sha256 = SHA } + end + local CART = { + id = "wild_green", title = "Wild Green", version = "1.75.0", + author = "wild1walker", shell = "#3a8f3a", base = "red", seal = "sealed", + mods = { pin("wild_green", "wild1walker/Gen1MakeItGreen"), + pin("wg_music", "wild1walker/wg-music"), + pin("wg_items", "wild1walker/wg-items"), + pin("wg_maps", "wild1walker/wg-maps") }, + } + local function installCart() + local cart = CartManifest.parse(CART) + return cart and CartStore.install(CartManifest.encode(cart)) + end + + local FEED = ModIndex.parse(Json.encode({ + schema_version = 1, categories = { "GAMEPLAY" }, base_games = { "red" }, + mods = {}, + carts = { { + id = "wild_green", title = "Wild Green", author = "wild1walker", + version = "1.75.0", base = "red", seal = "sealed", + repo = "https://github.com/wild1walker/Gen1WildGreen", + mods = CART.mods, update_check = "ok", + latest = { version = "1.75.0", tag = "v1.75.0", + zip = { name = "wild_green-1.75.0.g1rcart", + url = "https://example.invalid/wild_green.g1rcart" } }, + } }, + })) + local ENTRY = FEED and FEED.carts and FEED.carts[1] + + pcall(CartStore.uninstall, "wild_green") + expect(installCart(), "cart_installed") + + local imp = RomImporter.new(function() end, { launcher = true }) + imp._refreshFindSources = function() end + imp._refreshFind = function() end + imp._pumpFindFetch = function() end + imp._findFetch = nil + imp.findSources = { { feed = "https://example.invalid/data/index.json", + base = "https://example.invalid/", + label = "example/index" } } + imp.findIndex = { schemaVersion = 1, categories = {}, mods = FEED.mods, + carts = FEED.carts, baseGames = ModIndex.baseGamesIn(FEED) } + imp.findLoaded = true + imp:_switchTab("find") + imp:_setFindKind("carts") + imp.ready.red = nil + U.wait(3) + + local buttons = {} + local realButton = Kit.button + Kit.button = function(x, y, w, h, label, opts) + if opts and opts.id then + buttons[opts.id] = { label = tostring(label), opts = opts } + end + return realButton(x, y, w, h, label, opts) + end + + local pending = nil + love.draw = function() + buttons = {} + imp:draw() + if pending then + local path = pending + pending = nil + love.graphics.captureScreenshot(function(imagedata) + local f = io.open(path, "wb") + if f then f:write(imagedata:encode("png"):getString()) f:close() end + end) + end + end + local function step(n) + for _ = 1, n do + imp:update(1 / 60) + coroutine.yield() + end + end + local function shot(name) + pending = dir .. "/" .. name + for _ = 1, 90 do + if not pending then break end + step(1) + end + step(3) + local f = io.open(dir .. "/" .. name, "rb") + U.log(f and "shot" or "FAIL shot", name) + if f then f:close() end + end + local function press(id) + local b = buttons[id] + if not b then return false end + imp:runActions({ { key = id, fn = b.opts.action, keepArm = b.opts.keepArm } }) + return true + end + + imp._findEntry = ENTRY + step(4) + local del = buttons["findpop-del"] + expect(del ~= nil and del.label == "Delete", "find_popup_has_delete", + del and del.label) + expect(buttons["findpop-inst"] and buttons["findpop-inst"].label == "Reinstall", + "find_popup_keeps_reinstall") + shot("2367_01_find_popup_delete.png") + + expect(press("findpop-del"), "find_delete_first_press") + step(4) + expect(buttons["findpop-del"] and buttons["findpop-del"].label == "Sure?", + "find_delete_armed") + expect(CartStore.get("wild_green") ~= nil, "find_first_press_keeps_cart") + shot("2367_02_find_delete_armed.png") + + expect(press("findpop-del"), "find_delete_second_press") + step(4) + expect(CartStore.get("wild_green") == nil, "find_delete_removed_cart") + expect(imp._findEntry == nil and imp._cartPopup == nil, "find_popup_closed") + expect(imp.findNotice and imp.findNotice.ok == true, "find_notice_ok") + step(40) + shot("2367_03_find_cart_deleted.png") + + imp._findEntry = ENTRY + step(4) + expect(buttons["findpop-inst"] and buttons["findpop-inst"].label == "Install" + and buttons["findpop-del"] == nil, "find_popup_back_to_install") + imp._findEntry = nil + + expect(installCart(), "cart_reinstalled") + imp:_refreshCarts("red") + imp.ready.red = true + imp:_switchTab("red") + imp._cartPopup = "red" + step(4) + local cp = buttons["cartpop-id-wild_green-delete"] + expect(cp ~= nil, "cart_popup_has_delete") + step(40) + shot("2367_04_cart_popup_delete.png") + press("cartpop-id-wild_green-delete") + step(4) + press("cartpop-id-wild_green-delete") + step(4) + expect(CartStore.get("wild_green") == nil, "cart_popup_delete_removed_cart") + shot("2367_05_cart_popup_after.png") + + Kit.button = realButton + U.log("done") + love.event.quit(failed and 1 or 0) +end diff --git a/tests/drivers/launcher_mod_load_order_bug2177.lua b/tests/drivers/launcher_mod_load_order_bug2177.lua new file mode 100644 index 00000000..45e1ee8c --- /dev/null +++ b/tests/drivers/launcher_mod_load_order_bug2177.lua @@ -0,0 +1,203 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local RomImporter = require("src.import.RomImporter") + local SaveData = require("src.core.SaveData") + local Loader = require("src.mods.Loader") + local Kit = require("src.ui.kit.Kit") + + local dir = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") + or "/tmp/modorder2177" + os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') + local winW = tonumber(os.getenv("DRIVER_W")) or 1024 + local winH = tonumber(os.getenv("DRIVER_H")) or 768 + local tag = winW < 600 and "phone" or "desktop" + love.window.setMode(winW, winH, { resizable = true, highdpi = true }) + U.wait(2) + + local failed = false + local function pass(label) print("PASS " .. label) end + local function fail(label, why) + failed = true + print("FAIL " .. label .. (why and (": " .. tostring(why)) or "")) + end + local function expect(cond, label, why) + if cond then pass(label) else fail(label, why) end + end + + local fs = love.filesystem + local IDS = { "driver_order_a", "driver_order_b" } + local NAMES = { driver_order_a = "AAAA", driver_order_b = "BBBB" } + local function plant() + for _, id in ipairs(IDS) do + fs.createDirectory("mods/" .. id) + fs.write("mods/" .. id .. "/manifest.json", ([[{"id":"%s","name":"Order %s", + "version":"1.0.0","entry":"main.lua","priority":-100, + "games":["red","blue","yellow"]}]]):format(id, NAMES[id])) + fs.write("mods/" .. id .. "/main.lua", ([[ +return function(mod) + mod.content.pokemon:override("PIKACHU", { name = "%s" }) +end +]]):format(NAMES[id])) + end + end + local function unplant() + for _, id in ipairs(IDS) do + fs.remove("mods/" .. id .. "/manifest.json") + fs.remove("mods/" .. id .. "/main.lua") + fs.remove("mods/" .. id) + end + end + local function setOrder(list) + local opts = SaveData.loadOptions() + SaveData.setModOrder(opts, list) + opts.modSort = nil + SaveData.saveOptions(opts) + end + + unplant() + plant() + setOrder({}) + + local imp = RomImporter.new(function() end, { launcher = true }) + imp:_switchTab("mods") + imp.modSort = "order" + imp:_refreshMods() + U.wait(3) + + local buttons = {} + local realButton = Kit.button + Kit.button = function(x, y, w, h, label, opts) + if opts and opts.id then + buttons[opts.id] = { label = tostring(label), opts = opts, y = y } + end + return realButton(x, y, w, h, label, opts) + end + local pending = nil + love.draw = function() + buttons = {} + imp:draw() + if pending then + local path = pending + pending = nil + love.graphics.captureScreenshot(function(imagedata) + local f = io.open(path, "wb") + if f then f:write(imagedata:encode("png"):getString()) f:close() end + end) + end + end + local function step(n) + for _ = 1, n do + imp:update(1 / 60) + coroutine.yield() + end + end + local function shot(name) + name = name:gsub("%.png$", "_" .. tag .. ".png") + pending = dir .. "/" .. name + for _ = 1, 90 do + if not pending then break end + step(1) + end + step(3) + local f = io.open(dir .. "/" .. name, "rb") + U.log(f and "shot" or "FAIL shot", name) + if f then f:close() end + end + local function press(id) + local b = buttons[id] + if not b or b.opts.enabled == false then return false end + imp:runActions({ { key = id, fn = b.opts.action, keepArm = b.opts.keepArm } }) + return true + end + local function visiblePos(id) + for i, m in ipairs(imp.mods or {}) do + local n = 0 + for _, o in ipairs(imp.mods) do + if (o.loadRank or 0) < (m.loadRank or 0) then n = n + 1 end + end + if m.id == id then return n + 1 end + end + end + local function savedBefore(a, b) + local list = SaveData.modOrder(SaveData.loadOptions()) + local ia, ib + for i, v in ipairs(list) do + if v == a then ia = i end + if v == b then ib = i end + end + return ia and ib and ia < ib + end + local function realBoot() + local proxy = setmetatable({ + getDirectoryItems = function(path) + if path == "mods" then return { IDS[1], IDS[2] } end + return fs.getDirectoryItems(path) + end, + }, { __index = fs }) + local base = game and game.data and game.data.pokemon + and game.data.pokemon.PIKACHU + local copy = {} + for k, v in pairs(base or { name = "PIKACHU" }) do copy[k] = v end + local data = { pokemon = { PIKACHU = copy } } + local loader = Loader.new({ fs = proxy }) + local ok, err = pcall(loader.load, loader, data) + if not ok then return nil, nil, err end + return table.concat(loader.order or {}, ","), data.pokemon.PIKACHU.name + end + + step(4) + expect(visiblePos(IDS[1]) == 1 and visiblePos(IDS[2]) == 2, + "initial_order_by_priority_then_id", + tostring(visiblePos(IDS[1])) .. "," .. tostring(visiblePos(IDS[2]))) + expect(buttons["mod-up-driver_order_a"] and buttons["mod-down-driver_order_a"], + "row_has_up_down") + expect(buttons["mod-up-driver_order_a"] + and buttons["mod-up-driver_order_a"].opts.enabled == false, + "first_row_up_disabled") + local order0, name0 = realBoot() + expect(order0 == "driver_order_a,driver_order_b" and name0 == "BBBB", + "loader_default_b_last_wins", tostring(order0) .. " " .. tostring(name0)) + shot("2177_01_mods_sorted_by_load_order.png") + + expect(press("mod-down-driver_order_a"), "press_down_on_a") + step(4) + expect(savedBefore(IDS[2], IDS[1]), "saved_order_b_before_a") + expect(visiblePos(IDS[1]) == 2, "list_shows_a_second") + expect(imp.modNotice and imp.modNotice.ok + and tostring(imp.modNotice.text):find("position 2", 1, true), + "notice_moved_to_position", imp.modNotice and imp.modNotice.text) + local order1, name1 = realBoot() + expect(order1 == "driver_order_b,driver_order_a" and name1 == "AAAA", + "loader_follows_player_order_a_wins", tostring(order1) .. " " .. tostring(name1)) + shot("2177_02_a_moved_down.png") + + imp._modActions = IDS[1] + step(4) + expect(buttons["modact-up"] and buttons["modact-down"] + and buttons["modact-top"] and buttons["modact-bottom"], + "details_has_move_buttons") + expect(buttons["modact-top"] and buttons["modact-top"].opts.enabled ~= false, + "details_move_to_top_enabled") + shot("2177_03_details_move_buttons.png") + expect(press("modact-top"), "press_move_to_top") + step(4) + imp._modActions = nil + expect(savedBefore(IDS[1], IDS[2]), "saved_order_a_first_again") + local order2, name2 = realBoot() + expect(order2 == "driver_order_a,driver_order_b" and name2 == "BBBB", + "loader_swapped_back_b_wins", tostring(order2) .. " " .. tostring(name2)) + + imp._sortPopup = "mods" + step(4) + expect(buttons["sortpop-order"] ~= nil and buttons["sortpop-reset-order"] ~= nil, + "sort_modal_offers_load_order_and_reset") + expect(press("sortpop-reset-order"), "press_reset_order") + step(4) + expect(#SaveData.modOrder(SaveData.loadOptions()) == 0, "reset_clears_saved_order") + + Kit.button = realButton + unplant() + setOrder({}) + U.log("done") + love.event.quit(failed and 1 or 0) +end diff --git a/tests/drivers/mart_price_text_bug2291.lua b/tests/drivers/mart_price_text_bug2291.lua new file mode 100644 index 00000000..bd4e299b --- /dev/null +++ b/tests/drivers/mart_price_text_bug2291.lua @@ -0,0 +1,149 @@ +-- engine/events/pokemart.asm:159-169 +-- engine/events/pokemart.asm:84-91 +-- home/list_menu.asm:89-91 +-- POKEPORT_DRIVER=tests/drivers/mart_price_text_bug2291.lua POKEPORT_IDENTITY=bsa0922-2291 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local Bag = require("src.inventory.Bag") + local Font = require("src.render.Font") + local Menu = require("src.ui.Menu") + local ListMenu = require("src.ui.ListMenu") + local QuantityBox = require("src.ui.QuantityBox") + local ChoiceBox = require("src.ui.ChoiceBox") + local TextBox = require("src.render.TextBox") + + 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 + local function top() return game.stack:top() end + local function topIs(cls) return getmetatable(top()) == cls end + local function onStack(cls) + for _, s in ipairs(game.stack.states or {}) do + if getmetatable(s) == cls then return s end + end + return nil + end + local function waitFor(pred, frames) + for _ = 1, frames or 300 do + if pred() then return true end + U.wait(1) + end + return pred() + end + local function finish() + U.log(failures == 0 and "PASS mart_price_text_bug2291" + or ("FAIL mart_price_text_bug2291 (" .. failures .. " failures)")) + love.event.quit(failures == 0 and 0 or 1) + U.wait(600) + end + + local ok, err = pcall(function() + game.save.money = 3000 + game.save.inventory = game.save.inventory or {} + Bag.add(game.save, "POKE_BALL", 5, game.data) + U.teleport(game, "PEWTER_MART", 2, 5, "left") + U.wait(10) + + U.tap(game, "a") + for _ = 1, 60 do + if topIs(Menu) then break end + U.tap(game, "a") + U.wait(4) + end + if not check("the mart menu opens", topIs(Menu)) then return end + local menu = top() + + U.tap(game, "a") + if not check("BUY opens the list", waitFor(function() return topIs(ListMenu) end, 60)) then return end + local list = top() + local itemId = list.items[list.index].value + local def = game.data.items[itemId] + local name = def.name + U.wait(4) + U.tap(game, "a") + if not check("the quantity box opens", waitFor(function() return topIs(QuantityBox) end, 30)) then return end + check("the chosen row shows the hollow cursor", list.hollowIndex == list.index) + U.wait(4) + U.tap(game, "a") + check("the price line opens as a text box, not YES/NO", + waitFor(function() return topIs(TextBox) end, 2) and not topIs(ChoiceBox)) + local box = top() + check("the price text box carries the YES/NO", box.choice ~= nil) + local lines = box.pages and box.pages[1] or {} + check("line 1 names the item: " .. tostring(lines[1]), lines[1] == name .. "?") + U.wait(8) + local typed = box.shown and box.shown[1] and #box.shown[1] or 0 + check(("the item name is typing (%d of %d glyphs)"):format(typed, #Font.encode(name .. "?")), + not box.done and typed > 0 and typed < #Font.encode(name .. "?")) + U.shot(game, DIR .. "/2291_01_name_typing.png") + + check("the price line reaches the arrow wait", + waitFor(function() return box.waiting and (box.preWait or 0) == 0 end, 300)) + local vis = box:visibleText() or {} + check("arrow wait shows the item name", vis[1] == name .. "?") + check("arrow wait shows That will be", vis[2] == "That will be") + check("no YES/NO at the arrow wait", top() == box and not onStack(ChoiceBox)) + check("the quantity box stays up under the text", onStack(QuantityBox) ~= nil) + check("the list row stays hollow under the text", list.hollowIndex == list.index) + U.shot(game, DIR .. "/2291_02_name_and_cont_arrow.png") + + U.tap(game, "a") + check("YES/NO opens after the price finishes typing", + waitFor(function() return topIs(ChoiceBox) end, 300)) + check("the text box was done before YES/NO", box.done == true) + local last = box:visibleText() or {} + check("price text above YES/NO: " .. tostring(last[2]), + last[1] == "That will be" and tostring(last[2]):find("OK?", 1, true) ~= nil) + check("the quantity box is still drawn under YES/NO", onStack(QuantityBox) ~= nil) + U.wait(4) + U.shot(game, DIR .. "/2291_03_price_yesno_over_qty.png") + + U.tap(game, "b") + check("NO returns to the buy list", waitFor(function() return top() == list end, 30)) + check("NO closes the quantity box", onStack(QuantityBox) == nil) + check("the list cursor is filled again", waitFor(function() return list.hollowIndex == nil end, 5)) + check("NO buys nothing", game.save.money == 3000) + + U.tap(game, "b") + if not check("B returns to BUY/SELL/QUIT", waitFor(function() return top() == menu end, 30)) then return end + U.wait(4) + U.tap(game, "down") + U.wait(4) + U.tap(game, "a") + if not check("SELL opens the sell list", waitFor(function() return topIs(ListMenu) end, 60)) then return end + local sellList = top() + for _ = 1, 20 do + local row = sellList.items[sellList.index] + if row and row.value == "POKE_BALL" then break end + U.tap(game, "down") + U.wait(4) + end + U.wait(4) + U.tap(game, "a") + if not check("the sell quantity box opens", waitFor(function() return topIs(QuantityBox) end, 30)) then return end + check("the sold row shows the hollow cursor", sellList.hollowIndex == sellList.index) + U.wait(4) + U.tap(game, "a") + check("the sell price opens as a text box, not YES/NO", + waitFor(function() return topIs(TextBox) end, 2) and not topIs(ChoiceBox)) + local sellBox = top() + U.wait(6) + check("the sell price is typing", sellBox.done == false) + check("sell YES/NO opens after the text", waitFor(function() return topIs(ChoiceBox) end, 300)) + check("the sell quantity box is still drawn under YES/NO", onStack(QuantityBox) ~= nil) + U.wait(4) + U.shot(game, DIR .. "/2291_04_sell_yesno_over_qty.png") + U.tap(game, "b") + check("sell NO returns to the sell list", waitFor(function() return top() == sellList end, 30)) + check("sell NO keeps the items", (game.save.inventory.POKE_BALL or 0) == 5) + end) + if not ok then + failures = failures + 1 + U.log("FAIL driver error:", tostring(err)) + end + finish() +end diff --git a/tests/drivers/mod_import_picker_bug2292.lua b/tests/drivers/mod_import_picker_bug2292.lua new file mode 100644 index 00000000..7d7cb02f --- /dev/null +++ b/tests/drivers/mod_import_picker_bug2292.lua @@ -0,0 +1,221 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local RomImporter = require("src.import.RomImporter") + local HostPicker = require("src.core.HostPicker") + local Platform = require("src.core.Platform") + + local dir = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") + or "/tmp/modpicker2292" + os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') + love.window.setMode(1024, 768, { resizable = true, highdpi = true }) + U.wait(2) + + local failed = false + local function expect(cond, label, why) + if cond then + print("PASS " .. label) + else + failed = true + print("FAIL " .. label .. (why and (": " .. tostring(why)) or "")) + end + end + + local MOD_ID = "picker_probe_2292" + local zipPath = dir .. "/" .. MOD_ID .. ".zip" + local stage = dir .. "/" .. MOD_ID + os.remove(zipPath) + os.execute('rm -rf "' .. stage .. '" && mkdir -p "' .. stage .. '"') + local mf = io.open(stage .. "/manifest.json", "wb") + if mf then + mf:write('{"id":"' .. MOD_ID .. '","name":"Picker Probe","version":"1.0.0",' + .. '"entry":"main.lua","category":"GAMEPLAY","games":["gen1"],' + .. '"description":"Installed through the async mod picker."}') + mf:close() + end + local ml = io.open(stage .. "/main.lua", "wb") + if ml then ml:write("return {}\n") ml:close() end + os.execute('cd "' .. stage .. '" && zip -qr "' .. zipPath .. '" . 2>/dev/null') + os.execute('rm -rf "' .. stage .. '"') + local zf = io.open(zipPath, "rb") + expect(zf ~= nil, "fixture_zip_built", zipPath) + if zf then zf:close() end + + local realGetOS = love.system.getOS + local realStart = HostPicker.start + local realPickFile = love.system.pickFile + local starts, captured = 0, {} + local fakeCommand = nil + HostPicker.start = function(commands) + starts = starts + 1 + captured = commands + return realStart({ fakeCommand }) + end + + local function newLauncher() + local imp = RomImporter.new(function() end, { launcher = true }) + imp.safeMode = false + imp._refreshFindSources = function(self) self.findSources = {} end + imp._refreshFind = function() end + imp._pumpFindFetch = function() end + imp._findFetch = nil + imp.findLoaded = true + imp.findIndex = { mods = {}, carts = {} } + imp:_switchTab("mods") + return imp + end + + local imp + local frames = 0 + local pending = nil + love.draw = function() + if imp then imp:draw() end + if pending then + local path = pending + pending = nil + love.graphics.captureScreenshot(function(imagedata) + local f = io.open(path, "wb") + if f then f:write(imagedata:encode("png"):getString()) f:close() end + end) + end + end + local function step(n) + for _ = 1, n do + if imp then imp:update(1 / 60) end + frames = frames + 1 + coroutine.yield() + end + end + local function shot(name) + pending = dir .. "/" .. name + for _ = 1, 30 do + if not pending then break end + step(1) + end + step(2) + local f = io.open(dir .. "/" .. name, "rb") + U.log(f and "shot" or "FAIL shot", name) + if f then f:close() end + end + local function clickImportAsWindows() + love.system.getOS = function() return "Windows" end + Platform._resetForTests() + local t0 = love.timer.getTime() + imp:chooseMod() + local spent = love.timer.getTime() - t0 + love.system.getOS = realGetOS + Platform._resetForTests() + return spent + end + + imp = newLauncher() + step(20) + + fakeCommand = "sleep 3; printf '%s' '" .. zipPath .. "'" + local spent = clickImportAsWindows() + expect(spent < 0.5, "click_returns_immediately", ("%.3fs"):format(spent)) + expect(imp._hostPick ~= nil, "picker_pending_same_frame") + expect(starts == 1, "one_worker_started", starts) + local cmd = captured and captured[1] or "" + expect(cmd:find("$o.TopMost=$true;", 1, true) ~= nil + and cmd:find("ShowDialog($o)", 1, true) ~= nil, "windows_dialog_owned_topmost") + local before = frames + step(30) + clickImportAsWindows() + expect(starts == 1, "second_click_ignored_while_open", starts) + shot("2292_01_waiting_for_picker.png") + expect(imp._hostPick ~= nil and frames - before >= 30, + "launcher_keeps_running_while_dialog_open", frames - before) + expect(imp.modNotice and tostring(imp.modNotice.text):find("file dialog", 1, true) ~= nil + and tostring(imp.modNotice.text):find("taskbar", 1, true) == nil, + "waiting_notice_shown", imp.modNotice and imp.modNotice.text) + local clicked = 0 + imp:runActions({ { key = "play-red", fn = function() clicked = clicked + 1 end } }) + expect(clicked == 0, "launcher_clicks_blocked_while_open", clicked) + imp:play("red", true) + expect(imp._launchFade == nil, "play_blocked_while_open") + for _ = 1, 600 do + if not imp._hostPick then break end + step(1) + end + expect(imp._hostPick == nil, "pick_answer_consumed") + expect(frames - before >= 150, "frames_advanced_through_dialog", frames - before) + expect(imp.modNotice and imp.modNotice.ok == true + and tostring(imp.modNotice.text):find("Installed", 1, true) ~= nil, + "picked_mod_installed", imp.modNotice and imp.modNotice.text) + local listed = false + for _, row in ipairs(imp.mods or {}) do + if row.id == MOD_ID then listed = true end + end + expect(listed, "mod_in_list") + step(20) + shot("2292_02_mod_installed_from_picker.png") + + fakeCommand = "sleep 1" + clickImportAsWindows() + expect(imp._hostPick ~= nil, "cancel_run_pending") + for _ = 1, 300 do + if not imp._hostPick then break end + step(1) + end + expect(imp._hostPick == nil and imp.modNotice == nil, "cancel_clears_picker") + step(10) + shot("2292_03_cancel_back_to_mods.png") + + pcall(function() require("src.mods.LauncherMods").uninstall(MOD_ID) end) + + love.filesystem.write("stray_2292.zip", "PK\3\4 not a mod") + imp = newLauncher() + imp.android = true + local pickCalls = 0 + love.system.pickFile = function() pickCalls = pickCalls + 1 return true end + love.system.getOS = function() return "Android" end + imp:chooseMod() + love.system.getOS = realGetOS + expect(pickCalls == 1, "android_stray_zip_not_installed_picker_opened", pickCalls) + expect(love.filesystem.getInfo("stray_2292.zip") ~= nil, "android_stray_zip_left_alone") + step(200) + expect(imp.modNotice == nil, "android_no_notice_before_4s") + step(60) + expect(imp.modNotice and imp.modNotice.ok == false + and tostring(imp.modNotice.text):find("did not open", 1, true) ~= nil + and tostring(imp.modNotice.text):find("imports/mods", 1, true) ~= nil, + "android_picker_did_not_open_notice", imp.modNotice and imp.modNotice.text) + step(10) + shot("2292_04_android_picker_did_not_open.png") + + love.filesystem.createDirectory("imports/mods") + local inboxZip = "imports/mods/" .. MOD_ID .. ".zip" + local zr = io.open(zipPath, "rb") + local bytes = zr and zr:read("*a") or "" + if zr then zr:close() end + love.filesystem.write(inboxZip, bytes) + pickCalls = 0 + love.system.getOS = function() return "Android" end + imp:chooseMod() + love.system.getOS = realGetOS + expect(pickCalls == 0 and imp._modInboxInstall ~= nil, "android_inbox_install_queued", pickCalls) + expect(imp.modNotice and tostring(imp.modNotice.text):find("Installing", 1, true) ~= nil, + "android_inbox_notice_before_install", imp.modNotice and imp.modNotice.text) + pending = dir .. "/2292_05_android_installing_from_inbox.png" + step(1) + local f5 = io.open(dir .. "/2292_05_android_installing_from_inbox.png", "rb") + U.log(f5 and "shot" or "FAIL shot", "2292_05_android_installing_from_inbox.png") + if f5 then f5:close() end + step(3) + local inboxListed = false + for _, row in ipairs(imp.mods or {}) do + if row.id == MOD_ID then inboxListed = true end + end + expect(imp._modInboxInstall == nil and inboxListed, "android_inbox_installed_next_frame", + imp.modNotice and imp.modNotice.text) + love.filesystem.remove(inboxZip) + pcall(function() require("src.mods.LauncherMods").uninstall(MOD_ID) end) + + love.filesystem.remove("stray_2292.zip") + love.system.pickFile = realPickFile + HostPicker.start = realStart + imp = nil + os.remove(zipPath) + U.log("done") + love.event.quit(failed and 1 or 0) +end diff --git a/tests/drivers/pewter_jigglypuff_sleep_bug2335.lua b/tests/drivers/pewter_jigglypuff_sleep_bug2335.lua new file mode 100644 index 00000000..a5622d20 --- /dev/null +++ b/tests/drivers/pewter_jigglypuff_sleep_bug2335.lua @@ -0,0 +1,205 @@ +-- scripts/PewterPokecenter_2.asm:10-68 +-- engine/pikachu/pikachu_emotions.asm:318 +-- engine/pikachu/pikachu_movement.asm:973 +return function(game) + local U = dofile("tests/drivers/util.lua") + local GameVersion = require("src.core.GameVersion") + local Pokemon = require("src.pokemon.Pokemon") + local TextBox = require("src.render.TextBox") + local PikachuFollower = require("src.world.PikachuFollower") + + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local MAP = "PEWTER_POKECENTER" + local PUFF = "PEWTERPOKECENTER_JIGGLYPUFF" + local failures = 0 + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + if not ok then failures = failures + 1 end + return ok + end + + local function finish() + U.log(failures == 0 and "ALL PASS" or ("DONE " .. failures .. " check(s) failed")) + love.event.quit(failures == 0 and 0 or 1) + while true do coroutine.yield() end + end + + if not check("2335_yellow_cache", GameVersion.isYellow()) then finish() end + + local starter = Pokemon.new(game.data, "PIKACHU", 12) + require("src.battle.BattleState").stampOT(game.save, starter) + game.save.party = { starter } + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true + game.save.pikachuInBall = false + + U.teleport(game, MAP, 2, 3, "left") + U.wait(20) + local ow = game.overworld + + local function find(pred) + for _, n in ipairs(ow.npcs or {}) do + if pred(n) then return n end + end + return nil + end + local puff = find(function(n) return n.def and n.def.name == PUFF end) + local pika = find(function(n) return n.pikachuFollower end) + check("2335_puff_loaded", puff ~= nil) + if not check("2335_follower_spawned", pika ~= nil) then finish() end + U.log("player", ow.player.cellX, ow.player.cellY, "pikachu", pika.cellX, pika.cellY) + + local function facingNpc(n) + local fx, fy = ow.player:facingCell() + return ow:npcAtCell(fx, fy) == n + end + if not check("2335_facing_puff", facingNpc(puff)) then finish() end + + local function boxOnTop() + local top = game.stack:top() + if getmetatable(top) == TextBox then return top end + return nil + end + + U.tap(game, "a") + U.wait(2) + local box = boxOnTop() + if not check("2335_dance_box_open", box ~= nil and box.auto ~= nil) then finish() end + + local turns, frames, last = 0, 0, puff.facing + local shotStart, shotMid = false, false + while boxOnTop() == box and frames < 1500 do + U.wait(1) + frames = frames + 1 + if puff.facing ~= last then + turns = turns + 1 + last = puff.facing + if turns == 1 and not shotStart then + shotStart = true + U.shot(game, DIR .. "/2335_01_song_first_turn.png") + elseif turns == 8 and not shotMid then + shotMid = true + U.shot(game, DIR .. "/2335_02_mid_dance_still_boxed.png") + end + end + end + U.log("dance: turns", turns, "frames after open", frames) + check("2335_box_closed_itself", boxOnTop() ~= box) + check("2335_four_spins", turns >= 14) + check("2335_sleep_flag_set", ow.pikachuPewterSleepScene == true) + check("2335_following_disabled", PikachuFollower.isFollowingDisabled(ow)) + U.wait(4) + U.shot(game, DIR .. "/2335_03_box_closed_pikachu_asleep.png") + + local function step(dir) + local p = ow.player + local x, y = p.cellX, p.cellY + for _ = 1, 30 do + U.hold(game, dir, 1) + if p.moving then break end + end + for _ = 1, 40 do + if not p.moving then break end + U.wait(1) + end + U.wait(4) + return p.cellX ~= x or p.cellY ~= y + end + + local px, py = pika.cellX, pika.cellY + local route = { "down", "down", "right", "right", "right" } + local walked = 0 + for _, d in ipairs(route) do + if step(d) then walked = walked + 1 end + end + U.log("walked", walked, "player", ow.player.cellX, ow.player.cellY, + "pikachu", pika.cellX, pika.cellY) + check("2335_player_walked_away", walked >= 4) + check("2335_pikachu_stayed_put", pika.cellX == px and pika.cellY == py) + U.shot(game, DIR .. "/2335_04_walked_away_pikachu_stays.png") + + local DIRS = { { "up", 0, -1 }, { "down", 0, 1 }, { "left", -1, 0 }, { "right", 1, 0 } } + local function free(x, y) + return ow.map:inBounds(x, y) and ow.map:isWalkableCell(x, y) and not ow:npcAtCell(x, y) + end + local function pathTo(goal) + local sx, sy = ow.player.cellX, ow.player.cellY + local key = function(x, y) return x .. "," .. y end + local prev, queue, head = { [key(sx, sy)] = false }, { { sx, sy } }, 1 + while queue[head] do + local c = queue[head] + head = head + 1 + if goal(c[1], c[2]) then + local path, k = {}, key(c[1], c[2]) + while prev[k] do + table.insert(path, 1, prev[k].dir) + k = prev[k].from + end + return path + end + for _, d in ipairs(DIRS) do + local nx, ny = c[1] + d[2], c[2] + d[3] + local nk = key(nx, ny) + if prev[nk] == nil and free(nx, ny) then + prev[nk] = { dir = d[1], from = key(c[1], c[2]) } + queue[#queue + 1] = { nx, ny } + end + end + end + return nil + end + local faceDir + local path = pathTo(function(x, y) + for _, d in ipairs(DIRS) do + if x + d[2] == px and y + d[3] == py then faceDir = d[1] return true end + end + return false + end) + if path then + for _, d in ipairs(path) do step(d) end + if ow.player.facing ~= faceDir then U.tap(game, faceDir) U.wait(8) end + end + if not check("2335_facing_sleeping_pikachu", facingNpc(pika)) then + U.log("player", ow.player.cellX, ow.player.cellY, ow.player.facing, + "pikachu", pika.cellX, pika.cellY) + finish() + end + check("2335_still_asleep_before_talk", ow.pikachuPewterSleepScene == true) + + U.tap(game, "a") + local sawBubble, sawPic, picPath = false, false, nil + for _ = 1, 600 do + local e = ow.emote + if e and e.bubble and not sawBubble then + sawBubble = true + U.shot(game, DIR .. "/2335_05_zzz_bubble.png") + elseif e and e.pikaPic and not sawPic then + sawPic, picPath = true, e.pikaPic + U.wait(20) + U.shot(game, DIR .. "/2335_06_pikapic_wake_face.png") + end + if sawPic and not ow.emote then break end + U.wait(1) + end + U.log("pikapic:", tostring(picPath)) + check("2335_zzz_bubble_shown", sawBubble) + check("2335_pikapic_26_shown", picPath ~= nil and picPath:find("pikapic_26", 1, true) ~= nil) + U.wait(10) + check("2335_sleep_flag_cleared", ow.pikachuPewterSleepScene == nil) + check("2335_following_reenabled", not PikachuFollower.isFollowingDisabled(ow)) + + local qx, qy = pika.cellX, pika.cellY + local moved = 0 + for _, d in ipairs({ "down", "down" }) do + if step(d) then moved = moved + 1 end + end + U.wait(20) + U.log("after wake: player", ow.player.cellX, ow.player.cellY, "pikachu", pika.cellX, pika.cellY) + check("2335_pikachu_follows_again", + moved > 0 and (pika.cellX ~= qx or pika.cellY ~= qy)) + U.shot(game, DIR .. "/2335_07_pikachu_follows_again.png") + + finish() +end diff --git a/tests/drivers/pikachu_mood_import_bug2344_test.lua b/tests/drivers/pikachu_mood_import_bug2344_test.lua new file mode 100644 index 00000000..176ceb24 --- /dev/null +++ b/tests/drivers/pikachu_mood_import_bug2344_test.lua @@ -0,0 +1,153 @@ +-- pokeyellow engine/menus/save.asm:260 +-- pokeyellow engine/pikachu/pikachu_pic_animation.asm:1 +-- pokeyellow engine/events/poison.asm:137 +-- POKEPORT_DRIVER=tests/drivers/pikachu_mood_import_bug2344_test.lua POKEPORT_IDENTITY=bug2344 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local GameVersion = require("src.core.GameVersion") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local SaveConvert = require("src.save_convert.SaveConvert") + local GenSave = require("src.save_convert.GenSave") + + local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" } + local failures = 0 + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + if not ok then failures = failures + 1 end + return ok + end + + local function quit() + U.log(failures == 0 and "ALL PASS" or ("DONE " .. failures .. " check(s) failed")) + love.event.quit(failures == 0 and 0 or 1) + while true do coroutine.yield() end + end + + if not check("2344_yellow_cache", GameVersion.isYellow()) then quit() end + + game.save.player.name = "bryan" + local pika = Pokemon.new(game.data, "PIKACHU", 12) + BattleState.stampOT(game.save, pika) + game.save.party = { pika } + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true + game.save.pikachuInBall = false + game.save.pikachuHappiness = 120 + game.save.pikachuMood = 0x6c + game.save.pikachuEmotionModifier = nil + + local bytes, err = SaveConvert.exportSav(game.save, "yellow") + if not check("2344_export_ok", bytes ~= nil) then + U.log("export error:", tostring(err)) + quit() + end + check("2344_export_mood_byte", bytes:byte(GenSave.OFFSETS.pikachuMood + 1) == 0x6c) + local imported, ierr = SaveConvert.importSav(bytes, nil, "yellow") + if not check("2344_import_ok", imported ~= nil) then + U.log("import error:", tostring(ierr)) + quit() + end + check("2344_import_mood", imported.pikachuMood == 0x6c) + check("2344_import_happiness", imported.pikachuHappiness == 120) + U.teleport(game, "ROUTE_1", 5, 20, "up") + U.wait(20) + local ow = game.overworld + + local function follower() + for _, n in ipairs(ow.npcs or {}) do + if n.pikachuFollower then return n end + end + return nil + end + local npc = follower() + if not check("2344_follower_spawned", npc ~= nil) then quit() end + + local DIRS = { { "up", 0, -1 }, { "left", -1, 0 }, + { "right", 1, 0 }, { "down", 0, 1 } } + local function faceFollower() + for _, d in ipairs(DIRS) do + if npc.cellX == ow.player.cellX + d[2] + and npc.cellY == ow.player.cellY + d[3] then + if ow.player.facing ~= d[1] then + U.tap(game, d[1]) + U.wait(6) + end + break + end + end + local fx, fy = ow.player:facingCell() + return ow:npcAtCell(fx, fy) == npc + end + + local stepDir + for _, d in ipairs(DIRS) do + local cx, cy = ow.player.cellX + d[2], ow.player.cellY + d[3] + if ow.map:inBounds(cx, cy) and ow.map:isWalkableCell(cx, cy) + and not ow:npcAtCell(cx, cy) then + stepDir = d[1] + break + end + end + if not check("2344_step_room", stepDir ~= nil) then quit() end + local function stepAndBack() + U.hold(game, stepDir, 24) + U.wait(6) + U.tap(game, OPPOSITE[stepDir]) + U.wait(6) + end + + local function talk(wantPic, shotName) + if not faceFollower() then + check("2344_facing_follower_" .. wantPic, false) + return + end + for _ = 1, 5 do + U.tap(game, "a") + for _ = 1, 60 do + if ow.emote then break end + U.wait(1) + end + if ow.emote then break end + end + for _ = 1, 300 do + if ow.emote and ow.emote.pikaPic then break end + U.wait(1) + end + local pic = ow.emote and ow.emote.pikaPic or "" + U.log("pikapic:", pic) + check("2344_pic_" .. wantPic, pic:find(wantPic, 1, true) ~= nil) + U.wait(20) + U.shot(game, SHOT_DIR .. "/" .. shotName) + for _ = 1, 600 do + if not ow.emote then break end + U.wait(1) + end + U.wait(10) + end + + stepAndBack() + game.save.pikachuHappiness = imported.pikachuHappiness + game.save.pikachuMood = imported.pikachuMood + game.save.pikachuEmotionModifier = imported.pikachuEmotionModifier + U.log("imported happiness", tostring(imported.pikachuHappiness), + "mood", tostring(imported.pikachuMood)) + talk("pikapic_3.png", "2344_01_mood_low_frown.png") + check("2344_mood_untouched_by_talk", game.save.pikachuMood == 0x6c) + + game.save.pikachuMood = 128 + talk("pikapic_1.png", "2344_02_mood_neutral_smile.png") + + game.save.pikachuMood = 0x81 + game.save.pikachuEmotionModifier = 2 + talk("pikapic_21.png", "2344_03_fishing_modifier.png") + check("2344_modifier_survives_talk", game.save.pikachuEmotionModifier == 2) + stepAndBack() + check("2344_modifier_clears_at_128", + game.save.pikachuMood == 128 and game.save.pikachuEmotionModifier == nil) + + quit() +end diff --git a/tests/drivers/pikachu_thunder_flash_2347.lua b/tests/drivers/pikachu_thunder_flash_2347.lua new file mode 100644 index 00000000..8196e585 --- /dev/null +++ b/tests/drivers/pikachu_thunder_flash_2347.lua @@ -0,0 +1,299 @@ +-- pokeyellow engine/pokemon/evos_moves.asm:366 +-- pokeyellow data/pikachu/pikachu_pic_animation.asm:281 +-- pokeyellow engine/pikachu/pikachu_pic_animation.asm:790 +return function(game) + local U = dofile("tests/drivers/util.lua") + local GameVersion = require("src.core.GameVersion") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local PikachuFollower = require("src.world.PikachuFollower") + local PaletteFX = require("src.render.PaletteFX") + local Sound = require("src.core.Sound") + local Music = require("src.core.Music") + + local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" } + local failures = 0 + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + if not ok then failures = failures + 1 end + return ok + end + + local startMode = PaletteFX.mode + local function quit() + if PaletteFX.mode ~= startMode then PaletteFX.setMode(startMode) end + U.log(failures == 0 and "ALL PASS" or ("DONE " .. failures .. " check(s) failed")) + love.event.quit(failures == 0 and 0 or 1) + while true do coroutine.yield() end + end + + if not check("2347_yellow_cache", GameVersion.isYellow()) then quit() end + if PaletteFX.usesGbcPack() then PaletteFX.setMode("gbc") end + + local moveSounds, ducks = {}, 0 + local realPlayMove, realDuck = Sound.playMove, Music.duckForFanfare + Sound.playMove = function(data, anim) + moveSounds[#moveSounds + 1] = anim and anim.sound or "?" + return realPlayMove(data, anim) + end + Music.duckForFanfare = function(src) + ducks = ducks + 1 + return realDuck(src) + end + + game.save.player.name = "bryan" + local pika = Pokemon.new(game.data, "PIKACHU", 30) + BattleState.stampOT(game.save, pika) + pika.moves = { + { id = "THUNDERSHOCK", pp = 30 }, { id = "GROWL", pp = 40 }, + { id = "THUNDER_WAVE", pp = 20 }, { id = "QUICK_ATTACK", pp = 30 }, + } + game.save.party = { pika } + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true + game.save.pikachuInBall = false + game.save.pikachuHappiness = 120 + game.save.pikachuMood = 128 + game.save.pikachuEmotionModifier = nil + game.save.flashLit = nil + game.save.repelSteps = 250 + + U.teleport(game, "ROCK_TUNNEL_1F", 19, 6, "right") + U.wait(20) + local ow = game.overworld + check("2347_rock_tunnel_dark", ow.dark == true) + + local function follower() + for _, n in ipairs(ow.npcs or {}) do + if n.pikachuFollower then return n end + end + return nil + end + local npc = follower() + if not check("2347_follower_spawned", npc ~= nil) then quit() end + + local DIRS = { { "up", 0, -1 }, { "left", -1, 0 }, + { "right", 1, 0 }, { "down", 0, 1 } } + local function freeDir() + for _, d in ipairs({ { "left", -1, 0 }, { "right", 1, 0 }, + { "up", 0, -1 }, { "down", 0, 1 } }) do + local cx, cy = ow.player.cellX + d[2], ow.player.cellY + d[3] + if ow.map:inBounds(cx, cy) and ow.map:isWalkableCell(cx, cy) + and not ow:npcAtCell(cx, cy) then + return d[1] + end + end + return nil + end + local function walk(dir) + local sx, sy = ow.player.cellX, ow.player.cellY + for _ = 1, 60 do + U.hold(game, dir, 1) + if ow.player.cellX ~= sx or ow.player.cellY ~= sy then break end + end + for _ = 1, 40 do + if not ow.player.moving then break end + U.wait(1) + end + U.wait(4) + return ow.player.cellX ~= sx or ow.player.cellY ~= sy + end + if not check("2347_step_room", freeDir() ~= nil) then quit() end + local function stepAndBack() + local dir = freeDir() + if not dir then return false end + local out = walk(dir) + local back = walk(OPPOSITE[dir]) + return out and back + end + local function faceFollower() + for _, d in ipairs(DIRS) do + if npc.cellX == ow.player.cellX + d[2] + and npc.cellY == ow.player.cellY + d[3] then + if ow.player.facing ~= d[1] then + U.tap(game, d[1]) + U.wait(6) + end + break + end + end + local fx, fy = ow.player:facingCell() + return ow:npcAtCell(fx, fy) == npc + end + + stepAndBack() + U.shot(game, SHOT_DIR .. "/2347_01_dark_before.png") + + pika.moves[4] = { id = "THUNDERBOLT", pp = 15 } + PikachuFollower.onMoveLearned(game.save, pika, "THUNDERBOLT") + check("2347_learn_arms_modifier", game.save.pikachuEmotionModifier == 5 + and game.save.pikachuMood == 0x85) + + local function startTalk(tag) + if not check(tag .. "_facing_follower", faceFollower()) then return false end + for _ = 1, 5 do + U.tap(game, "a") + for _ = 1, 60 do + if ow.emote then break end + U.wait(1) + end + if ow.emote then break end + end + return ow.emote ~= nil + end + + local function isBoltMap(m) + return m and m[0] == 0 and m[1] == 0 and m[2] == 0 and m[3] == 3 + end + local function isDarkMap(m) + return m and m[0] == 2 and m[1] == 3 and m[2] == 3 and m[3] == 3 + end + + moveSounds, ducks = {}, 0 + if not check("2347_talk1_emote", startTalk("2347_talk1")) then quit() end + check("2347_talk1_bolt_bubble", ow.emote.bubble and not ow.emote.pikaPic and true or false) + for _ = 1, 200 do + if ow.emote and ow.emote.pikaPic then break end + U.wait(1) + end + local pic = ow.emote and ow.emote.pikaPic or "" + check("2347_talk1_pikapic_25", pic:find("pikapic_25", 1, true) ~= nil) + local emote = ow.emote + check("2347_talk1_bolt_spec", emote and emote.boltAt == 45) + local bgpSeq, shadeSeq, frames = {}, {}, 0 + local prebolt = 0 + while ow.emote == emote and frames < 900 do + U.wait(1) + frames = frames + 1 + if ow.emote ~= emote then break end + bgpSeq[#bgpSeq + 1] = emote.bgp or false + shadeSeq[#bgpSeq] = PaletteFX.shadeMap() or false + if not emote.bgp then prebolt = prebolt + 1 end + end + local strobeOk, seen = true, 0 + local first + for i, b in ipairs(bgpSeq) do + if b then first = first or i end + end + if first then + for row = 1, 20 do + local want = (row % 2 == 1) and 0xC0 or 0xE4 + for f = 1, 4 do + local i = first + (row - 1) * 4 + f - 1 + if bgpSeq[i] ~= want then strobeOk = false end + seen = seen + 1 + end + end + end + U.log("prebolt frames", prebolt, "strobe start", tostring(first), "total", frames) + check("2347_talk1_strobe_alternates_4f", first ~= nil and strobeOk and seen == 80) + local whiteDrawn, darkDuringPre = false, false + for i = 1, #bgpSeq do + if isBoltMap(shadeSeq[i]) then whiteDrawn = true end + if first and i < first and isDarkMap(shadeSeq[i]) then darkDuringPre = true end + end + check("2347_talk1_prebolt_dark", darkDuringPre) + check("2347_talk1_strobe_white_drawn", whiteDrawn) + local litTail = first and #bgpSeq > first + 82 + if litTail then + for i = first + 82, #bgpSeq do + if shadeSeq[i] then litTail = false end + end + end + check("2347_talk1_lit_tail_while_box_up", litTail and true or false) + check("2347_talk1_thunderbolt_sound", moveSounds[1] == "Battle_2F") + check("2347_talk1_music_muted", ducks == 1) + U.wait(6) + check("2347_talk1_dark_after_close", isDarkMap(PaletteFX.shadeMap())) + check("2347_modifier_survives_talk", game.save.pikachuEmotionModifier == 5) + + if not check("2347_talk2_emote", startTalk("2347_talk2")) then quit() end + U.shot(game, SHOT_DIR .. "/2347_02_bolt_bubble.png") + for _ = 1, 200 do + if ow.emote and ow.emote.pikaPic then break end + U.wait(1) + end + emote = ow.emote + check("2347_talk2_replays_bolt", emote and emote.boltAt ~= nil) + U.wait(20) + U.shot(game, SHOT_DIR .. "/2347_03_pikapic_prebolt.png") + for _ = 1, 200 do + if emote.bgp == 0xC0 then break end + U.wait(1) + end + U.shot(game, SHOT_DIR .. "/2347_04_strobe_white.png") + for _ = 1, 200 do + if emote.bgp == 0xE4 then break end + U.wait(1) + end + U.shot(game, SHOT_DIR .. "/2347_05_strobe_lit.png") + for _ = 1, 400 do + if emote.boltDone or ow.emote ~= emote then break end + U.wait(1) + end + if ow.emote == emote then U.shot(game, SHOT_DIR .. "/2347_06_lit_tail.png") end + for _ = 1, 200 do + if ow.emote ~= emote then break end + U.wait(1) + end + U.wait(10) + U.shot(game, SHOT_DIR .. "/2347_07_after_close_dark.png") + + for _ = 1, 3 do + stepAndBack() + U.log("step", ow.player.cellX, ow.player.cellY, "mood", + tostring(game.save.pikachuMood), "modifier", tostring(game.save.pikachuEmotionModifier)) + end + check("2347_modifier_clears_after_steps", game.save.pikachuEmotionModifier == nil + and game.save.pikachuMood == 128) + moveSounds = {} + if not check("2347_talk3_emote", startTalk("2347_talk3")) then quit() end + for _ = 1, 200 do + if ow.emote and ow.emote.pikaPic then break end + U.wait(1) + end + U.wait(20) + U.shot(game, SHOT_DIR .. "/2347_08_after_steps_mood_talk.png") + check("2347_talk3_no_bolt", ow.emote and ow.emote.pikaPic and not ow.emote.boltAt + and true or false) + for _ = 1, 600 do + if not ow.emote then break end + U.wait(1) + end + check("2347_talk3_no_sound", #moveSounds == 0) + + PaletteFX.setMode("redpp") + U.wait(10) + ow = game.overworld + npc = follower() or npc + stepAndBack() + if ow:bakedWorldColors() then + PikachuFollower.onMoveLearned(game.save, pika, "THUNDERBOLT") + if startTalk("2347_baked") then + for _ = 1, 200 do + if ow.emote and ow.emote.pikaPic then break end + U.wait(1) + end + emote = ow.emote + local veiled = false + for _ = 1, 400 do + if ow.emote ~= emote then break end + U.wait(1) + local v = game.renderer and game.renderer.screenVeil + if emote.bgp == 0xC0 and v and v[1] == 1 then + if not veiled then U.shot(game, SHOT_DIR .. "/2347_09_baked_strobe_white.png") end + veiled = true + end + end + check("2347_baked_white_veil", veiled) + end + else + U.log("SKIP 2347_baked (no gbc atlas in this cache)") + end + + Sound.playMove, Music.duckForFanfare = realPlayMove, realDuck + quit() +end diff --git a/tests/drivers/pp_restore_menu_2155_2158.lua b/tests/drivers/pp_restore_menu_2155_2158.lua index ab123869..3fb3cb23 100644 --- a/tests/drivers/pp_restore_menu_2155_2158.lua +++ b/tests/drivers/pp_restore_menu_2155_2158.lua @@ -112,6 +112,15 @@ return function(game) end end + local function waitMoveMenu() + for _ = 1, 240 do + local t = top() + if isMoveMenu(t) and t.ready then return true end + U.wait(1) + end + return false + end + local function waitForBag() for _ = 1, 60 do local t = top() @@ -176,8 +185,7 @@ return function(game) check("keepOpen is set for the ETHERs", ether.keepOpen == true) cursorTo(ether, 1) U.tap(game, "a") - U.wait(8) - check("the move window is the top state", isMoveMenu(top())) + check("the move window is the top state", waitMoveMenu()) check("...with the party menu still drawn underneath", inStack(isPicker)) U.wait(20) U.shot(game, DIR .. "/pp2158_ether_move_window.png") @@ -187,8 +195,7 @@ return function(game) U.wait(8) check("B goes back to the party menu", isPicker(top())) U.tap(game, "a") - U.wait(8) - check("and the move window opens again", isMoveMenu(top())) + check("and the move window opens again", waitMoveMenu()) local before = lead.moves[1].pp U.tap(game, "a") @@ -215,8 +222,7 @@ return function(game) if check("party picker opened for MAX ETHER", maxEther ~= nil) then cursorTo(maxEther, 2) -- the untouched SNORLAX U.tap(game, "a") - U.wait(8) - check("the move window opened", isMoveMenu(top())) + check("the move window opened", waitMoveMenu()) U.tap(game, "a") U.wait(6) for _ = 1, 60 do @@ -247,8 +253,5 @@ return function(game) U.log("spaced with '-' for empty slots, no PP column, cursor at column 5,") U.log("and 'Restore PP of / which technique?' in the bottom box. B returns") U.log("to the party menu; A restores PP with the Heal_Ailment jingle.") - - while true do - coroutine.yield() - end + love.event.quit(fail == 0 and 0 or 1) end diff --git a/tests/drivers/pp_up_move_box_2320.lua b/tests/drivers/pp_up_move_box_2320.lua new file mode 100644 index 00000000..60c32dde --- /dev/null +++ b/tests/drivers/pp_up_move_box_2320.lua @@ -0,0 +1,236 @@ +-- home/pokemon.asm:246 +-- engine/items/item_effects.asm:1967-2006 +-- engine/pokemon/learn_move.asm:119-181 +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Screens = require("src.ui.Screens") + local Bag = require("src.inventory.Bag") + local PartyMenu = require("src.ui.PartyMenu") + local MoveSelectMenu = require("src.ui.MoveSelectMenu") + local MoveLearnMenu = require("src.ui.MoveLearnMenu") + local ChoiceBox = require("src.ui.ChoiceBox") + local TextBox = require("src.render.TextBox") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots2320" + local fail = 0 + + local function check(label, ok) + if not ok then fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + local function top() return game.stack:top() end + local function isBox(s) return getmetatable(s) == TextBox end + local function isParty(s) return getmetatable(s) == PartyMenu end + local function isMoveMenu(s) return getmetatable(s) == MoveSelectMenu end + local function isLearn(s) return getmetatable(s) == MoveLearnMenu end + local function find(pred) + for _, s in ipairs(game.stack.states or {}) do + if pred(s) then return s end + end + end + local function boxText(b) + local out = {} + for _, page in ipairs((b and b.pages) or {}) do + for _, l in ipairs(page) do out[#out + 1] = tostring(l) end + end + return table.concat(out, " ") + end + local function waitFor(pred, n) + for _ = 1, n or 240 do + if pred() then return true end + U.wait(1) + end + return pred() + end + local function cursorTo(menu, want) + for _ = 1, 40 do + if menu.index == want then return true end + U.tap(game, menu.index < want and "down" or "up") + U.wait(3) + end + return menu.index == want + end + + local gengar = Pokemon.new(game.data, "GENGAR", 30) + gengar.moves = { + { id = "LICK", pp = 30 }, { id = "CONFUSE_RAY", pp = 10 }, + { id = "NIGHT_SHADE", pp = 15 }, { id = "HYPNOSIS", pp = 20 }, + } + local nido = Pokemon.new(game.data, "NIDOKING", 40) + nido.moves = { + { id = "SURF", pp = 15 }, { id = "HORN_ATTACK", pp = 25 }, + { id = "POISON_STING", pp = 35 }, { id = "LEER", pp = 30 }, + } + game.save.party = { gengar, nido } + game.save.player.name = "RED" + game.save.inventory = {} + Bag.add(game.save, "PP_UP", 5, game.data) + Bag.add(game.save, "TM_MEGA_PUNCH", 1, game.data) + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(10) + + local function openPicker(id) + while #game.stack.states > 1 do game.stack:pop() end + U.wait(2) + local bag = Screens.push(game, "BagMenu", {}) + U.wait(12) + local row + for i, r in ipairs(bag.items or {}) do + if r.value == id then row = i break end + end + if not row or not cursorTo(bag, row) then return nil end + U.tap(game, "a") + U.wait(12) + local ut = top() + if ut and ut.items and ut.items[1] and ut.items[1].label == "USE" then + cursorTo(ut, 1) + U.tap(game, "a") + U.wait(4) + end + if not waitFor(function() + if isParty(top()) then return true end + local t = top() + if isBox(t) and (t.done or t.waiting) then U.tap(game, "a") end + if getmetatable(t) == ChoiceBox then U.tap(game, "a") end + return false + end, 600) then return nil end + return top() + end + + local function pickMon(picker, slot) + cursorTo(picker, slot) + U.tap(game, "a") + end + + U.log("======== pass 1: timing, no shots ========") + local picker = openPicker("PP_UP") + if check("PP UP party picker opened", picker ~= nil) then + pickMon(picker, 1) + local promptBox = top() + check("A on GENGAR: a text box is on top, not the move box", isBox(promptBox)) + local mm = find(isMoveMenu) + check("the move menu waits underneath, not ready", mm ~= nil and not mm.ready) + check("the chosen arrow is hollow", picker.chosenHollow == true) + check("the prompt is still typing", isBox(promptBox) and not promptBox.done) + local frames = 0 + while not (isMoveMenu(top()) and top().ready) and frames < 400 do + U.wait(1) + frames = frames + 1 + end + U.log("prompt frames before the move box:", frames) + check("the move box waited for the typed prompt (" .. frames .. " frames)", + frames >= 10 and frames < 400) + U.tap(game, "b") + U.wait(4) + check("B returns to the party menu", isParty(top())) + U.wait(2) + check("the party arrow is filled again", not picker.chosenHollow) + end + + U.log("======== pass 2: PP UP with shots ========") + picker = openPicker("PP_UP") + if check("PP UP party picker opened again", picker ~= nil) then + pickMon(picker, 1) + U.wait(3) + U.shot(game, DIR .. "/2320_01_prompt_typing_hollow_arrow.png") + check("move box ready after the prompt", + waitFor(function() return isMoveMenu(top()) and top().ready end, 400)) + local mm = top() + U.wait(2) + U.shot(game, DIR .. "/2320_02_move_box_over_textbox.png") + cursorTo(mm, 1) + U.tap(game, "a") + check("the result box opened", + waitFor(function() return isBox(top()) end, 30)) + local res = top() + check("result reads PP increased", boxText(res):find("increased") ~= nil) + check("the move box is held under the result", mm.held == true and find(isMoveMenu) == mm) + check("the party arrow is still hollow", picker.chosenHollow == true) + waitFor(function() return res.done end, 240) + U.shot(game, DIR .. "/2320_03_result_keeps_move_box.png") + waitFor(function() + if isBox(top()) then U.tap(game, "a") end + return not find(isParty) + end, 120) + check("dismissing closes both the move box and the picker", + not find(isParty) and not find(isMoveMenu)) + check("LICK got one PP Up", (gengar.moves[1].ppUps or 0) == 1) + end + + U.log("======== PP UP on a maxed move reprompts ========") + gengar.moves[1].ppUps = 3 + local before = game.save.inventory.PP_UP + picker = openPicker("PP_UP") + if check("PP UP party picker opened for the maxed move", picker ~= nil) then + pickMon(picker, 1) + waitFor(function() return isMoveMenu(top()) and top().ready end, 400) + local mm = top() + cursorTo(mm, 1) + U.tap(game, "a") + waitFor(function() return isBox(top()) end, 30) + local res = top() + check("maxed out text prints", boxText(res):find("maxed out") ~= nil) + waitFor(function() return res.done end, 240) + U.shot(game, DIR .. "/2320_04_pp_maxed_out.png") + U.tap(game, "a") + U.wait(2) + check("the prompt types again", isBox(top()) and find(isMoveMenu) == mm and not mm.ready) + check("the move box comes back", + waitFor(function() return top() == mm and mm.ready end, 400)) + U.wait(2) + U.shot(game, DIR .. "/2320_05_maxed_reprompt.png") + check("no PP UP was spent", game.save.inventory.PP_UP == before) + U.tap(game, "b") + U.wait(4) + U.tap(game, "b") + U.wait(8) + end + + U.log("======== TM on a full moveset ========") + picker = openPicker("TM_MEGA_PUNCH") + if check("TM party picker opened", picker ~= nil) then + pickMon(picker, 2) + local learn + local ok = waitFor(function() + learn = find(isLearn) + local t = top() + if learn and isBox(t) and boxText(t):find("forgotten") then return true end + if getmetatable(t) == ChoiceBox then U.tap(game, "a") + elseif isBox(t) and (t.done or t.waiting) then U.tap(game, "a") end + return false + end, 900) + check("Which move should be forgotten? types first", ok and learn and not learn.selecting) + U.wait(3) + U.shot(game, DIR .. "/2320_06_forget_prompt_typing.png") + check("the forget box follows the text", + waitFor(function() return learn and top() == learn and learn.selecting end, 400)) + U.wait(2) + U.shot(game, DIR .. "/2320_07_forget_box_over_textbox.png") + cursorTo(learn, 1) + U.tap(game, "a") + U.wait(2) + check("SURF is refused and the forget box is gone", + isBox(top()) and boxText(top()):find("HM") ~= nil and not learn.selecting) + waitFor(function() return top().done end, 240) + U.shot(game, DIR .. "/2320_08_hm_refused_no_box.png") + U.tap(game, "a") + check("the forget prompt retypes, then the box returns", + waitFor(function() return top() == learn and learn.selecting end, 400)) + cursorTo(learn, 2) + U.tap(game, "a") + U.wait(2) + check("the forget box is gone for 1, 2 and... Poof!", not find(isLearn)) + waitFor(function() + local t = top() + if isBox(t) and (t.done or t.waiting) then U.tap(game, "a") end + return not find(isParty) + end, 900) + local knows = false + for _, mv in ipairs(nido.moves) do if mv.id == "MEGA_PUNCH" then knows = true end end + check("NIDOKING learned MEGA PUNCH", knows) + end + + U.log(fail == 0 and "PASS pp_up_move_box_2320" or ("FAIL pp_up_move_box_2320 (" .. fail .. ")")) + love.event.quit(fail == 0 and 0 or 1) +end diff --git a/tests/drivers/route.lua b/tests/drivers/route.lua index e68b340e..08dd35a1 100644 --- a/tests/drivers/route.lua +++ b/tests/drivers/route.lua @@ -2396,6 +2396,7 @@ local function sellItem(id) qtyTo(top().max or 1) -- the whole stack press("a") U.wait(6) + pressUntil(function() return isChoice() or isList() end, "a", 60) end if isChoice() then cursorTo("index", 1) -- YES @@ -2498,6 +2499,7 @@ local function buyItem(id, qty, where) end press("a") U.wait(6) + pressUntil(function() return isChoice() or isList() end, "a", 60) if isChoice() then if not cursorTo("index", 1) then return false end -- YES press("a") diff --git a/tests/drivers/route_shop_buy_sell_bug2291.lua b/tests/drivers/route_shop_buy_sell_bug2291.lua new file mode 100644 index 00000000..cfaab989 --- /dev/null +++ b/tests/drivers/route_shop_buy_sell_bug2291.lua @@ -0,0 +1,98 @@ +-- engine/events/pokemart.asm:159-169 +-- engine/events/pokemart.asm:84-91 +-- POKEPORT_DRIVER=tests/drivers/route_shop_buy_sell_bug2291.lua POKEPORT_VERSION=red love . +return function(game) + local U = require("tests.drivers.util") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local Menu = require("src.ui.Menu") + + 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 + local function finish() + U.log(failures == 0 and "PASS route_shop_buy_sell_bug2291" + or ("FAIL route_shop_buy_sell_bug2291 (" .. failures .. " failures)")) + love.event.quit(failures == 0 and 0 or 1) + U.wait(600) + end + + local function loadRouteShop() + local f = assert(io.open("tests/drivers/route.lua", "r")) + local src = f:read("*a") + f:close() + local function slice(from, to) + local a = assert(src:find(from, 1, true), from) + local b = assert(src:find(to, a, true), to) + return src:sub(a, b - 1) + end + local body = table.concat({ + "local G, U, press, say, note, WD = ...", + slice("local function top() return G.stack:top() end", + "-- PokeBotBad's shop lists"), + slice("local function qtyTo(want, tries)", "-- Free up bag slots"), + slice("local function buyItem(id, qty, where)", "-- Back all the way out"), + "return { buyItem = buyItem, sellItem = sellItem, isMenu = isMenu,", + " isList = isList, cursorTo = cursorTo, pressUntil = pressUntil }", + }, "\n") + local chunk = assert(loadstring(body, "=route.lua shops")) + local said = {} + local function say(...) + local parts = { ... } + for i = 1, #parts do parts[i] = tostring(parts[i]) end + said[#said + 1] = table.concat(parts, " ") + U.log("[route]", said[#said]) + end + local function press(btn) U.tap(game, btn) end + local function note() end + return chunk(game, U, press, say, note, { keepItems = {} }), said + end + + local ok, err = pcall(function() + local R = loadRouteShop() + game.save.money = 3000 + game.save.inventory = game.save.inventory or {} + U.teleport(game, "PEWTER_MART", 2, 5, "left") + U.wait(10) + U.tap(game, "a") + for _ = 1, 60 do + if getmetatable(game.stack:top()) == Menu then break end + U.tap(game, "a") + U.wait(4) + end + if not check("the mart menu opens", R.isMenu()) then return end + + check("cursor on BUY", R.cursorTo("index", 1)) + U.tap(game, "a") + U.wait(10) + if not check("the buy list opens", R.pressUntil(R.isList, "a", 30)) then return end + local money0 = game.save.money + local have0 = (game.save.inventory or {}).POKE_BALL or 0 + local bought = R.buyItem("POKE_BALL", 3, "PEWTER_MART") + U.shot(game, DIR .. "/2291_route_after_buy.png") + check("route buyItem reports success", bought) + check("route buyItem put 3 POKe BALLs in the bag", + ((game.save.inventory or {}).POKE_BALL or 0) == have0 + 3) + check("route buyItem paid 600", game.save.money == money0 - 600) + check("route buyItem leaves the buy list on top", R.isList()) + + for _ = 1, 10 do + if R.isMenu() then break end + U.tap(game, "b") + U.wait(6) + end + if not check("back on the BUY/SELL menu", R.isMenu()) then return end + local money1 = game.save.money + local sold = R.sellItem("POKE_BALL") + U.shot(game, DIR .. "/2291_route_after_sell.png") + check("route sellItem reports success", sold) + check("route sellItem emptied the POKe BALL stack", + ((game.save.inventory or {}).POKE_BALL or 0) == 0) + check("route sellItem was paid 300", game.save.money == money1 + 300) + check("route sellItem returns to the BUY/SELL menu", R.isMenu()) + end) + if not ok then check("driver error: " .. tostring(err), false) end + finish() +end diff --git a/tests/drivers/save_editor_item_move_bug1951.lua b/tests/drivers/save_editor_item_move_bug1951.lua new file mode 100644 index 00000000..dd2d6f0f --- /dev/null +++ b/tests/drivers/save_editor_item_move_bug1951.lua @@ -0,0 +1,155 @@ +local U = require("tests.drivers.util") + +return function(game) + local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots_1951" + os.execute('mkdir -p "' .. DIR .. '" 2>/dev/null') + local fails = 0 + local function check(label, cond) + print((cond and "PASS " or "FAIL ") .. label) + if not cond then fails = fails + 1 end + return cond + end + + local ok, err = xpcall(function() + U.wait(30) + package.path = package.path .. ";./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua" + local SaveData = require("src.core.SaveData") + local App = require("tools.save-editor.App") + local Kit = require("Kit") + + local W, H = 1280, 720 + local realDim = love.graphics.getDimensions + local realSafe = love.window.getSafeArea + local canvas = love.graphics.newCanvas(W, H) + + local rects = {} + local realButton = Kit.button + Kit.button = function(x, y, w, h, label, opts) + if label == "PC" or label == "BAG" then + rects[#rects + 1] = { label = label, x = x, y = y, w = w, h = h, + enabled = not (opts and opts.enabled == false) } + end + return realButton(x, y, w, h, label, opts) + end + + local function frame(shotName) + rects = {} + love.graphics.getDimensions = function() return W, H end + love.window.getSafeArea = function() return 0, 0, W, H end + love.graphics.push("all") + love.graphics.setCanvas(canvas) + love.graphics.clear(0, 0, 0, 1) + App.draw() + love.graphics.setCanvas() + love.graphics.pop() + love.graphics.getDimensions = realDim + love.window.getSafeArea = realSafe + if shotName then + local fd = canvas:newImageData():encode("png") + local f = io.open(DIR .. "/" .. shotName .. ".png", "wb") + if f then f:write(fd:getString()) f:close() end + end + U.wait(1) + end + + local function find(label) + for _, r in ipairs(rects) do + if r.label == label then return r end + end + end + + local function click(r) + App.mousepressed(r.x + r.w / 2, r.y + r.h / 2, 1) + frame() + end + + local seed = SaveData.newGame() + seed.inventory, seed.bagOrder = { POTION = 30 }, { "POTION" } + local fill = { "ANTIDOTE", "BURN_HEAL", "ICE_HEAL", "AWAKENING", "PARLYZ_HEAL", + "FULL_HEAL", "REVIVE", "MAX_REVIVE", "ESCAPE_ROPE", "REPEL", "SUPER_REPEL", + "MAX_REPEL", "FIRE_STONE", "THUNDER_STONE", "WATER_STONE", "LEAF_STONE", + "MOON_STONE", "RARE_CANDY", "NUGGET" } + for _, id in ipairs(fill) do + seed.inventory[id] = 1 + seed.bagOrder[#seed.bagOrder + 1] = id + end + seed.pcItems = { POTION = 90, ULTRA_BALL = 5 } + local path = os.tmpname() .. "-bug1951.lua" + local f = assert(io.open(path, "wb")); f:write(SaveData.encode(seed)); f:close() + + App.load(path, { version = "red", embedded = true }) + local S = App.getState() + S.tab = "items" + frame(); frame("1951_01_items_tab_before_move") + + local bagCount = 0 + for _, id in ipairs(S.save.bagOrder or {}) do bagCount = bagCount + 1 end + check("1951 bag starts at 20/20 slots", bagCount == 20) + local pcBtn = find("PC") + local bagBtn = find("BAG") + check("1951 bag rows carry a PC button", pcBtn ~= nil and pcBtn.enabled) + check("1951 PC rows carry a BAG button", bagBtn ~= nil) + + if pcBtn then + click(pcBtn) + frame("1951_02_potion_moved_21_left") + check("1951 PC POTION tops out at 99", S.save.pcItems.POTION == 99) + check("1951 bag POTION keeps the 21 that did not fit", S.save.inventory.POTION == 21) + check("1951 status names the remainder: " .. tostring(S.status), + tostring(S.status):find("21 left in the bag", 1, true) ~= nil) + end + + S.pcSort = "name" + frame() + local ultra + local order = require("Ops").pcOrder(S) + local ultraIdx + for i, id in ipairs(order) do if id == "ULTRA_BALL" then ultraIdx = i end end + local bagRects = {} + for _, r in ipairs(rects) do if r.label == "BAG" then bagRects[#bagRects + 1] = r end end + ultra = ultraIdx and bagRects[ultraIdx] + check("1951 ULTRA_BALL row has a BAG button", ultra ~= nil) + if ultra then + click(ultra) + frame("1951_03_bag_full_refused") + check("1951 full bag refuses ULTRA_BALL", S.save.inventory.ULTRA_BALL == nil + and S.save.pcItems.ULTRA_BALL == 5) + check("1951 status says the bag is full: " .. tostring(S.status), + tostring(S.status):find("Bag is full", 1, true) ~= nil) + require("Ops").bagDrop(S, "NUGGET") + frame() + bagRects = {} + for _, r in ipairs(rects) do if r.label == "BAG" then bagRects[#bagRects + 1] = r end end + order = require("Ops").pcOrder(S) + for i, id in ipairs(order) do if id == "ULTRA_BALL" then ultraIdx = i end end + ultra = bagRects[ultraIdx] + if ultra then click(ultra) end + frame("1951_04_ultra_ball_withdrawn") + check("1951 ULTRA_BALL withdraws once a slot frees", + S.save.inventory.ULTRA_BALL == 5 and S.save.pcItems.ULTRA_BALL == nil) + end + + W, H = 500, 800 + canvas = love.graphics.newCanvas(W, H) + frame(); frame() + pcBtn = find("PC") + if pcBtn and pcBtn.y > H - 60 then + S.itemsScroll = pcBtn.y - 160 + frame(); frame() + end + frame("1951_05_phone_layout_move_buttons") + pcBtn = find("PC") + check("1951 phone layout keeps the PC button inside the row", pcBtn ~= nil + and pcBtn.x >= 0 and pcBtn.x + pcBtn.w <= W) + + Kit.button = realButton + App.unload() + os.remove(path) + end, debug.traceback) + if not ok then + print("FAIL 1951 driver error " .. tostring(err)) + fails = fails + 1 + end + if fails == 0 then print("PASS save_editor_item_move_bug1951") end + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/seafoam_boulder_hole_bug2290.lua b/tests/drivers/seafoam_boulder_hole_bug2290.lua new file mode 100644 index 00000000..7a5e9fed --- /dev/null +++ b/tests/drivers/seafoam_boulder_hole_bug2290.lua @@ -0,0 +1,116 @@ +-- scripts/SeafoamIslands1F.asm:1 +-- POKEPORT_DRIVER=tests/drivers/seafoam_boulder_hole_bug2290.lua POKEPORT_IDENTITY=bsa0922-2290 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local Sound = require("src.core.Sound") + local MAP = "SEAFOAM_ISLANDS_1F" + local HX, HY = 17, 6 + local EVENT = "EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE" + local DIRS = { "right", "left", "down", "up" } + local STEP = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, right = { 1, 0 } } + local BACK = { right = "left", left = "right", up = "down", down = "up" } + + 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 + local function finish() + for _, d in ipairs(DIRS) do game.input.state[d] = false end + U.log(ok and "PASS seafoam_boulder_hole_2290" or "FAIL seafoam_boulder_hole_2290") + love.event.quit(ok and 0 or 1) + while true do coroutine.yield() end + end + + if game.save.flags then game.save.flags[EVENT] = nil end + U.teleport(game, MAP, 18, 11, "up") + U.wait(10) + local ow = game.overworld + game.data.encounters[MAP] = nil + local rock = ow and ow:npcAtCell(18, 10) + if not check("1F boulder 1 present at (18,10)", + rock ~= nil and rock.def.sprite == "SPRITE_BOULDER") then + finish() + end + ow.strengthActive = true + + local function free(x, y) + if not (ow.map:inBounds(x, y) and ow.map:isWalkableCell(x, y)) then return false end + local n = ow:npcAtCell(x, y) + return n == nil or n == rock + end + local pushDir + for _, d in ipairs(DIRS) do + local s = STEP[d] + if not pushDir and free(HX - s[1], HY - s[2]) and free(HX - 2 * s[1], HY - 2 * s[2]) then + pushDir = d + end + end + if not check("found an approach lane into the hole at (17,6)", pushDir ~= nil) then + finish() + end + local s = STEP[pushDir] + rock.cellX, rock.cellY = HX - s[1], HY - s[2] + rock.px, rock.py = rock.cellX * 16, rock.cellY * 16 + rock.moving, rock.targetX, rock.targetY = false, nil, nil + local p = ow.player + p.cellX, p.cellY = HX - 2 * s[1], HY - 2 * s[2] + p.px, p.py = p.cellX * 16, p.cellY * 16 + p.moving, p.targetX, p.targetY = false, nil, nil + p.facing = pushDir + ow.boulderTried = nil + U.wait(8) + U.log("pushing", pushDir, "from", p.cellX, p.cellY) + U.shot(game, DIR .. "/2290_01_boulder_next_to_hole.png") + + local cues = {} + local realPlay = Sound.play + Sound.play = function(data, key, ...) + cues[#cues + 1] = tostring(key) + return realPlay(data, key, ...) + end + + local function inEntities() + for _, e in ipairs(ow.entities) do if e == rock then return true end end + return false + end + local first = true + for _ = 1, 300 do + if not inEntities() then break end + if first then table.insert(game.input.pressQueue, pushDir); first = false end + game.input.state[pushDir] = true + U.wait(1) + end + game.input.state[pushDir] = false + for _ = 1, 60 do + if not ow.dustAnim then break end + U.wait(1) + end + U.wait(4) + Sound.play = realPlay + + check("boulder removed from 1F after reaching the hole", not inEntities()) + check(EVENT .. " is set", game.save.flags and game.save.flags[EVENT] == true) + check("no text box on top after the fall", game.stack:top() == ow) + local thud = false + for _, c in ipairs(cues) do if c == "Faint_Thud" then thud = true end end + U.log("sounds heard:", #cues > 0 and table.concat(cues, ", ") or "nothing") + check("no Faint_Thud when the boulder falls", not thud) + U.shot(game, DIR .. "/2290_02_boulder_gone_no_text.png") + + local back = BACK[pushDir] + local bs = STEP[back] + local sx, sy = p.cellX, p.cellY + if free(sx + bs[1], sy + bs[2]) then + U.hold(game, back, 20) + U.wait(10) + check("player walks right away without dismissing anything", + p.cellX ~= sx or p.cellY ~= sy) + else + U.log("skip walk check: cell behind the player is blocked") + end + + finish() +end diff --git a/tests/drivers/seam_palette_timing_bug2186_test.lua b/tests/drivers/seam_palette_timing_bug2186_test.lua new file mode 100644 index 00000000..01d75d02 --- /dev/null +++ b/tests/drivers/seam_palette_timing_bug2186_test.lua @@ -0,0 +1,96 @@ +-- home/overworld.asm:675 +return function(game) + local U = dofile("tests/drivers/util.lua") + local Zoom = require("src.render.Zoom") + local PaletteFX = require("src.render.PaletteFX") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots2186" + + local failures = 0 + local function check(label, ok) + if not ok then failures = failures + 1 end + print((ok and "PASS " or "FAIL ") .. label) + return ok + end + + local function key(colors) + if not colors then return "nil" end + local out = {} + for i, c in ipairs(colors) do out[i] = table.concat(c, ",") end + return table.concat(out, "|") + end + + local function baseKey(ow) + local z = ow:sgbWorldZones() + return key(z and z[1] and z[1].colors), z and #z or 0 + end + + local function crossNorth(ow) + for _ = 1, 40 do + table.insert(game.input.pressQueue, "up") + game.input.state.up = true + coroutine.yield() + if ow.map.id == "ROUTE_1" then break end + end + game.input.state.up = false + return ow.map.id == "ROUTE_1" and ow.player.moving + end + + U.newGame(game) + PaletteFX.setMode("gbc") + Zoom.offset = 0 + local ow = game.overworld + + U.teleport(game, "PALLET_TOWN", 10, 1, "up") + U.wait(20) + ow = game.overworld + check("standing at the Pallet Town / Route 1 seam", ow.map.id == "PALLET_TOWN") + local pallet = key(PaletteFX.pal(game.data, ow:paletteNameFor(ow.map))) + local r1 = { id = "ROUTE_1", def = game.data.maps.ROUTE_1 } + local route = key(PaletteFX.pal(game.data, ow:paletteNameFor(r1))) + check("Pallet and Route 1 wear different palettes", pallet ~= route) + + check("seam step starts onto Route 1", crossNorth(ow)) + local held, frames, zoneCount = true, 0, 0 + while ow.player.moving and frames < 40 do + local k, n = baseKey(ow) + zoneCount = math.max(zoneCount, n) + if k ~= pallet then held = false end + frames = frames + 1 + coroutine.yield() + end + U.log("seam step frames under Pallet's palette:", frames) + check("whole screen keeps Pallet's palette for every seam-step frame", held and frames > 0) + check("seam step is one whole-screen zone", zoneCount == 1) + local landed, n = baseKey(ow) + check("palette flips to Route 1 the frame the step lands", landed == route and n == 1) + check("held palette cleared on landing", ow.seamPalette == nil) + + U.teleport(game, "PALLET_TOWN", 10, 1, "up") + U.wait(20) + ow = game.overworld + check("second pass: seam step starts", crossNorth(ow)) + U.shot(game, DIR .. "/2186_01_seam_step_still_pallet.png") + check("second pass: shot taken mid-step under Pallet's palette", + ow.player.moving and (baseKey(ow)) == pallet) + for _ = 1, 40 do + if not ow.player.moving then break end + coroutine.yield() + end + U.shot(game, DIR .. "/2186_02_landed_route1_palette.png") + check("second pass: landed under Route 1's palette", (baseKey(ow)) == route) + + U.teleport(game, "PALLET_TOWN", 10, 1, "up") + U.wait(20) + ow = game.overworld + Zoom.offset = -1 + U.wait(5) + check("survey zoom: seam step starts", crossNorth(ow)) + local sk, sn = baseKey(ow) + check("survey zoom keeps per-map zones mid-step", sk == route and sn > 1) + U.shot(game, DIR .. "/2186_03_survey_per_map.png") + Zoom.offset = 0 + + print(failures == 0 and "PASS seam_palette_timing_2186" or "FAIL seam_palette_timing_2186") + love.event.quit(failures == 0 and 0 or 1) + while true do coroutine.yield() end +end diff --git a/tests/drivers/shop_test.lua b/tests/drivers/shop_test.lua index f94ccb74..a5853b46 100644 --- a/tests/drivers/shop_test.lua +++ b/tests/drivers/shop_test.lua @@ -4,7 +4,7 @@ -- open_mart with a yielded runner, the old softlock). return function(game) local U = dofile("tests/drivers/util.lua") - local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" local Flags = require("src.script.Flags") local Menu = require("src.ui.Menu") local ListMenu = require("src.ui.ListMenu") @@ -15,6 +15,12 @@ return function(game) Flags.set(game.save, "EVENT_GOT_OAKS_PARCEL") Flags.set(game.save, "EVENT_OAK_GOT_PARCEL") + 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 local function topIs(cls) return getmetatable(game.stack:top()) == cls end @@ -31,39 +37,57 @@ return function(game) local ow = game.overworld U.tap(game, "a") -- talk to the clerk U.wait(20) - U.log(tag, "menu:", mash("a", function() return topIs(Menu) end)) + check(tag .. " menu", mash("a", function() return topIs(Menu) end)) U.tap(game, "a") -- BUY U.wait(8) - U.log(tag, "list:", topIs(ListMenu)) + check(tag .. " list", topIs(ListMenu)) U.shot(game, ("%s/%s_0_list.png"):format(DIR, tag)) U.tap(game, "a") -- first item U.wait(8) - U.log(tag, "qty:", topIs(QuantityBox)) + check(tag .. " qty", topIs(QuantityBox)) U.tap(game, "down") -- engine/events/pokemart.asm:152-153 U.wait(6) - U.log(tag, "qty max:", game.stack:top().qty) + check(tag .. " qty max 99", game.stack:top().qty == 99) U.shot(game, ("%s/%s_0b_qty99.png"):format(DIR, tag)) U.tap(game, "up") U.wait(6) U.tap(game, "a") -- x01 - U.wait(8) - U.log(tag, "confirm:", topIs(ChoiceBox)) + for _ = 1, 300 do + if topIs(ChoiceBox) then break end + local t = game.stack:top() + if t and t.isTextBox and t.waiting and (t.preWait or 0) == 0 then + U.tap(game, "a") + end + U.wait(1) + end + check(tag .. " confirm", topIs(ChoiceBox)) U.shot(game, ("%s/%s_1_confirm.png"):format(DIR, tag)) + local money0 = game.save.money U.tap(game, "a") -- YES + for _ = 1, 40 do + if game.save.money < money0 then break end + U.wait(1) + end U.wait(8) + check(tag .. " bought", game.save.money < money0) U.shot(game, ("%s/%s_2_bought.png"):format(DIR, tag)) - U.log(tag, "unwound:", mash("b", function() return game.stack:top() == ow end)) - U.log(tag, "runner idle:", - not (ow.runner and ow.runner:isRunning()) and true or false) + check(tag .. " unwound", mash("b", function() return game.stack:top() == ow end)) + check(tag .. " runner idle", not (ow.runner and ow.runner:isRunning())) local x0, y0 = ow.player.cellX, ow.player.cellY U.hold(game, "right", 30) U.wait(20) - U.log(tag, "player moved:", ow.player.cellX ~= x0 or ow.player.cellY ~= y0) + check(tag .. " player moved", ow.player.cellX ~= x0 or ow.player.cellY ~= y0) U.shot(game, ("%s/%s_3_walk.png"):format(DIR, tag)) end - U.teleport(game, "PEWTER_MART", 2, 5, "left") - buyRun("pewter") - U.teleport(game, "VIRIDIAN_MART", 2, 5, "left") - buyRun("viridian") + local ok, err = pcall(function() + U.teleport(game, "PEWTER_MART", 2, 5, "left") + buyRun("pewter") + U.teleport(game, "VIRIDIAN_MART", 2, 5, "left") + buyRun("viridian") + end) + if not ok then check("shop_test error: " .. tostring(err), false) end + U.log(failures == 0 and "PASS shop_test" or ("FAIL shop_test (" .. failures .. " failures)")) + love.event.quit(failures == 0 and 0 or 1) + U.wait(600) end diff --git a/tests/drivers/title_skin_scale_2368.lua b/tests/drivers/title_skin_scale_2368.lua new file mode 100644 index 00000000..19d92c2b --- /dev/null +++ b/tests/drivers/title_skin_scale_2368.lua @@ -0,0 +1,134 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local TouchSkin = require("src.core.TouchSkin") + local Renderer = require("src.render.Renderer") + local GameVersion = require("src.core.GameVersion") + local TitleState = require("src.ui.TitleState") + local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local version = tostring(GameVersion.get()) + local tag = "2368_" .. version + + local fails = 0 + local function check(label, ok, detail) + U.log((ok and "PASS " or "FAIL ") .. tag .. " " .. label, detail or "") + if not ok then fails = fails + 1 end + return ok + end + + local function finish() + TouchSkin.setActive(nil) + if fails > 0 then + U.log("FAIL " .. tag .. ": " .. fails .. " check(s), shots in " .. SHOT_DIR) + love.event.quit(1) + else + U.log("PASS " .. tag .. ": all checks, shots in " .. SHOT_DIR) + love.event.quit(0) + end + while true do coroutine.yield() end + end + + local function waitFor(pred, limit) + for _ = 1, limit or 3000 do + if pred() then return true end + U.wait(1) + end + return false + end + + local function shot(name) + U.shot(game, SHOT_DIR .. "/" .. tag .. "_" .. name .. ".png") + end + + local function scale() + U.wait(4) + local r = Renderer:frameRects() + return r.Up, Renderer:fitScale(), r + end + + local function wholeFit(label) + local up, fit = scale() + check(label .. " draws at the integer fit", up == fit and up == math.floor(up), + ("Up=%s fit=%s"):format(tostring(up), tostring(fit))) + return up + end + + love.window.setMode(1024, 768, { resizable = true }) + U.wait(2) + local skin = assert(TouchSkin.parse([[ +overlays = 1 +overlay0_name = "bezel" +overlay0_full_screen = true +overlay0_normalized = true +overlay0_viewport = "0.15,0.08,0.7,0.84" +overlay0_descs = 1 +overlay0_desc0 = "nul,0.5,0.5,rect,0.02,0.02" +]])) + TouchSkin.setActive(skin) + TouchSkin.setOverlayLive(false) + U.wait(240) + + local introUp = wholeFit("intro inside the bezel") + shot("01_intro_skin") + + local function isTitle() + return getmetatable(game.stack:top()) == TitleState + end + for _ = 1, 40 do + if isTitle() then break end + U.tap(game, "start") + U.wait(10) + end + if not check("reached the title screen", isTitle()) then finish() end + U.wait(30) + local titleUp = wholeFit("title inside the bezel") + check("title matches the intro scale", titleUp == introUp, + ("title=%s intro=%s"):format(tostring(titleUp), tostring(introUp))) + shot("02_title_skin") + + local title = game.stack:top() + waitFor(function() + U.tap(game, "a") + U.wait(5) + return game.stack:top() ~= title + end, 60) + if not check("opened the CONTINUE/NEW GAME menu", game.stack:top() ~= title) then finish() end + U.wait(10) + wholeFit("main menu over the title inside the bezel") + shot("03_mainmenu_skin") + + local OverworldState = require("src.world.OverworldController") + U.tap(game, "a") + U.wait(10) + local arrived = waitFor(function() + U.tap(game, "a") + U.wait(2) + return game.stack:top() == OverworldState + end, 1500) + if not arrived then + for i, s in ipairs(game.stack.states) do + local keys = {} + for k in pairs(s) do keys[#keys + 1] = tostring(k) end + U.log("stack", i, tostring(s == OverworldState), tostring(s.screenId), + table.concat(keys, ","):sub(1, 200)) + end + end + if not check("reached the overworld", arrived) then finish() end + U.wait(20) + U.tap(game, "start") + U.wait(20) + check("START menu is up", game.stack:top() ~= OverworldState) + local owUp = wholeFit("overworld START menu inside the bezel") + check("overworld matches the title scale", owUp == titleUp, + ("overworld=%s title=%s"):format(tostring(owUp), tostring(titleUp))) + shot("04_overworld_start_skin") + + TouchSkin.setActive(nil) + game:returnToTitle() + U.wait(30) + local up, fit = scale() + check("title with no skin still fills past the integer fit", up > fit, + ("Up=%s fit=%s"):format(tostring(up), tostring(fit))) + shot("05_title_noskin_fill") + + finish() +end diff --git a/tests/drivers/vitamin_bug2332.lua b/tests/drivers/vitamin_bug2332.lua new file mode 100644 index 00000000..ae803e94 --- /dev/null +++ b/tests/drivers/vitamin_bug2332.lua @@ -0,0 +1,111 @@ +-- engine/items/item_effects.asm:1310-1317 +-- data/text/text_6.asm:71-77 +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Screens = require("src.ui.Screens") + local Bag = require("src.inventory.Bag") + local Sound = require("src.core.Sound") + local PartyMenu = require("src.ui.PartyMenu") + local TextBox = require("src.render.TextBox") + local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") + or "/tmp/shots" + local failed = false + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + if not ok then failed = true end + return ok + end + + local function stackHas(pred) + for _, s in ipairs(game.stack.states or {}) do + if pred(s) then return true end + end + return false + end + local function isBox(s) return getmetatable(s) == TextBox end + local function isParty(s) return getmetatable(s) == PartyMenu end + + if (game.save.options and game.save.options.sfxVol or 1) == 0 then + U.log("WARNING: sfxVol is 0, so nothing here can be heard.") + end + + local mon = Pokemon.new(game.data, "NIDORINO", 20) + game.save.party = { mon } + game.save.player.name = "bryan" + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(10) + + local heard + local realPlay = Sound.play + Sound.play = function(data, name, ...) + if name == "Heal_Ailment" and not heard then + heard = { boxUp = stackHas(isBox), partyUp = stackHas(isParty) } + end + return realPlay(data, name, ...) + end + + local function useVitamin(id, statName, tag, shotOpen, shotDone) + heard = nil + while #game.stack.states > 1 do + game.stack:pop() + end + game.save.inventory = {} + Bag.add(game.save, id, 1, game.data) + Screens.push(game, "BagMenu", {}) + U.wait(12) + U.tap(game, "a") + U.wait(12) + U.tap(game, "a") + U.wait(12) + U.tap(game, "a") + local box + for _ = 1, 30 do + local top = game.stack:top() + if isBox(top) then box = top break end + U.wait(1) + end + if not check(tag .. " rose box opened", box ~= nil) then return end + local typedAtOpen = box.charIndex or 0 + check(tag .. " Heal_Ailment fired before the rose box existed", + heard ~= nil and heard.boxUp == false) + check(tag .. " Heal_Ailment fired over the party menu", + heard ~= nil and heard.partyUp == true) + check(tag .. " rose box was still typing when first seen (char " + .. tostring(typedAtOpen) .. ")", not box.done and typedAtOpen <= 2) + U.wait(2) + U.shot(game, SHOT_DIR .. "/" .. shotOpen) + for _ = 1, 240 do + if box.done then break end + U.wait(1) + end + local page = box.pages and box.pages[1] or {} + check(tag .. " line 1 is NIDORINO's", page[1] == "NIDORINO's") + check(tag .. " line 2 is " .. statName .. " rose.", + page[2] == statName .. " rose.") + check(tag .. " party menu is still drawn under the message", + stackHas(isParty)) + U.shot(game, SHOT_DIR .. "/" .. shotDone) + U.log(tag, "typed:", tostring(page[1]), "/", tostring(page[2])) + U.tap(game, "a") + U.wait(20) + end + + useVitamin("CALCIUM", "SPECIAL", "CALCIUM", + "2332_01_calcium_jingle_box_opening.png", + "2332_02_calcium_special_rose_typed.png") + check("CALCIUM raised SPECIAL stat exp", + (mon.statExp and mon.statExp.special or 0) == 2560) + useVitamin("HP_UP", "HEALTH", "HP_UP", + "2332_03_hp_up_jingle_box_opening.png", + "2332_04_hp_up_health_rose_typed.png") + check("HP_UP raised HP stat exp", + (mon.statExp and mon.statExp.hp or 0) == 2560) + + Sound.play = realPlay + U.log("the cure jingle should start as the box opens and play while") + U.log("NIDORINO's / SPECIAL rose. types, not after the last letter.") + U.log(failed and "FAIL vitamin_bug2332" or "PASS vitamin_bug2332") + love.event.quit(failed and 1 or 0) +end diff --git a/tests/engine/cart_delete_bug2367.lua b/tests/engine/cart_delete_bug2367.lua new file mode 100644 index 00000000..abda647a --- /dev/null +++ b/tests/engine/cart_delete_bug2367.lua @@ -0,0 +1,245 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end +love.graphics.polygon = love.graphics.polygon or function() end + +local clock = 5000 +love.timer.getTime = function() return clock end + +local Kit = require("src.ui.kit.Kit") +local CartManifest = require("src.carts.CartManifest") +local CartStore = require("src.carts.CartStore") +local ModIndex = require("src.mods.ModIndex") +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") + +local SHA = ("a1b2c3d4"):rep(8) + +love.graphics.getDimensions = function() return 1280, 720 end +love.graphics.getPixelDimensions = function() return 1280, 720 end + +local function pin(id, repo) + return { id = id, source = "github", repo = repo, version = "1.0.0", + sha256 = SHA } +end + +local CART = { + id = "wild_green", title = "Wild Green", version = "1.75.0", + author = "wild1walker", shell = "#3a8f3a", base = "red", seal = "sealed", + mods = { pin("wild_green", "wild1walker/Gen1MakeItGreen"), + pin("wg_music", "wild1walker/wg-music"), + pin("wg_items", "wild1walker/wg-items"), + pin("wg_maps", "wild1walker/wg-maps") }, +} + +local function installCart() + local cart, perr = CartManifest.parse(CART) + check(cart ~= nil, "fixture parses: " .. tostring(perr)) + local ok, err = CartStore.install(CartManifest.encode(cart)) + check(ok ~= nil, "fixture installs: " .. tostring(err)) +end + +local Json = require("src.link.Json") +local ENTRY_RAW = { + id = "wild_green", title = "Wild Green", author = "wild1walker", + version = "1.75.0", base = "red", seal = "sealed", + repo = "https://github.com/wild1walker/Gen1WildGreen", + github = "wild1walker/Gen1WildGreen", + mods = CART.mods, update_check = "ok", + latest = { version = "1.75.0", tag = "v1.75.0", + zip = { name = "wild_green-1.75.0.g1rcart", + url = "https://example.test/wild_green-1.75.0.g1rcart" } }, +} +local FEED = ModIndex.parse(Json.encode({ + schema_version = 1, categories = { "GAMEPLAY" }, base_games = { "red" }, + mods = {}, carts = { ENTRY_RAW }, +})) +check(FEED ~= nil and FEED.carts and FEED.carts[1] ~= nil, "the feed parses") +local ENTRY = FEED.carts[1] +check(ModIndex.isCart(ENTRY), "the index entry is a cart") + +local realButton = Kit.button +local function drawButtons(imp) + local byId = {} + Kit.button = function(x, y, w, h, label, opts) + if opts and opts.id then + byId[opts.id] = { label = tostring(label), opts = opts, + x = x, y = y, w = w, h = h } + end + return realButton(x, y, w, h, label, opts) + end + local ok, err = pcall(LauncherView.draw, imp) + Kit.button = realButton + check(ok, "the frame draws: " .. tostring(err)) + return byId +end + +local function press(imp, b) + imp:runActions({ { key = b.opts.id, fn = b.opts.action, + keepArm = b.opts.keepArm } }) +end + +local function findLauncher() + local imp = RomImporter.new(function() end, { launcher = true }) + imp.tab = "find" + imp.modScope = nil + imp.findLoaded = true + imp._findFetch = nil + imp.findSources = { { feed = "https://example.test/data/index.json", + base = "https://example.test/", + label = "example/index" } } + imp.findIndex = { mods = FEED.mods, carts = FEED.carts, categories = {}, + baseGames = ModIndex.baseGamesIn(FEED) } + imp:_setFindKind("carts") + return imp +end + +do + love.filesystem.createDirectory("mods/wild_green") + love.filesystem.write("mods/wild_green/manifest.lua", + "return { id = \"wild_green\", name = \"Wild Green\", version = \"1.0.0\" }") + installCart() + local imp = findLauncher() + imp.ready.red = nil + check(imp:_findInstalledCarts().wild_green ~= nil, + "FIND reads the cart as installed") + imp._findEntry = ENTRY + + local b = drawButtons(imp) + check(b["findpop-inst"] ~= nil, "the popup keeps its install row") + eq(b["findpop-inst"] and b["findpop-inst"].label, "Reinstall", + "which reads Reinstall for the installed version") + local del = b["findpop-del"] + check(del ~= nil, "an installed cart's FIND popup offers Delete") + if del then + eq(del.label, "Delete", "the chip starts unarmed") + eq(del.opts.kind, "danger", "and is drawn as a danger control") + check(del.y == b["findpop-inst"].y, "it shares the install row") + check(del.x >= b["findpop-inst"].x + b["findpop-inst"].w, + "beside the install button, not over it") + + press(imp, del) + check(CartStore.get("wild_green") ~= nil, "the first press does not delete") + check(imp._findEntry == ENTRY, "and the popup stays open") + local armed = drawButtons(imp)["findpop-del"] + eq(armed and armed.label, "Sure?", "the chip asks to confirm") + + clock = clock + 0.5 + press(imp, armed or del) + check(CartStore.get("wild_green") == nil, "the second press deletes the cart") + eq(love.filesystem.getInfo(CartStore.fileFor("wild_green")), nil, + "and its file is gone") + eq(imp:_findInstalledCarts().wild_green, nil, + "FIND no longer reads it as installed") + eq(#imp:_ensureCarts("red"), 0, "the red cart list is empty") + eq(imp._findEntry, nil, "the popup closes") + eq(imp._cartPopup, nil, "no Custom Carts popup opens for an unready game") + check(imp.findNotice ~= nil and imp.findNotice.ok == true, + "FIND reports the delete") + check(imp.findNotice and tostring(imp.findNotice.text) + :find("Cart deleted", 1, true) ~= nil, "with the delete notice") + check(love.filesystem.getInfo("mods/wild_green/manifest.lua") ~= nil, + "the MODS-side mod sharing the id is untouched") + + imp._findEntry = ENTRY + local after = drawButtons(imp) + eq(after["findpop-inst"] and after["findpop-inst"].label, "Install", + "the listing offers Install again") + eq(after["findpop-del"], nil, "and no Delete for a cart that is gone") + end +end + +do + local imp = findLauncher() + imp._findEntry = ENTRY + local b = drawButtons(imp) + eq(b["findpop-del"], nil, "a cart that is not installed has no Delete") +end + +do + installCart() + local imp = findLauncher() + local realUninstall = CartStore.uninstall + CartStore.uninstall = function() return nil, "could not delete the cart: busy" end + imp:deleteCart("red", "wild_green", { from = "find" }) + CartStore.uninstall = realUninstall + check(imp.findNotice ~= nil and imp.findNotice.ok == false, + "a failed delete from FIND reports the error there") + eq(imp._cartNotice, nil, "not on the Custom Carts popup") + check(CartStore.get("wild_green") ~= nil, "and the cart is still installed") + imp:deleteCart("red", "wild_green", { from = "find" }) + check(CartStore.get("wild_green") == nil, "cleanup") +end + +do + installCart() + local imp = findLauncher() + imp.tab = "red" + imp.ready.red = true + imp:_selectCart("red", "wild_green") + eq(imp.activeCart.red, "wild_green", "the cart is selected for red") + imp._cartPopup = "red" + local b = drawButtons(imp) + local del = b["cartpop-id-wild_green-delete"] + check(del ~= nil, "the Custom Carts popup draws the cart's Delete chip") + if del then + clock = clock + 10 + press(imp, del) + check(CartStore.get("wild_green") ~= nil, "one press only arms") + clock = clock + 0.5 + press(imp, del) + check(CartStore.get("wild_green") == nil, "the second press deletes it") + eq(imp.activeCart.red, nil, "and deselects it") + eq(imp._cartPopup, "red", "the popup reopens on that game") + check(tostring(imp._cartNotice):find("Cart deleted", 1, true) ~= nil, + "with the delete notice") + end +end + +do + installCart() + local imp = findLauncher() + imp.ready.red = nil + imp._findEntry = ENTRY + imp.workState = "working" + local del = drawButtons(imp)["findpop-del"] + check(del ~= nil, "Delete is drawn during an import") + if del then + clock = clock + 10 + press(imp, del) + clock = clock + 0.5 + press(imp, del) + check(CartStore.get("wild_green") ~= nil, + "a delete confirmed mid-import leaves the cart") + check(imp.findNotice ~= nil and imp.findNotice.ok == false, + "and says why on FIND instead of closing silently") + check(imp.findNotice and tostring(imp.findNotice.text) + :find("import", 1, true) ~= nil, "naming the running import") + end + imp.findNotice = nil + imp._cartNotice = nil + eq(imp:deleteCart("red", "wild_green"), false, + "the popup path refuses mid-import too") + check(imp._cartNotice ~= nil, "with a Custom Carts notice") + imp.workState = nil + + local stale = { image = {}, width = 1, height = 1 } + imp._cartridgeLabels = { ["cart:wild_green"] = stale, + ["gba:cart:wild_green"] = stale, red = stale } + imp._gbaLabels = { ["gba:cart:wild_green"] = stale } + eq(imp:deleteCart("red", "wild_green", { from = "find" }), true, + "the delete runs once the import is done") + eq(imp._cartridgeLabels["cart:wild_green"], nil, + "the deleted cart's label art is dropped") + eq(imp._cartridgeLabels["gba:cart:wild_green"], nil, + "in the GBA shell cache key too") + eq(imp._gbaLabels["gba:cart:wild_green"], nil, "and the GBA label cache") + check(imp._cartridgeLabels.red == stale, "stock labels stay cached") +end + +T.finish("cart delete from FIND (#2367)") diff --git a/tests/engine/ether_move_menu_2158.lua b/tests/engine/ether_move_menu_2158.lua index 4eab0839..edb7f266 100644 --- a/tests/engine/ether_move_menu_2158.lua +++ b/tests/engine/ether_move_menu_2158.lua @@ -30,6 +30,10 @@ local FontStub = { drawCode = function(code, x, y) draws[#draws + 1] = { "code", code, x, y } end, } package.loaded["src.render.Font"] = FontStub +local TextBox = require("src.render.TextBox") +TextBox.new = function(_, text, onDone, opts) + return { fake = true, text = text, onDone = onDone, opts = opts } +end local MoveSelectMenu = require("src.ui.MoveSelectMenu") local pressed = {} @@ -49,41 +53,53 @@ local chosen, cancelled local menu = MoveSelectMenu.new(game, mon, "Restore PP of\nwhich technique?", function(i) chosen = i end, function() cancelled = true end) stack[1] = menu +local function showPrompt(m) + m:enter() + local box = stack[#stack] + check(box and box.fake and box.opts and box.opts.stay, "the prompt types in a stay box") + box.opts.stay.onShown() + eq(stack[#stack], m, "then hands the screen to the move window") +end +showPrompt(menu) draws = {} menu:draw() --- engine/battle/core.asm:2526 -eq(draws[1][1], "box", "the move window is a bordered box") -eq(draws[1][2], 4, "at tile column 4") -eq(draws[1][3], 7, "tile row 7") -eq(draws[1][4], 16, "16 tiles wide") -eq(draws[1][5], 6, "6 tiles tall") --- engine/battle/core.asm:2530 -eq(draws[2][2], "SCRATCH", "slot 1 name") -eq(draws[2][3], 48, "names start at x 48") -eq(draws[2][4], 64, "slot 1 on row 8") -eq(draws[3][4], 72, "slot 2 on row 9, single spaced") --- engine/battle/misc.asm:37 -eq(draws[4][2], "-", "empty slot 3 is a dash") -eq(draws[5][2], "-", "empty slot 4 is a dash") -check(not tostring(draws[2][2]):find("5"), "no PP column on the move rows") --- engine/battle/core.asm:2535 -eq(draws[6][1], "code", "cursor glyph") -eq(draws[6][3], 40, "cursor at tile column 5") -eq(draws[6][4], 64, "cursor starts on the first move") -- engine/items/item_effects.asm:1979 -eq(draws[7][2], 0, "prompt box at column 0") -eq(draws[7][3], 12, "row 12") -eq(draws[8][2], "Restore PP of", "prompt line 1") -eq(draws[8][4], 112, "on row 14") -eq(draws[9][2], "which technique?", "prompt line 2") -eq(draws[9][4], 128, "on row 16") +eq(draws[1][1], "box", "the prompt box is drawn first") +eq(draws[1][2], 0, "prompt box at column 0") +eq(draws[1][3], 12, "row 12") +eq(draws[2][2], "Restore PP of", "prompt line 1") +eq(draws[2][4], 112, "on row 14") +eq(draws[3][2], "which technique?", "prompt line 2") +eq(draws[3][4], 128, "on row 16") +-- engine/battle/core.asm:2526 +eq(draws[4][1], "box", "the move window is a bordered box") +eq(draws[4][2], 4, "at tile column 4") +eq(draws[4][3], 7, "tile row 7") +eq(draws[4][4], 16, "16 tiles wide") +eq(draws[4][5], 6, "6 tiles tall") +-- engine/battle/core.asm:2530 +eq(draws[5][2], "SCRATCH", "slot 1 name") +eq(draws[5][3], 48, "names start at x 48") +eq(draws[5][4], 64, "slot 1 on row 8") +eq(draws[6][4], 72, "slot 2 on row 9, single spaced") +-- engine/battle/misc.asm:37 +eq(draws[7][2], "-", "empty slot 3 is a dash") +eq(draws[8][2], "-", "empty slot 4 is a dash") +check(not tostring(draws[5][2]):find("5"), "no PP column on the move rows") +-- engine/battle/core.asm:2535 +eq(draws[9][1], "code", "cursor glyph") +eq(draws[9][3], 40, "cursor at tile column 5") +eq(draws[9][4], 64, "cursor starts on the first move") local terminated = MoveSelectMenu.new(game, mon, "Restore PP of\nwhich technique?{DONE}", function() end) +stack[#stack + 1] = terminated +showPrompt(terminated) +table.remove(stack) draws = {} terminated:draw() -eq(draws[9][2], "which technique?", "the {DONE} terminator is not drawn") +eq(draws[3][2], "which technique?", "the {DONE} terminator is not drawn") -- engine/battle/core.asm:2692 pressed = { down = true } @@ -102,16 +118,18 @@ eq(#stack, 0, "B pops the move window") eq(cancelled, true, "back to the party menu") stack[1] = menu +showPrompt(menu) menu.index = 2 pressed = { a = true } menu:update() eq(chosen, 2, "A hands back the picked slot") -eq(#stack, 0, "and pops itself first") +eq(#stack, 0, "and lets go once nothing prints over it") local empty, escaped = { moves = {} }, 0 local emptyMenu = MoveSelectMenu.new(game, empty, "Restore PP of\nwhich technique?", function() escaped = -1 end, function() escaped = escaped + 1 end) stack[1] = emptyMenu +showPrompt(emptyMenu) pressed = { down = true } emptyMenu:update() eq(#stack, 1, "an empty list ignores DOWN") @@ -122,6 +140,7 @@ eq(#stack, 0, "B pops the empty move window") eq(escaped, 1, "and runs onCancel") stack[1] = emptyMenu +showPrompt(emptyMenu) escaped = 0 pressed = { a = true } emptyMenu:update() diff --git a/tests/engine/game3_bills_pc_label_bug2404_test.lua b/tests/engine/game3_bills_pc_label_bug2404_test.lua new file mode 100644 index 00000000..bf1cece4 --- /dev/null +++ b/tests/engine/game3_bills_pc_label_bug2404_test.lua @@ -0,0 +1,75 @@ +#!/usr/bin/env luajit +-- pokefirered/src/script_menu.c:1027, pokefirered/data/maps/Route25_SeaCottage/scripts.inc:105 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +package.loaded["src.ui.game3.stack"] = { + push = function() end, + pop = function() end, + busy = function() return false end, + drawOrder = function() return {} end, +} +package.loaded["src.core.game3.audio"] = { + playSe = function() end, + playCry = function() end, +} + +local drawn = {} +package.loaded["src.ui.game3.window"] = setmetatable({ + printPx = function(text) drawn[#drawn + 1] = text end, + template = function() return {} end, +}, { __index = function() return function() end end }) + +local Flags = require("src.core.game3.scripting.flags") +local Space = require("src.core.game3.scripting.space") +local PcMenu = require("src.ui.game3.pc_menu") + +local function label(session) + PcMenu.show({ session = session }) + PcMenu.mode = "root" + drawn = {} + PcMenu.draw() + PcMenu.close() + local got = drawn[1] + if PcMenu.storageLabel then + check(PcMenu.storageLabel(session) == got, "storageLabel matches drawn row") + end + return got +end + +print("[test] 1. fresh store: SOMEONE'S PC") +local store = Flags.newStore() +Space.store = store +local session = { flags = {} } +check(label(session) == "SOMEONE'S PC", "no flags -> SOMEONE'S PC (got " .. tostring(label(session)) .. ")") + +print("[test] 2. FLAG_SYS_POKEMON_GET (0x828) alone stays SOMEONE'S PC") +Flags.setFlag(store, nil, 0x828, true) +session.flags = Flags.serialize(store).flags +check(label(session) == "SOMEONE'S PC", "0x828 -> SOMEONE'S PC (got " .. tostring(label(session)) .. ")") + +print("[test] 3. FLAG_SYS_NOT_SOMEONES_PC in the live store: BILL'S PC") +Flags.setFlag(store, nil, "FLAG_SYS_NOT_SOMEONES_PC", true) +check(Flags.getFlag(store, nil, 0x834), "setFlag by name lands on 0x834") +check(label(session) == "BILL'S PC", "live 0x834 -> BILL'S PC (got " .. tostring(label(session)) .. ")") + +print("[test] 4. post-load snapshot (string keys, no live store): BILL'S PC") +local snap = Flags.serialize(store).flags +Space.store = nil +check(label({ flags = snap }) == "BILL'S PC", "serialized 0x834 -> BILL'S PC (got " .. tostring(label({ flags = snap })) .. ")") + +print("[test] 5. snapshot without 0x834: SOMEONE'S PC") +check(label({ flags = { ["2088"] = true } }) == "SOMEONE'S PC", "serialized 0x828 only -> SOMEONE'S PC") + +print(("game3_bills_pc_label_bug2404_test: %s (%d failed)"):format(failed == 0 and "PASS" or "FAIL", failed)) +if failed > 0 then os.exit(1) end diff --git a/tests/game3_trainer_sight_test.lua b/tests/engine/game3_trainer_sight_test.lua similarity index 74% rename from tests/game3_trainer_sight_test.lua rename to tests/engine/game3_trainer_sight_test.lua index 04bd0e5f..38ec07e6 100644 --- a/tests/game3_trainer_sight_test.lua +++ b/tests/engine/game3_trainer_sight_test.lua @@ -79,6 +79,7 @@ local eo = { py = 160, facing = "down", sight = 4, + trainerType = 1, elevation = 0, visible = true, hidden = false, @@ -206,12 +207,12 @@ scriptFired = false FieldEffects.invalidate() Objects.clearMovements() local trainer1 = { - localId = 1, cellX = 10, cellY = 8, px = 160, py = 128, facing = "down", sight = 4, + localId = 1, cellX = 10, cellY = 8, px = 160, py = 128, facing = "down", sight = 4, trainerType = 1, elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false, scriptKey = "trainer_battle_01", } local trainer2 = { - localId = 2, cellX = 12, cellY = 11, px = 192, py = 176, facing = "left", sight = 4, + localId = 2, cellX = 12, cellY = 11, px = 192, py = 176, facing = "left", sight = 4, trainerType = 1, elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false, scriptKey = "trainer_battle_02", } @@ -302,6 +303,7 @@ local sightTrainer = { py = 160, facing = "down", sight = 4, + trainerType = 1, elevation = 0, visible = true, hidden = false, @@ -320,6 +322,101 @@ TrainerSight.check(dummyGame) check(playedSeId == 21, "SE_PIN (21) played immediately on spot") check(playedSongId == 284 or playedSongId == 285 or playedSongId == 283, "Encounter theme started immediately on spot (song=" .. tostring(playedSongId) .. ")") +print("[test] 9. TRAINER_TYPE_NONE with leftover sight never engages (SSAnne_1F_Room2 Woman)") +scripts["ssanne_woman_msgbox"] = { + { op = "msgbox", text = "cruising" }, + { op = "end" }, +} +local function resetField() + Field.locked = false + FieldEffects.invalidate() + Objects.clearMovements() + playedSeId = nil + playedSongId = nil +end +local startedKey = nil +Space.startScript = function(key) startedKey = key end + +resetField() +local woman = { + localId = 3, cellX = 2, cellY = 6, px = 32, py = 96, facing = "up", + sight = 1, trainerType = 0, + elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false, + scriptKey = "ssanne_woman_msgbox", +} +Objects.clear() +Objects._byId[3] = woman +Objects._order = { 3 } +Player.reset(2, 5, "down") +check(TrainerSight.checkLineOfSight(woman, Player, dummyGame) == true, + "Woman's leftover range-1 cone covers the player") +check(TrainerSight.isTrainerType ~= nil and TrainerSight.isTrainerType(woman) == false, + "trainerType 0 is not a trainer") +check(TrainerSight.check(dummyGame) == false, "Idle sight check does not engage the Woman") +check(Field.locked == false, "Field stays unlocked") +check(woman.scriptBusy == false, "Woman not claimed") +check(playedSeId == nil and playedSongId == nil, "No SE_PIN / encounter music") +resetField() +woman.scriptBusy = false +woman.frozen = false +woman.moving = false +woman.facing = "up" +startedKey = nil +Player.reset(2, 5, "down") +check(TrainerSight.check(dummyGame, woman) == false, "Spinning-hook path does not engage the Woman") +check(Field.locked == false and woman.scriptBusy == false and playedSeId == nil, + "Spinning-hook path leaves the field alone") +resetField() +woman.scriptBusy = false +woman.frozen = false +woman.moving = false +woman.facing = "up" +startedKey = nil +Player.reset(2, 5, "down") +for _ = 1, 3 do TrainerSight.check(dummyGame) end +check(Field.locked == false and startedKey == nil, "Repeated idle checks never loop the msgbox") + +resetField() +local seeAll = { + localId = 4, cellX = 2, cellY = 6, px = 32, py = 96, facing = "up", + sight = 1, trainerType = 2, + elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false, + scriptKey = "trainer_battle_02", +} +Objects.clear() +Objects._byId[4] = seeAll +Objects._order = { 4 } +Player.reset(2, 5, "down") +check(TrainerSight.check(dummyGame) == false, "TRAINER_TYPE_SEE_ALL_DIRECTIONS (2) is not scanned") + +resetField() +local buried = { + localId = 6, cellX = 2, cellY = 6, px = 32, py = 96, facing = "up", + sight = 1, trainerType = 3, + elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false, + scriptKey = "trainer_battle_02", +} +Objects.clear() +Objects._byId[6] = buried +Objects._order = { 6 } +Player.reset(2, 5, "down") +check(TrainerSight.check(dummyGame) == true, "TRAINER_TYPE_BURIED (3) still engages") +check(Field.locked == true and buried.scriptBusy == true, "Buried trainer claims the field") + +resetField() +local normal = { + localId = 7, cellX = 2, cellY = 6, px = 32, py = 96, facing = "up", + sight = 1, def = { trainerType = 1 }, + elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false, + scriptKey = "trainer_battle_01", +} +Objects.clear() +Objects._byId[7] = normal +Objects._order = { 7 } +Player.reset(2, 5, "down") +check(TrainerSight.check(dummyGame) == true, "TRAINER_TYPE_NORMAL (1) from def engages") +resetField() + if failed > 0 then print(string.format("\n%d FAILURE(S)", failed)) os.exit(1) diff --git a/tests/engine/gen2_fishing_bite_gate_bug1368.lua b/tests/engine/gen2_fishing_bite_gate_bug1368.lua index 8594b4eb..b9abe84e 100644 --- a/tests/engine/gen2_fishing_bite_gate_bug1368.lua +++ b/tests/engine/gen2_fishing_bite_gate_bug1368.lua @@ -33,6 +33,7 @@ local function fakeMap(waterCell) return (x == waterCell[1] and y == waterCell[2]) and COLL_WATER or COLL_FLOOR end, + inBounds = function() return true end, } end diff --git a/tests/engine/gen2_link_cross_version_bug2382.lua b/tests/engine/gen2_link_cross_version_bug2382.lua new file mode 100644 index 00000000..8c52b289 --- /dev/null +++ b/tests/engine/gen2_link_cross_version_bug2382.lua @@ -0,0 +1,182 @@ +-- ../pokecrystal/engine/battle/effect_commands.asm:2644-2654 +-- ../pokecrystal/data/items/attributes.asm:149-150 +-- ../pokegold/data/items/attributes.asm:149-150 +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local Fingerprint = require("src.link.Fingerprint") +local Handshake = require("src.link.Handshake") +local Wire = require("src.link.Wire") +local GameVersion = require("src.core.GameVersion") +local Battle = require("src.battle.gen2.Battle") +local Damage = require("src.battle.gen2.Damage") +local Mon = require("src.battle.gen2.Mon") + +local function deepCopy(v) + if type(v) ~= "table" then return v end + local out = {} + for k, x in pairs(v) do out[k] = deepCopy(x) end + return out +end + +local function tmhmList(n) + local out = {} + for i = 1, n do out[i] = "MOVE_" .. i end + return out +end + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + FIGHTING = { id = "FIGHTING", index = 1, category = "physical" }, +} + +local function goldData() + return { + pokemon = { + growthRates = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, + }, + tmhmMoves = tmhmList(57), + MACHOP = { + id = "MACHOP", index = 66, name = "MACHOP", + baseStats = { hp = 70, attack = 80, defense = 50, speed = 35, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + }, + moves = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + }, + type_chart = { generation = 2, types = deepCopy(TYPES), matchups = {} }, + gen2HeldItems = { + ITEM_46 = { heldEffect = "HELD_NONE", heldParameter = 0 }, + ITEM_73 = { heldEffect = "HELD_NONE", heldParameter = 0 }, + LEFTOVERS = { heldEffect = "HELD_LEFTOVERS", heldParameter = 10 }, + }, + } +end + +local function crystalData() + local data = goldData() + data.pokemon.tmhmMoves = tmhmList(60) + data.pokemon.tutorMoves = { "FLAMETHROWER", "THUNDERBOLT", "ICE_BEAM" } + data.gen2HeldItems.ITEM_46 = nil + data.gen2HeldItems.ITEM_73 = nil + data.gen2HeldItems.CLEAR_BELL = { heldEffect = "HELD_NONE", heldParameter = 0 } + data.gen2HeldItems.GS_BALL = { heldEffect = "HELD_NONE", heldParameter = 0 } + return data +end + +do + local gold, crystal = goldData(), crystalData() + local g = Fingerprint.compute(gold, {}, 2) + local c = Fingerprint.compute(crystal, {}, 2) + T.eq(c, g, "Crystal's tutor list and renamed HELD_NONE slots do not split the digest") + + local helloG = Handshake.hello({ data = gold }) + local helloC = Handshake.hello({ data = crystal }) + T.eq(helloG.generation, 2, "the gold hello is gen 2") + T.eq((Handshake.checkCompat(helloG, helloC)), "full", + "a Gold hello meeting a Crystal hello pairs as full") + T.eq((Handshake.checkCompat(helloC, helloG)), "full", "and in reverse") +end + +do + local crystal = crystalData() + local base = Fingerprint.compute(crystal, {}, 2) + local armed = crystalData() + armed.gen2HeldItems.CLEAR_BELL = { heldEffect = "HELD_LEFTOVERS", + heldParameter = 10 } + T.check(Fingerprint.compute(armed, {}, 2) ~= base, + "a HELD_NONE slot given a real effect moves the digest") + local tuned = crystalData() + tuned.gen2HeldItems.LEFTOVERS.heldParameter = 20 + T.check(Fingerprint.compute(tuned, {}, 2) ~= base, + "a real held item's parameter still moves the digest") + local species = crystalData() + species.pokemon.MACHOP.baseStats.attack = 81 + T.check(Fingerprint.compute(species, {}, 2) ~= base, + "a species edit still moves the digest") +end + +do + local gold, crystal = goldData(), crystalData() + local cmons = Fingerprint.records(crystal, "pokemon", 2) + T.eq(cmons.tutorMoves, nil, "tutorMoves is not a species record") + T.eq(cmons.tmhmMoves, nil, "tmhmMoves is not a species record") + T.eq(cmons.growthRates, nil, "growthRates is not a species record") + T.check(cmons.MACHOP ~= nil, "a real species keeps its record") + local gheld = Fingerprint.records(gold, "held_items", 2) + T.check(gheld.ITEM_46 ~= nil, + "the per-record held map keeps HELD_NONE ids for party eligibility") + local cheld = Fingerprint.records(crystal, "held_items", 2) + T.eq(cheld.ITEM_46, nil, "Crystal has no ITEM_46 record") +end + +do + local detail = "dataset fingerprint differs: the room has 6f577c0127a1e94d, " + .. "you have c444a8971640c763" + local out = Wire.sanitize({ type = "join_error", reason = "profile_mismatch", + field = "fingerprint", detail = detail }) + T.eq(out.detail, detail, "the relay's mismatch detail survives the wire whole") + local long = string.rep("x", 120) + out = Wire.sanitize({ type = "join_error", reason = "x", detail = long }) + T.eq(out.detail, long, "a 120-byte detail survives") +end + +do + local data = goldData() + data.items = {} + local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } + perfect.hp = Mon.hpDV(perfect) + local function highRoll(n) return 99 % math.max(1, n or 1) end + + local seen + local realCalc = Damage.calc + Damage.calc = function(opts) + seen = opts + return realCalc(opts) + end + + local function reflectHit(version, link) + GameVersion.set(version) + local player = Mon.new(data, "MACHOP", 15, { dvs = perfect }) + player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local wild = Mon.new(data, "MACHOP", 15, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local battle = Battle.new({ data = data, party = { player }, wild = wild, + random = highRoll }) + player.stats.attack = 200 + wild.stats.defense = 512 + wild.maxHp, wild.hp = 999, 999 + battle.linkBattle = link or nil + battle.screens.enemy.reflect = 5 + seen = nil + battle:useMove(player, wild, "TACKLE") + battle:takeEvents() + return 999 - wild.hp, seen and seen.reflectOverflowFixed + end + + local restore = GameVersion.get() + local gold = reflectHit("gold", false) + local crystalSolo, soloFlag = reflectHit("crystal", false) + local crystalLink, linkFlag = reflectHit("crystal", true) + local goldLink = reflectHit("gold", true) + GameVersion.set(restore) + Damage.calc = realCalc + + T.check(gold > 0, "the Reflect hit lands") + T.eq(soloFlag, nil, "a single-player battle leaves the version's fix in charge") + T.check(crystalSolo < gold, "single-player Crystal keeps its Reflect fix") + T.eq(linkFlag, false, "a link battle forces the single truncation pass") + T.eq(crystalLink, gold, "a Crystal link battle computes Gold's Reflect damage") + T.eq(goldLink, gold, "and Gold's link battle is unchanged") +end + +T.finish("gen2 link cross version bug 2382") diff --git a/tests/engine/host_picker_async_bug2292.lua b/tests/engine/host_picker_async_bug2292.lua new file mode 100644 index 00000000..23640995 --- /dev/null +++ b/tests/engine/host_picker_async_bug2292.lua @@ -0,0 +1,326 @@ +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("mod import picker off the main thread (#2292)") +local check, eq = S.check, S.eq + +local Platform = require("src.core.Platform") +local HostPicker = require("src.core.HostPicker") +local RomImporter = require("src.import.RomImporter") + +love.system = love.system or {} +local saved = { + getOS = love.system.getOS, + pickFile = love.system.pickFile, + pickFileKinds = love.system.pickFileKinds, + thread = love.thread, + mouse = love.mouse, + popen = io.popen, + fsLoad = love.filesystem.load, +} + +local channels = {} +local function channel(name) + if channels[name] then return channels[name] end + local ch = { q = {} } + function ch:push(v) self.q[#self.q + 1] = v end + function ch:pop() return table.remove(self.q, 1) end + function ch:clear() self.q = {} end + function ch:performAtomic(fn, ...) return fn(self, ...) end + channels[name] = ch + return ch +end + +local started = {} +local threadStub = { + getChannel = channel, + newThread = function(path) + local th = { path = path } + function th:start(...) + self.args = { ... } + started[#started + 1] = self + end + function th:getError() return self.err end + return th + end, +} + +local popens = {} +local popenAnswer = "" +io.popen = function(command) + popens[#popens + 1] = command + return { + read = function() return popenAnswer end, + close = function() return true end, + } +end +love.mouse = nil + +local function useOS(name) + love.system.getOS = function() return name end + Platform._resetForTests() +end + +local function desktopImporter() + local ri = setmetatable({ + android = false, + workState = nil, + tab = "red", + ready = { red = true, blue = true }, + saveNotice = {}, + installs = {}, + browsers = 0, + }, RomImporter) + ri._installMod = function(self, path) self.installs[#self.installs + 1] = path end + ri._openModBrowser = function(self) self.browsers = self.browsers + 1 return true end + return ri +end + +local function run() + useOS("Windows") + love.thread = threadStub + HostPicker._reset() + started, popens = {}, {} + local ri = desktopImporter() + ri:chooseMod() + eq(#popens, 0, "Import mod .zip spawns no process on the main thread") + eq(#started, 1, "one picker worker starts") + check(ri._hostPick ~= nil, "the importer remembers the open picker") + eq(#ri.installs, 0, "nothing is installed while the dialog is open") + check(ri.modNotice and ri.modNotice.ok == true + and tostring(ri.modNotice.text):find("file dialog", 1, true) ~= nil + and tostring(ri.modNotice.text):find("taskbar", 1, true) == nil, + "the Mods tab says the dialog is open without pointing at a missing taskbar button") + eq(ri.tab, "mods", "the Mods tab stays in front") + + local th = started[1] + eq(th and th.path, "src/core/host_picker_worker.lua", "the worker is the host picker") + local commands = th and th.args[2] or {} + eq(type(commands), "table", "the worker receives the command list") + local command = commands[1] or "" + check(command:find("powershell", 1, true) == 1, "the worker runs the PowerShell dialog") + check(command:find("$o.TopMost=$true;", 1, true) ~= nil, + "the dialog gets a TopMost owner form") + check(command:find("ShowDialog($o)", 1, true) ~= nil, + "the dialog is shown owned by that form") + check(command:find("ShowDialog()", 1, true) == nil, "no ownerless ShowDialog") + local body = command:match('%-Command "(.*)"$') or "" + check(body ~= "" and body:find('"', 1, true) == nil, + "the -Command body has no double quote to break the cmd wrapper") + + ri:chooseMod() + eq(#started, 1, "a second click while the dialog is open starts nothing") + local ran = 0 + ri:runActions({ { key = "rom-red", fn = function() ran = ran + 1 end } }) + eq(ran, 0, "launcher clicks are dropped while the dialog is open") + ri:play("red", true) + eq(ri._launchFade, nil, "Play does not start while the dialog is open") + eq(#popens, 0, "no second dialog opens on the main thread") + + ri:_pumpHostPick() + check(ri._hostPick ~= nil, "no answer yet: still waiting") + + channel(HostPicker.RESULT):push({ id = th.args[1], + output = "C:/Temp/pokeport_mod_pick.zip" }) + ri:_pumpHostPick() + eq(ri._hostPick, nil, "the answer clears the pending pick") + eq(#ri.installs, 1, "the pick is installed once") + eq(ri.installs[1], "C:/Temp/pokeport_mod_pick.zip", "with the path the worker returned") + eq(#popens, 0, "and the main thread still spawned nothing") + + started = {} + ri = desktopImporter() + ri:chooseMod() + channel(HostPicker.RESULT):push({ id = started[1].args[1] }) + ri:_pumpHostPick() + eq(ri._hostPick, nil, "a cancelled dialog clears the pending pick") + eq(#ri.installs, 0, "and installs nothing") + eq(ri.browsers, 0, "and does not open the fallback browser over the cancel") + eq(ri.modNotice, nil, "the waiting notice is cleared") + + started = {} + ri = desktopImporter() + ri:chooseMod() + started[1].err = "boom" + ri:_pumpHostPick() + eq(ri._hostPick, nil, "a dead worker clears the pending pick") + eq(ri.browsers, 1, "and falls back to the in-app browser") + + love.thread = nil + HostPicker._reset() + started, popens = {}, {} + popenAnswer = "C:\\Temp\\pokeport_mod_pick.zip\r\n" + ri = desktopImporter() + ri:chooseMod() + eq(#started, 0, "without threads no worker is started") + eq(#popens, 1, "the picker runs synchronously instead") + eq(ri.installs[1], "C:\\Temp\\pokeport_mod_pick.zip", "and its pick is installed") + popenAnswer = "" + + love.thread = threadStub + local savedLoaded = {} + for _, m in ipairs({ "love.thread", "love.filesystem", "love.system" }) do + savedLoaded[m] = package.loaded[m] + package.loaded[m] = package.loaded[m] or true + end + love.filesystem.load = function(path) return loadfile(path) end + popens = {} + local answers = { "", " /home/p/mod.zip\n" } + io.popen = function(cmd) + popens[#popens + 1] = cmd + local a = answers[#popens] or "" + return { read = function() return a end, close = function() return true end } + end + useOS("Linux") + local worker = assert(loadfile("src/core/host_picker_worker.lua")) + worker(42, { "zenity x", "kdialog y" }, "picker_test_chan") + local msg = channel("picker_test_chan"):pop() + check(msg ~= nil, "the worker posts one result") + eq(msg and msg.id, 42, "tagged with its job id") + eq(msg and msg.output, "/home/p/mod.zip", "the first non-empty answer, trimmed") + eq(#popens, 2, "an empty first answer tries the next command") + for m, v in pairs(savedLoaded) do package.loaded[m] = v end + love.filesystem.load = saved.fsLoad + + for _, path in ipairs({ "src/import/RomImporter.lua", "src/core/FilePicker.lua", + "tools/save-editor/SaveIO.lua" }) do + local f = io.open(path, "rb") + local src = f and f:read("*a") or "" + if f then f:close() end + check(src ~= "", path .. " is readable") + check(src:find("ShowDialog()", 1, true) == nil, + path .. " has no ownerless ShowDialog()") + check(src:find("ShowDialog($o)", 1, true) ~= nil + or src:find("HostPicker.WIN_SHOW", 1, true) ~= nil, + path .. " shows its dialog owned by a TopMost form") + end + check(HostPicker.WIN_OPEN_DIALOG:find('"', 1, true) == nil + and HostPicker.WIN_SHOW:find('"', 1, true) == nil, + "the shared dialog prefix is double-quote free") +end + +local function androidRun() + useOS("Android") + love.thread = nil + local pickCalls = {} + love.system.pickFile = function(kind) + pickCalls[#pickCalls + 1] = kind or "rom" + return true + end + love.system.pickFileKinds = function() return "rom,mod,sav" end + local function androidImporter() + local ri = setmetatable({ + android = true, + workState = nil, + tab = "mods", + ready = { red = true, blue = true }, + saveNotice = {}, + installs = {}, + }, RomImporter) + ri._installMod = function(self, source) + self.installs[#self.installs + 1] = source + self.modNotice = { ok = true, text = "Installed test" } + end + ri._refreshMods = function() end + return ri + end + + love.filesystem.write("foo.zip", "PK\0junk") + local ri = androidImporter() + ri:chooseMod() + eq(#ri.installs, 0, "a stray .zip at the save root is not installed on tap") + eq(#pickCalls, 1, "the tap opens the picker instead") + eq(pickCalls[1], "mod", "asking for a mod archive") + check(love.filesystem.getInfo("foo.zip") ~= nil, "the player's file is left alone") + love.filesystem.remove("foo.zip") + + pickCalls = {} + love.filesystem.write("picked_mod.zip", "PK\0saf") + ri = androidImporter() + ri:chooseMod() + eq(ri.installs[1], "picked_mod.zip", "the SAF drop picked_mod.zip still installs on tap") + eq(#pickCalls, 0, "without reopening the picker") + check(love.filesystem.getInfo("picked_mod.zip") == nil, "and is retired") + + pickCalls = {} + ri = androidImporter() + ri:chooseMod() + eq(#pickCalls, 1, "the picker is asked to open") + for _ = 1, 200 do ri:_pumpModPickOpen(1 / 60) end + eq(ri.modNotice, nil, "no notice before four seconds") + for _ = 1, 60 do ri:_pumpModPickOpen(1 / 60) end + check(ri.modNotice and ri.modNotice.ok == false, + "four seconds with no focus loss reports the picker did not open") + local text = ri.modNotice and tostring(ri.modNotice.text) or "" + check(text:find("did not open", 1, true) ~= nil, "saying so") + check(text:find(love.filesystem.getSaveDirectory() .. "/imports/mods/", 1, true) ~= nil, + "and pointing at the imports/mods folder") + local shown = ri.modNotice + for _ = 1, 600 do ri:_pumpModPickOpen(1 / 60) end + eq(ri.modNotice, shown, "the notice is shown once") + + love.filesystem.write("imports/mods/fix.zip", "PK\0inbox") + pickCalls = {} + local before = #ri.installs + ri:chooseMod() + eq(#pickCalls, 0, "after a stalled picker the next tap reads imports/mods") + eq(#ri.installs, before, "the inbox install waits a frame") + check(ri.modNotice and tostring(ri.modNotice.text):find("imports/mods", 1, true) ~= nil, + "so the installing notice is drawn first") + ri:chooseMod() + eq(#pickCalls, 0, "a tap while the inbox install is queued opens nothing") + ri:_pumpModInboxInstall() + eq(#ri.installs, before, "still waiting on the frame the tap landed") + ri:_pumpModInboxInstall() + eq(ri.installs[#ri.installs], "imports/mods/fix.zip", "and installs the inbox copy") + pickCalls = {} + ri:chooseMod() + eq(#pickCalls, 1, "the tap after that opens the picker again") + love.filesystem.remove("imports/mods/fix.zip") + + ri = androidImporter() + ri:chooseMod() + for _ = 1, 30 do ri:_pumpModPickOpen(1 / 60) end + ri:focus(false) + for _ = 1, 600 do ri:_pumpModPickOpen(1 / 60) end + eq(ri.modNotice, nil, "an opened picker (focus lost) never reports a failure") + + ri = androidImporter() + ri:chooseMod() + ri:_pumpModPickOpen(30) + eq(ri.modNotice, nil, "one huge dt does not fire the notice") + + love.system.pickFile = function() return false end + ri = androidImporter() + ri:chooseMod() + check(ri.modNotice and ri.modNotice.ok == false + and tostring(ri.modNotice.text):find("/imports/mods/", 1, true) ~= nil, + "a refused picker points at imports/mods") + + useOS("iOS") + love.system.pickFile = function() return true end + ri = androidImporter() + ri:chooseMod() + for _ = 1, 600 do ri:_pumpModPickOpen(1 / 60) end + eq(ri.modNotice, nil, "iOS never reports a picker that did not open") +end + +local ok, err = pcall(run) +if not ok then check(false, "desktop suite raised: " .. tostring(err)) end +ok, err = pcall(androidRun) +if not ok then check(false, "android suite raised: " .. tostring(err)) end + +love.system.getOS = saved.getOS +love.system.pickFile = saved.pickFile +love.system.pickFileKinds = saved.pickFileKinds +love.thread = saved.thread +love.mouse = saved.mouse +io.popen = saved.popen +love.filesystem.load = saved.fsLoad +Platform._resetForTests() +HostPicker._reset() +love.filesystem.remove("foo.zip") +love.filesystem.remove("picked_mod.zip") +love.filesystem.remove("imports/mods/fix.zip") + +S.finish() diff --git a/tests/engine/intro_og_obj_palette_bug2264.lua b/tests/engine/intro_og_obj_palette_bug2264.lua index d0c61c0b..12591288 100644 --- a/tests/engine/intro_og_obj_palette_bug2264.lua +++ b/tests/engine/intro_og_obj_palette_bug2264.lua @@ -94,15 +94,31 @@ do end do - local r = drawFight("ogred", "red", function(m) m.fade = 0.5 end) - eq(#r, 3, "GBFadeOutToWhite adds a white wash over the replayed layer") - local w = r[3] or {} - eq(w.color and w.color[4], 0.5, "the wash carries the fade alpha") - eq(w.sx, 160, "the wash spans the playfield width") - eq(w.sy, 80, "the wash spans the playfield height") - eq(w.x, 0, "the wash starts at the playfield left") - eq(w.y, 32, "the wash starts under the top bar") - check(sameClip(w.clip), "the wash is clipped to the playfield") + local W, OBJ, BG = PaletteFX.GBC_OBJ[1], PaletteFX.GBC_OBJ, PaletteFX.GBC_BG + local r, b = drawFight("ogred", "red", function(m) m.fadeStep = 1 end) + eq(#r, 2, "FadePal6 replays only the two layers, no white wash") + local nb, gb + for _, x in ipairs(b) do + if x.group == "gbcobj128" then nb = x end + if x.group == "ogbg144" then gb = x end + end + check(nb and nb.colors[2] == W and nb.colors[3] == W and nb.colors[4] == OBJ[3], + "FadePal6 rOBP0 $80 bakes Nidorino c1/c2 white, c3 OBJ shade 2") + check(gb and gb.colors[2] == BG[1] and gb.colors[3] == BG[2] and gb.colors[4] == BG[3], + "FadePal6 rBGP $90 bakes Gengar c1 white, c2 shade 1, c3 shade 2") + r, b = drawFight("ogred", "red", function(m) m.fadeStep = 2 end) + nb = nil + for _, x in ipairs(b) do if x.group == "gbcobj64" then nb = x end end + check(nb and nb.colors[2] == W and nb.colors[3] == W and nb.colors[4] == OBJ[2], + "FadePal7 rOBP0 $40 leaves only c3 at OBJ shade 1") + r, b = drawFight("gbc", "red", function(m) m.fadeStep = 1 end) + eq(#r, 0, "SGB fade records no replay") + local grp = {} + for _, x in ipairs(b) do grp[x.group] = x end + check(grp.fadeobp128 and grp.fadeobp128.colors[4] == PaletteFX.GRAYS[3], + "SGB fade bakes Nidorino through rOBP0 $80 into DMG grays") + check(grp.fadebgp144 and grp.fadebgp144.colors[3] == PaletteFX.GRAYS[2], + "SGB fade bakes Gengar through rBGP $90 into DMG grays") end do diff --git a/tests/engine/intro_splash_fight_timing_bug2385.lua b/tests/engine/intro_splash_fight_timing_bug2385.lua new file mode 100644 index 00000000..6b42e270 --- /dev/null +++ b/tests/engine/intro_splash_fight_timing_bug2385.lua @@ -0,0 +1,208 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Sound = require("src.core.Sound") +local Timing = require("src.core.Timing") +local IntroMovie = require("src.ui.IntroMovie") + +local realPlay = Sound.play +Sound.play = function() end + +local WAVES_START, SPLASH_FRAMES = 134, 318 + +local function newGame() + local input = { pressed = {}, down = {} } + function input:wasPressed(b) return self.pressed[b] or false end + function input:isDown(b) return self.down[b] or false end + return { + input = input, + data = { field = { intro = { + fallingStar = { path = "tests/fixtures/missing_fallingstar_2385.png" }, + fallingStarBlink = { path = "tests/fixtures/missing_fallingstarblink_2385.png" }, + } } }, + stack = { pop = function() end }, + } +end + +local function starDraws(m, t) + m.phase, m.timer = 2, t + local calls = {} + local realDraw = love.graphics.draw + love.graphics.draw = function(img, x, y, ...) + if img == m.smallStar or img == m.smallStarBlink then + calls[#calls + 1] = { img = img, x = x, y = y } + end + end + local ok, err = pcall(m.drawSplash, m) + love.graphics.draw = realDraw + check(ok, "drawSplash runs headless" .. (ok and "" or (": " .. tostring(err)))) + return calls +end + +local function at(calls, x) + for _, c in ipairs(calls) do if c.x == x then return c end end +end + +do + local m = IntroMovie.new(newGame()) + check(m.smallStar and m.smallStarBlink and m.smallStar ~= m.smallStarBlink, + "both falling-star looks load") + local c = starDraws(m, WAVES_START) + local s = at(c, 40) + eq(s and s.y, 89, "wave 1 first shows one row below $68 (MoveDownSmallStars runs before the delay)") + eq(s and s.img, m.smallStarBlink, "first substep shows rOBP1 $04 (toggled once before the delay)") + s = at(starDraws(m, WAVES_START + 3), 40) + eq(s and s.y, 90, "second substep moves one more row") + eq(s and s.img, m.smallStar, "second substep is back on rOBP1 $A4") + s = at(starDraws(m, WAVES_START + 24), 48) + eq(s and s.y, 89, "wave 2 enters one row below $68 on its first substep") + s = at(starDraws(m, SPLASH_FRAMES - 1), 52) + eq(s and s.y, 88 + 48 - 24, "wave 4 rests after 24 moves") + eq(s and s.img, m.smallStar, "the 40-frame tail holds rOBP1 $A4 after 48 toggles") +end + +local function fightMovie() + local game = newGame() + local m = IntroMovie.new(game) + m:startPhase(3) + return m, game.input +end + +local function tick(m, input, n, press) + for i = 1, n or 1 do + input.pressed = (i == 1 and press) or {} + m:update(1 / 60) + end + input.pressed = {} +end + +local function runToOp(m, input, idx) + for _ = 1, 2000 do + if m.opIndex >= idx then return true end + tick(m, input, 1) + end + return false +end + +local function fadeRun(m, input) + local steps = {} + for _ = 1, 40 do + tick(m, input, 1) + if m.phase == 4 then steps[#steps + 1] = 4 break end + steps[#steps + 1] = m.fadeStep or 0 + end + return steps +end + +local function isStepped(steps) + if #steps ~= 24 then return false end + for i = 1, 23 do + if steps[i] ~= math.floor((i - 1) / 8) + 1 then return false end + end + return steps[24] == 4 +end + +do + local m, input = fightMovie() + tick(m, input, Timing.DELAY3, { a = true }) + eq(m.opIndex, 1, "A during PlayShootingStar's Delay3 is not polled") + eq(m.fadeStep, nil, "the deaf Delay3 starts no fade") + tick(m, input, 1) + local x = m.gengarX + tick(m, input, 1, { a = true }) + eq(m.gengarX, x, "scroll-in odd frame moves nothing") + local steps = fadeRun(m, input) + check(isStepped(steps), "an interrupted scroll-in still runs GBFadeOutToWhite: 8 frames FadePal6, 8 FadePal7, then white (" + .. table.concat(steps, ",") .. ")") +end + +do + local m, input = fightMovie() + check(runToOp(m, input, 3), "reached the first hip animation") + tick(m, input, 1, { a = true }) + tick(m, input, 3) + eq(m.fadeStep, nil, "AnimateIntroNidorino's DelayFrames 5 is deaf") + eq(m.phase, 3, "the fight goes on after a press inside an animation") +end + +do + local m, input = fightMovie() + check(runToOp(m, input, 6), "reached the 10-frame wait") + tick(m, input, 1, { start = true }) + check(isStepped(fadeRun(m, input)), "START inside a CheckForUserInterruption wait ends the scene into the stepped fade") +end + +do + local m, input = fightMovie() + check(runToOp(m, input, 12), "reached the raise pose") + local x = m.gengarX + tick(m, input, 1) + eq(m.opIndex, 14, "pose and SFX_INTRO_RAISE chain into the move on one frame") + eq(m.gengarX, x - 2, "the raise's first SCX step lands on that frame") + tick(m, input, 1, { a = true }) + eq(m.gengarX, x - 2, "a press on the step's second poll frame stops the move") + eq(m.opIndex, 15, "IntroMoveMon returns early and the scene moves on to the next wait") + eq(m.fadeStep, nil, "an interrupted Gengar move does not end the scene") +end + +do + local m, input = fightMovie() + check(runToOp(m, input, 6), "reached the 10-frame wait again") + input.down = { b = true } + tick(m, input, 2) + eq(m.fadeStep, nil, "B alone never interrupts") + input.down = { up = true, select = true, b = true } + tick(m, input, 1) + input.down = {} + check(isStepped(fadeRun(m, input)), "holding exactly UP+SELECT+B interrupts (home/overworld.asm:2404)") +end + +do + local m, input = fightMovie() + m.lastPollHeld = { a = true } + input.down = { up = true, select = true, b = true, a = true } + check(runToOp(m, input, 6), "reached the wait with A held too") + tick(m, input, 2) + eq(m.fadeStep, nil, "UP+SELECT+B with another button held is not the combo") +end + +do + local m, input = fightMovie() + check(runToOp(m, input, 3), "reached the first hip animation for the held press") + input.down = { a = true } + tick(m, input, 3) + eq(m.fadeStep, nil, "A pressed and held inside a deaf animation is not seen there") + check(runToOp(m, input, 6), "reached the next CheckForUserInterruption wait") + tick(m, input, 1) + input.down = {} + check(isStepped(fadeRun(m, input)), + "A still held at the next poll is new against hJoyLast and interrupts (home/joypad2.asm:16)") +end + +do + local m, input = fightMovie() + m.lastPollHeld = { a = true } + input.down = { a = true } + check(runToOp(m, input, 7), "A held since the previous poll runs straight through the wait") + eq(m.fadeStep, nil, "a button already held at the last poll is not a new press") + input.down = {} +end + +local function modMovie() + local game = newGame() + game.data.field.intro.gamefreakLogo = { path = "mods/demo/gamefreak_logo.png" } + game.data.field.intro.gamefreakText = { path = "tests/fixtures/missing_gftext_2385.png" } + return IntroMovie.new(game) +end + +do + local m = modMovie() + eq(m.logoPath, nil, "mod-supplied logo art keeps the raw draw path") + eq(m.gfTextPath, "tests/fixtures/missing_gftext_2385.png", "ROM text art still bakes through rOBP0") +end + +Sound.play = realPlay +T.finish("intro_splash_fight_timing_bug2385") diff --git a/tests/engine/intro_splash_palette_bug2385.lua b/tests/engine/intro_splash_palette_bug2385.lua new file mode 100644 index 00000000..0a478ce3 --- /dev/null +++ b/tests/engine/intro_splash_palette_bug2385.lua @@ -0,0 +1,208 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local GameVersion = require("src.core.GameVersion") +local PaletteFX = require("src.render.PaletteFX") +local SpriteRenderer = require("src.render.SpriteRenderer") +local IntroMovie = require("src.ui.IntroMovie") + +local prevVersion, prevMode = GameVersion.get(), PaletteFX.mode + +local STAR_START, FLASH_START = 64, 104 +local WAVES_START, SPLASH_FRAMES = 134, 318 + +local LOGO = "tests/fixtures/missing_gflogo_2385.png" +local TEXT = "tests/fixtures/missing_gftext_2385.png" +local STAR = "tests/fixtures/missing_bigstar_2385.png" + +local function pal(n) + return { { 255, 239, 255 }, { n, 200, 100 }, { n, 90, 40 }, { 16, 16, 16 } } +end +local PALS = { palettes = { + GAMEFREAK = pal(1), REDMON = pal(2), VIRIDIAN = pal(3), BLUEMON = pal(4), +} } + +local function newGame(studio) + return { + data = { palettes = PALS, field = { intro = { + gamefreakLogo = { path = LOGO }, + gamefreakText = { path = TEXT }, + bigStar = { path = STAR }, + studio = studio, + } } }, + stack = { pop = function() end }, + } +end + +local bakes = {} +local realObp = SpriteRenderer.obpImage +SpriteRenderer.obpImage = function(path, colors, group) + bakes[#bakes + 1] = { path = path, colors = colors, group = group } + return realObp(path, colors, group) +end + +local function sameRamp(a, b) + if not (a and b) then return false end + for i = 1, 4 do + if a[i] ~= b[i] then return false end + end + return true +end + +local function ramp(base, i1, i2, i3) + return { base[1], base[i1 + 1], base[i2 + 1], base[i3 + 1] } +end + +local function bakeFor(list, path, group) + for _, b in ipairs(list) do + if b.path == path and b.group == group then return b end + end +end + +local function drawSplash(mode, version, timer) + GameVersion.set(version) + PaletteFX.setMode(mode) + local movie = IntroMovie.new(newGame()) + movie.phase = 2 + movie.timer = timer + local before = #bakes + PaletteFX.clearSpriteRedraws() + PaletteFX.setPass("ui") + local ok, err = pcall(movie.draw, movie) + PaletteFX.setPass(nil) + check(ok, mode .. "/" .. version .. " splash draw runs headless" .. (ok and "" or (": " .. tostring(err)))) + local out, newBakes = {}, {} + for i, r in ipairs(PaletteFX.uiSpriteRedraws()) do out[i] = r end + for i = before + 1, #bakes do newBakes[#newBakes + 1] = bakes[i] end + PaletteFX.clearSpriteRedraws() + return out, newBakes, movie +end + +do + local game = newGame() + for _, v in ipairs({ "red", "blue" }) do + GameVersion.set(v) + PaletteFX.setMode("ogred") + local movie = IntroMovie.new(game) + local z = movie:sgbPalettes(game) + check(z and z[1], "ogred/" .. v .. ": the copyright card has palette zones") + check(z and z[1] and sameRamp(z[1].colors, PaletteFX.ogBg()), + "ogred/" .. v .. ": the copyright card wears the boot-ROM BG ramp") + PaletteFX.setMode("gbc") + z = movie:sgbPalettes(game) + eq(z and #z, 4, "gbc/" .. v .. ": the copyright card gets PalPacket_GameFreakIntro's four zones") + movie.phase = 2 + local z2 = movie:sgbPalettes(game) + check(z and z2 and sameRamp(z[1].colors, z2[1].colors), + "gbc/" .. v .. ": the card and the splash share PAL_GAMEFREAK") + end + GameVersion.set("red") + PaletteFX.setMode("ogred") + local mod = newGame() + mod.data.field.intro.studio = { card = "A MOD" } + local movie = IntroMovie.new(mod) + eq(movie:sgbPalettes(mod), nil, "a studio text card stays untinted") +end + +do + local movie = IntroMovie.new(newGame()) + local function at(t) + movie.timer = t + return movie.logoObp0 and movie:logoObp0() + end + eq(at(STAR_START), 0xF9, "rOBP0 starts at $F9") + eq(at(FLASH_START - 1), 0xF9, "rOBP0 holds $F9 until the flash loop") + eq(at(FLASH_START), 0x7E, "first rrc rrc gives $7E") + eq(at(FLASH_START + 9), 0x7E, "$7E holds 10 frames") + eq(at(FLASH_START + 10), 0x9F, "second rotation gives $9F") + eq(at(FLASH_START + 20), 0xE7, "third rotation gives $E7") + eq(at(WAVES_START + 10), 0xE7, "$E7 stays through the small-star waves") + eq(at(SPLASH_FRAMES - 1), 0xE7, "$E7 stays through the 40-frame tail") +end + +do + local G = PaletteFX.GRAYS + local _, b = drawSplash("gbc", "red", STAR_START + 5) + local logo = bakeFor(b, LOGO, "gfobp" .. 0xF9) + check(logo and sameRamp(logo.colors, ramp(G, 2, 3, 3)), + "$F9 draws the logo's colour 2 as black on the canvas") + local text = bakeFor(b, TEXT, "gfobp" .. 0xF9) + check(text and sameRamp(text.colors, ramp(G, 2, 3, 3)), + "$F9 draws the text's colour 3 as black on the canvas") + local star = bakeFor(b, STAR, "gfobp" .. 0xA4) + check(star and sameRamp(star.colors, ramp(G, 1, 2, 2)), + "rOBP1 $A4 draws the big star's colour 3 as shade 2") + _, b = drawSplash("gbc", "red", FLASH_START + 5) + logo = bakeFor(b, LOGO, "gfobp" .. 0x7E) + check(logo and sameRamp(logo.colors, ramp(G, 3, 3, 1)), + "$7E: logo black, text light") + _, b = drawSplash("gbc", "red", FLASH_START + 15) + logo = bakeFor(b, LOGO, "gfobp" .. 0x9F) + check(logo and sameRamp(logo.colors, ramp(G, 3, 1, 2)), + "$9F: logo light, text dark") + _, b = drawSplash("og", "red", WAVES_START + 10) + logo = bakeFor(b, LOGO, "gfobp" .. 0xE7) + check(logo and sameRamp(logo.colors, ramp(G, 1, 2, 3)), + "$E7 settles the logo on shade 2 and the text on black") +end + +do + local O = PaletteFX.GBC_OBJ + local r, b = drawSplash("ogred", "red", STAR_START + 5) + eq(#r, 3, "ogred red: logo, text and big star are replayed over the zone pass") + local logo, text, star = r[1] or {}, r[2] or {}, r[3] or {} + eq(logo.x, 72, "logo replays at x 72") + eq(logo.y, 56, "logo replays at y 56") + eq(text.x, 40, "text replays at x 40") + eq(text.y, 80, "text replays at y 80") + local lb = bakeFor(b, LOGO, "gfobj" .. 0xF9) + check(lb and sameRamp(lb.colors, { O[1], O[3], O[4], O[4] }), + "ogred red: the logo starts black through OBJ0") + eq(logo.image, SpriteRenderer.obpImage(LOGO, lb and lb.colors or O, "gfobj" .. 0xF9), + "the replayed logo is the OBJ0 bake") + local sb = bakeFor(b, STAR, "gfobj1" .. 0xA4) + check(sb and sameRamp(sb.colors, ramp(PaletteFX.GBC_BG, 1, 2, 2)), + "ogred red: the big star replays through OBJ1 = the BG ramp") + eq(star.image, SpriteRenderer.obpImage(STAR, sb and sb.colors or O, "gfobj1" .. 0xA4), + "the big star replays last so it stays over the logo") + + r, b = drawSplash("ogred", "red", WAVES_START + 10) + eq(#r, 2, "ogred red: after the star, only logo and text replay") + lb = bakeFor(b, LOGO, "gfobj" .. 0xE7) + check(lb and sameRamp(lb.colors, { O[1], O[2], O[3], O[4] }), + "ogred red: $E7 leaves the logo dark green and the text black") + _, b = drawSplash("ogred", "red", FLASH_START + 5) + lb = bakeFor(b, TEXT, "gfobj" .. 0x7E) + check(lb and sameRamp(lb.colors, { O[1], O[4], O[4], O[2] }), + "ogred red: the first flash turns the text light green") +end + +do + local OB = PaletteFX.GBC_OBJ_BLUE + local r, b = drawSplash("ogred", "blue", WAVES_START + 10) + eq(#r, 2, "ogred blue: logo and text replay") + local lb = bakeFor(b, LOGO, "gfobj_blue" .. 0xE7) + check(lb and sameRamp(lb.colors, { OB[1], OB[2], OB[3], OB[4] }), + "ogred blue: the logo bakes Blue's pink OBJ0 under its own group") + check(not bakeFor(b, LOGO, "gfobj" .. 0xE7), "ogred blue never bakes into the red group") +end + +do + for _, mode in ipairs({ "gbc", "og", "classic", "redpp" }) do + local r = drawSplash(mode, "red", WAVES_START + 10) + eq(#r, 0, mode .. " records no splash OBJ replay") + end + local r = drawSplash("ogred", "yellow", WAVES_START + 10) + eq(#r, 0, "OG YELLOW keeps the CGB zone path") + r = drawSplash("ogred", "red", 0) + eq(#r, 0, "nothing replays before the star") +end + +SpriteRenderer.obpImage = realObp +GameVersion.set(prevVersion) +PaletteFX.setMode(prevMode) + +T.finish() diff --git a/tests/engine/jessie_james_mtmoon_walk.lua b/tests/engine/jessie_james_mtmoon_walk.lua index 21fbf14e..0fbb588f 100644 --- a/tests/engine/jessie_james_mtmoon_walk.lua +++ b/tests/engine/jessie_james_mtmoon_walk.lua @@ -129,11 +129,169 @@ check(faceJessie ~= nil and rows[faceJessie][3] == "down", check(faceJames ~= nil and rows[faceJames][3] == "left", "Script11 leaves James facing LEFT at the player") -local facePlayer = indexOf(rows, isRow("face_player_dir")) -check(facePlayer ~= nil and rows[facePlayer][2] == "up", - "the player turns up to the duo, not right") -local emote = indexOf(rows, isRow("emote", "player")) -check(emote ~= nil, "the player gets the exclamation bubble TEXT12 shows") +-- scripts/MtMoonB2F.asm:446 +local function rowsWithPlayer(site, x, y, flags, player) + local captured = nil + local ow = { player = player, + runner = { run = function(_, r) captured = r end } } + sites[site].onStep({ save = { flags = flags or {} } }, ow, x, y) + return captured, ow +end + +local SITES = { + { site = "MT_MOON_B2F", x = 3, y = 5, flags = armed, + motto = "_MtMoonJessieJamesText1", parting = "_MtMoonJessieJamesText4", + challenge = "_MtMoonJessieJamesText2", face = "up", duo = { 2, 6 } }, + { site = "ROCKET_HIDEOUT_B4F", x = 24, y = 14, flags = {}, + motto = "_RocketHideoutJessieJamesText1", + parting = "_RocketHideoutJessieJamesText4", + challenge = "_RocketHideoutJessieJamesText2", face = "up", duo = { 2, 3 } }, + { site = "POKEMON_TOWER_7F", x = 10, y = 12, flags = {}, + motto = "_PokemonTowerJessieJamesText1", + parting = "_PokemonTowerJessieJamesText4", + challenge = "_PokemonTowerJessieJamesText2", face = "up", duo = { 1, 2 } }, + { site = "SILPH_CO_11F", x = 3, y = 3, + flags = { EVENT_BEAT_SILPH_CO_GIOVANNI = true }, + motto = "_SilphCoJessieJamesText1", parting = "_SilphCoJessieJamesText4", + challenge = "_SilphCoJessieJamesText2", face = "down", duo = { 4, 6 } }, +} + +for _, s in ipairs(SITES) do + local player = { facing = "right" } + local sr, sow = rowsWithPlayer(s.site, s.x, s.y, s.flags, player) + sr = sr or {} + local m = indexOf(sr, isRow("show_text", s.motto)) + check(m ~= nil, s.site .. " plays the motto") + m = m or 1 + local armedRow = sr[m - 1] + local auto = armedRow and armedRow[1] == "text_opts" + and type(armedRow[2]) == "table" and armedRow[2].auto + check(type(auto) == "table", s.site .. " motto box is armed no-wait") + auto = type(auto) == "table" and auto or {} + eq(auto.delay, 91, s.site .. " motto box closes 10+60+1+20 frames after typing") + check(not auto.wait, s.site .. " motto box never waits for A/B") + check(type(auto.tick) == "function", s.site .. " motto box drives the bubble") + + local firstWalk = indexOf(sr, isRow("walk_npc")) or #sr + check(indexOf(sr, isRow("face_player_dir")) == nil + or indexOf(sr, isRow("face_player_dir")) > firstWalk, + s.site .. " has no separate turn row after the box closes") + check(indexOf(sr, isRow("emote")) == nil, + s.site .. " has no separate bubble row after the box closes") + + if type(auto.tick) == "function" then + local early, during, late = true, true, true + for t = 1, 91 do + auto.tick() + if t <= 10 and sow.emote ~= nil then early = false end + if t >= 11 and t <= 70 then + if not (sow.emote and sow.emote.npc == player) + or player.facing ~= "right" then during = false end + end + if t >= 71 and (sow.emote ~= nil or player.facing ~= s.face) then + late = false + end + end + check(early, s.site .. " DelayFrames 10 before the bubble") + check(during, s.site .. " bubble over the player for 60 frames, old facing kept") + check(late, s.site .. " bubble gone, player turned " .. s.face .. " with the box still up") + end + + local ch = indexOf(sr, isRow("show_text", s.challenge)) or #sr + local fastOk, faceOk, walks, faces = true, true, 0, 0 + for i = 1, ch do + local r = sr[i] + if r[1] == "walk_npc" and r[2] ~= "player" then + walks = walks + 1 + if not (type(r[4]) == "table" and r[4].stepFrames == 16) then + fastOk = false + end + elseif r[1] == "walk_npc" and r[2] == "player" then + if type(r[4]) == "table" and r[4].stepFrames then fastOk = false end + elseif r[1] == "face_object" and i > firstWalk then + faces = faces + 1 + if not (type(r[4]) == "table" and (r[4].hold or 0) >= 256) then + faceOk = false + end + end + end + eq(walks, 2, s.site .. " walks both of the duo") + check(fastOk, s.site .. " duo walks at the player's 16-frame step") + eq(faces, 2, s.site .. " faces both of the duo after their walks") + check(faceOk, s.site .. " post-walk facings hold past the STAY turn timer") + + local p = indexOf(sr, isRow("show_text", s.parting)) + check(p ~= nil, s.site .. " plays the parting line") + p = p or 1 + local pArm = sr[p - 1] + local pAuto = pArm and pArm[1] == "text_opts" and type(pArm[2]) == "table" + and pArm[2].auto + check(type(pAuto) == "table" and pAuto.delay == 64 and not pAuto.wait, + s.site .. " parting box closes itself after DelayFrames 64") + local downs = 0 + for i = math.max(1, p - 4), p - 1 do + local r = sr[i] + if r and r[1] == "face_object" and r[3] == "down" + and (r[2] == s.duo[1] or r[2] == s.duo[2]) then + downs = downs + 1 + end + end + eq(downs, 2, s.site .. " both of the duo face down before the parting line") +end + +do + local Commands = require("src.script.Commands") + local NPC = require("src.world.NPC") + local npc = setmetatable({ facing = "left", pinnedFacing = "left", + wanders = true, timer = 5, moving = false, + cellX = 9, cellY = 3, px = 144, py = 48 }, NPC) + local player = { stepFrames = 16, cellX = 3, cellY = 5 } + local frames, stepDuring, playerDuring = 0, {}, {} + local ow = { player = player } + function ow.npcByIndex(_, i) if i == 2 then return npc end end + function ow.scriptMove(_, e, dir, _, onDone) + if e == player then + playerDuring[#playerDuring + 1] = e.stepFrames + onDone() + return + end + stepDuring[#stepDuring + 1] = e.stepFrames + e.facing = dir + e.targetX, e.targetY = e.cellX - 1, e.cellY + e.moving, e.progress = true, 0 + while e.moving do + e:update(nil, {}) + frames = frames + 1 + end + onDone() + end + local ctx = { overworld = ow, runner = {} } + Commands.walk_npc(ctx, 2, { "left", "left", "left", "left", "left", "left" }, + { stepFrames = 16 }) + eq(frames, 96, "six fast steps take 96 frames, not 192") + eq(stepDuring[1], 16, "walk_npc stepFrames applies during the walk") + eq(npc.stepFrames, nil, "walk_npc restores the NPC step length after") + Commands.walk_npc(ctx, "player", { "up" }, { stepFrames = 8 }) + eq(playerDuring[1], 16, "walk_npc stepFrames never touches the player") + + frames = 0 + Commands.walk_npc(ctx, 2, { "left" }) + eq(frames, 32, "walk_npc with no opts keeps the 32-frame NPC step") + + npc.timer = 5 + Commands.face_object(ctx, 2, "down", { hold = 510 }) + eq(npc.timer, 510, "face_object hold arms the turn timer") + local held = true + for _ = 1, 200 do + npc:update(nil, {}) + if npc.facing ~= "down" then held = false end + end + check(held, "a held facing survives 200 frames of the STAY pinned-facing timer") + + npc.timer = 5 + Commands.face_object(ctx, 2, "down") + eq(npc.timer, 5, "face_object without hold leaves the timer alone") +end if challenge and jamesWalk and faceJames then check(faceJames < challenge and challenge < (battle or math.huge), diff --git a/tests/engine/jigglypuff_dance_gate_bug2335.lua b/tests/engine/jigglypuff_dance_gate_bug2335.lua new file mode 100644 index 00000000..d323c0d4 --- /dev/null +++ b/tests/engine/jigglypuff_dance_gate_bug2335.lua @@ -0,0 +1,131 @@ +-- scripts/PewterPokecenter_2.asm:10-68 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local SONG_POLLS = 16 + +local music = { polls = 0 } +package.loaded["src.core.Music"] = { + ONE_SHOT_CEILING = 600, + stop = function() end, + restoreMap = function() end, + play = function() end, + playOnce = function() music.polls = 0; return true end, + oneShotPlaying = function() + music.polls = music.polls + 1 + return music.polls <= SONG_POLLS + end, +} + +local Sound = require("src.core.Sound") +local TextBox = require("src.render.TextBox") +local GameVersion = require("src.core.GameVersion") +local story5 = require("data.scripts.story5") + +Sound.setRate(1) + +local function newGame(party, player) + local game = { + save = { player = player or {}, flags = {}, options = { textSpeed = "FAST" }, + party = party }, + data = { audio = { fanfares = {}, sfx = {}, songs = {} }, text = {} }, + logicSpeed = function() return 1 end, + } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + game.input = { + queue = {}, + wasPressed = function(self, btn) return self.queue[btn] or false end, + isDown = function(self, btn) return self.queue[btn] or false end, + } + return game +end + +local handler = story5.PEWTER_POKECENTER.talk.TEXT_PEWTERPOKECENTER_JIGGLYPUFF + +local function dance(party, player) + local game = newGame(party, player) + local ow, npc = {}, { facing = "left" } + local fired = false + handler(game, ow, npc, function() fired = true end) + local box = game.stack:top() + local res = { turns = 0, held = 0, ow = ow, box = box } + local last = npc.facing + for _ = 1, 5000 do + if game.stack:top() ~= box then break end + box:update(1 / 60) + if box.autoStarted then res.held = res.held + 1 end + if npc.facing ~= last then + res.turns = res.turns + 1 + last = npc.facing + end + end + res.closed = game.stack:top() ~= box + res.fired = fired + return res +end + +GameVersion.set("yellow") + +do + local r = dance({ { species = "PIKACHU", hp = 20 } }) + check(r.closed, "the dance box closes itself") + check(r.fired, "the talk callback fires once the box pops") + check(r.held >= 32 + (SONG_POLLS + 1) * 24 + 48, + ("the box stays up for the whole song (held %d frames)"):format(r.held)) + eq(r.turns, SONG_POLLS, "one quarter turn per 24-frame poll while the song plays") + check(r.turns >= 16, "at least four full spins") + eq(r.ow.pikachuPewterSleepScene, true, + "a healthy starter PIKACHU falls asleep once the dance ends") +end + +do + local r = dance({ { species = "PIKACHU", hp = 20, status = "PSN" } }) + check(r.closed, "statused: the dance still ends") + eq(r.ow.pikachuPewterSleepScene, nil, + "a statused starter PIKACHU stays awake and following") +end + +do + local r = dance({ { species = "BULBASAUR", hp = 20 } }) + eq(r.ow.pikachuPewterSleepScene, nil, "no PIKACHU in the party, no sleep scene") +end + +do + local player = { id = 1234, name = "YELLOW" } + local r = dance({ { species = "PIKACHU", hp = 20, otId = 999, ot = "BRUNO" } }, player) + eq(r.ow.pikachuPewterSleepScene, nil, "a traded PIKACHU is not the starter, no sleep scene") + r = dance({ { species = "PIKACHU", hp = 20, otId = 999, ot = "BRUNO" }, + { species = "PIKACHU", hp = 20, otId = 1234, ot = "YELLOW" } }, player) + eq(r.ow.pikachuPewterSleepScene, true, "the OT-matched starter behind a traded PIKACHU falls asleep") + r = dance({ { species = "PIKACHU", hp = 20, otId = 999, ot = "BRUNO" }, + { species = "PIKACHU", hp = 20, otId = 1234, ot = "YELLOW", status = "SLP" } }, player) + eq(r.ow.pikachuPewterSleepScene, nil, "the status check reads the starter, not a healthy traded PIKACHU") +end + +GameVersion.set("red") + +do + local r = dance({ { species = "PIKACHU", hp = 20 } }) + check(r.turns >= 16, "Red: the same full-length dance") + eq(r.ow.pikachuPewterSleepScene, nil, "Red: no sleep scene") +end + +do + local game = newGame({}) + local ow, npc = {}, { facing = "left" } + handler(game, ow, npc, function() end) + local gate = game.stack:top().auto.sound() + check(Sound.waitFrames(gate) > 32 + 600 + 24 + 48, + "the gate's wait budget covers the longest possible dance") +end + +T.finish("jigglypuff_dance_gate_bug2335") diff --git a/tests/engine/launch_prelaunch_bug1657.lua b/tests/engine/launch_prelaunch_bug1657.lua index 135076c5..42f089f9 100644 --- a/tests/engine/launch_prelaunch_bug1657.lua +++ b/tests/engine/launch_prelaunch_bug1657.lua @@ -290,9 +290,12 @@ do optionsFile:close() check(optionsSrc:find("tasks.update = false", 1, true) ~= nil, "legacy Android intents never run the update check") - local bootAt = src:find("if not Prelaunch then bootShortcut()", 1, true) + local bootAt = src:find("if not Prelaunch then bootAfterMods()", 1, true) check(bootAt ~= nil, "and boots on the same frame when there is no stage to run") + check(src:find("if not wantsModUpdate(request) then return bootShortcut() end", + 1, true) ~= nil, + "straight into the game unless the mods pass was asked for") end T.finish("shortcut pre-boot stage (#1657)") diff --git a/tests/engine/launch_update_mods_bug2037.lua b/tests/engine/launch_update_mods_bug2037.lua new file mode 100644 index 00000000..2f620398 --- /dev/null +++ b/tests/engine/launch_update_mods_bug2037.lua @@ -0,0 +1,305 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local LaunchOptions = require("src.core.LaunchOptions") +local ModUpdate = require("src.mods.ModUpdate") +local LauncherMods = require("src.mods.LauncherMods") +local Platform = require("src.core.Platform") +local CartStore = require("src.carts.CartStore") +local RomImporter = require("src.import.RomImporter") + +local realGetenv = os.getenv +local env = {} +os.getenv = function(name) + if env[name] ~= nil then return env[name] end + return realGetenv(name) +end + +local function tasks(argv, rawArgv, uri) + return LaunchOptions.tasks(argv, rawArgv, uri or false) +end + +do + eq(tasks({}, nil).mods, false, "with no flag the mods pass is off") + eq(tasks({ "--update-mods" }, nil).mods, true, "--update-mods turns it on") + eq(tasks({ "--updatemods" }, nil).mods, true, + "--updatemods, the spelling from the request, does too") + eq(tasks({ "-update-mods" }, nil).mods, true, "as does -update-mods") + eq(tasks({}, { "--update-mods" }).mods, true, + "a copy only in the raw argv is still found") + eq(tasks({ "--update-mods", "--no-update-mods" }, nil).mods, false, + "--no-update-mods beats the positive flag") + eq(tasks({ "--updatemods", "--no-update-mods" }, nil).mods, false, + "across the two spellings") + eq(tasks({ "--update" }, nil).mods, false, + "--update stays the engine updater and never touches mods") + eq(tasks({ "--update-mods" }, nil).update, false, + "and --update-mods never runs the engine updater") + env.POKEPORT_LAUNCH_UPDATE_MODS = "1" + eq(tasks({}, nil).mods, true, "the env var turns it on") + eq(tasks({ "--no-update-mods" }, nil).mods, false, "a flag beats the env") + env.POKEPORT_LAUNCH_UPDATE_MODS = "0" + eq(tasks({}, nil).mods, false, "and 0 leaves it off") + env.POKEPORT_LAUNCH_UPDATE_MODS = "maybe" + eq(tasks({}, nil).mods, false, "anything else leaves it off") + env.POKEPORT_LAUNCH_UPDATE_MODS = nil +end + +do + local uri = LaunchOptions.parseURI( + "gen1recomp++://launch?game=red&update_mods=1") + eq(uri and uri.updateMods, true, "a launch URI can ask for the mods pass") + eq(tasks({}, nil, uri).mods, true, "and the task picks it up") + eq(tasks({ "--no-update-mods" }, nil, uri).mods, false, + "the command line still wins over the URI") + local built = LaunchOptions.uriFor("red", { updateMods = true }) + check(built and built:find("update_mods=1", 1, true) ~= nil, + "uriFor writes the parameter") + eq(LaunchOptions.parseURI(built).updateMods, true, "which reads back") + local intent = LaunchOptions.fromGame("red") + eq(intent and intent.tasks.mods, false, + "a legacy Android intent never runs the mods pass") + check(LaunchOptions.commandFor("red", nil, { mods = true }) + :find("--update-mods", 1, true) ~= nil, + "the shortcut command quotes the flag") +end + +local RELEASES = { + { version = "2.0.0", zip = { url = "https://example.invalid/a.zip" } }, +} + +local oldBeginFetch = ModUpdate.beginFetchReleases +local oldBeginZip = ModUpdate.beginDownloadZip +local oldPumpZip = ModUpdate.pumpDownloadZip +local oldInstall = LauncherMods.installDownloadedZip +local oldDeps = LauncherMods.checkDependencies +local oldRemote = Platform.canFetchRemote +local oldCartList = CartStore.list +local oldCartIndex = CartStore.index +local oldCartListFor = CartStore.listFor + +local failId = nil +local installs = {} +local depIssue = false + +ModUpdate.beginFetchReleases = function() return {} end +ModUpdate.beginDownloadZip = function() return {} end +ModUpdate.pumpDownloadZip = function() + love.filesystem.write("install.zip", "BYTES") + return true, "install.zip" +end +LauncherMods.installDownloadedZip = function(id, _, version) + installs[#installs + 1] = id + if id == failId then return nil, "the archive had no manifest" end + return true, version +end +LauncherMods.checkDependencies = function() + return { hasIssues = depIssue } +end +Platform.canFetchRemote = function() return true end +CartStore.list = function() return {} end +CartStore.listFor = function() return {} end +CartStore.index = function() return {} end + +local function info(status, best) + return { status = status, latest = best and best.version or nil, + best = best, releases = RELEASES } +end + +local function launcher(allCurrent) + local ri = setmetatable({ + mods = { + { id = "one", name = "One", version = "1.0.0", github = "a/one" }, + { id = "two", name = "Two", version = "1.0.0" }, + }, + modUpdateInfo = { + one = allCurrent and info("current") or info("available", RELEASES[1]), + }, + activeCart = {}, + carts = {}, + findLoaded = true, + findIndex = { mods = {}, carts = {} }, + }, RomImporter) + ri._refreshMods = function() end + ri._syncModUpdateInfo = function() end + return ri +end + +local function run(ri) + local confirms, fired = 0, 0 + for _ = 1, 40 do + if ri._modConfirm then confirms = confirms + 1 end + if ri:_fireAutoUpdateAll() then fired = fired + 1 end + if not ri._updateAll and not ri._autoUpdateAll then break end + ri:_pumpUpdateAll() + ri:_pumpModInstall() + end + return confirms, fired +end + +do + installs = {} + local ri = launcher() + local results = {} + check(ri:autoUpdateAll(function(r) results[#results + 1] = r end), + "the launch flag starts the sweep") + local confirms, fired = run(ri) + eq(confirms, 0, "with no confirm modal for a CLI launch to answer") + eq(#installs, 1, "the outdated mod is installed") + eq(installs[1], "one", "by id") + eq(fired, 1, "the caller hears back once") + eq(#results, 1, "exactly once") + eq((results[1] or {}).ok, true, "that it worked") + eq((results[1] or {}).updated, 1, "with the count") + eq(ri.modNotice and ri.modNotice.text, "Updated 1 items.", + "and the launcher shows the same notice the button would") + eq(ri._autoUpdateAll, nil, "the auto job is cleared") + eq(ri._busy, nil, "with the overlay down") +end + +do + installs = {} + local ri = launcher(true) + local results = {} + ri:autoUpdateAll(function(r) results[#results + 1] = r end) + run(ri) + eq(#installs, 0, "nothing outdated, nothing installed") + eq((results[1] or {}).ok, true, "an up-to-date set still boots") + eq(ri.modNotice and ri.modNotice.text, "Everything is up to date.", + "and says so") +end + +do + installs = {} + failId = "one" + local ri = launcher() + local results = {} + ri:autoUpdateAll(function(r) results[#results + 1] = r end) + run(ri) + failId = nil + eq((results[1] or {}).ok, false, "a failed install is reported as not ok") + eq(#((results[1] or {}).failures or {}), 1, "with the failure line") + check(ri.modNotice and not ri.modNotice.ok, + "and the failure stays on the launcher") +end + +do + installs = {} + depIssue = true + local ri = launcher() + local results = {} + ri:autoUpdateAll(function(r) results[#results + 1] = r end) + run(ri) + depIssue = false + eq((results[1] or {}).ok, false, + "an update that raises the dependency resolver is not ok") + check(ri._modDepResolver ~= nil, "so the resolver can be answered") +end + +do + installs = {} + local ri = launcher() + ri._syncModUpdateInfo = function(self) self._modInfoFetch = {} end + local results = {} + ri:autoUpdateAll(function(r) results[#results + 1] = r end) + eq((ri._updateAll or {}).stage, "check", + "the sweep waits on the release checks") + ri:_cancelUpdateAll() + run(ri) + eq((results[1] or {}).cancelled, true, "cancel is reported as cancelled") + eq(#installs, 0, "with nothing installed") +end + +do + Platform.canFetchRemote = function() return false end + local ri = launcher() + local results = {} + eq(ri:autoUpdateAll(function(r) results[#results + 1] = r end), false, + "no remote fetch, no sweep") + run(ri) + eq((results[1] or {}).skipped, true, "reported as skipped") + Platform.canFetchRemote = function() return true end +end + +do + local ri = launcher() + ri.safeMode = true + local results = {} + eq(ri:autoUpdateAll(function(r) results[#results + 1] = r end), false, + "safe mode refuses the sweep") + run(ri) + eq((results[1] or {}).skipped, true, "and reports it skipped") + check(ri.modNotice ~= nil, "with a notice saying why") +end + +do + local ri = launcher() + local switched + ri._switchTab = function(self, id) switched = id; self.tab = id end + ri:autoUpdateAll(function() end, { tab = "mods" }) + eq(switched, "mods", "the sweep can put the launcher on the MODS tab") + eq(ri:autoUpdateAll(function() end), false, + "a second request while one runs is ignored") + run(ri) +end + +do + installs = {} + local ri = launcher() + check(ri:pressUpdateAllMods(), "the button still starts the sweep") + ri:_pumpUpdateAll() + eq((ri._updateAll or {}).stage, "confirm", "and still asks first") + eq((ri._modConfirm or {}).kind, "updateAllRun", "with the modal up") + eq(#installs, 0, "installing nothing before the answer") + ri._modConfirm = nil + ri:_pumpUpdateAll() +end + +do + local forced = {} + local ri = launcher() + ri._syncModUpdateInfo = function(_, force) forced[#forced + 1] = force end + ri:autoUpdateAll(function() end) + run(ri) + eq(forced[1], false, "the launch flag keeps the 6h release cache") + local rb = launcher() + rb._syncModUpdateInfo = function(_, force) forced[#forced + 1] = force end + rb:pressUpdateAllMods() + eq(forced[2], true, "while the button still forces a fresh check") + rb._modConfirm = nil + rb._updateAll = nil + rb._busy = nil +end + +do + local f = assert(io.open("main.lua", "r")) + local src = f:read("*a") + f:close() + check(src:find("Importer:autoUpdateAll(", 1, true) ~= nil, + "main.lua runs the sweep on a launcher it opens for the flag") + check(src:find("if not Prelaunch then bootAfterMods()", 1, true) ~= nil, + "after the pre-boot stage") + check(src:find("onBoot = function(v, c, opts)", 1, true) ~= nil + and src:find("if onBoot and onBoot(version, cartId, opts) then return end", 1, true) ~= nil, + "Play after a failed sweep boots through the shortcut, keeping --slot") + check(src:find("if launcherBusy() then\n deferredLaunchRequest = request", 1, true) ~= nil, + "a launch request during a download waits instead of dropping the launcher") + check(src:find("if deferredLaunchRequest and not launcherBusy() then", 1, true) ~= nil, + "and runs once the launcher is idle") +end + +ModUpdate.beginFetchReleases = oldBeginFetch +ModUpdate.beginDownloadZip = oldBeginZip +ModUpdate.pumpDownloadZip = oldPumpZip +LauncherMods.installDownloadedZip = oldInstall +LauncherMods.checkDependencies = oldDeps +Platform.canFetchRemote = oldRemote +CartStore.list = oldCartList +CartStore.index = oldCartIndex +CartStore.listFor = oldCartListFor +os.getenv = realGetenv + +T.finish("launch --update-mods (#2037)") diff --git a/tests/engine/mart_bag_full_bug2162.lua b/tests/engine/mart_bag_full_bug2162.lua index 009b1e1c..92c900f3 100644 --- a/tests/engine/mart_bag_full_bug2162.lua +++ b/tests/engine/mart_bag_full_bug2162.lua @@ -99,12 +99,11 @@ local function buyOne(game, stack) local list = stack[#stack] list.onChoose({ value = "POKE_BALL" }) local qty = stack[#stack] - game.stack:pop() qty.onDone(1) local confirm = stack[#stack] game.stack:pop() - confirm.onChoose(true) - return menu, list + confirm.choice(true) + return menu, list, qty end do @@ -119,13 +118,14 @@ for _, case in ipairs({ { name = "not enough money", money = 100, count = nil, text = NO_MONEY }, }) do local game, events, stack = newGame(case.money, case.count) - local menu, list = buyOne(game, stack) + local menu, list, qty = buyOne(game, stack) local box = stack[#stack] if check(getmetatable(box) == TextBox, case.name .. ": a text box is pushed") then check(boxText(box):find(case.text:gsub("\n", " "), 1, true) ~= nil, case.name .. ": the clerk's refusal is the text") end - eq(stack[#stack - 1], list, case.name .. ": the list is still under the box") + eq(stack[#stack - 1], qty, case.name .. ": the quantity box is still under the box") + eq(stack[#stack - 2], list, case.name .. ": the list is under the quantity box") local popsBefore = 0 for _, ev in ipairs(events) do if ev[1] == "push" and ev[2] == box then break end diff --git a/tests/engine/mart_price_text_bug2291.lua b/tests/engine/mart_price_text_bug2291.lua new file mode 100644 index 00000000..fd19d972 --- /dev/null +++ b/tests/engine/mart_price_text_bug2291.lua @@ -0,0 +1,215 @@ +-- engine/events/pokemart.asm:159-169 +-- engine/events/pokemart.asm:84-91 +-- home/list_menu.asm:89-91 +-- luajit tests/engine/mart_price_text_bug2291.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local function glyphs(text) + local n = 0 + for _ in tostring(text):gmatch("[%z\1-\127\194-\244][\128-\191]*") do + n = n + 1 + end + return n +end + +local realFont = package.loaded["src.render.Font"] +package.loaded["src.render.Font"] = { + BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 }, + draw = function() end, + drawCode = function() end, + drawBox = function() end, + width = function(text) return glyphs(text) * 8 end, + split = function(text) + local out = {} + for s in tostring(text):gmatch("[%z\1-\127\194-\244][\128-\191]*") do + out[#out + 1] = s + end + return out + end, + spansFitting = function(spans, pixels) + return math.min(#spans, math.floor(pixels / 8)) + end, + encode = function(text) + local out = {} + for s in tostring(text):gmatch("[%z\1-\127\194-\244][\128-\191]*") do + out[#out + 1] = s + end + return out + end, + advanceOf = function() return 8 end, +} + +local RELOAD = { + "src.ui.QuantityBox", "src.ui.ShopMenu", "src.ui.ListMenu", + "src.ui.ChoiceBox", "src.ui.Menu", "src.ui.Theme", "src.render.TextBox", +} +for _, m in ipairs(RELOAD) do package.loaded[m] = nil end + +local ShopMenu = require("src.ui.ShopMenu") +local TextBox = require("src.render.TextBox") +local QuantityBox = require("src.ui.QuantityBox") +local ChoiceBox = require("src.ui.ChoiceBox") + +local GREET = "Take your time." +local SELL_GREET = "What would you\nlike to sell?" + +local function newGame(money) + local stack, pressed = {}, {} + local game = { + data = { + items = { POKE_BALL = { name = "POKe BALL", price = 200 } }, + text = { + _PokemartBuyingGreetingText = GREET, + _PokemonSellingGreetingText = SELL_GREET, + _PokemartTellBuyPriceText = "{RAM:wStringBuffer}?\nThat will be\011¥{NUM:hMoney, 3 | LEADING_ZEROES | LEFT_ALIGN}. OK?{DONE}", + _PokemartTellSellPriceText = "I can pay you\n¥{NUM:hMoney, 3 | LEADING_ZEROES | LEFT_ALIGN} for that.{DONE}", + _PokemartBoughtItemText = "Here you are!\nThank you!{PROMPT}", + }, + constants = {}, + }, + save = { money = money, inventory = {}, bagOrder = {}, options = { textSpeed = 3 } }, + input = { + wasPressed = function(_, b) return pressed[b] == true end, + isDown = function() return false end, + }, + } + game.stack = { + push = function(_, s) stack[#stack + 1] = s end, + pop = function() return table.remove(stack) end, + top = function() return stack[#stack] end, + } + local function frame(btn) + for k in pairs(pressed) do pressed[k] = nil end + if btn then pressed[btn] = true end + local top = stack[#stack] + top:update(1 / 60) + for k in pairs(pressed) do pressed[k] = nil end + end + return game, stack, frame +end + +local function flat(box) + local out = {} + for _, page in ipairs(box.pages or {}) do + for _, line in ipairs(page) do out[#out + 1] = line end + end + return out +end + +do + local game, stack, frame = newGame(3000) + local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end) + menu.items[1].onSelect() + local list = stack[#stack] + list.index = 1 + list.onChoose({ value = "POKE_BALL" }) + eq(list.hollowIndex, list.index, "buy: the chosen row shows the hollow cursor") + local qty = stack[#stack] + check(getmetatable(qty) == QuantityBox, "buy: the quantity box opens") + qty.onDone(1) + local box = stack[#stack] + check(getmetatable(box) == TextBox, "buy: the price line is a typed text box, not a bare YES/NO") + check(box.choice ~= nil, "buy: YES/NO waits on the text box") + eq(stack[#stack - 1], qty, "buy: the quantity box stays up under the price text") + eq(stack[#stack - 2], list, "buy: the list is under the quantity box") + eq(list.footer, GREET, "buy: the price line is not dumped into the footer") + local lines = flat(box) + eq(lines[1], "POKe BALL?", "buy: the clerk names the item") + eq(lines[2], "That will be", "buy: line 2") + eq(lines[3], "¥200. OK?", "buy: line 3 carries the price") + + frame() + eq(box.done, false, "buy: the price line types, it is not instant") + eq(stack[#stack], box, "buy: no YES/NO while the line is typing") + + local sawCont = false + for _ = 1, 300 do + if box.waiting then sawCont = true break end + frame() + end + check(sawCont, "buy: the cont line waits on the arrow") + local vis = box:visibleText() or {} + eq(vis[1], "POKe BALL?", "buy: arrow wait shows the item name") + eq(vis[2], "That will be", "buy: arrow wait shows That will be") + eq(stack[#stack], box, "buy: no YES/NO at the arrow wait") + + for _ = 1, 10 do frame() end + frame("a") + local pushed = false + for _ = 1, 300 do + if getmetatable(stack[#stack]) == ChoiceBox then pushed = true break end + frame() + end + check(pushed, "buy: YES/NO opens once the text finishes") + check(box.done, "buy: the text box is done when YES/NO opens") + eq(stack[#stack - 1], box, "buy: YES/NO sits over the price text") + eq(stack[#stack - 2], qty, "buy: the quantity box is still drawn under YES/NO") + + game.stack:pop() + game.stack:pop() + box.choice(false) + eq(stack[#stack], list, "buy NO: the quantity box closes back to the list") + eq(list.footer, GREET, "buy NO: the greeting is back") +end + +do + local game, stack = newGame(3000) + local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end) + menu.items[1].onSelect() + local list = stack[#stack] + list.onChoose({ value = "POKE_BALL" }) + local qty = stack[#stack] + qty.onDone(2) + local box = stack[#stack] + game.stack:pop() + box.choice(true) + eq(game.save.money, 2600, "buy YES: money is paid") + eq(game.save.inventory.POKE_BALL, 2, "buy YES: the items go in the bag") + local bought = stack[#stack] + check(getmetatable(bought) == TextBox and bought.choice == nil, + "buy YES: the receipt text is up") + eq(stack[#stack - 1], qty, "buy YES: the quantity box stays under the receipt") + game.stack:pop() + bought.onDone() + eq(stack[#stack], list, "buy YES: the receipt closes back to the list") +end + +do + local game, stack = newGame(0) + game.save.inventory.POKE_BALL = 5 + game.save.bagOrder = { "POKE_BALL" } + local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end) + menu.items[2].onSelect() + local list = stack[#stack] + list.index = 1 + list.onChoose(list.items[1]) + eq(list.hollowIndex, list.index, "sell: the chosen row shows the hollow cursor") + local qty = stack[#stack] + check(getmetatable(qty) == QuantityBox, "sell: the quantity box opens") + qty.onDone(2) + local box = stack[#stack] + check(getmetatable(box) == TextBox and box.choice ~= nil, + "sell: the price line is a typed text box with YES/NO") + eq(stack[#stack - 1], qty, "sell: the quantity box stays up under the price text") + eq(list.footer, SELL_GREET, "sell: the price line is not dumped into the footer") + local lines = flat(box) + eq(lines[1], "I can pay you", "sell: line 1") + eq(lines[2], "¥200 for that.", "sell: line 2 carries the price") + eq(box.done, false, "sell: the price line is not instant") + game.stack:pop() + box.choice(true) + eq(stack[#stack], list, "sell YES: back to the sell list") + eq(game.save.money, 200, "sell YES: money is paid") + eq(game.save.inventory.POKE_BALL, 3, "sell YES: items leave the bag") +end + +package.loaded["src.render.Font"] = realFont +for _, m in ipairs(RELOAD) do package.loaded[m] = nil end +require("src.ui.Screens").invalidate() + +T.finish() diff --git a/tests/engine/mod_load_order_bug2177.lua b/tests/engine/mod_load_order_bug2177.lua new file mode 100644 index 00000000..0daf2d4b --- /dev/null +++ b/tests/engine/mod_load_order_bug2177.lua @@ -0,0 +1,475 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end + +local Loader = require("src.mods.Loader") +local CartManifest = require("src.carts.CartManifest") +local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") +local GameVersion = require("src.core.GameVersion") +local Manifest = require("src.mods.Manifest") +local ModProfile = require("src.mods.ModProfile") +local LauncherMods = require("src.mods.LauncherMods") +local LoadOrder = require("src.mods.LoadOrder") +local Logger = require("src.core.Logger") +Logger.warn = function() end +Logger.info = function() end + +local function names(list) return table.concat(list or {}, ",") end + +local function memfs(files) + return { + files = files, + read = function(path) return files[path] end, + write = function(path, content) files[path] = content return true end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end, + createDirectory = function() return true end, + getDirectoryItems = function(path) + local seen, items = {}, {} + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, + } +end + +local function manifestJson(id, extra) + return ([[{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua"%s}]]) + :format(id, id, extra or "") +end + +local function entry(label) + return ([[ +return function(mod) + mod.content.pokemon:override("PIKACHU", { name = "%s" }) +end +]]):format(label) +end + +local function install(opts, extras) + extras = extras or {} + local files = { ["options.lua"] = SaveSerializer.encode(opts or {}) } + for _, id in ipairs({ "alpha", "beta", "gamma" }) do + files["mods/" .. id .. "/manifest.json"] = manifestJson(id, extras[id]) + files["mods/" .. id .. "/main.lua"] = entry(id:upper()) + end + SaveData.resetSlotState() + GameVersion.set("red") + return files +end + +local function boot(files, mode) + local data = { pokemon = { PIKACHU = { name = "PIKACHU" } } } + local loader = Loader.new({ fs = memfs(files) }) + local ok = loader:load(data, mode and { mode = mode } or nil) + return loader, data, ok +end + + +do + T.check(type(SaveData.defaultOptions().modOrder) == "table", + "defaultOptions carries a modOrder list") + T.eq(names(SaveData.modOrder({ modOrder = { "b", 3, "a", "b", "", "c" } })), + "b,a,c", "modOrder keeps strings once, in order") + T.eq(#SaveData.modOrder({}), 0, "no modOrder reads as empty") + local o = SaveData.setModOrder({}, { "x", "x", "y" }) + T.eq(names(o.modOrder), "x,y", "setModOrder stores the sanitized list") +end + + +do + local entries = { + { id = "gamma", priority = 0 }, { id = "alpha", priority = 5 }, + { id = "beta", priority = 0 }, + } + T.eq(names(LoadOrder.materialize({}, entries)), "beta,gamma,alpha", + "an empty saved order materializes to (priority, id)") + T.eq(names(LoadOrder.materialize({ "alpha", "gone" }, entries)), + "beta,gamma,alpha,gone", + "unlisted ids merge in by priority, uninstalled saved ids keep their slot") + T.eq(names(LoadOrder.materialize({ "gamma" }, entries)), "gamma,beta,alpha", + "an unlisted id tied on priority follows the saved one") + local grown = { + { id = "a", priority = 0 }, { id = "b", priority = 0 }, { id = "c", priority = 0 }, + { id = "fw", priority = -1000 }, { id = "late", priority = 50 }, + } + T.eq(names(LoadOrder.materialize({ "b", "a", "c" }, grown)), "fw,b,a,c,late", + "a mod installed after a reorder still lands by its manifest priority") + local l = { "a", "b", "c", "d" } + T.eq(names(LoadOrder.move(l, "b", -1)), "b,a,c,d", "move up one") + T.eq(names(LoadOrder.move(l, "a", -1)), "a,b,c,d", "move up clamps at the top") + T.eq(names(LoadOrder.move(l, "d", 1)), "a,b,c,d", "move down clamps at the bottom") + T.eq(names(LoadOrder.move(l, "c", -math.huge)), "c,a,b,d", "move to top") + T.eq(names(LoadOrder.move(l, "a", math.huge)), "b,c,d,a", "move to bottom") + T.eq(names(l), "a,b,c,d", "move never edits its input") + local full, changed = LoadOrder.moveWithin({ "a", "x", "b", "c" }, + { "a", "b", "c" }, "b", -1) + T.check(changed, "moveWithin reports a change") + T.eq(names(full), "b,x,a,c", + "moveWithin swaps visible neighbours and keeps hidden ids in their slots") +end + + +do + local files = install() + local loader, data = boot(files) + T.eq(names(loader.order), "alpha,beta,gamma", + "no modOrder keeps the (priority, id) order") + T.eq(data.pokemon.PIKACHU.name, "GAMMA", "and the last id wins") +end + +do + local files = install({ modOrder = { "gamma", "alpha", "beta" } }) + local loader, data = boot(files) + T.eq(names(loader.order), "gamma,alpha,beta", "modOrder decides the load order") + T.eq(data.pokemon.PIKACHU.name, "BETA", "the last mod in the player's order wins") +end + +do + local files = install({ modOrder = { "beta" } }) + local loader = boot(files) + T.eq(names(loader.order), "beta,alpha,gamma", + "a partial list leads, the rest follow by priority and id") +end + +do + local files = install({ modOrder = { "nope", "gamma" } }) + local loader, _, ok = boot(files) + T.check(ok ~= false, "an unknown id in modOrder is not an error") + T.eq(names(loader.order), "gamma,alpha,beta", "unknown ids are skipped") +end + +do + local files = install({ modOrder = { "alpha", "beta", "gamma" } }, + { alpha = [[,"priority":9]], gamma = [[,"priority":-4]] }) + local loader = boot(files) + T.eq(names(loader.order), "alpha,beta,gamma", + "the player's order outranks manifest priority") +end + +do + local files = install({ modOrder = { "beta", "alpha" } }, + { gamma = [[,"priority":-1000]] }) + local loader = boot(files) + T.eq(names(loader.order), "gamma,beta,alpha", + "a low-priority mod missing from the saved order still loads first") +end + +do + local files = install({ modOrder = { "alpha", "beta", "gamma" } }, + { alpha = [[,"dependencies":["gamma"] ]] }) + local loader = boot(files) + T.eq(names(loader.order), "beta,gamma,alpha", + "a dependency still loads before the mod that needs it") +end + +do + local files = install({ modOrder = { "alpha", "beta", "gamma" } }) + local cart = assert(CartManifest.parse({ id = "cartx", title = "cartx", + version = "1.0.0", author = "t", shell = "#102030", base = "red", + seal = "sealed", + mods = { { id = "alpha", source = "local", version = "1.0.0" }, + { id = "gamma", source = "local", version = "1.0.0" } }, + load_order = { "gamma", "alpha" } })) + files["carts/cartx" .. CartManifest.EXT] = CartManifest.encode(cart) + SaveData.setCart("cartx", "hashx") + local loader = boot(files) + T.eq(names(loader.order), "gamma,alpha", "a cart's load_order beats modOrder") + SaveData.setCart(nil) +end + +do + local files = install({ modOrder = { "gamma", "alpha", "beta" } }) + local loader = boot(files, "disableAll") + T.eq(loader.playerRank and next(loader.playerRank), nil, + "arena modes ignore the player's order") +end + + +do + local p = ModProfile.capture({ { id = "a", enabled = true } }, {}, {}, + { "b", "a", "b" }) + T.eq(names(p.order), "b,a", "capture copies the sanitized order") + local back = ModProfile.decode(ModProfile.encode({ name = "P", enabled = {}, + options = {}, slots = {}, enabledByVersion = {}, order = { "b", "a" } })) + T.eq(names(back and back.order), "b,a", "encode/decode round-trips order") + local bad = SaveSerializer.encode({ format = ModProfile.FORMAT, + formatVersion = 1, profile = { name = "Q", order = { "x", 4, "x", "y" } } }) + T.eq(names(ModProfile.decode(bad).order), "x,y", + "decode drops non-strings and duplicates") + local live = { modOrder = { "z" } } + T.check(not ModProfile.matchesOrder({ name = "old" }, live), + "a profile without an order does not match a custom order") + ModProfile.restoreOrder({ name = "old" }, live) + T.eq(names(live.modOrder), "", "a profile without an order resets to the default order") + T.check(ModProfile.matchesOrder({ name = "old" }, live), + "and matches once the order is back to default") + ModProfile.restoreOrder(back, live) + T.eq(names(live.modOrder), "b,a", "restoreOrder writes the profile's order") + T.check(ModProfile.matchesOrder(back, live), "matchesOrder agrees after restore") + T.check(not ModProfile.matchesOrder(back, { modOrder = { "a", "b" } }), + "matchesOrder notices a moved mod") +end + + +local function mf(raw) return Manifest.validate(raw) end + +do + local manifests = { + mf({ id = "aaa", name = "A", version = "1.0.0", entry = "m.lua" }), + mf({ id = "bbb", name = "B", version = "1.0.0", entry = "m.lua", + dependencies = { "ccc" } }), + mf({ id = "ccc", name = "C", version = "1.0.0", entry = "m.lua" }), + } + local rows = LauncherMods.deriveList(manifests, + { mods = {}, modOrder = { "bbb", "ccc", "aaa" } }, "red") + local by = {} + for _, r in ipairs(rows) do by[r.id] = r end + T.eq(by.bbb.loadRank, 1, "deriveList stamps the saved position") + T.eq(by.aaa.loadRank, 3, "for every row") + T.eq(by.bbb.orderAfter, "ccc", "a dependency placed later is flagged") + T.check(type(by.bbb.orderNote) == "string" and by.bbb.orderNote:find("C", 1, true), + "with a note naming it") + T.eq(by.ccc.orderNote, nil, "no note when nothing forces a move") + T.eq(names(LauncherMods.orderedIds(rows, { modOrder = { "ccc" } })), + "ccc,aaa,bbb", "orderedIds materializes over rows") + T.eq(names(LauncherMods.moveInOrder({ "a", "b" }, "b", -1)), "b,a", + "moveInOrder swaps") +end + +do + local fs = love.filesystem + for _, id in ipairs({ "lo_one", "lo_two", "lo_three" }) do + fs.write("mods/" .. id .. "/manifest.json", manifestJson(id)) + fs.write("mods/" .. id .. "/main.lua", "return function() end") + end + SaveData.saveOptions(SaveData.defaultOptions()) + local ok, at = LauncherMods.moveMod("lo_one", 1) + T.check(ok, "moveMod moves a mod down") + T.eq(at, 2, "and reports its new position") + T.eq(names(SaveData.loadOptions().modOrder), "lo_three,lo_one,lo_two", + "moveMod persists the whole materialized list") + T.check(not LauncherMods.moveMod("lo_two", 1), "the last mod cannot move down") + T.check(not LauncherMods.moveMod("missing_mod", -1), "an unknown id is refused") + local opts = SaveData.loadOptions() + opts.safeMode = true + SaveData.saveOptions(opts) + T.check(not LauncherMods.moveMod("lo_one", -1), "safe mode refuses a move") + opts = SaveData.loadOptions() + opts.safeMode = false + SaveData.saveOptions(opts) + fs.write("mods/lo_zero/manifest.json", manifestJson("lo_zero", [[,"priority":-1000]])) + fs.write("mods/lo_zero/main.lua", "return function() end") + T.check(LauncherMods.moveMod("lo_two", -1), "a move after a new install") + T.eq(names(SaveData.loadOptions().modOrder), "lo_zero,lo_three,lo_two,lo_one", + "the new low-priority mod was placed by priority, not forced last") + opts = SaveData.loadOptions() + opts.modProfiles = { { name = "PLAIN", enabled = {}, options = {}, slots = {}, + enabledByVersion = {} } } + SaveData.saveOptions(opts) + T.check(LauncherMods.applyProfile("PLAIN"), "applyProfile takes a profile with no order") + T.eq(#SaveData.loadOptions().modOrder, 0, "and resets the load order to default") + LauncherMods.moveMod("lo_one", 1) + T.check(LauncherMods.resetOrder(), "resetOrder clears a saved order") + T.eq(#SaveData.loadOptions().modOrder, 0, "back to author priority") + opts = SaveData.loadOptions() + opts.modProfiles, opts.activeProfile = nil, nil + SaveData.saveOptions(opts) + for _, id in ipairs({ "lo_one", "lo_two", "lo_three", "lo_zero" }) do + fs.remove("mods/" .. id .. "/manifest.json") + fs.remove("mods/" .. id .. "/main.lua") + end +end + + +do + local ManagerState = require("src.mods.ManagerState") + local writes = 0 + local game = { + save = { options = { mods = {}, modOrder = {} } }, + modStatus = { errors = {}, available = { + { id = "ma", name = "MA", enabled = true, state = "loaded", priority = 0 }, + { id = "mb", name = "MB", enabled = true, state = "loaded", priority = 0 }, + { id = "mc", name = "MC", enabled = true, state = "loaded", priority = 0 }, + } }, + writeOptions = function() writes = writes + 1 end, + stack = { pop = function() end, push = function() end }, + } + local ms = ManagerState.new(game) + ms:refresh() + local function labels(rows) + local out = {} + for _, r in ipairs(rows) do out[#out + 1] = r.label end + return table.concat(out, "|") + end + local function rowNamed(rows, label) + for _, r in ipairs(rows) do if r.label == label then return r end end + end + local rows = ms:detailRows(ms.byId.ma) + local orderRow = rowNamed(rows, "< LOAD #1/3 >") + T.check(orderRow and orderRow.adjust, "the detail screen has one load position row") + T.eq(labels(rows):find("LOAD EARLIER", 1, true), nil, "no separate earlier/later rows") + ms.currentMod, ms.screen, ms.scroll = ms.byId.ma, "detail", 1 + for i, r in ipairs(rows) do if r == orderRow then ms.cursor = i end end + ms:adjustOrTab(1) + T.eq(names(game.save.options.modOrder), "mb,ma,mc", "RIGHT on the row loads it later") + T.check(writes > 0, "and persists the options") + T.check(ms.restartPending, "a moved mod stages a restart") + rows = ms:detailRows(ms.byId.ma) + T.check(rowNamed(rows, "< LOAD #2/3 >") ~= nil, "the row follows the move") + ms:adjustOrTab(-1) + T.check(not ms.restartPending, "moving back to where it booted clears the restart") + T.eq(ms.descScroll, 1, "left/right on the row does not scroll the description") + rowNamed(ms:detailRows(ms.byId.mc), "< LOAD #3/3 >").adjust(-1) + ms:discardChanges() + T.eq(names(game.save.options.modOrder), "", "discard restores the boot order") + T.check(not ms.restartPending, "and clears the pending restart") + + local Font = require("src.render.Font") + local busy = { id = "md", name = "MD", enabled = true, state = "loaded", priority = 0, + github = "someone/md", experimental = true, error = "boom", permissions = { "net" } } + game.modStatus.available[#game.modStatus.available + 1] = busy + ms:refresh() + ms.currentMod, ms.screen, ms.cursor, ms.scroll = ms.byId.md, "detail", 1, 1 + local all = ms:rowsForScreen() + T.check(#all > 4, "a busy mod has more detail rows than fit above the footer") + local function drawAll() + local drawn = {} + local realDraw, realCode = Font.draw, Font.drawCode + Font.draw = function(text, x, y) drawn[#drawn + 1] = { text = tostring(text), x = x, y = y } end + Font.drawCode = function() end + ms:drawDetail() + Font.draw, Font.drawCode = realDraw, realCode + local low, seen = true, {} + for _, d in ipairs(drawn) do + if d.x == 32 then + if d.y >= 15 * 8 then low = false end + seen[d.text] = true + end + end + return low, seen + end + local clear, seen = drawAll() + T.check(clear, "no detail row draws on the footer") + T.check(seen["FOR " .. require("src.mods.ModTargets").chip(busy)] == nil, + "FOR is a header line, not a row") + T.check(not seen["BACK"], "BACK starts below the scroll window") + for _ = 1, #all - 1 do ms:moveCursor(1) end + T.eq(all[ms.cursor].label, "BACK", "the cursor reaches BACK") + clear, seen = drawAll() + T.check(clear and seen["BACK"], "and BACK scrolls into view above the footer") +end + + +do + local Kit = require("src.ui.kit.Kit") + local RomImporter = require("src.import.RomImporter") + local LauncherView = require("src.import.LauncherView") + local function window(w, h) + love.graphics.getDimensions = function() return w, h end + love.graphics.getPixelDimensions = function() return w, h end + end + local manifests = { + mf({ id = "va", name = "Alpha", version = "1.0.0", entry = "m.lua" }), + mf({ id = "vb", name = "Beta", version = "1.0.0", entry = "m.lua" }), + mf({ id = "vc", name = "Gamma", version = "1.0.0", entry = "m.lua" }), + } + local rows = LauncherMods.deriveList(manifests, + { mods = {}, modOrder = { "vc", "va", "vb" } }) + local seen, labels = {}, {} + local realButton = Kit.button + Kit.button = function(x, y, w, h, label, opts) + if opts and opts.id then + seen[opts.id] = { x = x, y = y, w = w, h = h, opts = opts } + labels[opts.id] = label + end + return realButton(x, y, w, h, label, opts) + end + local texts = {} + local realText = Kit.text + Kit.text = function(font, s, ...) + texts[#texts + 1] = s + return realText(font, s, ...) + end + for _, size in ipairs({ { 1280, 2400 }, { 360, 3200 } }) do + window(size[1], size[2]) + seen, labels, texts = {}, {}, {} + local imp = RomImporter.new(function() end, { launcher = true }) + imp.tab = "mods" + imp.ready = { red = true } + imp.mods = rows + imp.modSort = "order" + local moved + imp._moveMod = function(_, id, delta) moved = { id, delta } end + local ok, err = pcall(LauncherView.draw, imp) + T.check(ok, ("%dx%d draws: %s"):format(size[1], size[2], tostring(err))) + local tag = size[1] .. "x" .. size[2] + T.check(seen["mod-up-va"] ~= nil and seen["mod-down-va"] ~= nil, + tag .. ": up/down buttons are drawn for a visible row") + T.check(seen["mod-up-vc"] and seen["mod-up-vc"].opts.enabled == false, + tag .. ": the first mod cannot move up") + T.check(seen["mod-down-vb"] and seen["mod-down-vb"].opts.enabled == false, + tag .. ": the last mod cannot move down") + local found = false + for _, s in ipairs(texts) do + if type(s) == "string" and s:find("^#1 Gamma") then found = true end + end + T.check(found, tag .. ": the list is sorted by load order with #n badges") + local det, up = seen["mod-row-va"], seen["mod-up-va"] + if det and up then + T.check(det.y == up.y and det.x + det.w <= up.x, + tag .. ": the move buttons share the Details line, row height unchanged") + end + seen["mod-down-va"].opts.action() + T.eq(moved and moved[1], "va", tag .. ": Down moves that mod") + T.eq(moved and moved[2], 1, tag .. ": by one slot") + imp._modActions = "va" + seen, texts = {}, {} + imp._uiActions = {} + ok, err = pcall(LauncherView.draw, imp) + T.check(ok, tag .. " details modal draws: " .. tostring(err)) + T.check(seen["modact-up"] and seen["modact-down"] and seen["modact-top"] + and seen["modact-bottom"], tag .. ": Details offers the four Move buttons") + T.check(seen["modact-top"] and seen["modact-top"].opts.enabled == true, + tag .. ": the middle mod can move to the top") + seen["modact-bottom"].opts.action() + T.eq(moved and moved[2], math.huge, tag .. ": Move to bottom") + imp._modActions = nil + imp._sortPopup = "mods" + seen = {} + pcall(LauncherView.draw, imp) + T.check(seen["sortpop-order"] ~= nil, tag .. ": Load order is a MODS sort") + T.check(seen["sortpop-reset-order"] ~= nil, tag .. ": with a reset button") + imp._sortPopup = "find" + seen = {} + pcall(LauncherView.draw, imp) + T.check(seen["sortpop-order"] == nil, tag .. ": but not a FIND sort") + end + Kit.button, Kit.text = realButton, realText +end + +T.finish("mod load order (#2177)") diff --git a/tests/engine/move_box_overlap_2320.lua b/tests/engine/move_box_overlap_2320.lua new file mode 100644 index 00000000..46dcc35b --- /dev/null +++ b/tests/engine/move_box_overlap_2320.lua @@ -0,0 +1,278 @@ +-- home/pokemon.asm:246 +-- engine/items/item_effects.asm:1979-2006 +-- engine/pokemon/learn_move.asm:119-181 + +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("PP UP / TM move box #2320") +local check, eq = S.check, S.eq + +local ItemEffects = require("src.inventory.ItemEffects") + +do + local Data = { + items = { PP_UP = { name = "PP UP" } }, + moves = { LICK = { name = "LICK", pp = 30 } }, + pokemon = { GENGAR = { name = "GENGAR" } }, + text = {}, + } + local t = { species = "GENGAR", hp = 20, stats = { hp = 20 }, + moves = { { id = "LICK", pp = 48, ppUps = 3 } } } + local result, payload = ItemEffects.use(Data, {}, "PP_UP", t, nil, 1) + eq(result, "ppmaxed", "PP UP on a move with 3 PP Ups reports maxed out") + eq(payload and payload[1], "LICK's PP\nis maxed out.", "and prints PPMaxedOutText") + eq(t.moves[1].ppUps, 3, "the move is untouched") +end + +local draws +local FontStub = { + drawBox = function(tx, ty, tw, th) draws[#draws + 1] = { "box", tx, ty, tw, th } end, + draw = function(text, x, y) draws[#draws + 1] = { "text", text, x, y } end, + drawCode = function(code, x, y) draws[#draws + 1] = { "code", code, x, y } end, +} +package.loaded["src.render.Font"] = FontStub + +local boxes = {} +local TextBoxStub = {} +TextBoxStub.strip = function(text) + if type(text) ~= "string" then return text end + return (text:gsub("{DONE}", ""):gsub("{PROMPT}", "")) +end +TextBoxStub.new = function(game, text, onDone, opts) + local b = { fake = true, text = text, onDone = onDone, opts = opts } + boxes[#boxes + 1] = b + return b +end +package.loaded["src.render.TextBox"] = TextBoxStub +package.loaded["src.render.HudTiles"] = { + tile = function() end, drawHPBar = function() end, statusTile = function() end, +} +package.loaded["src.render.PaletteFX"] = { + shader = function() return nil end, pal = function() return nil end, +} + +local MoveSelectMenu = require("src.ui.MoveSelectMenu") +local MoveLearnMenu = require("src.ui.MoveLearnMenu") +local PartyMenu = require("src.ui.PartyMenu") +PartyMenu.drawIcon = function() end + +local pressed = {} +local function newGame() + local game = { + input = { wasPressed = function(_, b) return pressed[b] == true end, + isDown = function() return false end }, + data = { + moves = { LICK = { name = "LICK" }, HYPNOSIS = { name = "HYPNOSIS" }, + SURF = { name = "SURF" }, MEGA_PUNCH = { name = "MEGA PUNCH", pp = 20 } }, + pokemon = { GENGAR = { name = "GENGAR" } }, + statuses = {}, + text = {}, + }, + } + game.stack = { + states = {}, + push = function(self, s, ...) + self.states[#self.states + 1] = s + if type(s.enter) == "function" then s:enter(...) end + end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + return game +end + +local function press(state, btn) + pressed = { [btn] = true } + state:update(1 / 60) + pressed = {} +end + +local function finishBox(game) + local b = game.stack:top() + if b and b.fake and b.opts and b.opts.stay and b.opts.stay.onShown then + b.opts.stay.onShown() + return true + end + return false +end + +local function cursorGlyphAt(y) + for _, d in ipairs(draws) do + if d[1] == "code" and d[3] == 0 and d[4] == y then return d[2] end + end +end + +do + local game = newGame() + local mon = { species = "GENGAR", level = 30, hp = 60, stats = { hp = 60 }, + moves = { { id = "LICK", pp = 30 }, { id = "HYPNOSIS", pp = 20 } } } + game.save = { party = { mon, { species = "GENGAR", level = 5, hp = 10, + stats = { hp = 10 }, moves = {} } } } + local menuRef, chosen + local picker = PartyMenu.new(game, { + pickOnly = true, itemUse = true, keepOpen = true, + onSwitch = function(m, p) + menuRef = MoveSelectMenu.new(game, m, "Raise PP of which\ntechnique?{DONE}", + function(i, menu) chosen = { i, menu } end, nil, p) + game.stack:push(menuRef) + end, + }) + game.stack:push(picker) + + draws = {} + picker:draw() + eq(cursorGlyphAt(8), 0xED, "the party cursor is the filled arrow before the pick") + + press(picker, "a") + check(picker.chosenHollow == true, "A on a kept-open picker marks the row chosen") + draws = {} + picker:draw() + eq(cursorGlyphAt(8), 0xEC, "the chosen mon's arrow turns hollow") + + check(menuRef ~= nil, "the move menu was pushed") + local top = game.stack:top() + check(top and top.fake, "a text box types the prompt before the move box") + eq(top and top.text, "Raise PP of which\ntechnique?{DONE}", "with the item's prompt") + check(top and top.opts and top.opts.stay ~= nil, "a done-text stay box") + eq(menuRef.ready, false, "the move menu takes no input while the prompt types") + draws = {} + menuRef:draw() + eq(#draws, 0, "and draws nothing yet") + pressed = { a = true } + menuRef:update(1 / 60) + pressed = {} + eq(chosen, nil, "an A press during the prompt picks nothing") + + check(finishBox(game), "the prompt finished typing") + eq(game.stack:top(), menuRef, "the prompt box hands the screen to the move menu") + eq(menuRef.ready, true, "the move menu is live") + draws = {} + menuRef:draw() + eq(draws[1][1], "box", "prompt text box drawn first") + eq(draws[1][2], 0, "at column 0") + eq(draws[1][3], 12, "row 12") + local last + for _, d in ipairs(draws) do if d[1] == "box" then last = d end end + eq(last and last[2], 4, "the move box is the last box drawn") + eq(last and last[3], 7, "at 4,7, over the text box's top edge") + + press(menuRef, "down") + press(menuRef, "a") + eq(chosen and chosen[1], 2, "A hands back the picked slot") + eq(chosen and chosen[2], menuRef, "and the menu") + eq(game.stack:top(), picker, "nothing was pushed over it, so it released itself") + eq(#game.stack.states, 1, "back to the party menu") + + menuRef = nil + press(picker, "a") + finishBox(game) + local held = game.stack:top() + eq(getmetatable(held), MoveSelectMenu, "the move menu is up again") + local result = { fake = true, text = "LICK's PP\nincreased." } + held.onChoose = function() game.stack:push(result) end + press(held, "a") + eq(game.stack:top(), result, "the result text is on top") + check(held.held == true, "the move menu is held under it") + eq(game.stack.states[#game.stack.states - 1], held, "still on the stack under the result") + draws = {} + held:draw() + local moveBox + for _, d in ipairs(draws) do + if d[1] == "box" and d[2] == 4 and d[3] == 7 then moveBox = d end + if d[1] == "box" and d[2] == 0 and d[3] == 12 then moveBox = "prompt" end + end + check(type(moveBox) == "table", "the held menu keeps drawing the move box, not its prompt") + draws = {} + picker:draw() + eq(cursorGlyphAt(8), 0xEC, "the party arrow stays hollow under the result") + + game.stack:pop() + picker:close() + eq(#game.stack.states, 0, "closing the picker drops the held move box too") +end + +do + local game = newGame() + local mon = { species = "GENGAR", moves = { { id = "LICK" } } } + local p = { fake = "picker" } + local other = MoveSelectMenu.new(game, mon, "x", nil, nil, nil) + game.stack.states = { p, other } + PartyMenu.close(setmetatable({ game = game }, { __index = p })) + eq(#game.stack.states, 2, "close leaves a menu it does not own alone") +end + +do + local game = newGame() + local mon = { species = "GENGAR", moves = { { id = "LICK" }, { id = "HYPNOSIS" } } } + local m = MoveSelectMenu.new(game, mon, "Raise PP of which\ntechnique?") + game.stack:push(m) + finishBox(game) + press(m, "down") + local maxed = { fake = true } + m.onChoose = function() game.stack:push(maxed) end + press(m, "a") + game.stack:pop() + m:ask() + local reprompt = game.stack:top() + check(reprompt.fake and reprompt.text == "Raise PP of which\ntechnique?", + "ask() types the prompt again") + eq(m.ready, false, "the menu waits for it") + draws = {} + m:draw() + local promptBox, moveBox = false, false + for _, d in ipairs(draws) do + if d[1] == "box" and d[3] == 12 then promptBox = true end + if d[1] == "box" and d[3] == 7 then moveBox = true end + end + check(moveBox and not promptBox, "the move box stays up while the prompt retypes") + finishBox(game) + eq(m.index, 1, "the cursor starts over on the first move") + eq(m.ready, true, "and the menu is live again") +end + +do + local game = newGame() + local mon = { species = "GENGAR", level = 30, + moves = { { id = "SURF" }, { id = "LICK" }, { id = "HYPNOSIS" }, { id = "LICK" } } } + local m = MoveLearnMenu.new(game, mon, "MEGA_PUNCH", function() end) + game.stack:push(m) + local ask = game.stack:top() + check(ask.fake and ask.opts and ask.opts.choice, "TryingToLearn YES/NO first") + game.stack:pop() + ask.opts.choice(true) + local forget = game.stack:top() + check(forget.fake and forget.text:find("Which move should", 1, true) ~= nil, + "YES types WhichMoveToForgetText") + check(forget.opts and forget.opts.stay ~= nil, "as a done-text stay box") + eq(m.selecting, false, "the forget list waits for the text") + draws = {} + m:draw() + eq(#draws, 0, "and is not drawn yet") + finishBox(game) + eq(game.stack:top(), m, "the text hands off to the forget list") + eq(m.selecting, true, "which is live") + draws = {} + m:draw() + eq(draws[1][1], "box", "text box first") + eq(draws[1][3], 12, "at row 12") + local last + for _, d in ipairs(draws) do if d[1] == "box" then last = d end end + eq(last and last[2], 4, "forget box last") + eq(last and last[3], 7, "at 4,7") + + press(m, "a") + local hm = game.stack:top() + check(hm.fake and hm.text:find("HM techniques", 1, true) ~= nil, "SURF is refused") + eq(m.selecting, false, "the forget box is gone under the refusal") + game.stack:pop() + hm.onDone() + local again = game.stack:top() + check(again.fake and again.text:find("Which move should", 1, true) ~= nil, + "then the prompt types again") + finishBox(game) + eq(m.selecting, true, "and the forget list comes back") + eq(m.index, 1, "on the first move") +end + +S.finish() diff --git a/tests/engine/pikachu_mood_battle_2344.lua b/tests/engine/pikachu_mood_battle_2344.lua new file mode 100644 index 00000000..6fcb4b1b --- /dev/null +++ b/tests/engine/pikachu_mood_battle_2344.lua @@ -0,0 +1,109 @@ +-- pokeyellow engine/battle/end_of_battle.asm:49 +-- pokeyellow engine/pikachu/pikachu_status.asm:1 +-- pokeyellow engine/items/item_effects.asm:564 +-- pokeyellow engine/items/item_effects.asm:810 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) + +local GameVersion = require("src.core.GameVersion") +local BattleState = require("src.battle.BattleState") +local ItemEffects = require("src.inventory.ItemEffects") +local PikachuFollower = require("src.world.PikachuFollower") +local SaveData = require("src.core.SaveData") + +local Pokemon = require("src.pokemon.Pokemon") +if not Data.pokemon.PIKACHU then + local def = {} + for k, v in pairs(Data.pokemon.FIXMON_A) do def[k] = v end + def.name = "PIKACHU" + def.evolutions = { { method = "ITEM", item = "THUNDER_STONE", species = "FIXMON_B" } } + Data.pokemon.PIKACHU = def +end + +GameVersion.set("yellow") + +local function starterSave(mood, opts) + opts = opts or {} + local save = SaveData.newGame() + save.player.name, save.player.id = "YELLOW", 1234 + local pika = Pokemon.new(Data, "PIKACHU", 12) + pika.ot, pika.otId = opts.ot or "YELLOW", opts.otId or 1234 + save.party = { pika } + save.pikachuHappiness = 120 + save.pikachuMood = mood + save.pikachuEmotionModifier = nil + return save +end + +local function finishWith(result, opts) + opts = opts or {} + local save = starterSave(0x6c) + local game = { data = Data, save = save, + stack = { top = function() return nil end, + push = function() end, pop = function() end } } + local battle = BattleState.newWild(game, "FIXMON_C", 8) + battle.evolutionsChecked = true + battle.result = result + battle.playerRan = opts.playerRan + battle.demo = opts.demo + pcall(battle.finish, battle) + return save +end + +T.eq(finishWith("win").pikachuMood, 0x82, "a win lifts the starter's mood to $82") +T.eq(finishWith("run", { playerRan = true }).pikachuMood, 0x6c, + "the player running away leaves the mood alone (wBattleResult 2)") +T.eq(finishWith("run").pikachuMood, 0x82, + "the enemy fleeing (wBattleResult 0) still lifts the mood") +T.eq(finishWith("caught").pikachuMood, 0x6c, + "a catch skips the after-battle lift (wBattleResult 2)") +T.eq(finishWith("lose").pikachuMood, 0x6c, "a loss skips the after-battle lift") +T.eq(finishWith("win", { demo = true }).pikachuMood, 0x6c, + "the demo battle never touches the mood") + +do + local s = starterSave(0x6c, { ot = "ASH", otId = 999 }) + PikachuFollower.moodAfterBattle(s) + T.eq(s.pikachuMood, 0x6c, "a traded-in Pikachu does not count as the starter") +end +do + local s = starterSave(0x6c) + table.insert(s.party, 1, { species = "PIKACHU", hp = 20, ot = "ASH", otId = 999 }) + PikachuFollower.moodAfterBattle(s) + T.eq(s.pikachuMood, 0x82, "the OT-matched starter behind a traded Pikachu counts") +end + +do + local save = starterSave(0x6c) + local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } + local battle = BattleState.newWild(game, "FIXMON_C", 8) + battle:storeCaughtMon() + T.eq(save.pikachuEmotionModifier, 1, "a catch sets emotion modifier 1") + T.eq(save.pikachuMood, 0x85, "a catch sets mood $85") +end + +for _, stone in ipairs({ "FIRE_STONE", "MOON_STONE", "THUNDER_STONE" }) do + local save = starterSave(0x6c) + local result = ItemEffects.use(Data, save, stone, save.party[1]) + T.eq(result, "failed", stone .. " on the starter is not consumed") + if stone == "THUNDER_STONE" then + T.eq(save.pikachuEmotionModifier, 4, "THUNDER_STONE refusal sets modifier 4") + T.eq(save.pikachuMood, 0x82, "THUNDER_STONE refusal sets mood $82") + else + T.eq(save.pikachuEmotionModifier, nil, stone .. " leaves the modifier alone") + T.eq(save.pikachuMood, 0x6c, stone .. " leaves the mood alone") + end +end + +GameVersion.set("red") +do + local save = starterSave(0x6c) + ItemEffects.use(Data, save, "THUNDER_STONE", save.party[1]) + T.eq(save.pikachuMood, 0x6c, "Red/Blue stones never touch the mood") +end + +T.finish("pikachu_mood_battle_2344") diff --git a/tests/engine/pikachu_mood_save_2344.lua b/tests/engine/pikachu_mood_save_2344.lua new file mode 100644 index 00000000..e51cf005 --- /dev/null +++ b/tests/engine/pikachu_mood_save_2344.lua @@ -0,0 +1,136 @@ +-- pokeyellow engine/menus/save.asm:260 +-- pokeyellow engine/pikachu/pikachu_pic_animation.asm:1 +-- pokeyellow engine/events/poison.asm:137 +-- pokeyellow engine/pikachu/pikachu_status.asm:117 +-- pokeyellow engine/battle/end_of_battle.asm:49 +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local GameVersion = require("src.core.GameVersion") +local PikachuFollower = require("src.world.PikachuFollower") +local GenSave = require("src.save_convert.GenSave") + +local OFF = GenSave.OFFSETS +eq(OFF.pikachuMood, 0x271D, "wPikachuMood is sav byte 0x271D (d470 - d2f6 = 378)") +eq(OFF.pikachuMood, OFF.pikachuHappiness + 1, "wPikachuMood follows wPikachuHappiness") +eq(OFF.pikachuEmotionModifier, OFF.mainData + 421, + "wPikachuEmotionModifier is 421 bytes past sMainData (d49b - d2f6)") +check(OFF.pikachuMood >= OFF.checksumStart and OFF.pikachuMood < OFF.checksumEnd + and OFF.pikachuEmotionModifier < OFF.checksumEnd, + "mood and modifier sit inside the main checksum window") + +GameVersion.set("yellow") + +local function starterSave(h, mood) + return { + player = { name = "YELLOW", id = 1234 }, + party = { { species = "PIKACHU", hp = 20, ot = "YELLOW", otId = 1234 } }, + pikachuHappiness = h, pikachuMood = mood, flags = {}, + } +end + +local cells = { + { 120, 128, 1 }, { 120, 108, 3 }, { 120, 130, 8 }, { 90, 128, 5 }, + { 255, 255, 20 }, { 40, 30, 14 }, { 180, 200, 2 }, +} +for _, c in ipairs(cells) do + eq(PikachuFollower.moodEmotion(starterSave(c[1], c[2])), c[3], + string.format("happiness %d mood %d picks emotion %d", c[1], c[2], c[3])) +end + +local s = starterSave(120, 0x81) +s.pikachuEmotionModifier = 2 +PikachuFollower.onStep(s) +eq(s.pikachuMood, 128, "mood $81 converges to 128 in one step") +eq(s.pikachuEmotionModifier, nil, "reaching 128 clears the emotion modifier") + +s = starterSave(120, 0x85) +s.pikachuEmotionModifier = 1 +PikachuFollower.onStep(s) +eq(s.pikachuEmotionModifier, 1, "the modifier holds while mood is above 128") + +s = starterSave(120, 128) +s.pikachuEmotionModifier = 5 +PikachuFollower.onStep(s) +eq(s.pikachuEmotionModifier, nil, "a step at a steady 128 clears the modifier") + +s = starterSave(120, 108) +PikachuFollower.moodAfterBattle(s) +eq(s.pikachuMood, 0x82, "a battle win lifts mood 108 to $82") +s = starterSave(120, 200) +PikachuFollower.moodAfterBattle(s) +eq(s.pikachuMood, 200, "a battle win leaves mood 200 alone") +s = starterSave(120, 108) +s.party[1].hp = 0 +PikachuFollower.moodAfterBattle(s) +eq(s.pikachuMood, 108, "a fainted starter gets no after-battle mood") + +s = starterSave(120, 128) +PikachuFollower.onMoveLearned(s, s.party[1], "THUNDERBOLT") +eq(s.pikachuEmotionModifier, 5, "the starter learning THUNDERBOLT sets modifier 5") +eq(s.pikachuMood, 0x85, "and mood $85") +s = starterSave(120, 128) +PikachuFollower.onMoveLearned(s, s.party[1], "THUNDERSHOCK") +eq(s.pikachuEmotionModifier, nil, "other moves leave the modifier alone") + +GameVersion.set("red") +s = starterSave(120, 108) +PikachuFollower.moodAfterBattle(s) +eq(s.pikachuMood, 108, "Red/Blue never touch Pikachu's mood") +GameVersion.set("yellow") + +if not loadfile("data/generated/pokemon.lua") then + print("pikachu_mood_save_2344 codec half skipped (needs data/generated/)") + GameVersion.set("red") + T.finish("pikachu_mood_save_2344") + return +end + +local SaveConvert = require("src.save_convert.SaveConvert") +local SaveData = require("src.core.SaveData") +local stampMapWindow = loadfile("tests/fixture_data/map_window.lua")() +local yellowData = assert(SaveConvert.loadData("yellow")) +local redData = assert(SaveConvert.loadData("red")) +stampMapWindow(yellowData, "REDS_HOUSE_2F") +stampMapWindow(redData, "REDS_HOUSE_2F") + +local function newSave() + return SaveData.newGame({ playerName = "RED", rivalName = "BLUE" }) +end + +local y = newSave() +y.pikachuHappiness = 120 +y.pikachuMood = 0x62 +y.pikachuEmotionModifier = 2 +local yBytes = GenSave.encode(y, yellowData, nil) +eq(yBytes:byte(OFF.pikachuMood + 1), 0x62, "mood $62 reaches sav byte 0x271D") +eq(yBytes:byte(OFF.pikachuEmotionModifier + 1), 2, "modifier 2 reaches its sav byte") +local yBack = GenSave.decode(yBytes, yellowData) +eq(yBack.pikachuMood, 0x62, "mood survives export -> import on Yellow") +eq(yBack.pikachuEmotionModifier, 2, "the emotion modifier survives the round trip") +eq(yBack.pikachuHappiness, 120, "happiness still round-trips next to it") +eq(PikachuFollower.moodEmotion(yBack), 3, + "the imported save talks with the low-mood pic (emotion 3), not emotion 1") + +local seed = newSave() +seed.pikachuMood, seed.pikachuEmotionModifier = nil, nil +local seedBytes = GenSave.encode(seed, yellowData, nil) +eq(seedBytes:byte(OFF.pikachuMood + 1), 0x80, "a missing mood exports as the $80 seed") +eq(seedBytes:byte(OFF.pikachuEmotionModifier + 1), 0, "a missing modifier exports as 0") +eq(GenSave.decode(seedBytes, yellowData).pikachuEmotionModifier, nil, + "a zero modifier byte imports as no modifier") + +local r = newSave() +r.pikachuMood = 0x62 +r.pikachuEmotionModifier = 2 +local rBytes = GenSave.encode(r, redData, nil) +eq(rBytes:byte(OFF.pikachuMood + 1), 0, "a Red/Blue export leaves 0x271D alone") +local rBack = GenSave.decode(rBytes, redData) +eq(rBack.pikachuMood, nil, "a Red/Blue import never invents a mood") +eq(rBack.pikachuEmotionModifier, nil, "a Red/Blue import never invents a modifier") + +GameVersion.set("red") +T.finish("pikachu_mood_save_2344") diff --git a/tests/engine/pikachu_thunderbolt_flash_2347.lua b/tests/engine/pikachu_thunderbolt_flash_2347.lua new file mode 100644 index 00000000..20106d60 --- /dev/null +++ b/tests/engine/pikachu_thunderbolt_flash_2347.lua @@ -0,0 +1,161 @@ +-- pokeyellow data/pikachu/pikachu_pic_animation.asm:281 +-- pokeyellow engine/pikachu/pikachu_pic_animation.asm:790 +-- pokeyellow data/pikachu/pikachu_pic_animation.asm:1 +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local GameVersion = require("src.core.GameVersion") +local PikachuFollower = require("src.world.PikachuFollower") +local Sound = require("src.core.Sound") +local Music = require("src.core.Music") +local Assets = require("src.render.Assets") + +GameVersion.set("yellow") + +local moveCalls, cryCalls, ducks = {}, {}, {} +local busyFrames = 0 +local realPlayMove, realPikaCry, realCry = Sound.playMove, Sound.playPikaCry, Sound.playCry +local realBusy, realDuck = Sound.moveSfxBusy, Music.duckForFanfare +Sound.playMove = function(_, anim) moveCalls[#moveCalls + 1] = anim end +Sound.playPikaCry = function(_, n) cryCalls[#cryCalls + 1] = n return true end +Sound.playCry = function() return nil end +Sound.moveSfxBusy = function() + if busyFrames > 0 then busyFrames = busyFrames - 1 return true end + return false +end +Music.duckForFanfare = function(src) ducks[#ducks + 1] = src end +local realExists = Assets.exists +Assets.exists = function(path) + if path:find("pikapic_", 1, true) then return true end + return realExists(path) +end + +local tbAnim = { sound = "Battle_2F", pitch = 32, tempo = 128 } +local game = { + data = { + moves = { THUNDERBOLT = { anim = tbAnim } }, + field = { emotionBubbles = { bubbles = { + { name = "EXCLAMATION_BUBBLE" }, { name = "QUESTION_BUBBLE" }, + { name = "SMILE_BUBBLE" }, { name = "BOLT_BUBBLE" }, + } } }, + }, + save = { + player = { name = "YELLOW", id = 1234 }, + party = { { species = "PIKACHU", hp = 40, ot = "YELLOW", otId = 1234 } }, + pikachuHappiness = 120, pikachuMood = 128, flags = {}, + }, +} + +PikachuFollower.onMoveLearned(game.save, game.save.party[1], "THUNDERBOLT") +eq(game.save.pikachuEmotionModifier, 5, "learning THUNDERBOLT arms modifier 5") + +local function newOw() + return { + map = { id = "ROCK_TUNNEL_1F" }, + player = { facing = "up" }, + } +end + +local function runPress(ow, pressAt) + local log = { frames = 0, bgp = {}, skippableAt = {} } + local guard = 0 + while ow.emote and guard < 2000 do + guard = guard + 1 + local e = ow.emote + if e.boltAt then PikachuFollower.tickBolt(game, ow, e) end + e.frames = e.frames - 1 + local pressed = pressAt and log.frames + 1 == pressAt + log.frames = log.frames + 1 + if e.pikaPic then + log.pic = log.pic or e + log.bgp[#log.bgp + 1] = e.bgp or false + log.skippableAt[#log.skippableAt + 1] = e.skippable and true or false + end + if (e.skippable and pressed) or e.frames <= 0 then + local done = e.onDone + ow.emote = nil + if done then done() end + end + end + return log +end + +local function newNpc() + return { cellX = 5, cellY = 5, px = 80, py = 80, facing = "down" } +end + +local ow = newOw() +PikachuFollower.talk(game, ow, newNpc(), function() end) +check(ow.emote ~= nil, "talking to Pikachu opens an emote") +eq(ow.emote.bubble, 4, "modifier 5 opens with the BOLT bubble (PikachuEmotion25)") +eq(ow.emote.pikaPic, nil, "the bubble comes before the pikapic") + +local log = runPress(ow) +check(log.pic ~= nil, "the pikapic box follows the bubble") +eq(log.pic.boltAt, 45, "the bolt fires after 2 setup ticks + writebyte 13 (45 frames)") +eq(#cryCalls >= 1 and cryCalls[1], 35, "PikachuCry35 plays") +eq(#moveCalls, 1, "the THUNDERBOLT move sound plays once") +check(moveCalls[1] == tbAnim, "it is THUNDERBOLT's MoveSoundTable entry (Battle_2F)") +eq(#ducks, 1, "music is muted for the bolt") + +local pre = 0 +for i = 1, #log.bgp do + if log.bgp[i] then break end + pre = pre + 1 +end +eq(pre, 46, "no palette write until the mute DelayFrame has passed") +local strobeOk = true +for row = 1, 20 do + local want = (row % 2 == 1) and 0xC0 or 0xE4 + for f = 1, 4 do + if log.bgp[pre + (row - 1) * 4 + f] ~= want then strobeOk = false end + end +end +check(strobeOk, "20 rows of 4 frames alternate %11000000 / %11100100") +local tail = #log.bgp - (pre + 80) +check(tail >= 1, "the box stays up after the strobe") +local tailLit = true +for i = pre + 81, #log.bgp do + if log.bgp[i] ~= 0xE4 then tailLit = false end +end +check(tailLit, "the lit %11100100 BGP holds until the box closes") +eq(log.skippableAt[45], true, "A/B can still cut the pic before the bolt") +eq(log.skippableAt[46], false, "the bolt itself is not skippable") +check(not ducks[1].isPlaying(), "the mute releases once the box closes") + +eq(game.save.pikachuEmotionModifier, 5, "the talk keeps the modifier") +moveCalls, ducks = {}, {} +ow = newOw() +PikachuFollower.talk(game, ow, newNpc(), function() end) +eq(ow.emote and ow.emote.bubble, 4, "a second talk inside the window replays PikachuEmotion25") +busyFrames = 30 +log = runPress(ow) +eq(#moveCalls, 1, "the second talk bolts again") +eq(#log.bgp - (46 + 80), 30 + 4, "WaitForSoundToFinish holds the box until the sound ends") + +moveCalls, ducks = {}, {} +ow = newOw() +PikachuFollower.talk(game, ow, newNpc(), function() end) +local bubbleFrames = ow.emote.frames +log = runPress(ow, bubbleFrames + 10) +eq(#moveCalls, 0, "cutting the pic short before the bolt skips the flash") +eq(#ducks, 0, "and never mutes the music") + +for _ = 1, 5 do PikachuFollower.onStep(game.save) end +eq(game.save.pikachuEmotionModifier, nil, "five steps from $85 clear the modifier") +ow = newOw() +PikachuFollower.talk(game, ow, newNpc(), function() end) +moveCalls = {} +log = runPress(ow) +eq(#moveCalls, 0, "a plain mood talk has no bolt") +check(log.pic == nil or log.pic.boltAt == nil, "and no bolt spec") + +Sound.playMove, Sound.playPikaCry, Sound.playCry = realPlayMove, realPikaCry, realCry +Sound.moveSfxBusy, Music.duckForFanfare = realBusy, realDuck +Assets.exists = realExists +GameVersion.set("red") + +T.finish("pikachu_thunderbolt_flash_2347") diff --git a/tests/engine/poke_flute_battle_def_bug2305.lua b/tests/engine/poke_flute_battle_def_bug2305.lua new file mode 100644 index 00000000..bbd576eb --- /dev/null +++ b/tests/engine/poke_flute_battle_def_bug2305.lua @@ -0,0 +1,153 @@ +-- audio/poke_flute.asm:1 +-- engine/items/item_effects.asm:1739 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +love = require("tests.love_stub") + +local GameVersion = require("src.core.GameVersion") + +local rendered = {} +local ducked = {} +package.loaded["src.core.ChipAudio"] = { + isSuspended = function() return false end, + holdMusic = function() end, + newSfx = function(_, name, _, _, header) + rendered[#rendered + 1] = { name = name, header = header } + return { + setVolume = function() end, setPitch = function() end, + stop = function() end, play = function() end, + isPlaying = function() return true end, + } + end, +} +package.loaded["src.core.Music"] = { + duckForFanfare = function(src) ducked[#ducked + 1] = src end, +} +love.audio = love.audio or {} +love.audio.newSource = love.audio.newSource or function() return nil end + +local Sound = require("src.core.Sound") + +local function stockData() + return { audio = { sfx = { + Caught_Mon = { bank = 8, address = 0x41CE, engine = 2 }, + Pokeflute = { bank = 2, address = 0x4228, engine = 1 }, + } } } +end + +local function starts(def) + local map = {} + for _, spec in ipairs(def.startChannels or {}) do + map[spec.number] = spec.address + end + return map +end + +GameVersion.current = "red" +local red = Sound._pokefluteInBattleDef(stockData()) +check(red ~= nil, "a stock Caught_Mon header derives the in-battle flute") +local r = starts(red) +eq(r[5], 0x6322, "red SFX_Pokeflute_Ch5") +eq(r[6], 0x6325, "red SFX_Pokeflute_Ch6") +eq(r[7], 0x449b, "red SFX_Pokeflute_Ch7") +eq(red.bank, 8, "the header stays SFX_CAUGHT_MON's bank") +eq(red.address, 0x41CE, "and its address") +eq(red.fanfare, true, "it claims the fanfare duck") + +GameVersion.current = "blue" +local b = starts(Sound._pokefluteInBattleDef(stockData())) +eq(b[5], 0x6322, "blue shares red's addresses") +eq(b[7], 0x449b, "blue ch7") + +GameVersion.current = "yellow" +local y = starts(Sound._pokefluteInBattleDef(stockData())) +eq(y[5], 0x59eb, "yellow SFX_Pokeflute_Ch5") +eq(y[6], 0x59ee, "yellow SFX_Pokeflute_Ch6") +eq(y[7], 0x444b, "yellow SFX_Pokeflute_Ch7") +GameVersion.current = "red" + +local data = stockData() +check(data.audio.sfx.Caught_Mon.startChannels == nil, + "the registry Caught_Mon def is never mutated") +check(data.audio.sfx.Pokeflute_In_Battle == nil, + "and nothing new lands in the sfx registry") + +eq(Sound._pokefluteInBattleDef({ audio = { sfx = { + Caught_Mon = "caught.wav" } } }), nil, "a file Caught_Mon derives nothing") +eq(Sound._pokefluteInBattleDef({ audio = { sfx = { + Caught_Mon = { chip = {}, bank = 8, address = 0x41CE } } } }), nil, + "a chip-blob Caught_Mon derives nothing") +eq(Sound._pokefluteInBattleDef({ audio = { sfx = { + Caught_Mon = { bank = 8, address = 0x41D1, engine = 2 } } } }), nil, + "a relocated Caught_Mon derives nothing") +eq(Sound._pokefluteInBattleDef({}), nil, "no audio table derives nothing") + +rendered, ducked = {}, {} +local src = Sound.playPokefluteInBattle(stockData()) +check(src ~= nil, "the in-battle flute starts a source") +eq(#rendered, 1, "one render") +eq(rendered[1] and rendered[1].name, "Pokeflute_In_Battle", + "under its own key, never the field Pokeflute") +local p = starts(rendered[1] and rendered[1].header or {}) +eq(p[5], 0x6322, "rendered with ch5 overwritten") +eq(p[6], 0x6325, "rendered with ch6 overwritten") +eq(p[7], 0x449b, "rendered with ch7 overwritten") +eq(#ducked, 1, "the battle song ducks under it") + +rendered = {} +Sound.invalidate() +local plain = { audio = { sfx = { + Pokeflute = { bank = 2, address = 0x4228, engine = 1 } } } } +Sound.playPokefluteInBattle(plain) +eq(rendered[1] and rendered[1].name, "Pokeflute", + "no Caught_Mon header falls back to the field tune") + +local function modded(field) + local d = stockData() + d.audio.sfx.Pokeflute = field + return d +end + +love.audio.newSource = function(file) + rendered[#rendered + 1] = { name = "Pokeflute", file = file } + return { + setVolume = function() end, setPitch = function() end, + stop = function() end, play = function() end, + isPlaying = function() return true end, + } +end + +for _, case in ipairs({ + { "mod_flute.wav", "a mod file Pokeflute" }, + { { chip = {}, bank = 2, address = 0x4228, engine = 1 }, "a mod chip-blob Pokeflute" }, + { { bank = 2, address = 0x4300, engine = 1 }, "a relocated Pokeflute" }, + { { bank = 8, address = 0x4228, engine = 2 }, "a Pokeflute moved to another bank" }, +}) do + rendered = {} + Sound.invalidate() + Sound.playPokefluteInBattle(modded(case[1])) + eq(rendered[1] and rendered[1].name, "Pokeflute", + case[2] .. " plays in battle instead of the derived phrase") + check(rendered[1] and starts(rendered[1].header or {})[5] == nil, + case[2] .. " keeps its own channels") +end + +rendered = {} +Sound.invalidate() +local noField = stockData() +noField.audio.sfx.Pokeflute = nil +Sound.playPokefluteInBattle(noField) +eq(rendered[1] and rendered[1].name, "Pokeflute_In_Battle", + "no Pokeflute def at all still derives the in-battle phrase") + +rendered = {} +Sound.invalidate() +Sound.playPokefluteInBattle(stockData()) +eq(rendered[1] and rendered[1].name, "Pokeflute_In_Battle", + "the stock Pokeflute header still gets the derived phrase") + +T.finish("in-battle poke flute def (#2305)") diff --git a/tests/engine/poke_flute_battle_tune_bug1938.lua b/tests/engine/poke_flute_battle_tune_bug1938.lua index 97f0b1b3..63908214 100644 --- a/tests/engine/poke_flute_battle_tune_bug1938.lua +++ b/tests/engine/poke_flute_battle_tune_bug1938.lua @@ -156,4 +156,25 @@ do eq(battle.turns, 1, "the turn is spent the same way") end +do + plays = {} + sources["flute.wav"].playing = false + Data.audio.sfx.Pokeflute_In_Battle = "battleflute.wav" + local game, battle = newGame(false) + check(useFlute(game, battle), "the flute is in the bag again") + local box = game.stack:top() + check(typeOut(game, box), "the played-flute line types out") + game.press("a") + game.press() + eq(tunes(), 0, "the field SFX_Pokeflute never plays in battle") + eq(plays[#plays], "battleflute.wav", "Music_PokeFluteInBattle does") + eq(game.stack:top(), box, "and the box holds under it") + sources["battleflute.wav"].playing = false + game.press() + local woke = game.stack:top() + check(woke ~= box and getmetatable(woke) == TextBox, + "FluteWokeUpText follows once it ends") + Data.audio.sfx.Pokeflute_In_Battle = nil +end + T.finish("poke flute battle tune (#1938)") diff --git a/tests/engine/rom_pick_error_bug442.lua b/tests/engine/rom_pick_error_bug442.lua index 5009d259..927f9e43 100644 --- a/tests/engine/rom_pick_error_bug442.lua +++ b/tests/engine/rom_pick_error_bug442.lua @@ -191,16 +191,14 @@ do eq(ri.workState, "error", "the next focus reaches the ROM pick under it") end --- A USB copy is the player's own file: keep it, but stop retrying it this session. do clearSaveDir() love.filesystem.write("mymod.zip", "not a zip") local ri = freshImporter({}) ri:chooseMod() - eq(ri._installed, "mymod.zip", "Import Mod picks up a USB copy") + eq(ri._installed, nil, "Import Mod leaves a loose save-root zip alone") check(love.filesystem.getInfo("mymod.zip") ~= nil, "the player's own file stays") - ri:chooseMod() - eq(#pickCalls, 1, "the second tap opens the picker instead of retrying it") + eq(#pickCalls, 1, "the tap opens the picker instead") eq(pickCalls[1], "mod", "and asks for a mod archive") end diff --git a/tests/engine/save_menu_badges_bug2405.lua b/tests/engine/save_menu_badges_bug2405.lua new file mode 100644 index 00000000..2d90a2c6 --- /dev/null +++ b/tests/engine/save_menu_badges_bug2405.lua @@ -0,0 +1,109 @@ +-- pokefirered/src/save_menu_util.c:44 +-- pokefirered/src/start_menu.c:966 +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.ui.game3.stack"] = { + push = function() end, pop = function() end, + busy = function() return false end, drawOrder = function() return {} end, +} +package.loaded["src.ui.game3.window"] = { + template = function(l, t, w, h) + return { left = l, top = t, width = w, height = h } + end, + stdFrame = function() end, + fixedStdFrame = function() end, + cursorPx = function() end, +} +package.loaded["src.ui.game3.chrome"] = { dialogueFrame = function() end } + +local Flags = require("src.core.game3.scripting.flags") +local Space = require("src.core.game3.scripting.space") +local SaveMenu = require("src.ui.game3.save_menu") +local FrlgFont = require("src.ui.game3.frlg_font") + +local ROW_BADGES = 1 * 8 + 32 +local ROW_DEX = 1 * 8 + 46 +local ROW_TIME_LOW = 1 * 8 + 60 +local LABEL_X = 1 * 8 + 4 + +local function drawn(session) + local seen = {} + local realDraw = FrlgFont.draw + FrlgFont.draw = function(text, x, y) + seen[#seen + 1] = { text = text, x = x, y = y } + return 0 + end + SaveMenu.open = true + SaveMenu._phase = "confirm" + SaveMenu._session = session + local ok, err = pcall(SaveMenu.draw) + SaveMenu.open = false + FrlgFont.draw = realDraw + check(ok, "save stats window draws: " .. tostring(err)) + return seen +end + +local function valueAt(seen, y) + for _, s in ipairs(seen) do + if s.y == y and s.x ~= LABEL_X and s.x > LABEL_X then return s.text end + end +end + +local function labelRow(seen, label) + for _, s in ipairs(seen) do + if s.text == label and s.x == LABEL_X then return s.y end + end +end + +local function storeWith(ids) + local store = Flags.newStore() + for _, id in ipairs(ids) do Flags.setFlag(store, nil, id, true) end + return store +end + +Space.store = storeWith({ 0x820, 0x821 }) +local session = { name = "RED", map = "FR_PALLET_TOWN", flags = Flags.serialize(Space.store).flags } +local seen = drawn(session) +eq(valueAt(seen, ROW_BADGES), "2", "two badge flags in the live store print BADGES 2") + +Space.store = nil +seen = drawn({ name = "RED", map = "FR_PALLET_TOWN", + flags = Flags.serialize(storeWith({ 0x820, 0x821, 0x822 })).flags }) +eq(valueAt(seen, ROW_BADGES), "3", "session.flags string keys are counted") + +seen = drawn({ name = "RED", map = "FR_PALLET_TOWN" }) +eq(valueAt(seen, ROW_BADGES), "0", "no badge flags prints BADGES 0") + +Space.store = storeWith({ 0x820, 0x821 }) +seen = drawn({ name = "RED", map = "FR_PALLET_TOWN", flags = { ["2080"] = true } }) +eq(valueAt(seen, ROW_BADGES), "2", "the live flag store wins over stale session.flags") +eq(SaveMenu.countBadges({}), 2, "SaveMenu.countBadges reads the live store") + +Space.store = storeWith({ 0x820, 0x821, 0x822, 0x823, 0x824, 0x825, 0x826, 0x827 }) +seen = drawn({ name = "RED", map = "FR_PALLET_TOWN" }) +eq(valueAt(seen, ROW_BADGES), "8", "all eight badge flags print BADGES 8") + +Space.store = storeWith({ 0x820 }) +seen = drawn({ name = "RED", map = "FR_PALLET_TOWN", playTimeHours = 1, playTimeMinutes = 5 }) +eq(labelRow(seen, "POKéDEX"), nil, "no POKéDEX row before FLAG_SYS_POKEDEX_GET") +eq(labelRow(seen, "TIME"), ROW_DEX, "TIME moves up to the POKéDEX row (y=42)") +eq(valueAt(seen, ROW_DEX), "1:05", "TIME value moves up with its label") + +local dex = { seen = {}, caught = { [1] = true, [4] = true, [7] = true, [152] = true, [250] = true } } +Space.store = storeWith({ 0x820, 0x829 }) +seen = drawn({ name = "RED", map = "FR_PALLET_TOWN", dex = dex }) +eq(labelRow(seen, "POKéDEX"), ROW_DEX, "POKéDEX row drawn with FLAG_SYS_POKEDEX_GET") +eq(labelRow(seen, "TIME"), ROW_TIME_LOW, "TIME stays at y=56 under the POKéDEX row") +eq(valueAt(seen, ROW_DEX), "3", "Kanto dex count (<=151) before the National Dex") + +Space.store = storeWith({ 0x820, 0x829, 0x840 }) +Flags.setVar(Space.store, nil, 0x404E, 0x6258) +seen = drawn({ name = "RED", map = "FR_PALLET_TOWN", dex = dex }) +eq(valueAt(seen, ROW_DEX), "5", "National dex count once the National Dex is enabled") + +Space.store = nil +T.finish("save_menu_badges_bug2405") diff --git a/tests/engine/shaderfx_ffi_c_fallback.lua b/tests/engine/shaderfx_ffi_c_fallback.lua index 25d7e49d..8edaea81 100644 --- a/tests/engine/shaderfx_ffi_c_fallback.lua +++ b/tests/engine/shaderfx_ffi_c_fallback.lua @@ -62,6 +62,21 @@ T.check(iosErr and iosErr:find("ffi.C has no librashader_translate_preset", 1, t T.check(iosErr and iosErr:find("looked in", 1, true) == nil, "iOS lists no file candidates (got " .. tostring(iosErr) .. ")") +local LINK_ERR = 'dlopen failed: cannot locate symbol "_ZTVNSt6__ndk117bad_function_callE" ' + .. 'referenced by "/data/app/lib/arm64/liblibrashader_bridge.so"' +local linkFfi = baseFfi() +linkFfi.load = function(name) + if name == "liblibrashader_bridge.so" then error(LINK_ERR, 0) end + error('dlopen failed: library "' .. tostring(name) .. '" not found', 0) +end +linkFfi.C = emptyFfi.C +local linkCan, linkErr = withFfi(linkFfi, "Android") +T.eq(linkCan, false, "an Android bridge that fails to link cannot convert") +T.check(linkErr and linkErr:find("cannot locate symbol", 1, true) ~= nil, + "bridgeError keeps the dlopen reason (got " .. tostring(linkErr) .. ")") +T.check(linkErr and linkErr:find("liblibrashader_bridge.so (" .. LINK_ERR, 1, true) ~= nil, + "the reason sits next to the name that produced it") + local iosCan = withFfi(staticFfi, "iOS") T.eq(iosCan, true, "iOS resolves the bridge through ffi.C alone") diff --git a/tests/engine/shaderfx_no_bridge_ui.lua b/tests/engine/shaderfx_no_bridge_ui.lua index b8b9f9c8..accefc72 100644 --- a/tests/engine/shaderfx_no_bridge_ui.lua +++ b/tests/engine/shaderfx_no_bridge_ui.lua @@ -91,6 +91,22 @@ screen.onChoose(screen.items[2]) eq(#converts, 0, "activating a converted preset skips the reconvert with no bridge") eq(activated[1], "bevel.slangp", "the cached artifact still activates") +do + local realBridgeError = fake.bridgeError + fake.bridgeError = function() + return 'librashader bridge not found; looked in liblibrashader_bridge.so (dlopen failed: ' + .. 'cannot locate symbol "_ZTVNSt6__ndk117bad_function_callE" referenced by "liblibrashader_bridge.so")' + end + fake.can, fake.convertOk = false, false + local s = open() + s.onChoose(s.items[3]) + eq(#converts, 0, "a bridge that fails to link never calls convert") + eq(s.footer, "Update the app", "a bridge that ships but fails to link does not advise a reinstall") + check(#s.footer <= 18, "the link-failure footer stays one 18-column line") + eq(s.items[3].right, "UPDATE", "the row keeps its UPDATE label") + fake.bridgeError = realBridgeError +end + fake.can, fake.convertOk = true, true screen = open() eq(screen.items[3].right, "CONVERT", "a build with the bridge still offers CONVERT") diff --git a/tests/engine/stat_rise_message_translation_test.lua b/tests/engine/stat_rise_message_translation_test.lua index f71d894c..c3e8c3fc 100644 --- a/tests/engine/stat_rise_message_translation_test.lua +++ b/tests/engine/stat_rise_message_translation_test.lua @@ -57,10 +57,10 @@ withCatalog({ DEFENSE = "DEFENSE_FR" }, function() end) local hpTarget = Pokemon.new(Data, "FIXMON_A", 10) -withCatalog({ HP = "PV" }, function() +withCatalog({ HEALTH = "PV" }, function() local _, msgs = ItemEffects.use(Data, save, "HP_UP", hpTarget) T.check(msgs[1]:find("PV", 1, true) ~= nil, - "a catalog translating HP reaches the HP UP rose! message") + "a catalog translating HEALTH reaches the HP UP rose message") end) -- ------------------------------------------------------- AI trainer X-item diff --git a/tests/engine/title_fill_skin_lock.lua b/tests/engine/title_fill_skin_lock.lua new file mode 100644 index 00000000..4d915fe5 --- /dev/null +++ b/tests/engine/title_fill_skin_lock.lua @@ -0,0 +1,109 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local TouchSkin = require("src.core.TouchSkin") +local FaithfulRes = require("src.core.FaithfulRes") +local Renderer = require("src.render.Renderer") +local Zoom = require("src.render.Zoom") +local Game = require("src.core.Game") +local TitleState = require("src.ui.TitleState") +local IntroMovie = require("src.ui.IntroMovie") + +local function setWindow(w, h) + love.graphics.getDimensions = function() return w, h end + love.graphics.getPixelDimensions = function() return w, h end +end + +local function cfg(viewport) + local vp = viewport and ('overlay0_viewport = "%s"\n'):format(viewport) or "" + return ([[ +overlays = 1 +overlay0_name = "bezel" +overlay0_full_screen = true +overlay0_normalized = true +%soverlay0_descs = 1 +overlay0_desc0 = "nul,0.5,0.5,rect,0.02,0.02" +]]):format(vp) +end + +local function useSkin(viewport) + local skin = assert(TouchSkin.parse(cfg(viewport))) + TouchSkin.setActive(skin) + TouchSkin.setOverlayLive(false) + return skin +end + +local title = setmetatable({}, { __index = TitleState }) +local intro = setmetatable({}, { __index = IntroMovie }) +local function stack(...) return { states = { ... } } end + +TouchSkin.setActive(nil) +FaithfulRes.locked = false +eq(TitleState.wantsFillScale(nil), true, "no skin, no lock: the title fills") +eq(IntroMovie.wantsFillScale(nil), true, "no skin, no lock: the intro fills") + +useSkin("0.15,0.08,0.7,0.84") +eq(TitleState.wantsFillScale(nil), false, "a skin with a cutout: the title stays integer") +eq(IntroMovie.wantsFillScale(nil), false, "a skin with a cutout: the intro stays integer") +eq(Game.fillScaleInStack(stack(title, {})), false, + "a menu over the title inside a skin does not fill") + +useSkin(nil) +eq(TitleState.wantsFillScale(nil), false, "a full-window bezel: the title stays integer") +eq(IntroMovie.wantsFillScale(nil), false, "a full-window bezel: the intro stays integer") + +TouchSkin.setActive(nil) +eq(TitleState.wantsFillScale(nil), true, "clearing the skin restores the title fill") +eq(IntroMovie.wantsFillScale(nil), true, "clearing the skin restores the intro fill") + +FaithfulRes.locked = true +eq(title:wantsFillScale(), false, "FAITHFUL RATIO locked: the title stays integer") +eq(intro:wantsFillScale(), false, "FAITHFUL RATIO locked: the intro stays integer") +check(FaithfulRes.scaleCap() == nil, + "and that holds where scaleCap is nil (desktop / linux handheld)") +FaithfulRes.locked = false + +local function frame(states, worldActive) + Renderer.uiFill = Game.fillScaleInStack(stack(unpack(states))) + Renderer.uiCentered = true + Renderer.worldActive = worldActive + return Renderer:frameRects() +end + +setWindow(1024, 768) +Zoom.offset = 0 +useSkin("0.15,0.08,0.7,0.84") +Renderer:init() +Renderer:setUISize(160, 144) +local tr = frame({ title }, false) +local ir = frame({ intro }, false) +local mr = frame({ title, {} }, false) +local ow = frame({ {} }, true) +eq(tr.cut, true, "1024x768 with a bezel cutout") +eq(tr.Up, Renderer:fitScale(), "title inside the bezel draws at the integer fit") +eq(tr.Up, math.floor(tr.Up), "and that scale is whole") +eq(ir.Up, ow.Up, "intro matches the overworld scale inside the bezel") +eq(tr.Up, ow.Up, "title matches the overworld scale inside the bezel") +eq(mr.Up, ow.Up, "main menu over the title matches the overworld scale") +check(tr.uvph <= tr.vuh and tr.uvpw <= tr.vuw, "title leaves a margin inside the cutout") + +useSkin(nil) +local br = frame({ title }, false) +local bw = frame({ {} }, true) +eq(br.Up, bw.Up, "full-window bezel: title matches the overworld scale") + +TouchSkin.setActive(nil) +local nr = frame({ title }, false) +local nw = frame({ {} }, true) +eq(nw.Up, 5, "no skin at 1024x768: the overworld fits 5x") +check(nr.Up > Renderer:fitScale(), "no skin: the title still fills past the integer fit") + +FaithfulRes.locked = true +local lr = frame({ title }, false) +eq(lr.Up, 5, "FAITHFUL RATIO on a fixed linux panel: title back to whole pixels") +FaithfulRes.locked = false + +T.finish("title_fill_skin_lock") diff --git a/tests/engine/vitamin_rose_text_sfx_bug2332.lua b/tests/engine/vitamin_rose_text_sfx_bug2332.lua new file mode 100644 index 00000000..a65d125c --- /dev/null +++ b/tests/engine/vitamin_rose_text_sfx_bug2332.lua @@ -0,0 +1,173 @@ +-- engine/items/item_effects.asm:1310-1317 +-- data/text/text_6.asm:71-77 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq, same = T.check, T.eq, T.same +love = love or require("tests.love_stub") + +local game +local played = {} +package.loaded["src.core.Sound"] = { + play = function(_, name) + local boxes = 0 + if game then + for _, s in ipairs(game.stack.states) do + if type(s) == "table" and s.textBox then boxes = boxes + 1 end + end + end + played[#played + 1] = { name = name, boxes = boxes } + end, + playCry = function() end, +} +local jingles = {} +package.loaded["src.render.TextBox"] = { + new = function(_, text, done, opts) + return { textBox = true, text = text, done = done, opts = opts } + end, + strip = function(text) + if type(text) ~= "string" then return text end + return (text:gsub("{DONE}", ""):gsub("{PROMPT}", "")) + end, + soundOpts = function(_, sound, opts) + jingles[#jingles + 1] = sound + opts = opts or {} + opts.auto = { sound = sound, wait = true } + return opts + end, +} +package.loaded["src.inventory.ItemEffects"] = nil +package.loaded["src.ui.BagMenu"] = nil +package.loaded["src.ui.PartyMenu"] = nil +local ItemEffects = require("src.inventory.ItemEffects") +local BagMenu = require("src.ui.BagMenu") +local PartyMenu = require("src.ui.PartyMenu") +require("src.ui.Screens").invalidate() + +local Fixtures = require("tests.modkit.fixtures") +local Bag = require("src.inventory.Bag") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") + +local Data = Fixtures.fresh() +local VITAMIN_NAMES = { + HP_UP = "HP UP", PROTEIN = "PROTEIN", IRON = "IRON", + CARBOS = "CARBOS", CALCIUM = "CALCIUM", +} +local idx = 200 +for id, name in pairs(VITAMIN_NAMES) do + idx = idx + 1 + Data.items[id] = { id = id, index = idx, name = name, price = 9800, + tossable = true } +end + +local function heard(name) + for _, p in ipairs(played) do + if p.name == name then return p end + end + return nil +end + +local function gengar() + local mon = Pokemon.new(Data, "FIXMON_A", 20) + mon.nickname = "GENGAR" + return mon +end + +do + local save = SaveData.newGame() + for id, stat in pairs({ HP_UP = "HEALTH", PROTEIN = "ATTACK", + IRON = "DEFENSE", CARBOS = "SPEED", + CALCIUM = "SPECIAL" }) do + played = {} + local result, msgs, extra = ItemEffects.use(Data, save, id, gengar()) + eq(result, "consumed", id .. " is consumed") + eq(msgs and msgs[1], "GENGAR's\n" .. stat .. " rose.", + id .. " prints VitaminStatRoseText's layout") + check(not (extra and extra.useJingle), + id .. " does not defer Heal_Ailment to the end of the text") + check(heard("Heal_Ailment") ~= nil, + id .. " plays Heal_Ailment itself, before PrintText") + end +end + +do + local mon = gengar() + mon.statExp = { special = 25600 } + played = {} + local result = ItemEffects.use(Data, SaveData.newGame(), "CALCIUM", mon) + eq(result, "failed", "a maxed stat is refused") + eq(heard("Heal_Ailment"), nil, "and the refusal plays no Heal_Ailment") +end + +local function freshGame() + local mon = gengar() + local g = { + data = Data, + save = { + party = { mon }, + player = { name = "RED", id = 1 }, + inventory = {}, + options = {}, + flags = {}, + money = 0, + }, + } + g.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, + } + g.input = { pressed = nil } + function g.input:wasPressed(b) return self.pressed == b end + Bag.add(g.save, "CALCIUM", 2) + return g, mon +end + +do + local mon + game, mon = freshGame() + played, jingles = {}, {} + local list = BagMenu.new(game, {}) + game.stack:push(list) + local row + for i, r in ipairs(list.items) do + if r.value == "CALCIUM" then row = i end + end + if check(row ~= nil, "CALCIUM is in the bag") then + list.index = row + list.onChoose(list.items[row], list) + local sub = game.stack:top() + if sub and sub.items and sub.items[1] and sub.items[1].onSelect then + game.stack:pop() + sub.items[1].onSelect() + end + local picker = game.stack:top() + if check(getmetatable(picker) == PartyMenu, "the party picker opened") then + game.input.pressed = "a" + picker:update(1 / 60) + game.input.pressed = nil + eq(mon.statExp.special, 2560, "CALCIUM raised SPECIAL stat exp") + local p = heard("Heal_Ailment") + if check(p ~= nil, "Heal_Ailment played through the bag flow") then + eq(p.boxes, 0, "before the rose box was pushed") + end + same(jingles, {}, "no end-of-text jingle is armed on the rose box") + local box = game.stack:top() + if check(type(box) == "table" and box.textBox, "the rose line prints") then + eq(box.text, "GENGAR's\nSPECIAL rose.", "with the cart's line break") + eq(box.opts, nil, "as a plain prompt box") + end + local partyUp = false + for _, s in ipairs(game.stack.states) do + if getmetatable(s) == PartyMenu then partyUp = true end + end + check(partyUp, "the party menu stays drawn under the message") + end + end + game = nil +end + +T.finish() diff --git a/tests/game3_save_trainer_card_test.lua b/tests/game3_save_trainer_card_test.lua index 6c602683..5ede1cfc 100644 --- a/tests/game3_save_trainer_card_test.lua +++ b/tests/game3_save_trainer_card_test.lua @@ -20,7 +20,7 @@ local session = { dex = { caught = { [1] = true, [4] = true, [7] = true } }, playTimeHours = 2, playTimeMinutes = 45, - badges = { true, true, false, false, false, false, false, false }, + flags = { [0x820] = true, [0x821] = true }, -- sessions carry the engine's map id; the save screen resolves the place name -- from it (see tests/engine/save_menu_location_bug2328.lua) map = "FR_PALLET_TOWN", @@ -95,6 +95,11 @@ test("SaveMenu starts in confirm phase", function() assert(SaveMenu.cursor == 1, "Cursor should start on YES (1)") end) +test("SaveMenu and TrainerCard agree on the badge flags", function() + assert(SaveMenu.countBadges(session) == 2, "save stats reads 2 badges from session.flags") + assert(TrainerCard.countBadges(session) == 2, "trainer card reads 2 badges from session.flags") +end) + test("SaveMenu cursor movement", function() SaveMenu.move(1) assert(SaveMenu.cursor == 2, "Cursor should flip to NO (2)") diff --git a/tests/gen2_cycling_road_test.lua b/tests/gen2_cycling_road_test.lua index ef159cf7..2acf53b4 100644 --- a/tests/gen2_cycling_road_test.lua +++ b/tests/gen2_cycling_road_test.lua @@ -161,6 +161,7 @@ local function fieldSelf(onBike, badge) width = 8, height = 8, def = { blocks = {}, environment = "ROUTE", tileset = "TILESET_KANTO" }, cellCollision = function(_, cx, cy) return coll[cy * 16 + cx] or COLL_LAND end, + inBounds = function() return true end, } for i = 1, 64 do map.def.blocks[i] = 1 end -- water in the cell the player faces, land under their feet @@ -179,6 +180,7 @@ local function fieldSelf(onBike, badge) engineFlag = World.engineFlag, alwaysOnBike = World.alwaysOnBike, blockIndexAt = World.blockIndexAt, + cellCollisionAcross = World.cellCollisionAcross, escapeRopeTarget = function() return nil end, facingObject = function() return nil end, hour = function() return 12 end, diff --git a/tests/gen2_facing_edge_2352_test.lua b/tests/gen2_facing_edge_2352_test.lua new file mode 100644 index 00000000..bdb3648d --- /dev/null +++ b/tests/gen2_facing_edge_2352_test.lua @@ -0,0 +1,166 @@ +-- home/map.asm:1511 GetMovementPermissions +-- engine/events/overworld.asm:1444 FishFunction.TryFish +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 facing edge 2352") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local World = require("src.world.gen2.World") +local Map = require("src.world.gen2.Map") +local WorldAPI = require("src.world.gen2.WorldAPI") + +local COLL_FLOOR, COLL_WATER = 0x00, 0x29 + +local function only(species) + local list = { { chance = 256, species = species, level = 10 } } + return { old = list, good = list, super = list } +end + +local DATA = { + items = { OLD_ROD = { name = "OLD ROD" } }, + moves = { SPLASH = { name = "SPLASH", pp = 40 } }, + pokemon = { + MAGIKARP = { + name = "MAGIKARP", types = { "WATER", "WATER" }, + baseStats = { hp = 20, attack = 10, defense = 55, speed = 80, + specialAttack = 15, specialDefense = 20 }, + levelMoves = { { level = 1, move = "SPLASH" } }, + }, + }, +} + +local function buildWorld(maps, tilesets, mapId, encounters) + local game = { + data = DATA, + save = { player = { name = "GOLD" }, party = {}, + inventory = { OLD_ROD = 1 } }, + } + local world = World.new(game) + game.world = world + world.maps = maps + world.tilesets = tilesets + world.map = Map.new(maps[mapId], tilesets[maps[mapId].tileset]) + world.encounters = encounters + world.player = { + cellX = 0, cellY = 0, px = 0, py = 0, facing = "down", moving = false, + turnArmed = true, update = function() return false end, + setSprite = function() end, + } + world.pollTimeOfDay = function() end + world.vm = { running = function() return false end, update = function() end } + return world, game +end + +local function place(world, x, y, facing) + world.player.cellX, world.player.cellY = x, y + world.player.facing = facing +end + +local function hasAction(game, id) + local rows = WorldAPI.new(game, "test"):availableFieldActions() + for _, row in ipairs(rows) do + if row.id == id then return true end + end + return false +end + +do + local tileset = { collision = { + { COLL_FLOOR, COLL_FLOOR, COLL_FLOOR, COLL_FLOOR }, + { COLL_FLOOR, COLL_FLOOR, COLL_FLOOR, COLL_FLOOR }, + { COLL_WATER, COLL_WATER, COLL_WATER, COLL_WATER }, + } } + local maps = { + TOWN = { id = "TOWN", width = 2, height = 2, blocks = { 1, 1, 1, 1 }, + borderBlock = 2, tileset = "T", environment = "TOWN", + fishGroup = "FISHGROUP_SHORE", bgEvents = {}, objects = {}, + connections = { + south = { mapId = "ROUTE", offset = 0 }, + north = { mapId = "ROUTE", offset = 1 }, + } }, + ROUTE = { id = "ROUTE", width = 1, height = 2, blocks = { 1, 1 }, + borderBlock = 2, tileset = "T", environment = "ROUTE", + bgEvents = {}, objects = {}, connections = {} }, + } + local world, game = buildWorld(maps, { T = tileset }, "TOWN", + { fishGroups = { FISHGROUP_SHORE = only("MAGIKARP") } }) + + place(world, 1, 3, "down") + eq(world.map:cellCollision(1, 4), COLL_WATER, + "the unpadded grid still reads the border block below the south mouth") + eq(world:fieldContext().facingColl, COLL_FLOOR, + "facing south into the connection strip reads the neighbour's floor") + eq(world:rollFishing("OLD_ROD"), "nowhere", + "and the rod refuses there") + check(not hasAction(game, "fish"), + "and no FISH shortcut is offered at the mouth") + + place(world, 2, 0, "up") + eq(world:fieldContext().facingColl, COLL_FLOOR, + "facing north into an offset strip reads the neighbour's floor") + eq(world:fieldContext().upColl, COLL_FLOOR, + "and wTileUp reads it too") + eq(world:rollFishing("OLD_ROD"), "nowhere", "and the rod refuses there too") + + place(world, 3, 3, "down") + eq(world:fieldContext().facingColl, COLL_WATER, + "past the end of the strip the border block is still water") + check(world:rollFishing("OLD_ROD") ~= "nowhere", + "so the rod still casts there") + check(hasAction(game, "fish"), "and the FISH shortcut is offered") + + place(world, 0, 1, "left") + eq(world:fieldContext().facingColl, COLL_WATER, + "an edge with no connection keeps the border block") + check(world:rollFishing("OLD_ROD") ~= "nowhere", + "and fishing into it still casts") +end + +do + local home = os.getenv("HOME") or "" + local roots = { + os.getenv("CRYSTAL_CACHE") or "", os.getenv("GOLD_CACHE") or "", + home .. "/Library/Application Support/LOVE/crystal-dev/crystal", + home .. "/Library/Application Support/LOVE/bsa2237c/crystal", + home .. "/Library/Application Support/LOVE/gold-dev/gold", + home .. "/Library/Application Support/LOVE/bsa2237g/gold", + } + local maps, tilesets + for _, root in ipairs(roots) do + if root then + local m = loadfile(root .. "/data/generated/maps.lua") + local t = loadfile(root .. "/data/generated/tilesets.lua") + if m and t then + maps, tilesets = m(), t() + break + end + end + end + if not (maps and maps.GOLDENROD_CITY) then + check(true, "no gen2 cache: Goldenrod mouths (SKIP)") + else + local world = buildWorld(maps, tilesets, "GOLDENROD_CITY", + { fishGroups = { FISHGROUP_SHORE = only("MAGIKARP") } }) + for x = 18, 21 do + place(world, x, 35, "down") + check(world:fieldContext().facingColl ~= COLL_WATER, + "Goldenrod south road x=" .. x .. " faces Route 34, not water") + eq(world:rollFishing("OLD_ROD"), "nowhere", + "and the rod refuses at x=" .. x) + end + for x = 24, 27 do + place(world, x, 0, "up") + check(world:fieldContext().facingColl ~= COLL_WATER, + "Goldenrod north road x=" .. x .. " faces Route 35, not water") + eq(world:rollFishing("OLD_ROD"), "nowhere", + "and the rod refuses at x=" .. x) + end + place(world, 0, 10, "left") + eq(world:fieldContext().facingColl, COLL_WATER, + "Goldenrod's west edge keeps its $35 water border") + end +end + +S.finish() diff --git a/tests/gen2_fishing_time_test.lua b/tests/gen2_fishing_time_test.lua index 9b5ff539..3bdb21fd 100644 --- a/tests/gen2_fishing_time_test.lua +++ b/tests/gen2_fishing_time_test.lua @@ -49,6 +49,7 @@ local function fishWorld(mapId, fishGroup, daytime) cellCollision = function(self, cx, cy) return (cx == 5 and cy == 4) and COLL_WATER or COLL_FLOOR end, + inBounds = function() return true end, } local game = { save = { diff --git a/tests/gen2_hidden_items_test.lua b/tests/gen2_hidden_items_test.lua index b70a2119..3dca9cbf 100644 --- a/tests/gen2_hidden_items_test.lua +++ b/tests/gen2_hidden_items_test.lua @@ -296,6 +296,7 @@ local function fakeWorld(bgEvents, px, py, facing) id = "TEST_MAP", def = { bgEvents = bgEvents, objects = {}, width = 10, height = 10 }, cellCollision = function() return COLL_FLOOR end, + inBounds = function() return true end, warpAt = function() return nil end, } world.maps = { TEST_MAP = world.map.def } diff --git a/tests/gen2_link_fingerprint_cross_version_test.lua b/tests/gen2_link_fingerprint_cross_version_test.lua new file mode 100644 index 00000000..05efd53e --- /dev/null +++ b/tests/gen2_link_fingerprint_cross_version_test.lua @@ -0,0 +1,69 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = love or require("tests.love_stub") + +local S = require("tests.harness").suite("gen2 link fingerprint cross version") +local check, eq = S.check, S.eq + +local Fingerprint = require("src.link.Fingerprint") +local ItemEffects = require("src.core.gen2.ItemEffects") + +local home = os.getenv("HOME") or "" +local LOVE = home .. "/Library/Application Support/LOVE/" + +local function cacheDir(env, fallback) + return os.getenv(env) or (LOVE .. fallback) +end + +local function readable(path) + local f = io.open(path, "r") + if not f then return false end + f:close() + return true +end + +local function dataset(dir) + local root = dir .. "/data/generated/" + if not readable(root .. "pokemon.lua") then return nil end + local function load(name) + if not readable(root .. name .. ".lua") then return nil end + return dofile(root .. name .. ".lua") + end + local data = { + pokemon = load("pokemon") or {}, + items = load("items") or {}, + moves = load("moves") or {}, + type_chart = load("type_chart") or {}, + gen2Constants = load("constants"), + } + local chart = data.type_chart + chart.matchups = chart.matchups or {} + for _, row in ipairs(chart.foresightMatchups or {}) do + chart.matchups[#chart.matchups + 1] = row + end + data.gen2HeldItems = ItemEffects.heldItemsFrom(data.items) + return data +end + +local gold = dataset(cacheDir("GOLD_CACHE", "gold-dev/gold")) +local silver = dataset(cacheDir("SILVER_CACHE", "rv2283-silver/silver")) +local crystal = dataset(cacheDir("CRYSTAL_CACHE", "crystal-dev/crystal")) + +if not (gold and crystal) then + check(true, "gold or crystal cache absent : SKIP") +else + local g = Fingerprint.compute(gold, {}, 2) + local c = Fingerprint.compute(crystal, {}, 2) + eq(c, g, "a vanilla Crystal cache digests the same as a vanilla Gold cache") + if silver then + eq(Fingerprint.compute(silver, {}, 2), g, "and Silver matches both") + else + check(true, "silver cache absent : SKIP") + end + check(crystal.pokemon.tutorMoves ~= nil, + "the crystal cache really carries the tutor list") + eq(Fingerprint.records(crystal, "pokemon", 2).tutorMoves, nil, + "and it is not treated as a species") +end + +S.finish() diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua index 51e55538..5554aa6e 100644 --- a/tests/gen2_menus_test.lua +++ b/tests/gen2_menus_test.lua @@ -2838,6 +2838,150 @@ modRowChecks() Sound.play = realPlay end)() +-- engine/items/pack.asm:562, engine/pokemon/mon_menu.asm:270 +;(function() + local Typer = require("src.ui.gen2.Typer") + local HeldItemMenu = require("src.ui.gen2.HeldItemMenu") + local ITEMS = { + BERRY = { id = "BERRY", name = "BERRY", pocket = "ITEM", index = 1, + canToss = true, fieldMenu = "ITEMMENU_NOUSE" }, + POTION = { id = "POTION", name = "POTION", pocket = "ITEM", index = 2, + canToss = true, fieldMenu = "ITEMMENU_PARTY" }, + } + + local function drain(game, input, state) + for _ = 1, 50 do + if game.stack:top() ~= state then return end + if not (state.message or state.confirm) then return end + input.pressed = {} + for _ = 1, 400 do + state:update(0) + if not Typer.typing(state) then break end + end + input:press("a") + state:update(0) + end + end + + local function setup(inventory, party) + local save = Save.newGame({ playerName = "GOLD" }) + save.inventory = inventory + save.bagOrder = nil + save.party = party + local game, input = newGame(save) + game.data.items = ITEMS + game.world = { useFieldItem = function() return nil end } + local pack = PackMenu.new(game, { pocket = "ITEM" }) + game.stack:push(pack) + return save, game, input, pack + end + + local function rowOf(pack, id) + for _, row in ipairs(pack.rows) do + if row.id == id then return row end + end + end + + local function mon(nick, item, egg) + return { species = "CYNDAQUIL", nickname = nick, hp = 20, maxHp = 20, + level = 5, moves = {}, item = item, isEgg = egg } + end + + do + local save, game, input, pack = setup({ BERRY = 1 }, + { mon("ONE"), mon("TWO") }) + pack:giveItem(rowOf(pack, "BERRY")) + local party = game.stack:top() + check("2378 GIVE opens the party list", party.prompt, "To which ?") + input:press("a") + party:update(0) + drain(game, input, game.stack:top()) + check("2378 a give to an empty hand ends at the PACK", + game.stack:top(), pack) + check("2378 with the party list gone", #game.stack._items, 1) + check("2378 the mon holds the berry", save.party[1].item, "BERRY") + check("2378 and the bag has none", save.inventory.BERRY, nil) + check("2378 and the PACK dropped the row", rowOf(pack, "BERRY"), nil) + end + + do + local save, game, input, pack = setup({ BERRY = 1 }, + { mon("ONE", "BERRY"), mon("TWO") }) + pack:giveItem(rowOf(pack, "BERRY")) + local party = game.stack:top() + input:press("a") + party:update(0) + drain(game, input, game.stack:top()) + check("2378 a swap ends at the PACK", game.stack:top(), pack) + check("2378 the mon still holds one berry", save.party[1].item, "BERRY") + check("2378 and the bag got the old one back", save.inventory.BERRY, 1) + check("2378 and the second mon got nothing", save.party[2].item, nil) + end + + do + local save, game, input, pack = setup({ POTION = 1 }, + { mon("ONE", "BERRY"), mon("TWO") }) + pack:giveItem(rowOf(pack, "POTION")) + local party = game.stack:top() + input:press("a") + party:update(0) + local held = game.stack:top() + input.pressed = {} + for _ = 1, 400 do + held:update(0) + if not Typer.typing(held) then break end + end + input:press("a") + held:update(0) + for _ = 1, 400 do + held:update(0) + if not Typer.typing(held) then break end + end + input:press("down") + held:update(0) + input:press("a") + held:update(0) + check("2378 swap NO ends at the PACK", game.stack:top(), pack) + check("2378 and nothing moved", save.party[1].item, "BERRY") + check("2378 with the potion still in the bag", save.inventory.POTION, 1) + end + + do + local save, game, input, pack = setup({ BERRY = 1 }, + { mon("EGG", nil, true), mon("TWO") }) + pack:giveItem(rowOf(pack, "BERRY")) + local party = game.stack:top() + input:press("a") + party:update(0) + drain(game, input, game.stack:top()) + check("2378 an EGG refusal keeps the party list up", game.stack:top(), + party) + check("2378 and the egg holds nothing", save.party[1].item, nil) + input:press("b") + party:update(0) + check("2378 B from the list returns to the PACK", game.stack:top(), pack) + check("2378 with the berry still in the bag", save.inventory.BERRY, 1) + end + + do + local save = Save.newGame({ playerName = "GOLD" }) + save.inventory = {} + save.party = { mon("ONE", "BERRY") } + local game, input = newGame(save) + game.data.items = ITEMS + local closed = 0 + local held = HeldItemMenu.new(game, { save = save, slot = 1, + items = ITEMS, onClose = function() closed = closed + 1 end }) + game.stack:push(held) + held:giveItem("POTION") + drain(game, input, held) + check("2378 giving an item the bag lacks closes", closed, 1) + check("2378 without touching the mon", save.party[1].item, "BERRY") + check("2378 or minting its old item", save.inventory.BERRY, nil) + check("2378 or a potion", save.inventory.POTION, nil) + end +end)() + print(("gen2 menus: %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 diff --git a/tests/gen2_pc_screens_test.lua b/tests/gen2_pc_screens_test.lua index 90279f63..2acf7a3a 100644 --- a/tests/gen2_pc_screens_test.lua +++ b/tests/gen2_pc_screens_test.lua @@ -780,6 +780,7 @@ do def = { bgEvents = {} }, cellCollision = function(_, x, _) return x == 1 and 0x93 or (x == 3 and 0x94) or 0x00 end, + inBounds = function() return true end, }, player = { facing = "up", cellX = 1, cellY = 1, moving = false }, npcs = {}, diff --git a/tests/gen2_reflect_overflow_test.lua b/tests/gen2_reflect_overflow_test.lua index 12fed752..93e11c94 100644 --- a/tests/gen2_reflect_overflow_test.lua +++ b/tests/gen2_reflect_overflow_test.lua @@ -10,9 +10,7 @@ -- 512+ defence pushes the doubled value to 1024 and the truncated byte wraps -- to 0; DamageCalc's minimum-defence check turns that into 1 and the hit caps. -- Crystal repeats the pass until both stats fit, which is the fix CT-10 names --- reflectOverflow. Crystal kept the old arithmetic under LINK_COLOSSEUM; the --- Gen 2 battle engine here has no link mode, so only the single-player halves --- are pinned. +-- reflectOverflow. package.path = "./?.lua;./?/init.lua;" .. package.path @@ -148,8 +146,6 @@ end -- ------------------------------------------------------------- the override do - -- The gate is readable per call, which is where a LINK_COLOSSEUM carve-out - -- would hang if the Gen 2 engine ever grows one. GameVersion.set("crystal") local bugged = Damage.calc({ level = 50, power = 100, moveType = "NORMAL", @@ -166,6 +162,15 @@ do eq(fixed, 10, "Gold forced onto the fixed arm matches Crystal") end +-- ../pokecrystal/engine/battle/effect_commands.asm:2646 +do + local Battle = require("src.battle.gen2.Battle") + eq(Battle.reflectOverflowFixed({ linkBattle = true }), false, + "a link battle takes the single pass on every version") + eq(Battle.reflectOverflowFixed({}), nil, + "outside a link battle the version decides") +end + -- ------------------------------------------------------- the 999 stat cap do diff --git a/tests/gen2_trainers_test.lua b/tests/gen2_trainers_test.lua index 68340d51..858b35f7 100644 --- a/tests/gen2_trainers_test.lua +++ b/tests/gen2_trainers_test.lua @@ -302,7 +302,8 @@ local fightGame = { } local fightWorld = World.new(fightGame) fightGame.world = fightWorld -fightWorld.map = { def = { id = "TEST_MAP" } } +fightWorld.map = { def = { id = "TEST_MAP" }, + inBounds = function() return true end } fightWorld.maps = { TEST_MAP = fightWorld.map.def } fightWorld.events = Events.new() fightWorld.playBattleMusic = function() end diff --git a/tests/parity_H.lua b/tests/parity_H.lua index 5d519013..0874f540 100644 --- a/tests/parity_H.lua +++ b/tests/parity_H.lua @@ -155,6 +155,8 @@ local pushes = { checkDst = true }, } +local Sound = require("src.core.Sound") +local realSoundPlay = Sound.play for i, p in ipairs(pushes) do if ow.map.id ~= p.curMap then ow:setMap(p.curMap, 1, 1, "down") @@ -162,8 +164,15 @@ for i, p in ipairs(pushes) do local npc = { cellX = p.hx, cellY = p.hy, def = { name = p.srcName } } table.insert(ow.npcs, npc) table.insert(ow.entities, npc) + local played = {} + Sound.play = function(_, name, ...) played[#played + 1] = name end local ok = ow:boulderIntoHole(npc) + Sound.play = realSoundPlay check(ok, ("push %d: boulderIntoHole(%s) returns true"):format(i, p.srcName)) + check(Game.stack:top() == ow, + ("push %d: no text box after the boulder falls"):format(i)) + eq(#played, 0, ("push %d: boulder falling plays no sound"):format(i)) + while Game.stack:top() and Game.stack:top() ~= ow do Game.stack:pop() end check(Game.save.flags[p.event] == true, ("push %d: %s is set"):format(i, p.event)) check(not OW.objectVisible(Game.save, p.srcMap, objOf(p.srcMap, p.srcName)), diff --git a/tests/parity_bide_anim.lua b/tests/parity_bide_anim.lua index a6234c84..5630090f 100644 --- a/tests/parity_bide_anim.lua +++ b/tests/parity_bide_anim.lua @@ -100,11 +100,16 @@ do eq(rows[spiral] and rows[spiral].hit and rows[spiral].hit.animType, 6, "PlayBattleAnimation2 stamps wAnimationType 6 on the player's turn (#1564)") local used = indexOfText(rows, "used BIDE!") - local storing = indexOfText(rows, "storing energy!") check(used and spiral and used < spiral, "the spiral comes after the used-BIDE line") - check(spiral and storing and spiral < storing, - "and before the storing-energy line") + check(indexOfText(rows, "storing energy!") == nil, + "BideEffect prints no storing-energy line (#2338)") + local textAfter = false + for i = (spiral or #rows) + 1, #rows do + if rows[i].text then textAfter = true end + end + check(spiral ~= nil and not textAfter, + "no text row follows the spiral on the storing turn (#2338)") check(tb.moveAnimRow == nil, "the peeled move-anim row is not left dangling") check(not anyAnim(rows, "XSTATITEM_DUPLICATE_ANIM"), "the player never gets the enemy-side duplicate") @@ -125,10 +130,17 @@ do check(dup and not rows[dup].isPlayer, "attributed to the enemy side") eq(rows[dup] and rows[dup].hit and rows[dup].hit.animType, 3, "and wAnimationType 3 on the enemy's turn (#1564)") + check(indexOfText(rows, "storing energy!") == nil, + "the foe's storing turn prints no storing-energy line (#2338)") + local textAfter = false + for i = (dup or #rows) + 1, #rows do + if rows[i].text then textAfter = true end + end + check(dup ~= nil and not textAfter, + "no text row follows the foe's spiral (#2338)") end --- locked turns: .BideCheck decrements with no text of its own in pokered and --- no animation either; the port prints the storing line, and that is all +-- engine/battle/core.asm:3481-3501 do local tb = freshBattle() tb.player.bideTurns = 3 @@ -136,12 +148,24 @@ do tb:continueBide(tb.player, tb.enemy) local rows = trace(tb) eq(tb.player.bideTurns, 2, "a locked turn just counts down") - check(indexOfText(rows, "storing energy!") ~= nil, "and reprints the line") + eq(#tb.queue, 0, "a locked turn queues nothing at all (#2338)") + check(indexOfText(rows, "storing energy!") == nil, + "a locked turn prints no storing-energy line (#2338)") for _, r in ipairs(rows) do check(r.anim == nil, "no animation on a locked turn: " .. tostring(r.anim)) end end +-- engine/battle/core.asm:5856-5876 +do + local tb = freshBattle() + tb.enemy.bideTurns = 2 + tb.enemy.bideDamage = 0 + tb:continueBide(tb.enemy, tb.player) + eq(tb.enemy.bideTurns, 1, "the foe's locked turn counts down") + eq(#tb.queue, 0, "and queues nothing either (#2338)") +end + -- release turn: text, then BIDE's animation, then the bar drains do local tb = freshBattle() diff --git a/tests/parity_pokeflute_battle_tune_bug2305.lua b/tests/parity_pokeflute_battle_tune_bug2305.lua new file mode 100644 index 00000000..ca599653 --- /dev/null +++ b/tests/parity_pokeflute_battle_tune_bug2305.lua @@ -0,0 +1,84 @@ +-- audio/poke_flute.asm:1 +-- audio/sfx/pokeflute_ch7.asm:1 +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 in-battle poke flute #2305") +local check, eq = S.check, S.eq + +local ChipSynth = require("src.core.ChipSynth") +local Sound = require("src.core.Sound") +local GameVersion = require("src.core.GameVersion") + +local function readAll(path, mode) + local f = io.open(path, mode or "r") + if not f then return nil end + local body = f:read("*a") + f:close() + return body +end + +local function framesOf(data, def) + local rate = ChipSynth.SAMPLE_RATE + local engine = ChipSynth.newEngine(data, def, + { sfx = true, allowLoops = false, frameTicks = 0x100 }) + local ran, cap = 0, rate * 20 + while ran < cap and not engine:finished() do + ran = ran + 1 + engine:sample() + end + return engine:finished() and ran * 60 / rate or nil +end + +local function runCache(label, version, root) + local audioPath = root .. "/data/generated/audio.lua" + local progPath = root .. "/assets/generated/audio/programs.bin" + local chunk = loadfile(audioPath) + local bytes = readAll(progPath, "rb") + if not (chunk and bytes) then return false end + local audio = chunk() + local data = { audio = audio } + local realRead = love.filesystem.read + local savedVersion = GameVersion.current + love.filesystem.read = function(name, ...) + if name == audio.programFile then return bytes end + return realRead(name, ...) + end + ChipSynth.invalidateBanks() + GameVersion.current = version + local ok, err = pcall(function() + local def = Sound._pokefluteInBattleDef(data) + check(def ~= nil, label .. ": the in-battle flute derives from Caught_Mon") + if not def then return end + local battle = framesOf(data, def) + local field = framesOf(data, audio.sfx.Pokeflute) + check(battle and math.abs(battle - 256) <= 1, + ("%s: in-battle flute runs 256 frames (got %s)"):format(label, + tostring(battle))) + check(field and math.abs(field - 576) <= 1, + ("%s: field flute runs 576 frames (got %s)"):format(label, + tostring(field))) + eq(ChipSynth.effectChannels(data, def) ~= nil, true, + label .. ": the derived def reads as a header") + end) + GameVersion.current = savedVersion + love.filesystem.read = realRead + ChipSynth.invalidateBanks() + check(ok, label .. ": renders" .. (ok and "" or (": " .. tostring(err)))) + return true +end + +local savedRate = ChipSynth.SAMPLE_RATE +ChipSynth.setSampleRate(8000) +local okTree = pcall(runCache, "tree", os.getenv("POKEPORT_VERSION") or "red", ".") +ChipSynth.setSampleRate(savedRate) +check(okTree, "tree cache ran") + +local yellow = os.getenv("POKEPORT_YELLOW_CACHE") +if yellow and yellow ~= "" then + ChipSynth.setSampleRate(8000) + local okY = pcall(runCache, "yellow", "yellow", yellow) + ChipSynth.setSampleRate(savedRate) + check(okY, "yellow cache ran") +end + +S.finish() diff --git a/tests/parity_seam_palette_timing_bug2186.lua b/tests/parity_seam_palette_timing_bug2186.lua new file mode 100644 index 00000000..dd619832 --- /dev/null +++ b/tests/parity_seam_palette_timing_bug2186.lua @@ -0,0 +1,100 @@ +-- home/overworld.asm:675 +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 Game = require("src.core.Game") +local Input = require("src.core.Input") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local PaletteFX = require("src.render.PaletteFX") +local Zoom = require("src.render.Zoom") +local OW = require("src.world.OverworldController") +local S = require("tests.harness").suite("seam palette timing #2186") +local check, eq = S.check, S.eq + +local savedZoom, savedMode = Zoom.offset, PaletteFX.mode + +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack +StateStack:init() +Game.save = SaveData.newGame() +Game.overworld = OW +PaletteFX.setMode("gbc") + +local function key(colors) + if not colors then return "nil" end + local out = {} + for i, c in ipairs(colors) do + out[i] = table.concat(c, ",") + end + return table.concat(out, "|") +end + +local function landStep(ow) + for _ = 1, 40 do + ow:update(1 / 60) + if not ow.player.moving then return true end + end + return false +end + +while Game.stack:top() do Game.stack:pop() end +Game.stack:push(OW, "PALLET_TOWN", 10, 0, "up") +local ow = Game.stack:top() +local north = ow.map:connection("north") +check(north and north.map == "ROUTE_1", "Pallet north connects to ROUTE_1") + +Zoom.offset = 0 +local palletName = ow:paletteNameFor(ow.map) +local pallet = key(PaletteFX.pal(Data, palletName)) +eq(#ow:sgbWorldZones(), 1, "FIT at Pallet is one whole-screen world zone") + +check(ow:crossConnection("up", north) == true, "Pallet -> Route 1 crosses") +eq(ow.map.id, "ROUTE_1", "map data swapped to ROUTE_1 at the seam") +local routeName = ow:paletteNameFor(ow.map) +local route = key(PaletteFX.pal(Data, routeName)) +check(route ~= pallet, "Route 1 and Pallet wear different palettes") +check(ow.player.moving, "seam step is in progress") + +local zones = ow:sgbWorldZones() +eq(#zones, 1, "seam step is still one whole-screen world zone") +eq(key(zones[1].colors), pallet, "world keeps Pallet's palette while the seam step walks") +eq(key(ow:sgbPalettes()[1].colors), pallet, "UI pass keeps Pallet's palette while the seam step walks") + +ow:update(1 / 60) +check(ow.player.moving, "one frame in, the seam step is still walking") +eq(key(ow:sgbWorldZones()[1].colors), pallet, "mid-step world zone is still Pallet") + +check(landStep(ow), "seam step lands") +eq(ow.seamPalette, nil, "held palette cleared once the step lands") +zones = ow:sgbWorldZones() +eq(#zones, 1, "landed on Route 1: one whole-screen zone") +eq(key(zones[1].colors), route, "world flips to Route 1's palette on landing") +eq(key(ow:sgbPalettes()[1].colors), route, "UI pass flips to Route 1's palette on landing") + +Zoom.offset = -1 +local south = ow.map:connection("south") +check(south and south.map == "PALLET_TOWN", "Route 1 south connects to Pallet") +ow.player.facing = "down" +check(ow:crossConnection("down", south) == true, "Route 1 -> Pallet crosses in survey zoom") +zones = ow:sgbWorldZones() +eq(key(zones[1].colors), pallet, "survey zoom base zone is the destination map immediately") +check(#zones > 1, "survey zoom keeps per-map neighbor zones") +check(landStep(ow), "survey seam step lands") + +Zoom.offset = 0 +north = ow.map:connection("north") +check(ow:crossConnection("up", north) == true, "Pallet -> Route 1 crosses again") +check(ow.seamPalette ~= nil, "outgoing palette held for the seam step") +ow:setMap("REDS_HOUSE_1F", 3, 7, "up") +eq(ow.seamPalette, nil, "a warp mid-step drops the held palette") + +Zoom.offset = savedZoom +PaletteFX.setMode(savedMode) +S.finish() diff --git a/tests/rom_importer_android_mod_pick_test.lua b/tests/rom_importer_android_mod_pick_test.lua index 8789067e..1bd75f73 100644 --- a/tests/rom_importer_android_mod_pick_test.lua +++ b/tests/rom_importer_android_mod_pick_test.lua @@ -59,15 +59,15 @@ ri:chooseMod() eq(#pickCalls, 1, "chooseMod opens the picker when no pending zip exists") eq(pickCalls[1], "mod", "chooseMod asks pickFile for a mod") --- Pending USB zip installs without opening the picker. love.filesystem.write("usb_mod.zip", "PK\0fake") pickCalls = {} ri = freshImporter({ red = true, blue = true }) ri:chooseMod() -eq(#pickCalls, 0, "chooseMod installs a pending zip without opening the picker") -eq(ri._installed, "usb_mod.zip", "chooseMod consumed the USB zip") -check(love.filesystem.getInfo("usb_mod.zip") == nil, - "successful install removes the pending zip") +eq(#pickCalls, 1, "a loose save-root zip does not stop the picker opening") +eq(ri._installed, nil, "chooseMod leaves the loose zip uninstalled") +check(love.filesystem.getInfo("usb_mod.zip") ~= nil, + "the player's loose zip is left in place") +love.filesystem.remove("usb_mod.zip") -- Focus consumes picked_mod.zip even when both ROMs are already ready. love.filesystem.write("picked_mod.zip", "PK\0saf") diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 7d8c9650..7442521a 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -3736,6 +3736,7 @@ runSuites(orderedGlob( "tests/gen2_repel_test.lua", "tests/gen2_swarm_test.lua", "tests/gen2_fishing_swarm_test.lua", + "tests/gen2_facing_edge_2352_test.lua", "tests/gen2_rock_smash_test.lua", "tests/gen2_currents_test.lua", "tests/gen2_big_object_test.lua", diff --git a/tests/save_editor_item_move_bug1951_test.lua b/tests/save_editor_item_move_bug1951_test.lua new file mode 100644 index 00000000..54bc826f --- /dev/null +++ b/tests/save_editor_item_move_bug1951_test.lua @@ -0,0 +1,382 @@ +package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua" + .. ";./tools/save-editor/panels/?.lua" + +love = require("tests.love_stub") + +local passed, failed = 0, 0 + +local function check(cond, msg) + if cond then + passed = passed + 1 + else + failed = failed + 1 + print("FAIL: " .. msg) + end +end + +local function eq(a, b, msg) + check(a == b, msg .. string.format(" (got %s, want %s)", tostring(a), tostring(b))) +end + +print("== save editor item move tests (#1951) ==") + +local Ops = require("Ops") +local State = require("State") +local Catalog = require("Catalog") +local Gen = require("Gen") +local Bag = require("src.inventory.Bag") +local SaveData = require("src.core.SaveData") +local G3 = require("Game3Adapter") + +local function has(list, id) + for _, v in ipairs(list or {}) do if v == id then return true end end + return false +end + +local Data = require("src.core.Data") +Data:load() + +local function red() + local S = State.new() + S.data = Data + S.cat = Catalog.build(Data) + S.save = SaveData.newGame() + S.save.inventory, S.save.bagOrder, S.save.pcItems = {}, nil, {} + return S +end + +local function fillers(S, n, skip) + local out = {} + for _, id in ipairs(S.cat.items) do + if #out >= n then break end + if not Ops.isBadgeId(id) and not (skip and skip[id]) then out[#out + 1] = id end + end + return out +end + +check(type(Ops.bagToPc) == "function" and type(Ops.pcToBag) == "function" + and type(Ops.moveCount) == "function", "gen1: move ops exist") + +do + local S = red() + Bag.add(S.save, "POTION", 30, S.data) + S.selectedBagId = "POTION" + local ok = Ops.bagToPc and Ops.bagToPc(S, "POTION") + check(ok == true, "gen1: bagToPc succeeds") + eq(S.save.inventory.POTION, nil, "gen1: whole stack leaves the bag") + check(not has(S.save.bagOrder, "POTION"), "gen1: bagOrder drops the moved id") + eq(S.save.pcItems.POTION, 30, "gen1: PC gets the stack") + check(S.dirty == true, "gen1: a move marks dirty") + eq(S.selectedBagId, nil, "gen1: a vanished bag row is deselected") + + S.dirty = false + ok = Ops.pcToBag and Ops.pcToBag(S, "POTION") + check(ok == true, "gen1: pcToBag succeeds") + eq(S.save.pcItems.POTION, nil, "gen1: PC stack empties") + eq(S.save.inventory.POTION, 30, "gen1: bag gets the stack back") + check(has(Bag.order(S.save, S.data), "POTION"), "gen1: bag order has the id again") + check(S.dirty == true, "gen1: pcToBag marks dirty") +end + +do + local S = red() + Bag.add(S.save, "POTION", 30, S.data) + S.save.pcItems.POTION = 90 + eq(Ops.moveCount and Ops.moveCount(S, true, "POTION"), 9, "gen1: move clamps to the 99 stack") + check(Ops.bagToPc and Ops.bagToPc(S, "POTION") == true, "gen1: clamped bagToPc succeeds") + eq(S.save.pcItems.POTION, 99, "gen1: PC tops out at 99") + eq(S.save.inventory.POTION, 21, "gen1: remainder stays in the bag") + check(tostring(S.status):find("21 left in the bag", 1, true) ~= nil, + "gen1: status names the remainder: " .. tostring(S.status)) + S.dirty = false + eq(Ops.moveCount and Ops.moveCount(S, true, "POTION"), 0, "gen1: nothing more fits") + check(Ops.bagToPc and Ops.bagToPc(S, "POTION") == false, "gen1: full PC stack refuses") + check(S.dirty == false, "gen1: refusal does not dirty") +end + +do + local S = red() + local ids = fillers(S, 51, { POTION = true }) + for i = 1, 50 do S.save.pcItems[ids[i]] = 1 end + Bag.add(S.save, "POTION", 5, S.data) + S.dirty = false + check(Ops.bagToPc and Ops.bagToPc(S, "POTION") == false, "gen1: 51st PC stack refused") + eq(S.save.pcItems.POTION, nil, "gen1: refused move leaves PC untouched") + eq(S.save.inventory.POTION, 5, "gen1: refused move leaves the bag untouched") + check(S.dirty == false, "gen1: refused move not dirty") + check(Ops.addToPc(S, ids[51]) == false, "gen1: addToPc refuses a 51st PC id") + eq(S.save.pcItems[ids[51]], nil, "gen1: refused addToPc leaves no entry") + check(Ops.addToPc(S, ids[1]) == true, "gen1: addToPc still grows an existing stack") +end + +do + local S = red() + local ids = fillers(S, 20, { POTION = true, ULTRA_BALL = true }) + for i = 1, 20 do Bag.add(S.save, ids[i], 1, S.data) end + S.save.pcItems.ULTRA_BALL = 5 + S.dirty = false + check(Ops.pcToBag and Ops.pcToBag(S, "ULTRA_BALL") == false, "gen1: full bag refuses pcToBag") + eq(S.save.pcItems.ULTRA_BALL, 5, "gen1: refused pcToBag leaves PC") + eq(S.save.inventory.ULTRA_BALL, nil, "gen1: refused pcToBag adds nothing") + check(tostring(S.status):find("Bag is full", 1, true) ~= nil, "gen1: status says bag is full") + check(S.dirty == false, "gen1: refused pcToBag not dirty") + Bag.remove(S.save, ids[20], 1) + check(Ops.pcToBag and Ops.pcToBag(S, "ULTRA_BALL") == true, "gen1: pcToBag works once a slot frees") + eq(S.save.inventory.ULTRA_BALL, 5, "gen1: bag gets all 5") +end + +do + local S = red() + Bag.add(S.save, "BICYCLE", 1, S.data) + check(Ops.bagToPc and Ops.bagToPc(S, "BICYCLE") == true, "gen1: key item deposits") + eq(S.save.pcItems.BICYCLE, 1, "gen1: BICYCLE in PC") + check(Ops.pcToBag and Ops.pcToBag(S, "BICYCLE") == true, "gen1: key item withdraws") + eq(S.save.inventory.BICYCLE, 1, "gen1: BICYCLE back in bag") + S.save.inventory.BOULDERBADGE = true + eq(Ops.moveCount and Ops.moveCount(S, true, "BOULDERBADGE"), 0, "gen1: badges never move") +end + +do + local S = red() + Bag.add(S.save, "POTION", 30, S.data) + S.save.pcItems.ANTIDOTE = 4 + S.save.pcOrder = { "ANTIDOTE" } + if Ops.bagToPc then Ops.bagToPc(S, "POTION") end + if Ops.pcToBag then Ops.pcToBag(S, "ANTIDOTE") end + local back = assert(SaveData.decode(SaveData.encode(S.save))) + eq(back.pcItems.POTION, 30, "gen1: moved PC stack survives encode") + eq(back.pcItems.ANTIDOTE, nil, "gen1: withdrawn PC stack gone after encode") + eq(back.inventory.ANTIDOTE, 4, "gen1: withdrawn stack in bag after encode") + local GenSave = require("src.save_convert.GenSave") + GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")()) + local okE, bytes = pcall(GenSave.encode, back, Data) + check(okE and type(bytes) == "string", "gen1: .sav encode runs: " .. tostring(bytes)) + if okE and type(bytes) == "string" then + local okD, dec = pcall(GenSave.decode, bytes, Data) + check(okD and type(dec) == "table", "gen1: .sav decode runs: " .. tostring(dec)) + if okD and type(dec) == "table" then + local sav = dec.save or dec + eq(sav.pcItems and sav.pcItems.POTION, 30, "gen1: .sav round trip keeps the moved stack") + eq(sav.pcItems and sav.pcItems.ANTIDOTE, nil, "gen1: .sav round trip drops the stale pcOrder id") + eq(sav.inventory and sav.inventory.ANTIDOTE, 4, "gen1: .sav round trip keeps the withdrawn stack") + end + end +end + +do + local GameVersion = require("src.core.GameVersion") + local Save2 = require("src.core.gen2.Save") + local data2 = { + pokemon = {}, moves = {}, maps = {}, + items = { + POTION = { pocket = "ITEM", index = 20, name = "POTION" }, + MASTER_BALL = { pocket = "BALL", index = 1, name = "MASTER BALL" }, + BICYCLE = { pocket = "KEY_ITEM", index = 6, name = "BICYCLE" }, + HM_01 = { pocket = "TM_HM", index = 249, name = "HM01" }, + }, + } + local function gold() + local prior = GameVersion.get() + GameVersion.set("gold") + local S = State.new() + S.data = data2 + S.cat = Catalog.build(data2) + S.save = Save2.newGame() + S.version = "gold" + S.save.inventory, S.save.bagOrder, S.save.pcItems = {}, nil, {} + GameVersion.set(prior) + return S + end + + local S = gold() + eq(Gen.ofState(S), 2, "gen2: fixture is gen 2") + for i = 1, 20 do Bag.add(S.save, "FILLER_" .. i, 1, S.data) end + S.save.pcItems.MASTER_BALL = 3 + S.save.pcItems.POTION = 4 + check(Ops.pcToBag and Ops.pcToBag(S, "MASTER_BALL") == true, "gen2: BALL pocket free while ITEM is full") + eq(S.save.inventory.MASTER_BALL, 3, "gen2: MASTER_BALL in bag") + S.dirty = false + check(Ops.pcToBag and Ops.pcToBag(S, "POTION") == false, "gen2: 21st ITEM pocket id refused") + eq(S.save.pcItems.POTION, 4, "gen2: refused ITEM withdraw leaves PC") + check(S.dirty == false, "gen2: refused withdraw not dirty") + + S = gold() + S.save.pcItems.POTION = 150 + check(Ops.pcToBag and Ops.pcToBag(S, "POTION") == true, "gen2: 150 PC potions withdraw") + eq(S.save.inventory.POTION, 99, "gen2: bag takes one 99 stack") + eq(S.save.pcItems.POTION, 51, "gen2: 51 stay in PC") + + S = gold() + Bag.add(S.save, "HM_01", 1, S.data) + check(Ops.bagToPc and Ops.bagToPc(S, "HM_01") == true, "gen2: HM deposits") + eq(S.save.pcItems.HM_01, 1, "gen2: HM_01 in PC") + check(Ops.pcToBag and Ops.pcToBag(S, "HM_01") == true, "gen2: HM withdraws") + eq(S.save.inventory.HM_01, 1, "gen2: HM_01 back in bag") + + S = gold() + for i = 1, 49 do S.save.pcItems["FILLER_" .. i] = 1 end + S.save.pcItems.POTION = 99 + Bag.add(S.save, "POTION", 5, S.data) + S.dirty = false + check(Ops.bagToPc and Ops.bagToPc(S, "POTION") == false, "gen2: move needing a 51st PC stack refused") + eq(S.save.inventory.POTION, 5, "gen2: refused deposit leaves bag") + + S = gold() + Ops.addToPc(S, "BICYCLE") + S.dirty = false + check(Ops.addToPc(S, "BICYCLE") == false, "gen2: second key item add refused") + eq(S.save.pcItems.BICYCLE, 1, "gen2: key item in PC stays x1") + check(S.dirty == false, "gen2: refused key item add not dirty") + Ops.pcAdjust(S, "BICYCLE", 1) + eq(S.save.pcItems.BICYCLE, 1, "gen2: key item + stays x1") + + S = gold() + S.save.pcItems.BICYCLE = 3 + eq(Ops.moveCount(S, false, "BICYCLE"), 1, "gen2: key item withdraw count is 1") + check(Ops.pcToBag(S, "BICYCLE") == true, "gen2: key item withdraws") + eq(S.save.inventory.BICYCLE, 1, "gen2: key item lands in the bag x1") + eq(S.save.pcItems.BICYCLE, 2, "gen2: key item withdraw takes only 1") + eq(Ops.moveCount(S, false, "BICYCLE"), 0, "gen2: held key item cannot withdraw again") + + S = gold() + Bag.add(S.save, "BICYCLE", 1, S.data) + S.save.pcItems.BICYCLE = 1 + eq(Ops.moveCount(S, true, "BICYCLE"), 0, "gen2: key item deposit cannot stack past 1") +end + +do + local Schema = require("src.core.game3.save_schema_firered") + local Copy = require("src.mods.Merge").deepCopy + local function deq(a, b) + if type(a) ~= "table" or type(b) ~= "table" then return a == b end + for k, v in pairs(a) do if not deq(v, b[k]) then return false end end + for k in pairs(b) do if a[k] == nil then return false end end + return true + end + local mock = {} + Gen.bindGame3Data(mock) + local function fire() + local save = Schema.newGame({ name = "RED" }) + save.bag.pockets.ITEMS, save.bag.pockets.POKE_BALLS = {}, {} + save.bag.pockets.TM_CASE, save.bag.pockets.KEY_ITEMS = {}, {} + save.storage.items = {} + local S = { data = mock, version = "firered", save = save, cat = Catalog.build(mock, save, "firered") } + Gen.hydrateSave(mock, save) + return S + end + + eq(G3.PC_ITEMS_COUNT, 30, "gen3: editor PC cap is FRLG PC_ITEMS_COUNT") + + local S = fire() + Ops.addToBag(S, "POTION"); Ops.bagAdjust(S, "POTION", 9) + eq(S.save.inventory["13"], 10, "gen3: seeded 10 potions") + check(Ops.bagToPc and Ops.bagToPc(S, "POTION") == true, "gen3: bagToPc succeeds") + eq(S.save.inventory["13"], nil, "gen3: projection bag empty") + eq(S.save.pcItems["13"], 10, "gen3: projection PC 10") + eq(#S.save.bag.pockets.ITEMS, 0, "gen3: native ITEMS pocket emptied") + eq(S.save.storage.items[1] and S.save.storage.items[1].qty, 10, "gen3: native storage slot written") + check(Ops.pcToBag and Ops.pcToBag(S, "13") == true, "gen3: pcToBag succeeds") + eq(S.save.inventory["13"], 10, "gen3: back in bag") + eq(#S.save.storage.items, 0, "gen3: native storage emptied") + + S = fire() + for i = 1, 42 do S.save.bag.pockets.ITEMS[i] = { id = 2000 + i, qty = 1 } end + S.save.storage.items = { { id = 13, qty = 5 } } + Gen.hydrateSave(mock, S.save) + local before = Copy(S.save) + S.dirty = false + check(Ops.pcToBag and Ops.pcToBag(S, "13") == false, "gen3: full ITEMS pocket refuses") + check(deq(S.save, before), "gen3: refused withdraw rolls back fully") + check(S.dirty == false, "gen3: refused withdraw not dirty") + + S = fire() + for i = 1, 30 do S.save.storage.items[i] = { id = 2000 + i, qty = 1 } end + S.save.bag.pockets.ITEMS = { { id = 13, qty = 3 } } + Gen.hydrateSave(mock, S.save) + before = Copy(S.save) + S.dirty = false + check(Ops.bagToPc and Ops.bagToPc(S, "13") == false, "gen3: 31st PC slot refused") + check(deq(S.save, before), "gen3: refused deposit rolls back fully") + check(Ops.addToPc(S, "POTION") == false, "gen3: addToPc refuses a 31st PC slot") + check(deq(S.save, before), "gen3: refused addToPc rolls back fully") + + S = fire() + S.save.storage.items = { { id = 289, qty = 1 } } + Gen.hydrateSave(mock, S.save) + check(Ops.pcToBag and Ops.pcToBag(S, "289") == true, "gen3: TM withdraws") + check(S.save.inventory["364"] == 1, "gen3: TM withdraw adds TM_CASE") + eq(S.save.inventory["289"], 1, "gen3: TM01 in bag") + eq(Ops.moveCount and Ops.moveCount(S, true, "364"), 0, "gen3: TM_CASE itself cannot go to PC") + local out = G3.export(S.save) + check(type(out.bag) == "table" and out.pcItems == nil, "gen3: export drops the flat projection") + local tmSlot = out.bag.pockets.TM_CASE and out.bag.pockets.TM_CASE[1] + eq(tmSlot and tmSlot.id, 289, "gen3: export keeps TM01 in the TM_CASE pocket") + + S = fire() + S.save.bag.pockets.ITEMS = { { id = 13, qty = 999 } } + S.save.storage.items = { { id = 13, qty = 990 } } + Gen.hydrateSave(mock, S.save) + eq(Ops.moveCount and Ops.moveCount(S, true, "13"), 9, "gen3: move clamps to the 999 stack") + check(Ops.bagToPc and Ops.bagToPc(S, "13") == true, "gen3: clamped move succeeds") + eq(S.save.pcItems["13"], 999, "gen3: PC tops out at 999") + eq(S.save.inventory["13"], 990, "gen3: remainder stays in the bag") +end + +do + local Kit = require("Kit") + local App = require("App") + local oldDim = love.graphics.getDimensions + for _, size in ipairs({ { 1280, 720 }, { 500, 800 } }) do + love.graphics.getDimensions = function() return size[1], size[2] end + local path = os.tmpname() .. "-bug1951.lua" + local seed = SaveData.newGame() + seed.inventory, seed.bagOrder = { POTION = 30 }, { "POTION" } + seed.pcItems = { ULTRA_BALL = 5 } + local f = assert(io.open(path, "wb")); f:write(SaveData.encode(seed)); f:close() + App.load(path, { version = "red" }) + local S = App.getState() + S.tab = "items" + local rects = {} + local real = Kit.button + Kit.button = function(x, y, w, h, label, opts) + if label == "PC" or label == "BAG" then + rects[#rects + 1] = { label = label, x = x, y = y, w = w, h = h, + enabled = not (opts and opts.enabled == false) } + end + return real(x, y, w, h, label, opts) + end + local pcBtn, bagBtn + local function capture() + rects = {} + App.draw() + pcBtn, bagBtn = nil, nil + for _, r in ipairs(rects) do + if r.label == "PC" then pcBtn = r elseif r.label == "BAG" then bagBtn = r end + end + end + capture() + if pcBtn and pcBtn.y > size[2] - 60 then + S.itemsScroll = pcBtn.y - 120 + capture() + capture() + end + local tag = ("%dx%d"):format(size[1], size[2]) + check(pcBtn ~= nil and pcBtn.enabled, tag .. " panel: bag row has an enabled PC button") + check(bagBtn ~= nil and bagBtn.enabled, tag .. " panel: PC row has an enabled BAG button") + if pcBtn then + App.mousepressed(pcBtn.x + pcBtn.w / 2, pcBtn.y + pcBtn.h / 2, 1) + App.draw() + eq(S.save.pcItems.POTION, 30, tag .. " panel: PC click moved the potions") + eq(S.save.inventory.POTION, nil, tag .. " panel: bag row emptied") + check(tostring(S.status):find("Moved 30", 1, true) ~= nil, + tag .. " panel: move status is not clobbered by row select: " .. tostring(S.status)) + end + Kit.button = real + App.unload() + os.remove(path) + end + love.graphics.getDimensions = oldDim +end + +print(string.format("save editor item move tests: %d passed, %d failed", passed, failed)) +if failed > 0 then os.exit(1) end diff --git a/tests/shaderfx_bridge_packaging_test.lua b/tests/shaderfx_bridge_packaging_test.lua index 9cc58c02..c64f6def 100644 --- a/tests/shaderfx_bridge_packaging_test.lua +++ b/tests/shaderfx_bridge_packaging_test.lua @@ -81,4 +81,59 @@ for _, pair in ipairs({ { rg34, "build-rg34xxsp.sh" }, { sbc, "build-linux-arm-s mustContain(pair[1], "SHADERFX_BRIDGE_REQUIRED", pair[2]) end +check(release:find("ANDROID_NDK_LATEST_HOME", 1, true) == nil, + "release.yml must not build the Android bridge with the runner's newest NDK") +mustContain(release, "mobile/android/app/build.gradle", "release.yml reads gradle's NDK pin") +mustContain(release, '"ndk;$ndk_ver"', "release.yml installs gradle's NDK") +mustContain(release, "bash scripts/android_bridge_link_check.sh", "release.yml link-checks the CI bridge") +mustContain(android, 'android_bridge_link_check.sh" "$bridge" "$libcxx"', "build_android.sh link check") +mustContain(android, '"lib/$abi/libc++_shared.so"', "build_android.sh checks against the APK's own libc++") +mustContain(android, 'shader_bridge_gradle_libcxx "$abi"', "build_android.sh checks prebuilt bridges before gradle") + +local gradle = read("mobile/android/app/build.gradle") +local gradleNdk = gradle:match("ndkVersion%s+['\"]([%d%.]+)['\"]") +check(gradleNdk ~= nil, "mobile/android/app/build.gradle pins ndkVersion") +mustContain(android, 'NDK_VERSION="' .. tostring(gradleNdk) .. '"', "build_android.sh NDK_VERSION matches gradle") + +local function sh(cmd) + local a, b, c = os.execute(cmd) + if type(a) == "number" then return a >= 256 and math.floor(a / 256) or a end + if b == "exit" then return c end + return a and 0 or 1 +end + +local tmp = os.tmpname() +os.remove(tmp) +check(sh("mkdir -p '" .. tmp .. "'") == 0, "temp dir for the link-check fixture") +local function put(name, body) + local f = assert(io.open(tmp .. "/" .. name, "w")) + f:write(body) + f:close() + return tmp .. "/" .. name +end +local fakeNm = put("llvm-nm", '#!/bin/sh\nfor a do last="$a"; done\ncat "$last"\n') +sh("chmod +x '" .. fakeNm .. "'") +local libcxx = put("libc++_shared.so", + "0000000000001000 T _ZNSt6__ndk16chrono12system_clock3nowEv\n" + .. "0000000000002000 T _Znwm@@LIBCXX_NDK\n") +local good = put("good.so", + " U _Znwm\n" + .. " U memcpy@LIBC\n" + .. " w _ZWeakOnly\n" + .. " U _ZNSt6__ndk16chrono12system_clock3nowEv\n") +local bad = put("bad.so", " U _Znwm\n U _ZTVNSt6__ndk117bad_function_callE\n") +local checker = "LLVM_NM='" .. fakeNm .. "' bash scripts/android_bridge_link_check.sh " +local out = tmp .. "/out.txt" +T.eq(sh(checker .. "'" .. good .. "' '" .. libcxx .. "' >'" .. out .. "' 2>&1"), 0, + "link check passes a bridge whose C++ symbols libc++_shared.so defines") +T.eq(sh(checker .. "'" .. bad .. "' '" .. libcxx .. "' >'" .. out .. "' 2>&1"), 1, + "link check fails a bridge that needs a symbol libc++_shared.so lacks") +local f = io.open(out, "r") +local body = f and f:read("*a") or "" +if f then f:close() end +mustContain(body, "_ZTVNSt6__ndk117bad_function_callE", "link check output names the missing symbol") +T.eq(sh(checker .. "'" .. bad .. "' '" .. tmp .. "/absent.so' >/dev/null 2>&1"), 1, + "link check fails a bridge whose APK carries no libc++_shared.so") +sh("rm -rf '" .. tmp .. "'") + T.finish("shaderfx_bridge_packaging_test") diff --git a/tools/save-editor/Game3Adapter.lua b/tools/save-editor/Game3Adapter.lua index 46df6ba4..5e0afd8b 100644 --- a/tools/save-editor/Game3Adapter.lua +++ b/tools/save-editor/Game3Adapter.lua @@ -6,6 +6,7 @@ local Mons = require("src.core.game3.save_mon") local M = {} local pockets = { "ITEMS", "KEY_ITEMS", "POKE_BALLS", "TM_CASE", "BERRY_POUCH" } +M.PC_ITEMS_COUNT = 30 -- include/constants/global.h:35 function M.itemId(data, id) local def = data and data.items and data.items[id] @@ -124,7 +125,7 @@ local function set(data, save, pc, id, qty) id = M.itemId(data, id) local slots, cap if pc then - slots, cap = save.storage.items, Storage.PC_ITEMS_COUNT + slots, cap = save.storage.items, M.PC_ITEMS_COUNT else for _, p in ipairs(pockets) do local list = save.bag.pockets[p] @@ -186,4 +187,21 @@ function M.change(data, save, pc, changes) return true end +-- src/item_menu.c:2004 Task_TryDoItemDeposit / src/item.c:385 AddPCItem +function M.transfer(data, save, toPc, id, qty) + if type(qty) ~= "number" or qty <= 0 or qty ~= math.floor(qty) then return false end + local fromQty = M.quantity(data, save, not toPc, id) + local toQty = M.quantity(data, save, toPc, id) + if qty > fromQty or toQty + qty > 999 then return false end + local staged = { bag = Copy(save.bag), storage = {} } + for k, value in pairs(save.storage) do staged.storage[k] = value end + staged.storage.items = Copy(save.storage.items) + if not set(data, staged, toPc, id, toQty + qty) then return false end + if not set(data, staged, not toPc, id, fromQty - qty) then return false end + save.bag, save.storage = staged.bag, staged.storage + M.ensureStorage(save) + M.project(data, save) + return true +end + return M diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index ef132d1d..c74bcc47 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -1233,17 +1233,37 @@ function Ops.pcSort(S, mode) return not repeated end +-- ram/wram.asm:1895 wBoxItems / pokecrystal ram/wram.asm:3120 wPCItems +local function pcCapacity(S) + return (S.data and S.data.field and S.data.field.pcItemCap) or 50 +end + +local function pcStacksFree(S, id, addQty) + local pc = Ops.pcItems(S) + local used = 0 + for rowId in pairs(pc) do used = used + math.ceil(math.max(0, itemQty(pc, rowId)) / 99) end + local held = math.max(0, itemQty(pc, id)) + return used - math.ceil(held / 99) + math.ceil((held + addQty) / 99) <= pcCapacity(S) +end + +-- engine/events/pokecenter_pc.asm:505 _CheckTossableItem +local function slotMax(S, id) + if Gen.ofState(S) == 2 and not Ops.itemStacks(S, id) then return 1 end + return Ops.stackMax(S) +end + function Ops.addToPc(S, id) if Gen.ofState(S) == 3 then return changeG3(S, true, id, G3.quantity(S.data, S.save, true, id) + 1) end if not id then return Ops.say(S, "Pick an item first") end local pc = Ops.pcItems(S) - local n = 0 - for _ in pairs(pc) do n = n + 1 end - if not pc[id] and Gen.ofState(S) == 2 and n >= 50 then - return Ops.say(S, "PC item storage is full (50 stacks)") + if not pc[id] and not pcStacksFree(S, id, 1) then + return Ops.say(S, ("PC item storage is full (%d stacks)"):format(pcCapacity(S))) end local cur = itemQty(pc, id) - pc[id] = math.min(Ops.stackMax(S), cur + 1) + if cur >= slotMax(S, id) then + return Ops.say(S, ("%s is already at x%d"):format(tostring(id), slotMax(S, id))) + end + pc[id] = math.min(slotMax(S, id), cur + 1) return Ops.mark(S, ("%s x%d in PC storage"):format(tostring(id), pc[id])) end @@ -1253,10 +1273,10 @@ function Ops.pcAdjust(S, id, delta) local pc = Ops.pcItems(S) local cur = itemQty(pc, id) if not pc[id] and cur <= 0 then return Ops.say(S, ("%s is not in PC storage"):format(tostring(id))) end - if delta > 0 and cur >= Ops.stackMax(S) then - return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.stackMax(S))) + if delta > 0 and cur >= slotMax(S, id) then + return Ops.say(S, ("%s is already at x%d"):format(tostring(id), slotMax(S, id))) end - local nextQty = clamp(cur + delta, 0, Ops.stackMax(S)) + local nextQty = clamp(cur + delta, 0, math.max(cur, slotMax(S, id))) if nextQty <= 0 then pc[id] = nil return Ops.mark(S, ("Removed %s from PC storage"):format(tostring(id))) @@ -1326,6 +1346,92 @@ function Ops.pcMaxAll(S) :format(n, n == 1 and "" or "s", Ops.stackMax(S))) end +local function itemContainer(S, id) + local Items3 = require("src.core.game3.items_data") + local id3 = G3.itemId(S.data, id) + return id3 == Items3.ITEM_TM_CASE or id3 == Items3.ITEM_BERRY_POUCH +end + +function Ops.moveCount(S, toPc, id) + if id == nil or Ops.isBadgeId(id) then return 0 end + local fromHave, toHave + if Gen.ofState(S) == 3 then + if toPc and itemContainer(S, id) then return 0 end + fromHave = G3.quantity(S.data, S.save, not toPc, id) + toHave = G3.quantity(S.data, S.save, toPc, id) + else + local inv = S.save.inventory or {} + fromHave = itemQty(toPc and inv or Ops.pcItems(S), id) + toHave = itemQty(toPc and Ops.pcItems(S) or inv, id) + end + return math.max(0, math.min(fromHave, slotMax(S, id) - toHave)) +end + +local function itemName(S, id) + local def = S.data and S.data.items and S.data.items[id] + return tostring((def and def.name) or id) +end + +local function moveG3(S, toPc, id, n) + local left = G3.quantity(S.data, S.save, not toPc, id) - n + if not G3.transfer(S.data, S.save, toPc, id, n) then + return Ops.say(S, toPc and "No room to store items in PC storage" + or "That bag pocket is full") + end + return left +end + +-- engine/menus/players_pc.asm:86 PlayerPCDeposit +function Ops.bagToPc(S, id) + if id == nil then return Ops.say(S, "No bag row selected") end + local n = Ops.moveCount(S, true, id) + if n <= 0 then + return Ops.say(S, ("%s cannot go to PC storage"):format(itemName(S, id))) + end + local left + if Gen.ofState(S) == 3 then + left = moveG3(S, true, id, n) + if not left then return false end + else + if not pcStacksFree(S, id, n) then + return Ops.say(S, ("PC item storage is full (%d stacks)"):format(pcCapacity(S))) + end + local pc = Ops.pcItems(S) + left = itemQty(S.save.inventory, id) - n + pc[id] = itemQty(pc, id) + n + Bag.remove(S.save, id, n) + end + if left <= 0 and S.selectedBagId == id then S.selectedBagId = nil end + return Ops.mark(S, ("Moved %d %s to PC storage%s"):format(n, itemName(S, id), + left > 0 and (" (%d left in the bag)"):format(left) or "")) +end + +-- engine/menus/players_pc.asm:140 PlayerPCWithdraw +function Ops.pcToBag(S, id) + if id == nil then return Ops.say(S, "No PC row selected") end + local n = Ops.moveCount(S, false, id) + if n <= 0 then + return Ops.say(S, ("%s cannot go to the bag"):format(itemName(S, id))) + end + local left + if Gen.ofState(S) == 3 then + left = moveG3(S, false, id, n) + if not left then return false end + else + if not Bag.add(S.save, id, n, S.data) then + local pocket = Bag.pocketOf(id, S.data) + return Ops.say(S, ("Bag is full (%d/%d %s slots)"):format( + Bag.slots(S.save, S.data, pocket), Bag.capacity(S.data, pocket), pocket)) + end + local pc = Ops.pcItems(S) + left = itemQty(pc, id) - n + pc[id] = left > 0 and left or nil + end + if left <= 0 and S.selectedPcId == id then S.selectedPcId = nil end + return Ops.mark(S, ("Moved %d %s to the bag%s"):format(n, itemName(S, id), + left > 0 and (" (%d left in PC storage)"):format(left) or "")) +end + -- Badges are truthy inventory flags, not stackable items, which is why the -- design gives them toggle chips instead of quantity rows. function Ops.isBadgeId(id) diff --git a/tools/save-editor/SaveIO.lua b/tools/save-editor/SaveIO.lua index 76c2d81b..8782a0df 100644 --- a/tools/save-editor/SaveIO.lua +++ b/tools/save-editor/SaveIO.lua @@ -58,10 +58,13 @@ function SaveIO.choosePath() elseif platform == "Windows" then local script = table.concat({ "Add-Type -AssemblyName System.Windows.Forms;", + "$o=New-Object System.Windows.Forms.Form;", + "$o.TopMost=$true;", + "$o.ShowInTaskbar=$false;", "$d=New-Object System.Windows.Forms.OpenFileDialog;", "$d.Title='Choose a Pokemon save.lua';", "$d.Filter='Save files (*.lua)|*.lua|All files (*.*)|*.*';", - "if($d.ShowDialog() -eq 'OK'){[Console]::Write($d.FileName)}", + "if($d.ShowDialog($o) -eq 'OK'){[Console]::Write($d.FileName)}", }) return commandOutput( 'powershell -NoProfile -STA -Command "' .. script .. '"') diff --git a/tools/save-editor/panels/Items.lua b/tools/save-editor/panels/Items.lua index 315fdbd9..68123b09 100644 --- a/tools/save-editor/panels/Items.lua +++ b/tools/save-editor/panels/Items.lua @@ -1,6 +1,6 @@ -- Items panel: money, the shared item picker, badges, the configurable bag -- (Bag.add/remove, ordered by Bag.order) and PC item storage (a plain --- S.save.pcItems dict with no slot cap). +-- S.save.pcItems dict). -- -- The picker is a searchable list rather than the old pair of arrows that -- cycled one id at a time through ~250 items, which was the single worst @@ -10,7 +10,7 @@ -- inventory flags, not stackable items, and must not look like quantity rows. -- -- #715 reflow: side by side the wallet column plus the two quantity lists --- need about 900 real px (a quantity row's -/+/x cluster alone is ~110px). +-- need about 900 real px (a quantity row's -/+/move/x cluster alone is ~150px). -- Below that the five cards stack in one full-width column that scrolls in -- pixels (Kit.scrollPixels); the inner lists keep their own wheel/drag -- regions, which claim the notch first when the pointer is over them. @@ -28,11 +28,12 @@ local COIN_STEPS = { -100, -10, 10, 100 } -- One quantity row shape, shared by the bag and the PC list: id, qty, then local function quantityRow(S, Kit, x, y, w, h, id, qty, selected, onMinus, onPlus, - onMax, canMax, onDrop) + onMax, canMax, onDrop, moveLabel, onMove, canMove) local s = Kit.scale local clicked = Kit.row(x, y, w, h, selected, PAL.blue, 9 * s) local btn = 24 * s - local bx = x + w - 10 * s - 4 * btn - 3 * (6 * s) + local mw = moveLabel and math.max(btn, Kit.textWidth("tiny", moveLabel) + 10 * s) or 0 + local bx = x + w - 10 * s - 4 * btn - 3 * (6 * s) - (moveLabel and (mw + 6 * s) or 0) if Kit.stepper(bx, y + (h - btn) / 2, btn, btn, "-", { font = "small" }) then onMinus() end @@ -44,7 +45,16 @@ local function quantityRow(S, Kit, x, y, w, h, id, qty, selected, onMinus, onPlu tostring(Ops.stackMax(S)), { font = "tiny", enabled = canMax }) then onMax() end - if Kit.button(bx + 3 * (btn + 6 * s), y + (h - btn) / 2, btn, btn, "x", + local dx = bx + 3 * (btn + 6 * s) + if moveLabel then + if Kit.button(dx, y + (h - btn) / 2, mw, btn, moveLabel, + { kind = "ghost", font = "tiny", radius = 6 * s, enabled = canMove }) then + onMove() + clicked = false + end + dx = dx + mw + 6 * s + end + if Kit.button(dx, y + (h - btn) / 2, btn, btn, "x", { kind = "danger", font = "tiny", radius = 6 * s }) then onDrop() end @@ -283,7 +293,9 @@ local function drawQuantityCard(S, Kit, x, y, w, h, cfg) function() if cfg.adjust then cfg.adjust(id, -1) end end, function() if cfg.adjust then cfg.adjust(id, 1) end end, function() if cfg.max then cfg.max(id) end end, cfg.canMax and cfg.canMax(id), - function() if cfg.drop then cfg.drop(id) end end) then + function() if cfg.drop then cfg.drop(id) end end, + cfg.moveLabel, function() if cfg.move then cfg.move(id) end end, + cfg.canMove and cfg.canMove(id)) then if cfg.select then cfg.select(id) end end end @@ -320,6 +332,9 @@ local function drawBag(S, Kit, x, y, w, h) canMaxAll = function() return Ops.bagCanMax(S) end, sort = function(mode) Ops.bagSort(S, mode) end, drop = function(id) Ops.bagDrop(S, id) end, + moveLabel = "PC", + move = function(id) Ops.bagToPc(S, id) end, + canMove = function(id) return Ops.moveCount(S, true, id) > 0 end, }) end @@ -344,6 +359,9 @@ local function drawPc(S, Kit, x, y, w, h) canMaxAll = function() return Ops.pcCanMax(S) end, sort = function(mode) Ops.pcSort(S, mode) end, drop = function(id) Ops.pcDrop(S, id) end, + moveLabel = "BAG", + move = function(id) Ops.pcToBag(S, id) end, + canMove = function(id) return Ops.moveCount(S, false, id) > 0 end, }) end