Merge branch 'dev' into pr-2409

# Conflicts:
#	src/ui/game3/save_menu.lua
This commit is contained in:
bryanthaboi
2026-09-22 16:41:00 -04:00
134 changed files with 11244 additions and 516 deletions
+16 -1
View File
@@ -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"
+2
View File
@@ -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
+16 -2
View File
@@ -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
+78 -40
View File
@@ -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,
}
+1 -1
View File
@@ -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. |
+3
View File
@@ -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
+60 -2
View File
@@ -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
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
export LC_ALL=C
if [ $# -ne 2 ]; then
echo "usage: $0 <liblibrashader_bridge.so> <libc++_shared.so>" >&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")"
+71 -5
View File
@@ -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/<abi>/$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
+1
View File
@@ -417,6 +417,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
+20 -4
View File
@@ -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.
-1
View File
@@ -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 = {
+9
View File
@@ -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",
+3 -3
View File
@@ -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;",
+1 -2
View File
@@ -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]
+84
View File
@@ -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
+17 -4
View File
@@ -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
+20
View File
@@ -368,6 +368,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 = {},
@@ -858,6 +859,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]
+45
View File
@@ -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
+11 -2
View File
@@ -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
+35
View File
@@ -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 })
+111 -9
View File
@@ -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)
+259 -55
View File
@@ -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
+24 -15
View File
@@ -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
+15 -2
View File
@@ -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
+2 -1
View File
@@ -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
+4 -4
View File
@@ -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)
+76 -2
View File
@@ -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 "<dir>/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
+109
View File
@@ -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
+21 -1
View File
@@ -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
@@ -359,11 +360,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
@@ -781,6 +785,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
@@ -1046,6 +1056,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
@@ -1078,9 +1096,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
+116 -9
View File
@@ -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
+28 -3
View File
@@ -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
+2
View File
@@ -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
+3 -1
View File
@@ -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
+10
View File
@@ -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)
+8 -1
View File
@@ -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
+22 -5
View File
@@ -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
+163 -57
View File
@@ -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
+35 -6
View File
@@ -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
+49 -9
View File
@@ -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
+11 -1
View File
@@ -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)
+6 -1
View File
@@ -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()
+80 -53
View File
@@ -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)
+7 -1
View File
@@ -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.
+13 -5
View File
@@ -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)
+40 -58
View File
@@ -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,50 +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
-- review-v3 V1: game3 sessions carry the badge flags (0x820-0x827), not
-- badgeN keys; boot's ContinueInfo reads the same flags via Flags.countBadges.
if count == 0 and type(session.flags) == "table" then
for id = 0x820, 0x827 do
if session.flags[id] or session.flags[tostring(id)] then count = count + 1 end
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
-- review-v3 V6: saves written before the caught key existed (owned-only
-- writers) fall back to owned; fresh writers keep the two in step.
local t = dex.caught
if t == nil then t = dex.owned or {} end
for sp, on in pairs(t) 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)
@@ -190,14 +180,6 @@ end
-- GetMapNameGeneric(dest, gMapHeader.regionMapSectionId) -> region_map.c
-- GetMapName(dst, mapsec, 0), i.e. the sMapNames place name and never the
-- engine's internal map id (which is what session.map holds).
--
-- CONTRACT (intentional; pinned by tests/engine/save_menu_location_bug2328):
-- session.mapName / session.regionMapSectionId / session.mapSec are a
-- session/mod injection seam — when a caller supplies them they win over the
-- def-derived path. The engine itself never writes these three fields (V8:
-- zero assignments in src, absent from every save_schema build), so the branch
-- is dead for engine-authored sessions and live for injected ones. Do not
-- remove: the bug2328 regression pin exists precisely to keep this override.
function SaveMenu.locationName(session)
session = session or {}
if type(session.mapName) == "string" and session.mapName ~= "" and not session.mapName:find("^FR_") and not session.mapName:find("^SEVII_") then
@@ -243,12 +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 0 -- review-v3 V5: no phantom caughtMonsCount fallback
-- review-v3 V7: the nested playtime is what the schema persists
-- (save_schema_firered.lua playTime ← session.playtime); the flat mirrors
-- are only written by the runtime ticker, so a freshly loaded save showed
-- 0:00 before.
local badges = SaveMenu.countBadges(session)
local hasDex = SaveMenu.hasDex(session)
local pt = session.playtime or session.playTime or {}
local hours = tonumber(pt.hours or session.playTimeHours or session.hours) or 0
local mins = tonumber(pt.minutes or session.playTimeMinutes or session.minutes) or 0
@@ -269,11 +247,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()
+2
View File
@@ -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
+2
View File
@@ -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,
+29 -4
View File
@@ -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)
+76 -2
View File
@@ -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
+9 -6
View File
@@ -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]
@@ -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
+4 -5
View File
@@ -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
+220
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,154 @@
-- ../pokegold/engine/items/pack.asm:562
-- ../pokegold/engine/pokemon/mon_menu.asm:270
-- POKEPORT_VERSION=gold POKEPORT_IDENTITY=<identity> POKEPORT_TOUCH=0 \
-- POKEPORT_SHOT_DIR=<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
@@ -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()
@@ -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
@@ -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
+8 -10
View File
@@ -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
@@ -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
@@ -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
@@ -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
+149
View File
@@ -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
+221
View File
@@ -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
@@ -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
@@ -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
@@ -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
+13 -10
View File
@@ -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
+236
View File
@@ -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
+2
View File
@@ -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")
@@ -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
@@ -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
@@ -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
@@ -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
+39 -15
View File
@@ -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
+134
View File
@@ -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
+111
View File
@@ -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
+245
View File
@@ -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)")
+46 -27
View File
@@ -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()
@@ -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
@@ -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)
@@ -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
@@ -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")
+326
View File
@@ -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()
+25 -9
View File
@@ -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
@@ -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")
@@ -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()
+163 -5
View File
@@ -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),
@@ -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")
+4 -1
View File
@@ -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)")
+305
View File
@@ -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)")
+5 -5
View File
@@ -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
+215
View File
@@ -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()
+475
View File
@@ -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)")
+278
View File
@@ -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()

Some files were not shown because too many files have changed in this diff Show More