mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4a1dd9e16 | |||
| 6f75a64c46 | |||
| cd37aa175c | |||
| cb6cfb5556 | |||
| 439e97aee6 | |||
| 5528525888 | |||
| a49ba66c36 | |||
| 9de4db8531 | |||
| dbc48f377a | |||
| 1d77d42fe2 | |||
| 683fee8028 | |||
| a83d18fc51 | |||
| b9e8b00af0 | |||
| e2820e02c5 | |||
| f92364a002 | |||
| 9696c8d3e2 | |||
| 18b2bcd0a7 | |||
| f56970350a | |||
| 863f371e68 | |||
| 24c5114745 | |||
| 7def560726 | |||
| 6cbd0de77d | |||
| 9a8101df6a | |||
| 78744a6853 | |||
| efce1bb116 | |||
| 104c95a942 | |||
| f6392e8932 | |||
| 88e34ef65c | |||
| 8f0117a145 | |||
| 1f878c3098 | |||
| f0ed2efe07 | |||
| bbf48c7e9e | |||
| 99d54b82b6 | |||
| 49776ca1eb | |||
| 272305f3a4 | |||
| 89f023c7dd | |||
| afc5a52978 | |||
| 12a04f4188 | |||
| 0fe7a96781 | |||
| a06a49bfed | |||
| 86b90fd4b8 | |||
| 78ac154998 | |||
| bbcaac7b71 | |||
| 54ae20c69b | |||
| 1963a5453e | |||
| 59a383736c | |||
| d377518632 | |||
| f74e21782b | |||
| 7b060c19f4 | |||
| 604b9338f9 | |||
| 92f73d8bed | |||
| e3fcdd0776 |
@@ -260,6 +260,117 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
linux-arm64-changes:
|
||||
name: detect Linux arm64 changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.paths.outputs.changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: paths
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_linux_arm64\.sh$|scripts/linux-arm64/|scripts/pack_love\.sh$|docs/linux-arm64-build\.md$|\.github/workflows/(ci|release)\.yml$)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
linux-arm64-selftest:
|
||||
name: Linux arm64 offline selftest
|
||||
needs: linux-arm64-changes
|
||||
if: needs.linux-arm64-changes.outputs.changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# Deliberately on x86_64: everything this gate checks (pins, the
|
||||
# host-arch guard, the dependency exclude list, the AppRun fusion
|
||||
# contract) is answerable without a container or an aarch64 machine,
|
||||
# so the slow native job below only ever starts on a sane tree.
|
||||
- name: Linux arm64 offline selftest
|
||||
run: bash scripts/linux-arm64/selftest_build_linux_arm64.sh
|
||||
|
||||
linux-arm64-build:
|
||||
name: Linux arm64 AppImage build
|
||||
needs: [linux-arm64-changes, linux-arm64-selftest]
|
||||
if: |
|
||||
always()
|
||||
&& needs.linux-arm64-changes.outputs.changed == 'true'
|
||||
&& needs.linux-arm64-selftest.result == 'success'
|
||||
# No fork restriction, unlike switch-build: this needs no secrets and no
|
||||
# self-hosted hardware, just GitHub's free arm64 runner for public repos,
|
||||
# so contributors get the same coverage on their own PRs.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build the aarch64 AppImage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_linux_arm64.sh --version 0.0.0
|
||||
- name: Verify the AppImage is self-contained and bullseye-compatible
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image="dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage"
|
||||
|
||||
# --appimage-extract needs no FUSE, so this works on a runner
|
||||
# without /dev/fuse and still exercises the real payload.
|
||||
"$image" --appimage-extract >/dev/null
|
||||
for required in AppRun bin/love game.love lib/liblove-11.5.so; do
|
||||
[ -e "squashfs-root/$required" ] \
|
||||
|| { echo "::error::AppImage is missing $required"; exit 1; }
|
||||
done
|
||||
|
||||
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
|
||||
# applied; an unresolved soname here is a user-visible launch crash.
|
||||
#
|
||||
# This runs on a HEADLESS runner on purpose, and that is the point.
|
||||
# The first version of this build bundled Debian's SDL2, which
|
||||
# hard-links libpulse/libasound/libX11/libwayland, so it only ever
|
||||
# started on a full desktop -- a bare runner is what exposed it.
|
||||
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
|
||||
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
||||
| grep 'not found' || true)"
|
||||
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
|
||||
|
||||
# Nothing may hard-link a driver, session or audio-stack library:
|
||||
# those must be reached through dlopen so the AppImage runs on a box
|
||||
# with only ALSA, only Wayland, or only KMSDRM.
|
||||
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
|
||||
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
|
||||
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
|
||||
[ -z "$linked" ] \
|
||||
|| { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; }
|
||||
|
||||
# The whole point of compiling on bullseye. If a future change moves
|
||||
# the builder to a newer base, the glibc floor silently rises and
|
||||
# every user on an older distro gets "GLIBC_2.xx not found" -- catch
|
||||
# it here instead of in a release.
|
||||
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
||||
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
|
||||
echo "highest required glibc symbol version: $floor"
|
||||
[ -n "$floor" ] \
|
||||
|| { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; }
|
||||
highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
|
||||
[ "$highest" = "GLIBC_2.31" ] \
|
||||
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
|
||||
- name: Upload the AppImage
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-linux-arm64
|
||||
path: |
|
||||
dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage
|
||||
dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
headless:
|
||||
name: headless suites (no ROM)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -140,6 +140,36 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
linux-arm64:
|
||||
name: build Linux arm64 AppImage
|
||||
needs: [version, love-payload]
|
||||
# GitHub's free arm64 runner for public repos. It has to be arm64: the
|
||||
# AppImage compiles LÖVE natively inside a Debian bullseye arm64
|
||||
# container, and the qemu-emulated alternative takes hours.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Download shared payload
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-release-love
|
||||
path: .bazinga/work
|
||||
- name: Build Linux arm64 AppImage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_linux_arm64.sh \
|
||||
--version "${{ needs.version.outputs.version }}" \
|
||||
--game-love .bazinga/work/game.love
|
||||
- name: Upload Linux arm64 release
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-linux-arm64-release
|
||||
path: |
|
||||
dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage
|
||||
dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
xbox-uwp:
|
||||
name: build Xbox UWP release
|
||||
needs: [version, love-payload]
|
||||
@@ -222,7 +252,7 @@ jobs:
|
||||
}
|
||||
|
||||
release:
|
||||
needs: [version, xbox-uwp]
|
||||
needs: [version, xbox-uwp, linux-arm64]
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
|
||||
steps:
|
||||
@@ -367,6 +397,13 @@ jobs:
|
||||
name: gen1recomp-xbox-uwp-release
|
||||
path: dist/xbox-uwp
|
||||
|
||||
- name: Download Linux arm64 release
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-linux-arm64-release
|
||||
path: dist/linux-arm64
|
||||
|
||||
- name: Stage release assets
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
id: assets
|
||||
@@ -379,6 +416,15 @@ jobs:
|
||||
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
|
||||
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
|
||||
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
|
||||
|
||||
# arm64 desktop Linux (Raspberry Pi, Armbian, arm64 VMs). Built on
|
||||
# its own runner because LÖVE publishes no aarch64 binary and the
|
||||
# AppImage has to be compiled natively; ships as a runnable
|
||||
# AppImage rather than a zip so `chmod +x && ./it` just works.
|
||||
arm64_appimage="dist/linux-arm64/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
|
||||
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||
@@ -512,6 +558,7 @@ jobs:
|
||||
"dist/release/gen1recomp-${v}-macos.zip"
|
||||
"dist/release/gen1recomp-${v}-windows.zip"
|
||||
"dist/release/gen1recomp-${v}-linux.zip"
|
||||
"dist/release/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
"dist/release/gen1recomp-${v}-android.apk"
|
||||
"dist/release/gen1recomp-${v}-ios.ipa"
|
||||
"dist/release/gen1recomp-${v}-switch.zip"
|
||||
|
||||
@@ -205,6 +205,39 @@ even on a different computer, as long as the same folder comes along.
|
||||
already written to either location is touched automatically, so copy files
|
||||
over yourself if you want to carry existing progress across the switch.
|
||||
|
||||
## Launch Options
|
||||
|
||||
By default the app opens the launcher so you can pick a game. Launch options
|
||||
skip it and start one game directly, which is what you want for a one-click
|
||||
entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
|
||||
|
||||
| Option | Effect |
|
||||
| --- | --- |
|
||||
| `--game=red` | boot Red, skipping the launcher (`blue` and `yellow` too, or just `r` / `b` / `y`) |
|
||||
| `--slot=2` | load that save slot; takes a slot number or a slot id |
|
||||
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
|
||||
|
||||
|
||||
## Linux on arm64 (Raspberry Pi)
|
||||
|
||||
Alongside the x86_64 `gen1recomp-*-linux.zip`, every release ships
|
||||
`gen1recomp-*-linux-arm64.AppImage` for 64-bit ARM desktop Linux — Raspberry
|
||||
Pi 4/5, Armbian and other SBC distros, and arm64 VMs on Apple Silicon:
|
||||
|
||||
```sh
|
||||
chmod +x gen1recomp-*-linux-arm64.AppImage
|
||||
./gen1recomp-*-linux-arm64.AppImage
|
||||
```
|
||||
|
||||
LÖVE publishes no aarch64 binary of any kind, so this artifact compiles the
|
||||
engine — and SDL2, OpenAL and the codecs — from source inside a Debian
|
||||
bullseye arm64 container. It needs only glibc 2.29+, libstdc++, freetype and
|
||||
zlib on the host; OpenGL, X11, Wayland, KMSDRM, ALSA and PulseAudio are all
|
||||
dlopened, so the same image runs on a full desktop, a Wayland-only session or
|
||||
a KMSDRM handheld with no X server. Build instructions and the reasoning are
|
||||
in [docs/linux-arm64-build.md](docs/linux-arm64-build.md).
|
||||
|
||||
|
||||
## iOS
|
||||
|
||||
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
|
||||
|
||||
@@ -16,9 +16,13 @@ local function push(game, s, done)
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
end
|
||||
|
||||
-- PrintText on a text_end string returns with the box still drawn and
|
||||
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
|
||||
-- engine/menus/text_box.asm); no A press clears the question first. Ride
|
||||
-- TextBox's opts.choice, the same as Commands.ask (#854).
|
||||
local function ask(game, s, cb)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
M.PEWTER_CITY = {
|
||||
|
||||
@@ -23,9 +23,13 @@ local function push(game, s, done)
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
end
|
||||
|
||||
-- PrintText on a text_end string returns with the box still drawn and
|
||||
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
|
||||
-- engine/menus/text_box.asm); no A press clears the question first. Ride
|
||||
-- TextBox's opts.choice, the same as Commands.ask (#854).
|
||||
local function ask(game, s, cb)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
M.VIRIDIAN_CITY = {
|
||||
|
||||
+37
-14
@@ -18,6 +18,26 @@ local M = {
|
||||
VIRIDIAN_GYM = { city = "VIRIDIAN CITY", leader = "GIOVANNI", badge = "EARTHBADGE" },
|
||||
}
|
||||
|
||||
-- The originals' middle branch shared by every leader's text_asm: beaten
|
||||
-- but EVENT_GOT_TM* unset means the bag was full when the victory script
|
||||
-- ran GiveItem, so talking to the leader re-runs the ReceiveTM script.
|
||||
-- Returns true when the retry took over the talk. A save from before
|
||||
-- #797 already holds the TM without the flag; treat the owned TM as
|
||||
-- received so those saves fall through to the advice text instead of
|
||||
-- collecting a second copy.
|
||||
local function retryTmGive(game, ow, victoryKey, done)
|
||||
local reward = require("data.scripts.victories")[victoryKey]
|
||||
if not (reward and reward.gotFlag) then return false end
|
||||
if game.save.flags[reward.gotFlag] then return false end
|
||||
local owned = game.save.inventory and game.save.inventory[reward.item] or 0
|
||||
if owned > 0 then
|
||||
game.save.flags[reward.gotFlag] = true
|
||||
return false
|
||||
end
|
||||
ow:offerGymTm(reward, done)
|
||||
return true
|
||||
end
|
||||
|
||||
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
|
||||
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
|
||||
-- _PewterGymBrockPreBattleText and engages the leader battle
|
||||
@@ -25,12 +45,14 @@ local M = {
|
||||
-- badge/TM34 rewards and EVENT_BEAT_BROCK come from
|
||||
-- data/scripts/victories.lua OPP_BROCK#1). After the badge his
|
||||
-- .afterBeat branch prints _PewterGymBrockPostBattleAdviceText ("Go to
|
||||
-- the GYM in CERULEAN..."). The original's middle branch (beat but
|
||||
-- TM34 not yet handed over, CheckEventReuseA EVENT_GOT_TM34) is
|
||||
-- unreachable in the port: the TM is granted with the victory.
|
||||
-- the GYM in CERULEAN..."). The middle branch (beat but TM34 not yet
|
||||
-- handed over, CheckEventReuseA EVENT_GOT_TM34 -> call
|
||||
-- PewterGymScriptReceiveTM34) retries the TM give when the bag was full
|
||||
-- at the victory (#797).
|
||||
M.PEWTER_GYM.talk = {
|
||||
TEXT_PEWTERGYM_BROCK = function(game, ow, npc, done)
|
||||
if game.save.flags.EVENT_BEAT_BROCK then
|
||||
if retryTmGive(game, ow, "OPP_BROCK#1", done) then return end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._PewterGymBrockPostBattleAdviceText
|
||||
@@ -48,16 +70,17 @@ M.PEWTER_GYM.talk = {
|
||||
-- (engageTrainer shows that same pre-battle text via resolveText; the
|
||||
-- badge/TM rewards and the beat flag come from data/scripts/victories.lua)
|
||||
-- -- and once beaten print the post-battle advice text. As with Brock,
|
||||
-- the originals' middle branch (beaten but the TM not yet handed over,
|
||||
-- CheckEventReuseA EVENT_GOT_TM*) is unreachable in the port: the TM is
|
||||
-- granted with the victory.
|
||||
-- the middle branch (beaten but the TM not yet handed over,
|
||||
-- CheckEventReuseA EVENT_GOT_TM*) retries the TM give when the bag was
|
||||
-- full at the victory.
|
||||
-- afterAdvice, when given, takes over `done`: it is handed (game, ow, npc,
|
||||
-- done) and must call done() itself once whatever it's doing (e.g. a fade
|
||||
-- around a HideObject) finishes, rather than having it invoked
|
||||
-- automatically. Only Giovanni's farewell uses this.
|
||||
local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice)
|
||||
local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryKey)
|
||||
return function(game, ow, npc, done)
|
||||
if game.save.flags[beatFlag] then
|
||||
if victoryKey and retryTmGive(game, ow, victoryKey, done) then return end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local finish = done
|
||||
if afterAdvice then
|
||||
@@ -79,42 +102,42 @@ end
|
||||
M.CERULEAN_GYM.talk = {
|
||||
TEXT_CERULEANGYM_MISTY = leaderTalk("EVENT_BEAT_MISTY",
|
||||
"_CeruleanGymMistyTM11ExplanationText",
|
||||
"TM11 teaches\nBUBBLEBEAM!"),
|
||||
"TM11 teaches\nBUBBLEBEAM!", nil, "OPP_MISTY#1"),
|
||||
}
|
||||
|
||||
-- scripts/VermilionGym.asm VermilionGymLTSurgeText .got_tm24_already
|
||||
M.VERMILION_GYM.talk = {
|
||||
TEXT_VERMILIONGYM_LT_SURGE = leaderTalk("EVENT_BEAT_LT_SURGE",
|
||||
"_VermilionGymLTSurgePostBattleAdviceText",
|
||||
"A little word of\nadvice, kid!"),
|
||||
"A little word of\nadvice, kid!", nil, "OPP_LT_SURGE#1"),
|
||||
}
|
||||
|
||||
-- scripts/CeladonGym.asm CeladonGymErikaText .afterBeat
|
||||
M.CELADON_GYM.talk = {
|
||||
TEXT_CELADONGYM_ERIKA = leaderTalk("EVENT_BEAT_ERIKA",
|
||||
"_CeladonGymErikaPostBattleAdviceText",
|
||||
"You are cataloging\nPOKéMON? I must\nsay I'm impressed."),
|
||||
"You are cataloging\nPOKéMON? I must\nsay I'm impressed.", nil, "OPP_ERIKA#1"),
|
||||
}
|
||||
|
||||
-- scripts/FuchsiaGym.asm FuchsiaGymKogaText .afterBeat
|
||||
M.FUCHSIA_GYM.talk = {
|
||||
TEXT_FUCHSIAGYM_KOGA = leaderTalk("EVENT_BEAT_KOGA",
|
||||
"_FuchsiaGymKogaPostBattleAdviceText",
|
||||
"When afflicted by\nTOXIC, POKéMON\nsuffer more and\nmore as battle\nprogresses!"),
|
||||
"When afflicted by\nTOXIC, POKéMON\nsuffer more and\nmore as battle\nprogresses!", nil, "OPP_KOGA#1"),
|
||||
}
|
||||
|
||||
-- scripts/SaffronGym.asm SaffronGymSabrinaText .afterBeat
|
||||
M.SAFFRON_GYM.talk = {
|
||||
TEXT_SAFFRONGYM_SABRINA = leaderTalk("EVENT_BEAT_SABRINA",
|
||||
"_SaffronGymSabrinaPostBattleAdviceText",
|
||||
"Everyone has\npsychic power!\nPeople just don't\nrealize it!"),
|
||||
"Everyone has\npsychic power!\nPeople just don't\nrealize it!", nil, "OPP_SABRINA#1"),
|
||||
}
|
||||
|
||||
-- scripts/CinnabarGym.asm CinnabarGymBlaineText .afterBeat
|
||||
M.CINNABAR_GYM.talk = {
|
||||
TEXT_CINNABARGYM_BLAINE = leaderTalk("EVENT_BEAT_BLAINE",
|
||||
"_CinnabarGymBlainePostBattleAdviceText",
|
||||
"FIRE BLAST is the\nultimate fire\ntechnique!"),
|
||||
"FIRE BLAST is the\nultimate fire\ntechnique!", nil, "OPP_BLAINE#1"),
|
||||
}
|
||||
|
||||
-- scripts/ViridianGym.asm ViridianGymGiovanniText .afterBeat: after the
|
||||
@@ -142,7 +165,7 @@ M.VIRIDIAN_GYM.talk = {
|
||||
"VIRIDIAN_GYM", "VIRIDIANGYM_GIOVANNI")
|
||||
end
|
||||
end, done))
|
||||
end),
|
||||
end, "OPP_GIOVANNI#3"),
|
||||
}
|
||||
|
||||
return M
|
||||
|
||||
+57
-36
@@ -800,9 +800,13 @@ M.SILPH_CO_11F = {
|
||||
-- line) would touch, and the whole Silph ending -- the flag, the Master
|
||||
-- Ball, the Saffron streets clearing -- silently never happened.
|
||||
--
|
||||
-- engageTrainer shows TEXT_SILPHCO11F_GIOVANNI as the battle text and,
|
||||
-- via victories.lua OPP_GIOVANNI#2, sets the event on a win; a loss
|
||||
-- sets nothing, so the trigger re-arms exactly as vanilla does.
|
||||
-- SilphCo11FDefaultScript orders it DisplayTextID TEXT_SILPHCO11F_GIOVANNI
|
||||
-- FIRST, then MoveSprite .GiovanniMovement: he speaks from behind the desk
|
||||
-- and only then walks the three tiles down. Moving him before the box made
|
||||
-- him cross the room in silence and deliver the speech point-blank (#869),
|
||||
-- so the box comes first here and engageTrainer skips its own battle text.
|
||||
-- victories.lua OPP_GIOVANNI#2 sets the event on a win; a loss sets
|
||||
-- nothing, so the trigger re-arms exactly as vanilla does.
|
||||
onStep = function(game, ow, x, y)
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return false end
|
||||
if not ((x == 6 and y == 13) or (x == 7 and y == 12)) then return false end
|
||||
@@ -811,21 +815,28 @@ M.SILPH_CO_11F = {
|
||||
if npc.def and npc.def.name == "SILPHCO11F_GIOVANNI" then gio = npc break end
|
||||
end
|
||||
if not gio or ow:trainerDefeated(gio) then return false end
|
||||
ow:scriptMove(gio, "down", 3, function()
|
||||
gio:facePlayer(ow.player)
|
||||
ow:engageTrainer(gio, function()
|
||||
-- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!" speech,
|
||||
-- then SilphCo11FTeamRocketLeavesScript behind a fade so every Silph
|
||||
-- rocket leaves off-screen (the street rockets are handled by
|
||||
-- M.SAFFRON_CITY.onEnter in story4.lua). Queued, not run here: the
|
||||
-- battle's own callbacks are still unwinding, so queueScript starts
|
||||
-- it on the first idle overworld frame -- after the end-battle
|
||||
-- "Arrgh!!" box victories.lua OPP_GIOVANNI#2 pushes (#722).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
ow:queueScript(silphAftermathRows())
|
||||
end
|
||||
end)
|
||||
end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._SilphCo11FGiovanniText
|
||||
or "Ah {PLAYER}!\nSo we meet again!",
|
||||
function()
|
||||
ow:scriptMove(gio, "down", 3, function()
|
||||
gio:facePlayer(ow.player)
|
||||
ow:engageTrainer(gio, function()
|
||||
-- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!"
|
||||
-- speech, then SilphCo11FTeamRocketLeavesScript behind a fade so
|
||||
-- every Silph rocket leaves off-screen (the street rockets are
|
||||
-- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not
|
||||
-- run here: the battle's own callbacks are still unwinding, so
|
||||
-- queueScript starts it on the first idle overworld frame --
|
||||
-- after the end-battle "Arrgh!!" box victories.lua OPP_GIOVANNI#2
|
||||
-- pushes (#722).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
ow:queueScript(silphAftermathRows())
|
||||
end
|
||||
end, nil, true)
|
||||
end)
|
||||
end))
|
||||
return true
|
||||
end,
|
||||
onEnter = function(game, ow)
|
||||
@@ -1031,23 +1042,31 @@ local championsRoomRivalScript = {
|
||||
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 10
|
||||
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
|
||||
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
|
||||
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
|
||||
{ "play_music", "Music_Cities1", { keep = true } }, -- 11
|
||||
{ "show_text", "_ChampionsRoomOakText" }, -- 12
|
||||
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 13
|
||||
{ "move_npc", 2, "up", 5 }, -- 14 OakEntranceAfterVictoryMovement
|
||||
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in.
|
||||
-- audio/alternate_tempo.asm Music_Cities1AlternateTempo is not a plain
|
||||
-- PlayMusic: it fades the current song out (wAudioFadeOutControl = 10),
|
||||
-- waits 100 frames for the fade, then restarts Cities1 with channel 1
|
||||
-- pointed at Music_Cities1_Ch1_AlternateTempo -- `tempo 232` where the
|
||||
-- normal Music_Cities1_Ch1 opens `tempo 144`, i.e. the slower, heavier
|
||||
-- reading of the town theme this scene is known for (#847).
|
||||
{ "fade_music", 10 }, -- 11
|
||||
{ "wait", 100 }, -- 12
|
||||
{ "play_music", "Music_Cities1", { keep = true, tempo = 232 } }, -- 13
|
||||
{ "show_text", "_ChampionsRoomOakText" }, -- 14
|
||||
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 15
|
||||
{ "move_npc", 2, "up", 5 }, -- 16 OakEntranceAfterVictoryMovement
|
||||
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
||||
{ "face_object", 1, "left" }, -- 15
|
||||
{ "face_object", 2, "down" }, -- 16
|
||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 17
|
||||
{ "face_object", 1, "left" }, -- 17
|
||||
{ "face_object", 2, "down" }, -- 18
|
||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19
|
||||
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
||||
{ "face_object", 2, "right" }, -- 18
|
||||
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 19
|
||||
{ "face_object", 2, "right" }, -- 20
|
||||
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 21
|
||||
-- OakComeWithMeScript: Oak faces down again, then exits up
|
||||
{ "face_object", 2, "down" }, -- 20
|
||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 21
|
||||
{ "move_npc", 2, "up", 2 }, -- 22 OakExitChampionsRoomMovement
|
||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 23
|
||||
{ "face_object", 2, "down" }, -- 22
|
||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
|
||||
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
|
||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
|
||||
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement
|
||||
-- (PAD_UP 4, PAD_LEFT 1): the player walks out after Oak instead of the
|
||||
-- screen just fading on the spot (#704). The entrance walk leaves the
|
||||
@@ -1057,12 +1076,14 @@ local championsRoomRivalScript = {
|
||||
-- trailing UP/LEFT are dropped. Scripted steps ignore collision here just
|
||||
-- as they do in the original (CollisionCheckOnLand skips its checks while
|
||||
-- wSimulatedJoypadStatesIndex is non-zero), so stepping through the
|
||||
-- rival's cell at (4,2) is the ported behavior, not a clip.
|
||||
{ "move_player", "up", 3 }, -- 24
|
||||
-- rival's cell at (4,2) is the ported behavior, not a clip. Re-reported
|
||||
-- as a clip in #847 and re-checked against home/overworld.asm
|
||||
-- CollisionCheckOnLand, which is still the authority: do not "fix" it.
|
||||
{ "move_player", "up", 3 }, -- 26
|
||||
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
||||
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
|
||||
{ "set_field", "pendingHallOfFame", true }, -- 25
|
||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 26
|
||||
{ "set_field", "pendingHallOfFame", true }, -- 27
|
||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 28
|
||||
}
|
||||
|
||||
M.CHAMPIONS_ROOM = {
|
||||
|
||||
+66
-15
@@ -151,9 +151,27 @@ M.POKEMON_TOWER_6F = {
|
||||
-- trick, and the speedrun route this bot follows depends on it.
|
||||
if result == "win" or battle.pokeDollEscape then
|
||||
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._PokemonTower6FSoulWasCalmedText
|
||||
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!"))
|
||||
-- PokemonTower6FMarowakDepartedText (scripts/PokemonTower6F.asm)
|
||||
-- is two texts, not one: the CUBONE's-mother line first, then
|
||||
-- PlayCry RESTLESS_SOUL (EQU MAROWAK, constants/pokemon_constants
|
||||
-- .asm:209) + WaitForSoundToFinish + DelayFrames 30 before the
|
||||
-- calmed line; the port dropped the first text and the cry
|
||||
-- (#867). play_cry arms the next show_text, so the cry rides
|
||||
-- the calmed box's open with the button prompt kept, and the
|
||||
-- wait row stands in for the asm's 30-frame gap.
|
||||
local rows = {
|
||||
{ "show_text", t._PokemonTower6FGhostWasCubonesMotherText
|
||||
or "The GHOST was the\nrestless soul of\vCUBONE's mother!" },
|
||||
{ "play_cry", "MAROWAK", true },
|
||||
{ "wait", 30 },
|
||||
{ "show_text", t._PokemonTower6FSoulWasCalmedText
|
||||
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!" },
|
||||
}
|
||||
if ow.runner then
|
||||
ow.runner:run(rows)
|
||||
elseif ow.queueScript then
|
||||
ow:queueScript(rows)
|
||||
end
|
||||
elseif result ~= "lose" then
|
||||
-- .did_not_defeat: one simulated step right, off the trigger,
|
||||
-- so fleeing does not leave you standing on a cell that
|
||||
@@ -517,6 +535,14 @@ M.GAME_CORNER = {
|
||||
done()
|
||||
return
|
||||
end
|
||||
-- GameCornerRocketText hands the battle its own loss line through
|
||||
-- SaveEndBattleTextPointers (.BattleEndText ->
|
||||
-- _GameCornerRocketBattleEndText, "Dang!"), and PrintEndBattleText
|
||||
-- prints it ON the battle screen between TrainerDefeatedText and
|
||||
-- MoneyForWinningText (engine/battle/core.asm TrainerBattleVictory).
|
||||
-- He is a text_asm trainer with no def_trainers header, so there is no
|
||||
-- header.won for engageTrainer to find and the line has to be handed
|
||||
-- over here or it never shows at all (#862).
|
||||
ow:engageTrainer(npc, function()
|
||||
if not ow:trainerDefeated(npc) then
|
||||
done()
|
||||
@@ -527,19 +553,44 @@ M.GAME_CORNER = {
|
||||
game.data.text._GameCornerRocketAfterBattleText
|
||||
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
|
||||
function()
|
||||
-- #198: GameCornerRocketExitScript (scripts/GameCorner.asm)
|
||||
-- ApplyMovementData walks the grunt one tile UP into the poster
|
||||
-- (the hideout's secret entrance at 9,4) before HideObject, so
|
||||
-- he leaves the floor rather than popping out of existence on
|
||||
-- (9,5). scriptMove locks player input (#scriptMoves>0) and
|
||||
-- ignores collision, so we despawn + unfreeze (done) only once
|
||||
-- the step lands.
|
||||
ow:scriptMove(npc, "up", 1, function()
|
||||
hideRocket()
|
||||
done()
|
||||
end)
|
||||
-- #198/#862: GameCornerRocketBattleScript (scripts/GameCorner.asm)
|
||||
-- picks the exit walk from where the player is standing, because
|
||||
-- the grunt on (9,5) has to get past him: wYCoord == 6 (talked to
|
||||
-- from the south) or wXCoord == 8 (from the west) leaves the row
|
||||
-- clear and takes GameCornerMovement_Rocket_WalkDirect, five steps
|
||||
-- RIGHT; otherwise the player is east of him on (10,5) and
|
||||
-- GameCornerMovement_Rocket_WalkAroundPlayer steps DOWN, right, UP
|
||||
-- and right again to go AROUND him. pokeyellow's copy of the
|
||||
-- around-path takes one extra RIGHT on the lower row before coming
|
||||
-- back up (it also has to clear Pikachu); both versions end on
|
||||
-- (15,5). He never steps UP: (9,4) is the poster wall, which is
|
||||
-- where the old single UP step sent him.
|
||||
local px = ow.player and ow.player.cellX
|
||||
local py = ow.player and ow.player.cellY
|
||||
local path
|
||||
if py == 6 or px == 8 then
|
||||
path = { { "right", 5 } }
|
||||
elseif require("src.core.GameVersion").isYellow() then
|
||||
path = { { "down", 1 }, { "right", 3 }, { "up", 1 }, { "right", 3 } }
|
||||
else
|
||||
path = { { "down", 1 }, { "right", 2 }, { "up", 1 }, { "right", 4 } }
|
||||
end
|
||||
-- GameCornerRocketExitScript only HideObjects him once
|
||||
-- BIT_SCRIPTED_NPC_MOVEMENT clears, i.e. after the last step.
|
||||
-- scriptMove locks player input (#scriptMoves>0) and ignores
|
||||
-- collision, so the despawn + unfreeze (done) ride the final step.
|
||||
local function step(i)
|
||||
if i > #path then
|
||||
hideRocket()
|
||||
done()
|
||||
return
|
||||
end
|
||||
ow:scriptMove(npc, path[i][1], path[i][2],
|
||||
function() step(i + 1) end)
|
||||
end
|
||||
step(1)
|
||||
end))
|
||||
end)
|
||||
end, game.data.text._GameCornerRocketBattleEndText or "Dang!")
|
||||
end,
|
||||
-- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a
|
||||
-- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the
|
||||
|
||||
+31
-15
@@ -13,9 +13,18 @@ local function push(game, s, done)
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
end
|
||||
|
||||
-- The question stays on screen under the YES/NO menu. The dojo prize
|
||||
-- balls are the clearest case: FightingDojoHitmonleePokeBallText
|
||||
-- (scripts/FightingDojo.asm) is `call PrintText` on a text_end string --
|
||||
-- no prompt, so no WaitForTextScrollButtonPress -- immediately followed
|
||||
-- by `call YesNoChoice`, and InitYesNoTextBoxParameters
|
||||
-- (engine/menus/text_box.asm) puts the menu above the dialogue box
|
||||
-- rather than replacing it. Ride TextBox's opts.choice, the same as
|
||||
-- Commands.ask, instead of popping the box with an A press and leaving a
|
||||
-- bare ChoiceBox over the overworld (#854).
|
||||
local function ask(game, s, cb)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
-- fill the extracted text placeholders ({NUM:...}, {RAM:...}, {PLAYER})
|
||||
@@ -155,19 +164,26 @@ local function dojoBall(species, ownBall, otherBall, askKey)
|
||||
push(game, "You'll have to\nbeat the master\nfirst!", done)
|
||||
return
|
||||
end
|
||||
ask(game, t[askKey] or ("You want\n" .. species .. "?"), function(yes)
|
||||
if not yes then done() return end
|
||||
flags["EVENT_GOT_" .. species] = true
|
||||
flags.EVENT_DEFEATED_FIGHTING_DOJO = true
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.give_pokemon(ctx, species, 30)
|
||||
-- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides
|
||||
-- just the picked object's index) and routes to the greedy line above
|
||||
-- when talked to (#197).
|
||||
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall)
|
||||
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
|
||||
end)
|
||||
-- Examining a ball shows that species' POKéDEX entry first
|
||||
-- (DisplayPokedex in FightingDojo.asm, which also marks it seen),
|
||||
-- then the yes/no take-it prompt (#853).
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.mark_seen(ctx, species)
|
||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||
game.stack:push(DexEntryMenu.new(game, species, function()
|
||||
ask(game, t[askKey] or ("You want\n" .. species .. "?"), function(yes)
|
||||
if not yes then done() return end
|
||||
flags["EVENT_GOT_" .. species] = true
|
||||
flags.EVENT_DEFEATED_FIGHTING_DOJO = true
|
||||
Commands.give_pokemon(ctx, species, 30)
|
||||
-- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides
|
||||
-- just the picked object's index) and routes to the greedy line above
|
||||
-- when talked to (#197).
|
||||
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall)
|
||||
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
|
||||
end)
|
||||
end))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -12,9 +12,13 @@ local function push(game, s, done)
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
end
|
||||
|
||||
-- PrintText on a text_end string returns with the box still drawn and
|
||||
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
|
||||
-- engine/menus/text_box.asm); no A press clears the question first. Ride
|
||||
-- TextBox's opts.choice, the same as Commands.ask (#854).
|
||||
local function ask(game, s, cb)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
@@ -18,6 +18,16 @@
|
||||
-- script). Leaders are not def_trainers entries, so engageTrainer has
|
||||
-- no header.won -- checkVictoryRewards shows this chain instead of a
|
||||
-- synthetic "received badge/TM" stub.
|
||||
--
|
||||
-- Gym entries split the TM hand-over out of `dialogue`, mirroring the
|
||||
-- originals' GiveItem check (`call GiveItem` / `jr nc, .BagFull`):
|
||||
-- `tmPre` is the ReceiveTM script's lead-in (badge info / "Wait! Take
|
||||
-- this!"), shown at the victory and again when a beaten leader retries
|
||||
-- the hand-over; `tmDialogue` shows only when the TM actually goes in
|
||||
-- the bag; `noRoom` is the "make room" line shown instead when the bag
|
||||
-- is full; `gotFlag` (pokered's EVENT_GOT_TM*) is set only on a
|
||||
-- successful give, which is what makes the leader's talk script retry
|
||||
-- later (gyms.lua).
|
||||
|
||||
local function range(prefix, first, last)
|
||||
local t = {}
|
||||
@@ -33,6 +43,8 @@ return {
|
||||
-- escort NPC and the first Route 22 rival stay gone after the badge.
|
||||
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
|
||||
item = "TM_BIDE",
|
||||
gotFlag = "EVENT_GOT_TM34",
|
||||
noRoom = "_PewterGymTM34NoRoomText",
|
||||
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
|
||||
hide = {
|
||||
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
||||
@@ -41,69 +53,99 @@ return {
|
||||
dialogue = {
|
||||
"_PewterGymBrockReceivedBoulderBadgeText",
|
||||
"_PewterGymBrockBoulderBadgeInfoText",
|
||||
"_PewterGymBrockWaitTakeThisText",
|
||||
},
|
||||
tmPre = { "_PewterGymBrockWaitTakeThisText" },
|
||||
tmDialogue = {
|
||||
"_PewterGymReceivedTM34Text",
|
||||
"_TM34ExplanationText",
|
||||
} },
|
||||
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
|
||||
item = "TM_BUBBLEBEAM",
|
||||
gotFlag = "EVENT_GOT_TM11",
|
||||
noRoom = "_CeruleanGymMistyTM11NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
|
||||
dialogue = {
|
||||
"_CeruleanGymMistyReceivedCascadeBadgeText",
|
||||
"_CeruleanGymMistyCascadeBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_CeruleanGymMistyCascadeBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_CeruleanGymMistyReceivedTM11Text",
|
||||
} },
|
||||
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
|
||||
item = "TM_THUNDERBOLT",
|
||||
gotFlag = "EVENT_GOT_TM24",
|
||||
noRoom = "_VermilionGymLTSurgeTM24NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
|
||||
dialogue = {
|
||||
"_VermilionGymLTSurgeReceivedThunderBadgeText",
|
||||
"_VermilionGymLTSurgeThunderBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_VermilionGymLTSurgeThunderBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_VermilionGymLTSurgeReceivedTM24Text",
|
||||
"_TM24ExplanationText",
|
||||
} },
|
||||
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
|
||||
item = "TM_MEGA_DRAIN",
|
||||
gotFlag = "EVENT_GOT_TM21",
|
||||
noRoom = "_CeladonGymTM21NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
|
||||
dialogue = {
|
||||
"_CeladonGymErikaReceivedRainbowBadgeText",
|
||||
"_CeladonGymRainbowBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_CeladonGymRainbowBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_CeladonGymReceivedTM21Text",
|
||||
"_TM21ExplanationText",
|
||||
} },
|
||||
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
|
||||
item = "TM_TOXIC",
|
||||
gotFlag = "EVENT_GOT_TM06",
|
||||
noRoom = "_FuchsiaGymKogaTM06NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
|
||||
dialogue = {
|
||||
"_FuchsiaGymKogaReceivedSoulBadgeText",
|
||||
"_FuchsiaGymKogaSoulBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_FuchsiaGymKogaSoulBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_FuchsiaGymKogaReceivedTM06Text",
|
||||
"_FuchsiaGymKogaTM06ExplanationText",
|
||||
} },
|
||||
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
|
||||
item = "TM_PSYWAVE",
|
||||
gotFlag = "EVENT_GOT_TM46",
|
||||
noRoom = "_SaffronGymSabrinaTM46NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
|
||||
dialogue = {
|
||||
"_SaffronGymSabrinaReceivedMarshBadgeText",
|
||||
"_SaffronGymSabrinaMarshBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_SaffronGymSabrinaMarshBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_SaffronGymSabrinaReceivedTM46Text",
|
||||
"_TM46ExplanationText",
|
||||
} },
|
||||
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
|
||||
item = "TM_FIRE_BLAST",
|
||||
gotFlag = "EVENT_GOT_TM38",
|
||||
noRoom = "_CinnabarGymBlaineTM38NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
|
||||
dialogue = {
|
||||
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
|
||||
"_CinnabarGymBlaineVolcanoBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_CinnabarGymBlaineVolcanoBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_CinnabarGymBlaineReceivedTM38Text",
|
||||
"_CinnabarGymBlaineTM38ExplanationText",
|
||||
} },
|
||||
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
|
||||
item = "TM_FISSURE",
|
||||
gotFlag = "EVENT_GOT_TM27",
|
||||
noRoom = "_ViridianGymGiovanniTM27NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
|
||||
dialogue = {
|
||||
"_ViridianGymGiovanniReceivedEarthBadgeText",
|
||||
"_ViridianGymGiovanniEarthBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_ViridianGymGiovanniEarthBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_ViridianGymGiovanniReceivedTM27Text",
|
||||
"_ViridianGymGiovanniTM27ExplanationText",
|
||||
} },
|
||||
|
||||
@@ -62,10 +62,15 @@ M.MT_MOON_B2F = {
|
||||
{ "walk_npc", 6, { "left", "left", "left", "left", "left" } },
|
||||
{ "face_object", 6, "left" },
|
||||
{ "show_text", "_MtMoonJessieJamesText2" },
|
||||
-- MtMoonB2FScript12 arms _MtMoonJessieJamesText3 with
|
||||
-- SaveEndBattleTextPointers before it sets wCurOpponent, so
|
||||
-- TrainerBattleVictory prints it on the battle screen as "ROCKET: A
|
||||
-- brat beat us?" between TrainerDefeatedText and MoneyForWinningText.
|
||||
-- Its one-word first line only reads right behind that tag (#866).
|
||||
{ "save_end_battle_text", "_MtMoonJessieJamesText3" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 42 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_MtMoonJessieJamesText3" },
|
||||
{ "show_text", "_MtMoonJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
@@ -85,7 +90,8 @@ M.MT_MOON_B2F = {
|
||||
-- motto plays from off-screen FIRST, then the duo pops in at (25,10) /
|
||||
-- (24,10) and whichever of them shares the player's column ($18=24 or
|
||||
-- $19=25, EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT) walks the three
|
||||
-- tiles down to loom over the player while the other steps one. A loss
|
||||
-- tiles down to loom over the player while the other walks four and ends
|
||||
-- up beside him. A loss
|
||||
-- re-hides them (RocketHideoutB4FResetScripts via EVENT_6A0), so the
|
||||
-- trigger re-arms clean.
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -106,7 +112,7 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
if f.EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES then return false end
|
||||
-- ON_LEFT: player under James's column (25); movement data pairs
|
||||
-- RocketHideoutB4FJessieJamesMovementData_45605/45606 swap so the
|
||||
-- column-mate walks 3, the other 1.
|
||||
-- column-mate walks 3, the other 4.
|
||||
local onLeft = (x == 25)
|
||||
ow.runner:run({
|
||||
{ "stop_music" },
|
||||
@@ -116,16 +122,30 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
{ "emote", "player", "shock", 30 },
|
||||
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
|
||||
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
|
||||
-- James (object 2) then Jessie (object 3), Script4..Script9 order
|
||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } },
|
||||
-- James (object 2) then Jessie (object 3), Script4..Script9 order.
|
||||
-- RocketHideoutB4FJessieJamesMovementData_45605 is a lone $4 that FALLS
|
||||
-- THROUGH into _45606 ($4 $4 $4 $ff), so MoveSprite_ (home/pathfinding.asm)
|
||||
-- reads _45605 as FOUR steps and _45606 as three; $4 is DOWN in Yellow's
|
||||
-- Func_5288 lookup (engine/overworld/movement.asm), which walks with no
|
||||
-- collision test. From (25,10)/(24,10) against a player on y=14 the
|
||||
-- column-mate stops three down, right above him, and the other walks the
|
||||
-- full four to stand alongside -- which is what the facings below assume.
|
||||
-- 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" },
|
||||
{ "walk_npc", 3, onLeft and { "down" } or { "down", "down", "down" } },
|
||||
{ "walk_npc", 3, onLeft and { "down", "down", "down", "down" }
|
||||
or { "down", "down", "down" } },
|
||||
{ "face_object", 3, onLeft and "right" or "down" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText2" },
|
||||
-- RocketHideoutB4FScript10 saves _RocketHideoutJessieJamesText3 as the
|
||||
-- end-battle text, so it prints as "ROCKET: Such a dreadful twerp!" on
|
||||
-- the battle screen ahead of MoneyForWinningText (#866).
|
||||
{ "save_end_battle_text", "_RocketHideoutJessieJamesText3" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 43 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "lost" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText3" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
@@ -175,16 +195,27 @@ M.POKEMON_TOWER_7F = {
|
||||
{ "show_text", "_PokemonTowerJessieJamesText1" },
|
||||
{ "face_player_dir", "up" },
|
||||
{ "emote", "player", "shock", 30 },
|
||||
-- Jessie (object 1) then James (object 2), Script1..Script6 order
|
||||
{ "walk_npc", 1, onLeft and { "down" } or { "down", "down", "down" } },
|
||||
-- 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
|
||||
-- steps and _60d7b is three. From (10,8)/(11,8) against a player on
|
||||
-- y=12 the column-mate halts one tile above him and the other closes the
|
||||
-- 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" },
|
||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } },
|
||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" }
|
||||
or { "down", "down", "down", "down" } },
|
||||
{ "face_object", 2, onLeft and "down" or "left" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText2" },
|
||||
-- PokemonTower7FScript7 saves _PokemonTowerJessieJamesText3 as the
|
||||
-- end-battle text: "ROCKET: You will regret this!" on the battle screen,
|
||||
-- before the prize money (#866).
|
||||
{ "save_end_battle_text", "_PokemonTowerJessieJamesText3" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 44 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText3" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
@@ -254,10 +285,12 @@ M.SILPH_CO_11F = {
|
||||
{ "walk_npc", 6, jessieDirs },
|
||||
{ "face_object", 6, jessieFace },
|
||||
{ "show_text", "_SilphCoJessieJamesText2" },
|
||||
-- SilphCo11FScript11 saves _SilphCoJessieJamesText3 (SilphCo11FText_624c2)
|
||||
-- as the end-battle text: "ROCKET: Like always..." before the money (#866).
|
||||
{ "save_end_battle_text", "_SilphCoJessieJamesText3" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 45 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_SilphCoJessieJamesText3" },
|
||||
{ "show_text", "_SilphCoJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
|
||||
@@ -234,8 +234,12 @@ What was ported from pokered's engine code and where it came from.
|
||||
pre-battle text and engages the leader battle (badge/TM via
|
||||
data/scripts/victories.lua); post-badge talk prints the leader's
|
||||
post-battle advice text (Misty's is her TM11 explanation). The
|
||||
originals' middle branch (beaten but TM not handed over) is
|
||||
unreachable since the TM is granted with the victory. Giovanni's
|
||||
originals' middle branch (beaten but TM not handed over,
|
||||
CheckEventReuseA EVENT_GOT_TM*) is ported too: the victory's GiveItem
|
||||
goes through the bag's capacity check, a full bag shows the leader's
|
||||
"make room" text instead of the received lines and leaves
|
||||
EVENT_GOT_TM* unset, and talking to the leader re-runs the ReceiveTM
|
||||
script until the TM goes in (#797). Giovanni's
|
||||
farewell (`ViridianGymGiovanniText` .afterBeat) hides him inside a
|
||||
fade-to-black/fade-in Transition matching ViridianGym.asm's
|
||||
GBFadeOutToBlack → HideObject → GBFadeInFromBlack, persisted
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# Linux arm64 (aarch64) AppImage
|
||||
|
||||
Releases ship `gen1recomp-<version>-linux-arm64.AppImage` alongside the
|
||||
existing x86_64 `gen1recomp-<version>-linux.zip`. It targets 64-bit ARM
|
||||
desktop Linux: Raspberry Pi 4/5 running Raspberry Pi OS, Armbian and other
|
||||
SBC distros, arm64 VMs on Apple Silicon, Ampere/Graviton desktops, and the
|
||||
aarch64 handhelds that run a full distro.
|
||||
|
||||
> The Anbernic RG34XXSP has its own PortMaster-style pack
|
||||
> (`gen1recomp-*-rg34xxsp-stockos64-mod.zip`, see
|
||||
> [anbernic-rg34xxsp.md](anbernic-rg34xxsp.md)). That one bundles PortMaster's
|
||||
> LÖVE runtime and expects the device's own SDL; this AppImage is the generic
|
||||
> desktop-Linux artifact and shares nothing with it but the `game.love`.
|
||||
|
||||
## For players
|
||||
|
||||
```sh
|
||||
chmod +x gen1recomp-*-linux-arm64.AppImage
|
||||
./gen1recomp-*-linux-arm64.AppImage
|
||||
```
|
||||
|
||||
Then use **Import ROM** in the launcher to point it at your own legal Red /
|
||||
Blue / Yellow cartridge dump, exactly as on every other platform.
|
||||
|
||||
If your system has no FUSE (`dlopen(): error loading libfuse.so.2`), either
|
||||
install it (`sudo apt install libfuse2`) or run without it:
|
||||
|
||||
```sh
|
||||
./gen1recomp-*-linux-arm64.AppImage --appimage-extract-and-run
|
||||
```
|
||||
|
||||
### What the host has to provide
|
||||
|
||||
Very little, and this is enforced by an assertion in the build rather than by
|
||||
good intentions. The only libraries the AppImage requires at startup are:
|
||||
|
||||
```
|
||||
glibc 2.29+ libstdc++ libfreetype6 zlib
|
||||
```
|
||||
|
||||
Everything else — OpenGL/Mesa, X11, Wayland, KMSDRM, ALSA, PulseAudio — is
|
||||
**dlopened**, so it is used when present and skipped when absent. That means
|
||||
one image runs on a full desktop, on a Wayland-only session, on a
|
||||
KMSDRM-only handheld with no X server, and on a box with ALSA but no
|
||||
PulseAudio, without a different build for each.
|
||||
|
||||
That property does not come for free from Debian's packages, and getting it
|
||||
is most of what the build below is doing; see
|
||||
[Why five libraries are built from source](#why-five-libraries-are-built-from-source).
|
||||
|
||||
## For builders
|
||||
|
||||
```sh
|
||||
scripts/build_linux_arm64.sh --version 0.1.0
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage
|
||||
dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage.sha256
|
||||
```
|
||||
|
||||
Useful flags: `--game-love PATH` reuses an already-packed payload (CI does
|
||||
this so every platform ships identical bytes), `--rebuild-image` forces the
|
||||
builder container to rebuild, `--clean-cache` throws away the pinned
|
||||
downloads and the compiled LÖVE prefix.
|
||||
|
||||
### Requirements
|
||||
|
||||
An **aarch64 host** with **docker or podman**. A Raspberry Pi 5 is the
|
||||
reference machine (a cold build takes about 10 minutes on one — six libraries
|
||||
plus the engine; rebuilds reuse the cached prefix and take seconds). Apple Silicon with Docker
|
||||
Desktop and GitHub's `ubuntu-24.04-arm` runner both work too.
|
||||
|
||||
The script refuses to run on x86_64 rather than falling back to qemu-user
|
||||
emulation: that path takes hours and has produced miscompiled LuaJIT.
|
||||
|
||||
### Why this is not just another `scripts/build.sh` target
|
||||
|
||||
`scripts/build.sh linux` downloads LÖVE's official `love-11.5-x86_64.AppImage`,
|
||||
unpacks its squashfs, drops `game.love` in, and glues it back together. That
|
||||
trick is not available here — **LÖVE publishes no aarch64 binary at all.** The
|
||||
11.5 release has win32, win64, macOS, Android, iOS and one x86_64 AppImage,
|
||||
and that is the entire list.
|
||||
|
||||
So this build compiles LÖVE 11.5 from the official `linux-src` tarball and
|
||||
assembles the AppImage from scratch. Every pinned input — the LÖVE source, the
|
||||
five libraries built alongside it, and the AppImage type-2 runtime — is
|
||||
SHA-256 verified on the host before the container ever sees it, and the
|
||||
container itself runs with no network access.
|
||||
|
||||
### Why the build happens in a Debian bullseye container
|
||||
|
||||
glibc is backward compatible but not forward compatible: a binary linked
|
||||
against glibc 2.41 will not start on a system with 2.31, and there is no way
|
||||
to fix that after the fact. Compiling on the oldest base we support is
|
||||
therefore the only thing that makes one artifact work everywhere.
|
||||
|
||||
Bullseye (glibc 2.31) is that base. The resulting binaries actually come out
|
||||
needing only **glibc 2.29** and **GLIBCXX_3.4.21**, so the AppImage covers
|
||||
everything from Ubuntu 20.04 and Raspberry Pi OS bullseye through current
|
||||
trixie.
|
||||
|
||||
This is a statement about the *compile environment*, not about where the
|
||||
artifact runs — building on your own newer distro would silently raise that
|
||||
floor and strand every user on an older one, with no symptom until they
|
||||
download it. CI enforces the floor: `linux-arm64-build` fails if the highest
|
||||
required glibc symbol version climbs above 2.31.
|
||||
|
||||
### Why five libraries are built from source
|
||||
|
||||
SDL2, OpenAL, libtheora, libogg/libvorbis and libmpg123 are compiled rather
|
||||
than installed from bullseye. In every case the reason is *correctness*, not
|
||||
a newer version number — Debian builds these for a system where every
|
||||
dependency is installed and co-versioned, which is the opposite of an
|
||||
AppImage's situation. Each one broke the build in a different way, and all
|
||||
three failure modes are now assertions that fail the build instead of
|
||||
shipping.
|
||||
|
||||
**1. Hard-linked backends (SDL2, OpenAL).** Debian's `libSDL2` lists
|
||||
`libpulse`, `libasound`, `libX11` and `libwayland-client` as `DT_NEEDED` —
|
||||
resolved by the loader at startup, not dlopened. An AppImage bundling it
|
||||
refuses to start unless the host has *all four*. It appeared to work in
|
||||
testing only because a desktop Pi has all four; a headless CI runner is what
|
||||
exposed it. Debian's OpenAL does the same via `libsndio`, which itself
|
||||
hard-links `libasound`. Built from source with `--enable-*-shared` and
|
||||
`ALSOFT_DLOPEN`, both dlopen their backends instead.
|
||||
|
||||
**2. A stray link (libtheora).** Debian's `libtheoradec.so.1` is linked
|
||||
against `libcairo.so.2` — a packaging artifact, since a video decoder has no
|
||||
business drawing vector graphics — and cairo drags in X11, xcb, fontconfig
|
||||
and freetype. `--disable-examples` produces a `libtheoradec` needing only
|
||||
`libogg`.
|
||||
|
||||
**3. SONAME collision with the host (ogg, vorbis, mpg123).** The subtle one.
|
||||
OpenAL dlopens ALSA, ALSA's config loads its PulseAudio hook plugin, and that
|
||||
plugin pulls the *host's* `libsndfile` into our process. `libsndfile` links
|
||||
`libogg`, `libvorbis` and `libmpg123` — the same three we bundle. The loader
|
||||
resolves a SONAME exactly once per process, so the host's `libsndfile` binds
|
||||
to *our* copies:
|
||||
|
||||
```
|
||||
openal -> libasound -> libasound_module_conf_pulse -> libsndfile (host, new)
|
||||
`-> mpg123_info2 -> libmpg123 (ours, bullseye 1.26)
|
||||
```
|
||||
|
||||
`mpg123_info2` arrived in mpg123 1.32, so the plugin failed to relocate, ALSA
|
||||
config collapsed, and the game ran with **no audio device at all**. Not
|
||||
bundling these instead would make `libogg`/`libvorbis`/`libmpg123` mandatory
|
||||
host packages; building them current means our copies *satisfy* the host's
|
||||
`libsndfile` rather than starving it.
|
||||
|
||||
The same collision is why the font stack — freetype, fontconfig, libpng,
|
||||
brotli, zlib — is left to the host entirely. Bundling a bullseye freetype
|
||||
2.10.4 meant a host `libcairo` could not find `FT_Get_Transform` (added in
|
||||
2.11) and the game died at startup. Leaving the whole stack to the host keeps
|
||||
it self-consistent, while `liblove` — compiled against 2.10.4 — only ever
|
||||
asks for symbols every supported host already has.
|
||||
|
||||
The general rule this all reduces to: **never bundle a library the host's own
|
||||
stack may also load, unless yours is at least as new as theirs.**
|
||||
|
||||
### CI
|
||||
|
||||
Three jobs, path-gated on `scripts/build_linux_arm64.sh`,
|
||||
`scripts/linux-arm64/`, `scripts/pack_love.sh` and this document:
|
||||
|
||||
- **`linux-arm64-selftest`** (`ubuntu-latest`, x86_64) — offline gate. Checks
|
||||
the pins are real digests on a dated tag rather than the moving
|
||||
`continuous` one, that the Dockerfile still builds on bullseye, that the
|
||||
exclude list still classifies known sonames correctly, that AppRun still
|
||||
launches `game.love` with `--fused`, and that the host-arch guard actually
|
||||
fires. Needs no container and no arm64 machine.
|
||||
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then extracts
|
||||
the artifact and asserts the layout, that every bundled object resolves
|
||||
under AppRun's `LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31.
|
||||
Uploads the AppImage for 7 days.
|
||||
- **release** — `linux-arm64` runs on `ubuntu-24.04-arm`, reuses the shared
|
||||
`game.love` from the `love-payload` job, and the AppImage is staged and
|
||||
published like every other release asset.
|
||||
|
||||
Unlike the Switch job, none of this needs secrets or self-hosted hardware, so
|
||||
it runs on fork PRs too.
|
||||
|
||||
### Updating the pins
|
||||
|
||||
Both pins live in `scripts/linux-arm64/common.sh`:
|
||||
|
||||
- `LOVE_VERSION` / `LOVE_SRC_SHA256` — bumping any version invalidates the
|
||||
cached prefix automatically (its name is keyed by every source version at
|
||||
once, so a partial rebuild cannot mix vintages). Check that bullseye still
|
||||
has `-dev` packages new enough for the new release; `build_appimage.sh`
|
||||
asserts every optional module actually linked, because LÖVE's `configure`
|
||||
exits 0 and silently drops a module when one is missing.
|
||||
- `SDL2_*`, `OPENAL_*`, `THEORA_*`, `OGG_*`, `VORBIS_*`, `MPG123_*` — the
|
||||
source-built libraries. Bumping these is usually safe and occasionally
|
||||
necessary: `libmpg123` in particular must stay at least as new as what a
|
||||
target host's `libsndfile` expects, which is asserted for `mpg123_info2`.
|
||||
- `APPIMAGE_RUNTIME_TAG` / `APPIMAGE_RUNTIME_SHA256` — always a dated tag
|
||||
from [AppImage/type2-runtime](https://github.com/AppImage/type2-runtime/releases).
|
||||
The selftest fails the build if this ever points at `continuous`.
|
||||
@@ -226,5 +226,12 @@ for driving a second physical display. This is what lets a mod lay the two
|
||||
passes out as two stacked Game Boy screens, or push one onto a second screen,
|
||||
without the engine knowing the layout.
|
||||
|
||||
`screen.render_visible` receives `(next, state)` while the main screen is being
|
||||
composed. Return `false` to omit that state from drawing, opacity selection and
|
||||
palette-zone ownership. The state remains on the stack and keeps its normal
|
||||
update and input ownership, so a mod can mirror a native menu on another
|
||||
display without reimplementing it. The default is `true`. Treat the wrapper as
|
||||
a pure predicate: the renderer may ask it more than once per frame.
|
||||
|
||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||
that reach outside their permission set.
|
||||
|
||||
+30
-3
@@ -332,6 +332,26 @@ one used sideways. An `options.lua` from before this split keeps its single
|
||||
layout in both orientations until one of them is edited. In-game, Options →
|
||||
**TOUCH PAD** toggles the same on/off flag without leaving a play session.
|
||||
|
||||
## Haptic feedback (mobile)
|
||||
|
||||
Options → **VIBRATION** (also in the launcher's gear menu) buzzes the device
|
||||
the instant an on-screen control takes a button (#806). A glass pad has no
|
||||
edges under a thumb, so the pulse is what tells you the press landed:
|
||||
sliding the d-pad from one direction to the next buzzes again, a second
|
||||
finger landing on a button that is already held does not, and releasing
|
||||
never does.
|
||||
|
||||
Four levels: **OFF**, **LIGHT** (the default), **MEDIUM**, **HEAVY**.
|
||||
"Intensity" is really a pulse length -- the platform call takes a duration
|
||||
and nothing else -- so LIGHT is a 12 ms tick, MEDIUM 25 ms, HEAVY 45 ms.
|
||||
Stepping the row fires one sample pulse at the level you land on, so the
|
||||
three can be compared without leaving the menu. On iOS the system
|
||||
vibration has one fixed length, so all three levels feel the same there and
|
||||
the row is effectively on/off. The setting lives in `options.lua` and the
|
||||
row only appears where the on-screen pad can (Android/iOS, or desktop with
|
||||
`POKEPORT_TOUCH=1`, where it does nothing since desktop LOVE has no
|
||||
vibrator).
|
||||
|
||||
## Screen orientation lock (Android)
|
||||
|
||||
Options → **ORIENTATION** (also in the launcher's gear menu) locks the
|
||||
@@ -585,6 +605,11 @@ settings gear and pulses when an update is waiting, instead of sitting in a
|
||||
banner at the bottom of the page that you had to scroll to notice. Checking
|
||||
for updates from there shows a loader like everything else.
|
||||
|
||||
**A quit button.** An X sits to the right of the settings gear and closes
|
||||
the app cleanly, the same shutdown path as the window's close button. Mostly
|
||||
for platforms where reaching the window chrome is awkward (Android, Steam
|
||||
Deck, fullscreen desktops).
|
||||
|
||||
**The look.** Black background, white outlines, no gradients or glows, and
|
||||
buttons that are solid colour-coded keys: green commits, blue navigates, red
|
||||
destroys, yellow wants attention. The three game tabs keep their red, blue
|
||||
@@ -600,10 +625,12 @@ layout. Both ask twice.
|
||||
|
||||
## Launch options: boot straight into a game
|
||||
|
||||
`love . --game red` skips the launcher and starts that game; `--slot <id or
|
||||
`love . --game=red` skips the launcher and starts that game; `--slot=<id or
|
||||
number>` picks the save slot to load, and `--launcher` forces the launcher
|
||||
anyway. `POKEPORT_GAME` / `POKEPORT_SLOT` do the same for shortcuts that can
|
||||
only pass environment variables. This is for one-click entries: a desktop
|
||||
anyway. Spell these with an `=`: LÖVE reads the command line first and takes a
|
||||
bare word as a path to a game, so `--game red` fails looking for a folder
|
||||
called `red`. `POKEPORT_GAME` / `POKEPORT_SLOT` do the same for shortcuts that
|
||||
can only pass environment variables. This is for one-click entries: a desktop
|
||||
shortcut per game, a Steam entry, or a handheld frontend. Asking for a game
|
||||
whose ROM has not been imported opens the launcher on that game's tab rather
|
||||
than failing.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# RFC 0002 — Let mods hide an active screen state from the main render
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `StateStack.lua`, `Game.lua`. Tests:
|
||||
`screen_render_visible.lua`.
|
||||
|
||||
## Motivation
|
||||
|
||||
A mod can render a native menu on a companion display through
|
||||
`render.compose`, but it cannot remove that menu from the main display without
|
||||
also popping it. Popping transfers update and input ownership and forces the
|
||||
mod to reimplement native menu behavior.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
No prior D-number. Extends the render-hook plan in `docs/modding.md` and the
|
||||
state-stack rendering contract in `docs/architecture.md`.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only.
|
||||
|
||||
### `screen.render_visible`
|
||||
|
||||
New hook called with `(state) -> boolean` through the public wrapper signature
|
||||
`(next, state)`. Its vanilla result is `true`.
|
||||
|
||||
Returning `false` excludes the state from the main draw, from opaque-base
|
||||
selection and from palette-zone ownership. It does not remove the state or
|
||||
change update, input, push or pop behavior. The call sites are
|
||||
`StateStack:visibleBase`, `StateStack:draw` and the equivalent draw and palette
|
||||
walks in `Game:draw`.
|
||||
|
||||
The hook is guarded by `Runtime.wantsHook`, so the no-subscriber path allocates
|
||||
nothing. It is a pure render predicate and may be evaluated more than once per
|
||||
frame.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing.** With no subscriber every state remains visible, and the existing
|
||||
state-stack, event and hook behavior is unchanged.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod:** the topmost opaque state still owns drawing and palette zones,
|
||||
and `Runtime.wantsHook("screen.render_visible")` stays false.
|
||||
- **Mod-API:** a fixture mod registers through `mod.hooks:wrap`, hides one
|
||||
opaque state and proves the state beneath draws and owns the palette while
|
||||
the hidden state remains topmost and continues updating.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. This is one additive hook with a `true` vanilla default.
|
||||
@@ -30,6 +30,21 @@ end
|
||||
|
||||
local Game, EditorApp, Importer, TouchEditor
|
||||
|
||||
-- #887: quit-to-launcher state, shared by love.load and love.quit (both need
|
||||
-- it, so it is declared here rather than next to love.quit).
|
||||
-- * launchedIntoGame -- a --game / POKEPORT_GAME shortcut booted this
|
||||
-- session straight into a game, so there is no launcher behind it and a
|
||||
-- window close must exit. Restarting instead re-read the same shortcut
|
||||
-- and came right back into the game, and the next close did it again:
|
||||
-- the app could not be closed at all (macOS feels this worst, where the
|
||||
-- red X, Cmd+Q and the Dock's Quit are all the same quit event).
|
||||
-- * RELAUNCH_MARKER -- written in the save dir just before the #785
|
||||
-- restart, so the fresh boot ignores any boot-straight-into-a-game
|
||||
-- option exactly once and keeps #785's promise of landing in the
|
||||
-- launcher, whatever put the game on screen this time.
|
||||
local launchedIntoGame = false
|
||||
local RELAUNCH_MARKER = "relaunch_to_launcher.txt"
|
||||
|
||||
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
|
||||
local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
|
||||
-- coroutine that receives `Game` and yields once per
|
||||
@@ -334,10 +349,19 @@ function love.load(args)
|
||||
-- EmulationStation needs: one click into the game the player wants, with no
|
||||
-- menu in between. A game that is not imported falls through to the
|
||||
-- launcher on its tab rather than booting into nothing.
|
||||
-- A window close that restarted us into the launcher (#785) leaves the
|
||||
-- marker behind: consume it and stay on the launcher, or the shortcut below
|
||||
-- would boot the same game again and that close would restart again,
|
||||
-- forever (#887). Consumed on read, so the very next launch is normal.
|
||||
local relaunched = love.filesystem.getInfo(RELAUNCH_MARKER) ~= nil
|
||||
if relaunched then pcall(love.filesystem.remove, RELAUNCH_MARKER) end
|
||||
|
||||
local launchGame, launchSlot = LaunchOptions.resolve(arg)
|
||||
if launchGame and not LaunchOptions.forceLauncher(arg) then
|
||||
if launchGame and not relaunched and not LaunchOptions.forceLauncher(arg) then
|
||||
if RomImporter.isReady(launchGame) then
|
||||
if launchSlot then LaunchOptions.selectSlot(launchGame, launchSlot) end
|
||||
-- No launcher behind this session: love.quit must exit, not restart.
|
||||
launchedIntoGame = true
|
||||
bootGame(launchGame)
|
||||
return
|
||||
end
|
||||
@@ -667,7 +691,33 @@ function love.wheelmoved(x, y)
|
||||
Game:wheelmoved(x, y)
|
||||
end
|
||||
|
||||
-- #781: Linux X11 multi-monitor with the primary display away from desktop
|
||||
-- (0,0): SDL's polled mouse state can come back in desktop-virtual
|
||||
-- coordinates while the event stream stays window-relative, which strands
|
||||
-- every polled consumer (launcher Kit rising-edge clicks, the pad-cursor
|
||||
-- motion yield, PadCursor) on coordinates no hit test can match. Sanitize
|
||||
-- the poll once here: remember the last window-relative event coordinates
|
||||
-- and substitute them whenever the polled value falls outside the window.
|
||||
-- Linux only -- macOS / Windows / mobile keep the stock function, and the
|
||||
-- NX launcher shim still composes because it captures whatever
|
||||
-- love.mouse.getPosition is at bridge time (_ensureNxPointerBridge).
|
||||
local eventMouseX, eventMouseY
|
||||
if love.system and love.system.getOS() == "Linux"
|
||||
and love.mouse and love.mouse.getPosition then
|
||||
local polledGetPosition = love.mouse.getPosition
|
||||
love.mouse.getPosition = function()
|
||||
local x, y = polledGetPosition()
|
||||
local w, h = love.graphics.getDimensions()
|
||||
if x < 0 or y < 0 or x > w or y > h then
|
||||
if eventMouseX then return eventMouseX, eventMouseY end
|
||||
return math.max(0, math.min(x, w)), math.max(0, math.min(y, h))
|
||||
end
|
||||
return x, y
|
||||
end
|
||||
end
|
||||
|
||||
function love.mousepressed(x, y, button, istouch)
|
||||
if not istouch then eventMouseX, eventMouseY = x, y end
|
||||
if TouchEditor then
|
||||
-- Android primary touch already arrived via love.touchpressed; a second
|
||||
-- mouse path would double-fire Done / begin a second drag.
|
||||
@@ -720,6 +770,7 @@ function love.mousereleased(x, y, button, istouch)
|
||||
end
|
||||
|
||||
function love.mousemoved(x, y, dx, dy, istouch)
|
||||
if not istouch then eventMouseX, eventMouseY = x, y end
|
||||
if TouchEditor then
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousemoved(x, y)
|
||||
@@ -748,7 +799,13 @@ local quitToLauncher = false
|
||||
|
||||
function love.quit()
|
||||
if editorMode and EditorApp.quit then
|
||||
return EditorApp.quit() -- return true to abort quit
|
||||
-- true blocks the quit (unsaved-changes prompt). A quit that proceeds
|
||||
-- must fall through to the worker shutdowns below instead of returning:
|
||||
-- the bundled editor opens from a live launcher whose update-check and
|
||||
-- fetch-pool workers are still parked in Channel:demand(), and returning
|
||||
-- here skipped their "quit" push, so the process outlived the closed
|
||||
-- window and kept the install folder locked on Windows (#727).
|
||||
if EditorApp.quit() then return true end
|
||||
end
|
||||
-- Closing the window of a running game returns to the launcher instead of
|
||||
-- exiting the app, so testing a mod does not need a relaunch every time
|
||||
@@ -760,8 +817,15 @@ function love.quit()
|
||||
-- restart path must be no worse than that, not quietly better.
|
||||
local scripted = os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER")
|
||||
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or os.getenv("POKEPORT_IMPORT_ROM")
|
||||
if Game and not Importer and not quitToLauncher and not scripted then
|
||||
-- #887: a shortcut session (--game / POKEPORT_GAME) has no launcher to go
|
||||
-- back to and the restart would re-read the shortcut, so it exits instead.
|
||||
if Game and not Importer and not quitToLauncher and not scripted
|
||||
and not launchedIntoGame then
|
||||
quitToLauncher = true
|
||||
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
||||
-- once, so the restart really does land in the launcher (#887). A failed
|
||||
-- write only costs that suppression, so it must never block the restart.
|
||||
pcall(love.filesystem.write, RELAUNCH_MARKER, "1")
|
||||
require("src.core.HostShell").restart()
|
||||
return true -- abort this quit; the restart lands back in the launcher
|
||||
end
|
||||
|
||||
@@ -37,6 +37,13 @@
|
||||
|
||||
#include "filesystem/physfs/PhysfsIo.h"
|
||||
|
||||
// #604 / #839: the SAF bridges below must hand GameActivity the exact
|
||||
// directory physfs mounted as the save dir -- the same contract the iOS
|
||||
// GRPickerBridge already gets (mobile/ios/patch_love_src.py,
|
||||
// gr_saveDirectory) -- instead of letting Java recompute the root on its
|
||||
// own, which can name a different volume on merged / adopted-SD storage.
|
||||
#include "filesystem/Filesystem.h"
|
||||
|
||||
namespace love
|
||||
{
|
||||
namespace android
|
||||
@@ -183,6 +190,19 @@ void vibrate(double seconds)
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
// The directory physfs actually mounted as the save dir, or "" before the
|
||||
// filesystem module is up. GameActivity must copy SAF picks HERE: its own
|
||||
// getExternalFilesDir(null) recomputation can disagree with the mounted
|
||||
// root on merged / adopted-SD storage (#604, #839).
|
||||
static const char *bridgeSaveDirectory()
|
||||
{
|
||||
auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM);
|
||||
if (fs == nullptr)
|
||||
return "";
|
||||
const char *dir = fs->getSaveDirectory();
|
||||
return dir != nullptr ? dir : "";
|
||||
}
|
||||
|
||||
bool showFilePicker(const char *destFilename)
|
||||
{
|
||||
if (destFilename == nullptr || destFilename[0] == '\0')
|
||||
@@ -192,9 +212,11 @@ bool showFilePicker(const char *destFilename)
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
|
||||
"(Ljava/lang/String;)Z");
|
||||
"(Ljava/lang/String;Ljava/lang/String;)Z");
|
||||
jstring jname = env->NewStringUTF(destFilename);
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
|
||||
jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory());
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname, jsavedir);
|
||||
env->DeleteLocalRef(jsavedir);
|
||||
env->DeleteLocalRef(jname);
|
||||
|
||||
env->DeleteLocalRef(activity);
|
||||
@@ -210,9 +232,11 @@ bool showCreateDocument(const char *suggestedName)
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument",
|
||||
"(Ljava/lang/String;)Z");
|
||||
"(Ljava/lang/String;Ljava/lang/String;)Z");
|
||||
jstring jname = env->NewStringUTF(suggestedName);
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
|
||||
jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory());
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname, jsavedir);
|
||||
env->DeleteLocalRef(jsavedir);
|
||||
env->DeleteLocalRef(jname);
|
||||
|
||||
env->DeleteLocalRef(activity);
|
||||
|
||||
@@ -116,6 +116,18 @@ public class GameActivity extends SDLActivity {
|
||||
// bad ROM instead of installing it (#553).
|
||||
private String pendingPickFilename = PICKED_ROM_FILENAME;
|
||||
private static final String STATE_PENDING_PICK = "pendingPickFilename";
|
||||
// Absolute save directory physfs actually mounted, as reported by the
|
||||
// native bridge call that opened the picker (love/src/common/android.cpp,
|
||||
// bridgeSaveDirectory). This activity used to recompute
|
||||
// getExternalFilesDir(null)/save/<identity> on its own at result time; on
|
||||
// merged / adopted-SD storage that can name a different volume than the
|
||||
// one LOVE mounted, so the copied pick (and pick_error.flag) landed where
|
||||
// Lua never scans -- the launcher then "did nothing" after a pick (#604)
|
||||
// and the folders a file manager can browse stayed empty while the game
|
||||
// saved fine elsewhere (#839). Empty string means "not told yet": fall
|
||||
// back to the historical computation.
|
||||
private String pendingPickSaveDir = "";
|
||||
private static final String STATE_PENDING_PICK_DIR = "pendingPickSaveDir";
|
||||
private static final String STATE_PENDING_CREATE = "pendingCreateSuggestedName";
|
||||
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
|
||||
private String pendingCreateSuggestedName = "export.sav";
|
||||
@@ -186,6 +198,8 @@ public class GameActivity extends SDLActivity {
|
||||
// a recreated activity still lands under the basename it asked for.
|
||||
String pick = savedInstanceState.getString(STATE_PENDING_PICK);
|
||||
if (pick != null) pendingPickFilename = pick;
|
||||
String pickDir = savedInstanceState.getString(STATE_PENDING_PICK_DIR);
|
||||
if (pickDir != null) pendingPickSaveDir = pickDir;
|
||||
String create = savedInstanceState.getString(STATE_PENDING_CREATE);
|
||||
if (create != null) pendingCreateSuggestedName = create;
|
||||
}
|
||||
@@ -467,13 +481,23 @@ public class GameActivity extends SDLActivity {
|
||||
* @param destFilename basename under the app save identity (e.g.
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav)
|
||||
*/
|
||||
/** Legacy single-argument entry; resolves the save dir itself. */
|
||||
@Keep
|
||||
public static boolean showFilePicker(String destFilename) {
|
||||
return showFilePicker(destFilename, null);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean showFilePicker(String destFilename, String saveDir) {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
if (destFilename == null || destFilename.length() == 0) {
|
||||
destFilename = PICKED_ROM_FILENAME;
|
||||
}
|
||||
// Remember where LOVE's filesystem is really mounted so
|
||||
// onActivityResult copies the pick there, not into a recomputed
|
||||
// (possibly different-volume) root (#604, #839).
|
||||
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
|
||||
// Reject path separators so a hostile JNI caller cannot escape the
|
||||
// save identity directory.
|
||||
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
|
||||
@@ -644,8 +668,14 @@ public class GameActivity extends SDLActivity {
|
||||
* return degrades on the Lua side (RomImporter export) to "Exported
|
||||
* inside the app folder", which is the correct pre-KitKat behavior.
|
||||
*/
|
||||
/** Legacy single-argument entry; resolves the save dir itself. */
|
||||
@Keep
|
||||
public static boolean showCreateDocument(String suggestedName) {
|
||||
return showCreateDocument(suggestedName, null);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean showCreateDocument(String suggestedName, String saveDir) {
|
||||
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
||||
// (see showFilePicker for why the import side got a pre-19 path)
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
@@ -657,9 +687,12 @@ public class GameActivity extends SDLActivity {
|
||||
Log.d("GameActivity", "refusing unsafe create name: " + suggestedName);
|
||||
return false;
|
||||
}
|
||||
File source = new File(
|
||||
new File(self.getExternalFilesDir(null), "save"),
|
||||
ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME);
|
||||
// Route through the mounted save dir (#604, #839): Lua staged
|
||||
// pending_export.sav where physfs writes, which is not necessarily
|
||||
// where a fresh getExternalFilesDir(null) points on merged /
|
||||
// adopted-SD storage.
|
||||
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
|
||||
File source = new File(self.saveIdentityDir(), PENDING_EXPORT_FILENAME);
|
||||
if (!source.isFile()) {
|
||||
Log.d("GameActivity", "no pending export at " + source);
|
||||
return false;
|
||||
@@ -680,7 +713,21 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
|
||||
private File saveIdentityDir() {
|
||||
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
|
||||
// Prefer the mounted save dir the last bridge call reported: the
|
||||
// recomputation below can name a different volume than the one LOVE
|
||||
// mounted on merged / adopted-SD storage (#604, #839).
|
||||
if (pendingPickSaveDir != null && pendingPickSaveDir.length() > 0) {
|
||||
return new File(pendingPickSaveDir);
|
||||
}
|
||||
File ext = getExternalFilesDir(null);
|
||||
if (ext == null) {
|
||||
// Shared storage unavailable (ejected / mid-adoption): without
|
||||
// this guard File(null, "save") silently built the RELATIVE
|
||||
// path save/<identity>, mkdirs() failed against "/", and the
|
||||
// pick was dropped with no message at all (#604).
|
||||
ext = getFilesDir();
|
||||
}
|
||||
return new File(new File(ext, "save"), ROM_SAVE_IDENTITY);
|
||||
}
|
||||
|
||||
/** Drops a small flag file in the save identity for Lua to consume on focus. */
|
||||
@@ -875,6 +922,7 @@ public class GameActivity extends SDLActivity {
|
||||
protected void onSaveInstanceState(Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
outState.putString(STATE_PENDING_PICK, pendingPickFilename);
|
||||
outState.putString(STATE_PENDING_PICK_DIR, pendingPickSaveDir);
|
||||
outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,69 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.73",
|
||||
"date": "2026-08-06",
|
||||
"size": 9585729,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.73/gen1recomp-0.1.73-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #878 Mod API: allow active screen states to be hidden from the main render\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.72",
|
||||
"date": "2026-08-05",
|
||||
"size": 9586678,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.72/gen1recomp-0.1.72-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #604 Android, retroid pocket 2+ Rom won't import\n- #666 Pikachu emotions are not working on Android\n- #716 Lock Auto Rotate Mobile\n- #727 [Bug] [Windows] Gen1 Recomp \"still in use\" after closing\n- #763 Some EVENTS are turned off\n- #781 Mouse cursor broken on Linux with multi-monitor X11 setup\n- #784 Leech Seed effect\n- #799 Held direction randomly stops player movement (requires re-input)\n- #801 Cannot update mods from the launcher (MacOS)\n- #810 Launcher menu cuts off in vertical mode iOS\n- #828 Closing the app causes settings in launcher to reset\n- #834 Mod import failing\n- #838 Exporting save file Pokemon Yellow\n- #839 AYN Thor Misplaced Data files\n- #849 Public folder support on iOS\n- #852 Cannot switch between saves states on smaller 4:3 screen or in vertical mode\n- #857 Mt. Moon Fossils Reappeared and Won’t Disappear.\n- #863 [Yellow] When you use stairs, Pikachu shouldn't be next to you in the new area\n- #864 Faithful Ratio\n- #867 Missing Dialogue after defeating Marowak in Pokemon Tower\n- #869 Giovanni moves up to the player too early\n- #870 Start Menu on Classic Color\n- #872 Missing text when finding an item with full inventory\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.71",
|
||||
"date": "2026-08-05",
|
||||
"size": 9569738,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.71/gen1recomp-0.1.71-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #806 Haptic Feedback for On-screen controls (mobile)\n- #809 NPC Stuck in Rock when battle triggered and pushing rock towards it\n- #853 When selecting one of the two pokemon after defeating the Karate Master it should show the Pokedex entry\n- #854 Textbox disappears when the yes/no dialogue appears\n- #860 Disabled moves can still be used in the turn they were disabled\n- #862 Casino Poster Rocket Grunt walks into the poster and doesn't \"Dang!\"\n- #865 [Yellow] James doesn't move in multiple encounters\n- #866 [Yellow] Dialogue in wrong \"order\" after multiple encounters (J+J, Giovanni, probably more)\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.70",
|
||||
"date": "2026-08-05",
|
||||
"size": 9562731,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #590 Launcher/Save Editor/Dex\n- #788 Fly to the Pokemon Centers in the Routes is not available in original\n- #795 Fly logic is drastically different from the originals.\n- #796 Using Rare Candy from the menu closes it out.\n- #797 Gym Leaders giving items when your bag is full / Bypassing bag limit.\n- #805 Escape Rope Moltres Tower\n- #826 \"Super effective\" and \"not very effective\" SFX are reversed\n- #833 Cancelling nickname entry results in \"A\" as the nickname\n- #835 Restarting the launcher forgets the last rom used\n- #837 Wrong sound effect for Pikachu when entering battle\n- #844 Blizzard sound effect.\n- #845 Moderate issue: Fuchsia City binoculars.\n- #846 Surfing speed after using the bicycle.\n- #847 Minor issues related to the endgame.\n\n## Contributors\n\n- @bryanthaboi\n- @dburton95\n- @johnjohto\n- @KikiManjaro"
|
||||
},
|
||||
{
|
||||
"version": "0.1.69",
|
||||
"date": "2026-08-04",
|
||||
"size": 9552851,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.69/gen1recomp-0.1.69-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #768 Menu behaviour for Pokemon and HM moves\n- #785 Back to launcher\n- #792 HM moves in the wrong position in the menu.\n- #807 Expose gameplay pointer events and source-safe mod input injection\n- #811 Untranslatables\n- #814 [minor thing] bold arrow on move swap (select)\n\n## Contributors\n\n- @bryanthaboi\n- @johnjohto"
|
||||
},
|
||||
{
|
||||
"version": "0.1.68",
|
||||
"date": "2026-08-04",
|
||||
"size": 9768011,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.68/gen1recomp-0.1.68-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @caorthann-celt"
|
||||
},
|
||||
{
|
||||
"version": "0.1.67",
|
||||
"date": "2026-08-04",
|
||||
"size": 9767619,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.67/gen1recomp-0.1.67-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #373 Lines at Title Screen\n- #644 Substitute failing causes visual issues\n- #673 iOS: thin vertical seam below the START menu panel (v0.1.56, iPhone 15 Pro Max)\n- #703 Credits playing too fast (song ends after it should)\n- #726 Surfing Pikachu Minigame Broken\n- #737 Battle menu move cursor fails to reset to the first slot after switching Pokémon\n- #750 NPC (possibly player) trades still graphically broken\n- #752 Launcher exports save to AppData while in portable mode\n- #764 Trainer Fanfare doesn't play\n- #765 Text Advance broken in certain aspects\n- #768 Menu behaviour for Pokemon and HM moves\n- #773 Battle screen colours messed up when BG = World, battle in un-flashed Rock Tunnel\n- #774 bug(build): Desktop build can reject a valid game archive under pipefail\n- #775 TM42 Dream Eater dialog.\n- #777 Battle Screen is Very dark\n- #780 Do not delete save\n- #782 Giovanni battle at Silph Co plays wrong song\n\n## Contributors\n\n- @bryanthaboi\n- @luisgonzaleznf\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.66",
|
||||
"date": "2026-08-04",
|
||||
"size": 9749980,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.66/gen1recomp-0.1.66-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #691 Save file transfer\n- #743 Cannot Scroll Main Menu/Mod Menu\n- #779 \"Enemy \" untranslateable\n\n## Contributors\n\n- @bryanthaboi\n- @jherediagu\n- @ShaneMcGovernIE\n- @vegerot"
|
||||
},
|
||||
{
|
||||
"version": "0.1.65",
|
||||
"date": "2026-08-03",
|
||||
"size": 9376618,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.65/gen1recomp-0.1.65-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #592 Screen Orientation\n- #702 FLY overworld animation incorrect/incomplete\n- #748 Launcher Menu has overlap\n- #754 Can push boulders with STRENGTH through walls\n- #758 PC to Mac Online Multiplayer Disconnects Shortly After Starting Match\n\n## Contributors\n\n- @andrewqsantos\n- @Bortlesboat\n- @bryanthaboi\n- @castdrian\n- @johnjohto\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.64",
|
||||
"date": "2026-08-03",
|
||||
|
||||
Executable
+181
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the aarch64 (arm64) Linux AppImage.
|
||||
#
|
||||
# scripts/build.sh's `linux` target only produces x86_64: it unpacks LÖVE's
|
||||
# official x86_64 AppImage and re-fuses it, and no aarch64 equivalent is
|
||||
# published. This script compiles LÖVE 11.5 from the official linux-src
|
||||
# tarball inside a Debian bullseye arm64 container and fuses game.love into a
|
||||
# type-2 AppImage, so one artifact covers Raspberry Pi OS, Armbian, Ubuntu
|
||||
# arm64 and the aarch64 handhelds.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/build_linux_arm64.sh [--version X.Y.Z] [--game-love PATH]
|
||||
# [--rebuild-image] [--clean-cache]
|
||||
#
|
||||
# Output:
|
||||
# dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage
|
||||
# dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage.sha256
|
||||
#
|
||||
# Requirements: docker or podman on an aarch64 host (a Raspberry Pi 5, an
|
||||
# ubuntu-24.04-arm runner or Apple Silicon Docker all work). Nothing is
|
||||
# cross-compiled and no qemu emulation is involved.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
. "$ROOT/scripts/linux-arm64/common.sh"
|
||||
|
||||
HERE="$ROOT/.bazinga"
|
||||
CACHE="$HERE/cache/linux-arm64"
|
||||
WORK="$HERE/work/linux-arm64"
|
||||
DIST="$ROOT/dist/linux-arm64"
|
||||
|
||||
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||
GAME_LOVE=""
|
||||
REBUILD_IMAGE=0
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version) VERSION="${2:?--version needs a value}"; shift ;;
|
||||
--game-love) GAME_LOVE="${2:?--game-love needs a path}"; shift ;;
|
||||
--rebuild-image) REBUILD_IMAGE=1 ;;
|
||||
--clean-cache) rm -rf "$CACHE" ;;
|
||||
-h|--help)
|
||||
sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown argument: $1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# --------------------------------------------------------------- host checks
|
||||
# aarch64 only. The container is arch-native; running it under qemu-user on an
|
||||
# x86_64 host "works" but takes hours and has produced miscompiled LuaJIT
|
||||
# before, so refuse rather than hand back a build nobody can trust.
|
||||
host_arch="$(uname -m)"
|
||||
case "$host_arch" in
|
||||
aarch64|arm64) ;;
|
||||
*) fail "this build must run on an aarch64 host (found: $host_arch).
|
||||
Use a Raspberry Pi 5 / arm64 VM / Apple Silicon, or the ubuntu-24.04-arm CI runner." ;;
|
||||
esac
|
||||
|
||||
RUNTIME="$(container_runtime)" || fail_need_container
|
||||
say "container runtime: $RUNTIME"
|
||||
|
||||
mkdir -p "$CACHE" "$WORK" "$DIST"
|
||||
|
||||
# --------------------------------------------------------------- game.love
|
||||
# Shared packer, same include/exclude set and the same verification gates as
|
||||
# every other platform, so this artifact can never drift from the desktop one.
|
||||
if [ -n "$GAME_LOVE" ]; then
|
||||
[ -f "$GAME_LOVE" ] || fail "--game-love: no such file: $GAME_LOVE"
|
||||
say "using prebuilt payload: $GAME_LOVE"
|
||||
else
|
||||
GAME_LOVE="$WORK/game.love"
|
||||
"$ROOT/scripts/pack_love.sh" \
|
||||
--output "$GAME_LOVE" \
|
||||
--listing "$WORK/love-listing.txt" \
|
||||
--version "$VERSION"
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------- icon
|
||||
# One source of truth for every platform's launcher icon (scripts/build.sh
|
||||
# resizes the same file with sips on macOS). Pillow is already a project
|
||||
# dependency via tools/build_data.py; without it, ship the 1024px original
|
||||
# rather than failing the build over an icon.
|
||||
IN_DIR="$WORK/in"
|
||||
rm -rf "$IN_DIR"; mkdir -p "$IN_DIR"
|
||||
ICON_SRC="$ROOT/assets/logo/gen1recomp_cover.png"
|
||||
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
|
||||
if ! python3 - "$ICON_SRC" "$IN_DIR/icon.png" <<'PY' 2>/dev/null
|
||||
import sys
|
||||
from PIL import Image
|
||||
with Image.open(sys.argv[1]) as image:
|
||||
image.convert("RGBA").resize((512, 512), Image.LANCZOS).save(sys.argv[2])
|
||||
PY
|
||||
then
|
||||
warn "Pillow not available, shipping the unresized icon"
|
||||
cp "$ICON_SRC" "$IN_DIR/icon.png"
|
||||
fi
|
||||
cp "$GAME_LOVE" "$IN_DIR/game.love"
|
||||
|
||||
# --------------------------------------------------------------- downloads
|
||||
# Fetched on the host and checksum-pinned here so the container never needs
|
||||
# network access and every input is verified in exactly one place.
|
||||
download_pinned "$LOVE_SRC_URL" "$CACHE/$LOVE_SRC_TARBALL" "$LOVE_SRC_SHA256"
|
||||
download_pinned "$SDL2_URL" "$CACHE/$SDL2_TARBALL" "$SDL2_SHA256"
|
||||
download_pinned "$OPENAL_URL" "$CACHE/$OPENAL_TARBALL" "$OPENAL_SHA256"
|
||||
download_pinned "$THEORA_URL" "$CACHE/$THEORA_TARBALL" "$THEORA_SHA256"
|
||||
download_pinned "$OGG_URL" "$CACHE/$OGG_TARBALL" "$OGG_SHA256"
|
||||
download_pinned "$VORBIS_URL" "$CACHE/$VORBIS_TARBALL" "$VORBIS_SHA256"
|
||||
download_pinned "$MPG123_URL" "$CACHE/$MPG123_TARBALL" "$MPG123_SHA256"
|
||||
download_pinned "$APPIMAGE_RUNTIME_URL" "$CACHE/$APPIMAGE_RUNTIME_NAME" \
|
||||
"$APPIMAGE_RUNTIME_SHA256"
|
||||
|
||||
# --------------------------------------------------------------- builder image
|
||||
if [ "$REBUILD_IMAGE" = 1 ] || ! "$RUNTIME" image inspect "$BUILDER_IMAGE" >/dev/null 2>&1; then
|
||||
say "building $BUILDER_IMAGE ($BUILDER_BASE_IMAGE)"
|
||||
"$RUNTIME" build -t "$BUILDER_IMAGE" \
|
||||
-f "$ROOT/scripts/linux-arm64/Dockerfile" "$ROOT/scripts/linux-arm64" \
|
||||
|| fail "failed to build the $BUILDER_BASE_IMAGE builder image"
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------- build
|
||||
OUT_DIR="$WORK/out"
|
||||
rm -rf "$OUT_DIR"; mkdir -p "$OUT_DIR"
|
||||
|
||||
# --user keeps the AppImage owned by the invoking user instead of root; podman
|
||||
# maps root in the container to the host user already, so only docker needs it.
|
||||
user_args=()
|
||||
if [ "$RUNTIME" = "docker" ]; then
|
||||
user_args=(--user "$(id -u):$(id -g)")
|
||||
fi
|
||||
|
||||
say "compiling and packaging inside $BUILDER_BASE_IMAGE"
|
||||
"$RUNTIME" run --rm ${user_args[@]+"${user_args[@]}"} \
|
||||
-e LOVE_VERSION="$LOVE_VERSION" \
|
||||
-e SDL2_VERSION="$SDL2_VERSION" \
|
||||
-e SDL2_TARBALL="$SDL2_TARBALL" \
|
||||
-e OPENAL_VERSION="$OPENAL_VERSION" \
|
||||
-e OPENAL_TARBALL="$OPENAL_TARBALL" \
|
||||
-e THEORA_VERSION="$THEORA_VERSION" \
|
||||
-e THEORA_TARBALL="$THEORA_TARBALL" \
|
||||
-e OGG_VERSION="$OGG_VERSION" \
|
||||
-e OGG_TARBALL="$OGG_TARBALL" \
|
||||
-e VORBIS_VERSION="$VORBIS_VERSION" \
|
||||
-e VORBIS_TARBALL="$VORBIS_TARBALL" \
|
||||
-e MPG123_VERSION="$MPG123_VERSION" \
|
||||
-e MPG123_TARBALL="$MPG123_TARBALL" \
|
||||
-e APP_NAME="$APP_NAME" \
|
||||
-e VERSION="$VERSION" \
|
||||
-v "$CACHE:/cache" \
|
||||
-v "$IN_DIR:/in:ro" \
|
||||
-v "$OUT_DIR:/out" \
|
||||
-v "$ROOT/scripts/linux-arm64:/scripts:ro" \
|
||||
"$BUILDER_IMAGE" bash /scripts/build_appimage.sh
|
||||
|
||||
# --------------------------------------------------------------- publish
|
||||
built="$OUT_DIR/$APP_NAME-$VERSION-linux-arm64.AppImage"
|
||||
[ -f "$built" ] || fail "container produced no AppImage at $built"
|
||||
|
||||
# The runtime is a static-pie ELF and the payload starts where its section
|
||||
# headers end; a truncated cat would still be "a file", so prove both halves
|
||||
# survived before shipping.
|
||||
head -c 4 "$built" | od -An -tx1 | tr -d ' \n' | grep -q '^7f454c46$' \
|
||||
|| fail "built AppImage is not an ELF"
|
||||
e_shoff=$(od -An -j40 -N8 -tu8 "$built" | tr -d ' ')
|
||||
e_shentsize=$(od -An -j58 -N2 -tu2 "$built" | tr -d ' ')
|
||||
e_shnum=$(od -An -j60 -N2 -tu2 "$built" | tr -d ' ')
|
||||
sfs_offset=$((e_shoff + e_shentsize * e_shnum))
|
||||
[ "$(dd if="$built" bs=1 skip="$sfs_offset" count=4 2>/dev/null)" = "hsqs" ] \
|
||||
|| fail "no squashfs payload at offset $sfs_offset (runtime/payload fusion failed)"
|
||||
|
||||
out="$DIST/$(basename "$built")"
|
||||
rm -f "$out" "$out.sha256"
|
||||
mv "$built" "$out"
|
||||
chmod +x "$out"
|
||||
printf '%s %s\n' "$(sha256_file "$out")" "$(basename "$out")" > "$out.sha256"
|
||||
|
||||
say "Linux arm64 build: $out ($(du -h "$out" | cut -f1))"
|
||||
say "sha256: $(cut -d' ' -f1 "$out.sha256")"
|
||||
@@ -0,0 +1,45 @@
|
||||
# Build environment for the aarch64 Linux AppImage.
|
||||
#
|
||||
# Debian bullseye on purpose: it ships glibc 2.31, the oldest runtime we
|
||||
# promise to support. Everything linked here therefore runs on bullseye and
|
||||
# every later distro (glibc is backward compatible, not forward), which is
|
||||
# what makes the resulting AppImage portable across Raspberry Pi OS, Armbian,
|
||||
# Ubuntu 20.04+, and the aarch64 handheld distros.
|
||||
#
|
||||
# This image is arch-native: build it on an aarch64 host (Raspberry Pi 5,
|
||||
# ubuntu-24.04-arm runner, Apple Silicon Docker) — no qemu emulation.
|
||||
FROM debian:bullseye
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# build-essential/autoconf: LÖVE 11.5's linux-src tarball is autotools.
|
||||
# squashfs-tools: packs the AppDir into the AppImage payload.
|
||||
#
|
||||
# Note what is deliberately ABSENT: libsdl2-dev, libtheora-dev and
|
||||
# libopenal-dev. All three are built from source instead (see common.sh for
|
||||
# why), and having Debian's copies installed would let pkg-config hand LÖVE's
|
||||
# configure the system ones and silently undo it.
|
||||
#
|
||||
# The remaining lib*-dev set is LÖVE's optional-module surface. A missing one
|
||||
# does not fail configure, it silently drops a module (love.sound decoders,
|
||||
# love.font, love.video), so they are pinned here deliberately and asserted
|
||||
# after the build.
|
||||
#
|
||||
# The X11/Wayland/audio -dev packages are here for SDL2's *build*, not for
|
||||
# runtime linkage: SDL detects each backend at compile time and then dlopens
|
||||
# it, so these headers decide which backends exist at all while adding no
|
||||
# DT_NEEDED entry to the shipped library.
|
||||
RUN apt-get update -qq \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config autoconf automake libtool cmake \
|
||||
ca-certificates curl file xz-utils bzip2 zip unzip squashfs-tools \
|
||||
libogg-dev libvorbis-dev \
|
||||
libmodplug-dev libmpg123-dev libfreetype6-dev libluajit-5.1-dev \
|
||||
zlib1g-dev libgl1-mesa-dev libgles2-mesa-dev libegl1-mesa-dev \
|
||||
libasound2-dev libpulse-dev libudev-dev libdbus-1-dev \
|
||||
libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev \
|
||||
libxinerama-dev libxss-dev libxkbcommon-dev \
|
||||
libwayland-dev wayland-protocols libdrm-dev libgbm-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /work
|
||||
Executable
+427
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compiles LÖVE for aarch64 and fuses game.love into a self-contained
|
||||
# AppImage. Runs INSIDE the Debian bullseye container from Dockerfile --
|
||||
# scripts/build_linux_arm64.sh is the entry point on the host.
|
||||
#
|
||||
# Mounts the host provides:
|
||||
# /cache pinned downloads + the compiled LÖVE prefix (persists between runs)
|
||||
# /in read-only inputs: game.love, icon.png
|
||||
# /out the finished AppImage lands here
|
||||
#
|
||||
# Environment:
|
||||
# LOVE_VERSION, APP_NAME, VERSION passed through from the host script
|
||||
# JOBS make -j (defaults to nproc)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LOVE_VERSION="${LOVE_VERSION:?}"
|
||||
SDL2_VERSION="${SDL2_VERSION:?}"
|
||||
SDL2_TARBALL="${SDL2_TARBALL:?}"
|
||||
OPENAL_VERSION="${OPENAL_VERSION:?}"
|
||||
OPENAL_TARBALL="${OPENAL_TARBALL:?}"
|
||||
THEORA_VERSION="${THEORA_VERSION:?}"
|
||||
THEORA_TARBALL="${THEORA_TARBALL:?}"
|
||||
OGG_VERSION="${OGG_VERSION:?}"
|
||||
OGG_TARBALL="${OGG_TARBALL:?}"
|
||||
VORBIS_VERSION="${VORBIS_VERSION:?}"
|
||||
VORBIS_TARBALL="${VORBIS_TARBALL:?}"
|
||||
MPG123_VERSION="${MPG123_VERSION:?}"
|
||||
MPG123_TARBALL="${MPG123_TARBALL:?}"
|
||||
APP_NAME="${APP_NAME:?}"
|
||||
VERSION="${VERSION:?}"
|
||||
JOBS="${JOBS:-$(nproc)}"
|
||||
|
||||
CACHE="/cache"
|
||||
IN="/in"
|
||||
OUT="/out"
|
||||
WORK="/tmp/build"
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$WORK"
|
||||
|
||||
# --------------------------------------------------------------- prefix
|
||||
# Everything we compile lands in one prefix, cached because the compiling is
|
||||
# the only slow part (~5 min cold on a Pi 5) and is identical for every game
|
||||
# version. The key includes every source version, so bumping any of them
|
||||
# invalidates the cache instead of silently reusing a stale mix.
|
||||
PREFIX="$CACHE/prefix-love$LOVE_VERSION-sdl$SDL2_VERSION-al$OPENAL_VERSION-theora$THEORA_VERSION-ogg$OGG_VERSION-vorbis$VORBIS_VERSION-mpg$MPG123_VERSION"
|
||||
export PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig"
|
||||
# Our own libraries must win over the system ones during LÖVE's configure and
|
||||
# link, or the whole point of building them is lost.
|
||||
export LD_LIBRARY_PATH="$PREFIX/lib"
|
||||
|
||||
# ------------------------------------------------------ compile audio codecs
|
||||
# Ordered by dependency: vorbis needs ogg, and theora needs ogg too. All three
|
||||
# are small, plain autotools builds -- well under a minute each.
|
||||
build_autotools() { # $1 = label $2 = version $3 = tarball $4 = probe lib $5.. = configure args
|
||||
local label="$1" version="$2" tarball="$3" probe="$4"; shift 4
|
||||
if [ -f "$PREFIX/lib/$probe" ]; then
|
||||
say "reusing cached $label $version"
|
||||
return 0
|
||||
fi
|
||||
say "compiling $label $version"
|
||||
local src="$WORK/$label-src"
|
||||
rm -rf "$src"; mkdir -p "$src"
|
||||
case "$tarball" in
|
||||
*.tar.bz2) tar -xjf "$CACHE/$tarball" -C "$src" --strip-components=1 ;;
|
||||
*) tar -xzf "$CACHE/$tarball" -C "$src" --strip-components=1 ;;
|
||||
esac
|
||||
(
|
||||
cd "$src"
|
||||
# Several of these tarballs predate aarch64's entry in config.guess; the
|
||||
# distro's copies know about it, so refresh them or configure bails out
|
||||
# with "cannot guess build type".
|
||||
for helper in config.guess config.sub; do
|
||||
[ -f "$helper" ] && cp "/usr/share/misc/$helper" . 2>/dev/null
|
||||
done
|
||||
./configure --prefix="$PREFIX" --disable-static "$@" >/dev/null
|
||||
make -j"$JOBS" >/dev/null
|
||||
make install >/dev/null
|
||||
)
|
||||
}
|
||||
|
||||
build_autotools ogg "$OGG_VERSION" "$OGG_TARBALL" libogg.so.0
|
||||
build_autotools vorbis "$VORBIS_VERSION" "$VORBIS_TARBALL" libvorbis.so.0
|
||||
# mpg123's ports/ tree and the command-line player are irrelevant here; only
|
||||
# libmpg123 gets linked, and --disable-modules keeps the output-backend
|
||||
# plugins (and their dlopen of ALSA/pulse) out of the shipped library.
|
||||
build_autotools mpg123 "$MPG123_VERSION" "$MPG123_TARBALL" libmpg123.so.0 \
|
||||
--disable-modules --with-audio=dummy --disable-lfs-alias
|
||||
|
||||
# The symbol that was missing when this was bullseye's copy. Assert it, so a
|
||||
# version bump that quietly regresses below the host's expectations fails the
|
||||
# build instead of silently killing audio again.
|
||||
objdump -T "$PREFIX/lib/libmpg123.so.0" | grep -q 'mpg123_info2' \
|
||||
|| fail "bundled libmpg123 lacks mpg123_info2; the host's libsndfile will fail to relocate"
|
||||
|
||||
# ------------------------------------------------------------ compile SDL2
|
||||
# --enable-*-shared (the defaults, made explicit so a future SDL release
|
||||
# cannot flip them under us) is the entire reason this is built from source:
|
||||
# each backend is dlopened at runtime rather than becoming a DT_NEEDED entry,
|
||||
# so the AppImage starts on a host with only ALSA, or only Wayland, or only
|
||||
# KMSDRM, instead of demanding all of them at once the way Debian's build does.
|
||||
if [ -f "$PREFIX/lib/libSDL2-2.0.so.0" ]; then
|
||||
say "reusing cached SDL2 $SDL2_VERSION"
|
||||
else
|
||||
say "compiling SDL2 $SDL2_VERSION (jobs: $JOBS)"
|
||||
rm -rf "$WORK/sdl-src"; mkdir -p "$WORK/sdl-src"
|
||||
tar -xzf "$CACHE/$SDL2_TARBALL" -C "$WORK/sdl-src" --strip-components=1
|
||||
(
|
||||
cd "$WORK/sdl-src"
|
||||
./configure --prefix="$PREFIX" --disable-static \
|
||||
--enable-alsa --enable-alsa-shared \
|
||||
--enable-pulseaudio --enable-pulseaudio-shared \
|
||||
--enable-video-x11 --enable-x11-shared \
|
||||
--enable-video-wayland --enable-wayland-shared \
|
||||
--enable-video-kmsdrm --enable-kmsdrm-shared \
|
||||
--enable-libudev --disable-sndio --disable-jack --disable-esd \
|
||||
--disable-arts --disable-nas --disable-oss >/dev/null
|
||||
make -j"$JOBS" >/dev/null
|
||||
make install >/dev/null
|
||||
)
|
||||
fi
|
||||
|
||||
# Prove the dlopen intent actually took. If SDL ever hard-links an audio or
|
||||
# video backend again, the AppImage silently regains a startup dependency on
|
||||
# the host having that exact stack -- which is the bug this replaced.
|
||||
sdl_lib="$PREFIX/lib/libSDL2-2.0.so.0"
|
||||
[ -f "$sdl_lib" ] || fail "SDL2 build produced no libSDL2-2.0.so.0"
|
||||
for forbidden in libpulse libasound libX11 libwayland libdrm libgbm libsndio; do
|
||||
if objdump -p "$sdl_lib" | grep -q "NEEDED.*$forbidden"; then
|
||||
fail "SDL2 hard-links $forbidden; it must dlopen its backends (--enable-*-shared)"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---------------------------------------------------- compile openal-soft
|
||||
# ALSOFT_DLOPEN keeps the ALSA and PulseAudio backends behind dlopen, and
|
||||
# sndio is switched off outright -- Debian enables it, which is what chained
|
||||
# libopenal -> libsndio -> libasound into a mandatory startup dependency.
|
||||
if [ -f "$PREFIX/lib/libopenal.so.1" ]; then
|
||||
say "reusing cached openal-soft $OPENAL_VERSION"
|
||||
else
|
||||
say "compiling openal-soft $OPENAL_VERSION (jobs: $JOBS)"
|
||||
rm -rf "$WORK/openal-src"; mkdir -p "$WORK/openal-src"
|
||||
tar -xzf "$CACHE/$OPENAL_TARBALL" -C "$WORK/openal-src" --strip-components=1
|
||||
(
|
||||
cd "$WORK/openal-src"
|
||||
cmake -S . -B build \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX="$PREFIX" \
|
||||
-DALSOFT_DLOPEN=ON \
|
||||
-DALSOFT_BACKEND_SNDIO=OFF \
|
||||
-DALSOFT_BACKEND_OSS=OFF \
|
||||
-DALSOFT_BACKEND_JACK=OFF \
|
||||
-DALSOFT_EXAMPLES=OFF \
|
||||
-DALSOFT_UTILS=OFF \
|
||||
-DALSOFT_TESTS=OFF \
|
||||
-DLIBTYPE=SHARED >/dev/null
|
||||
cmake --build build -j"$JOBS" >/dev/null
|
||||
cmake --install build >/dev/null
|
||||
)
|
||||
fi
|
||||
|
||||
openal_lib="$PREFIX/lib/libopenal.so.1"
|
||||
[ -f "$openal_lib" ] || fail "openal-soft build produced no libopenal.so.1"
|
||||
for forbidden in libsndio libasound libpulse libjack; do
|
||||
if objdump -p "$openal_lib" | grep -q "NEEDED.*$forbidden"; then
|
||||
fail "openal hard-links $forbidden; backends must stay behind dlopen"
|
||||
fi
|
||||
done
|
||||
|
||||
# --------------------------------------------------- compile libtheora
|
||||
# --disable-examples is what drops Debian's libcairo link (and with it libX11,
|
||||
# libxcb, libfontconfig and libfreetype as startup dependencies). The encoder
|
||||
# is dead weight for a player, but libtheoradec is what LÖVE actually links.
|
||||
if [ -f "$PREFIX/lib/libtheoradec.so.1" ]; then
|
||||
say "reusing cached libtheora $THEORA_VERSION"
|
||||
else
|
||||
say "compiling libtheora $THEORA_VERSION"
|
||||
rm -rf "$WORK/theora-src"; mkdir -p "$WORK/theora-src"
|
||||
tar -xjf "$CACHE/$THEORA_TARBALL" -C "$WORK/theora-src" --strip-components=1
|
||||
(
|
||||
cd "$WORK/theora-src"
|
||||
# theora 1.1.1 predates the aarch64 config.guess, so refresh the autotools
|
||||
# helper scripts or configure rejects the host outright.
|
||||
for helper in config.guess config.sub; do
|
||||
cp "/usr/share/misc/$helper" . 2>/dev/null || true
|
||||
done
|
||||
./configure --prefix="$PREFIX" --disable-static \
|
||||
--disable-examples --disable-spec --disable-doc >/dev/null
|
||||
make -j"$JOBS" >/dev/null
|
||||
make install >/dev/null
|
||||
)
|
||||
fi
|
||||
|
||||
theora_lib="$PREFIX/lib/libtheoradec.so.1"
|
||||
[ -f "$theora_lib" ] || fail "libtheora build produced no libtheoradec.so.1"
|
||||
if objdump -p "$theora_lib" | grep -q "NEEDED.*libcairo"; then
|
||||
fail "libtheoradec still links libcairo (--disable-examples stopped working)"
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------ compile LÖVE
|
||||
if [ -x "$PREFIX/bin/love" ] && [ -f "$PREFIX/lib/liblove-$LOVE_VERSION.so" ]; then
|
||||
say "reusing cached LÖVE $LOVE_VERSION aarch64 build"
|
||||
else
|
||||
say "compiling LÖVE $LOVE_VERSION for aarch64 (jobs: $JOBS)"
|
||||
rm -rf "$WORK/love-src"
|
||||
mkdir -p "$WORK/love-src"
|
||||
tar -xzf "$CACHE/love-$LOVE_VERSION-linux-src.tar.gz" \
|
||||
-C "$WORK/love-src" --strip-components=1
|
||||
(
|
||||
cd "$WORK/love-src"
|
||||
# No --disable-* flags on purpose: configure silently drops a love module
|
||||
# when its -dev package is absent, so the Dockerfile pins the full set and
|
||||
# the assertions below prove each one actually linked. CPPFLAGS/LDFLAGS
|
||||
# point at our prefix so the SDL2 and theora just built above win over
|
||||
# anything the base image might still provide.
|
||||
./configure --prefix="$PREFIX" --disable-static \
|
||||
CPPFLAGS="-I$PREFIX/include" LDFLAGS="-L$PREFIX/lib" >/dev/null
|
||||
make -j"$JOBS" >/dev/null
|
||||
make install >/dev/null
|
||||
# Keep LÖVE's license inside the cached prefix: the unpacked source tree
|
||||
# is thrown away, so a later cache-hit run would otherwise have nothing
|
||||
# to ship and the AppImage would go out without its engine license.
|
||||
cp license.txt "$PREFIX/license.txt"
|
||||
)
|
||||
fi
|
||||
|
||||
love_bin="$PREFIX/bin/love"
|
||||
love_lib="$PREFIX/lib/liblove-$LOVE_VERSION.so"
|
||||
[ -x "$love_bin" ] || fail "LÖVE build produced no bin/love"
|
||||
[ -f "$love_lib" ] || fail "LÖVE build produced no lib/liblove-$LOVE_VERSION.so"
|
||||
file "$love_bin" | grep -q 'ARM aarch64' \
|
||||
|| fail "built love is not an aarch64 ELF (got: $(file -b "$love_bin"))"
|
||||
|
||||
# A configure run that lost an optional dependency still exits 0 and still
|
||||
# builds -- the loss only shows up as a missing love module at runtime, i.e.
|
||||
# in a shipped artifact. Assert the decoder/font/video libs really linked.
|
||||
for soname in libSDL2-2.0.so.0 libopenal.so.1 libfreetype.so.6 \
|
||||
libmodplug.so.1 libmpg123.so.0 libvorbisfile.so.3 \
|
||||
libtheoradec.so.1 libluajit-5.1.so.2; do
|
||||
objdump -p "$love_lib" | grep -q "NEEDED.*$soname" \
|
||||
|| fail "liblove is not linked against $soname (a -dev package went missing)"
|
||||
done
|
||||
|
||||
# --------------------------------------------------------------- AppDir
|
||||
# Layout mirrors LÖVE's own x86_64 AppImage exactly (bin/ lib/ share/ at the
|
||||
# AppDir root, not usr/-prefixed), so the AppRun contract below -- and the
|
||||
# FUSE_PATH fusion scripts/build.sh performs on the x86_64 image -- stay the
|
||||
# same idea on both architectures.
|
||||
APPDIR="$WORK/AppDir"
|
||||
rm -rf "$APPDIR"
|
||||
mkdir -p "$APPDIR/bin" "$APPDIR/lib" "$APPDIR/share"
|
||||
|
||||
cp "$love_bin" "$APPDIR/bin/love"
|
||||
chmod +x "$APPDIR/bin/love"
|
||||
|
||||
# ------------------------------------------------------ bundle dependencies
|
||||
# Walk the DT_NEEDED graph from love + liblove, copying in everything that is
|
||||
# not host-provided. Recursion stops at excluded libraries, so the driver and
|
||||
# session subtrees behind SDL2 are never pulled in.
|
||||
#
|
||||
# Three reasons a library MUST come from the host, and every entry below is
|
||||
# one of them:
|
||||
#
|
||||
# 1. Driver/session coupled. A bundled libGL would bypass Mesa's V3D driver
|
||||
# on the Pi; a bundled libpulse/libdbus would fight the user's running
|
||||
# session. GL/EGL/gbm/drm, X11/xcb/wayland/xkbcommon, dbus, pulse, alsa,
|
||||
# systemd/udev. Note that after the source builds above, none of these are
|
||||
# DT_NEEDED of anything we ship -- SDL2 and OpenAL dlopen them, so they are
|
||||
# used when present and skipped when absent.
|
||||
#
|
||||
# 2. Loader coupled. glibc's pieces cannot be mixed with the host's ld.so at
|
||||
# all, and libstdc++/libgcc_s must be at least as new as the compiler --
|
||||
# bullseye's gcc 10 is older than any supported host's, so the host copy
|
||||
# always satisfies us.
|
||||
#
|
||||
# 3. The font/compression stack: freetype, fontconfig, libpng, brotli, zlib.
|
||||
# These are shared with whatever the host's own graphics libraries have
|
||||
# already loaded, and mixing vintages inside one process breaks the older
|
||||
# copy. Bundling a bullseye freetype 2.10.4 is what made a host cairo fail
|
||||
# to find FT_Get_Transform (added in 2.11) and killed the game at startup.
|
||||
# Leaving the whole stack to the host keeps it self-consistent, and
|
||||
# liblove -- compiled against 2.10.4 -- only ever asks for symbols every
|
||||
# supported host already has.
|
||||
EXCLUDE_RE='^(ld-linux-aarch64\.so\.1|libc\.so\.6|libm\.so\.6|libdl\.so\.2|libpthread\.so\.0|librt\.so\.1|libresolv\.so\.2|libutil\.so\.1|libanl\.so\.1|libnsl\.so\.[0-9]+|libstdc\+\+\.so\.6|libgcc_s\.so\.1|lib(GL|GLX|GLdispatch|OpenGL|EGL|GLESv[12]|glapi|gbm|drm)\..*|libX[a-z0-9]*\..*|libxcb.*|libwayland-.*|libxkbcommon.*|libdbus-1\..*|libpulse.*|libasound\..*|libsndfile\..*|libFLAC\..*|libopus\..*|libsystemd\..*|libudev\..*|libselinux\..*|libcap\..*|libgcrypt\..*|libgpg-error\..*|liblzma\..*|libzstd\..*|liblz4\..*|libffi\..*|libexpat\..*|libbsd\..*|libmd\..*|libuuid\..*|libg(lib|object|module|thread)-2\..*|libfontconfig\..*|libfreetype\..*|libpng[0-9]*\..*|libbrotli.*|libz\.so\..*|libwrap\..*|libasyncns\..*|libtirpc\..*|lib(gssapi_krb5|krb5|k5crypto|com_err|krb5support|keyutils)\..*|libpcre.*)$'
|
||||
|
||||
# soname -> absolute path, harvested from the full ldd closure of both roots.
|
||||
declare -A RESOLVED=()
|
||||
while read -r soname _arrow path _addr; do
|
||||
[ -n "${path:-}" ] || continue
|
||||
[ -e "$path" ] || continue
|
||||
RESOLVED["$soname"]="$path"
|
||||
done < <(ldd "$love_bin" "$love_lib" | awk '/=>/ {print $1, $2, $3, $4}')
|
||||
|
||||
declare -A BUNDLED=()
|
||||
bundle_needed() { # $1 = ELF whose DT_NEEDED entries to walk
|
||||
local soname target
|
||||
while read -r soname; do
|
||||
[ -n "$soname" ] || continue
|
||||
if [[ "$soname" =~ $EXCLUDE_RE ]]; then continue; fi
|
||||
if [ -n "${BUNDLED[$soname]:-}" ]; then continue; fi
|
||||
target="${RESOLVED[$soname]:-}"
|
||||
[ -n "$target" ] || fail "cannot resolve $soname (needed by $(basename "$1"))"
|
||||
# Copy dereferenced and under the soname: the AppDir must not depend on
|
||||
# the builder's libSDL2-2.0.so.0 -> libSDL2-2.0.so.0.14.0 symlink chain.
|
||||
cp -L "$target" "$APPDIR/lib/$soname"
|
||||
chmod 0644 "$APPDIR/lib/$soname"
|
||||
BUNDLED["$soname"]=1
|
||||
bundle_needed "$APPDIR/lib/$soname"
|
||||
done < <(objdump -p "$1" | awk '/NEEDED/ {print $2}')
|
||||
}
|
||||
|
||||
say "bundling shared libraries"
|
||||
cp "$love_lib" "$APPDIR/lib/liblove-$LOVE_VERSION.so"
|
||||
chmod 0644 "$APPDIR/lib/liblove-$LOVE_VERSION.so"
|
||||
BUNDLED["liblove-$LOVE_VERSION.so"]=1
|
||||
bundle_needed "$APPDIR/bin/love"
|
||||
bundle_needed "$APPDIR/lib/liblove-$LOVE_VERSION.so"
|
||||
say "bundled $(ls "$APPDIR/lib" | wc -l) libraries: $(ls "$APPDIR/lib" | tr '\n' ' ')"
|
||||
|
||||
# ------------------------------------------------- host dependency contract
|
||||
# The portability promise, stated as an assertion instead of a paragraph in a
|
||||
# README: these are the ONLY sonames the shipped objects may require from the
|
||||
# host. Everything driver-, session- or audio-related has to be reached
|
||||
# through dlopen, so the AppImage starts on a box with no PulseAudio, no X11
|
||||
# or no ALSA and simply uses whatever it does find.
|
||||
#
|
||||
# The original build failed exactly here and nobody noticed until CI ran on a
|
||||
# headless runner: Debian's SDL2 hard-links libpulse/libasound/libX11/
|
||||
# libwayland, so the image only ever started on a full desktop.
|
||||
HOST_ALLOWED_RE='^(ld-linux-aarch64\.so\.1|libc\.so\.6|libm\.so\.6|libdl\.so\.2|libpthread\.so\.0|librt\.so\.1|libstdc\+\+\.so\.6|libgcc_s\.so\.1|libatomic\.so\.1|libfreetype\.so\.6|libpng[0-9]*\.so\.[0-9]+|libz\.so\.1|libbrotli(dec|common)\.so\.1)$'
|
||||
|
||||
unexpected=""
|
||||
for object in "$APPDIR/bin/love" "$APPDIR"/lib/*.so*; do
|
||||
while read -r soname; do
|
||||
[ -n "$soname" ] || continue
|
||||
# Satisfied from inside the AppDir, so not a host requirement at all.
|
||||
if [ -n "${BUNDLED[$soname]:-}" ]; then continue; fi
|
||||
if [[ "$soname" =~ $HOST_ALLOWED_RE ]]; then continue; fi
|
||||
unexpected="$unexpected $(basename "$object") -> $soname"$'\n'
|
||||
done < <(objdump -p "$object" | awk '/NEEDED/ {print $2}')
|
||||
done
|
||||
[ -z "$unexpected" ] || fail "$(printf '%s\n%s' \
|
||||
"these objects hard-require host libraries outside the allowed set (they must be dlopened, not linked):" \
|
||||
"$unexpected")"
|
||||
say "host dependency contract holds (glibc, libstdc++ and the font stack only)"
|
||||
|
||||
# LÖVE loads jit.* (jit.status, the profiler) through LUA_PATH; without these
|
||||
# the modules are simply absent, so ship them the way upstream's image does.
|
||||
jit_share="$(ls -d /usr/share/luajit-* 2>/dev/null | head -1)"
|
||||
[ -n "$jit_share" ] || fail "luajit jit/*.lua modules not found under /usr/share"
|
||||
LUAJIT_SHARE_DIR="$(basename "$jit_share")"
|
||||
mkdir -p "$APPDIR/share/$LUAJIT_SHARE_DIR" "$APPDIR/share/lua/5.1" "$APPDIR/lib/lua/5.1"
|
||||
cp -R "$jit_share/jit" "$APPDIR/share/$LUAJIT_SHARE_DIR/"
|
||||
|
||||
# --------------------------------------------------------------- branding
|
||||
cp "$IN/game.love" "$APPDIR/game.love"
|
||||
# The .desktop's Icon= resolves against the AppDir root by basename, and
|
||||
# .DirIcon is what appimaged and file-manager thumbnailers read.
|
||||
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png"
|
||||
cp "$IN/icon.png" "$APPDIR/.DirIcon"
|
||||
|
||||
cat > "$APPDIR/$APP_NAME.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=gen1recomp
|
||||
Comment=Pokémon Gen 1 recompilation
|
||||
Exec=$APP_NAME
|
||||
Icon=$APP_NAME
|
||||
Categories=Game;
|
||||
Terminal=false
|
||||
EOF
|
||||
|
||||
# AppRun follows LÖVE's own, with FUSE_PATH committed to instead of shipped
|
||||
# commented out: this image is a game, not the engine, so it must never fall
|
||||
# through to LÖVE's "no game" screen.
|
||||
cat > "$APPDIR/AppRun" <<EOF
|
||||
#!/bin/sh
|
||||
# gen1recomp aarch64 AppImage launcher.
|
||||
|
||||
if [ -z "\$APPDIR" ]; then
|
||||
APPDIR="\$(dirname "\$(readlink -f "\$0")")"
|
||||
fi
|
||||
|
||||
export LD_LIBRARY_PATH="\$APPDIR/lib/:\$LD_LIBRARY_PATH"
|
||||
|
||||
if [ -z "\$XDG_DATA_DIRS" ]; then
|
||||
XDG_DATA_DIRS="/usr/local/share/:/usr/share/"
|
||||
fi
|
||||
export XDG_DATA_DIRS="\$APPDIR/share/:\$XDG_DATA_DIRS"
|
||||
|
||||
if [ -z "\$LUA_PATH" ]; then
|
||||
LUA_PATH=";"
|
||||
fi
|
||||
export LUA_PATH="\$APPDIR/share/$LUAJIT_SHARE_DIR/?.lua;\$APPDIR/share/lua/5.1/?.lua;\$LUA_PATH"
|
||||
|
||||
if [ -z "\$LUA_CPATH" ]; then
|
||||
LUA_CPATH=";"
|
||||
fi
|
||||
export LUA_CPATH="\$APPDIR/lib/lua/5.1/?.so;\$LUA_CPATH"
|
||||
|
||||
exec "\$APPDIR/bin/love" --fused "\$APPDIR/game.love" "\$@"
|
||||
EOF
|
||||
chmod +x "$APPDIR/AppRun"
|
||||
|
||||
[ -f "$PREFIX/license.txt" ] || fail "LÖVE license.txt missing from the build prefix"
|
||||
cp "$PREFIX/license.txt" "$APPDIR/license.love2d.txt"
|
||||
|
||||
# --------------------------------------------------------------- fuse image
|
||||
# An AppImage is just <runtime ELF><squashfs>. gzip at 128K blocks matches what
|
||||
# LÖVE's official image uses and what every type-2 runtime can read; zstd would
|
||||
# be smaller but is not universally supported by older runtimes users may have
|
||||
# registered through appimaged.
|
||||
say "packing squashfs"
|
||||
sfs="$WORK/payload.squashfs"
|
||||
rm -f "$sfs"
|
||||
mksquashfs "$APPDIR" "$sfs" \
|
||||
-comp gzip -b 131072 -noappend -all-root -no-xattrs -quiet >/dev/null
|
||||
|
||||
out="$OUT/$APP_NAME-$VERSION-linux-arm64.AppImage"
|
||||
rm -f "$out"
|
||||
cat "$CACHE/runtime-aarch64" "$sfs" > "$out"
|
||||
chmod +x "$out"
|
||||
|
||||
say "AppImage: $(basename "$out") ($(du -h "$out" | cut -f1))"
|
||||
Executable
+180
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared helpers and pins for the aarch64 Linux AppImage build.
|
||||
# Source from other scripts: . "$(dirname "$0")/common.sh"
|
||||
|
||||
# shellcheck disable=SC2034
|
||||
if [ -z "${ROOT:-}" ]; then
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
fi
|
||||
export ROOT
|
||||
|
||||
# ---------------------------------------------------------------- pins
|
||||
# LÖVE ships no aarch64 binary of any kind -- the 11.5 release has win32/win64,
|
||||
# macOS, Android, iOS and an x86_64 AppImage, and that is the whole list. So
|
||||
# this port compiles the official linux-src tarball instead of unpacking a
|
||||
# prebuilt image the way scripts/build.sh does for x86_64.
|
||||
LOVE_VERSION="11.5"
|
||||
LOVE_SRC_TARBALL="love-$LOVE_VERSION-linux-src.tar.gz"
|
||||
LOVE_SRC_URL="https://github.com/love2d/love/releases/download/$LOVE_VERSION/$LOVE_SRC_TARBALL"
|
||||
LOVE_SRC_SHA256="066e0843f71aa9fd28b8eaf27d41abb74bfaef7556153ac2e3cf08eafc874c39"
|
||||
|
||||
# SDL2 is built from source rather than taken from bullseye, and this is a
|
||||
# correctness requirement, not a version preference. Debian's libSDL2 lists
|
||||
# libpulse, libasound, libX11 and libwayland-client as DT_NEEDED -- hard links
|
||||
# resolved by the loader at startup -- so an AppImage bundling it refuses to
|
||||
# launch unless the host has ALL FOUR installed. That is wrong for an artifact
|
||||
# whose whole job is to run on arbitrary arm64 systems: an ALSA-only handheld
|
||||
# or a minimal Wayland box would die before main(). Built from source, SDL
|
||||
# defaults to dlopening every audio and video backend (--enable-*-shared), so
|
||||
# it loads whichever the host actually has and degrades gracefully.
|
||||
# The newer version is a bonus: 2.30 has a far better controller database and
|
||||
# real KMSDRM support, both of which matter on Pi-class and handheld hardware.
|
||||
SDL2_VERSION="2.30.12"
|
||||
SDL2_TARBALL="SDL2-$SDL2_VERSION.tar.gz"
|
||||
SDL2_URL="https://github.com/libsdl-org/SDL/releases/download/release-$SDL2_VERSION/$SDL2_TARBALL"
|
||||
SDL2_SHA256="ac356ea55e8b9dd0b2d1fa27da40ef7e238267ccf9324704850d5d47375b48ea"
|
||||
|
||||
# libtheora likewise. Debian's libtheoradec.so.1 is linked against libcairo --
|
||||
# a packaging artifact, since a video decoder has no business drawing vector
|
||||
# graphics -- and cairo drags in libX11, libxcb, libfontconfig and libfreetype
|
||||
# as hard dependencies. LOVE needs theora for love.video, so that link would
|
||||
# put the entire X11 and font stack on the critical path at startup, and it is
|
||||
# what caused the FT_Get_Transform crash this build hit on a trixie host.
|
||||
# Upstream's tarball with --disable-examples produces a libtheoradec that
|
||||
# needs only libogg.
|
||||
THEORA_VERSION="1.1.1"
|
||||
THEORA_TARBALL="libtheora-$THEORA_VERSION.tar.bz2"
|
||||
THEORA_URL="https://downloads.xiph.org/releases/theora/$THEORA_TARBALL"
|
||||
THEORA_SHA256="b6ae1ee2fa3d42ac489287d3ec34c5885730b1296f0801ae577a35193d3affbc"
|
||||
|
||||
# OpenAL for the same reason as SDL2, one level down. Debian's libopenal is
|
||||
# openal-soft built with the sndio backend enabled, so it hard-links
|
||||
# libsndio, which itself hard-links libasound -- reintroducing exactly the
|
||||
# mandatory-ALSA dependency the SDL2 source build exists to remove. Upstream
|
||||
# openal-soft dlopens its backends, so building it here leaves the shipped
|
||||
# library with no audio-stack dependency at all.
|
||||
OPENAL_VERSION="1.23.1"
|
||||
OPENAL_TARBALL="openal-soft-$OPENAL_VERSION.tar.gz"
|
||||
OPENAL_URL="https://github.com/kcat/openal-soft/archive/refs/tags/$OPENAL_VERSION.tar.gz"
|
||||
OPENAL_SHA256="dfddf3a1f61059853c625b7bb03de8433b455f2f79f89548cbcbd5edca3d4a4a"
|
||||
|
||||
# The audio codecs are built from source for a third, different reason: SONAME
|
||||
# collision with the host's audio stack.
|
||||
#
|
||||
# OpenAL dlopens ALSA, ALSA's config loads its PulseAudio hook plugin, and that
|
||||
# plugin pulls the HOST's libsndfile into our process. libsndfile links
|
||||
# libogg, libvorbis and libmpg123 -- the same three we bundle. The loader
|
||||
# resolves a SONAME once per process, so the host's libsndfile binds to OUR
|
||||
# copies, and a bullseye libmpg123 has no mpg123_info2 (added in 1.32):
|
||||
#
|
||||
# openal -> libasound -> libasound_module_conf_pulse -> libsndfile (host)
|
||||
# `-> mpg123_info2 -> libmpg123 (ours, bullseye)
|
||||
#
|
||||
# which failed to relocate and left the game with no audio device at all.
|
||||
# Not bundling them instead would make libogg/libvorbis/libmpg123 mandatory
|
||||
# host packages; building them current means our copies satisfy the host's
|
||||
# libsndfile rather than starving it. libvorbisfile ships in the vorbis
|
||||
# tarball.
|
||||
OGG_VERSION="1.3.5"
|
||||
OGG_TARBALL="libogg-$OGG_VERSION.tar.gz"
|
||||
OGG_URL="https://downloads.xiph.org/releases/ogg/$OGG_TARBALL"
|
||||
OGG_SHA256="0eb4b4b9420a0f51db142ba3f9c64b333f826532dc0f48c6410ae51f4799b664"
|
||||
|
||||
VORBIS_VERSION="1.3.7"
|
||||
VORBIS_TARBALL="libvorbis-$VORBIS_VERSION.tar.gz"
|
||||
VORBIS_URL="https://downloads.xiph.org/releases/vorbis/$VORBIS_TARBALL"
|
||||
VORBIS_SHA256="0e982409a9c3fc82ee06e08205b1355e5c6aa4c36bca58146ef399621b0ce5ab"
|
||||
|
||||
MPG123_VERSION="1.32.10"
|
||||
MPG123_TARBALL="mpg123-$MPG123_VERSION.tar.bz2"
|
||||
MPG123_URL="https://www.mpg123.de/download/$MPG123_TARBALL"
|
||||
MPG123_SHA256="87b2c17fe0c979d3ef38eeceff6362b35b28ac8589fbf1854b5be75c9ab6557c"
|
||||
|
||||
# AppImage type-2 runtime: the ~900 KB static-pie ELF that gets prepended to
|
||||
# the squashfs payload. Pinned to a dated tag, never "continuous", so a
|
||||
# rebuild months from now produces the same bytes.
|
||||
APPIMAGE_RUNTIME_TAG="20251108"
|
||||
APPIMAGE_RUNTIME_NAME="runtime-aarch64"
|
||||
APPIMAGE_RUNTIME_URL="https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_TAG/$APPIMAGE_RUNTIME_NAME"
|
||||
APPIMAGE_RUNTIME_SHA256="00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444"
|
||||
|
||||
# Debian bullseye (glibc 2.31) is the compile environment, NOT a statement
|
||||
# about where the artifact runs. glibc is backward compatible but not forward
|
||||
# compatible, so linking against the oldest glibc we support is what lets one
|
||||
# AppImage cover Raspberry Pi OS bullseye/bookworm/trixie, Ubuntu 20.04+ and
|
||||
# the aarch64 handheld distros. Building on a newer base would silently
|
||||
# restrict the artifact to that base and newer.
|
||||
BUILDER_BASE_IMAGE="debian:bullseye"
|
||||
BUILDER_IMAGE="${GEN1_LINUX_ARM64_IMAGE:-gen1recomp-linux-arm64-builder}"
|
||||
|
||||
APP_NAME="gen1recomp"
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# Print SHA-256 hex digest of PATH. Prefers sha256sum, falls back to shasum
|
||||
# (same order-agnostic pair scripts/switch/common.sh uses).
|
||||
sha256_file() {
|
||||
local path="$1"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$path" | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$path" | awk '{print $1}'
|
||||
else
|
||||
fail "need sha256sum or shasum (install coreutils)"
|
||||
fi
|
||||
}
|
||||
|
||||
# download_pinned URL DEST EXPECTED_SHA256
|
||||
#
|
||||
# A cache hit is only trusted if it still hashes to the pin: a download
|
||||
# truncated by a network drop would otherwise be reused forever, which is the
|
||||
# same trap scripts/build.sh guards for the win64 zip and the x86_64 AppImage.
|
||||
download_pinned() {
|
||||
local url="$1" dest="$2" want="$3" got=""
|
||||
if [ -f "$dest" ]; then
|
||||
got="$(sha256_file "$dest")"
|
||||
if [ "$got" = "$want" ]; then
|
||||
return 0
|
||||
fi
|
||||
warn "cached $(basename "$dest") has the wrong digest, re-downloading"
|
||||
rm -f "$dest"
|
||||
fi
|
||||
say "downloading $(basename "$dest")"
|
||||
curl -fL --progress-bar "$url" -o "$dest.tmp" || fail "download failed: $url"
|
||||
got="$(sha256_file "$dest.tmp")"
|
||||
[ "$got" = "$want" ] || fail "$(printf '%s\n expected %s\n got %s' \
|
||||
"checksum mismatch for $(basename "$dest")" "$want" "$got")"
|
||||
mv "$dest.tmp" "$dest"
|
||||
}
|
||||
|
||||
# Echo the container runtime to use: docker, else podman.
|
||||
container_runtime() {
|
||||
if [ -n "${GEN1_CONTAINER_RUNTIME:-}" ]; then
|
||||
printf '%s' "$GEN1_CONTAINER_RUNTIME"
|
||||
return 0
|
||||
fi
|
||||
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
||||
printf 'docker'
|
||||
elif command -v podman >/dev/null 2>&1; then
|
||||
printf 'podman'
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
fail_need_container() {
|
||||
fail "$(cat <<'EOF'
|
||||
the aarch64 AppImage is compiled inside a Debian bullseye container and needs
|
||||
docker or podman on an aarch64 host.
|
||||
|
||||
Raspberry Pi OS / Debian / Ubuntu: sudo apt install docker.io && sudo usermod -aG docker "$USER"
|
||||
Fedora / Asahi: sudo dnf install podman
|
||||
macOS (Apple Silicon): brew install --cask docker
|
||||
|
||||
Override the runtime with GEN1_CONTAINER_RUNTIME=podman.
|
||||
See docs/linux-arm64-build.md.
|
||||
EOF
|
||||
)"
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env bash
|
||||
# Offline checks for the aarch64 Linux AppImage build.
|
||||
#
|
||||
# Runs anywhere -- no container, no network, no aarch64 host -- so PR CI can
|
||||
# gate the parts of this build that do not need three minutes of compiling.
|
||||
# The real build is exercised separately by the linux-arm64-build job.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=common.sh
|
||||
. "$SCRIPT_DIR/common.sh"
|
||||
|
||||
require_command() {
|
||||
command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"
|
||||
}
|
||||
require_command unzip
|
||||
require_command zip
|
||||
|
||||
say "checking shell entry points"
|
||||
bash -n "$ROOT/scripts/build_linux_arm64.sh" "$SCRIPT_DIR"/*.sh
|
||||
help="$(bash "$ROOT/scripts/build_linux_arm64.sh" --help)"
|
||||
printf '%s' "$help" | grep -q -- '--version X.Y.Z' \
|
||||
|| fail "build help does not document --version"
|
||||
printf '%s' "$help" | grep -q 'linux-arm64\.AppImage' \
|
||||
|| fail "build help does not name the artifact it produces"
|
||||
|
||||
say "checking the host-architecture guard"
|
||||
# The guard is what stops someone from kicking off a qemu-emulated build that
|
||||
# takes hours and miscompiles LuaJIT. Prove it fires rather than trusting it.
|
||||
# The guard is what stops someone from kicking off a qemu-emulated build that
|
||||
# takes hours and has miscompiled LuaJIT before. Prove it fires by shadowing
|
||||
# uname, rather than trusting the branch is reachable.
|
||||
fake_bin="$(mktemp -d "${TMPDIR:-/tmp}/gen1recomp-fake-uname.XXXXXX")"
|
||||
printf '#!/bin/sh\necho x86_64\n' > "$fake_bin/uname"
|
||||
chmod +x "$fake_bin/uname"
|
||||
guard_out="$(PATH="$fake_bin:$PATH" \
|
||||
bash "$ROOT/scripts/build_linux_arm64.sh" --version 0.0.0 2>&1 || true)"
|
||||
rm -rf "$fake_bin"
|
||||
printf '%s' "$guard_out" | grep -q 'aarch64 host' \
|
||||
|| fail "build script does not refuse to run on a non-aarch64 host"
|
||||
|
||||
say "checking pinned inputs"
|
||||
# Pins must be real digests, and the AppImage runtime must come from a dated
|
||||
# tag: "continuous" is a moving target and would make rebuilds unreproducible.
|
||||
for pin_name in LOVE_SRC_SHA256 SDL2_SHA256 OPENAL_SHA256 THEORA_SHA256 \
|
||||
OGG_SHA256 VORBIS_SHA256 MPG123_SHA256 APPIMAGE_RUNTIME_SHA256; do
|
||||
pin_value="${!pin_name}"
|
||||
printf '%s' "$pin_value" | grep -Eq '^[0-9a-f]{64}$' \
|
||||
|| fail "$pin_name is not a sha256 digest: $pin_value"
|
||||
done
|
||||
if printf '%s' "$APPIMAGE_RUNTIME_URL" | grep -q '/continuous/'; then
|
||||
fail "the AppImage runtime is pinned to the moving 'continuous' tag"
|
||||
fi
|
||||
printf '%s' "$APPIMAGE_RUNTIME_URL" | grep -q "/$APPIMAGE_RUNTIME_TAG/$APPIMAGE_RUNTIME_NAME\$" \
|
||||
|| fail "APPIMAGE_RUNTIME_URL does not match the pinned tag/asset"
|
||||
printf '%s' "$LOVE_SRC_URL" | grep -q "/$LOVE_VERSION/$LOVE_SRC_TARBALL\$" \
|
||||
|| fail "LOVE_SRC_URL does not match LOVE_VERSION/LOVE_SRC_TARBALL"
|
||||
|
||||
say "checking the builder base image"
|
||||
# Building on anything newer than bullseye silently raises the glibc floor and
|
||||
# strands every user on an older distro, with no symptom until they run it.
|
||||
grep -q '^FROM debian:bullseye$' "$SCRIPT_DIR/Dockerfile" \
|
||||
|| fail "Dockerfile no longer builds on debian:bullseye (that raises the glibc floor)"
|
||||
[ "$BUILDER_BASE_IMAGE" = "debian:bullseye" ] \
|
||||
|| fail "BUILDER_BASE_IMAGE disagrees with the Dockerfile"
|
||||
|
||||
say "checking the dependency exclude list"
|
||||
# Extract the live regex from the build script and classify known sonames
|
||||
# through it, so a future edit cannot quietly start bundling glibc or stop
|
||||
# bundling the engine's own dependencies.
|
||||
EXCLUDE_RE="$(
|
||||
# shellcheck disable=SC1090
|
||||
grep -m1 "^EXCLUDE_RE=" "$SCRIPT_DIR/build_appimage.sh" | sed "s/^EXCLUDE_RE='//; s/'\$//"
|
||||
)"
|
||||
[ -n "$EXCLUDE_RE" ] || fail "could not read EXCLUDE_RE out of build_appimage.sh"
|
||||
|
||||
must_exclude=(libc.so.6 ld-linux-aarch64.so.1 libstdc++.so.6 libgcc_s.so.1
|
||||
libGL.so.1 libEGL.so.1 libgbm.so.1 libdrm.so.2 libX11.so.6
|
||||
libwayland-client.so.0 libpulse.so.0 libasound.so.2
|
||||
libfreetype.so.6 libfontconfig.so.1 libpng16.so.16 libz.so.1)
|
||||
must_bundle=(libSDL2-2.0.so.0 libopenal.so.1 libluajit-5.1.so.2 libmodplug.so.1
|
||||
libmpg123.so.0 libogg.so.0 libvorbis.so.0 libvorbisfile.so.3
|
||||
libtheoradec.so.1 liblove-11.5.so)
|
||||
|
||||
for soname in "${must_exclude[@]}"; do
|
||||
[[ "$soname" =~ $EXCLUDE_RE ]] \
|
||||
|| fail "$soname must be host-provided but the exclude list would bundle it"
|
||||
done
|
||||
for soname in "${must_bundle[@]}"; do
|
||||
if [[ "$soname" =~ $EXCLUDE_RE ]]; then
|
||||
fail "$soname is an engine dependency but the exclude list drops it"
|
||||
fi
|
||||
done
|
||||
|
||||
say "checking AppRun and the fusion contract"
|
||||
# The AppImage must boot straight into the game. If AppRun ever loses --fused,
|
||||
# users get vanilla LÖVE's "no game" screen instead, and nothing else catches
|
||||
# that before someone downloads a release.
|
||||
grep -qF -- '--fused "\$APPDIR/game.love"' "$SCRIPT_DIR/build_appimage.sh" \
|
||||
|| fail "AppRun no longer launches game.love with --fused"
|
||||
grep -qF 'LD_LIBRARY_PATH="\$APPDIR/lib/' "$SCRIPT_DIR/build_appimage.sh" \
|
||||
|| fail "AppRun no longer puts the bundled lib directory on LD_LIBRARY_PATH"
|
||||
grep -qF 'comp gzip -b 131072' "$SCRIPT_DIR/build_appimage.sh" \
|
||||
|| fail "squashfs payload is no longer gzip/128K (older type-2 runtimes cannot read it)"
|
||||
|
||||
say "checking the linked-module assertions"
|
||||
# configure exits 0 when an optional -dev package is missing and just drops the
|
||||
# module, so these assertions are the only thing standing between a missing
|
||||
# build dependency and a release that cannot play sound.
|
||||
for soname in libSDL2-2.0.so.0 libopenal.so.1 libfreetype.so.6 libmodplug.so.1 \
|
||||
libmpg123.so.0 libvorbisfile.so.3 libtheoradec.so.1; do
|
||||
grep -qF "$soname" "$SCRIPT_DIR/build_appimage.sh" \
|
||||
|| fail "build_appimage.sh no longer asserts liblove links $soname"
|
||||
done
|
||||
|
||||
say "checking the dlopen guarantees"
|
||||
# SDL2, OpenAL and libtheora are compiled from source for correctness, not for
|
||||
# a newer version number: Debian's builds hard-link libpulse/libasound/libX11/
|
||||
# libwayland (SDL2), libsndio (OpenAL) and libcairo (libtheora), each of which
|
||||
# turns an optional runtime capability into a mandatory startup dependency.
|
||||
# If a future edit drops the source build and reaches for the -dev package
|
||||
# again, the AppImage silently stops starting on lean systems.
|
||||
for forbidden_pkg in libsdl2-dev libtheora-dev libopenal-dev; do
|
||||
if grep -qE "^ +.*\b$forbidden_pkg\b" "$SCRIPT_DIR/Dockerfile"; then
|
||||
fail "Dockerfile installs $forbidden_pkg; that library is built from source on purpose"
|
||||
fi
|
||||
done
|
||||
grep -qF -- '--enable-alsa-shared' "$SCRIPT_DIR/build_appimage.sh" \
|
||||
|| fail "SDL2 is no longer configured to dlopen its audio backends"
|
||||
grep -qF -- '--enable-x11-shared' "$SCRIPT_DIR/build_appimage.sh" \
|
||||
|| fail "SDL2 is no longer configured to dlopen its video backends"
|
||||
grep -qF 'ALSOFT_DLOPEN=ON' "$SCRIPT_DIR/build_appimage.sh" \
|
||||
|| fail "openal-soft is no longer configured to dlopen its backends"
|
||||
grep -qF -- '--disable-examples' "$SCRIPT_DIR/build_appimage.sh" \
|
||||
|| fail "libtheora is no longer built with --disable-examples (it regains the libcairo link)"
|
||||
|
||||
say "checking the host dependency contract"
|
||||
# The shipped objects may require nothing from the host beyond glibc,
|
||||
# libstdc++ and the font stack. Everything driver-, session- or audio-related
|
||||
# has to be dlopened. This is the invariant a headless CI runner proved was
|
||||
# broken the first time round.
|
||||
HOST_ALLOWED_RE="$(
|
||||
grep -m1 "^HOST_ALLOWED_RE=" "$SCRIPT_DIR/build_appimage.sh" \
|
||||
| sed "s/^HOST_ALLOWED_RE='//; s/'\$//"
|
||||
)"
|
||||
[ -n "$HOST_ALLOWED_RE" ] || fail "could not read HOST_ALLOWED_RE out of build_appimage.sh"
|
||||
for soname in libpulse.so.0 libasound.so.2 libX11.so.6 libwayland-client.so.0 \
|
||||
libGL.so.1 libcairo.so.2 libsndio.so.7.0 libdbus-1.so.3; do
|
||||
if [[ "$soname" =~ $HOST_ALLOWED_RE ]]; then
|
||||
fail "$soname is allowed as a hard host dependency; it must be dlopened"
|
||||
fi
|
||||
done
|
||||
for soname in libc.so.6 libstdc++.so.6 libfreetype.so.6 libz.so.1; do
|
||||
[[ "$soname" =~ $HOST_ALLOWED_RE ]] \
|
||||
|| fail "$soname must be allowed as a host dependency but the contract rejects it"
|
||||
done
|
||||
|
||||
say "checking the shared game.love payload"
|
||||
temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/gen1recomp-linux-arm64-selftest.XXXXXX")"
|
||||
trap 'rm -rf "$temp_dir"' EXIT
|
||||
"$ROOT/scripts/pack_love.sh" \
|
||||
--output "$temp_dir/game.love" \
|
||||
--listing "$temp_dir/love-listing.txt" \
|
||||
--version 1.2.3 \
|
||||
--dry-run >/dev/null
|
||||
unzip -p "$temp_dir/game.love" src/core/Version.lua \
|
||||
| grep -Eq 'engine[[:space:]]*=[[:space:]]*"1\.2\.3"' \
|
||||
|| fail "shared payload version was not stamped"
|
||||
|
||||
say "Linux arm64 self-test passed"
|
||||
@@ -22,6 +22,7 @@ set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
LUA=${LUA:-luajit}
|
||||
LUA54=${LUA54:-lua5.4}
|
||||
BLESS=0
|
||||
QUICK=0
|
||||
SHOTS=${WITH_SHOTS:-0}
|
||||
@@ -134,6 +135,16 @@ if [ -f data/generated/maps.lua ]; then
|
||||
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 "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
|
||||
# INDEPENDENT PKHeX-derived gen1lib codec, which cannot run under luajit
|
||||
# (native 5.3+ operators). Needs a stock Lua 5.3/5.4; skip when absent.
|
||||
if command -v "$LUA54" >/dev/null 2>&1; then
|
||||
run_tier "T3 save oversize vendor oracle" "$LUA54" tests/save_oversize_vendor_test.lua
|
||||
else
|
||||
echo ""
|
||||
echo "-- T3 save oversize vendor oracle: skipped (no '$LUA54' on PATH; set LUA54=...)"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
|
||||
+70
-13
@@ -1362,6 +1362,23 @@ function BattleState:sendOutText(name)
|
||||
return self:romText("_EnemysWeakText", "The enemy's weak!\nGet'm! %s!", name)
|
||||
end
|
||||
|
||||
-- The cry a mon makes as it takes the field. Yellow does not run its
|
||||
-- starter Pikachu through PlayCry at all: SendOutMon branches to
|
||||
-- .starterPikachu (engine/battle/core.asm:1807-1817) and voices PCM
|
||||
-- PikachuCry11, the short "Pika!", or PikachuCry37 when the Pikachu is
|
||||
-- asleep (IsPlayerPikachuAsleepInParty); PrintBeginningBattleText does the
|
||||
-- same for the BATTLE_TYPE_PIKACHU intro (engine/battle/common_text.asm:
|
||||
-- 12-19). Without a clip the bare playCry reached for clip 1, the long
|
||||
-- title-screen "Pikachuuu" (#837). Every PIKACHU gets it here, the same
|
||||
-- starter approximation the rest of the port makes
|
||||
-- (PikachuFollower.starterInParty).
|
||||
function BattleState:playEntranceCry(battler)
|
||||
local mon = battler and battler.mon
|
||||
if not mon then return end
|
||||
require("src.core.Sound").playCry(self.data, mon.species,
|
||||
mon.status == "SLP" and 37 or 11)
|
||||
end
|
||||
|
||||
-- audio/play_battle_music.asm: gym leaders (wGymLeaderNo) get the
|
||||
-- gym-leader theme, Lance does too, and the Champion (OPP_RIVAL3)
|
||||
-- gets the final-battle theme
|
||||
@@ -1495,7 +1512,7 @@ function BattleState:enter()
|
||||
-- a different point in each battle kind, so queue it per branch
|
||||
local function queueEnemyCry()
|
||||
self:act(function()
|
||||
require("src.core.Sound").playCry(self.data, self.enemy.mon.species)
|
||||
self:playEntranceCry(self.enemy)
|
||||
end)
|
||||
end
|
||||
-- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild
|
||||
@@ -1606,7 +1623,7 @@ function BattleState:enter()
|
||||
-- SendOutMon (core.asm:1757-1762): after the poof the mon grows
|
||||
-- out of the ball (AnimateSendingOutMon at hlcoord 4,11)
|
||||
self:startGrowIn(self.player)
|
||||
require("src.core.Sound").playCry(self.data, self.player.mon.species)
|
||||
self:playEntranceCry(self.player)
|
||||
end)
|
||||
self:markParticipant()
|
||||
end
|
||||
@@ -2355,7 +2372,7 @@ function BattleState:resolveSwitch(newMon)
|
||||
self.sendingOut = false
|
||||
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
||||
self:startGrowIn(self.player)
|
||||
require("src.core.Sound").playCry(self.data, self.player.mon.species)
|
||||
self:playEntranceCry(self.player)
|
||||
end)
|
||||
end)
|
||||
self:act(function()
|
||||
@@ -2823,7 +2840,16 @@ function BattleState:applyHitFx(hit)
|
||||
local t = hit.animType
|
||||
if not t and hit.blink then t = hit.blink.isPlayer and 1 or 4 end
|
||||
if hit.sfx then
|
||||
require("src.core.Sound").play(self.data, hit.sfx)
|
||||
local Sound = require("src.core.Sound")
|
||||
-- EffectRegistry hands the row the PlayApplyingAttackSound sound WITH its
|
||||
-- wFrequencyModifier byte, so it goes through the same pitch/tempo path
|
||||
-- move sounds use (#826). A bare string -- an older row, or a mod that
|
||||
-- built its own hit fx -- still plays unmodified.
|
||||
if type(hit.sfx) == "table" then
|
||||
Sound.playMove(self.data, hit.sfx)
|
||||
else
|
||||
Sound.play(self.data, hit.sfx)
|
||||
end
|
||||
end
|
||||
if not t or not self:animationsOn() then return end
|
||||
if t == 1 then
|
||||
@@ -3162,6 +3188,16 @@ function BattleState:executeAction(user, target, action)
|
||||
user.boundTurns = target.trappingTurns
|
||||
and math.max(1, target.trappingTurns) or nil
|
||||
|
||||
-- wPlayerSelectedMove / wEnemySelectedMove as the status gauntlet
|
||||
-- reads it: the locked specials keep continuing the move they
|
||||
-- started, so they carry a move id too. Resolved once here and
|
||||
-- handed to every statusInterrupt below, which is where
|
||||
-- .TriedToUseDisabledMoveCheck lives (#860).
|
||||
local selectedId = action.id
|
||||
or (action.special == "trapping" and user.trapMove)
|
||||
or (action.special == "bide" and "BIDE")
|
||||
or nil
|
||||
|
||||
-- trainer class AI actions (engine/battle/trainer_ai.asm)
|
||||
if action.special == "aiItem" then
|
||||
self.aiUses = (self.aiUses or 1) - 1
|
||||
@@ -3219,17 +3255,17 @@ function BattleState:executeAction(user, target, action)
|
||||
return
|
||||
end
|
||||
if action.special == "trapping" then
|
||||
if self:statusInterrupt(user, target) then return end
|
||||
if self:statusInterrupt(user, target, selectedId) then return end
|
||||
self:continueTrapping(user, target)
|
||||
return
|
||||
end
|
||||
if action.special == "bide" then
|
||||
if self:statusInterrupt(user, target) then return end
|
||||
if self:statusInterrupt(user, target, selectedId) then return end
|
||||
self:continueBide(user, target)
|
||||
return
|
||||
end
|
||||
|
||||
if self:statusInterrupt(user, target) then return end
|
||||
if self:statusInterrupt(user, target, selectedId) then return end
|
||||
self:performMove(user, target, action, false)
|
||||
end
|
||||
run()
|
||||
@@ -3323,8 +3359,8 @@ end
|
||||
|
||||
-- Runs Status.beforeMove plus the shared interruption bookkeeping;
|
||||
-- returns true when the user's action is interrupted.
|
||||
function BattleState:statusInterrupt(user, target)
|
||||
local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self)
|
||||
function BattleState:statusInterrupt(user, target, selectedId)
|
||||
local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self, selectedId)
|
||||
for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end
|
||||
if selfHit then
|
||||
-- confusion self-hit (core.asm:3428-3434): clears everything in
|
||||
@@ -3894,7 +3930,7 @@ function BattleState:enemyMonFainted()
|
||||
self.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").playCry(self.data, self.enemy.mon.species)
|
||||
self:playEntranceCry(self.enemy)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
@@ -3933,7 +3969,7 @@ function BattleState:enemyMonFainted()
|
||||
self:actNext(function()
|
||||
self.sendingOut = false
|
||||
self:startGrowIn(self.player)
|
||||
require("src.core.Sound").playCry(self.data, self.player.mon.species)
|
||||
self:playEntranceCry(self.player)
|
||||
end)
|
||||
end)
|
||||
return
|
||||
@@ -4127,7 +4163,7 @@ function BattleState:openReplacementMenu()
|
||||
self.sendingOut = false
|
||||
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
||||
self:startGrowIn(self.player)
|
||||
require("src.core.Sound").playCry(self.data, self.player.mon.species)
|
||||
self:playEntranceCry(self.player)
|
||||
end)
|
||||
end,
|
||||
})
|
||||
@@ -5116,11 +5152,32 @@ function BattleState:drawZonePass(src, sx, sy)
|
||||
local shader = PaletteFX.shader()
|
||||
local pals = self:sgbBattlePals()
|
||||
local bgp = self:activeBgp()
|
||||
-- #822: OG / OG INV / CLASSIC are forced-mono modes, so sgbPalettes() being
|
||||
-- nil here makes PaletteFX.ensureZones invent a whole-screen zone and the
|
||||
-- WHOLE finished frame is re-thresholded through the shade shader at blit
|
||||
-- time -- which is why picImage already hands those modes raw DMG grays.
|
||||
-- This pass has to leave DMG shades behind for the same reason: sendColors
|
||||
-- runs the mode substitution HERE too, and the frame-level pass then
|
||||
-- substitutes a second time. OG INV inverts twice and comes out upright;
|
||||
-- CLASSIC's color 0 (155,188,15) has red 0.61, which falls in the shader's
|
||||
-- c1 bucket, so the paper darkens one shade. Either way the battle stops
|
||||
-- matching the YES/NO box an overlay state draws over it, since that box
|
||||
-- only ever sees the frame-level pass. OG is the identity, which is why
|
||||
-- only the other two showed it. Keep this mode set in sync with picImage /
|
||||
-- PaletteFX.ensureZones / WideBattle.monoMode.
|
||||
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
|
||||
or PaletteFX.mode == "classic"
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setShader(shader)
|
||||
local shaking = sx ~= 0 or sy ~= 0
|
||||
for _, z in ipairs(BATTLE_ZONES) do
|
||||
PaletteFX.sendColors(shader, PaletteFX.permute(pals[z.pal], bgp))
|
||||
if mono then
|
||||
-- the BGP fade still runs, just in gray: the frame-level pass colors
|
||||
-- whatever DMG shade this leaves behind
|
||||
PaletteFX.sendShades(shader, PaletteFX.permute(PaletteFX.GRAYS, bgp))
|
||||
else
|
||||
PaletteFX.sendColors(shader, PaletteFX.permute(pals[z.pal], bgp))
|
||||
end
|
||||
local zx, zy = z[1] * 8, z[2] * 8
|
||||
local zw, zh = (z[3] - z[1] + 1) * 8, (z[4] - z[2] + 1) * 8
|
||||
love.graphics.setScissor(zx, zy, zw, zh)
|
||||
|
||||
@@ -209,8 +209,28 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- announcement-time moveAnimRow, later hits queue fresh anim rows.
|
||||
-- Thrash/rage continuations have no announcement anim -- a bare
|
||||
-- hitRow carries the blink instead.
|
||||
local hitSfx = info.typeMult > 10 and "Super_Effective"
|
||||
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
|
||||
-- PlayApplyingAttackSound (engine/battle/animations.asm, the routine after
|
||||
-- PlayApplyingAttackAnimation) picks the sound off wDamageMultipliers -- 10
|
||||
-- is neutral, above it super effective, below it not very -- and sets
|
||||
-- wFrequencyModifier/wTempoModifier alongside it: $20/$30 damage, $e0/$ff
|
||||
-- super effective, $50/$01 not very. All three programs live on the noise
|
||||
-- channel (audio/sfx/{damage,super_effective,not_very_effective}.asm,
|
||||
-- `channel 8`), where the frequency modifier is added to the polynomial
|
||||
-- counter and so IS the pitch of the hit, while the tempo modifier is
|
||||
-- skipped outright (audio/engine_2.asm Audio2_note_length: `cp CHAN8 /
|
||||
-- jr z, .skip` keeps the noise channel at the default $100). Playing them
|
||||
-- bare made the super effective hit a dull thud and the not very effective
|
||||
-- one a bright crack, which is why they sounded swapped (#826); the tempo
|
||||
-- byte is deliberately not carried, since applying it would stretch notes
|
||||
-- the hardware never stretches.
|
||||
local hitSfx
|
||||
if info.typeMult > 10 then
|
||||
hitSfx = { sound = "Super_Effective", pitch = 0xe0 }
|
||||
elseif info.typeMult < 10 then
|
||||
hitSfx = { sound = "Not_Very_Effective", pitch = 0x50 }
|
||||
else
|
||||
hitSfx = { sound = "Damage", pitch = 0x20 }
|
||||
end
|
||||
-- GetPlayerAnimationType / GetEnemyAnimationType (engine/battle/core.asm
|
||||
-- :3159 / :5555): wAnimationType is 4 (blink the enemy pic) or 1 (shake
|
||||
-- the screen vertically) for a damaging move with no added effect, and
|
||||
|
||||
+23
-1
@@ -168,7 +168,7 @@ end
|
||||
-- The active status record's beforeMove runs at its priority slot: above
|
||||
-- VOLATILE_PRIORITY before the held/disable/confusion block (sleep,
|
||||
-- freeze), at or below after it (paralysis) -- the original's order.
|
||||
function Status.beforeMove(battler, rng, battle)
|
||||
function Status.beforeMove(battler, rng, battle, selectedMoveId)
|
||||
local mon = battler.mon
|
||||
-- Haze curing this mon's sleep/freeze forfeits its pending move for
|
||||
-- the turn, silently (haze.asm writes $ff/CANNOT_MOVE to the selected
|
||||
@@ -225,6 +225,28 @@ function Status.beforeMove(battler, rng, battle)
|
||||
end
|
||||
end
|
||||
end
|
||||
-- .TriedToUseDisabledMoveCheck (engine/battle/core.asm, and the enemy
|
||||
-- copy .checkIfTriedToUseDisabledMove): the disabled-move test runs at
|
||||
-- EXECUTION time, comparing wPlayerDisabledMoveNumber against the
|
||||
-- already SELECTED move, so a Disable that lands earlier in the same
|
||||
-- turn still blocks the slower mon's move (#860). It sits after the
|
||||
-- confusion block and before the paralysis roll, so a confusion self-hit
|
||||
-- still pre-empts it and the paralysis roll is never spent on a turn the
|
||||
-- disable eats. PrintMoveIsDisabledText clears CHARGING_UP before
|
||||
-- printing, so a disabled charge move drops its stored turn instead of
|
||||
-- releasing later.
|
||||
if selectedMoveId and battler.disabledSlot then
|
||||
local disabled = (battler.curMoves or {})[battler.disabledSlot]
|
||||
if disabled and disabled.id == selectedMoveId then
|
||||
battler.charging, battler.chargeReady = nil, nil
|
||||
local moves = battle and battle.data and battle.data.moves
|
||||
local shown = moves and moves[selectedMoveId] and moves[selectedMoveId].name
|
||||
or tostring(selectedMoveId)
|
||||
table.insert(msgs, romText(battle and battle.data, "_MoveIsDisabledText",
|
||||
"%s's\n%s is\ndisabled!", name(battler), shown))
|
||||
return false, msgs
|
||||
end
|
||||
end
|
||||
if handler then
|
||||
local canMove, selfHit = runStatus()
|
||||
if not canMove or selfHit then return canMove, msgs, selfHit end
|
||||
|
||||
+48
-2
@@ -202,6 +202,30 @@ local function headerChannels(banks, header)
|
||||
return channels
|
||||
end
|
||||
|
||||
-- Which software channels (CHAN5-8) an sfx occupies: its header carries one
|
||||
-- 3-byte descriptor per channel. Audio2_PlaySound walks exactly this list to
|
||||
-- decide whether a new sfx may start at all (audio/engine_2.asm
|
||||
-- .sfxChannelLoop), so Sound.playMove needs the set to reproduce that gate.
|
||||
-- nil = not knowable here (a file def, or the banks are not readable yet),
|
||||
-- which callers read as "no conflict".
|
||||
function ChipSynth.effectChannels(data, def)
|
||||
if type(def) ~= "table" then return nil end
|
||||
local chip = def.chip
|
||||
local specs = chip and chip.channels
|
||||
if not specs then
|
||||
if not def.address then return nil end
|
||||
local ok, banks = pcall(engineBanks, data, chip)
|
||||
if not ok then return nil end
|
||||
local read
|
||||
ok, read = pcall(headerChannels, banks, def)
|
||||
if not ok then return nil end
|
||||
specs = read
|
||||
end
|
||||
local channels = {}
|
||||
for _, spec in ipairs(specs) do channels[#channels + 1] = spec.number end
|
||||
return channels
|
||||
end
|
||||
|
||||
local function fadeValue(nibble)
|
||||
if bit.band(nibble, 8) ~= 0 then return -bit.band(nibble, 7) end
|
||||
return nibble
|
||||
@@ -406,7 +430,15 @@ function Channel:nextEvent()
|
||||
elseif command == 0xEC then
|
||||
self.duty = bit.band(self:byte(), 3)
|
||||
elseif command == 0xED then
|
||||
self.engine.tempo = self:byte() * 0x100 + self:byte()
|
||||
local high = self:byte()
|
||||
local low = self:byte()
|
||||
-- a header carrying its own tempo is one of audio/alternate_tempo.asm's
|
||||
-- Music_*AlternateTempo entry points, which re-point channel 1 at a
|
||||
-- stub that sets the tempo and jumps into the normal body -- the body's
|
||||
-- own tempo command never runs there, so ignore it here (#847)
|
||||
if not self.engine.tempoLocked then
|
||||
self.engine.tempo = high * 0x100 + low
|
||||
end
|
||||
elseif command == 0xEE then
|
||||
self.engine.pan = self:byte()
|
||||
elseif command == 0xEF or command == 0xF0 then
|
||||
@@ -458,7 +490,15 @@ function Channel:nextEvent()
|
||||
local volume = bit.rshift(packed, 4)
|
||||
local fade = fadeValue(bit.band(packed, 0x0F))
|
||||
if self.noise then
|
||||
local parameter = self:byte()
|
||||
-- Audio2_ApplyWavePatternAndFrequency adds wFrequencyModifier to the
|
||||
-- frequency low byte for every channel at or past CHAN5, the noise
|
||||
-- channel included (audio/engine_2.asm Audio2_ApplyFrequencyModifier).
|
||||
-- On CHAN8 that byte is the polynomial counter, so the modifier moves
|
||||
-- the noise pitch; it wraps at 8 bits, the carry landing in the high
|
||||
-- byte that noise does not use for frequency. Dropping it left the
|
||||
-- battle hit sounds at their unmodified pitches, where super effective
|
||||
-- reads as the duller of the two (#826).
|
||||
local parameter = bit.band(self:byte() + self.frequencyOffset, 0xFF)
|
||||
return self:noiseEvent(
|
||||
self:durationTicks(length), volume, fade, parameter)
|
||||
end
|
||||
@@ -753,6 +793,12 @@ function Engine.new(data, header, options)
|
||||
noiseInstruments = {},
|
||||
channels = {},
|
||||
}, Engine)
|
||||
-- header.tempo: the Music_*AlternateTempo override Music.play stamps onto
|
||||
-- a copy of the song def (audio/alternate_tempo.asm) (#847)
|
||||
if header.tempo then
|
||||
engine.tempo = header.tempo
|
||||
engine.tempoLocked = true
|
||||
end
|
||||
for _, spec in ipairs(chip and chip.channels
|
||||
or headerChannels(banks, header)) do
|
||||
local frameTicks = options.frameTicks
|
||||
|
||||
+16
-5
@@ -16,6 +16,10 @@ local Screens = require("src.ui.Screens")
|
||||
|
||||
local Game = {}
|
||||
|
||||
local function renderVisible(stack, state)
|
||||
return state and (not stack.renderVisible or stack:renderVisible(state))
|
||||
end
|
||||
|
||||
-- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev
|
||||
-- module unloaded, so a player boot never touches a byte of dev code
|
||||
local devMode = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true
|
||||
@@ -460,7 +464,7 @@ function Game:draw()
|
||||
local state = self.stack.states[i]
|
||||
local wideState = state and state.isWideBattleLayout
|
||||
and state:isWideBattleLayout()
|
||||
if state and state.draw then
|
||||
if renderVisible(self.stack, state) and state.draw then
|
||||
if classicOffset ~= 0 and not wideState then
|
||||
love.graphics.push()
|
||||
love.graphics.translate(classicOffset, 0)
|
||||
@@ -484,7 +488,7 @@ function Game:draw()
|
||||
local zones, worldZones, zoneOwner
|
||||
for i = #self.stack.states, 1, -1 do
|
||||
local s = self.stack.states[i]
|
||||
if s.sgbPalettes then
|
||||
if renderVisible(self.stack, s) and s.sgbPalettes then
|
||||
zones = s:sgbPalettes(self)
|
||||
zoneOwner = s
|
||||
break
|
||||
@@ -793,11 +797,13 @@ function Game:joystickhat(joystick, hat, direction)
|
||||
end
|
||||
|
||||
-- Window focus/visibility flips: a release due while unfocused/hidden can
|
||||
-- be swallowed by the OS. Reset on both edges -- gaining focus with a
|
||||
-- physically held key won't re-fire keypressed, so trusting leftover
|
||||
-- state is worse than asking the player to re-press.
|
||||
-- be swallowed by the OS. Reset on both edges; on the regain, reconcile
|
||||
-- re-arms only what is still physically held -- a held key won't re-fire
|
||||
-- keypressed by itself, and without the rebuild a spurious lifecycle event
|
||||
-- parked the player until every direction was re-pressed (#799).
|
||||
function Game:focus(f)
|
||||
Input:reset()
|
||||
if f then Input:reconcile() end
|
||||
TouchControls:reset()
|
||||
self:cancelPointers()
|
||||
end
|
||||
@@ -814,6 +820,7 @@ end
|
||||
|
||||
function Game:onResume()
|
||||
Input:reset()
|
||||
Input:reconcile()
|
||||
TouchControls:reset()
|
||||
self:cancelPointers()
|
||||
-- Chip music may survive NX suspend as a duplicate stream; stop it and let
|
||||
@@ -830,6 +837,10 @@ end
|
||||
|
||||
function Game:recoverInput(event, joystick)
|
||||
Input:reset()
|
||||
-- A hotplug can arrive with no hotplug (macOS Bluetooth re-enumeration),
|
||||
-- and the blanket reset above also drops unrelated keyboard holds; put
|
||||
-- back whatever is still physically down (#799).
|
||||
Input:reconcile()
|
||||
TouchControls:reset()
|
||||
-- reset just dropped every source, mod holds included: retire the mods'
|
||||
-- outstanding press tokens so nothing stale can be released later, and
|
||||
|
||||
+35
-1
@@ -62,8 +62,35 @@ function HostShell.hideHostConsole()
|
||||
return consoleHidden
|
||||
end
|
||||
|
||||
-- #254 was fixed inside the launcher and nowhere else: a native dialog opened
|
||||
-- while a mouse button is still down blocks the whole loop in io.popen, so SDL
|
||||
-- never processes the button-up and never drops the pointer capture it took
|
||||
-- for the press (on X11 an XGrabPointer with owner_events). The grab outlives
|
||||
-- the click, every pointer event over the child dialog is still routed to our
|
||||
-- window, and the dialog draws and keyboard-navigates but ignores the mouse.
|
||||
-- src/import/RomImporter.lua owns the launcher's copy; hoisting it here means
|
||||
-- every host spawn inherits it, including one a mod reaches through HostShell.
|
||||
-- Pump until nothing is held so SDL sees the release first; bounded, so a
|
||||
-- stuck button costs a moment and never the game. pump() drains OS events
|
||||
-- into LOVE's queue and dispatches nothing, so there is no reentry. Worker
|
||||
-- threads load neither love.mouse nor love.event, so the guard below makes
|
||||
-- this a no-op off the main thread.
|
||||
function HostShell.releasePointerGrab()
|
||||
if not (love and love.mouse and love.mouse.isDown and love.event
|
||||
and love.event.pump and love.timer) then
|
||||
return
|
||||
end
|
||||
local deadline = love.timer.getTime() + 1
|
||||
while love.mouse.isDown(1, 2, 3) do
|
||||
love.event.pump()
|
||||
if love.timer.getTime() > deadline then break end
|
||||
love.timer.sleep(0.005)
|
||||
end
|
||||
end
|
||||
|
||||
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed
|
||||
function HostShell.popen(command, mode)
|
||||
HostShell.releasePointerGrab()
|
||||
local ok, pipe = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r")
|
||||
if not ok or not pipe then return nil end
|
||||
return pipe
|
||||
@@ -154,8 +181,15 @@ local function haveBridge()
|
||||
if not (love and love.system and type(love.system.httpDownload) == "function") then
|
||||
return false
|
||||
end
|
||||
-- The OS allowlist is deliberate: the bridge is a per-port native addition,
|
||||
-- not part of LOVE, so a build that exports the name on a platform we never
|
||||
-- wired one for is a name collision, not a transport. UWP is listed because
|
||||
-- Xbox has no curl and no way to spawn one (Platform.canSpawnProcess is
|
||||
-- false there), so the bridge is its only possible transport (#876). Its
|
||||
-- LOVE backend does not export it today and this still returns false, but
|
||||
-- the gate is no longer the thing in the way.
|
||||
local osName = love.system.getOS and love.system.getOS()
|
||||
return osName == "Android" or osName == "iOS"
|
||||
return osName == "Android" or osName == "iOS" or osName == "UWP"
|
||||
end
|
||||
|
||||
-- Is any transport available at all? Callers gate on this, never on curl.
|
||||
|
||||
@@ -301,6 +301,71 @@ function Input:joystickhat(joystick, hat, direction)
|
||||
self.hatDirs[hat] = dirs
|
||||
end
|
||||
|
||||
-- Lifecycle resets (focus/visibility flips, joystick add/remove, resume)
|
||||
-- wipe held state because a release can be swallowed while the OS owns the
|
||||
-- event stream. A direction the player is STILL holding never re-fires
|
||||
-- keypressed/gamepadpressed after the wipe either, so a spurious reset --
|
||||
-- macOS re-enumerating a Bluetooth pad fires joystickadded with no hotplug,
|
||||
-- and the blanket reset took unrelated keyboard holds down with it --
|
||||
-- parked the player in place until every direction was released and
|
||||
-- pressed again (#799). Rebuild holds from the devices' ground truth
|
||||
-- instead: only what is physically down right now comes back, so the
|
||||
-- swallowed-release hazards the resets guard against stay cleared.
|
||||
-- Deliberately separate from reset(): the soft-reset chord path in
|
||||
-- Game:step needs the clean slate (re-arming A there would read it as a
|
||||
-- title-menu choice).
|
||||
function Input:reconcile()
|
||||
local kb = love and love.keyboard
|
||||
if kb and kb.isDown then
|
||||
for key, btn in pairs(self.keyBindings) do
|
||||
local ok, down = pcall(kb.isDown, key)
|
||||
if ok and down then press(self, btn, "key:" .. key) end
|
||||
end
|
||||
end
|
||||
local js = love and love.joystick
|
||||
if not (js and js.getJoysticks) then return end
|
||||
local ok, joysticks = pcall(js.getJoysticks)
|
||||
if not ok or type(joysticks) ~= "table" then return end
|
||||
for _, j in ipairs(joysticks) do
|
||||
if GamepadMap.ignoreRawForJoystick(j) then
|
||||
-- SDL-recognized pad: buttons + left stick, the gamepad surfaces
|
||||
if j.isGamepadDown then
|
||||
for button, btn in pairs(self.padBindings) do
|
||||
local ok2, down = pcall(j.isGamepadDown, j, button)
|
||||
if ok2 and down then press(self, btn, "pad:" .. button) end
|
||||
end
|
||||
end
|
||||
if j.getGamepadAxis then
|
||||
for _, axis in ipairs({ "leftx", "lefty" }) do
|
||||
local ok2, v = pcall(j.getGamepadAxis, j, axis)
|
||||
if ok2 and type(v) == "number" then self:gamepadaxis(j, axis, v) end
|
||||
end
|
||||
end
|
||||
else
|
||||
-- raw stick (#620/#632): the surfaces the joystick* events feed
|
||||
if j.isDown then
|
||||
for index, btn in pairs(self.joyBindings) do
|
||||
local ok2, down = pcall(j.isDown, j, index)
|
||||
if ok2 and down then press(self, btn, "joy:" .. index) end
|
||||
end
|
||||
end
|
||||
if j.getAxis then
|
||||
for _, axis in ipairs({ 1, 2 }) do
|
||||
local ok2, v = pcall(j.getAxis, j, axis)
|
||||
if ok2 and type(v) == "number" then self:joystickaxis(j, axis, v) end
|
||||
end
|
||||
end
|
||||
if j.getHatCount and j.getHat then
|
||||
local ok2, count = pcall(j.getHatCount, j)
|
||||
for hat = 1, (ok2 and count) or 0 do
|
||||
local ok3, dir = pcall(j.getHat, j, hat)
|
||||
if ok3 and dir then self:joystickhat(j, hat, dir) end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Input:isDown(btn)
|
||||
return self.state[btn] or false
|
||||
end
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
-- Launch options: boot straight into a game, skipping the launcher.
|
||||
--
|
||||
-- love . --game red -- boot Red
|
||||
-- love . --game yellow --slot 2 -- boot Yellow on save slot 2
|
||||
-- love . --game red --launcher -- open the launcher anyway (a shortcut
|
||||
-- the player wants to edit)
|
||||
-- POKEPORT_GAME=blue love . -- same, for launchers that only pass env
|
||||
-- love . --game=red -- boot Red
|
||||
-- love . --game=yellow --slot=2 -- boot Yellow on save slot 2
|
||||
-- love . --game=red --launcher -- open the launcher anyway (a shortcut
|
||||
-- the player wants to edit)
|
||||
-- POKEPORT_GAME=blue love . -- same, for launchers that only pass env
|
||||
--
|
||||
-- The "--flag value" spelling parses here (argValue reads argv[i + 1]), but it
|
||||
-- does not survive LOVE: boot.lua takes the first bare argument as a path to a
|
||||
-- game to run, so `--game red` dies with "Cannot load game at path .../red"
|
||||
-- before love.load is ever called, fused or not. Only the "=" spelling is
|
||||
-- reachable, so that is the one the docs quote.
|
||||
--
|
||||
-- This exists for the click-once cases: a desktop shortcut per game, a Steam
|
||||
-- entry, an EmulationStation/Playnite entry, a handheld frontend. Those all
|
||||
|
||||
+16
-1
@@ -82,6 +82,7 @@ state = {
|
||||
fanfare = nil, -- fanfare SFX source; the song pauses while it plays
|
||||
fanfareResume = false, -- start/resume state.source when the fanfare ends
|
||||
fade = nil, -- active volume-ramp fade-out (see Music.fadeOut)
|
||||
tempo = nil, -- alternate-tempo override in force for `current`
|
||||
failed = {}, -- labels whose def could not be started; logged once
|
||||
}
|
||||
|
||||
@@ -234,11 +235,23 @@ function Music.play(data, song, loop, ctx)
|
||||
if not song then return end
|
||||
if not love.audio then return end -- headless test stub
|
||||
song = selectSong(song, ctx)
|
||||
-- ctx.tempo is a Music_*AlternateTempo cue (audio/alternate_tempo.asm):
|
||||
-- the same song restarted with channel 1 re-pointed at a stub whose only
|
||||
-- difference is its `tempo`, so the same label at a different tempo is a
|
||||
-- different cue and must not be deduped away (#847)
|
||||
local tempo = ctx and ctx.tempo or nil
|
||||
-- a hook may silence the cue outright, or swap in a label the dedupe
|
||||
-- below has to compare against
|
||||
if not song or song == state.current then return end
|
||||
if not song or (song == state.current and tempo == state.tempo) then return end
|
||||
local def = songDef(data, song)
|
||||
if not def or state.failed[song] then return end
|
||||
if tempo then
|
||||
-- shallow copy: the registry def is shared, only this playback is slowed
|
||||
local slowed = {}
|
||||
for key, value in pairs(def) do slowed[key] = value end
|
||||
slowed.tempo = tempo
|
||||
def = slowed
|
||||
end
|
||||
local wantLoop = loop ~= false
|
||||
local src, loopSrc, isChip, err = startSong(data, def, wantLoop)
|
||||
if not src then
|
||||
@@ -274,6 +287,7 @@ function Music.play(data, song, loop, ctx)
|
||||
local previous = state.current
|
||||
state.source, state.loopSource, state.chip = src, loopSrc, isChip
|
||||
state.current = song
|
||||
state.tempo = tempo
|
||||
if Runtime.wants("music.started") then
|
||||
Runtime.emit("music.started", {
|
||||
song = song, previous = previous, chip = isChip,
|
||||
@@ -288,6 +302,7 @@ function Music.stop()
|
||||
stopSource(state.loopSource)
|
||||
require("src.core.ChipAudio").stopMusic()
|
||||
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
|
||||
state.tempo = nil
|
||||
state.chip = false
|
||||
state.pendingRestore = nil
|
||||
if previous and Runtime.wants("music.stopped") then
|
||||
|
||||
@@ -12,6 +12,8 @@ local function compute()
|
||||
local mobile = osName == "Android" or osName == "iOS"
|
||||
local nativePicker = love and love.system
|
||||
and type(love.system.pickFile) == "function"
|
||||
local nativeHttp = love and love.system
|
||||
and type(love.system.httpDownload) == "function"
|
||||
return {
|
||||
os = osName,
|
||||
nx = nx,
|
||||
@@ -24,6 +26,17 @@ local function compute()
|
||||
or (nativePicker and "native-picker")
|
||||
or "desktop",
|
||||
networkValidated = not nx and not uwp,
|
||||
-- networkValidated is the self-updater's gate and stays a per-platform
|
||||
-- policy call: a console package cannot replace itself on disk, so that
|
||||
-- answer never depends on whether a transport exists. Fetching a mod
|
||||
-- index or a mod zip is the narrower question, and #876 showed the two
|
||||
-- had been conflated, so Xbox lost the mod catalog for the updater's
|
||||
-- reason. Desktop answers it with curl through HostShell; the mobile and
|
||||
-- console ports answer it with the native love.system.httpDownload bridge
|
||||
-- (#597). The UWP LOVE backend does not export that bridge yet, so this
|
||||
-- still resolves false on Xbox and the launcher still says so, but the
|
||||
-- day the backend grows one, nothing here or in RomImporter has to change.
|
||||
canFetchRemote = (not nx and not uwp) or nativeHttp,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -52,6 +65,10 @@ function Platform.networkValidated()
|
||||
return Platform.detect().networkValidated
|
||||
end
|
||||
|
||||
function Platform.canFetchRemote()
|
||||
return Platform.detect().canFetchRemote
|
||||
end
|
||||
|
||||
-- Tests may swap love.system between cases.
|
||||
function Platform._resetForTests()
|
||||
cached = nil
|
||||
|
||||
@@ -28,6 +28,24 @@ function SafeArea.rect()
|
||||
return 0, 0, ww, wh
|
||||
end
|
||||
|
||||
-- A safe rect that cannot fit the window's unit space is a backend
|
||||
-- reporting framebuffer PIXELS -- the iOS build (LOVE 12 + SDL3) did this
|
||||
-- in portrait on iOS 16, and clamping it as-is kept a DPI-inflated top
|
||||
-- inset that pushed the whole launcher a band down the screen (#810).
|
||||
-- Convert back to units with per-axis ratios; the axes can disagree on
|
||||
-- forced-rotation devices (see displayMetrics in src/render/Renderer.lua,
|
||||
-- #208).
|
||||
if (w > ww + 0.5 or h > wh + 0.5)
|
||||
and love.graphics.getPixelDimensions then
|
||||
local pw, ph = love.graphics.getPixelDimensions()
|
||||
local dx = (pw and pw > 0) and (pw / ww) or 1
|
||||
local dy = (ph and ph > 0) and (ph / wh) or 1
|
||||
if dx > 1.01 or dy > 1.01 then
|
||||
x, w = x / dx, w / dx
|
||||
y, h = y / dy, h / dy
|
||||
end
|
||||
end
|
||||
|
||||
-- Clamp to the drawable window so a bad / mid-rotation backend cannot
|
||||
-- push layout outside the surface.
|
||||
x = math.max(0, math.min(x, ww))
|
||||
|
||||
+81
-2
@@ -30,6 +30,15 @@ local SaveData = {}
|
||||
-- deliberately shared across versions (it holds global preferences and the
|
||||
-- mod enable-state, not per-playthrough data).
|
||||
local OPTIONS_FILENAME = "options.lua"
|
||||
-- #828: options.lua is rewritten whole on every write (see saveOptions), and
|
||||
-- unlike the progress files it had no staged copy, so a write interrupted
|
||||
-- between the truncate and the flush -- the process replaced by
|
||||
-- HostShell.restart on the way back to the launcher, an Android
|
||||
-- external-storage volume that never flushed -- left a truncated or empty
|
||||
-- file that loadOptions could only answer with defaults: every setting
|
||||
-- "reset" at once. Same .bak/.tmp witness names the save files use.
|
||||
local OPTIONS_BACKUP_FILENAME = OPTIONS_FILENAME .. ".bak"
|
||||
local OPTIONS_TMP_FILENAME = OPTIONS_FILENAME .. ".tmp"
|
||||
|
||||
-- Main / backup / staged-witness names for a version (defaults to the active
|
||||
-- one). The backup is a rolling copy and .tmp is the staged-write witness;
|
||||
@@ -305,6 +314,13 @@ function SaveData.defaultOptions()
|
||||
-- layout (#633). Pre-#633 files stored one top-level positions table;
|
||||
-- TouchControls.normalizeConfig folds it into both orientations on load.
|
||||
touchControls = { enabled = true },
|
||||
-- Haptic feedback level for on-screen pad presses (#806):
|
||||
-- off | light | medium | heavy, mapped to a love.system.vibrate
|
||||
-- duration in src/core/TouchControls.lua. LIGHT by default, like the
|
||||
-- overlay itself defaulting on, so an options.lua predating this key
|
||||
-- gets the tick without going looking for the row. Inert wherever the
|
||||
-- overlay never appears (desktop) or LOVE has no vibrator.
|
||||
haptics = "light",
|
||||
}
|
||||
end
|
||||
|
||||
@@ -368,11 +384,54 @@ function SaveData.saveOptions(opts, fs)
|
||||
end
|
||||
opts.modOptions = merged
|
||||
end
|
||||
local ok, err = fs.write(OPTIONS_FILENAME, SaveSerializer.encode(opts))
|
||||
local encoded = SaveSerializer.encode(opts)
|
||||
-- Stage the new bytes and roll the last good file aside BEFORE the main
|
||||
-- write truncates it, the same tmp/bak dance SaveData.save uses for
|
||||
-- progress: whatever ends the process mid-write, one of the three copies
|
||||
-- is complete and loadOptions promotes it instead of falling back to
|
||||
-- defaults (#828).
|
||||
local ok, err = fs.write(OPTIONS_TMP_FILENAME, encoded)
|
||||
if not ok then
|
||||
Logger.error("options save failed: %s", tostring(err))
|
||||
return nil
|
||||
end
|
||||
return ok and opts or nil
|
||||
local prev = fs.getInfo(OPTIONS_FILENAME) and fs.read(OPTIONS_FILENAME)
|
||||
if type(prev) == "string" and prev ~= "" and prev ~= encoded then
|
||||
fs.write(OPTIONS_BACKUP_FILENAME, prev)
|
||||
end
|
||||
ok, err = fs.write(OPTIONS_FILENAME, encoded)
|
||||
if not ok then
|
||||
Logger.error("options save failed: %s", tostring(err))
|
||||
return nil
|
||||
end
|
||||
-- #828: settings "reset" on Android and Steam Deck with nothing in the log.
|
||||
-- Every options write is a WHOLE-FILE rewrite, so a write that reports
|
||||
-- success without the bytes landing (an external-storage volume that went
|
||||
-- away mid-session, a read-only or full save dir) is indistinguishable from
|
||||
-- "the launcher never saved". Read the file back and fail loudly instead:
|
||||
-- callers already treat nil as a failed write, and the log line is what the
|
||||
-- next report from those platforms needs to carry.
|
||||
local wrote = fs.getInfo(OPTIONS_FILENAME) and fs.read(OPTIONS_FILENAME)
|
||||
if wrote ~= encoded then
|
||||
Logger.error("options save did not land (%d bytes written, %s on disk)",
|
||||
#encoded, type(wrote) == "string" and tostring(#wrote) or "nothing")
|
||||
return nil
|
||||
end
|
||||
-- #828: roll the backup FORWARD to the bytes just verified. The
|
||||
-- pre-write roll above only preserves the previous file for a death
|
||||
-- during this rewrite; at rest the backup must hold the newest verified
|
||||
-- state, because the hard teardown out of a game session (HostShell's
|
||||
-- restartApp kill on Android, execv on a SteamOS AppImage) can eat the
|
||||
-- main file outright and loadOptions then promotes this copy. The
|
||||
-- encoder is key-sorted, so the follow-up rewrites a play session makes
|
||||
-- (play()'s lastVersion stamp, the in-game save flush) are byte-identical
|
||||
-- and skip the conditional roll -- without this line the backup still
|
||||
-- held the file from BEFORE the launcher's change, and recovery reverted
|
||||
-- the just-changed setting (BATTLE LAYOUT back to OG).
|
||||
fs.write(OPTIONS_BACKUP_FILENAME, encoded)
|
||||
-- the staged witness has served its purpose; the main file is verified
|
||||
remove(fs, OPTIONS_TMP_FILENAME)
|
||||
return opts
|
||||
end
|
||||
|
||||
function SaveData.loadOptions(fs)
|
||||
@@ -382,6 +441,26 @@ function SaveData.loadOptions(fs)
|
||||
if fs.getInfo(OPTIONS_FILENAME) then
|
||||
Logger.error("options load failed: %s", tostring(err))
|
||||
end
|
||||
-- #828: answering defaults here is what "closing the game reset all my
|
||||
-- settings" looked like -- one interrupted whole-file rewrite and every
|
||||
-- preference, the mod enable-state and the slot registry were gone.
|
||||
-- Promote the staged copy, then the rolled-aside backup, exactly as
|
||||
-- SaveData.load does for progress, and heal the main file from whichever
|
||||
-- one parsed.
|
||||
local recovered = readTable(fs, OPTIONS_TMP_FILENAME)
|
||||
local from = "tmp"
|
||||
if not recovered then
|
||||
recovered = readTable(fs, OPTIONS_BACKUP_FILENAME)
|
||||
from = "bak"
|
||||
end
|
||||
if recovered then
|
||||
Logger.warn("options.lua %s; recovered from %s copy",
|
||||
fs.getInfo(OPTIONS_FILENAME) and "corrupt" or "missing", from)
|
||||
if fs.write then
|
||||
fs.write(OPTIONS_FILENAME, SaveSerializer.encode(recovered))
|
||||
end
|
||||
return SaveData.mergeOptions(recovered)
|
||||
end
|
||||
return SaveData.defaultOptions()
|
||||
end
|
||||
return SaveData.mergeOptions(data)
|
||||
|
||||
+84
-15
@@ -208,29 +208,91 @@ end
|
||||
-- sfx table; older audio.lua builds without the variants fall back to
|
||||
-- the unmodified sound.
|
||||
-- anim: a moves.lua anim table { sound, pitch, tempo }.
|
||||
--
|
||||
-- Whether a row sound is heard at all is Audio2_PlaySound's channel gate
|
||||
-- (audio/engine_2.asm .playSfx/.sfxChannelLoop): for every channel the new
|
||||
-- sfx wants, a channel still busy with a LOWER sound id aborts the whole
|
||||
-- request (`cp [hl] / jr z,.playChannel / jr c,.playChannel / ret`), while
|
||||
-- an equal or lower id takes those channels over. A sound id is
|
||||
-- (header address - SFX_Headers_1) / 3 (constants/music_constants.asm
|
||||
-- music_const), so a def's header address orders ids inside one engine
|
||||
-- bank. Blizzard's animation is two rows, BLIZZARD then HYDRO_PUMP
|
||||
-- (data/moves/animations.asm BlizzardAnim), and SFX_BATTLE_29 (CHAN5+8) is
|
||||
-- still sounding when the second row starts, so the original never plays
|
||||
-- SFX_BATTLE_2A (CHAN5+6+8) at all -- unguarded, its tail is heard running
|
||||
-- past the end of the animation (#844).
|
||||
local lastMoveSfx -- { src, rank, engine, channels } of the last row sound
|
||||
|
||||
local function channelsOverlap(a, b)
|
||||
if not (a and b) then return false end
|
||||
for _, x in ipairs(a) do
|
||||
for _, y in ipairs(b) do
|
||||
if x == y then return true end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- would PlaySound start this def now? Taking a channel over also stops the
|
||||
-- sound that held it, the way .playChannel resets the channel.
|
||||
local function sfxChannelGate(data, def)
|
||||
local cur = lastMoveSfx
|
||||
if not cur then return true end
|
||||
local ok, playing = pcall(cur.src.isPlaying, cur.src)
|
||||
if not (ok and playing) then
|
||||
lastMoveSfx = nil
|
||||
return true
|
||||
end
|
||||
-- an unrankable def (file asset, or another engine's bank) has no
|
||||
-- comparable sound id: leave it to the mixer, as before
|
||||
if type(def) ~= "table" or not def.address or def.engine ~= cur.engine then
|
||||
return true
|
||||
end
|
||||
local channels = require("src.core.ChipSynth").effectChannels(data, def)
|
||||
if not channelsOverlap(channels, cur.channels) then return true end
|
||||
if def.address > cur.rank then return false end
|
||||
pcall(cur.src.stop, cur.src)
|
||||
lastMoveSfx = nil
|
||||
return true
|
||||
end
|
||||
|
||||
local function noteMoveSfx(data, def, src)
|
||||
if not src or type(def) ~= "table" or not def.address then
|
||||
lastMoveSfx = nil
|
||||
return
|
||||
end
|
||||
lastMoveSfx = {
|
||||
src = src, rank = def.address, engine = def.engine,
|
||||
channels = require("src.core.ChipSynth").effectChannels(data, def),
|
||||
}
|
||||
end
|
||||
|
||||
function Sound.playMove(data, anim)
|
||||
if not anim or not anim.sound then return end
|
||||
local sfx = data.audio and data.audio.sfx
|
||||
if not sfx then return end
|
||||
local name = anim.sound
|
||||
local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80
|
||||
local def = sfx[name]
|
||||
if not sfxChannelGate(data, def) then return end
|
||||
local src
|
||||
-- a chip program synthesizes the modified variant on demand; a file def
|
||||
-- can only reach for a pre-rendered one
|
||||
if isChipDef(sfx[name]) then
|
||||
if playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo),
|
||||
sfx[name], pitch, tempo) then
|
||||
played("move", name)
|
||||
end
|
||||
return
|
||||
end
|
||||
if pitch ~= 0 or tempo ~= 0x80 then
|
||||
if isChipDef(def) then
|
||||
src = playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo),
|
||||
def, pitch, tempo)
|
||||
else
|
||||
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
|
||||
if sfx[key] then
|
||||
if playPath(data, key, sfx[key]) then played("move", name) end
|
||||
return
|
||||
if (pitch ~= 0 or tempo ~= 0x80) and sfx[key] then
|
||||
src = playPath(data, key, sfx[key])
|
||||
else
|
||||
src = playPath(data, name, def)
|
||||
end
|
||||
end
|
||||
if playPath(data, name, sfx[name]) then played("move", name) end
|
||||
if src then
|
||||
played("move", name)
|
||||
noteMoveSfx(data, def, src)
|
||||
end
|
||||
end
|
||||
|
||||
-- A derived cry ({ base = "RHYDON", pitch, length }) borrows another
|
||||
@@ -304,12 +366,18 @@ end
|
||||
|
||||
-- returns the source (nil headless) so callers that block on the cry
|
||||
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
|
||||
function Sound.playCry(data, species)
|
||||
function Sound.playCry(data, species, pikaClip)
|
||||
if not love.audio then return nil end
|
||||
-- Yellow voices every Pikachu cry with the PCM clips (the chip cry is
|
||||
-- never used for the species there); clip 1 is the everyday "Pika!"
|
||||
-- never used for the species there). Which clip is a property of the
|
||||
-- call site in the original -- every caller of PlayPikachuSoundClip sets
|
||||
-- its own `ldpikacry e, PikachuCryN` -- so pikaClip carries that choice
|
||||
-- in; it is ignored for every other species. Clip 1 is the LONG
|
||||
-- title-screen "Pikachuuu" (engine/movie/title.asm:146), kept as the
|
||||
-- default only for the sites that have not been given their own clip
|
||||
-- yet; battle entrances pass 11/37 (#837).
|
||||
if species == "PIKACHU" then
|
||||
local src = Sound.playPikaCry(data, 1)
|
||||
local src = Sound.playPikaCry(data, pikaClip or 1)
|
||||
if src then return src end
|
||||
end
|
||||
local cries = data.audio and data.audio.cries
|
||||
@@ -451,6 +519,7 @@ end
|
||||
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
|
||||
-- variants included) or all of them, so the next play re-resolves the def
|
||||
function Sound.invalidate(name)
|
||||
lastMoveSfx = nil -- its source is about to be dropped or stopped
|
||||
local function evict(store, key)
|
||||
local src = store[key]
|
||||
if src then pcall(src.stop, src) end
|
||||
|
||||
+14
-2
@@ -39,17 +39,29 @@ function StateStack:update(dt)
|
||||
if top and top.update then top:update(dt) end
|
||||
end
|
||||
|
||||
local function visibleByDefault() return true end
|
||||
|
||||
-- A mod may mirror a state elsewhere and hide only its main-screen render.
|
||||
-- The state stays on the stack, so update and input ownership do not move.
|
||||
function StateStack:renderVisible(state)
|
||||
if not state then return false end
|
||||
if not Runtime.wantsHook("screen.render_visible") then return true end
|
||||
return Runtime.call("screen.render_visible", visibleByDefault, state) ~= false
|
||||
end
|
||||
|
||||
-- index of the lowest state drawn this frame (highest opaque, else 1)
|
||||
function StateStack:visibleBase()
|
||||
for i = #self.states, 1, -1 do
|
||||
if self.states[i].isOpaque then return i end
|
||||
local state = self.states[i]
|
||||
if self:renderVisible(state) and state.isOpaque then return i end
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
function StateStack:draw()
|
||||
for i = self:visibleBase(), #self.states do
|
||||
if self.states[i].draw then self.states[i]:draw() end
|
||||
local state = self.states[i]
|
||||
if self:renderVisible(state) and state.draw then state:draw() end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -85,6 +85,55 @@ local function clampScale(v)
|
||||
return v
|
||||
end
|
||||
|
||||
-- Haptic feedback (#806): a short vibration the instant a control takes a GB
|
||||
-- button, the way every mobile emulator front-end does it -- the pad has no
|
||||
-- edges under a thumb, so the buzz is the only confirmation a press landed.
|
||||
-- Persisted as options.haptics (src/core/SaveData.lua defaultOptions), NOT
|
||||
-- under options.touchControls: TouchControls:config() is the launcher
|
||||
-- editor's save snapshot and only emits enabled + layouts, so a nested key
|
||||
-- would be dropped on every editor save.
|
||||
-- love.system.vibrate takes a duration and nothing else, so "intensity" is a
|
||||
-- duration preset: Android runs the platform vibrator for exactly that long,
|
||||
-- while iOS ignores the duration and fires the fixed system vibration, so
|
||||
-- there the three levels all read as simply on.
|
||||
TouchControls.HAPTICS = { "off", "light", "medium", "heavy" }
|
||||
TouchControls.HAPTIC_DEFAULT = "light"
|
||||
|
||||
local HAPTIC_SECONDS = { off = 0, light = 0.012, medium = 0.025, heavy = 0.045 }
|
||||
local HAPTIC_LABELS = {
|
||||
off = "OFF", light = "LIGHT", medium = "MEDIUM", heavy = "HEAVY",
|
||||
}
|
||||
|
||||
function TouchControls.normalizeHaptics(level)
|
||||
if HAPTIC_SECONDS[level] then return level end
|
||||
return TouchControls.HAPTIC_DEFAULT
|
||||
end
|
||||
|
||||
function TouchControls.hapticLabel(level)
|
||||
return HAPTIC_LABELS[TouchControls.normalizeHaptics(level)]
|
||||
end
|
||||
|
||||
function TouchControls.cycleHaptics(level, dir)
|
||||
local cur, idx = TouchControls.normalizeHaptics(level), 1
|
||||
for i, m in ipairs(TouchControls.HAPTICS) do
|
||||
if m == cur then idx = i break end
|
||||
end
|
||||
local n = #TouchControls.HAPTICS
|
||||
return TouchControls.HAPTICS[(idx - 1 + (dir or 1)) % n + 1]
|
||||
end
|
||||
|
||||
-- One pulse at the given level. Feature-guarded rather than platform-gated:
|
||||
-- love.system.vibrate is a no-op on desktop and absent from the headless love
|
||||
-- stubs, so the press path below stays identical everywhere and the tests
|
||||
-- never reach a vibrator.
|
||||
function TouchControls.buzz(level)
|
||||
local secs = HAPTIC_SECONDS[TouchControls.normalizeHaptics(level)]
|
||||
if not secs or secs <= 0 then return false end
|
||||
if not (love and love.system and love.system.vibrate) then return false end
|
||||
pcall(love.system.vibrate, secs)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Copy a persisted positions table, dropping unknown / non-numeric entries.
|
||||
-- Always a fresh table: two orientations seeded from the same pre-#633
|
||||
-- layout must not alias, or dragging one would still move the other.
|
||||
@@ -174,6 +223,10 @@ end
|
||||
function TouchControls:init()
|
||||
self.active = wantsOverlay()
|
||||
self.enabled = true
|
||||
-- vibration level for presses (#806); applyOptions overwrites it from
|
||||
-- options.haptics, this is the value a harness that never applies options
|
||||
-- runs with
|
||||
self.haptics = TouchControls.HAPTIC_DEFAULT
|
||||
-- per-orientation buckets (#633); self.positions / self.scale mirror the
|
||||
-- one currently on screen so layout(), the editor and the tests keep a
|
||||
-- single lookup
|
||||
@@ -210,6 +263,9 @@ end
|
||||
function TouchControls:applyOptions(opts)
|
||||
local cfg = TouchControls.normalizeConfig(opts and opts.touchControls)
|
||||
self.enabled = cfg.enabled
|
||||
-- haptics is a plain top-level option, not part of the layout config the
|
||||
-- launcher editor round-trips through config() (#806)
|
||||
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
|
||||
self.layouts = cfg.layouts
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
self.layoutOx, self.layoutOy = nil, nil
|
||||
@@ -401,7 +457,14 @@ end
|
||||
local function pressBtn(self, btn)
|
||||
local n = (self.held[btn] or 0) + 1
|
||||
self.held[btn] = n
|
||||
if n == 1 then Input:overlayPressed(btn) end
|
||||
-- Buzz only on the 0 -> 1 edge, the same edge that presses the GB button:
|
||||
-- a second finger landing on a button that is already held, and a d-pad
|
||||
-- finger resting inside one direction, must not retrigger it. Sliding the
|
||||
-- d-pad to a new direction does, which is the point (#806).
|
||||
if n == 1 then
|
||||
Input:overlayPressed(btn)
|
||||
TouchControls.buzz(self.haptics)
|
||||
end
|
||||
end
|
||||
|
||||
local function releaseBtn(self, btn)
|
||||
|
||||
@@ -294,6 +294,9 @@ function CacheFs.read(rel)
|
||||
f:close()
|
||||
return data
|
||||
end
|
||||
-- headless (plain luajit, e.g. the modkit validate/pack driver): there is
|
||||
-- no save directory to read from, so a cache miss is nil, not a crash
|
||||
if not (love and love.filesystem) then return nil end
|
||||
return love.filesystem.read(rel)
|
||||
end
|
||||
|
||||
|
||||
@@ -242,6 +242,18 @@ local function coreRows(opts)
|
||||
opts.touchControls = tc
|
||||
return true
|
||||
end)
|
||||
-- VIBRATION sits with it (#806): same gate, same subsystem. Stepping
|
||||
-- the row buzzes once at the level being selected.
|
||||
local okTC, TC = pcall(require, "src.core.TouchControls")
|
||||
if okTC then
|
||||
add(Strings("VIBRATION"),
|
||||
function() return Strings(TC.hapticLabel(opts.haptics)) end,
|
||||
function(dir)
|
||||
opts.haptics = TC.cycleHaptics(opts.haptics, dir)
|
||||
TC.buzz(opts.haptics)
|
||||
return true
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -311,8 +311,20 @@ local function setPage(imp, key, v)
|
||||
imp._pages[key] = v
|
||||
end
|
||||
|
||||
-- A hand-drawn X, for the same reason drawCheck exists below: the UI font has
|
||||
-- no guaranteed glyph, and the launcher ships no icon asset for it.
|
||||
local function drawCross(x, y, size, color)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setColor(color)
|
||||
love.graphics.setLineWidth(math.max(2, size * 0.16))
|
||||
love.graphics.setLineJoin("bevel")
|
||||
love.graphics.line(x, y, x + size, y + size)
|
||||
love.graphics.line(x + size, y, x, y + size)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- header
|
||||
-- Rail, logo row (settings on the right), tab bar.
|
||||
-- Rail, logo row (settings and quit on the right), tab bar.
|
||||
-- Returns the y at which content may start. Its vertical arithmetic is
|
||||
-- mirrored by headerHeight() at the bottom of this file (the short-window
|
||||
-- scroll decision needs the height before anything draws) -- keep in sync.
|
||||
@@ -340,7 +352,14 @@ local function buildHeader(imp, m)
|
||||
local rx = m.x + m.w - m.pad
|
||||
local by = y + (rowH - gear) / 2
|
||||
|
||||
-- Settings gear, top-right corner.
|
||||
-- The right cluster is laid out right to left -- Quit outermost, the gear
|
||||
-- inboard of it -- but the two are REGISTERED gear first, because the first
|
||||
-- focusable of the first frame adopts the keyboard ring and that must not be
|
||||
-- the button that exits the app.
|
||||
local quitX = rx - gear
|
||||
rx = quitX - math.floor(6 * m.s)
|
||||
|
||||
-- Settings gear.
|
||||
imp._gearIcon = imp._gearIcon
|
||||
or love.graphics.newImage("assets/launcher/gear.png")
|
||||
rx = rx - gear
|
||||
@@ -365,6 +384,23 @@ local function buildHeader(imp, m)
|
||||
end
|
||||
end
|
||||
|
||||
-- Quit, top-right corner.
|
||||
do
|
||||
local x = quitX
|
||||
Kit._audit("control", x, by, gear, gear, "quit")
|
||||
local focused = Kit.focusable("quit", x, by, gear, gear)
|
||||
local hot = focused or Kit.hover(x, by, gear, gear)
|
||||
Theme.fill(x, by, gear, gear, hot and PAL.ink or PAL.bg, 1)
|
||||
Theme.stroke(x, by, gear, gear, PAL.line,
|
||||
hot and Theme.A.focus or Theme.A.hairline, 1)
|
||||
local pad = math.floor(gear * 0.32)
|
||||
drawCross(x + pad, by + pad, gear - 2 * pad,
|
||||
hot and { 0, 0, 0, 1 } or { 1, 1, 1, 0.85 })
|
||||
if Kit.press(x, by, gear, gear) or Kit._activateId == "quit" then
|
||||
queueAction(imp, "quit", function() imp:_quitApp() end)
|
||||
end
|
||||
end
|
||||
|
||||
-- The self-update control lives in the FOOTER next to the BCG mark (small,
|
||||
-- out of the wordmark's way -- it used to overlap the logo on a phone). It
|
||||
-- still GLOWS through Kit.button when there is something to act on.
|
||||
@@ -1505,6 +1541,8 @@ local function buildConfirmModal(imp, m)
|
||||
imp:_confirmModUpdate(c.id, c.release)
|
||||
elseif c.kind == "enableAll" then
|
||||
imp:_setAllMods(true, true)
|
||||
elseif c.kind == "importOversize" then
|
||||
imp:_importSave(c.version, c.source, true)
|
||||
else
|
||||
imp:_toggleMod(c.id, true)
|
||||
end
|
||||
@@ -2162,7 +2200,15 @@ end
|
||||
-- pinned Play block used to walk up over the cards on a short window, which
|
||||
-- is unusable, and the footer simply lives below the fold until scrolled to.
|
||||
local function minPanelHeight(m)
|
||||
return math.floor(460 * m.s)
|
||||
-- One column stacks the actions card, the slot card and the pinned Play
|
||||
-- block in a single pile, so it needs more room than the side-by-side
|
||||
-- layout: 460 was tuned for two columns, and on a squat one-column window
|
||||
-- (a 4:3 device, a phone held upright) it left the slot card clipped
|
||||
-- inert against the pinned buttons -- Kit's clip bounds hit-testing, so
|
||||
-- no slot could be picked at all (#852). 660 fits the actions card, one
|
||||
-- slot row with its pager and New button, and the pinned block; whatever
|
||||
-- the window cannot show, the page scroll above reaches.
|
||||
return math.floor((m.twoCol and 460 or 660) * m.s)
|
||||
end
|
||||
|
||||
function LauncherView.draw(imp)
|
||||
|
||||
+97
-28
@@ -315,27 +315,16 @@ end
|
||||
-- keyboard-navigates (keyboard focus is a separate grab) but ignores the
|
||||
-- mouse entirely -- issue #254 on Linux. Whether it bites is a race with how
|
||||
-- long the click was held, which is why the same build picks one ROM fine and
|
||||
-- then hangs the mouse on the next. So pump until no button is held, letting
|
||||
-- SDL see the release and let go first; bounded, so a stuck button costs a
|
||||
-- moment and never the launcher. pump() only drains OS events into LOVE's
|
||||
-- queue -- it dispatches nothing -- so there is no reentry into mousepressed
|
||||
-- and the release is still delivered normally on the next frame.
|
||||
local function releasePointerGrab()
|
||||
if not (love.mouse and love.mouse.isDown and love.event and love.event.pump
|
||||
and love.timer) then
|
||||
return
|
||||
end
|
||||
local deadline = love.timer.getTime() + 1
|
||||
while love.mouse.isDown(1, 2, 3) do
|
||||
love.event.pump()
|
||||
if love.timer.getTime() > deadline then break end
|
||||
love.timer.sleep(0.005)
|
||||
end
|
||||
end
|
||||
-- then hangs the mouse on the next.
|
||||
--
|
||||
-- The release itself now lives in HostShell.releasePointerGrab, called from
|
||||
-- HostShell.popen, so every host spawn inherits it and not just the three
|
||||
-- pickers here. It stays a single release point on purpose: this file used
|
||||
-- to run its own copy first, and each copy carries its own one-second bound,
|
||||
-- so keeping both made a stuck button cost two seconds instead of one.
|
||||
|
||||
local function commandOutput(command)
|
||||
if not Platform.canSpawnProcess() then return nil end
|
||||
releasePointerGrab()
|
||||
local pipe = HostShell.popen(command)
|
||||
if not pipe then return nil end
|
||||
local result = pipe:read("*a")
|
||||
@@ -997,6 +986,25 @@ local function updaterAllowed()
|
||||
return true
|
||||
end
|
||||
|
||||
-- #835: which column the launcher opens on. `tab` starts at the --game
|
||||
-- shortcut's version (LaunchOptions.pendingTab) or Red; this then prefers the
|
||||
-- game play() last handed off, so relaunching lands on the game that was last
|
||||
-- played instead of always Red. An explicit --game still wins, and a
|
||||
-- remembered version whose cache is gone or stale is ignored, since opening a
|
||||
-- column with no Play button would read as the launcher losing the import.
|
||||
-- Called from new() once self.ready is filled, which is what that check needs.
|
||||
function RomImporter:_applyLastVersionTab()
|
||||
local okLO, LO = pcall(require, "src.core.LaunchOptions")
|
||||
if okLO and LO.pendingTab then return end
|
||||
local okOpt, opts = pcall(function()
|
||||
return require("src.core.SaveData").loadOptions()
|
||||
end)
|
||||
local last = okOpt and opts and opts.lastVersion
|
||||
if last and GameVersion.VERSIONS[last] and self.ready[last] then
|
||||
self.tab = last
|
||||
end
|
||||
end
|
||||
|
||||
-- The launcher runs each GameVersion as an independent tab. Each dropped or
|
||||
-- chosen ROM is routed to its version by SHA-1, extracted into that version's
|
||||
-- own cache (Red at the root, Blue under blue/, Yellow under yellow/), so all
|
||||
@@ -1128,6 +1136,7 @@ function RomImporter.new(onComplete, opts)
|
||||
self.romName[version] = "pokemon_" .. info.id
|
||||
.. (info.id == "yellow" and ".gbc" or ".gb")
|
||||
end
|
||||
self:_applyLastVersionTab()
|
||||
|
||||
-- Android: import a save-dir .gb/.gbc that is not yet ready (USB drop or a
|
||||
-- leftover SAF pick), routed by SHA-1. Already-imported carts are skipped
|
||||
@@ -1564,7 +1573,7 @@ end
|
||||
-- the target tab forward so the notice (and, on success, the new active slot)
|
||||
-- is visible. Requires the ROM to be imported first, since a save is only
|
||||
-- playable with its game's data present.
|
||||
function RomImporter:_importSave(version, source)
|
||||
function RomImporter:_importSave(version, source, force)
|
||||
if self.workState == "working" then return end
|
||||
if GameVersion.VERSIONS[self.tab] or self.tab == "mods" then
|
||||
self.tab = version
|
||||
@@ -1574,15 +1583,35 @@ function RomImporter:_importSave(version, source)
|
||||
.. GameVersion.info(version).displayName .. " ROM before importing a save." }
|
||||
return
|
||||
end
|
||||
local ok, res = require("src.import.SaveFileIO").importToSlot(source, version)
|
||||
local ok, res, info = require("src.import.SaveFileIO").importToSlot(source, version, force)
|
||||
if ok then
|
||||
self:_refreshSlots(version)
|
||||
self.activeSlot[version] = res
|
||||
self.slotScroll[version] = math.huge -- pin the new row on screen (clamped in draw)
|
||||
self.saveNotice[version] = { ok = true, text = "Imported save into " .. tostring(res) .. "." }
|
||||
else
|
||||
self.saveNotice[version] = { ok = false, text = tostring(res) }
|
||||
return
|
||||
end
|
||||
if res == nil and info and info.needsConfirm then
|
||||
-- A .sav larger than 32 KB whose first 32768 bytes checksum: the surplus
|
||||
-- is almost certainly an emulator RTC footer, so ask before truncating.
|
||||
-- The yes arm re-enters with force=true; cancel leaves the file untouched.
|
||||
self._modConfirm = {
|
||||
kind = "importOversize",
|
||||
version = version,
|
||||
source = source,
|
||||
title = "Oversized save file",
|
||||
lines = {
|
||||
("This save is %d bytes; a cartridge save is exactly %d bytes (32 KB).")
|
||||
:format(info.size, 32768),
|
||||
"It may come from a ROM that saved the battery image with an emulator.",
|
||||
"The extra bytes would be discarded.",
|
||||
"Import it anyway?",
|
||||
},
|
||||
yesLabel = "Import anyway",
|
||||
}
|
||||
return
|
||||
end
|
||||
self.saveNotice[version] = { ok = false, text = tostring(res) }
|
||||
end
|
||||
|
||||
-- "Import save" button: open a native .sav picker and import the pick.
|
||||
@@ -2220,6 +2249,17 @@ function RomImporter:play(version)
|
||||
if self.workState == "working" then return end
|
||||
if not self.ready[version] then return end
|
||||
self._handedOff = true
|
||||
-- #835: remember the game being launched so the next launcher start opens on
|
||||
-- its column (_applyLastVersionTab). It rides options.lua rather than a file
|
||||
-- of its own, so portable installs and POKEPORT_IDENTITY sandboxes keep it
|
||||
-- with the rest of the launcher's persisted state. A failed write only
|
||||
-- costs the memory of the choice, so it must never block the boot.
|
||||
pcall(function()
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local opts = SaveData.loadOptions()
|
||||
opts.lastVersion = version
|
||||
SaveData.saveOptions(opts)
|
||||
end)
|
||||
resetPointerCursor(self)
|
||||
-- The game draws with raw love.graphics from here on; drop the view's
|
||||
-- element tree and canvases before the handoff.
|
||||
@@ -2358,10 +2398,18 @@ function RomImporter:runActions(queue)
|
||||
end
|
||||
|
||||
-- Clicks are polled inside FlexLove (mouse + love.touch); host-forwarded
|
||||
-- mousepressed stays inert so Android's synthesized mouse path cannot
|
||||
-- double-fire a tap (#553). Touch move/press/release must still reach
|
||||
-- mousepressed mints no click, so Android's synthesized mouse path cannot
|
||||
-- double-fire a tap (#553). It DOES hand the pointer back from the pad
|
||||
-- cursor (#781): a Linux boot with a joystick present arms it (see the
|
||||
-- getJoystickCount block in new()), and while it is active
|
||||
-- LauncherView.update refuses to mint mouse clicks, so a real press must
|
||||
-- win the pointer back even when the polled motion yield misses (X11
|
||||
-- multi-monitor coords). Same contract as PadCursor.yieldToPointer for
|
||||
-- the overlay hosts. Touch move/press/release must still reach
|
||||
-- FlexLove.touch* or scroll containers never drag on phones.
|
||||
function RomImporter:mousepressed() end
|
||||
function RomImporter:mousepressed()
|
||||
self._padCursorActive = false
|
||||
end
|
||||
|
||||
function RomImporter:touchpressed(id, x, y, dx, dy, pressure)
|
||||
if not self._flex then return end
|
||||
@@ -2408,6 +2456,13 @@ function RomImporter:_openSettings()
|
||||
if ok and model then self._settings = model end
|
||||
end
|
||||
|
||||
-- Quit from the launcher's own X. It goes through love.event.quit so main.lua's
|
||||
-- love.quit hook still runs: that is where the worker threads are shut down
|
||||
-- (#339) and where a launcher close is told apart from a running game's (#785).
|
||||
function RomImporter:_quitApp()
|
||||
if love.event and love.event.quit then love.event.quit() end
|
||||
end
|
||||
|
||||
function RomImporter:_closeSettings()
|
||||
if self._settings then self._settings.save() end
|
||||
self._settings = nil
|
||||
@@ -2851,9 +2906,14 @@ end
|
||||
-- Update button: when a newer release is known, confirm then install; when
|
||||
-- already current, force-refresh the 6h cache and report / offer update.
|
||||
function RomImporter:_modGithubAction(id, action)
|
||||
if not Platform.networkValidated() then
|
||||
-- canFetchRemote, not networkValidated: the self-updater's gate used to
|
||||
-- stand in for this one, which cost Xbox the whole mod catalog rather than
|
||||
-- just the self-update it actually cannot do (#876). Say what still works
|
||||
-- while we are here, since the native picker is live on every platform that
|
||||
-- lands in this branch.
|
||||
if not Platform.canFetchRemote() then
|
||||
self.modNotice = { ok = false,
|
||||
text = "Remote mod download is unavailable on this platform." }
|
||||
text = "Remote mod download is unavailable on this platform. Install a mod .zip from storage instead." }
|
||||
return
|
||||
end
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
@@ -3157,9 +3217,18 @@ end
|
||||
-- never ran. The fetch now starts here and completes across later frames in
|
||||
-- _pumpFindFetch; the loader overlay is up for the whole flight.
|
||||
function RomImporter:_refreshFind(force)
|
||||
if not Platform.networkValidated() then
|
||||
-- The notice is the fix, not the gate (#876). This branch used to return an
|
||||
-- empty listing silently, and because the player had by then added a source,
|
||||
-- the panel skipped its "No mod index added" card and rendered the merged
|
||||
-- listing empty state instead: a valid feed reported as "This index lists no
|
||||
-- mods yet." Every other failure on this panel surfaces through findNotice,
|
||||
-- and this one has to as well, or adding an index looks like it worked and
|
||||
-- the index looks empty.
|
||||
if not Platform.canFetchRemote() then
|
||||
self.findLoaded = true
|
||||
self.findIndex = { mods = {}, categories = {} }
|
||||
self.findNotice = { ok = false,
|
||||
text = "Mod indexes cannot be fetched on this platform. Install a mod .zip from storage instead." }
|
||||
return
|
||||
end
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
|
||||
@@ -64,18 +64,34 @@ local function readSource(source)
|
||||
return nil, "could not read the save file: " .. tostring(openErr)
|
||||
end
|
||||
|
||||
-- importToSlot(source, version) -> ok, slotIdOrErr
|
||||
-- source: an absolute path, a LOVE DroppedFile, or raw 32768 bytes. On success
|
||||
-- importToSlot(source, version, force) -> ok, slotIdOrErr | (false, nil, info)
|
||||
-- source: an absolute path, a LOVE DroppedFile, or raw bytes. On success
|
||||
-- registers a new slot for the version, writes the imported save into it, makes
|
||||
-- it the active slot, and returns true + the new slot id. On any failure
|
||||
-- returns false + a friendly message.
|
||||
function SaveFileIO.importToSlot(source, version)
|
||||
-- returns false + a friendly message. force only matters for a file LARGER
|
||||
-- than 32768 bytes whose first 32768 bytes carry a valid main-data checksum
|
||||
-- (i.e. a cartridge save padded with an emulator RTC footer): without force
|
||||
-- this returns false, nil, { needsConfirm = true, size = #bytes } so the
|
||||
-- launcher can ask the player before truncating; with force the extra bytes
|
||||
-- are dropped and the 32768-byte save imports.
|
||||
function SaveFileIO.importToSlot(source, version, force)
|
||||
version = version or GameVersion.get()
|
||||
local bytes, readErr = readSource(source)
|
||||
if not bytes then return false, readErr end
|
||||
if #bytes ~= SAVE_SIZE then
|
||||
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
|
||||
:format(SAVE_SIZE, #bytes)
|
||||
local check = SaveConvert.mainChecksumValid(bytes)
|
||||
if check == nil then
|
||||
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
|
||||
:format(SAVE_SIZE, #bytes)
|
||||
end
|
||||
if check == false then
|
||||
return false, "save data checksum invalid (main data checksum mismatch)"
|
||||
end
|
||||
if #bytes > SAVE_SIZE and not force then
|
||||
return false, nil, { needsConfirm = true, size = #bytes }
|
||||
end
|
||||
bytes = #bytes > SAVE_SIZE and bytes:sub(1, SAVE_SIZE)
|
||||
or (bytes .. string.rep("\0", SAVE_SIZE - #bytes))
|
||||
end
|
||||
-- 3rd arg: the crosswalk has to come from THIS game's ROM cache. The
|
||||
-- launcher imports before the cache is mounted on the un-prefixed paths, so
|
||||
|
||||
@@ -51,6 +51,10 @@ local STONES = {
|
||||
local VITAMINS = { HP_UP = "hp", PROTEIN = "attack", IRON = "defense",
|
||||
CARBOS = "speed", CALCIUM = "special" }
|
||||
|
||||
-- REPEL / SUPER_REPEL / MAX_REPEL all funnel through ItemUseRepelCommon,
|
||||
-- which refuses mid-battle before writing wRepelRemainingSteps (#894)
|
||||
local REPELS = { REPEL = true, SUPER_REPEL = true, MAX_REPEL = true }
|
||||
|
||||
ItemEffects.BALLS = BALLS
|
||||
|
||||
function ItemEffects.isBall(id) return BALLS[id] or false end
|
||||
@@ -144,9 +148,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
local name = itemDef and itemDef.name or itemId
|
||||
|
||||
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase /
|
||||
-- ItemUseTMHM all refuse mid-battle (jp nz, ItemUseNotTime)
|
||||
-- ItemUseTMHM / ItemUseRepelCommon all refuse mid-battle
|
||||
-- (jp nz, ItemUseNotTime)
|
||||
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
|
||||
or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
|
||||
or REPELS[itemId]
|
||||
or (itemDef and itemDef.machine)) then
|
||||
return "failed", { notTime(data, save) }
|
||||
end
|
||||
@@ -489,6 +495,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
if battle then
|
||||
return "failed", { notTime(data, save) }
|
||||
end
|
||||
-- ItemUseBicycle (engine/items/item_effects.asm) opens with
|
||||
-- `cp 2 ; is the player surfing?` -> jp z, ItemUseNotTime, so the
|
||||
-- BICYCLE refuses on the water with the same OAK text as the rods
|
||||
-- above (#846)
|
||||
if ow and ow.player and ow.player.surfing then
|
||||
return "failed", { notTime(data, save) }
|
||||
end
|
||||
return "bicycle"
|
||||
end
|
||||
|
||||
@@ -511,7 +524,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
return "failed", { romText(data, "_CoinCaseNumCoinsText",
|
||||
"Coin count:\n%d", save.coins or 0) }
|
||||
end
|
||||
if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then
|
||||
if REPELS[itemId] then
|
||||
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
|
||||
save.repelSteps = steps
|
||||
return "consumed", { Strings("%s used\n%s!", save.player.name, name) }
|
||||
|
||||
+47
-12
@@ -474,6 +474,33 @@ local function removeTree(path)
|
||||
fs.remove(path)
|
||||
end
|
||||
|
||||
-- Every mods/ folder currently holding this id, plus the bare mods/<id> tree
|
||||
-- even when its manifest is missing or unreadable. Second return: whether any
|
||||
-- of them carries a manifest the panel can actually list. An install names
|
||||
-- its dest after the manifest id, but a hand-unzipped copy keeps whatever
|
||||
-- folder name the archive carried, and discover()'s first-id-wins rule means
|
||||
-- whichever folder physfs happens to enumerate first is the one the panel and
|
||||
-- the loader really use. Replacing only mods/<id> let an update report
|
||||
-- success while the old copy kept winning that race (#801); and a
|
||||
-- manifest-less mods/<id> left by an interrupted copy blocked every re-import
|
||||
-- as "already installed" while showing nowhere the player could see (#834).
|
||||
local function sameIdTrees(fs, id)
|
||||
local out, installed = {}, false
|
||||
if not fs.getInfo("mods") then return out, installed end
|
||||
for _, name in ipairs(fs.getDirectoryItems("mods")) do
|
||||
local path = "mods/" .. name
|
||||
local raw = fs.read(path .. "/manifest.json")
|
||||
local manifest = raw and decodeManifest(raw, path)
|
||||
if manifest and manifest.id == id then
|
||||
out[#out + 1] = path
|
||||
installed = true
|
||||
elseif name == id and fs.getInfo(path) then
|
||||
out[#out + 1] = path
|
||||
end
|
||||
end
|
||||
return out, installed
|
||||
end
|
||||
|
||||
-- ------- strays: mods dropped beside the game that it cannot see
|
||||
|
||||
-- love.filesystem looks in two places for "mods/": the save directory, and --
|
||||
@@ -666,16 +693,21 @@ function LauncherMods._installZipInner(source, opts)
|
||||
end
|
||||
|
||||
local dest = "mods/" .. manifest.id
|
||||
if fs.getInfo(dest) then
|
||||
if not opts.replace then
|
||||
cleanup()
|
||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||
end
|
||||
-- drop the old tree before copy; enable-flag is preserved (uninstall
|
||||
-- would clear it, which would surprise an update)
|
||||
local existing, installedSomewhere = sameIdTrees(fs, manifest.id)
|
||||
if installedSomewhere and not opts.replace then
|
||||
cleanup()
|
||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||
end
|
||||
if #existing > 0 then
|
||||
-- drop every old tree before copy -- mods/<id> and any same-id folder
|
||||
-- under another name, or the survivor keeps winning discover()'s
|
||||
-- first-id-wins race after the "successful" update (#801). A tree with
|
||||
-- no readable manifest is debris from an interrupted copy: it never
|
||||
-- refuses the install, it only gets cleared (#834). Enable-flag is
|
||||
-- preserved (uninstall would clear it, which would surprise an update).
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
removeTree(dest)
|
||||
for _, path in ipairs(existing) do removeTree(path) end
|
||||
CacheFs.prefix = savedPrefix
|
||||
end
|
||||
|
||||
@@ -789,14 +821,17 @@ function LauncherMods.uninstall(id)
|
||||
return nil, "mod uninstall needs LOVE"
|
||||
end
|
||||
local fs = love.filesystem
|
||||
local dest = "mods/" .. id
|
||||
if not fs.getInfo(dest) then
|
||||
local trees = sameIdTrees(fs, id)
|
||||
if #trees == 0 then
|
||||
return nil, "mod '" .. id .. "' is not installed"
|
||||
end
|
||||
-- same root pin as installZip: the mods tree is not version-prefixed (#330)
|
||||
-- same root pin as installZip: the mods tree is not version-prefixed (#330).
|
||||
-- Every same-id tree goes, folder name notwithstanding, so Delete works on a
|
||||
-- hand-unzipped copy too and cannot leave a shadow copy for discover()'s
|
||||
-- first-id-wins rule to resurrect on the next boot (#801)
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
removeTree(dest)
|
||||
for _, path in ipairs(trees) do removeTree(path) end
|
||||
CacheFs.prefix = savedPrefix
|
||||
-- Drop the enable flag so a reinstall of the same id starts from the
|
||||
-- loader's default (enabled) rather than a stale false.
|
||||
|
||||
@@ -868,4 +868,20 @@ function PaletteFX.sendColors(shader, c)
|
||||
shader:send("c3", { c[4][1] / 255, c[4][2] / 255, c[4][3] / 255 })
|
||||
end
|
||||
|
||||
-- The same send with NO display-mode substitution and no shade map: the four
|
||||
-- colors reach the shader exactly as given. Only for an INTERMEDIATE pass
|
||||
-- whose output is re-thresholded downstream -- the classic battle's zone pass
|
||||
-- under a forced-mono mode, where ensureZones' whole-screen zone already
|
||||
-- substitutes once at blit time and doing it again here applies the mode
|
||||
-- twice (#822). Everything that draws a final pixel wants sendColors.
|
||||
function PaletteFX.sendShades(shader, c)
|
||||
-- headless (no love.graphics) leaves shader() nil; sendColors reaches the
|
||||
-- same no-op through effectiveColors returning nil for an absent palette
|
||||
if not shader or not c then return end
|
||||
shader:send("c0", { c[1][1] / 255, c[1][2] / 255, c[1][3] / 255 })
|
||||
shader:send("c1", { c[2][1] / 255, c[2][2] / 255, c[2][3] / 255 })
|
||||
shader:send("c2", { c[3][1] / 255, c[3][2] / 255, c[3][3] / 255 })
|
||||
shader:send("c3", { c[4][1] / 255, c[4][2] / 255, c[4][3] / 255 })
|
||||
end
|
||||
|
||||
return PaletteFX
|
||||
|
||||
+17
-2
@@ -820,8 +820,13 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- non-battle state that opts in) uses the paper shade. "world" never
|
||||
-- reaches here -- it makes the battle non-opaque, so the world pass is
|
||||
-- active and this whole branch is skipped.
|
||||
-- FAITHFUL RATIO's mobile lock promises the display outside the GB
|
||||
-- screen stays black (src/core/FaithfulRes.lua); the paper surround
|
||||
-- painted the whole phone white on New Game and in battle (#864), so
|
||||
-- the lock keeps the default black bars.
|
||||
if state and state.letterboxWhite
|
||||
and not (state.bgMode and state:bgMode() == "black") then
|
||||
and not (state.bgMode and state:bgMode() == "black")
|
||||
and not FaithfulRes.scaleCap() then
|
||||
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
|
||||
end
|
||||
end
|
||||
@@ -1029,7 +1034,17 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local veil = self.screenVeil
|
||||
if veil and veil[2] > 0 then
|
||||
love.graphics.setColor(veil[1], veil[1], veil[1], veil[2])
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
-- FAITHFUL RATIO's mobile lock: the surface the player sees is the
|
||||
-- locked viewport and the bars around it are dead display, not screen
|
||||
-- (src/core/FaithfulRes.lua). A whole-window veil lit the entire phone
|
||||
-- for the battle flash and the post-battle fade (#864), so under the
|
||||
-- lock the veil stops at the letterbox. The desktop lock is unaffected:
|
||||
-- there the window IS the viewport.
|
||||
if FaithfulRes.scaleCap() then
|
||||
love.graphics.rectangle("fill", ox, oy, vpw, vph)
|
||||
else
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
@@ -93,6 +93,12 @@ O.statusFlags1 = O.townVisited + 29 -- 1B
|
||||
O.statusFlags4 = O.townVisited + 35 -- 1B
|
||||
O.elite4Flags = O.townVisited + 41 -- 1B
|
||||
O.tradeFlags = O.townVisited + 44 -- 2B (flag_array NUM_NPC_TRADES)
|
||||
-- wToggleableObjectFlags (ram/wram.asm, flag_array $100): the ShowObject/
|
||||
-- HideObject persistence, one bit per data/maps/toggleable_objects.asm entry,
|
||||
-- set = hidden (engine/overworld/toggleable_objects.asm IsObjectHidden).
|
||||
-- Sits 2 bytes (wPlayerCoins) past O.coins per the walk above; absolute
|
||||
-- 0x2852 (#763, #857).
|
||||
O.toggleObjectFlags = O.coins + 2 -- 32B
|
||||
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
|
||||
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
|
||||
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
|
||||
@@ -107,6 +113,15 @@ O.playTimeMaxed = O.mainData + 1867 -- 1B (set once past 2
|
||||
O.playTimeMinutes = O.mainData + 1868 -- 1B (0-59)
|
||||
O.playTimeSeconds = O.mainData + 1869 -- 1B (0-59)
|
||||
O.playTimeFrames = O.mainData + 1870 -- 1B (0-59, 1/60s ticks)
|
||||
-- wPikachuHappiness, Yellow only (pret/pokeyellow ram/wram.asm; no local
|
||||
-- pokeyellow checkout, so verified against the pokeyellow symbol file
|
||||
-- instead: d46f - wMainDataStart d2f6 = 377, the well-known absolute
|
||||
-- 0x271C). In Red/Blue this byte is current-map scratch the game
|
||||
-- regenerates on load, so the codec touches it only when the crosswalk
|
||||
-- data set names the game "yellow" (#763, #838). Every other modeled
|
||||
-- offset is identical between pokered and pokeyellow (same sram.asm, same
|
||||
-- wMainData field spacing per both symbol files).
|
||||
O.pikachuHappiness = O.mainData + 377
|
||||
O.mainDataSize = 1929 -- wMainDataEnd - wMainDataStart
|
||||
|
||||
O.spriteData = O.mainData + O.mainDataSize
|
||||
@@ -190,6 +205,18 @@ local function checksum(bytes, from, to)
|
||||
return bit.band(bit.bnot(sum), 0xFF)
|
||||
end
|
||||
|
||||
-- Main-data checksum gate used before an import policy is decided. Returns
|
||||
-- nil when the buffer is too short to even carry the stored checksum byte
|
||||
-- (offset O.mainChecksum, the last byte of wMainData), false on a mismatch,
|
||||
-- true when it matches. Works on any length >= O.mainChecksum + 1, so a
|
||||
-- caller can classify a truncated or footer-padded file without a full
|
||||
-- decode -- the checksummed region (0x2598..0x3522) always sits entirely
|
||||
-- inside the first 0x3524 bytes of a save.
|
||||
function GenSave.mainChecksumValid(bytes)
|
||||
if #bytes < O.mainChecksum + 1 then return nil end
|
||||
return checksum(bytes, O.checksumStart, O.checksumEnd) == u8(bytes, O.mainChecksum)
|
||||
end
|
||||
|
||||
-- flag_array packs LSB-first within each byte (bit 0 of byte 0 = index 0).
|
||||
-- This is pokered's runtime FlagAction convention (home/predef macros): it
|
||||
-- takes flag number N, addresses byte N/8, and builds the mask by rotating
|
||||
@@ -717,6 +744,25 @@ function GenSave.decode(bytes, data, opts)
|
||||
if save.flags[vanillaName] then save.flags[portName] = true end
|
||||
end
|
||||
|
||||
-- wToggleableObjectFlags -> save.objectToggles (bit set = hidden). A few
|
||||
-- of these are re-derived from flags on map entry (#106/#234 onEnter
|
||||
-- re-applies), but most ShowObject/HideObject state -- the Mt Moon
|
||||
-- fossils, the Cerulean guard swap -- has no flag to re-derive from, so
|
||||
-- an import that drops the array resurrects taken fossils and blocking
|
||||
-- guards (#763, #857).
|
||||
local toggles = data.toggleObjects
|
||||
if toggles then
|
||||
save.objectToggles = {}
|
||||
for bitIdx, e in pairs(toggles.byBit) do
|
||||
local mapToggles = save.objectToggles[e[1]]
|
||||
if not mapToggles then
|
||||
mapToggles = {}
|
||||
save.objectToggles[e[1]] = mapToggles
|
||||
end
|
||||
mapToggles[e[2]] = not bitGet(bytes, O.toggleObjectFlags, bitIdx)
|
||||
end
|
||||
end
|
||||
|
||||
-- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
|
||||
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value
|
||||
-- into de and rotates it right one bit per iteration with b counting up
|
||||
@@ -762,6 +808,15 @@ function GenSave.decode(bytes, data, opts)
|
||||
+ u8(bytes, O.playTimeSeconds)
|
||||
+ u8(bytes, O.playTimeFrames) / 60
|
||||
|
||||
-- Yellow starter friendship (save.pikachuHappiness,
|
||||
-- src/world/PikachuFollower.lua reads it; pokeyellow's
|
||||
-- init_player_data.asm seeds 90 on a new game), gated on the data set's
|
||||
-- game because the byte is map scratch in Red/Blue (see
|
||||
-- O.pikachuHappiness) (#763, #838).
|
||||
if data.gameVersion == "yellow" then
|
||||
save.pikachuHappiness = u8(bytes, O.pikachuHappiness)
|
||||
end
|
||||
|
||||
save.warnings = warnings
|
||||
save.rawImport = bytes -- template for a later encode(); see file header
|
||||
return save
|
||||
@@ -856,6 +911,41 @@ function GenSave.encode(save, data, template)
|
||||
end
|
||||
end
|
||||
|
||||
-- wToggleableObjectFlags, written both ways like the #396 extras: this
|
||||
-- port's save is the authority, and vanilla folds three stores this port
|
||||
-- keeps separate into these same bits -- script ShowObject/HideObject
|
||||
-- (save.objectToggles), taken overworld items (engine/events/
|
||||
-- pick_up_item.asm -> save.itemsTaken) and beaten static encounters
|
||||
-- (home/trainers.asm HideObject after battle -> save.defeatedTrainers) --
|
||||
-- so all three fold back in here or an exported save resurrects them
|
||||
-- (#763, #857).
|
||||
local toggleData = data.toggleObjects
|
||||
if toggleData then
|
||||
local objectToggles = save.objectToggles or {}
|
||||
local itemsTaken = save.itemsTaken or {}
|
||||
local beaten = save.defeatedTrainers or {}
|
||||
for bitIdx, e in pairs(toggleData.byBit) do
|
||||
local mapId, objName, visible = e[1], e[2], e[3]
|
||||
local mapToggles = objectToggles[mapId]
|
||||
if mapToggles and mapToggles[objName] ~= nil then
|
||||
visible = mapToggles[objName]
|
||||
end
|
||||
if visible and data.maps and data.maps[mapId] then
|
||||
for _, obj in ipairs(data.maps[mapId].objects or {}) do
|
||||
if obj.name == objName then
|
||||
local key = mapId .. "_obj_" .. obj.index
|
||||
if (obj.item and itemsTaken[key])
|
||||
or (obj.pokemon and beaten[key]) then
|
||||
visible = false
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
bitSet(buf, O.toggleObjectFlags, bitIdx, not visible)
|
||||
end
|
||||
end
|
||||
|
||||
-- FLY destinations back into wTownVisitedFlag (see the decode note), so a
|
||||
-- save exported from this port is flyable on hardware (#263). A save
|
||||
-- table with no `visited` key at all says nothing about the set, so leave
|
||||
@@ -955,6 +1045,16 @@ function GenSave.encode(save, data, template)
|
||||
setByte(buf, O.playTimeFrames, rem - secs * 60)
|
||||
end
|
||||
|
||||
-- Yellow starter friendship back out (see O.pikachuHappiness); Red/Blue
|
||||
-- data sets never reach this write. 90 is the fresh-game seed the
|
||||
-- follower system itself uses when the save has never tracked it.
|
||||
-- Placed before the checksum pass so the byte is covered by the
|
||||
-- main-data checksum automatically (#763, #838).
|
||||
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))))
|
||||
end
|
||||
|
||||
local out = table.concat(buf)
|
||||
-- checksums, computed last over the now-final bytes
|
||||
local outBuf = {}
|
||||
|
||||
@@ -25,6 +25,7 @@ local GenSave = require("src.save_convert.GenSave")
|
||||
local SaveConvert = {}
|
||||
|
||||
SaveConvert.SAVE_SIZE = GenSave.SAVE_SIZE
|
||||
SaveConvert.mainChecksumValid = GenSave.mainChecksumValid
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Crosswalk data loading (cached). Mirrors src/core/Data.lua: prefer
|
||||
@@ -43,6 +44,19 @@ local DATA_MODULES = {
|
||||
maps = { "data.generated.maps", "data/generated/maps.lua" },
|
||||
charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" },
|
||||
eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" },
|
||||
toggleObjects = { "src.save_convert.data.toggle_objects", "src/save_convert/data/toggle_objects.lua" },
|
||||
}
|
||||
|
||||
-- Yellow renumbers wEventFlags bits: pokeyellow's constants/event_constants.asm
|
||||
-- inserts events pokered does not have (the Jessie & James fights, catch
|
||||
-- training, the Officer Jenny Squirtle) and shifts the Mt Moon 3 / Silph Co
|
||||
-- 11F block, so writing a Yellow save through the Red table lands bits on the
|
||||
-- wrong events and drops every Yellow-only flag. Kept outside DATA_MODULES so
|
||||
-- the ensureData loop never loads it as a crosswalk of its own -- it
|
||||
-- substitutes for `eventFlags` when the caller names Yellow (#838).
|
||||
local YELLOW_EVENT_FLAGS = {
|
||||
"src.save_convert.data.event_flags_yellow",
|
||||
"src/save_convert/data/event_flags_yellow.lua",
|
||||
}
|
||||
|
||||
local function loadTable(requirePath, filePath)
|
||||
@@ -111,6 +125,9 @@ local function ensureData(gameVersion)
|
||||
local data = {}
|
||||
for name, spec in pairs(DATA_MODULES) do
|
||||
if name ~= "charmap" then
|
||||
if name == "eventFlags" and gameVersion == "yellow" then
|
||||
spec = YELLOW_EVENT_FLAGS -- Yellow's bit numbering differs (#838)
|
||||
end
|
||||
local mod = loadCacheTable(gameVersion, spec[2])
|
||||
if not mod then
|
||||
local e
|
||||
@@ -120,6 +137,10 @@ local function ensureData(gameVersion)
|
||||
data[name] = mod
|
||||
end
|
||||
end
|
||||
-- record which game's tables these are: the codec gates Yellow-only
|
||||
-- bytes (wPikachuFriendship) on it, since those offsets are map
|
||||
-- scratch in Red/Blue (#763, #838)
|
||||
data.gameVersion = gameVersion
|
||||
crosswalks[key] = data
|
||||
end
|
||||
if not charmapReady then
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,243 @@
|
||||
-- wToggleableObjectFlags bit index -> { map id, object_event name, default
|
||||
-- visible } for the Gen1 save codec (src/save_convert/GenSave.lua).
|
||||
-- Derived entry by entry from ../pokered/data/maps/toggleable_objects.asm
|
||||
-- (ToggleableObjectStates: three bytes per entry, blocks laid out in map-id
|
||||
-- order, so an entry's position in the table IS its wToggleableObjectFlags
|
||||
-- bit -- constants/toggle_constants.asm numbers the same list), with the
|
||||
-- default taken from each row's ON/OFF state. Bit set = hidden
|
||||
-- (engine/overworld/toggleable_objects.asm IsObjectHidden). Object names
|
||||
-- match data/generated/maps.lua object_event names one for one; the two
|
||||
-- placeholder bits with no object_event in this port stay as comments so
|
||||
-- the numbering remains auditable (#763, #857).
|
||||
return {
|
||||
byBit = {
|
||||
[0] = { "PALLET_TOWN", "PALLETTOWN_OAK", false },
|
||||
[1] = { "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY", true },
|
||||
[2] = { "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN", false },
|
||||
[3] = { "PEWTER_CITY", "PEWTERCITY_SUPER_NERD1", true },
|
||||
[4] = { "PEWTER_CITY", "PEWTERCITY_YOUNGSTER", true },
|
||||
[5] = { "CERULEAN_CITY", "CERULEANCITY_RIVAL", false },
|
||||
[6] = { "CERULEAN_CITY", "CERULEANCITY_ROCKET", true },
|
||||
[7] = { "CERULEAN_CITY", "CERULEANCITY_GUARD1", false },
|
||||
[8] = { "CERULEAN_CITY", "CERULEANCITY_SUPER_NERD3", true },
|
||||
[9] = { "CERULEAN_CITY", "CERULEANCITY_GUARD2", true },
|
||||
[10] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET1", true },
|
||||
[11] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET2", true },
|
||||
[12] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET3", true },
|
||||
[13] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET4", true },
|
||||
[14] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET5", true },
|
||||
[15] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET6", true },
|
||||
[16] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET7", true },
|
||||
[17] = { "SAFFRON_CITY", "SAFFRONCITY_SCIENTIST", false },
|
||||
[18] = { "SAFFRON_CITY", "SAFFRONCITY_SILPH_WORKER_M", false },
|
||||
[19] = { "SAFFRON_CITY", "SAFFRONCITY_SILPH_WORKER_F", false },
|
||||
[20] = { "SAFFRON_CITY", "SAFFRONCITY_GENTLEMAN", false },
|
||||
[21] = { "SAFFRON_CITY", "SAFFRONCITY_PIDGEOT", false },
|
||||
[22] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKER", false },
|
||||
[23] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET8", true },
|
||||
[24] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET9", false },
|
||||
[25] = { "ROUTE_2", "ROUTE2_MOON_STONE", true },
|
||||
[26] = { "ROUTE_2", "ROUTE2_HP_UP", true },
|
||||
[27] = { "ROUTE_4", "ROUTE4_TM_WHIRLWIND", true },
|
||||
[28] = { "ROUTE_9", "ROUTE9_TM_TELEPORT", true },
|
||||
[29] = { "ROUTE_12", "ROUTE12_SNORLAX", true },
|
||||
[30] = { "ROUTE_12", "ROUTE12_TM_PAY_DAY", true },
|
||||
[31] = { "ROUTE_12", "ROUTE12_IRON", true },
|
||||
[32] = { "ROUTE_15", "ROUTE15_TM_RAGE", true },
|
||||
[33] = { "ROUTE_16", "ROUTE16_SNORLAX", true },
|
||||
[34] = { "ROUTE_22", "ROUTE22_RIVAL1", false },
|
||||
[35] = { "ROUTE_22", "ROUTE22_RIVAL2", false },
|
||||
[36] = { "ROUTE_24", "ROUTE24_COOLTRAINER_M1", true },
|
||||
[37] = { "ROUTE_24", "ROUTE24_TM_THUNDER_WAVE", true },
|
||||
[38] = { "ROUTE_25", "ROUTE25_TM_SEISMIC_TOSS", true },
|
||||
[39] = { "BLUES_HOUSE", "BLUESHOUSE_DAISY1", true },
|
||||
[40] = { "BLUES_HOUSE", "BLUESHOUSE_DAISY2", false },
|
||||
[41] = { "BLUES_HOUSE", "BLUESHOUSE_TOWN_MAP", true },
|
||||
[42] = { "OAKS_LAB", "OAKSLAB_RIVAL", true },
|
||||
[43] = { "OAKS_LAB", "OAKSLAB_CHARMANDER_POKE_BALL", true },
|
||||
[44] = { "OAKS_LAB", "OAKSLAB_SQUIRTLE_POKE_BALL", true },
|
||||
[45] = { "OAKS_LAB", "OAKSLAB_BULBASAUR_POKE_BALL", true },
|
||||
[46] = { "OAKS_LAB", "OAKSLAB_OAK1", false },
|
||||
[47] = { "OAKS_LAB", "OAKSLAB_POKEDEX1", true },
|
||||
[48] = { "OAKS_LAB", "OAKSLAB_POKEDEX2", true },
|
||||
[49] = { "OAKS_LAB", "OAKSLAB_OAK2", false },
|
||||
[50] = { "VIRIDIAN_GYM", "VIRIDIANGYM_GIOVANNI", true },
|
||||
[51] = { "VIRIDIAN_GYM", "VIRIDIANGYM_REVIVE", true },
|
||||
[52] = { "MUSEUM_1F", "MUSEUM1F_OLD_AMBER", true },
|
||||
[53] = { "CERULEAN_CAVE_1F", "CERULEANCAVE1F_FULL_RESTORE", true },
|
||||
[54] = { "CERULEAN_CAVE_1F", "CERULEANCAVE1F_MAX_ELIXER", true },
|
||||
[55] = { "CERULEAN_CAVE_1F", "CERULEANCAVE1F_NUGGET", true },
|
||||
[56] = { "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL", true },
|
||||
[57] = { "POKEMON_TOWER_3F", "POKEMONTOWER3F_ESCAPE_ROPE", true },
|
||||
[58] = { "POKEMON_TOWER_4F", "POKEMONTOWER4F_ELIXER", true },
|
||||
[59] = { "POKEMON_TOWER_4F", "POKEMONTOWER4F_AWAKENING", true },
|
||||
[60] = { "POKEMON_TOWER_4F", "POKEMONTOWER4F_HP_UP", true },
|
||||
[61] = { "POKEMON_TOWER_5F", "POKEMONTOWER5F_NUGGET", true },
|
||||
[62] = { "POKEMON_TOWER_6F", "POKEMONTOWER6F_RARE_CANDY", true },
|
||||
[63] = { "POKEMON_TOWER_6F", "POKEMONTOWER6F_X_ACCURACY", true },
|
||||
[64] = { "POKEMON_TOWER_7F", "POKEMONTOWER7F_ROCKET1", true },
|
||||
[65] = { "POKEMON_TOWER_7F", "POKEMONTOWER7F_ROCKET2", true },
|
||||
[66] = { "POKEMON_TOWER_7F", "POKEMONTOWER7F_ROCKET3", true },
|
||||
[67] = { "POKEMON_TOWER_7F", "POKEMONTOWER7F_MR_FUJI", true },
|
||||
[68] = { "MR_FUJIS_HOUSE", "MRFUJISHOUSE_MR_FUJI", false },
|
||||
[69] = { "CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL", true },
|
||||
[70] = { "GAME_CORNER", "GAMECORNER_ROCKET", true },
|
||||
[71] = { "WARDENS_HOUSE", "WARDENSHOUSE_RARE_CANDY", true },
|
||||
[72] = { "POKEMON_MANSION_1F", "POKEMONMANSION1F_ESCAPE_ROPE", true },
|
||||
[73] = { "POKEMON_MANSION_1F", "POKEMONMANSION1F_CARBOS", true },
|
||||
[74] = { "FIGHTING_DOJO", "FIGHTINGDOJO_HITMONLEE_POKE_BALL", true },
|
||||
[75] = { "FIGHTING_DOJO", "FIGHTINGDOJO_HITMONCHAN_POKE_BALL", true },
|
||||
[76] = { "SILPH_CO_1F", "SILPHCO1F_LINK_RECEPTIONIST", false },
|
||||
[77] = { "POWER_PLANT", "POWERPLANT_VOLTORB1", true },
|
||||
[78] = { "POWER_PLANT", "POWERPLANT_VOLTORB2", true },
|
||||
[79] = { "POWER_PLANT", "POWERPLANT_VOLTORB3", true },
|
||||
[80] = { "POWER_PLANT", "POWERPLANT_ELECTRODE1", true },
|
||||
[81] = { "POWER_PLANT", "POWERPLANT_VOLTORB4", true },
|
||||
[82] = { "POWER_PLANT", "POWERPLANT_VOLTORB5", true },
|
||||
[83] = { "POWER_PLANT", "POWERPLANT_ELECTRODE2", true },
|
||||
[84] = { "POWER_PLANT", "POWERPLANT_VOLTORB6", true },
|
||||
[85] = { "POWER_PLANT", "POWERPLANT_ZAPDOS", true },
|
||||
[86] = { "POWER_PLANT", "POWERPLANT_CARBOS", true },
|
||||
[87] = { "POWER_PLANT", "POWERPLANT_HP_UP", true },
|
||||
[88] = { "POWER_PLANT", "POWERPLANT_RARE_CANDY", true },
|
||||
[89] = { "POWER_PLANT", "POWERPLANT_TM_THUNDER", true },
|
||||
[90] = { "POWER_PLANT", "POWERPLANT_TM_REFLECT", true },
|
||||
[91] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_MOLTRES", true },
|
||||
[92] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_TM_SUBMISSION", true },
|
||||
[93] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_FULL_HEAL", true },
|
||||
[94] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_TM_MEGA_KICK", true },
|
||||
[95] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_GUARD_SPEC", true },
|
||||
[96] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3", true },
|
||||
[97] = { "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON", true },
|
||||
[98] = { "BILLS_HOUSE", "BILLSHOUSE_BILL1", false },
|
||||
[99] = { "BILLS_HOUSE", "BILLSHOUSE_BILL2", false },
|
||||
[100] = { "VIRIDIAN_FOREST", "VIRIDIANFOREST_ANTIDOTE", true },
|
||||
[101] = { "VIRIDIAN_FOREST", "VIRIDIANFOREST_POTION", true },
|
||||
[102] = { "VIRIDIAN_FOREST", "VIRIDIANFOREST_POKE_BALL", true },
|
||||
[103] = { "MT_MOON_1F", "MTMOON1F_POTION1", true },
|
||||
[104] = { "MT_MOON_1F", "MTMOON1F_MOON_STONE", true },
|
||||
[105] = { "MT_MOON_1F", "MTMOON1F_RARE_CANDY", true },
|
||||
[106] = { "MT_MOON_1F", "MTMOON1F_ESCAPE_ROPE", true },
|
||||
[107] = { "MT_MOON_1F", "MTMOON1F_POTION2", true },
|
||||
[108] = { "MT_MOON_1F", "MTMOON1F_TM_WATER_GUN", true },
|
||||
[109] = { "MT_MOON_B2F", "MTMOONB2F_DOME_FOSSIL", true },
|
||||
[110] = { "MT_MOON_B2F", "MTMOONB2F_HELIX_FOSSIL", true },
|
||||
[111] = { "MT_MOON_B2F", "MTMOONB2F_HP_UP", true },
|
||||
[112] = { "MT_MOON_B2F", "MTMOONB2F_TM_MEGA_PUNCH", true },
|
||||
[113] = { "SS_ANNE_2F", "SSANNE2F_RIVAL", false },
|
||||
[114] = { "SS_ANNE_1F_ROOMS", "SSANNE1FROOMS_TM_BODY_SLAM", true },
|
||||
[115] = { "SS_ANNE_2F_ROOMS", "SSANNE2FROOMS_MAX_ETHER", true },
|
||||
[116] = { "SS_ANNE_2F_ROOMS", "SSANNE2FROOMS_RARE_CANDY", true },
|
||||
[117] = { "SS_ANNE_B1F_ROOMS", "SSANNEB1FROOMS_ETHER", true },
|
||||
[118] = { "SS_ANNE_B1F_ROOMS", "SSANNEB1FROOMS_TM_REST", true },
|
||||
[119] = { "SS_ANNE_B1F_ROOMS", "SSANNEB1FROOMS_MAX_POTION", true },
|
||||
[120] = { "VICTORY_ROAD_3F", "VICTORYROAD3F_MAX_REVIVE", true },
|
||||
[121] = { "VICTORY_ROAD_3F", "VICTORYROAD3F_TM_EXPLOSION", true },
|
||||
[122] = { "VICTORY_ROAD_3F", "VICTORYROAD3F_BOULDER4", true },
|
||||
[123] = { "ROCKET_HIDEOUT_B1F", "ROCKETHIDEOUTB1F_ESCAPE_ROPE", true },
|
||||
[124] = { "ROCKET_HIDEOUT_B1F", "ROCKETHIDEOUTB1F_HYPER_POTION", true },
|
||||
[125] = { "ROCKET_HIDEOUT_B2F", "ROCKETHIDEOUTB2F_MOON_STONE", true },
|
||||
[126] = { "ROCKET_HIDEOUT_B2F", "ROCKETHIDEOUTB2F_NUGGET", true },
|
||||
[127] = { "ROCKET_HIDEOUT_B2F", "ROCKETHIDEOUTB2F_TM_HORN_DRILL", true },
|
||||
[128] = { "ROCKET_HIDEOUT_B2F", "ROCKETHIDEOUTB2F_SUPER_POTION", true },
|
||||
[129] = { "ROCKET_HIDEOUT_B3F", "ROCKETHIDEOUTB3F_TM_DOUBLE_EDGE", true },
|
||||
[130] = { "ROCKET_HIDEOUT_B3F", "ROCKETHIDEOUTB3F_RARE_CANDY", true },
|
||||
[131] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_GIOVANNI", true },
|
||||
[132] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_HP_UP", true },
|
||||
[133] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_TM_RAZOR_WIND", true },
|
||||
[134] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_IRON", true },
|
||||
[135] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_SILPH_SCOPE", false },
|
||||
[136] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY", false },
|
||||
[137] = { "SILPH_CO_2F", "SILPHCO2F_SILPH_WORKER_F", true },
|
||||
[138] = { "SILPH_CO_2F", "SILPHCO2F_SCIENTIST1", true },
|
||||
[139] = { "SILPH_CO_2F", "SILPHCO2F_SCIENTIST2", true },
|
||||
[140] = { "SILPH_CO_2F", "SILPHCO2F_ROCKET1", true },
|
||||
[141] = { "SILPH_CO_2F", "SILPHCO2F_ROCKET2", true },
|
||||
[142] = { "SILPH_CO_3F", "SILPHCO3F_ROCKET", true },
|
||||
[143] = { "SILPH_CO_3F", "SILPHCO3F_SCIENTIST", true },
|
||||
[144] = { "SILPH_CO_3F", "SILPHCO3F_HYPER_POTION", true },
|
||||
[145] = { "SILPH_CO_4F", "SILPHCO4F_ROCKET1", true },
|
||||
[146] = { "SILPH_CO_4F", "SILPHCO4F_SCIENTIST", true },
|
||||
[147] = { "SILPH_CO_4F", "SILPHCO4F_ROCKET2", true },
|
||||
[148] = { "SILPH_CO_4F", "SILPHCO4F_FULL_HEAL", true },
|
||||
[149] = { "SILPH_CO_4F", "SILPHCO4F_MAX_REVIVE", true },
|
||||
[150] = { "SILPH_CO_4F", "SILPHCO4F_ESCAPE_ROPE", true },
|
||||
[151] = { "SILPH_CO_5F", "SILPHCO5F_ROCKET1", true },
|
||||
[152] = { "SILPH_CO_5F", "SILPHCO5F_SCIENTIST", true },
|
||||
[153] = { "SILPH_CO_5F", "SILPHCO5F_ROCKER", true },
|
||||
[154] = { "SILPH_CO_5F", "SILPHCO5F_ROCKET2", true },
|
||||
[155] = { "SILPH_CO_5F", "SILPHCO5F_TM_TAKE_DOWN", true },
|
||||
[156] = { "SILPH_CO_5F", "SILPHCO5F_PROTEIN", true },
|
||||
[157] = { "SILPH_CO_5F", "SILPHCO5F_CARD_KEY", true },
|
||||
[158] = { "SILPH_CO_6F", "SILPHCO6F_ROCKET1", true },
|
||||
[159] = { "SILPH_CO_6F", "SILPHCO6F_SCIENTIST", true },
|
||||
[160] = { "SILPH_CO_6F", "SILPHCO6F_ROCKET2", true },
|
||||
[161] = { "SILPH_CO_6F", "SILPHCO6F_HP_UP", true },
|
||||
[162] = { "SILPH_CO_6F", "SILPHCO6F_X_ACCURACY", true },
|
||||
[163] = { "SILPH_CO_7F", "SILPHCO7F_ROCKET1", true },
|
||||
[164] = { "SILPH_CO_7F", "SILPHCO7F_SCIENTIST", true },
|
||||
[165] = { "SILPH_CO_7F", "SILPHCO7F_ROCKET2", true },
|
||||
[166] = { "SILPH_CO_7F", "SILPHCO7F_ROCKET3", true },
|
||||
[167] = { "SILPH_CO_7F", "SILPHCO7F_RIVAL", true },
|
||||
[168] = { "SILPH_CO_7F", "SILPHCO7F_CALCIUM", true },
|
||||
[169] = { "SILPH_CO_7F", "SILPHCO7F_TM_SWORDS_DANCE", true },
|
||||
-- [170] SILPH_CO_7F (SILPHCO7F_UNUSED): placeholder entry, no object_event in this port
|
||||
[171] = { "SILPH_CO_8F", "SILPHCO8F_ROCKET1", true },
|
||||
[172] = { "SILPH_CO_8F", "SILPHCO8F_SCIENTIST", true },
|
||||
[173] = { "SILPH_CO_8F", "SILPHCO8F_ROCKET2", true },
|
||||
[174] = { "SILPH_CO_9F", "SILPHCO9F_ROCKET1", true },
|
||||
[175] = { "SILPH_CO_9F", "SILPHCO9F_SCIENTIST", true },
|
||||
[176] = { "SILPH_CO_9F", "SILPHCO9F_ROCKET2", true },
|
||||
[177] = { "SILPH_CO_10F", "SILPHCO10F_ROCKET", true },
|
||||
[178] = { "SILPH_CO_10F", "SILPHCO10F_SCIENTIST", true },
|
||||
[179] = { "SILPH_CO_10F", "SILPHCO10F_SILPH_WORKER_F", true },
|
||||
[180] = { "SILPH_CO_10F", "SILPHCO10F_TM_EARTHQUAKE", true },
|
||||
[181] = { "SILPH_CO_10F", "SILPHCO10F_RARE_CANDY", true },
|
||||
[182] = { "SILPH_CO_10F", "SILPHCO10F_CARBOS", true },
|
||||
[183] = { "SILPH_CO_11F", "SILPHCO11F_GIOVANNI", true },
|
||||
[184] = { "SILPH_CO_11F", "SILPHCO11F_ROCKET1", true },
|
||||
[185] = { "SILPH_CO_11F", "SILPHCO11F_ROCKET2", true },
|
||||
-- [186] UNUSED_MAP_F4 ($02): placeholder entry, no object_event in this port
|
||||
[187] = { "POKEMON_MANSION_2F", "POKEMONMANSION2F_CALCIUM", true },
|
||||
[188] = { "POKEMON_MANSION_3F", "POKEMONMANSION3F_MAX_POTION", true },
|
||||
[189] = { "POKEMON_MANSION_3F", "POKEMONMANSION3F_IRON", true },
|
||||
[190] = { "POKEMON_MANSION_B1F", "POKEMONMANSIONB1F_RARE_CANDY", true },
|
||||
[191] = { "POKEMON_MANSION_B1F", "POKEMONMANSIONB1F_FULL_RESTORE", true },
|
||||
[192] = { "POKEMON_MANSION_B1F", "POKEMONMANSIONB1F_TM_BLIZZARD", true },
|
||||
[193] = { "POKEMON_MANSION_B1F", "POKEMONMANSIONB1F_TM_SOLARBEAM", true },
|
||||
[194] = { "POKEMON_MANSION_B1F", "POKEMONMANSIONB1F_SECRET_KEY", true },
|
||||
[195] = { "SAFARI_ZONE_EAST", "SAFARIZONEEAST_FULL_RESTORE", true },
|
||||
[196] = { "SAFARI_ZONE_EAST", "SAFARIZONEEAST_MAX_RESTORE", true },
|
||||
[197] = { "SAFARI_ZONE_EAST", "SAFARIZONEEAST_CARBOS", true },
|
||||
[198] = { "SAFARI_ZONE_EAST", "SAFARIZONEEAST_TM_EGG_BOMB", true },
|
||||
[199] = { "SAFARI_ZONE_NORTH", "SAFARIZONENORTH_PROTEIN", true },
|
||||
[200] = { "SAFARI_ZONE_NORTH", "SAFARIZONENORTH_TM_SKULL_BASH", true },
|
||||
[201] = { "SAFARI_ZONE_WEST", "SAFARIZONEWEST_MAX_POTION", true },
|
||||
[202] = { "SAFARI_ZONE_WEST", "SAFARIZONEWEST_TM_DOUBLE_TEAM", true },
|
||||
[203] = { "SAFARI_ZONE_WEST", "SAFARIZONEWEST_MAX_REVIVE", true },
|
||||
[204] = { "SAFARI_ZONE_WEST", "SAFARIZONEWEST_GOLD_TEETH", true },
|
||||
[205] = { "SAFARI_ZONE_CENTER", "SAFARIZONECENTER_NUGGET", true },
|
||||
[206] = { "CERULEAN_CAVE_2F", "CERULEANCAVE2F_PP_UP", true },
|
||||
[207] = { "CERULEAN_CAVE_2F", "CERULEANCAVE2F_ULTRA_BALL", true },
|
||||
[208] = { "CERULEAN_CAVE_2F", "CERULEANCAVE2F_FULL_RESTORE", true },
|
||||
[209] = { "CERULEAN_CAVE_B1F", "CERULEANCAVEB1F_MEWTWO", true },
|
||||
[210] = { "CERULEAN_CAVE_B1F", "CERULEANCAVEB1F_ULTRA_BALL", true },
|
||||
[211] = { "CERULEAN_CAVE_B1F", "CERULEANCAVEB1F_MAX_REVIVE", true },
|
||||
[212] = { "VICTORY_ROAD_1F", "VICTORYROAD1F_TM_SKY_ATTACK", true },
|
||||
[213] = { "VICTORY_ROAD_1F", "VICTORYROAD1F_RARE_CANDY", true },
|
||||
[214] = { "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK", false },
|
||||
[215] = { "SEAFOAM_ISLANDS_1F", "SEAFOAMISLANDS1F_BOULDER1", true },
|
||||
[216] = { "SEAFOAM_ISLANDS_1F", "SEAFOAMISLANDS1F_BOULDER2", true },
|
||||
[217] = { "SEAFOAM_ISLANDS_B1F", "SEAFOAMISLANDSB1F_BOULDER1", false },
|
||||
[218] = { "SEAFOAM_ISLANDS_B1F", "SEAFOAMISLANDSB1F_BOULDER2", false },
|
||||
[219] = { "SEAFOAM_ISLANDS_B2F", "SEAFOAMISLANDSB2F_BOULDER1", false },
|
||||
[220] = { "SEAFOAM_ISLANDS_B2F", "SEAFOAMISLANDSB2F_BOULDER2", false },
|
||||
[221] = { "SEAFOAM_ISLANDS_B3F", "SEAFOAMISLANDSB3F_BOULDER2", true },
|
||||
[222] = { "SEAFOAM_ISLANDS_B3F", "SEAFOAMISLANDSB3F_BOULDER3", true },
|
||||
[223] = { "SEAFOAM_ISLANDS_B3F", "SEAFOAMISLANDSB3F_BOULDER5", false },
|
||||
[224] = { "SEAFOAM_ISLANDS_B3F", "SEAFOAMISLANDSB3F_BOULDER6", false },
|
||||
[225] = { "SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER1", false },
|
||||
[226] = { "SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER2", false },
|
||||
[227] = { "SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_ARTICUNO", true },
|
||||
},
|
||||
}
|
||||
+38
-2
@@ -260,6 +260,26 @@ function Commands.take_item(ctx, itemId, count)
|
||||
if inv[itemId] == 0 then inv[itemId] = nil end
|
||||
end
|
||||
|
||||
-- save_end_battle_text <TEXT_KEY>: SaveEndBattleTextPointers
|
||||
-- (home/trainers.asm, called from e.g. RocketHideoutB4FScript10 just before
|
||||
-- wCurOpponent is set). The armed line is the trainer's OWN loss line and
|
||||
-- belongs on the battle screen: PrintEndBattleText (home/trainers.asm) runs
|
||||
-- from TrainerBattleVictory (engine/battle/core.asm) after
|
||||
-- TrainerDefeatedText and the pic scroll but BEFORE MoneyForWinningText, and
|
||||
-- TrainerEndBattleText prints _TrainerNameText first so the line opens with
|
||||
-- the "CLASS: " tag. Scripts that printed it with a plain show_text after
|
||||
-- start_battle got it a box too late -- after the payout -- and untagged
|
||||
-- (#866). Arms exactly one battle; start_battle consumes it.
|
||||
function Commands.save_end_battle_text(ctx, textId)
|
||||
local text = ctx.game.data.text[textId]
|
||||
if not text and ctx.overworld then
|
||||
text = ctx.game.data:resolveText(ctx.overworld.map.def.label, textId)
|
||||
end
|
||||
-- BattleState takes finished text, so expand {PLAYER}/{RIVAL} here the
|
||||
-- way OverworldState:engageTrainer does for the sight/talk path
|
||||
ctx.endBattleText = TextBox.substitute(ctx.game, text or textId)
|
||||
end
|
||||
|
||||
-- start_battle "wild" species level | start_battle "trainer" OPP_CLASS partyIndex
|
||||
function Commands.start_battle(ctx, kind, a, b)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
@@ -270,6 +290,9 @@ function Commands.start_battle(ctx, kind, a, b)
|
||||
else
|
||||
battle = BattleState.newTrainer(ctx.game, a, b)
|
||||
end
|
||||
-- one SaveEndBattleTextPointers arms one battle; leaving it set would leak
|
||||
-- the line into the next scripted fight
|
||||
battle.endBattleText, ctx.endBattleText = ctx.endBattleText, nil
|
||||
battle.onFinish = function(result)
|
||||
ctx.lastBattleResult = result
|
||||
ctx.lastCheck = result == "win"
|
||||
@@ -1075,9 +1098,13 @@ function Commands.march_in_place(ctx, objIndex, on)
|
||||
end
|
||||
|
||||
-- play_music <songId> [opts]: switch map music now; opts.keep marks it
|
||||
-- to survive the next warp (the story files' keepMusic idiom)
|
||||
-- to survive the next warp (the story files' keepMusic idiom).
|
||||
-- opts.tempo is the Music_*AlternateTempo override (audio/alternate_tempo.asm
|
||||
-- re-points channel 1 at a stub that only changes the song's `tempo`) (#847).
|
||||
function Commands.play_music(ctx, songId, opts)
|
||||
require("src.core.Music").play(ctx.game.data, songId)
|
||||
local tempo = opts and opts.tempo
|
||||
require("src.core.Music").play(ctx.game.data, songId, nil,
|
||||
tempo and { tempo = tempo } or nil)
|
||||
if opts and opts.keep and ctx.overworld then
|
||||
ctx.overworld.keepMusicOnce = true
|
||||
end
|
||||
@@ -1087,6 +1114,15 @@ function Commands.stop_music(ctx)
|
||||
require("src.core.Music").stop()
|
||||
end
|
||||
|
||||
-- fade_music [control]: FadeOutAudio (home/fade_audio.asm) -- ramp the
|
||||
-- current song to silence over 7 * control frames and stop it, the way
|
||||
-- Music_Cities1AlternateTempo does before it restarts Cities1 (#847).
|
||||
-- Non-blocking, like the ROM's write to wAudioFadeOutControl: pair it with
|
||||
-- the `wait` that stands in for the following DelayFrames.
|
||||
function Commands.fade_music(ctx, control)
|
||||
require("src.core.Music").fadeOut(control or 10)
|
||||
end
|
||||
|
||||
-- play_default_music: PlayDefaultMusic -- resume the current map's own
|
||||
-- theme (data.audio.mapSongs) after a cutscene override, keeping the
|
||||
-- bike/surf substitution rules. Headless-safe no-op without an overworld.
|
||||
|
||||
+21
-11
@@ -255,17 +255,36 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
|
||||
if result == "consumed" then
|
||||
consume(game, id)
|
||||
-- refresh counts in the list
|
||||
for i, it in ipairs(list.items) do
|
||||
if it.value == id then
|
||||
local left = game.save.inventory[id]
|
||||
if left then it.right = "x" .. left else table.remove(list.items, i) end
|
||||
break
|
||||
end
|
||||
end
|
||||
list.index = math.min(list.index, math.max(1, #list.items))
|
||||
if extra and extra.evolveTo then
|
||||
list:close()
|
||||
local Evolution = require("src.pokemon.Evolution")
|
||||
Evolution.evolve(game, target, extra.evolveTo)
|
||||
-- item_effects.asm ItemUseEvoStone sets wForceEvolution before
|
||||
-- TryEvolvingMon, so a stone evolution's B press is read and
|
||||
-- discarded (EvolutionState.lua's cancelable check). via = "ITEM"
|
||||
-- is what makes that non-cancelable here, same as the RARE_CANDY
|
||||
-- call below; without it the stone (already consumed above) could
|
||||
-- be cancelled out from under the player (#883)
|
||||
Evolution.evolve(game, target, extra.evolveTo, nil, "ITEM")
|
||||
return
|
||||
end
|
||||
-- RARE CANDY: after the level text, the stat window, any level-up
|
||||
-- moves and a level evolution follow (item_effects.asm .useRareCandy
|
||||
-- runs PrintStatsBox, LearnMoveFromLevelUp and TryEvolvingMon)
|
||||
if extra and extra.leveledTo and target then
|
||||
list:close()
|
||||
-- ...but the bag stays open underneath it all: RARE_CANDY is in
|
||||
-- pokered's UsableItems_PartyMenu (data/items/use_party.asm), and
|
||||
-- .useItem_partyMenu jumps back to StartMenu_Item once UseItem
|
||||
-- returns, cursor still on the candy (start_sub_menus.asm) -- so
|
||||
-- mashing A burns through a stack of them (#796)
|
||||
showMessages(game, payload, function()
|
||||
local StatBox = require("src.battle.BattleState").StatBox
|
||||
game.stack:push(StatBox.new(game, target, function()
|
||||
@@ -304,15 +323,6 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
end)
|
||||
return
|
||||
end
|
||||
-- refresh counts in the list
|
||||
for i, it in ipairs(list.items) do
|
||||
if it.value == id then
|
||||
local left = game.save.inventory[id]
|
||||
if left then it.right = "x" .. left else table.remove(list.items, i) end
|
||||
break
|
||||
end
|
||||
end
|
||||
list.index = math.min(list.index, math.max(1, #list.items))
|
||||
-- HP medicine: fill the bar in the still-open picker first, then print
|
||||
-- and close, the order item_effects.asm .doneHealing runs in
|
||||
-- (SFX_HEAL_HP -> UpdateHPBar2 -> RedrawPartyMenu prints the message).
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
-- StarterDex (engine/events/starter_dex.asm), which temporarily sets the
|
||||
-- owned bit so Oak's lab ball previews show height/weight/description
|
||||
-- without permanently marking the mon owned.
|
||||
--
|
||||
-- `onDone` (optional) runs right after the page pops itself, the way a
|
||||
-- TextBox onDone does; map scripts use it to continue once the player
|
||||
-- closes the entry (the Fighting Dojo prize balls chain their take-it
|
||||
-- prompt off it).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
@@ -31,9 +36,10 @@ local function resolveArgs(speciesOrOpts)
|
||||
return speciesOrOpts, false
|
||||
end
|
||||
|
||||
function DexEntryMenu.new(game, speciesOrOpts)
|
||||
function DexEntryMenu.new(game, speciesOrOpts, onDone)
|
||||
local species, forceOwned = resolveArgs(speciesOrOpts)
|
||||
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
|
||||
local self = setmetatable({ game = game, forceOwned = forceOwned,
|
||||
onDone = onDone }, DexEntryMenu)
|
||||
self.def = game.data.pokemon[species]
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
game.data, species, "front", { kind = "dex" })
|
||||
@@ -52,6 +58,7 @@ function DexEntryMenu:update(dt)
|
||||
local input = self.game.input
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+99
-17
@@ -1,6 +1,7 @@
|
||||
-- Hall of Fame induction (engine/movie/hall_of_fame.asm): each party
|
||||
-- member's front sprite scrolls onto the right side of the screen
|
||||
-- (HoFShowMonOrPlayer's .ScrollPic), then HoFDisplayMonInfo draws the
|
||||
-- member's back sprite sweeps across the screen and its front sprite then
|
||||
-- scrolls onto the right side (HoFShowMonOrPlayer's two .ScrollPic passes,
|
||||
-- #847), then HoFDisplayMonInfo draws the
|
||||
-- left-side LEVEL/TYPE box, plays the cry, holds, and pops the bottom
|
||||
-- "HALL OF FAME" text box before fading to the next mon. After the
|
||||
-- party, the player pic scrolls in and HoFDisplayPlayerStats shows the
|
||||
@@ -41,6 +42,25 @@ end
|
||||
local SCROLL_SPEED = 4 -- px/frame @ 60fps
|
||||
local PIC_X, PIC_Y = 12 * 8, 5 * 8
|
||||
|
||||
-- The BACK pic pass that runs in front of every front pic (#847).
|
||||
-- HoFShowMonOrPlayer loads the back pic (predef LoadMonBackPic, or
|
||||
-- RedPicBack through HoFLoadPlayerPics) into the same 7x7 window at
|
||||
-- hlcoord 12,5, points the tilemap at base tile $31, and sweeps hSCX from
|
||||
-- $c0 to $a0 at e = 4 -- in screen pixels the pic enters at x = 160 and
|
||||
-- leaves at x = -64, 56 frames. Only then is the window re-pointed at the
|
||||
-- front pic (base tile 0) with hSCY back to 0 and e = -4, so the front pic
|
||||
-- starts at x = -64 rather than at its own width.
|
||||
-- hSCY = $d0 during the back pass puts the window's top row at y = 88, so
|
||||
-- its 7 tiles sit flush with the bottom of the screen. ScaleSpriteByTwo
|
||||
-- (engine/battle/scale_sprites.asm) doubles the 32x32 back sprite into that
|
||||
-- 7x7 = 56x56 buffer with the last 4 source rows and the last source column
|
||||
-- dropped, so what shows is the sprite's top-left 28x28 at 2x.
|
||||
local BACK_START_X, BACK_END_X = 160, -64
|
||||
local BACK_Y = 88
|
||||
local BACK_SCALE = 2
|
||||
local BACK_CROP = 28
|
||||
local FRONT_START_X = -64
|
||||
|
||||
-- After HoFDisplayAndRecordMonInfo: 80 DelayFrames, then the bottom
|
||||
-- HALL OF FAME box for 180 DelayFrames, then GBFadeOutToWhite.
|
||||
local INFO_HOLD = 80
|
||||
@@ -74,6 +94,8 @@ function HallOfFame.new(game, onDone)
|
||||
self.phase = "mons"
|
||||
self.sprites = {} -- species -> image or false
|
||||
self.spriteTrueColor = {} -- species -> full-color art flag (#637)
|
||||
self.backs = {} -- species (or "@player") -> back image or false (#847)
|
||||
self.backTrueColor = {}
|
||||
local playerPath, playerTrueColor =
|
||||
require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "hof" })
|
||||
@@ -98,20 +120,14 @@ function HallOfFame:nextMon()
|
||||
local mon = self.game.save.party[self.index]
|
||||
self.showHofBanner = false
|
||||
self.fade = 0
|
||||
if mon then
|
||||
self.phase = "mons"
|
||||
self.timer = INFO_HOLD
|
||||
Sound.playCry(self.game.data, mon.species)
|
||||
local sprite = self:spriteFor(mon.species)
|
||||
local w = sprite and sprite:getWidth() or 56
|
||||
self.scrollX = -w
|
||||
else
|
||||
-- HoFShowMonOrPlayer with wHoFMonOrPlayer = player
|
||||
self.phase = "player"
|
||||
self.timer = 0
|
||||
local w = self.playerPic and self.playerPic:getWidth() or 56
|
||||
self.scrollX = -w
|
||||
end
|
||||
self.backQuad = nil
|
||||
-- HoFShowMonOrPlayer runs the back pic sweep for a party member and for
|
||||
-- the player alike (wHoFMonOrPlayer only picks which pics get loaded), so
|
||||
-- both enter through the "back" phase and the front pic follows it (#847)
|
||||
self.phase = "back"
|
||||
self.afterBack = mon and "mons" or "player"
|
||||
self.timer = 0
|
||||
self.scrollX = BACK_START_X
|
||||
end
|
||||
|
||||
function HallOfFame:spriteFor(species)
|
||||
@@ -126,6 +142,30 @@ function HallOfFame:spriteFor(species)
|
||||
return cached or nil
|
||||
end
|
||||
|
||||
-- The back pic for the pass ahead of the current front pic: the party
|
||||
-- member's own back sprite (predef LoadMonBackPic), or RedPicBack once the
|
||||
-- party is done (HoFLoadPlayerPics). Cached like spriteFor (#847).
|
||||
function HallOfFame:backPicFor()
|
||||
local mon = self.game.save.party[self.index]
|
||||
local key = mon and mon.species or "@player"
|
||||
local cached = self.backs[key]
|
||||
if cached == nil then
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local path, trueColor
|
||||
if mon then
|
||||
path, trueColor = Sprites.path(self.game.data, mon.species, "back",
|
||||
{ kind = "hof" })
|
||||
else
|
||||
path, trueColor = Sprites.playerPath(self.game.data, "back",
|
||||
{ kind = "hof" })
|
||||
end
|
||||
cached = tryImage(path) or false
|
||||
self.backs[key] = cached
|
||||
self.backTrueColor[key] = cached and trueColor or false
|
||||
end
|
||||
return cached or nil, self.backTrueColor[key]
|
||||
end
|
||||
|
||||
-- HoFDisplayPlayerStats' DisplayDexRating tally (also
|
||||
-- OverworldController:dexRating / PokedexMenu.new's seen+owned counts)
|
||||
function HallOfFame:dexSeenOwned()
|
||||
@@ -154,9 +194,28 @@ function HallOfFame:update(dt)
|
||||
local input = self.game.input
|
||||
local skip = input:wasPressed("a")
|
||||
|
||||
-- .ScrollPic with d = $a0, e = 4: the back pic crosses the screen right to
|
||||
-- left and is gone before the front pic starts. Both scrolls are plain
|
||||
-- DelayFrame loops in the ROM, so neither takes a button (#847).
|
||||
if self.phase == "back" then
|
||||
self.scrollX = self.scrollX - SCROLL_SPEED
|
||||
if self.scrollX <= BACK_END_X then
|
||||
self.phase = self.afterBack
|
||||
self.timer = self.phase == "mons" and INFO_HOLD or 0
|
||||
self.scrollX = FRONT_START_X
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if self.phase == "mons" or self.phase == "fade" then
|
||||
if self.phase == "mons" and self.scrollX < PIC_X then
|
||||
self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED)
|
||||
-- PlayCry is the tail of HoFDisplayMonInfo, which runs only after
|
||||
-- HoFShowMonOrPlayer's two scrolls have both settled (#847)
|
||||
if self.scrollX >= PIC_X then
|
||||
local mon = self.game.save.party[self.index]
|
||||
if mon then Sound.playCry(self.game.data, mon.species) end
|
||||
end
|
||||
return
|
||||
end
|
||||
if self.phase == "fade" then
|
||||
@@ -277,6 +336,27 @@ function HallOfFame:drawPic(img, trueColor)
|
||||
end
|
||||
end
|
||||
|
||||
-- The back pic sweep (see the BACK_* constants): the same 7x7 window as the
|
||||
-- front pic, one screen lower, cropped to the 28x28 ScaleSpriteByTwo keeps.
|
||||
function HallOfFame:drawBackPic()
|
||||
local img, trueColor = self:backPicFor()
|
||||
if not img then return end
|
||||
local w = math.min(img:getWidth(), BACK_CROP)
|
||||
local h = math.min(img:getHeight(), BACK_CROP)
|
||||
if not self.backQuad then
|
||||
self.backQuad = love.graphics.newQuad(0, 0, w, h, img:getDimensions())
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, self.backQuad, self.scrollX, BACK_Y, 0,
|
||||
BACK_SCALE, BACK_SCALE)
|
||||
-- same whole-screen palette exemption the front pic takes (#637)
|
||||
if trueColor then
|
||||
require("src.render.PaletteFX").markTrueColor(self.scrollX, BACK_Y,
|
||||
w * BACK_SCALE,
|
||||
h * BACK_SCALE)
|
||||
end
|
||||
end
|
||||
|
||||
-- HoFDisplayPlayerStats boxes + labels (player pic already on the right)
|
||||
function HallOfFame:drawPlayerStats()
|
||||
local save = self.game.save
|
||||
@@ -301,7 +381,9 @@ function HallOfFame:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
|
||||
if self.phase == "mons" or self.phase == "fade" then
|
||||
if self.phase == "back" then
|
||||
self:drawBackPic()
|
||||
elseif self.phase == "mons" or self.phase == "fade" then
|
||||
local mon = self.game.save.party[self.index]
|
||||
if mon then
|
||||
self:drawPic(self:spriteFor(mon.species),
|
||||
|
||||
+14
-1
@@ -99,7 +99,20 @@ end
|
||||
function NamingScreen:confirm()
|
||||
local name = table.concat(self.glyphs)
|
||||
if name == "" then
|
||||
name = (self.presets and self.presets[1]) or self.default or "A"
|
||||
-- An empty confirm (START, or the ED cell with nothing typed) must not
|
||||
-- invent a letter (#833). DisplayNamingScreen seeds wStringBuffer with
|
||||
-- '@' (engine/menus/naming_screen.asm) and every caller checks that first
|
||||
-- byte: AskName falls through to .declinedNickname, copying the species
|
||||
-- name over the nick slot -- vanilla's "un-nicknamed", which this port
|
||||
-- models as mon.nickname == nil (src/save_convert/GenSave.lua), so
|
||||
-- evolution can still rename the mon. DisplayNameRaterScreen takes
|
||||
-- .playerCancelled and keeps the old nick, which is why an explicit
|
||||
-- opts.default still wins here. Player/rival naming
|
||||
-- (oak_speech2.asm ChoosePlayerName) re-opens on '@' and never accepts an
|
||||
-- empty result; the port keeps its preset fallback for that.
|
||||
-- Contract for callers: "" means NO name -- BattleState:askNicknameUI and
|
||||
-- Commands.give_pokemon both guard on #name > 0 before setting nickname.
|
||||
name = (self.presets and self.presets[1]) or self.default or ""
|
||||
end
|
||||
Sound.play(self.game.data, "Press_AB")
|
||||
self.game.stack:pop()
|
||||
|
||||
+26
-3
@@ -437,6 +437,25 @@ local function buildRows(game)
|
||||
require("src.core.TouchControls"):applyOptions(o)
|
||||
return true
|
||||
end },
|
||||
-- Haptic feedback for on-screen pad presses (#806): OFF / LIGHT /
|
||||
-- MEDIUM / HEAVY, where the intensity is a vibration duration --
|
||||
-- love.system.vibrate takes nothing else. Hidden with TOUCH PAD below,
|
||||
-- since the only thing that buzzes is a virtual button press.
|
||||
{ id = "haptics", label = Strings("VIBRATION"),
|
||||
value = function(g)
|
||||
local TC = require("src.core.TouchControls")
|
||||
return Strings(TC.hapticLabel(g.save.options.haptics))
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
local TC = require("src.core.TouchControls")
|
||||
o.haptics = TC.cycleHaptics(o.haptics, dir)
|
||||
TC:applyOptions(o)
|
||||
-- sample the level being selected: stepping the row is the only way
|
||||
-- to compare LIGHT against HEAVY without leaving the menu
|
||||
TC.buzz(o.haptics)
|
||||
return true
|
||||
end },
|
||||
}
|
||||
-- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks
|
||||
if not GBCFX.isSupported() then
|
||||
@@ -454,8 +473,10 @@ local function buildRows(game)
|
||||
end
|
||||
rows = filtered
|
||||
end
|
||||
-- TOUCH PAD only where the overlay can appear (mobile, or desktop with
|
||||
-- POKEPORT_TOUCH=1). POKEPORT_TOUCH=0 forces it off everywhere.
|
||||
-- TOUCH PAD and VIBRATION only where the overlay can appear (mobile, or
|
||||
-- desktop with POKEPORT_TOUCH=1). POKEPORT_TOUCH=0 forces it off
|
||||
-- everywhere. VIBRATION rides the same gate: nothing else in the port
|
||||
-- vibrates, and love.system.vibrate is a no-op on desktop anyway.
|
||||
do
|
||||
local env = os.getenv("POKEPORT_TOUCH")
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
@@ -464,7 +485,9 @@ local function buildRows(game)
|
||||
if not show then
|
||||
local filtered = {}
|
||||
for _, row in ipairs(rows) do
|
||||
if row.id ~= "touchControls" then filtered[#filtered + 1] = row end
|
||||
if row.id ~= "touchControls" and row.id ~= "haptics" then
|
||||
filtered[#filtered + 1] = row
|
||||
end
|
||||
end
|
||||
rows = filtered
|
||||
end
|
||||
|
||||
@@ -68,7 +68,14 @@ function TitleState:sgbPalettes(game)
|
||||
local top = game.stack and game.stack:top()
|
||||
local box = top and top.titleUiBox
|
||||
if box then
|
||||
z[#z + 1] = P.trueColorZone(box[1], box[2], box[3], box[4])
|
||||
-- A DMG-grays zone, not the trueColor opt-out: through the shade-remap
|
||||
-- shader GRAYS is the identity for the box's four shades, so SGB /
|
||||
-- ADVANCED / OG modes keep #133's white paper and black ink exactly,
|
||||
-- while effectiveColors still substitutes the mono and inverted display
|
||||
-- modes -- a trueColor rect skipped the shader entirely, leaving the
|
||||
-- main menu and CONTINUE info box a raw white hole over a CLASSIC
|
||||
-- pea-green title instead of matching it like the START menu does (#870).
|
||||
z[#z + 1] = P.zone(P.GRAYS, box[1], box[2], box[3], box[4])
|
||||
end
|
||||
return z[3] and z or nil
|
||||
end
|
||||
|
||||
+20
-2
@@ -67,8 +67,15 @@ function TrainerCard.new(game, opts)
|
||||
end
|
||||
end
|
||||
self.circle = tryImage("assets/generated/trainer_card/circle_tile.png")
|
||||
self.pic = tryImage(require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "trainer_card" }))
|
||||
|
||||
-- Capture both return values from playerPath: path and trueColor flag.
|
||||
-- The trueColor flag is set by the player.sprite hook when a mod injects
|
||||
-- a custom portrait that should bypass the MEWMON palette pipeline.
|
||||
local picPath, picTrueColor = require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "trainer_card" })
|
||||
self.pic = tryImage(picPath)
|
||||
self.picTrueColor = self.pic and picTrueColor or false
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -117,7 +124,18 @@ function TrainerCard:draw()
|
||||
-- top card (rows 0-7): NAME / MONEY / TIME, pic upper-right
|
||||
self:frameBox(0, 0, 20, 8)
|
||||
if self.pic then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(self.pic, 104, 4)
|
||||
-- True-colour portraits (e.g. mod-injected custom characters) carry their
|
||||
-- own colours and must not be re-mapped by the MEWMON zone shader.
|
||||
-- markTrueColor appends a colors=false zone that the Renderer splices at
|
||||
-- the end of the zone list, causing it to re-blit just this rect without
|
||||
-- the palette shader on top of the already-colourised frame.
|
||||
-- This matches the pattern used by OakSpeech, HallOfFame and SummaryMenu.
|
||||
if self.picTrueColor then
|
||||
local w, h = self.pic:getDimensions()
|
||||
require("src.render.PaletteFX").markTrueColor(104, 4, w, h)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("NAME/%s", save.player.name or "RED"), 16, 16)
|
||||
|
||||
@@ -234,6 +234,11 @@ local function launchDownload(url, partAbs, doneAbs)
|
||||
local batRel = "updates/dl.bat"
|
||||
love.filesystem.write(batRel,
|
||||
"@echo off\r\n"
|
||||
-- start /b hands the child our cwd, the install folder, and the
|
||||
-- detached cmd.exe held that folder un-movable for the rest of the
|
||||
-- transfer after the game exited (#727). Every path below is
|
||||
-- absolute, so park the child in its own directory (the save dir).
|
||||
.. "cd /d \"%~dp0\"\r\n"
|
||||
.. "curl -fsSL --connect-timeout 15 --max-time 900 -o \""
|
||||
.. partAbs .. "\" \"" .. url .. "\"\r\n"
|
||||
.. "type nul > \"" .. doneAbs .. "\"\r\n")
|
||||
@@ -271,6 +276,14 @@ local function doDownload()
|
||||
-- stalled or run-away transfer breaks out and lets verification fail cleanly
|
||||
local waited, lastSize, lastChange = 0, -1, 0
|
||||
while true do
|
||||
-- A queued quit means the window already closed. Bail so the join in
|
||||
-- Check.shutdown does not hold the dead window's process (and, on
|
||||
-- Windows, its folder) open for up to the whole transfer (#727). The
|
||||
-- quit stays on the channel for the command loop; the detached curl
|
||||
-- times out on its own and the next launch's doCheck verifies and
|
||||
-- re-offers whatever landed.
|
||||
local peeked = cmdCh:peek()
|
||||
if type(peeked) == "table" and peeked.cmd == "quit" then return end
|
||||
if love.filesystem.getInfo(doneRel) then break end
|
||||
local pinfo = love.filesystem.getInfo(partRel)
|
||||
local cur = (pinfo and pinfo.size) or 0
|
||||
|
||||
@@ -440,8 +440,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
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
|
||||
-- self.entities: it does not block movement, pikachu_follow.asm)
|
||||
require("src.world.PikachuFollower").onMapEntered(Game, self, opts)
|
||||
-- self.entities: it does not block movement, pikachu_follow.asm).
|
||||
-- true = fresh map entry: the follower spawns under the player and
|
||||
-- walks out of the warp, not beside him (#863)
|
||||
require("src.world.PikachuFollower").onMapEntered(Game, self, opts, true)
|
||||
|
||||
-- opts.keepMusic: the Oak-escort warp keeps MUSIC_MEET_PROF_OAK
|
||||
-- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7);
|
||||
@@ -1259,8 +1261,14 @@ end
|
||||
function OverworldState:checkBoulderPush(dir)
|
||||
local p = self.player
|
||||
local fx, fy = Collision.target(p.cellX, p.cellY, dir)
|
||||
local npc = self:npcAtCell(fx, fy)
|
||||
if not npc or not Map.isPushable(npc.def) or npc.moving then
|
||||
-- IsSpriteInFrontOfPlayer (home/overworld.asm) hands TryPushingBoulder
|
||||
-- the LOWEST sprite index standing on the faced cell, so in the original a
|
||||
-- second sprite parked on the boulder's cell hides the boulder from the
|
||||
-- push path for the rest of the map visit. Pick the pushable sprite out of
|
||||
-- the cell instead: a scripted walk-up that lands a trainer on the boulder
|
||||
-- must not brick it permanently (#809).
|
||||
local npc = self:pushableAtCell(fx, fy)
|
||||
if not npc or npc.moving then
|
||||
self.boulderTried = nil -- pokered resets when no boulder is in front
|
||||
return false
|
||||
end
|
||||
@@ -1720,6 +1728,22 @@ function OverworldState:npcAtCell(cx, cy)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The Strength boulder on a cell, ignoring anything else standing there.
|
||||
-- npcAtCell returns whichever object the map listed first, which is only
|
||||
-- well defined while at most one sprite occupies a cell; scripted walks
|
||||
-- (TrainerWalkUpToPlayer) can break that, and the push path must still find
|
||||
-- the boulder underneath (#809).
|
||||
function OverworldState:pushableAtCell(cx, cy)
|
||||
for _, npc in ipairs(self.npcs) do
|
||||
if ((npc.cellX == cx and npc.cellY == cy) or
|
||||
(npc.targetX == cx and npc.targetY == cy))
|
||||
and Map.isPushable(npc.def) then
|
||||
return npc
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- what the A press resolved to, for world.interacted's listeners
|
||||
local function interacted(self, fx, fy, kind, target)
|
||||
Runtime.emit("world.interacted", { mapId = self.map.id, x = fx, y = fy,
|
||||
@@ -1903,7 +1927,14 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
save.hiddenTaken = save.hiddenTaken or {}
|
||||
if save.hiddenTaken[key] then return false end
|
||||
if not require("src.inventory.Bag").add(save, h.item, 1, Game.data) then
|
||||
Game.stack:push(TextBox.new(Game, romText(Game.data, "_CantCarryMoreText", "You can't carry\nany more items!")))
|
||||
-- hidden_items.asm FoundHiddenItemText: the find is announced first,
|
||||
-- then GiveItem's .bagFull branch prints _HiddenItemBagFullText and
|
||||
-- leaves the spot unfound; _CantCarryMoreText is the Toss line (#872)
|
||||
local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
Strings("%s found\n%s!", save.player.name, name) .. "\f"
|
||||
.. romText(Game.data, "_HiddenItemBagFullText",
|
||||
"But, {PLAYER} has\nno more room for\vother items!")))
|
||||
return true
|
||||
end
|
||||
save.hiddenTaken[key] = true
|
||||
@@ -2362,8 +2393,21 @@ function OverworldState:trySurf(fx, fy, onClose)
|
||||
Game.stack:push(TextBox.new(Game, text, function()
|
||||
if onClose then onClose() end
|
||||
p.surfing = true
|
||||
-- walking / biking / surfing is ONE state byte in the original:
|
||||
-- ItemUseSurfboard (engine/items/item_effects.asm) writes 2 over
|
||||
-- whatever wWalkBikeSurfState held, so mounting a surf ends the bike
|
||||
-- outright -- no bike step cadence in Player:tryMove and no bike
|
||||
-- theme on the water (#846). Music.playMap re-picks the override
|
||||
-- with BOTH flags, which setSurfing alone cannot do: effectiveMapSong
|
||||
-- (src/core/Music.lua) prefers state.onBike over state.surfing.
|
||||
Game.save.onBike = false
|
||||
self:syncSurfingPikachu()
|
||||
require("src.core.Music").setSurfing(Game.data, true)
|
||||
local Music = require("src.core.Music")
|
||||
if self.map then
|
||||
Music.playMap(Game.data, self.map.id, false, true)
|
||||
else
|
||||
Music.setSurfing(Game.data, true) -- headless harness with no map loaded
|
||||
end
|
||||
Game.stack:push(require("src.render.Transition").whiteFlash(Game, nil,
|
||||
function() self:stepForwardOrCrossEdge(p.facing) end))
|
||||
end))
|
||||
@@ -2535,7 +2579,17 @@ function OverworldState:talkTo(npc)
|
||||
-- the string "0" as truthy, so screen it out and fall through to text.
|
||||
if d.item and d.item ~= "0" and d.item ~= 0 then
|
||||
if not require("src.inventory.Bag").add(Game.save, d.item, 1, Game.data) then
|
||||
Game.stack:push(TextBox.new(Game, romText(Game.data, "_CantCarryMoreText", "You can't carry\nany more items!")))
|
||||
-- pick_up_item.asm .BagFull prints _NoMoreRoomForItemText, not the
|
||||
-- Toss-screen _CantCarryMoreText; Yellow announces the find first,
|
||||
-- then the refusal (#872)
|
||||
local noRoom = romText(Game.data, "_NoMoreRoomForItemText",
|
||||
"No more room for\nitems!")
|
||||
if GameVersion.isYellow() then
|
||||
local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item
|
||||
noRoom = Strings("%s found\n%s!", Game.save.player.name, name)
|
||||
.. "\f" .. noRoom
|
||||
end
|
||||
Game.stack:push(TextBox.new(Game, noRoom))
|
||||
return
|
||||
end
|
||||
Game.save.itemsTaken = Game.save.itemsTaken or {}
|
||||
@@ -2949,7 +3003,12 @@ local function meetTrainerTheme(cls)
|
||||
end
|
||||
|
||||
-- Run the pre-battle text -> battle -> won text -> flags sequence.
|
||||
function OverworldState:engageTrainer(npc, onDone)
|
||||
-- skipBattleText is for map scripts shaped like SilphCo11FDefaultScript
|
||||
-- (scripts/SilphCo11F.asm), which DisplayTextID the challenge line BEFORE
|
||||
-- the approach walk and then EngageMapTrainer with no further text: the
|
||||
-- caller already showed the box, so the battle starts without a second
|
||||
-- one (#869).
|
||||
function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText)
|
||||
local d = npc.def
|
||||
Runtime.emit("world.trainer_engaged", { npc = npc, trainerClass = d.trainerClass,
|
||||
partyIndex = d.trainerParty })
|
||||
@@ -2959,10 +3018,18 @@ function OverworldState:engageTrainer(npc, onDone)
|
||||
battleText = select(1, Game.data:resolveText(self.map.def.label, d.text))
|
||||
or Strings("I like shorts!\nThey're comfy and\neasy to wear!")
|
||||
end
|
||||
local wonText = header and header.won and Game.data.text[header.won]
|
||||
-- `endBattleText` is a caller-supplied stand-in for header.won: the
|
||||
-- text_asm trainers that hand their loss line to the battle through
|
||||
-- SaveEndBattleTextPointers (scripts/GameCorner.asm GameCornerRocketText
|
||||
-- passes _GameCornerRocketBattleEndText, "Dang!") have no def_trainers
|
||||
-- header for the extractor to read, so their script passes the finished
|
||||
-- line here and it still lands where PrintEndBattleText puts it -- between
|
||||
-- TrainerDefeatedText and MoneyForWinningText, on the battle screen (#862).
|
||||
local wonText = endBattleText
|
||||
or (header and header.won and Game.data.text[header.won])
|
||||
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
Game.stack:push(TextBox.new(Game, battleText, function()
|
||||
local function startBattle()
|
||||
-- TalkToTrainer (home/trainers.asm:88) prints the before-battle text
|
||||
-- FIRST and only then runs `call EngageMapTrainer` / `jp
|
||||
-- StartTrainerBattle`, so a trainer challenged on foot gets the sting
|
||||
@@ -3005,7 +3072,28 @@ function OverworldState:engageTrainer(npc, onDone)
|
||||
end
|
||||
end
|
||||
self:pushBattle(battle)
|
||||
end))
|
||||
end
|
||||
if skipBattleText then
|
||||
startBattle()
|
||||
else
|
||||
Game.stack:push(TextBox.new(Game, battleText, startBattle))
|
||||
end
|
||||
end
|
||||
|
||||
-- Shared GiveItem step for the victory rewards (pokered home/give.asm):
|
||||
-- the item goes through the bag's capacity check, and only a successful
|
||||
-- add sets the reward's gotFlag (EVENT_GOT_TM*) and copies the item name
|
||||
-- into wStringBuffer for the "{RAM:wStringBuffer}" received texts.
|
||||
local function giveVictoryItem(reward)
|
||||
if not require("src.inventory.Bag").add(Game.save, reward.item, 1, Game.data) then
|
||||
return false
|
||||
end
|
||||
if reward.gotFlag then
|
||||
Game.save.flags[reward.gotFlag] = true
|
||||
end
|
||||
local idef = Game.data.items[reward.item]
|
||||
Game.stringBuffer = idef and idef.name or reward.item
|
||||
return true
|
||||
end
|
||||
|
||||
-- Badges/items awarded after specific battles (data/scripts/victories.lua).
|
||||
@@ -3036,12 +3124,13 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
|
||||
if reward.badge then
|
||||
Game.save.inventory[reward.badge] = 1
|
||||
end
|
||||
local tmGiven = false
|
||||
if reward.item then
|
||||
local inv = Game.save.inventory
|
||||
inv[reward.item] = (inv[reward.item] or 0) + 1
|
||||
local idef = Game.data.items[reward.item]
|
||||
-- GiveItem -> CopyToStringBuffer for "{RAM:wStringBuffer}" received texts
|
||||
Game.stringBuffer = idef and idef.name or reward.item
|
||||
-- pokered GiveItem (home/give.asm): AddItemToInventory first, and a
|
||||
-- full bag (jr nc, .BagFull) skips the received lines for the "make
|
||||
-- room" text, leaving EVENT_GOT_TM* unset so the leader's talk script
|
||||
-- retries the hand-over later (offerGymTm via gyms.lua)
|
||||
tmGiven = giveVictoryItem(reward)
|
||||
end
|
||||
local lines = {}
|
||||
if reward.dialogue then
|
||||
@@ -3051,13 +3140,29 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
|
||||
table.insert(lines, text[label])
|
||||
end
|
||||
end
|
||||
if reward.item then
|
||||
for _, label in ipairs(reward.tmPre or {}) do
|
||||
if text[label] and text[label] ~= "" then
|
||||
table.insert(lines, text[label])
|
||||
end
|
||||
end
|
||||
if tmGiven then
|
||||
for _, label in ipairs(reward.tmDialogue or {}) do
|
||||
if text[label] and text[label] ~= "" then
|
||||
table.insert(lines, text[label])
|
||||
end
|
||||
end
|
||||
elseif reward.noRoom and text[reward.noRoom] and text[reward.noRoom] ~= "" then
|
||||
table.insert(lines, text[reward.noRoom])
|
||||
end
|
||||
end
|
||||
elseif reward.badge or reward.item then
|
||||
if reward.badge then
|
||||
local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name
|
||||
or reward.badge
|
||||
table.insert(lines, Strings("%s received\nthe %s!", Game.save.player.name, name))
|
||||
end
|
||||
if reward.item then
|
||||
if tmGiven then
|
||||
local name = Game.stringBuffer or reward.item
|
||||
table.insert(lines, Strings("%s received\n%s!", Game.save.player.name, name))
|
||||
end
|
||||
@@ -3068,6 +3173,33 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
|
||||
self:runVictoryHook()
|
||||
end
|
||||
|
||||
-- A beaten leader re-running their ReceiveTM script when the bag was full
|
||||
-- at the victory (pokered's middle branch, e.g. PewterGymBrockText
|
||||
-- CheckEventReuseA EVENT_GOT_TM34 -> call PewterGymScriptReceiveTM34).
|
||||
-- The script's lead-in lines (tmPre: badge info / "Wait! Take this!")
|
||||
-- show again, then the same GiveItem check decides between the received
|
||||
-- lines and the "make room" text.
|
||||
function OverworldState:offerGymTm(reward, done)
|
||||
local text = Game.data.text or {}
|
||||
local lines = {}
|
||||
local function addLine(label)
|
||||
if label and text[label] and text[label] ~= "" then
|
||||
table.insert(lines, text[label])
|
||||
end
|
||||
end
|
||||
for _, label in ipairs(reward.tmPre or {}) do addLine(label) end
|
||||
if giveVictoryItem(reward) then
|
||||
for _, label in ipairs(reward.tmDialogue or {}) do addLine(label) end
|
||||
else
|
||||
addLine(reward.noRoom)
|
||||
end
|
||||
if #lines > 0 then
|
||||
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f"), done))
|
||||
elseif done then
|
||||
done()
|
||||
end
|
||||
end
|
||||
|
||||
-- pokered reloads the map after every battle, re-running the map
|
||||
-- script (e.g. LoreleiShowOrHideExitBlock); this hook is the port's
|
||||
-- equivalent so seals/toggles refresh without leaving the map
|
||||
@@ -3168,8 +3300,26 @@ function OverworldState:startTrainerApproach(npc, dist)
|
||||
self.emote = {
|
||||
npc = npc, frames = 60,
|
||||
onDone = function()
|
||||
if dist > 1 then
|
||||
self:scriptMove(npc, npc.facing, dist - 1, fight)
|
||||
-- TrainerWalkUpToPlayer (engine/overworld/trainer_sight.asm) writes
|
||||
-- dist-1 NPC_MOVEMENT_* bytes and hands them to MoveSprite, and every
|
||||
-- scripted step skips collision entirely (CanWalkOntoTile,
|
||||
-- engine/overworld/movement.asm: "always allow walking if the
|
||||
-- movement is scripted"), so the original marches the trainer straight
|
||||
-- through a Strength boulder sitting on the sight line. Stop one cell
|
||||
-- short of the boulder instead: two sprites on one cell is a state the
|
||||
-- push path cannot represent, and the walk-up is the one scripted move
|
||||
-- the player can steer a boulder into (#809).
|
||||
local steps = dist - 1
|
||||
local cx, cy = npc.cellX, npc.cellY
|
||||
for i = 1, steps do
|
||||
cx, cy = Collision.target(cx, cy, npc.facing)
|
||||
if self:pushableAtCell(cx, cy) then
|
||||
steps = i - 1
|
||||
break
|
||||
end
|
||||
end
|
||||
if steps > 0 then
|
||||
self:scriptMove(npc, npc.facing, steps, fight)
|
||||
else
|
||||
fight()
|
||||
end
|
||||
@@ -3620,9 +3770,13 @@ function OverworldState:checkForcedMovement()
|
||||
return true
|
||||
end
|
||||
elseif tile.mode == "surf" then
|
||||
-- scripts/SeafoamIslandsB4F.asm writes wWalkBikeSurfState = 2 and
|
||||
-- jp ForceBikeOrSurf, so a forced surf clears the bike state the
|
||||
-- same way the party-menu mount does (#846)
|
||||
p.surfing = true
|
||||
Game.save.onBike = false
|
||||
self:syncSurfingPikachu()
|
||||
require("src.core.Music").setSurfing(Game.data, true)
|
||||
require("src.core.Music").playMap(Game.data, self.map.id, false, true)
|
||||
end
|
||||
return false
|
||||
end
|
||||
@@ -3913,25 +4067,47 @@ function OverworldState:warpToHealPoint(onDone, opts)
|
||||
-- Dig/Teleport/Escape Rope land OUTSIDE at the last Pokemon Center TOWN
|
||||
-- door, like Fly (#196) -- NOT the interior heal cell a blackout returns
|
||||
-- to. pret routes escape-warp and blackout both through wLastBlackoutMap
|
||||
-- (both appear inside in front of the nurse), but this port has decided
|
||||
-- the escape-warp destination is the town PC door. Prefer the canonical
|
||||
-- Fly landing (field.flyWarps, one tile south of the PC door warp), else
|
||||
-- the remembered outdoor door cell; fall back to the interior heal cell
|
||||
-- only for an old save with no recorded outdoor.
|
||||
local out = heal.outdoor
|
||||
if out then
|
||||
local fw = (Game.data.field.flyWarps or {})[out.id]
|
||||
map = out.id
|
||||
x = fw and fw.x or out.x
|
||||
y = fw and fw.y or out.y
|
||||
-- (LoadSpecialWarpData .usedFlyWarp, engine/overworld/special_warps.asm),
|
||||
-- and that map is ALWAYS an outdoor one: SetLastBlackoutMap copies
|
||||
-- wLastMap (engine/events/set_blackout_map.asm) and WarpFound2 only
|
||||
-- writes wLastMap on outside maps (home/overworld.asm), with the landing
|
||||
-- cell read from FlyWarpDataPtr. Prefer the canonical Fly landing
|
||||
-- (field.flyWarps, one tile south of the PC door warp), else the
|
||||
-- remembered outdoor door cell.
|
||||
--
|
||||
-- A heal record naming no outdoor town, or naming a map that is not
|
||||
-- outdoors at all, is never a legal escape-warp destination: a .sav
|
||||
-- import stamps lastHeal from wherever the cartridge was saved
|
||||
-- (SaveConvert mergeDefaults), so ESCAPE ROPE was dropping the player
|
||||
-- into the dungeon that save sat in, whose LAST_MAP exits then still
|
||||
-- pointed at the door they had walked in through (#805). Vanilla's
|
||||
-- zero-filled wLastBlackoutMap is map 0, so an unusable record falls
|
||||
-- back to the boot heal town exactly as a never-healed game does.
|
||||
local out = heal.outdoor or { id = heal.map, x = heal.x, y = heal.y }
|
||||
local fw = (Game.data.field.flyWarps or {})[out.id]
|
||||
local outX = fw and fw.x or out.x
|
||||
local outY = fw and fw.y or out.y
|
||||
local outDef = Game.data.maps[out.id]
|
||||
if not (outDef and outX and outY
|
||||
and Map.isOutside(outDef,
|
||||
FieldDefaults.field(Game.data, "outsideTilesets"))) then
|
||||
local zeroFill = require("src.core.SaveData")
|
||||
.defaultHeal(Game.data.field.boot)
|
||||
out, outX, outY = { id = zeroFill.map }, zeroFill.x, zeroFill.y
|
||||
end
|
||||
map, x, y = out.id, outX, outY
|
||||
end
|
||||
self:startWarpTo(map, x, y, "down", onDone)
|
||||
-- Blackouts land at the interior heal cell, so re-point LAST_MAP exits at
|
||||
-- the remembered town door. The teleport branch already lands ON that
|
||||
-- outdoor map, so startWarpTo remembers it on the next exit; re-pointing
|
||||
-- here would wrongly steer exits away from where the player now stands.
|
||||
if heal.outdoor and not teleport then
|
||||
-- the remembered town door. The teleport branch re-points at the town it
|
||||
-- just landed on: PrepareForSpecialWarp (engine/overworld/special_warps.asm)
|
||||
-- writes the special-warp destination straight back into wLastMap for every
|
||||
-- fly/escape warp that is not a dungeon warp, so the next LAST_MAP exit
|
||||
-- resolves against that town instead of the dungeon door the player walked
|
||||
-- in through before using the rope (#805).
|
||||
if teleport then
|
||||
self:rememberOutdoor(map, x, y)
|
||||
elseif heal.outdoor then
|
||||
self:rememberOutdoor(heal.outdoor.id, heal.outdoor.x, heal.outdoor.y)
|
||||
end
|
||||
end
|
||||
@@ -4323,7 +4499,13 @@ function OverworldState:drawWorld()
|
||||
-- ghost NPCs on neighbor maps, y-sorted among themselves
|
||||
table.sort(self.ghosts,
|
||||
function(a, b) return a.npc.py + a.oy < b.npc.py + b.oy end)
|
||||
table.sort(self.entities, function(a, b) return a.py < b.py end)
|
||||
table.sort(self.entities, function(a, b)
|
||||
if a.py ~= b.py then return a.py < b.py end
|
||||
-- a fresh warp spawn parks the follower on the player's own cell
|
||||
-- until it trails out; the tie must draw it under him, never on
|
||||
-- top (#863)
|
||||
return a.pikachuFollower == true and b.pikachuFollower ~= true
|
||||
end)
|
||||
|
||||
-- === shared FX draw bodies ==========================================
|
||||
-- Each draws at flat world-canvas offsets; the tilt path wraps the
|
||||
|
||||
@@ -188,7 +188,7 @@ function PikachuFollower.current(ow)
|
||||
return npc
|
||||
end
|
||||
|
||||
function PikachuFollower.onMapEntered(game, ow, opts)
|
||||
function PikachuFollower.onMapEntered(game, ow, opts, viaMapLoad)
|
||||
-- Bill's House owns a short scripted scene that deliberately keeps
|
||||
-- Pikachu off the normal trailing loop. A new map instance ends it.
|
||||
ow.pikachuBillsScene = nil
|
||||
@@ -200,7 +200,8 @@ function PikachuFollower.onMapEntered(game, ow, opts)
|
||||
-- takes .normal_spawn_state -- map coords rebased, sprite data and
|
||||
-- follow command buffer left alone. Re-list the same instance and let
|
||||
-- rebase() shift its cell; a warp arrives without it and respawns
|
||||
-- behind the player, the full spawn path of that same routine.
|
||||
-- under the player, the full spawn path of that same routine (the
|
||||
-- viaMapLoad spawn below, #863).
|
||||
local keep = opts and opts.keepPikachu
|
||||
if keep then
|
||||
table.insert(ow.npcs, keep)
|
||||
@@ -208,6 +209,12 @@ function PikachuFollower.onMapEntered(game, ow, opts)
|
||||
return
|
||||
end
|
||||
local x, y = spawnCell(ow)
|
||||
-- a fresh map entry (warp, boot) parks the follower ON the player's
|
||||
-- cell instead: it stays hidden under him (the draw-sort tie-break in
|
||||
-- OverworldController) and walks out of the warp behind him as the
|
||||
-- trail opens up. Mid-map respawns (bike dismount, revive) keep the
|
||||
-- behind-the-facing cell (#863)
|
||||
if viaMapLoad then x, y = ow.player.cellX, ow.player.cellY end
|
||||
local npc = makeFollower(game, ow, x, y, ow.player.facing)
|
||||
table.insert(ow.npcs, npc)
|
||||
-- entities is the draw list; passable keeps it out of collision
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
-- Manual check of both bag-full pickup refusals (#872): an item ball must
|
||||
-- refuse with _NoMoreRoomForItemText (pokered scripts/pick_up_item.asm
|
||||
-- .BagFull), never the Toss-screen _CantCarryMoreText, and a hidden item
|
||||
-- announces the find first, then _HiddenItemBagFullText (hidden_items.asm).
|
||||
-- Run without POKEPORT_SPEED -- the box paging under test is timing-honest.
|
||||
-- POKEPORT_DRIVER=tests/drivers/bag_full_pickup_bug872_test.lua POKEPORT_IDENTITY=bug872 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local MAP = "VIRIDIAN_FOREST"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- neither refusal plays a jingle, but the human will want the normal
|
||||
-- pickup sound as a contrast, so warn when it would be muted
|
||||
local opts = game.save.options or {}
|
||||
if (opts.sfxVol or 7) == 0 then
|
||||
U.log("note: sfxVol is 0, so a successful pickup afterwards will be",
|
||||
"silent; the refusal boxes themselves are unaffected")
|
||||
end
|
||||
|
||||
-- a real fresh game, so the save has a player name and clean flags
|
||||
U.newGame(game)
|
||||
local save = game.save
|
||||
|
||||
-- Empty the bag, then refill to exactly capacity with ids that are NOT
|
||||
-- POTION: Bag.add succeeds by quantity for an id already in a slot
|
||||
-- (src/inventory/Bag.lua), which would mask the bug, and both test
|
||||
-- targets below hand out POTION. Badges share save.inventory but are
|
||||
-- not bag slots, so they are left alone.
|
||||
for _, id in ipairs({ unpack(Bag.order(save)) }) do
|
||||
Bag.remove(save, id, save.inventory[id] or 1)
|
||||
end
|
||||
local ids = {}
|
||||
for id in pairs(game.data.items) do
|
||||
if not Bag.isBadge(id) and id ~= "POTION" then ids[#ids + 1] = id end
|
||||
end
|
||||
table.sort(ids)
|
||||
for _, id in ipairs(ids) do
|
||||
if Bag.slots(save) >= Bag.capacity(game.data) then break end
|
||||
Bag.add(save, id, 1, game.data)
|
||||
end
|
||||
check(("bag is full (%d/%d slots) and holds no POTION")
|
||||
:format(Bag.slots(save), Bag.capacity(game.data)),
|
||||
Bag.slots(save) >= Bag.capacity(game.data)
|
||||
and not save.inventory.POTION)
|
||||
|
||||
-- first target: the Potion item ball. pokered
|
||||
-- data/maps/objects/ViridianForest.asm:36 puts it at walk cell (12, 29)
|
||||
-- (object_event 12, 29, SPRITE_POKE_BALL ... POTION), free floor below.
|
||||
local BALL = { x = 12, y = 29 }
|
||||
U.teleport(game, MAP, BALL.x, BALL.y + 1, "left")
|
||||
U.wait(10)
|
||||
|
||||
local function isBall(n)
|
||||
return n and n.def and n.def.item and n.def.item ~= "0" and n.def.item ~= 0
|
||||
end
|
||||
local ow = game.overworld
|
||||
local ball = ow:npcAtCell(BALL.x, BALL.y)
|
||||
if not isBall(ball) then
|
||||
-- a map edit or mod moved the object: take any item ball on the map
|
||||
-- and stand on a free walkable neighbour instead
|
||||
ball = nil
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if isBall(n) then ball = n break end
|
||||
end
|
||||
if ball then
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = ball.cellX + s[1], ball.cellY + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("ball not at (%d, %d); using the one at (%d, %d)")
|
||||
:format(BALL.x, BALL.y, ball.cellX, ball.cellY))
|
||||
-- teleport facing away, the tap below still does the turn
|
||||
U.teleport(game, MAP, cx, cy, s[3] == "left" and "right" or "left")
|
||||
U.wait(10)
|
||||
BALL.x, BALL.y = ball.cellX, ball.cellY
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
check("an item ball is loaded on " .. MAP, ball ~= nil)
|
||||
local ballItem = ball and ball.def.item or "POTION"
|
||||
|
||||
-- turn toward the ball ourselves (a one-frame press only turns when the
|
||||
-- player faces elsewhere, src/world/Player.lua), then trigger the talk
|
||||
ow = game.overworld
|
||||
local dx, dy = BALL.x - ow.player.cellX, BALL.y - ow.player.cellY
|
||||
local dir = (dy < 0 and "up") or (dy > 0 and "down")
|
||||
or (dx < 0 and "left") or "right"
|
||||
U.tap(game, dir)
|
||||
U.wait(10)
|
||||
local fx, fy = game.overworld.player:facingCell()
|
||||
check("player turned to face the ball",
|
||||
game.overworld:npcAtCell(fx, fy) == ball)
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
|
||||
local function readPages()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= TextBox then return nil end
|
||||
local pages = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
pages[#pages + 1] = table.concat(page, " / ")
|
||||
end
|
||||
return pages
|
||||
end
|
||||
local function closeBox()
|
||||
for _ = 1, 8 do
|
||||
if getmetatable(game.stack:top()) ~= TextBox then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(25)
|
||||
end
|
||||
end
|
||||
|
||||
local pages = readPages()
|
||||
check("A on the full-bag ball opened a text box", pages ~= nil)
|
||||
if pages then
|
||||
local all = table.concat(pages, " || ")
|
||||
U.log("ball refusal reads:", all)
|
||||
check("it is the pickup refusal, not the Toss line",
|
||||
all:find("No more room", 1, true) ~= nil
|
||||
and all:find("can't carry", 1, true) == nil)
|
||||
if GameVersion.isYellow() then
|
||||
check("Yellow announces the find, then refuses on page 2",
|
||||
#pages == 2
|
||||
and pages[1]:find(ballItem, 1, true) ~= nil
|
||||
and pages[2]:find("No more room", 1, true) ~= nil)
|
||||
else
|
||||
check("Red/Blue refuse in one page with no found line",
|
||||
#pages == 1 and pages[1]:find("found", 1, true) == nil)
|
||||
end
|
||||
U.shot(game, SHOT_DIR .. "/bug872_ball_refusal.png")
|
||||
end
|
||||
closeBox()
|
||||
|
||||
-- the refusal must leave the world untouched so the pickup can be
|
||||
-- retried after tossing something
|
||||
check("the ball is still standing there",
|
||||
game.overworld:npcAtCell(BALL.x, BALL.y) == ball)
|
||||
check("itemsTaken was not marked",
|
||||
not (save.itemsTaken and ball and save.itemsTaken[ball.id]))
|
||||
check("the item stayed out of the bag", not save.inventory[ballItem])
|
||||
|
||||
-- second target: the hidden POTION. pokered
|
||||
-- data/events/hidden_item_coords.asm:8 puts it at (x=1, y=18) on
|
||||
-- VIRIDIAN_FOREST; Game.data.field.hiddenItems carries the same spot.
|
||||
local hidden
|
||||
local list = (game.data.field.hiddenItems or {})[MAP] or {}
|
||||
for _, h in ipairs(list) do
|
||||
if h.x == 1 and h.y == 18 then hidden = h break end
|
||||
end
|
||||
hidden = hidden or list[1]
|
||||
check("a hidden item exists on " .. MAP, hidden ~= nil)
|
||||
|
||||
local stood = false
|
||||
if hidden then
|
||||
-- {dx, dy, facing} from the hidden cell to a stand cell that looks
|
||||
-- back at it; the spot itself is usually an unwalkable tree tile
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = hidden.x + s[1], hidden.y + s[2]
|
||||
local m = game.overworld.map
|
||||
if m:isWalkableCell(cx, cy) and not game.overworld:npcAtCell(cx, cy) then
|
||||
U.teleport(game, MAP, cx, cy, s[3] == "left" and "right" or "left")
|
||||
U.wait(10)
|
||||
U.tap(game, s[3])
|
||||
U.wait(10)
|
||||
local hfx, hfy = game.overworld.player:facingCell()
|
||||
if hfx == hidden.x and hfy == hidden.y then stood = true break end
|
||||
end
|
||||
end
|
||||
end
|
||||
check("standing against the hidden spot", stood)
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
pages = readPages()
|
||||
check("A on the full-bag hidden spot opened a text box", pages ~= nil)
|
||||
if pages then
|
||||
local all = table.concat(pages, " || ")
|
||||
U.log("hidden refusal reads:", all)
|
||||
check("the found line comes first",
|
||||
#pages == 2 and pages[1]:find("found", 1, true) ~= nil
|
||||
and (not hidden or pages[1]:find(
|
||||
(game.data.items[hidden.item] or {}).name or hidden.item,
|
||||
1, true) ~= nil))
|
||||
check("then the hidden-item bag-full line, not the Toss line",
|
||||
#pages == 2
|
||||
and pages[2]:find("no more room", 1, true) ~= nil
|
||||
and pages[2]:find("other items", 1, true) ~= nil
|
||||
and all:find("can't carry", 1, true) == nil)
|
||||
U.shot(game, SHOT_DIR .. "/bug872_hidden_refusal.png")
|
||||
end
|
||||
closeBox()
|
||||
|
||||
local key = hidden and (MAP .. "_" .. hidden.x .. "_" .. hidden.y)
|
||||
check("the hidden spot can still be prompted again",
|
||||
not (key and save.hiddenTaken and save.hiddenTaken[key]))
|
||||
check("the hidden item stayed out of the bag",
|
||||
not (hidden and save.inventory[hidden.item]))
|
||||
|
||||
U.log("You are still facing the hidden spot with a full bag; pressing A")
|
||||
U.log("should say the item was found, then that there is no more room for")
|
||||
U.log("other items, and never the bag screen's \"can't carry\" wording.")
|
||||
U.log("Toss something and both spots should hand their item over normally.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,216 @@
|
||||
-- Eye check: a YES/NO box over a classic battle wears the same paper as the
|
||||
-- field behind it (#822). pokered data/sgb/sgb_packets.asm BlkPacket_Battle
|
||||
-- attributes all 18 rows, and home/yes_no.asm InitYesNoTextBoxParameters puts
|
||||
-- the box at hlcoord 14,7 -- inside the player-HP-bar region, pal 0.
|
||||
-- POKEPORT_DRIVER=tests/drivers/battle_choice_paper_bug822_test.lua POKEPORT_IDENTITY=bug822 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
-- No POKEPORT_SPEED: it scales the logic clock only, and these frames are judged as drawn.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
-- pokered data/maps/objects/Route1.asm puts the two youngsters at (5,24) and
|
||||
-- (15,13) and the sign at (9,27), so the top of the road is empty grass; the
|
||||
-- battle is pushed rather than walked into, the cell is only somewhere to stand.
|
||||
local MAP = "ROUTE_1"
|
||||
local STAND = { x = 5, y = 6, facing = "down" }
|
||||
local PARTY = { { "BULBASAUR", 12 }, { "PIDGEOTTO", 18 } }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- machine-checkable preconditions ------------------------------------
|
||||
local opts = game.save.options or {}
|
||||
local sfxVol = opts.sfxVol or 7
|
||||
if sfxVol == 0 then
|
||||
U.log("FAIL SFX volume is 0, so the box's A/B click is gone and there is no")
|
||||
U.log(" way to tell a dead box from a live one you cannot hear. Set SFX to 7.")
|
||||
end
|
||||
check(("SFX volume %d, so the YES/NO box clicks when you answer it"):format(sfxVol),
|
||||
sfxVol > 0)
|
||||
check("the shade-remap shader compiled (no shader, no colorization at all)",
|
||||
PaletteFX.shader() ~= nil)
|
||||
check("PaletteFX.sendShades exists -- the raw sender the #822 fix needs",
|
||||
type(PaletteFX.sendShades) == "function")
|
||||
check("all 7 COLORS modes are on the ladder ("
|
||||
.. table.concat(PaletteFX.MODES, ", ") .. ")", #PaletteFX.MODES == 7)
|
||||
local cl = PaletteFX.CLASSIC
|
||||
check(("CLASSIC paper is the light pea green %d,%d,%d and its second shade"
|
||||
.. " the darker %d,%d,%d -- the pair #822 confused")
|
||||
:format(cl[1][1], cl[1][2], cl[1][3], cl[2][1], cl[2][2], cl[2][3]),
|
||||
cl[1][1] == 155 and cl[1][2] == 188 and cl[2][1] == 139)
|
||||
check("OG's GRAYS start at pure white, so OG is the shader identity",
|
||||
PaletteFX.GRAYS[1][1] == 255)
|
||||
local box = Theme.choiceBox
|
||||
check(("the YES/NO box sits at tile %d,%d (%dx%d) -- InitYesNoTextBoxParameters'"
|
||||
.. " hlcoord 14,7"):format(box.tx, box.ty, box.tw, box.th),
|
||||
box.tx == 14 and box.ty == 7)
|
||||
|
||||
-- ---- get into a battle with the box up ----------------------------------
|
||||
game.save.party = {}
|
||||
for _, slot in ipairs(PARTY) do
|
||||
table.insert(game.save.party, Pokemon.new(game.data, slot[1], slot[2]))
|
||||
end
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit moved the road: any free neighbour will do, nothing here
|
||||
-- depends on the cell beyond having somewhere legal to stand
|
||||
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
|
||||
local cx, cy = STAND.x + d[1], STAND.y + d[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, STAND.facing)
|
||||
U.wait(20)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP)
|
||||
|
||||
local battle = BattleState.newWild(game, "RATTATA", 5)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
for _ = 1, 400 do
|
||||
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 120 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
end
|
||||
check("the battle reached its FIGHT/PKMN/ITEM/RUN menu", battle.phase == "menu")
|
||||
|
||||
-- The same bare box BattleState pushes for the switch offer (BattleState.lua
|
||||
-- :1291): no anchor, so it sits over the battle rather than riding a dialogue
|
||||
-- box. Pushed directly because the reporter's screen is the box on top of a
|
||||
-- live battle, and how it got there changes nothing about the colorization.
|
||||
local choice = ChoiceBox.new(game, function() end)
|
||||
game.stack:push(choice)
|
||||
U.wait(10)
|
||||
check("a YES/NO box is on top of the battle", game.stack:top() == choice)
|
||||
|
||||
-- ---- measure both papers, mode by mode ----------------------------------
|
||||
-- Replays what Game:draw does for a classic battle with an overlay above it:
|
||||
-- every state paints onto the one 160x144 UI canvas, then the topmost state
|
||||
-- with sgbPalettes owns the screen. BattleState:sgbPalettes returns nil for
|
||||
-- the classic layout, so PaletteFX.ensureZones decides whether a whole-screen
|
||||
-- shade pass runs at all -- it does in OG / OG INV / CLASSIC, and does not in
|
||||
-- the colorized modes. Offscreen so the sample boxes stay in clean 160x144
|
||||
-- space whatever the window is doing; the U.shot next to each reading is the
|
||||
-- same frame as presented.
|
||||
local g = love.graphics
|
||||
local shader = PaletteFX.shader()
|
||||
|
||||
-- The empty pocket the SGB attribute map leaves at tiles 9,4 - 10,6: below
|
||||
-- the enemy HP bar (rows 0-3), right of the player mon (cols 0-8), left of
|
||||
-- the enemy mon (col 11 on). Nothing is ever drawn here, so it is the field
|
||||
-- paper and nothing else.
|
||||
local FIELD = { 74, 36, 85, 52 }
|
||||
-- Inside the YES/NO box, clear of its border tiles. The glyphs and cursor
|
||||
-- live in here too, which is why the reading is the most common color rather
|
||||
-- than one pixel.
|
||||
local BOXI = { 120, 64, 151, 87 }
|
||||
|
||||
local function modal(id, r)
|
||||
local counts, best, bestN = {}, nil, -1
|
||||
for y = r[2], r[4] do
|
||||
for x = r[1], r[3] do
|
||||
local pr, pg, pb = id:getPixel(x, y)
|
||||
local key = math.floor(pr * 255 + 0.5) .. "," .. math.floor(pg * 255 + 0.5)
|
||||
.. "," .. math.floor(pb * 255 + 0.5)
|
||||
counts[key] = (counts[key] or 0) + 1
|
||||
if counts[key] > bestN then best, bestN = key, counts[key] end
|
||||
end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
local function sample()
|
||||
local prev = g.getCanvas()
|
||||
local a = g.newCanvas(160, 144)
|
||||
local b = g.newCanvas(160, 144)
|
||||
g.setCanvas(a)
|
||||
g.clear(1, 1, 1, 1) -- the battle letterbox is white (letterboxWhite)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
battle:draw()
|
||||
choice:draw()
|
||||
g.setCanvas(b)
|
||||
g.clear(0, 0, 0, 1)
|
||||
local zones = PaletteFX.ensureZones(nil)
|
||||
if zones and zones[1] then
|
||||
g.setShader(shader)
|
||||
PaletteFX.sendColors(shader, PaletteFX.GRAYS)
|
||||
end
|
||||
g.setColor(1, 1, 1, 1)
|
||||
g.draw(a, 0, 0)
|
||||
g.setShader()
|
||||
g.setCanvas(prev)
|
||||
local id = b:newImageData()
|
||||
return modal(id, FIELD), modal(id, BOXI), zones ~= nil and zones[1] ~= nil
|
||||
end
|
||||
|
||||
local mismatched = {}
|
||||
for _, m in ipairs(PaletteFX.MODES) do
|
||||
-- set the SAVED option too: Game:applyOptions re-reads save.options.colors,
|
||||
-- so a bare setMode gets reverted underneath the next frame
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.colors = m
|
||||
PaletteFX.setMode(m)
|
||||
U.wait(20)
|
||||
local field, boxp, framePass = sample()
|
||||
local label = PaletteFX.modeLabel(m)
|
||||
local same = field == boxp
|
||||
local known = (m == "gbc" or m == "gbc_inv")
|
||||
U.log(("%-9s field %-13s box %-13s %s"):format(
|
||||
label, field, boxp,
|
||||
framePass and "whole-screen pass" or "no whole-screen pass"))
|
||||
if m == "classic" then
|
||||
check("CLASSIC field is the light pea green 155,188,15, not the darker"
|
||||
.. " 139,172,15 one bucket down", field == "155,188,15")
|
||||
end
|
||||
if known then
|
||||
-- the other half of #822, left open on purpose: with no frame-level pass
|
||||
-- the overlay paints raw DMG white onto a canvas the battle has already
|
||||
-- colorized, and nothing local to drawZonePass can reach it
|
||||
U.log((" %s draws its overlays raw, so a mismatch here is the known"
|
||||
.. " open half, not this fix failing"):format(label))
|
||||
else
|
||||
if not same then mismatched[#mismatched + 1] = label end
|
||||
check(label .. " box paper matches the field behind it", same)
|
||||
end
|
||||
U.shot(game, DIR .. "/bug822_" .. m .. ".png")
|
||||
end
|
||||
check("no forced-mono or ADVANCED/OG RED mode left the box a different color"
|
||||
.. " from the field", #mismatched == 0)
|
||||
if #mismatched > 0 then
|
||||
U.log(" mismatched on:", table.concat(mismatched, ", "))
|
||||
end
|
||||
|
||||
-- ---- over to you --------------------------------------------------------
|
||||
PaletteFX.setMode("classic")
|
||||
game.save.options.colors = "classic"
|
||||
U.wait(20)
|
||||
U.log("You are looking at a YES/NO box sitting on a wild RATTATA battle in")
|
||||
U.log("CLASSIC. The paper inside the box and the empty field around the mons")
|
||||
U.log("should be the one same pea green, with no seam where the box begins;")
|
||||
U.log("press 2 through the ladder and OG INV should go black-on-black the same")
|
||||
U.log("way, while OG, OG RED and ADVANCED look exactly as they always did.")
|
||||
U.log("The near miss is a box that is only slightly lighter than the field --")
|
||||
U.log("that is the old one-bucket slip, not a border. SGB and SGB INV still")
|
||||
U.log("show a white box over a tinted field; that half of #822 is open.")
|
||||
U.log("Shots: " .. DIR .. "/bug822_*.png. A or B answers the box and it goes.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,222 @@
|
||||
-- A trainer's walk-up must stop short of a Strength boulder, and the boulder
|
||||
-- must still be pushable afterwards (#809). TrainerWalkUpToPlayer (pokered
|
||||
-- engine/overworld/trainer_sight.asm) writes dist-1 movement bytes that skip
|
||||
-- collision, so the trainer used to park ON the boulder, and after that
|
||||
-- IsSpriteInFrontOfPlayer (home/overworld.asm) handed TryPushingBoulder the
|
||||
-- trainer instead of the rock. POKEPORT_DRIVER=tests/drivers/boulder_trainer_bug809_test.lua POKEPORT_IDENTITY=bug809 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
|
||||
-- No POKEPORT_SPEED: the sighting, the "!" bubble and the walk-up all run at
|
||||
-- the normal 60 Hz logic clock so the stop-short frame is the one a player
|
||||
-- would see. The setup pushes are slow for the same reason; the run takes
|
||||
-- about half a minute of real time before it hands the pad over.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
|
||||
-- pokered data/maps/objects/VictoryRoad3F.asm:
|
||||
-- object_event 13, 3, SPRITE_COOLTRAINER_F, STAY, RIGHT, ..., OPP_COOLTRAINER_F, 3
|
||||
-- object_event 22, 3, SPRITE_BOULDER, STAY, BOULDER_MOVEMENT_BYTE_2, ...
|
||||
-- Her header range is 4 (data/generated/trainer_headers.lua VictoryRoad3F[4]),
|
||||
-- so she spots the player anywhere on row 3 within four cells to her east and
|
||||
-- then walks dist-1 cells toward him. Row 3 is walled at x=19, so BOULDER1
|
||||
-- cannot simply be shoved west into her sight line: it has to go down column
|
||||
-- 22 to row 6, west along row 6, and back up column 17 onto row 3.
|
||||
local MAP = "VICTORY_ROAD_3F"
|
||||
local MAP_LABEL = "VictoryRoad3F"
|
||||
local BOULDER = "VICTORYROAD3F_BOULDER1"
|
||||
local TRAINER = "VICTORYROAD3F_COOLTRAINER_F2"
|
||||
local START = { x = 22, y = 2, facing = "down" }
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local pass = true
|
||||
local function check(label, ok)
|
||||
if not ok then pass = false end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function findNpc(ow, name)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == name then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Hold `btn` until `cond` goes true or the budget runs out, then release and
|
||||
-- let any half-finished step land. Boulder pushes need a held direction:
|
||||
-- handleInput only reaches checkBoulderPush while the player already faces
|
||||
-- that way, and TryPushingBoulder arms on one poll and moves on the next
|
||||
-- (BIT_TRIED_PUSH_BOULDER), so a single tap can never shift a rock.
|
||||
local function holdUntil(btn, cond, budget)
|
||||
local first = true
|
||||
for _ = 1, budget or 600 do
|
||||
if cond() then break end
|
||||
if first then table.insert(game.input.pressQueue, btn); first = false end
|
||||
game.input.state[btn] = true
|
||||
coroutine.yield()
|
||||
end
|
||||
game.input.state[btn] = false
|
||||
for _ = 1, 40 do
|
||||
if not game.overworld.player.moving and #game.overworld.scriptMoves == 0 then
|
||||
break
|
||||
end
|
||||
coroutine.yield()
|
||||
end
|
||||
U.wait(4) -- an input-free poll re-arms turning in place (wCheckFor180DegreeTurn)
|
||||
return cond()
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, START.x, START.y, START.facing)
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
local rock = findNpc(ow, BOULDER)
|
||||
local trainer = findNpc(ow, TRAINER)
|
||||
|
||||
check("BOULDER1 loaded on " .. MAP, rock ~= nil)
|
||||
check("COOLTRAINER_F2 loaded on " .. MAP, trainer ~= nil)
|
||||
if not (rock and trainer) then
|
||||
U.log("map objects missing; nothing to drive")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
check("BOULDER1 starts at the asm cell (22,3)",
|
||||
rock.cellX == 22 and rock.cellY == 3)
|
||||
check("COOLTRAINER_F2 starts at the asm cell (13,3) facing right",
|
||||
trainer.cellX == 13 and trainer.cellY == 3 and trainer.facing == "right")
|
||||
check("checkBoulderPush resolves through pushableAtCell",
|
||||
type(ow.pushableAtCell) == "function")
|
||||
|
||||
local header = game.data:trainerHeader(MAP_LABEL, trainer.def.index)
|
||||
local range = header and header.range or 0
|
||||
check("her sight range is 4 cells", range == 4)
|
||||
|
||||
-- The whole route, so a map or tileset edit shows up here instead of as a
|
||||
-- driver that quietly wanders off. If a cell is not walkable the boulder
|
||||
-- cannot be pushed onto it (CheckForCollisionWhenPushingBoulder reuses the
|
||||
-- player's passability check) and the run is not worth continuing.
|
||||
local ROUTE = {
|
||||
{ 22, 4 }, { 22, 5 }, { 22, 6 }, { 23, 5 }, { 23, 6 },
|
||||
{ 21, 6 }, { 20, 6 }, { 19, 6 }, { 18, 6 }, { 17, 6 },
|
||||
{ 18, 7 }, { 17, 7 }, { 17, 5 }, { 17, 4 }, { 17, 3 },
|
||||
{ 18, 4 }, { 18, 3 }, { 16, 3 }, { 16, 4 }, { 16, 2 },
|
||||
}
|
||||
local routeOk = true
|
||||
for _, c in ipairs(ROUTE) do
|
||||
if not ow.map:isWalkableCell(c[1], c[2]) then
|
||||
routeOk = false
|
||||
U.log("route cell not walkable:", c[1], c[2])
|
||||
end
|
||||
end
|
||||
check("the push route is walkable end to end", routeOk)
|
||||
|
||||
-- STRENGTH is live for the map visit. BIT_STRENGTH_ACTIVE is what
|
||||
-- TryPushingBoulder gates on -- it never re-reads badges or party moves --
|
||||
-- so setting the field-move state is the whole grant (see the comment in
|
||||
-- OverworldState:checkBoulderPush).
|
||||
ow.strengthActive = true
|
||||
|
||||
-- Victory Road rolls a wild encounter on every completed step, not just in
|
||||
-- grass (wild_encounters.asm counts caves as indoor), and this run walks
|
||||
-- twenty-odd cells with an empty party. Drop the map's table: a wild
|
||||
-- battle mid-route interrupts the push with a screen transition and has
|
||||
-- nothing to do with what is being checked.
|
||||
game.data.encounters[MAP] = nil
|
||||
|
||||
if not pass then
|
||||
U.log("setup checks already failed; not driving the push")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local function boulderAt(x, y)
|
||||
return function() return rock.cellX == x and rock.cellY == y end
|
||||
end
|
||||
local function playerAt(x, y)
|
||||
local p = ow.player
|
||||
return function() return p.cellX == x and p.cellY == y end
|
||||
end
|
||||
|
||||
-- down column 22 to row 6
|
||||
holdUntil("down", boulderAt(22, 6), 400)
|
||||
check("boulder pushed down column 22 to (22,6)", rock.cellX == 22 and rock.cellY == 6)
|
||||
-- around to its east side
|
||||
holdUntil("right", playerAt(23, 5), 120)
|
||||
holdUntil("down", playerAt(23, 6), 120)
|
||||
-- west along row 6 to the column that reaches row 3
|
||||
holdUntil("left", boulderAt(17, 6), 700)
|
||||
check("boulder pushed west along row 6 to (17,6)", rock.cellX == 17 and rock.cellY == 6)
|
||||
-- around to its south side
|
||||
holdUntil("down", playerAt(18, 7), 120)
|
||||
holdUntil("left", playerAt(17, 7), 120)
|
||||
-- up column 17 onto her row
|
||||
holdUntil("up", boulderAt(17, 3), 400)
|
||||
check("boulder pushed up column 17 onto row 3 at (17,3)",
|
||||
rock.cellX == 17 and rock.cellY == 3)
|
||||
if not pass then
|
||||
U.log("the boulder never reached her row; the race below cannot happen")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
-- Step onto row 3 one cell out of range (18 - 13 = 5 > 4) so the sighting
|
||||
-- happens on the push itself and not a moment earlier.
|
||||
holdUntil("right", playerAt(18, 4), 120)
|
||||
holdUntil("up", playerAt(18, 3), 120)
|
||||
check("player waiting at (18,3), one cell outside her range",
|
||||
ow.player.cellX == 18 and ow.player.cellY == 3 and not ow.engaging)
|
||||
|
||||
-- The engage lands on a battle we are not going to fight: stand in for it,
|
||||
-- record where the walk-up stopped, and mark her beaten the way winning
|
||||
-- would. Everything the walk-up does has already happened by this point.
|
||||
local stopped
|
||||
local realEngage = ow.engageTrainer
|
||||
ow.engageTrainer = function(self, npc, onDone)
|
||||
stopped = { npc = npc, x = npc.cellX, y = npc.cellY }
|
||||
game.save.defeatedTrainers[npc.id] = true
|
||||
if onDone then onDone() end
|
||||
end
|
||||
|
||||
-- One push west: the boulder lands on (16,3) and the player follows onto
|
||||
-- (17,3), four cells from her, which is the frame she spots him on.
|
||||
holdUntil("left", function() return stopped ~= nil end, 400)
|
||||
|
||||
check("she spotted the player and finished her walk-up", stopped ~= nil)
|
||||
check("the boulder moved one cell west to (16,3)",
|
||||
rock.cellX == 16 and rock.cellY == 3)
|
||||
if stopped then
|
||||
U.log("she stopped at", stopped.x, stopped.y, "boulder at", rock.cellX, rock.cellY)
|
||||
check("she is not standing on the boulder cell",
|
||||
not (stopped.x == rock.cellX and stopped.y == rock.cellY))
|
||||
check("she stopped one cell short of it, at (15,3)",
|
||||
stopped.x == 15 and stopped.y == 3)
|
||||
check("the push path still finds the boulder under that cell",
|
||||
ow:pushableAtCell(rock.cellX, rock.cellY) == rock)
|
||||
check("nothing else shares the boulder's cell",
|
||||
ow:npcAtCell(rock.cellX, rock.cellY) == rock)
|
||||
end
|
||||
U.shot(game, SHOT_DIR .. "/bug809_walkup_stop.png")
|
||||
|
||||
-- ...and the rock still moves. Push it north, the one free direction left:
|
||||
-- west is her, east is the player, south is where he came from.
|
||||
holdUntil("down", playerAt(17, 4), 120)
|
||||
holdUntil("left", playerAt(16, 4), 120)
|
||||
holdUntil("up", boulderAt(16, 2), 400)
|
||||
if not check("the boulder is still pushable after the engage",
|
||||
rock.cellX == 16 and rock.cellY == 2) then
|
||||
U.log("boulder ended at", rock.cellX, rock.cellY, "player at",
|
||||
ow.player.cellX, ow.player.cellY)
|
||||
end
|
||||
holdUntil("down", playerAt(16, 4), 120)
|
||||
U.shot(game, SHOT_DIR .. "/bug809_still_pushable.png")
|
||||
|
||||
ow.engageTrainer = realEngage
|
||||
U.log(pass and "ALL CHECKS PASSED" or "SOME CHECKS FAILED")
|
||||
|
||||
U.log("On screen: the COOLTRAINER stands at (15,3) with a one-cell gap")
|
||||
U.log("between her and the rock, which now sits at (16,2), one row up from")
|
||||
U.log("where she stopped. The near miss to watch for is her sprite ending")
|
||||
U.log("the walk-up on top of the rock, or standing clear of it but leaving")
|
||||
U.log("it inert: walk into the rock from any side and it should still shift")
|
||||
U.log("a cell. Her battle was stubbed out and she is flagged as beaten;")
|
||||
U.log("re-run the driver to watch the race again from the start.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,286 @@
|
||||
-- Driver for #847: slowed Cities1 (scripts/ChampionsRoom.asm:112 farcall
|
||||
-- Music_Cities1AlternateTempo, audio/alternate_tempo.asm), the scripted walk
|
||||
-- over the rival (home/overworld.asm CollisionCheckOnLand) and the back-pic
|
||||
-- sweep (engine/movie/hall_of_fame.asm HoFShowMonOrPlayer). Never add
|
||||
-- POKEPORT_SPEED here: it scales the logic clock only and audio is the test.
|
||||
-- POKEPORT_DRIVER=tests/drivers/champion_alt_tempo_bug847_test.lua POKEPORT_IDENTITY=hof847 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local Music = require("src.core.Music")
|
||||
local ChipSynth = require("src.core.ChipSynth")
|
||||
local Commands = require("src.script.Commands")
|
||||
local HallOfFame = require("src.ui.HallOfFame")
|
||||
|
||||
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
|
||||
|
||||
-- audio/music/cities1.asm: Music_Cities1_Ch1 opens `tempo 144`, the
|
||||
-- Music_Cities1_Ch1_AlternateTempo stub `tempo 232` (macros/scripts/audio.asm
|
||||
-- emits db HIGH(x), LOW(x), so these are the literal engine values).
|
||||
local NORMAL_TEMPO, ALT_TEMPO = 144, 232
|
||||
local SONG = "Music_Cities1"
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- party + options
|
||||
-- ---------------------------------------------------------------
|
||||
local SPECIES = { "CHARIZARD", "SNORLAX", "PIKACHU" }
|
||||
game.save.party = {}
|
||||
for i, name in ipairs(SPECIES) do
|
||||
game.save.party[i] = Pokemon.new(game.data, name, 100 - (i - 1) * 27)
|
||||
end
|
||||
game.save.player.name = "BRYAN"
|
||||
local options = game.save.options
|
||||
check("sfxVol is non-zero, so the cries are audible", (options.sfxVol or 0) > 0)
|
||||
check("musicVol is non-zero, so the tempo swap is audible",
|
||||
(options.musicVol or 0) > 0)
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- machine checks: the parts an ear cannot separate from a bad build
|
||||
-- ---------------------------------------------------------------
|
||||
-- read the live registry, so a mod's map_scripts contribution is inspected
|
||||
local mapScripts = require("data.scripts.init")
|
||||
local champ = mapScripts.get("CHAMPIONS_ROOM")
|
||||
local rows = champ and champ.talk and champ.talk.TEXT_CHAMPIONSROOM_RIVAL
|
||||
check("CHAMPIONS_ROOM keeps its rival script", type(rows) == "table")
|
||||
rows = rows or {}
|
||||
|
||||
local iFade, iWait, iCue, iWalk, iWarp, cueOpts
|
||||
for i, row in ipairs(rows) do
|
||||
if row[1] == "fade_music" and not iFade then
|
||||
iFade = i
|
||||
elseif row[1] == "wait" and iFade and not iWait then
|
||||
iWait = i
|
||||
elseif row[1] == "play_music" and row[2] == SONG and not iCue then
|
||||
iCue, cueOpts = i, row[3]
|
||||
elseif row[1] == "move_player" and row[2] == "up" and not iWarp then
|
||||
iWalk = i
|
||||
elseif row[1] == "warp" and row[2] == "HALL_OF_FAME" then
|
||||
iWarp = iWarp or i
|
||||
end
|
||||
end
|
||||
check("the script fades the battle theme out before Cities1 (#847)",
|
||||
iFade ~= nil and iCue ~= nil and iFade < iCue)
|
||||
check("it waits out the fade, like the ld c, 100 / call DelayFrames",
|
||||
iWait ~= nil and iWait > iFade and iWait < iCue
|
||||
and (rows[iWait or 1][2] or 0) >= 100)
|
||||
check("the Cities1 cue carries the alternate tempo " .. ALT_TEMPO,
|
||||
type(cueOpts) == "table" and cueOpts.tempo == ALT_TEMPO)
|
||||
check("it still walks the player out before the warp (#704)",
|
||||
iWalk ~= nil and iWarp ~= nil and iWalk < iWarp)
|
||||
check("Commands.fade_music exists for that first row",
|
||||
type(Commands.fade_music) == "function")
|
||||
|
||||
-- the tempo has to survive the song's own `tempo` command: without the
|
||||
-- override the body's 144 wins and Cities1 plays as the ordinary town theme
|
||||
local def = game.data.audio and game.data.audio.songs
|
||||
and game.data.audio.songs[SONG]
|
||||
check(SONG .. " is in the extracted song table", type(def) == "table")
|
||||
if type(def) == "table" then
|
||||
local slowed = {}
|
||||
for k, v in pairs(def) do slowed[k] = v end
|
||||
slowed.tempo = ALT_TEMPO
|
||||
local okAlt, alt = pcall(ChipSynth.newEngine, game.data, slowed,
|
||||
{ allowLoops = true })
|
||||
local okPlain, plain = pcall(ChipSynth.newEngine, game.data, def,
|
||||
{ allowLoops = true })
|
||||
check("an overridden song header starts locked at " .. ALT_TEMPO,
|
||||
okAlt and alt and alt.tempo == ALT_TEMPO and alt.tempoLocked == true)
|
||||
check("a plain header is left unlocked, free to take its own tempo "
|
||||
.. NORMAL_TEMPO,
|
||||
okPlain and plain and not plain.tempoLocked)
|
||||
end
|
||||
|
||||
-- HoFShowMonOrPlayer loads a back pic for every party member and for the
|
||||
-- player; a missing key would silently draw nothing during the sweep
|
||||
for _, name in ipairs(SPECIES) do
|
||||
local path = Sprites.path(game.data, name, "back", { kind = "hof" })
|
||||
check(name .. " resolves a back pic for the induction",
|
||||
type(path) == "string" and path ~= "")
|
||||
end
|
||||
local playerBack = Sprites.playerPath(game.data, "back", { kind = "hof" })
|
||||
check("the player resolves RedPicBack for the closing sweep",
|
||||
type(playerBack) == "string" and playerBack ~= "")
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- stand where ChampionsRoomPlayerEntersScript leaves the player
|
||||
-- ---------------------------------------------------------------
|
||||
-- pokered data/maps/objects/ChampionsRoom.asm: CHAMPIONSROOM_RIVAL at (4,2),
|
||||
-- both HALL_OF_FAME warps on row 0, and RivalEntrance_RLEMovement (up 1,
|
||||
-- right 1, up 3) from warp 1 lands the player at (4,3), facing the rival.
|
||||
local STAND = { x = 4, y = 3 }
|
||||
U.teleport(game, "CHAMPIONS_ROOM", STAND.x, STAND.y, "up")
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
local rival
|
||||
for _, npc in ipairs(ow and ow.npcs or {}) do
|
||||
if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then rival = npc end
|
||||
end
|
||||
check("the rival object is on the map", rival ~= nil)
|
||||
if rival and (ow.player.cellX ~= rival.cellX
|
||||
or ow.player.cellY ~= rival.cellY + 1) then
|
||||
-- a map edit or a mod moved the object: stand on any free walkable
|
||||
-- neighbour instead of facing a wall. {dx, dy, facing} is the offset from
|
||||
-- the rival to the stand cell plus the direction that looks back at him.
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = rival.cellX + s[1], rival.cellY + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is not free; standing on"):format(STAND.x, STAND.y),
|
||||
cx, cy, "facing", s[3])
|
||||
U.teleport(game, "CHAMPIONS_ROOM", cx, cy, s[3])
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
for _, npc in ipairs(ow.npcs or {}) do
|
||||
if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then
|
||||
rival = npc
|
||||
end
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- run the tail of the script, from after the rival battle, so nobody has to
|
||||
-- win OPP_RIVAL3 to hear this. Only rows before that point carry jump
|
||||
-- targets, so the slice needs no reindexing -- assert that.
|
||||
local from
|
||||
for i, row in ipairs(rows) do
|
||||
if row[1] == "show_text"
|
||||
and row[2] == "_ChampionsRoomRivalAfterBattleText" then
|
||||
from = i
|
||||
break
|
||||
end
|
||||
end
|
||||
check("found the post-battle row to start from", from ~= nil)
|
||||
local slice, jumpy = {}, false
|
||||
for i = from or 1, #rows do
|
||||
local row = rows[i]
|
||||
if row[1] == "jump" or row[1] == "jump_if_true"
|
||||
or row[1] == "jump_if_false" then
|
||||
jumpy = true
|
||||
end
|
||||
slice[#slice + 1] = row
|
||||
end
|
||||
check("the tail of the script has no jump targets to reindex", not jumpy)
|
||||
|
||||
if failures > 0 then
|
||||
U.log("stopping before the cutscene:", failures,
|
||||
"check(s) failed above, so what you would hear means nothing")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
-- watch the cues the script actually issues: on speakers a dedupe that ate
|
||||
-- the restart and a fade that never fired sound like the same nothing
|
||||
local realPlay, realFade = Music.play, Music.fadeOut
|
||||
local cues, faded = {}, false
|
||||
Music.play = function(data, song, loop, ctx)
|
||||
cues[#cues + 1] = { song = song, tempo = ctx and ctx.tempo }
|
||||
return realPlay(data, song, loop, ctx)
|
||||
end
|
||||
Music.fadeOut = function(control)
|
||||
faded = true
|
||||
return realFade(control)
|
||||
end
|
||||
|
||||
ow:queueScript(slice, { npc = rival })
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- the walk out, over the rival's cell
|
||||
-- ---------------------------------------------------------------
|
||||
local startY = ow.player.cellY
|
||||
local minY, sharedCell, walkShot = startY, false, false
|
||||
local hof
|
||||
for i = 1, 6000 do
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == HallOfFame or (top and top.drawMonInfo) then
|
||||
hof = top
|
||||
break
|
||||
end
|
||||
local w = game.overworld
|
||||
if w and w.map and w.map.id == "CHAMPIONS_ROOM" and rival then
|
||||
local px, py = w.player.cellX, w.player.cellY
|
||||
if py < minY then minY = py end
|
||||
if px == rival.cellX and py == rival.cellY then
|
||||
sharedCell = true
|
||||
if not walkShot then
|
||||
walkShot = U.shot(game, DIR .. "/bug847_over_rival.png")
|
||||
end
|
||||
end
|
||||
end
|
||||
if i % 6 == 0 then U.tap(game, "a") else U.wait(1) end
|
||||
end
|
||||
Music.play, Music.fadeOut = realPlay, realFade
|
||||
|
||||
check("the battle theme was faded, not cut", faded)
|
||||
local altCue
|
||||
for _, c in ipairs(cues) do
|
||||
if c.song == SONG and c.tempo == ALT_TEMPO then altCue = c end
|
||||
end
|
||||
check("Cities1 was restarted at the alternate tempo, not deduped away",
|
||||
altCue ~= nil)
|
||||
check("the player walked out of the room before the warp (#704)",
|
||||
minY < startY)
|
||||
-- CollisionCheckOnLand skips its checks while wSimulatedJoypadStatesIndex is
|
||||
-- non-zero, so passing through (4,2) is the original behavior, not a clip
|
||||
check("the scripted walk passed through the rival's cell (#847, not a bug)",
|
||||
sharedCell)
|
||||
check("walk-over screenshot", walkShot)
|
||||
check("the induction started", hof ~= nil)
|
||||
if not hof then
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- the back-pic sweep ahead of the first front pic
|
||||
-- ---------------------------------------------------------------
|
||||
check("the induction opens on the back pic sweep, at the right edge",
|
||||
hof.phase == "back" and (hof.scrollX or 0) > 96)
|
||||
local sweepShot = false
|
||||
for _ = 1, 400 do
|
||||
if hof.phase ~= "back" then break end
|
||||
if not sweepShot and (hof.scrollX or 0) <= 56 then
|
||||
sweepShot = U.shot(game, DIR .. "/bug847_back_sweep.png")
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
check("back sweep screenshot", sweepShot)
|
||||
check("the front pic phase follows the sweep, entering from the left",
|
||||
hof.phase == "mons" and (hof.scrollX or 0) < 0)
|
||||
for _ = 1, 200 do
|
||||
if (hof.scrollX or 96) > 8 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("front scroll screenshot", U.shot(game, DIR .. "/bug847_front.png"))
|
||||
for _ = 1, 400 do
|
||||
if hof.phase == "mons" and (hof.scrollX or 0) >= 96 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the front pic settles at hlcoord (12,5)", (hof.scrollX or 0) == 96)
|
||||
|
||||
U.log(failures == 0 and "all checks passed" or ("FAILURES: " .. failures))
|
||||
U.log("input is yours now; the rest of the party and the player's own page")
|
||||
U.log("follow on their own, so just watch and listen.")
|
||||
U.log("after the rival's last line the battle music should fade out over")
|
||||
U.log("about a second, go quiet for another second and a half, and then")
|
||||
U.log("Pewter City comes back noticeably slower and heavier than it sounds")
|
||||
U.log("in town -- and it stays that slow across the warp until the hall of")
|
||||
U.log("fame theme takes over. For each mon a back sprite sweeps right to")
|
||||
U.log("left low on the screen, then the front sprite slides in from the")
|
||||
U.log("left and only cries once it stops.")
|
||||
U.log("the near miss to listen for: Cities1 at its ordinary town tempo, or")
|
||||
U.log("cutting in with no gap -- that is the old behavior, not the fix.")
|
||||
U.log("the other near miss: a cry that fires while a sprite is still moving.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,209 @@
|
||||
-- Driver: Fighting Dojo prize balls, #853 (dex page first) and #854 (the
|
||||
-- question stays on screen under YES/NO). pokered scripts/FightingDojo.asm
|
||||
-- runs `ld a, HITMONLEE / call DisplayPokedex` before .Text, and .Text is a
|
||||
-- text_end string printed with PrintText immediately followed by YesNoChoice.
|
||||
-- No POKEPORT_SPEED here: the dex page and the YES/NO pop are what is judged.
|
||||
-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/dojo_balls_bug853_test.lua POKEPORT_IDENTITY=bug853 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||
local MapScripts = require("src.script.MapScripts")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local OW = require("src.world.OverworldController")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
-- pokered data/maps/objects/FightingDojo.asm: the two SPRITE_POKE_BALL
|
||||
-- objects sit at (4, 1) HITMONLEE and (5, 1) HITMONCHAN, on the north wall
|
||||
-- under the posters. The only approach is from the mat below them.
|
||||
local MAP = "FIGHTING_DOJO"
|
||||
local LEE = { name = "FIGHTINGDOJO_HITMONLEE_POKE_BALL", x = 4, y = 1 }
|
||||
local CHAN = { name = "FIGHTINGDOJO_HITMONCHAN_POKE_BALL", x = 5, y = 1 }
|
||||
local START = { x = 4, y = 4 } -- walk up from here to (4, 2), facing LEE
|
||||
|
||||
local failures = {}
|
||||
local function check(cond, msg)
|
||||
if cond then U.log("PASS", msg) else
|
||||
failures[#failures + 1] = msg
|
||||
U.log("FAIL", msg)
|
||||
end
|
||||
return cond
|
||||
end
|
||||
|
||||
local function topIs(mt) return getmetatable(game.stack:top()) == mt end
|
||||
local function under()
|
||||
return game.stack.states[#game.stack.states - 1]
|
||||
end
|
||||
|
||||
local function npcByName(ow, name)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == name then return n end
|
||||
end
|
||||
end
|
||||
|
||||
local function pageText()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= TextBox then return "" end
|
||||
local page = top.pages and top.pages[top.pageIndex]
|
||||
return page and table.concat(page, "\n") or ""
|
||||
end
|
||||
|
||||
local function waitFor(cond, cap)
|
||||
for _ = 1, (cap or 200) do
|
||||
if cond() then return true end
|
||||
U.wait(2)
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
|
||||
local function mashUntil(cond, cap)
|
||||
for _ = 1, (cap or 100) do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
|
||||
-- fresh dojo with the master already beaten and neither prize taken
|
||||
local function seed(x, y, facing)
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
game.save.flags = {
|
||||
EVENT_BEAT_KARATE_MASTER = true,
|
||||
EVENT_BEAT_FIGHTING_DOJO_TRAINER_0 = true,
|
||||
EVENT_BEAT_FIGHTING_DOJO_TRAINER_1 = true,
|
||||
EVENT_BEAT_FIGHTING_DOJO_TRAINER_2 = true,
|
||||
EVENT_BEAT_FIGHTING_DOJO_TRAINER_3 = true,
|
||||
}
|
||||
game.save.defeatedTrainers = { FIGHTING_DOJO_obj_1 = true }
|
||||
game.save.objectToggles = {}
|
||||
game.save.player.name = game.save.player.name or "RED"
|
||||
-- one mon so give_pokemon has a party to append to, and room for a prize
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
game.stack:push(OW, MAP, x, y, facing or "up")
|
||||
U.wait(10)
|
||||
return game.stack:top()
|
||||
end
|
||||
|
||||
local ow = seed(START.x, START.y, "up")
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- machine-checkable half: seed, objects, script rows, text, screen id
|
||||
------------------------------------------------------------------
|
||||
check(game.save.flags.EVENT_BEAT_KARATE_MASTER == true,
|
||||
"EVENT_BEAT_KARATE_MASTER is set (the balls answer at all)")
|
||||
check(not game.save.flags.EVENT_GOT_HITMONLEE
|
||||
and not game.save.flags.EVENT_GOT_HITMONCHAN,
|
||||
"neither prize taken yet (no 'greedy' refusal path)")
|
||||
|
||||
local leeBall, chanBall = npcByName(ow, LEE.name), npcByName(ow, CHAN.name)
|
||||
check(leeBall ~= nil, "HITMONLEE ball object loaded")
|
||||
check(chanBall ~= nil, "HITMONCHAN ball object loaded")
|
||||
check(leeBall and leeBall.cellX == LEE.x and leeBall.cellY == LEE.y,
|
||||
("HITMONLEE ball sits at the asm cell (%d, %d)"):format(LEE.x, LEE.y))
|
||||
check(chanBall and chanBall.cellX == CHAN.x and chanBall.cellY == CHAN.y,
|
||||
("HITMONCHAN ball sits at the asm cell (%d, %d)"):format(CHAN.x, CHAN.y))
|
||||
|
||||
check(type(MapScripts.talkScript(MAP, "TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL"))
|
||||
== "function",
|
||||
"TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL has a hand-ported talk script")
|
||||
check(type(MapScripts.talkScript(MAP, "TEXT_FIGHTINGDOJO_HITMONCHAN_POKE_BALL"))
|
||||
== "function",
|
||||
"TEXT_FIGHTINGDOJO_HITMONCHAN_POKE_BALL has a hand-ported talk script")
|
||||
|
||||
-- the ask() string is the extracted descriptor, not the "You want X?" stub
|
||||
local leeText = game.data.text._FightingDojoHitmonleePokeBallText
|
||||
local chanText = game.data.text._FightingDojoHitmonchanPokeBallText
|
||||
check(type(leeText) == "string" and leeText ~= "",
|
||||
"_FightingDojoHitmonleePokeBallText resolves")
|
||||
check(type(chanText) == "string" and chanText ~= "",
|
||||
"_FightingDojoHitmonchanPokeBallText resolves")
|
||||
if type(leeText) == "string" then
|
||||
U.log("lee prompt reads:", (leeText:gsub("\n", " / ")))
|
||||
end
|
||||
local dexOk = pcall(Screens.get, game, "DexEntryMenu")
|
||||
check(dexOk, "DexEntryMenu resolves through the Screens registry")
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- rehearsal on the HITMONCHAN ball, answered NO so nothing is consumed
|
||||
------------------------------------------------------------------
|
||||
if chanBall then
|
||||
ow:talkTo(chanBall)
|
||||
check(waitFor(function() return topIs(DexEntryMenu) end, 60),
|
||||
"#853: the ball opens the HITMONCHAN dex page before any question")
|
||||
U.shot(game, DIR .. "/dojo_balls_1_dex.png")
|
||||
U.tap(game, "b")
|
||||
check(waitFor(function() return topIs(TextBox) end, 60),
|
||||
"#853: closing the dex page leads into the offer text")
|
||||
mashUntil(function() return topIs(ChoiceBox) end, 60)
|
||||
check(topIs(ChoiceBox), "#854: the YES/NO menu opens on the offer")
|
||||
check(getmetatable(under()) == TextBox,
|
||||
"#854: the question box is still on the stack under the YES/NO menu")
|
||||
U.shot(game, DIR .. "/dojo_balls_2_choice.png")
|
||||
U.tap(game, "b") -- B answers NO; the prize stays unclaimed
|
||||
waitFor(function() return game.stack:top() == ow end, 120)
|
||||
check(not game.save.flags.EVENT_GOT_HITMONCHAN,
|
||||
"answering NO leaves the HITMONCHAN prize unclaimed")
|
||||
check(#game.save.party == 1, "answering NO adds nothing to the party")
|
||||
end
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- hand-off: walk to the HITMONLEE ball and open it for real
|
||||
------------------------------------------------------------------
|
||||
ow = seed(START.x, START.y, "up")
|
||||
for _ = 1, 12 do
|
||||
if ow.player.cellY <= LEE.y + 1 then break end
|
||||
U.hold(game, "up", 16)
|
||||
U.wait(4)
|
||||
end
|
||||
|
||||
local function facingTheBall()
|
||||
local cur = game.overworld
|
||||
local ball = cur and npcByName(cur, LEE.name)
|
||||
if not ball then return false end
|
||||
local fx, fy = cur.player:facingCell()
|
||||
return cur:npcAtCell(fx, fy) == ball
|
||||
end
|
||||
|
||||
if not facingTheBall() then
|
||||
-- a map edit or a mod moved the ball: stand on any free walkable
|
||||
-- neighbour instead. {dx, dy, facing} is the offset from the ball to
|
||||
-- the stand cell plus the direction that looks back at it.
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 1, 0, "left" }, { -1, 0, "right" }, { 0, -1, "down" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = LEE.x + s[1], LEE.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log("walk up stopped short; standing on", cx, cy, "facing", s[3])
|
||||
ow = seed(cx, cy, s[3])
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check(facingTheBall(), "player is standing against the HITMONLEE ball")
|
||||
|
||||
U.tap(game, "a")
|
||||
check(waitFor(function() return topIs(DexEntryMenu) end, 60),
|
||||
"#853: pressing A opens the HITMONLEE dex page")
|
||||
U.shot(game, DIR .. "/dojo_balls_3_handoff.png")
|
||||
|
||||
if #failures == 0 then
|
||||
U.log("all checks passed")
|
||||
else
|
||||
U.log(("%d check(s) failed:"):format(#failures), table.concat(failures, "; "))
|
||||
end
|
||||
|
||||
U.log("On screen now: the HITMONLEE dex page the ball opened, name and")
|
||||
U.log("sprite only, since the mon is seen but not owned yet. Press B: the")
|
||||
U.log("offer types out, and the YES/NO menu should appear above it with the")
|
||||
U.log("question still readable -- the old bug swapped the text away for a")
|
||||
U.log("bare YES/NO over the overworld. Answer YES to take HITMONLEE; the")
|
||||
U.log("HITMONCHAN ball beside it stays put and gives the greedy refusal.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,296 @@
|
||||
-- Eye check: FAITHFUL RATIO's mobile scale lock keeps the display outside the
|
||||
-- locked 160x144 viewport black through the pre-battle flash (pokered
|
||||
-- BattleTransition_FlashScreen_, engine/battle/battle_transitions.asm), the
|
||||
-- post-battle fade (GBFadeInFromWhite, home/fade.asm) and Oak speech (#864).
|
||||
-- POKEPORT_DRIVER=tests/drivers/faithful_res_mobile_veil_bug864_test.lua POKEPORT_FORCE_MOBILE=1 POKEPORT_IDENTITY=bug864 POKEPORT_TOUCH=0 POKEPORT_VERSION=red SHOT_DIR=/tmp/shots love .
|
||||
-- No POKEPORT_SPEED anywhere: the flash and the fade ARE the frames under test.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local FaithfulRes = require("src.core.FaithfulRes")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- FaithfulRes.isMobile reads the env per call, so the desktop build only
|
||||
-- takes the scale-cap branch when the launcher command set it. Without it
|
||||
-- every check below tests the ordinary letterbox and proves nothing.
|
||||
if not check("POKEPORT_FORCE_MOBILE=1 is set (the branch under test)",
|
||||
os.getenv("POKEPORT_FORCE_MOBILE") == "1") then
|
||||
U.log("Re-run with POKEPORT_FORCE_MOBILE=1; nothing below is meaningful.")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
-- A phone-shaped window, so the locked viewport (160x144 at the largest
|
||||
-- whole multiple, here 3x = 480x432) leaves tall bars above and below --
|
||||
-- the "dead display" FaithfulRes.lua's contract says must stay black.
|
||||
-- 480x960 keeps the width an exact 3x so the bars are purely vertical.
|
||||
if love.window and love.window.setMode then
|
||||
love.window.setMode(480, 960, { resizable = true,
|
||||
minwidth = FaithfulRes.MIN_W,
|
||||
minheight = FaithfulRes.MIN_H })
|
||||
end
|
||||
U.wait(3)
|
||||
|
||||
-- New Game replaces game.save (and Game:applyOptions re-applies its fresh
|
||||
-- options, which releases the lock), so re-arm before every shot rather
|
||||
-- than trusting one application to survive the whole run.
|
||||
local function lock()
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.faithfulRes = 1
|
||||
game.save.options.battleBg = "black"
|
||||
FaithfulRes.applyOptions(game.save.options)
|
||||
return FaithfulRes.scaleCap()
|
||||
end
|
||||
check("the mobile scale lock engaged (FaithfulRes.scaleCap ~= nil)",
|
||||
lock() ~= nil)
|
||||
|
||||
local opts = game.save.options
|
||||
if (opts.musicVol or 0) == 0 or (opts.sfxVol or 0) == 0 then
|
||||
U.log("WARN music/sfx volume is zero; the flash's battle theme will be silent")
|
||||
end
|
||||
|
||||
-- Load a captured PNG back as ImageData; love.image cannot read absolute
|
||||
-- paths, so go through io.open + newFileData.
|
||||
local function loadShot(path)
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return nil 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 nil
|
||||
end
|
||||
|
||||
local function regionMean(img, x, y, w, h)
|
||||
local sum, n = 0, 0
|
||||
local x2 = math.min(x + w, img:getWidth()) - 1
|
||||
local y2 = math.min(y + h, img:getHeight()) - 1
|
||||
for yy = math.max(y, 0), y2 do
|
||||
for xx = math.max(x, 0), x2 do
|
||||
local r, g, b = img:getPixel(xx, yy)
|
||||
sum = sum + (r + g + b) / 3
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
return n > 0 and sum / n or 0, n
|
||||
end
|
||||
|
||||
-- The locked viewport in framebuffer pixels: the same uiSize * fitScale
|
||||
-- centring endFrame uses for ox/oy/vpw/vph, which is the rectangle the
|
||||
-- veil is clamped to under the lock. Screenshots are framebuffer-sized,
|
||||
-- so no dpi divide.
|
||||
local function viewBox(img)
|
||||
local pw, ph = img:getWidth(), img:getHeight()
|
||||
local uiw, uih = Renderer:uiSize()
|
||||
local S = Renderer:fitScale()
|
||||
local bw, bh = uiw * S, uih * S
|
||||
return math.floor((pw - bw) / 2), math.floor((ph - bh) / 2), bw, bh, pw, ph
|
||||
end
|
||||
|
||||
-- Mean over every bar strip the window has (top/bottom always here,
|
||||
-- left/right only if the width is not an exact multiple). Inset by 2px so
|
||||
-- the viewport's own edge pixels cannot bleed into the bar sample.
|
||||
local function barMean(img)
|
||||
local bx, by, bw, bh, pw, ph = viewBox(img)
|
||||
local sum, n = 0, 0
|
||||
local function add(x, y, w, h)
|
||||
if w < 1 or h < 1 then return end
|
||||
local m, c = regionMean(img, x, y, w, h)
|
||||
sum, n = sum + m * c, n + c
|
||||
end
|
||||
if by >= 8 then
|
||||
add(0, 0, pw, by - 2)
|
||||
add(0, by + bh + 2, pw, ph - (by + bh) - 2)
|
||||
end
|
||||
if bx >= 8 then
|
||||
add(0, by, bx - 2, bh)
|
||||
add(bx + bw + 2, by, bx - 2, bh)
|
||||
end
|
||||
return n > 0 and sum / n or -1, n
|
||||
end
|
||||
|
||||
local function innerMean(img)
|
||||
local bx, by, bw, bh = viewBox(img)
|
||||
return regionMean(img, bx + math.floor(bw / 4), by + math.floor(bh / 4),
|
||||
math.floor(bw / 2), math.floor(bh / 2))
|
||||
end
|
||||
|
||||
-- shot + the two-sided assertion every moment shares: bars dead black,
|
||||
-- viewport interior at least `bright` (the effect visibly inside the frame)
|
||||
local function shotAndCheck(name, bright)
|
||||
check("lock still held at " .. name, lock() ~= nil)
|
||||
local path = DIR .. "/bug864_" .. name .. ".png"
|
||||
U.shot(game, path)
|
||||
local img = loadShot(path)
|
||||
if not check(name .. " shot decoded", img ~= nil) then return nil end
|
||||
local bars, n = barMean(img)
|
||||
local inner = innerMean(img)
|
||||
U.log((" %s: bar mean %.3f over %d px, viewport interior %.3f")
|
||||
:format(name, bars, n, inner))
|
||||
check(name .. ": window has bars to sample", n > 0)
|
||||
check(name .. ": bars stay dead black (#864)", n > 0 and bars < 0.05)
|
||||
check(name .. ": the effect still lights the viewport", inner > bright)
|
||||
return img
|
||||
end
|
||||
|
||||
-- ---- (3rd symptom first: it is where a boot starts) New Game -----------
|
||||
-- OakSpeech sets letterboxWhite; before #864 that painted the WHOLE phone
|
||||
-- paper white, leaving the locked frame indistinguishable from its bars.
|
||||
U.wait(5)
|
||||
U.tap(game, "start") -- skip intro movie
|
||||
U.wait(10)
|
||||
U.tap(game, "a") -- title -> menu
|
||||
U.wait(5)
|
||||
U.tap(game, "a") -- NEW GAME (POKEPORT_IDENTITY=bug864 has no save)
|
||||
local oak
|
||||
for _ = 1, 300 do
|
||||
for i = #game.stack.states, 1, -1 do
|
||||
local s = game.stack.states[i]
|
||||
if s and s.letterboxWhite then oak = s break end
|
||||
end
|
||||
if oak then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
check("Oak speech reached (a letterboxWhite state is on the stack)",
|
||||
oak ~= nil)
|
||||
U.wait(40) -- let Oak's pic and a line of text land inside the frame
|
||||
shotAndCheck("oakspeech", 0.5)
|
||||
|
||||
-- mash through the rest of the speech into the overworld; the naming
|
||||
-- screens and the closing shrink-away beat (~103 unskippable frames) eat
|
||||
-- most of this, so the headroom is generous on purpose
|
||||
for _ = 1, 900 do
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
if game.overworld and game.stack:top() == game.overworld then break end
|
||||
end
|
||||
check("New Game landed in the overworld",
|
||||
game.overworld ~= nil and game.stack:top() == game.overworld)
|
||||
|
||||
-- ---- the pre-battle flash ----------------------------------------------
|
||||
-- pokered data/maps/objects/Route1.asm puts its youngsters at (5,24) and
|
||||
-- (15,13) and the sign at (9,27), so the top of the road is empty; the
|
||||
-- battle is pushed straight in, the cell is only somewhere to stand.
|
||||
local MAP = "ROUTE_1"
|
||||
local STAND = { x = 5, y = 6, facing = "down" }
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "BULBASAUR", 12) }
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP)
|
||||
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit moved the road: any free neighbour serves, the cell is not
|
||||
-- itself under test
|
||||
for _, d in ipairs({ {0,1}, {0,-1}, {1,0}, {-1,0} }) do
|
||||
local cx, cy = STAND.x + d[1], STAND.y + d[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("stand cell (%d, %d) blocked, using (%d, %d)")
|
||||
:format(STAND.x, STAND.y, cx, cy))
|
||||
U.teleport(game, MAP, cx, cy, STAND.facing)
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
U.shot(game, DIR .. "/bug864_route1_base.png") -- unlit reference frame
|
||||
lock()
|
||||
|
||||
-- wild, weaker (L5 vs the L12 lead), not a dungeon map: the 3-bit select
|
||||
-- (battle_transitions.asm) lands on %000 doublecircle, one of the two
|
||||
-- wipes that call BattleTransition_FlashScreen first
|
||||
local battle = BattleState.newWild(game, "RATTATA", 5)
|
||||
ow:pushBattle(battle)
|
||||
local trans = game.stack:top()
|
||||
check("the transition is a flashing wipe (doublecircle)",
|
||||
trans ~= nil and trans.def ~= nil and trans.def.flash == true)
|
||||
|
||||
-- screenVeil is written during draw and cleared at beginFrame, so at
|
||||
-- update time it holds the LAST rendered frame's veil; catch the white
|
||||
-- peak (shade 1, near-full alpha) and shoot the very next frames while
|
||||
-- the 2-frame palette holds keep it bright
|
||||
local caught = false
|
||||
for _ = 1, 400 do
|
||||
local v = game.renderer and game.renderer.screenVeil
|
||||
if v and v[1] == 1 and v[2] >= 0.9 then caught = true break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("caught the flash at its white peak", caught)
|
||||
local flashImg = shotAndCheck("flash", 0.5)
|
||||
local baseImg = loadShot(DIR .. "/bug864_route1_base.png")
|
||||
if flashImg and baseImg then
|
||||
local lit, plain = innerMean(flashImg), innerMean(baseImg)
|
||||
U.log((" viewport interior %.3f unlit -> %.3f mid-flash")
|
||||
:format(plain, lit))
|
||||
check("the flash visibly brightens the viewport over the base frame",
|
||||
lit > plain + 0.15)
|
||||
end
|
||||
|
||||
-- ---- the post-battle fade in from white --------------------------------
|
||||
for _ = 1, 600 do
|
||||
if game.stack:top() == battle then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the battle reached the screen", game.stack:top() == battle)
|
||||
for _ = 1, 200 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
end
|
||||
check("the battle reached its FIGHT/PKMN/ITEM/RUN menu",
|
||||
battle.phase == "menu")
|
||||
|
||||
-- run away (down+right lands on RUN from anywhere in the 2x2 grid; the
|
||||
-- L12 lead outspeeds the L5 wild mon, so the escape always succeeds),
|
||||
-- then watch for BattleReturn's white veil -- battle over, shade 1,
|
||||
-- alpha 1 through its hold frames
|
||||
local sawReturn = false
|
||||
for _ = 1, 1500 do
|
||||
local top = game.stack:top()
|
||||
local v = game.renderer and game.renderer.screenVeil
|
||||
if top ~= battle and v and v[1] == 1 and v[2] >= 0.9 then
|
||||
sawReturn = true
|
||||
break
|
||||
end
|
||||
if top == battle then
|
||||
if battle.phase == "menu" then
|
||||
U.tap(game, "down")
|
||||
U.wait(1)
|
||||
U.tap(game, "right")
|
||||
U.wait(1)
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
else
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
else
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
check("caught the post-battle fade in from white", sawReturn)
|
||||
shotAndCheck("return", 0.8)
|
||||
|
||||
-- ---- over to you --------------------------------------------------------
|
||||
U.log("You are back on Route 1 with the picture locked to a 480x432 frame in")
|
||||
U.log("the middle of a tall window. Open the three shots in " .. DIR .. ":")
|
||||
U.log("bug864_oakspeech, bug864_flash and bug864_return should each show a lit")
|
||||
U.log("frame -- paper, white flash, white fade -- with dead-black bars above")
|
||||
U.log("and below. Before #864 the white spilled over the whole window and the")
|
||||
U.log("frame had no edge at all. Walking into grass here replays the flash live.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -3,7 +3,8 @@
|
||||
-- BUG1 gate -- the master stops the player on the tile to his left
|
||||
-- BUG2 no speech -- no won text + no prize dialogue after the win
|
||||
-- BUG3 wrong re-talk -- shows the pre-battle challenge, not the after line
|
||||
-- BUG4 (verify) -- the ball ask() is the Gen1 descriptor, not a dex entry
|
||||
-- BUG4 (verify) -- the ball ask() is the Gen1 descriptor, shown after
|
||||
-- the species' dex entry (DisplayPokedex)
|
||||
-- BUG5 both balls -- the chosen ball AND the other one both vanish; the
|
||||
-- other should stay and give the "greedy" refusal
|
||||
-- BUG6 poster -- the north-wall posters ("Enemies on every side!") are
|
||||
@@ -163,8 +164,9 @@ return function(game)
|
||||
mashUntil(function() return game.stack:top() == ow end)
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- BUG4 (verify-only): the Hitmonlee ball prompt is the Gen1 descriptor
|
||||
-- ("You want the hard kicking HITMONLEE?"), not a Pokedex entry screen.
|
||||
-- BUG4 (verify-only): the Hitmonlee ball shows the species' Pokedex
|
||||
-- entry first (DisplayPokedex, #853), then the Gen1 descriptor prompt
|
||||
-- ("You want the hard kicking HITMONLEE?").
|
||||
------------------------------------------------------------------
|
||||
ow = resetDojo(4, 2, "up", { EVENT_BEAT_KARATE_MASTER = true })
|
||||
local leeBall = npcByName(ow, "FIGHTINGDOJO_HITMONLEE_POKE_BALL")
|
||||
@@ -173,7 +175,7 @@ return function(game)
|
||||
if leeBall then
|
||||
ow:talkTo(leeBall)
|
||||
check(sawText("hard kicking") or sawText("HITMONLEE"),
|
||||
"BUG4: ball asks the Gen1 descriptor prompt (no dex entry)")
|
||||
"BUG4: ball asks the Gen1 descriptor prompt after the dex entry")
|
||||
U.shot(game, DIR .. "/dojo_4_prompt.png")
|
||||
------------------------------------------------------------------
|
||||
-- BUG5: choose YES -> only the chosen ball vanishes; the other stays
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
-- Driver: #862 Celadon Game Corner poster grunt, loss line + exit walk.
|
||||
-- GameCornerRocketText saves _GameCornerRocketBattleEndText ("Dang!") for
|
||||
-- PrintEndBattleText, and GameCornerRocketBattleScript picks the exit walk
|
||||
-- from the player's cell (pokered/scripts/GameCorner.asm:54-102): east of
|
||||
-- him it is WalkAroundPlayer, DOWN/R/R/UP/R/R/R/R, never UP into the poster.
|
||||
-- No POKEPORT_SPEED: the walk and the battle text are what is under test.
|
||||
-- SHOT_DIR=/tmp/shots POKEPORT_IDENTITY=bug862 POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/game_corner_grunt_bug862_test.lua love .
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
os.execute("mkdir -p " .. DIR)
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok, detail)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label, detail or "")
|
||||
return ok
|
||||
end
|
||||
|
||||
-- pokered/data/maps/objects/GameCorner.asm:36 -- the grunt is
|
||||
-- object_event 9, 5, SPRITE_ROCKET, STAY, UP, facing the poster bg_event
|
||||
-- at (9,4), which is wall. Standing east of him on (10,5) is the branch
|
||||
-- that matters: wYCoord ~= 6 and wXCoord ~= 8, so the script takes
|
||||
-- GameCornerMovement_Rocket_WalkAroundPlayer.
|
||||
local MAP = "GAME_CORNER"
|
||||
local NAME = "GAMECORNER_ROCKET"
|
||||
local GX, GY = 9, 5
|
||||
local STAND = { x = 10, y = 5, facing = "left" }
|
||||
local POSTER = { x = 9, y = 4 }
|
||||
-- DOWN, RIGHT, RIGHT, UP, RIGHT x4 from (9,5), ending on (15,5)
|
||||
local AROUND = {
|
||||
{ 9, 6 }, { 10, 6 }, { 11, 6 }, { 11, 5 },
|
||||
{ 12, 5 }, { 13, 5 }, { 14, 5 }, { 15, 5 },
|
||||
}
|
||||
|
||||
-- clean slate: he must not read as already defeated or already hidden
|
||||
game.save.defeatedTrainers = {}
|
||||
game.save.objectToggles = game.save.objectToggles or {}
|
||||
game.save.objectToggles.GAME_CORNER = nil
|
||||
game.save.player = game.save.player or {}
|
||||
game.save.player.name = game.save.player.name or "RED"
|
||||
game.save.money = game.save.money or 3000
|
||||
|
||||
-- a tank that one-shots OPP_ROCKET #7, so the mash win below is quick and
|
||||
-- the same every run whatever the type matchups are
|
||||
local tank = Pokemon.new(game.data, "MEWTWO", 100)
|
||||
tank.moves = {
|
||||
{ id = "PSYCHIC_M", pp = 99 },
|
||||
{ id = "THUNDERBOLT", pp = 99 },
|
||||
{ id = "ICE_BEAM", pp = 99 },
|
||||
{ id = "EARTHQUAKE", pp = 99 },
|
||||
}
|
||||
game.save.party = { tank }
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
local ow = game.overworld
|
||||
|
||||
local function findGrunt()
|
||||
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 grunt = findGrunt()
|
||||
check("GAMECORNER_ROCKET is on the floor", grunt ~= nil)
|
||||
if grunt then
|
||||
check("he stands on (9,5)", grunt.cellX == GX and grunt.cellY == GY,
|
||||
("at (%d,%d)"):format(grunt.cellX, grunt.cellY))
|
||||
end
|
||||
|
||||
-- a map edit or a mod could take (10,5) away; anything east of him keeps
|
||||
-- the WalkAroundPlayer branch, so fall back to a free walkable neighbour
|
||||
-- and say which branch that lands on
|
||||
local function facingGrunt()
|
||||
local g = findGrunt()
|
||||
if not g then return false end
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return ow:npcAtCell(fx, fy) == g
|
||||
end
|
||||
if grunt and not facingGrunt() then
|
||||
local sides = {
|
||||
{ 1, 0, "left" }, { 0, 1, "up" }, { -1, 0, "right" }, { 0, -1, "down" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = grunt.cellX + s[1], grunt.cellY + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d,%d) is blocked; standing on"):format(STAND.x, STAND.y),
|
||||
cx, cy, "facing", s[3])
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
ow = game.overworld
|
||||
grunt = findGrunt()
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("the player is face to face with him", facingGrunt())
|
||||
local px, py = ow.player.cellX, ow.player.cellY
|
||||
local around = not (py == 6 or px == 8)
|
||||
U.log(("talking from (%d,%d): the script should take %s"):format(
|
||||
px, py, around and "WalkAroundPlayer (down, right, right, up, "
|
||||
.. "right x4)" or "WalkDirect (right x5)"))
|
||||
|
||||
-- the two strings the fix depends on, and the poster cell the pre-fix
|
||||
-- single UP step walked him into
|
||||
local t = game.data.text
|
||||
check("_GameCornerRocketBattleEndText resolves",
|
||||
type(t._GameCornerRocketBattleEndText) == "string"
|
||||
and t._GameCornerRocketBattleEndText ~= "",
|
||||
tostring(t._GameCornerRocketBattleEndText))
|
||||
check("_GameCornerRocketAfterBattleText resolves",
|
||||
type(t._GameCornerRocketAfterBattleText) == "string"
|
||||
and t._GameCornerRocketAfterBattleText ~= "")
|
||||
check("(9,4) is the poster wall, not a cell he can stand on",
|
||||
not ow.map:isWalkableCell(POSTER.x, POSTER.y))
|
||||
|
||||
-- engageTrainer has to accept the script-supplied loss line; a stale
|
||||
-- two-parameter copy would silently drop it and print nothing
|
||||
local info = debug.getinfo(ow.engageTrainer, "S")
|
||||
local sigOk = false
|
||||
if info and info.short_src then
|
||||
local src = io.open((info.short_src:gsub("^@", "")), "r")
|
||||
if src then
|
||||
local n = 0
|
||||
for line in src:lines() do
|
||||
n = n + 1
|
||||
if n == info.linedefined then
|
||||
sigOk = line:find("endBattleText", 1, true) ~= nil
|
||||
break
|
||||
end
|
||||
end
|
||||
src:close()
|
||||
end
|
||||
end
|
||||
check("engageTrainer takes an endBattleText argument", sigOk)
|
||||
|
||||
U.shot(game, DIR .. "/bug862_0_before.png")
|
||||
|
||||
-- Talk and mash to a win, recording every battle message in order and
|
||||
-- pausing on the loss line long enough to photograph it.
|
||||
local said, battle = {}, nil
|
||||
local lastSaid, dangShot = nil, false
|
||||
local function sample()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == BattleState then
|
||||
battle = battle or top
|
||||
local cur = top.current
|
||||
local text = type(cur) == "table" and cur.text
|
||||
if type(text) == "string" and text ~= lastSaid then
|
||||
lastSaid = text
|
||||
said[#said + 1] = text
|
||||
U.log("battle says:", (text:gsub("\n", " ")))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function pageText()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= TextBox then return "" end
|
||||
local parts = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
if type(page) == "table" then
|
||||
for _, line in ipairs(page) do parts[#parts + 1] = tostring(line) end
|
||||
end
|
||||
end
|
||||
return table.concat(parts, " ")
|
||||
end
|
||||
|
||||
local function idle()
|
||||
return game.stack:top() == ow and not ow.runner:isRunning()
|
||||
and #ow.scriptMoves == 0 and not ow.transitioning
|
||||
end
|
||||
|
||||
U.tap(game, "a")
|
||||
local sawAfter = false
|
||||
for f = 1, 4000 do
|
||||
sample()
|
||||
if pageText():find("hideout", 1, true) then sawAfter = true break end
|
||||
local top = game.stack:top()
|
||||
if lastSaid and lastSaid:find("Dang", 1, true) and not dangShot then
|
||||
-- stop mashing for a moment: the loss line is on the battle screen.
|
||||
-- The row is picked up the frame it starts typing, so let it finish
|
||||
-- before the capture or the shot is one letter wide.
|
||||
dangShot = true
|
||||
U.wait(60)
|
||||
U.shot(game, DIR .. "/bug862_1_dang.png")
|
||||
elseif top and top.phase then
|
||||
if top.phase == "menu" then top.menuIndex = 1
|
||||
elseif top.phase == "moveSelect" then top.moveIndex = 1 end
|
||||
U.tap(game, "a")
|
||||
if f > 2400 and top.onFinish then
|
||||
U.log("force-finishing a stalled battle")
|
||||
top.onFinish("win")
|
||||
if game.stack:top() == top then game.stack:pop() end
|
||||
end
|
||||
else
|
||||
U.tap(game, "a")
|
||||
end
|
||||
U.wait(2)
|
||||
sample()
|
||||
end
|
||||
check("reached the after-battle 'hideout' line", sawAfter)
|
||||
check("the battle carried the script's loss line",
|
||||
battle ~= nil and type(battle.endBattleText) == "string"
|
||||
and battle.endBattleText:find("Dang", 1, true) ~= nil,
|
||||
battle and tostring(battle.endBattleText) or "no battle seen")
|
||||
|
||||
-- PrintEndBattleText sits between TrainerDefeatedText and
|
||||
-- MoneyForWinningText (engine/battle/core.asm TrainerBattleVictory)
|
||||
local iDefeat, iDang, iMoney
|
||||
for i, line in ipairs(said) do
|
||||
if not iDefeat and line:find("defeated", 1, true) then iDefeat = i end
|
||||
if not iDang and line:find("Dang", 1, true) then iDang = i end
|
||||
if not iMoney and line:find("winning", 1, true) then iMoney = i end
|
||||
end
|
||||
check("the loss line printed on the battle screen", iDang ~= nil)
|
||||
check("it printed with the ROCKET: name tag",
|
||||
iDang ~= nil and said[iDang]:find(":", 1, true) ~= nil,
|
||||
iDang and said[iDang] or "")
|
||||
check("order is defeated -> Dang! -> payout",
|
||||
iDefeat ~= nil and iDang ~= nil and iMoney ~= nil
|
||||
and iDefeat < iDang and iDang < iMoney,
|
||||
("defeated=%s dang=%s payout=%s"):format(tostring(iDefeat),
|
||||
tostring(iDang),
|
||||
tostring(iMoney)))
|
||||
U.shot(game, DIR .. "/bug862_2_afterbattle.png")
|
||||
|
||||
-- Dismiss the after-battle box and watch the exit walk cell by cell.
|
||||
U.tap(game, "a")
|
||||
local visited, order, lowShot = {}, {}, false
|
||||
local function mark(cx, cy)
|
||||
local key = cx .. "," .. cy
|
||||
if not visited[key] then
|
||||
visited[key] = true
|
||||
order[#order + 1] = key
|
||||
end
|
||||
end
|
||||
-- the last step's hide_object rides its own onDone, so the grunt leaves
|
||||
-- ow.npcs on the frame he lands: count the cell he is walking INTO as
|
||||
-- visited too, or the destination never shows up in the sample
|
||||
local last = { GX, GY }
|
||||
for _ = 1, 900 do
|
||||
local g = findGrunt()
|
||||
if g then
|
||||
mark(g.cellX, g.cellY)
|
||||
last = { g.cellX, g.cellY }
|
||||
if g.targetX and g.targetY then
|
||||
mark(g.targetX, g.targetY)
|
||||
last = { g.targetX, g.targetY }
|
||||
end
|
||||
if g.cellY > GY and not lowShot then
|
||||
lowShot = true
|
||||
U.shot(game, DIR .. "/bug862_3_walk.png")
|
||||
end
|
||||
elseif idle() then
|
||||
break
|
||||
end
|
||||
if game.stack:top() ~= ow then U.tap(game, "a") end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 400 do
|
||||
if idle() then break end
|
||||
if game.stack:top() ~= ow then U.tap(game, "a") end
|
||||
U.wait(2)
|
||||
end
|
||||
U.wait(5)
|
||||
U.shot(game, DIR .. "/bug862_4_gone.png")
|
||||
|
||||
U.log("cells he stood on:", table.concat(order, " "))
|
||||
check("he never stood on the poster cell (9,4)",
|
||||
not visited[POSTER.x .. "," .. POSTER.y])
|
||||
check("he never stepped north of his start row", (function()
|
||||
for key in pairs(visited) do
|
||||
local y = tonumber(key:match(",(%d+)$"))
|
||||
if y and y < GY then return false end
|
||||
end
|
||||
return true
|
||||
end)())
|
||||
if around then
|
||||
check("he stepped down to (9,6) to get past the player", visited["9,6"])
|
||||
check("he came back up onto row 5 and finished on (15,5)",
|
||||
last[1] == 15 and last[2] == 5,
|
||||
("last seen on (%d,%d)"):format(last[1], last[2]))
|
||||
else
|
||||
check("he walked straight along row 5 to (15,5)",
|
||||
last[1] == 15 and last[2] == 5 and not visited["9,6"],
|
||||
("last seen on (%d,%d)"):format(last[1], last[2]))
|
||||
end
|
||||
local toggles = game.save.objectToggles.GAME_CORNER
|
||||
check("he despawned only after the last step", findGrunt() == nil)
|
||||
check("his objectToggle is hidden",
|
||||
toggles ~= nil and toggles.GAMECORNER_ROCKET == false)
|
||||
check("he is recorded as defeated",
|
||||
game.save.defeatedTrainers["GAME_CORNER_obj_11"] == true)
|
||||
U.log(("checks: %d passed, %d failed"):format(pass, fail))
|
||||
|
||||
-- Hand the pad over on a clean copy of the same setup so the whole beat
|
||||
-- can be watched at speed.
|
||||
game.save.defeatedTrainers = {}
|
||||
game.save.objectToggles.GAME_CORNER = nil
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.log("You are east of the grunt again, facing him. Press A and win.")
|
||||
U.log("Right looks like: he says his piece on the battle screen after")
|
||||
U.log("\"RED defeated ROCKET!\" -- one box, \"ROCKET: Dang!\" -- and the")
|
||||
U.log("¥ payout comes after it, not before. Then the hideout line, then")
|
||||
U.log("he steps DOWN off row 5, right past you, back up and out east.")
|
||||
U.log("The near miss to watch for: he steps UP into the poster, or the")
|
||||
U.log("Dang! box turns up in the overworld after the battle has torn down.")
|
||||
U.log("Talk to him from (9,6) below instead and he takes the straight")
|
||||
U.log("five-step version east; both are correct, the branch is your cell.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,224 @@
|
||||
-- Giovanni's Silph Co 11F coordinate trigger speaks BEFORE he walks (#869).
|
||||
-- pokered scripts/SilphCo11F.asm SilphCo11FDefaultScript: DisplayTextID
|
||||
-- TEXT_SILPHCO11F_GIOVANNI first, then MoveSprite .GiovanniMovement (3x down)
|
||||
-- and EngageMapTrainer with no second box. Do not set POKEPORT_SPEED: the
|
||||
-- box-vs-walk ordering is exactly the moment under test.
|
||||
-- POKEPORT_DRIVER=tests/drivers/giovanni_silph11f_bug869_test.lua POKEPORT_IDENTITY=bug869 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local BattleTransition = require("src.render.BattleTransition")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- Positions from ../pokered/data/maps/objects/SilphCo11F.asm: Giovanni
|
||||
-- object_event (6, 9), SILPHCO11F_ROCKET1 (3, 16). Trigger tiles from
|
||||
-- ../pokered/scripts/SilphCo11F.asm .PlayerCoordsArray: (6, 13) and
|
||||
-- (7, 12). data/generated/maps.lua stores the same cells 1:1.
|
||||
local MAP = "SILPH_CO_11F"
|
||||
local GIO_HOME = { x = 6, y = 9 }
|
||||
local GIO_STOP = { x = 6, y = 12 } -- home + 3x NPC_MOVEMENT_DOWN
|
||||
|
||||
local failed = 0
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
if not ok then failed = failed + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
-- party strong enough that the human can win the OPP_GIOVANNI#2 fight
|
||||
-- and watch the unchanged aftermath (victories.lua "Arrgh!!", then the
|
||||
-- "Blast it all!" speech and the rockets leaving)
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "MEWTWO", 80),
|
||||
Pokemon.new(game.data, "SNORLAX", 77),
|
||||
Pokemon.new(game.data, "CHARIZARD", 70),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
|
||||
-- (6,13) and (7,13) sit in the card-key doorway of the boss room: block
|
||||
-- (3,6) stays the closed id 32 until EVENT_SILPH_CO_11_UNLOCKED_DOOR is
|
||||
-- set (stampClosedDoors, mirroring pokered engine/events/card_key.asm),
|
||||
-- and a closed door refuses the step this test needs. A real player has
|
||||
-- opened it before the trigger can fire, so open it here too.
|
||||
game.save.flags.EVENT_SILPH_CO_11_UNLOCKED_DOOR = true
|
||||
|
||||
check("EVENT_BEAT_SILPH_CO_GIOVANNI starts unset -- trigger is armed",
|
||||
not game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI)
|
||||
local opts = game.save.options or {}
|
||||
if (opts.sfxVol or 0) == 0 then
|
||||
U.log("sfxVol is 0 -- the evil-trainer sting will be inaudible")
|
||||
end
|
||||
if (opts.musicVol or 0) == 0 then
|
||||
U.log("musicVol is 0 -- the encounter sting and battle theme are muted")
|
||||
end
|
||||
|
||||
local function findNpc(ow, name)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == name then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function topBox()
|
||||
local t = game.stack:top()
|
||||
if getmetatable(t) == TextBox then return t end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function boxText(box)
|
||||
local shown = {}
|
||||
for _, page in ipairs(box.pages or {}) do
|
||||
for _, line in ipairs(page) do shown[#shown + 1] = line end
|
||||
end
|
||||
return table.concat(shown, " / ")
|
||||
end
|
||||
|
||||
-- Stand next to (tx, ty) and take one real walking step onto it; onStep
|
||||
-- hooks fire on a finished step, so a bare teleport onto the tile would
|
||||
-- prove nothing. `sides` are {dx, dy, facing} in preference order, each
|
||||
-- checked for walkability so a map edit only degrades to the next side.
|
||||
local function stepOnto(tx, ty, sides)
|
||||
U.teleport(game, MAP, tx + sides[1][1], ty + sides[1][2], sides[1][3])
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = tx + s[1], ty + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
if ow.player.cellX ~= cx or ow.player.cellY ~= cy then
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
U.wait(10)
|
||||
end
|
||||
U.hold(game, s[3], 24)
|
||||
U.wait(10)
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Regression on the shared engageTrainer path first: an ordinary trainer
|
||||
-- with no skipBattleText must still get its normal pre-battle box.
|
||||
-- ROCKET1 faces up, so talk to him from above.
|
||||
check("regression: reached ROCKET1's cell",
|
||||
stepOnto(3, 15, { { 0, 1, "up" }, { 0, -1, "down" },
|
||||
{ 1, 0, "left" }, { -1, 0, "right" } }))
|
||||
do
|
||||
local ow = game.overworld
|
||||
local rocket = findNpc(ow, "SILPHCO11F_ROCKET1")
|
||||
check("regression: ROCKET1 object loaded", rocket ~= nil)
|
||||
if rocket then
|
||||
-- walk down one so we face him from (3, 15)
|
||||
local fx, fy = ow.player:facingCell()
|
||||
if ow:npcAtCell(fx, fy) ~= rocket then
|
||||
-- stepOnto left us adjacent to (3, 15); face the rocket directly
|
||||
local dx = rocket.cellX - ow.player.cellX
|
||||
local dy = rocket.cellY - ow.player.cellY
|
||||
local face = (dy > 0 and "down") or (dy < 0 and "up")
|
||||
or (dx > 0 and "right") or "left"
|
||||
U.tap(game, face)
|
||||
U.wait(10)
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
local box = topBox()
|
||||
check("regression: talking to ROCKET1 still opens the pre-battle box",
|
||||
box ~= nil)
|
||||
if box then
|
||||
local t = boxText(box)
|
||||
U.log("rocket box reads:", t)
|
||||
check("regression: it is his battle line (\"Stop right there!\")",
|
||||
t:find("Stop right there", 1, true) ~= nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Both trigger tiles must fire with Giovanni still at his desk. The
|
||||
-- (7, 12) probe is abandoned before the box is dismissed (the teleport
|
||||
-- rebuilds the map state), so the trigger re-arms for the main run --
|
||||
-- vanilla re-arms too, since only a win sets the event flag.
|
||||
do
|
||||
check("(7,12) approach: stepped onto the trigger from the right",
|
||||
stepOnto(7, 12, { { 1, 0, "left" }, { 0, 1, "up" },
|
||||
{ -1, 0, "right" } }))
|
||||
local box = topBox()
|
||||
check("(7,12) approach: intro box opened", box ~= nil)
|
||||
local gio = findNpc(game.overworld, "SILPHCO11F_GIOVANNI")
|
||||
check("(7,12) approach: Giovanni is still at his desk (6,9)",
|
||||
gio ~= nil and gio.cellX == GIO_HOME.x and gio.cellY == GIO_HOME.y)
|
||||
end
|
||||
|
||||
-- Main run, the route the issue screenshots show: (6,15) facing up, two
|
||||
-- steps onto (6,13).
|
||||
U.teleport(game, MAP, 6, 15, "up")
|
||||
U.wait(10)
|
||||
U.hold(game, "up", 24)
|
||||
U.hold(game, "up", 24)
|
||||
U.wait(15)
|
||||
if not topBox() then
|
||||
-- blocked approach fallback: one step onto (6,13) from any free side
|
||||
stepOnto(6, 13, { { 0, 1, "up" }, { -1, 0, "right" }, { 1, 0, "left" } })
|
||||
end
|
||||
|
||||
local ow = game.overworld
|
||||
local gio = findNpc(ow, "SILPHCO11F_GIOVANNI")
|
||||
check("Giovanni object loaded on " .. MAP, gio ~= nil)
|
||||
local box = topBox()
|
||||
check("stepping onto (6,13) opened a text box", box ~= nil)
|
||||
if box then
|
||||
local t = boxText(box)
|
||||
U.log("intro box reads:", t)
|
||||
check("it is the Giovanni intro (\"So we meet again!\")",
|
||||
t:find("So we meet again", 1, true) ~= nil)
|
||||
end
|
||||
check("the box opened with Giovanni STILL at his desk (6,9) -- the fix",
|
||||
gio ~= nil and gio.cellX == GIO_HOME.x and gio.cellY == GIO_HOME.y)
|
||||
U.shot(game, SHOT_DIR .. "/bug869_box_before_walk.png")
|
||||
U.wait(30)
|
||||
check("he holds the desk for the whole box, not just its first frame",
|
||||
gio ~= nil and gio.cellX == GIO_HOME.x and gio.cellY == GIO_HOME.y)
|
||||
|
||||
-- dismiss every page; the walk and the battle must follow with NO
|
||||
-- further dialogue box in between (EngageMapTrainer runs bare in the
|
||||
-- original, so engageTrainer is called with skipBattleText here)
|
||||
for _ = 1, 300 do
|
||||
if not topBox() then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(5)
|
||||
end
|
||||
check("intro box dismissed", topBox() == nil)
|
||||
local sawSecondBox, battleReached = false, false
|
||||
for _ = 1, 900 do
|
||||
local t = game.stack:top()
|
||||
local mt = getmetatable(t)
|
||||
if mt == TextBox and not sawSecondBox then
|
||||
sawSecondBox = true
|
||||
U.log("unexpected box reads:", boxText(t))
|
||||
end
|
||||
if mt == BattleTransition or mt == BattleState then
|
||||
battleReached = true
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
check("battle wipe started after the box", battleReached)
|
||||
check("no second dialogue box between the walk and the battle", not sawSecondBox)
|
||||
check("Giovanni walked the three tiles down to (6,12) first",
|
||||
gio ~= nil and gio.cellX == GIO_STOP.x and gio.cellY == GIO_STOP.y)
|
||||
|
||||
U.log(failed == 0 and "PASS all machine checks clean"
|
||||
or ("FAIL " .. failed .. " machine check(s) above"))
|
||||
|
||||
U.log("The battle wipe is running now; take the pad and win the fight.")
|
||||
U.log("Right looks like what just played: his speech opened while he was")
|
||||
U.log("behind the desk, then he walked down and the fight began straight")
|
||||
U.log("away, with the evil-trainer sting and no extra dialogue. After the")
|
||||
U.log("win you should get \"Arrgh!!\" on the battle screen, the \"Blast it")
|
||||
U.log("all!\" speech, a fade, and every rocket gone. Wrong is the old bug:")
|
||||
U.log("he crosses the room in silence and only then talks, point-blank.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -226,6 +226,18 @@ return function(game)
|
||||
-- ---------------------------------------------------------------
|
||||
-- mid scroll: .ScrollPic nudges hSCX 4px a frame, and the exemption has to
|
||||
-- travel with the pic instead of sitting at its resting column (#637)
|
||||
-- #847: HoFShowMonOrPlayer sweeps the BACK pic across the screen (low, at
|
||||
-- y=88) before the front pic scrolls in. Catch it mid-sweep, wait the
|
||||
-- sweep out, then catch the front pic partway through its own scroll.
|
||||
for _ = 1, 300 do
|
||||
if hof.phase ~= "back" or (hof.scrollX or 0) <= 56 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("back-pic sweep screenshot", U.shot(game, DIR .. "/hof847_back.png"))
|
||||
for _ = 1, 300 do
|
||||
if hof.phase ~= "back" then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 200 do
|
||||
if (hof.scrollX or PIC_X) > 8 then break end
|
||||
U.wait(1)
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
-- The super effective / not very effective hit sounds played at the wrong
|
||||
-- pitch, so the weak-sounding hit landed on the weakness (#826). pokered
|
||||
-- PlayApplyingAttackSound (engine/battle/animations.asm) sets
|
||||
-- wFrequencyModifier with the sound, and audio/engine_2.asm
|
||||
-- Audio2_ApplyFrequencyModifier adds it to the noise channel's polynomial
|
||||
-- counter. Ears only, so never under POKEPORT_SPEED -- the pitch is the test.
|
||||
-- POKEPORT_DRIVER=tests/drivers/hit_sfx_bug826_test.lua POKEPORT_IDENTITY=bug826 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local ChipSynth = require("src.core.ChipSynth")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
-- GOLEM is ROCK/GROUND, so one attacker covers both ends of the routine
|
||||
-- with no switching: WATER_GUN is 2x on each type and EMBER is 0.5x on
|
||||
-- ROCK. SPLASH on the foe keeps the lead alive for as many replays as
|
||||
-- the listener wants.
|
||||
local FOE, FOE_LEVEL = "GOLEM", 60
|
||||
local LEAD, LEAD_LEVEL = "BULBASAUR", 25
|
||||
local WEAK_MOVE, STRONG_MOVE = "EMBER", "WATER_GUN"
|
||||
-- PlayApplyingAttackSound's wFrequencyModifier per sound, and the
|
||||
-- polynomial-counter byte of each program's first note (audio/sfx/
|
||||
-- {damage,super_effective,not_very_effective}.asm) before and after it.
|
||||
local SOUNDS = {
|
||||
{ name = "Damage", pitch = 0x20, raw = 0x44, want = 0x64 },
|
||||
{ name = "Super_Effective", pitch = 0xe0, raw = 0x34, want = 0x14 },
|
||||
{ name = "Not_Very_Effective", pitch = 0x50, raw = 0x55, want = 0xa5 },
|
||||
}
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
local function hex(v) return v and ("$%02x"):format(v) or "nil" end
|
||||
|
||||
-- ---- data the moment depends on ----------------------------------------
|
||||
local sfx = game.data.audio and game.data.audio.sfx or {}
|
||||
for _, row in ipairs(SOUNDS) do
|
||||
local def = sfx[row.name]
|
||||
check(row.name .. " resolves to a chip program in the generated audio",
|
||||
type(def) == "table" and def.address ~= nil and def.bank ~= nil)
|
||||
end
|
||||
local moveWeak, moveStrong = game.data.moves[WEAK_MOVE], game.data.moves[STRONG_MOVE]
|
||||
local foeDef = game.data.pokemon[FOE]
|
||||
check(WEAK_MOVE .. " and " .. STRONG_MOVE .. " resolve in the move table",
|
||||
moveWeak ~= nil and moveStrong ~= nil)
|
||||
check(FOE .. " resolves with a dual type", foeDef ~= nil and #foeDef.types == 2)
|
||||
local weakMult, strongMult
|
||||
if moveWeak and moveStrong and foeDef then
|
||||
weakMult = TypeChart.effectiveness(moveWeak.type, foeDef.types)
|
||||
strongMult = TypeChart.effectiveness(moveStrong.type, foeDef.types)
|
||||
U.log(("%s on %s is x%.1f, %s is x%.1f (the x10 scale Damage.lua uses)")
|
||||
:format(WEAK_MOVE, FOE, weakMult / 10, STRONG_MOVE, strongMult / 10))
|
||||
end
|
||||
check(WEAK_MOVE .. " is the resisted side of the pair", (weakMult or 10) < 10)
|
||||
check(STRONG_MOVE .. " is the super effective side", (strongMult or 10) > 10)
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
check("sfx volume is up (" .. tostring(vol) .. "/7)", (vol or 0) > 0)
|
||||
if (vol or 0) == 0 then
|
||||
U.log("with sfxVol 0 both hits are silent and this run proves nothing;",
|
||||
"raise it in OPTION and start over")
|
||||
end
|
||||
|
||||
-- ---- the synth half: does the modifier reach the noise channel? ---------
|
||||
-- Sample each program once at offset 0 and again at its own modifier. A
|
||||
-- port that drops wFrequencyModifier reports the same byte twice, which is
|
||||
-- the whole of #826: unpitched, Super_Effective ends duller than
|
||||
-- Not_Very_Effective ends.
|
||||
local function firstNoise(header, offset)
|
||||
if not header then return nil end
|
||||
local engine = ChipSynth.newEngine(game.data, header, {
|
||||
sfx = true, allowLoops = false, frequencyOffset = offset,
|
||||
})
|
||||
for _, channel in ipairs(engine.channels) do
|
||||
channel:sample()
|
||||
local event = channel.event
|
||||
if event and event.noiseParameter then return event.noiseParameter end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
for _, row in ipairs(SOUNDS) do
|
||||
local bare = firstNoise(sfx[row.name], 0)
|
||||
local pitched = firstNoise(sfx[row.name], row.pitch)
|
||||
check(("%s reads NR43 %s unmodified, as in the asm")
|
||||
:format(row.name, hex(row.raw)), bare == row.raw)
|
||||
check(("...and %s once %s is applied"):format(hex(row.want), hex(row.pitch)),
|
||||
pitched == row.want)
|
||||
U.log(("%s: %s -> %s, shift clock %d -> %d (higher shift = duller)")
|
||||
:format(row.name, hex(bare), hex(pitched),
|
||||
math.floor((bare or 0) / 16), math.floor((pitched or 0) / 16)))
|
||||
end
|
||||
|
||||
-- ---- the battle half: what does a hit row actually carry? ---------------
|
||||
-- Offscreen scratch turn, no animation timing in the way. The row has to
|
||||
-- name the sound AND its modifier byte, and must not carry a tempo byte:
|
||||
-- Audio2_note_length skips Audio2_SetSfxTempo on CHAN8 (`cp CHAN8 / jr z,
|
||||
-- .skip`), so the hardware never retimes these three.
|
||||
-- the move is handed in whole, so the party lead keeps both its slots for
|
||||
-- the live battle below
|
||||
local function rowSfx(moveId)
|
||||
local scratch = BattleState.newWild(game, FOE, FOE_LEVEL)
|
||||
scratch.onFinish = function() end
|
||||
-- Damage.accuracyRoll is `rng(0, 255) < acc` (src/battle/Damage.lua:105),
|
||||
-- so the roll has to be pinned LOW to guarantee a hit. Pinning it high
|
||||
-- misses every time, the row never gets a .hit, and this scan reads nil.
|
||||
scratch.rng = function(lo) return lo end
|
||||
scratch:performMove(scratch.player, scratch.enemy, { id = moveId, pp = 20 })
|
||||
for _, row in ipairs(scratch.queue) do
|
||||
if row.hit and row.hit.sfx then return row.hit.sfx end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
do
|
||||
local lead = Pokemon.new(game.data, LEAD, LEAD_LEVEL)
|
||||
lead.moves = {
|
||||
{ id = WEAK_MOVE, pp = 25, maxPP = 25 },
|
||||
{ id = STRONG_MOVE, pp = 25, maxPP = 25 },
|
||||
}
|
||||
game.save.party = { lead }
|
||||
for _, case in ipairs({
|
||||
{ move = STRONG_MOVE, want = "Super_Effective", pitch = 0xe0 },
|
||||
{ move = WEAK_MOVE, want = "Not_Very_Effective", pitch = 0x50 },
|
||||
}) do
|
||||
local row = rowSfx(case.move)
|
||||
check(case.move .. " queues a hit sound with its modifier",
|
||||
type(row) == "table" and row.sound == case.want
|
||||
and row.pitch == case.pitch)
|
||||
check("...and no tempo byte, the way CHAN8 ignores one",
|
||||
type(row) == "table" and row.tempo == nil)
|
||||
U.log(("%s -> %s pitch %s"):format(case.move,
|
||||
type(row) == "table" and tostring(row.sound) or tostring(row),
|
||||
type(row) == "table" and hex(row.pitch) or "nil"))
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- reach the moment ---------------------------------------------------
|
||||
-- ROUTE_1 is open field (data/generated/maps.lua ROUTE_1); the stand cell
|
||||
-- is read back off the loaded map, and a map edit degrades to the first
|
||||
-- free cell instead of dropping the player into a wall.
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(10)
|
||||
local map = game.overworld.map
|
||||
if not map:isWalkableCell(5, 5) then
|
||||
local fx, fy
|
||||
for cy = 0, map.heightCells - 1 do
|
||||
for cx = 0, map.widthCells - 1 do
|
||||
if map:isWalkableCell(cx, cy) then fx, fy = cx, cy break end
|
||||
end
|
||||
if fx then break end
|
||||
end
|
||||
if fx then
|
||||
U.log("cell (5, 5) is not walkable, standing on", fx, fy)
|
||||
U.teleport(game, "ROUTE_1", fx, fy, "down")
|
||||
U.wait(10)
|
||||
end
|
||||
end
|
||||
local ow = game.overworld
|
||||
check("player stands on a walkable ROUTE_1 cell",
|
||||
ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY))
|
||||
|
||||
-- listen in on the real playback path so the log can tell "the fix is not
|
||||
-- wired to the battle" from "the fix is wired but you did not like it"
|
||||
local heard = {}
|
||||
local realPlayMove = Sound.playMove
|
||||
Sound.playMove = function(data, anim)
|
||||
if type(anim) == "table" and anim.sound then
|
||||
for _, row in ipairs(SOUNDS) do
|
||||
if anim.sound == row.name then
|
||||
heard[#heard + 1] = { sound = anim.sound, pitch = anim.pitch }
|
||||
end
|
||||
end
|
||||
end
|
||||
return realPlayMove(data, anim)
|
||||
end
|
||||
|
||||
local function mashUntil(cond, max)
|
||||
for _ = 1, max or 160 do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
|
||||
local function newFight()
|
||||
local battle = BattleState.newWild(game, FOE, FOE_LEVEL)
|
||||
battle.onFinish = function(result) ow:afterBattle(result, battle) end
|
||||
-- SPLASH so the foe's turn cannot end the run, or drown the hit under a
|
||||
-- damage sound of its own
|
||||
battle.enemy.mon.moves = { { id = "SPLASH", pp = 40, maxPP = 40 } }
|
||||
battle.enemy.curMoves = battle.enemy.mon.moves
|
||||
ow:pushBattle(battle)
|
||||
U.wait(220) -- the send-out intro plays before the menu is reachable
|
||||
mashUntil(function() return battle.phase == "menu" end)
|
||||
return battle
|
||||
end
|
||||
|
||||
local battle = newFight()
|
||||
check("the wild " .. FOE .. " battle reached its FIGHT menu",
|
||||
battle.phase == "menu")
|
||||
U.shot(game, DIR .. "/bug826_menu.png")
|
||||
|
||||
-- slot 1 first: the resisted hit, so the pair is heard weak then strong
|
||||
local function swing(slotDown, label)
|
||||
local before = #heard
|
||||
U.tap(game, "a") -- FIGHT
|
||||
U.wait(16)
|
||||
if slotDown then U.tap(game, "down"); U.wait(8) end
|
||||
U.tap(game, "a")
|
||||
for frame = 1, 1200 do
|
||||
if battle.phase == "menu" and #battle.queue == 0 and not battle.draining then
|
||||
break
|
||||
end
|
||||
if not battle.draining and frame % 8 == 0 then U.tap(game, "a") end
|
||||
U.wait(1)
|
||||
end
|
||||
local row = heard[before + 1]
|
||||
U.log(("%s played %s at pitch %s"):format(label,
|
||||
row and row.sound or "nothing",
|
||||
row and hex(row.pitch) or "nil"))
|
||||
return row
|
||||
end
|
||||
|
||||
local weakHeard = swing(false, WEAK_MOVE)
|
||||
U.shot(game, DIR .. "/bug826_not_very_effective.png")
|
||||
local strongHeard = swing(true, STRONG_MOVE)
|
||||
U.shot(game, DIR .. "/bug826_super_effective.png")
|
||||
check("the resisted hit reached the mixer as Not_Very_Effective $50",
|
||||
weakHeard ~= nil and weakHeard.sound == "Not_Very_Effective"
|
||||
and weakHeard.pitch == 0x50)
|
||||
check("the super effective hit reached it as Super_Effective $e0",
|
||||
strongHeard ~= nil and strongHeard.sound == "Super_Effective"
|
||||
and strongHeard.pitch == 0xe0)
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
|
||||
-- ---- hand the pad over --------------------------------------------------
|
||||
Sound.playMove = realPlayMove
|
||||
if battle.phase ~= "menu" then
|
||||
U.log("(the menu did not come back on its own: mash A to reach FIGHT)")
|
||||
end
|
||||
U.log("Both hits have already sounded once. GOLEM is still standing and")
|
||||
U.log("EMBER and WATER_GUN sit in slots 1 and 2, so play them back to back")
|
||||
U.log("as often as you like.")
|
||||
U.log("WATER_GUN, under \"It's super effective!\", should be the brighter")
|
||||
U.log("and sharper of the two -- a high crack. EMBER, under \"It's not very")
|
||||
U.log("effective...\", should be a low dull rumble underneath it.")
|
||||
U.log("The near miss to listen for: the two are close in brightness, or the")
|
||||
U.log("crack lands on EMBER and the thud on WATER_GUN. That is the modifier")
|
||||
U.log("going missing again, and it is what #826 sounded like.")
|
||||
U.log("The neutral hit changed too: any move that is neither, on any foe,")
|
||||
U.log("is now a shade duller than it used to be, and that is correct.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,177 @@
|
||||
-- Manual check of the Rocket Hideout B4F Jessie & James ambush: James walks
|
||||
-- the full four tiles to the player's side (#865) and their loss line prints
|
||||
-- on the battle screen before the prize money (#866).
|
||||
-- pokeyellow scripts/RocketHideoutB4F.asm (MovementData_45605 falls through
|
||||
-- into _45606) and data/maps/objects/RocketHideoutB4F.asm. No fast-forward:
|
||||
-- POKEPORT_DRIVER=tests/drivers/jessie_james_bug866_test.lua POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Commands = require("src.script.Commands")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local mapScripts = require("data.scripts.init")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local MAP = "ROCKET_HIDEOUT_B4F"
|
||||
local BEAT = "EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES"
|
||||
local JAMES, JESSIE = "ROCKETHIDEOUTB4F_JAMES", "ROCKETHIDEOUTB4F_JESSIE"
|
||||
-- RocketHideoutB4FScript_455a5 fires on wYCoord $e with wXCoord $18 or $19.
|
||||
-- x=24 leaves EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT clear, which is
|
||||
-- the branch that hands the four-step blob to James (object 2, spawned at
|
||||
-- 25,10) and the three-step one to Jessie (object 3, at 24,10).
|
||||
local TRIGGER = { x = 24, y = 14 }
|
||||
local EXPECT = {
|
||||
[JAMES] = { x = 25, y = 14, facing = "left" },
|
||||
[JESSIE] = { x = 24, y = 13, facing = "down" },
|
||||
}
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
check("running the Yellow cache (the duo exists nowhere else)",
|
||||
GameVersion.isYellow())
|
||||
if not GameVersion.isYellow() then
|
||||
U.log("re-run with POKEPORT_VERSION=yellow; nothing below will be true")
|
||||
end
|
||||
|
||||
local hooks = mapScripts.get(MAP)
|
||||
check("yellow_jessie_james registered an onStep for " .. MAP,
|
||||
type(hooks) == "table" and type(hooks.onStep) == "function")
|
||||
check("their talk entries are registered too",
|
||||
type(hooks) == "table" and type(hooks.talk) == "table"
|
||||
and hooks.talk.TEXT_ROCKETHIDEOUTB4F_JAMES ~= nil
|
||||
and hooks.talk.TEXT_ROCKETHIDEOUTB4F_JESSIE ~= nil)
|
||||
|
||||
-- the #866 fix is a new script verb; if a mod shadowed it or the registry
|
||||
-- never picked it up, the row would silently no-op and the line would come
|
||||
-- back after the money instead of before it
|
||||
local verb = Commands.resolve(game.data, "save_end_battle_text")
|
||||
check("save_end_battle_text resolves as a script verb", type(verb) == "function")
|
||||
|
||||
local texts = {}
|
||||
for i = 1, 4 do
|
||||
local key = "_RocketHideoutJessieJamesText" .. i
|
||||
texts[i] = game.data.text[key]
|
||||
check(key .. " resolves to a string",
|
||||
type(texts[i]) == "string" and texts[i] ~= "")
|
||||
end
|
||||
if type(texts[3]) == "string" then
|
||||
U.log("the armed loss line reads:", (texts[3]:gsub("\n", " / ")))
|
||||
end
|
||||
|
||||
local objs = (game.data.maps[MAP] or {}).objects or {}
|
||||
local defs = {}
|
||||
for _, o in ipairs(objs) do
|
||||
if o.name == JAMES or o.name == JESSIE then defs[o.name] = o end
|
||||
end
|
||||
check("James is object 2 of " .. MAP .. ", hidden at (25,10)",
|
||||
defs[JAMES] ~= nil and defs[JAMES].index == 2
|
||||
and defs[JAMES].x == 25 and defs[JAMES].y == 10
|
||||
and defs[JAMES].hidden == true)
|
||||
check("Jessie is object 3, hidden at (24,10)",
|
||||
defs[JESSIE] ~= nil and defs[JESSIE].index == 3
|
||||
and defs[JESSIE].x == 24 and defs[JESSIE].y == 10
|
||||
and defs[JESSIE].hidden == true)
|
||||
|
||||
local rocket = game.data.trainers.OPP_ROCKET
|
||||
check("OPP_ROCKET party 43 (the duo's shared team) exists",
|
||||
rocket ~= nil and rocket.parties ~= nil and rocket.parties[43] ~= nil)
|
||||
|
||||
-- arm the site: the ambush is gated only on its beat flag, so no story
|
||||
-- progress is needed to make it live
|
||||
game.save.flags[BEAT] = nil
|
||||
game.save.flags.EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT = nil
|
||||
check(BEAT .. " cleared, so the trigger is live",
|
||||
game.save.flags[BEAT] == nil)
|
||||
|
||||
-- a real party, because the human has to win the battle for the loss line
|
||||
-- to print at all
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 60),
|
||||
Pokemon.new(game.data, "NIDOKING", 58),
|
||||
Pokemon.new(game.data, "STARMIE", 58),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
|
||||
-- walk in from the north; the two elevator warps sit on row 15, so the
|
||||
-- approach cannot come from below
|
||||
U.teleport(game, MAP, TRIGGER.x, TRIGGER.y - 1, "down")
|
||||
local ow = game.overworld
|
||||
if not ow.map:isWalkableCell(TRIGGER.x, TRIGGER.y - 1) then
|
||||
-- a map edit moved the free cell: any walkable neighbour of the trigger
|
||||
-- works, the script only reads the tile the player lands on
|
||||
local sides = { { 0, -1, "down" }, { -1, 0, "right" }, { 1, 0, "left" } }
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) then
|
||||
U.log("standing on", cx, cy, "facing", s[3], "instead")
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
ow = game.overworld
|
||||
U.hold(game, s[3] == "down" and "down" or (s[3] == "right" and "right" or "left"), 20)
|
||||
break
|
||||
end
|
||||
end
|
||||
else
|
||||
U.hold(game, "down", 20)
|
||||
end
|
||||
U.wait(10)
|
||||
check("player stepped onto the trigger tile (24,14)",
|
||||
ow.player.cellX == TRIGGER.x and ow.player.cellY == TRIGGER.y)
|
||||
check("the ambush script is running", ow.runner:isRunning())
|
||||
|
||||
U.log("The cutscene is yours now: press A to read, then fight and win.")
|
||||
U.log("Right looks like both Rockets closing in -- Jessie stopping one tile")
|
||||
U.log("above you, James coming all the way down to stand at your right -- and")
|
||||
U.log("after you win, \"ROCKET: Such a dreadful twerp!\" appearing on the")
|
||||
U.log("battle screen just before the money line. The near-miss to watch for")
|
||||
U.log("is James halting three tiles up by the wall, or that line showing up")
|
||||
U.log("in the overworld box after the payout with no ROCKET: tag on it.")
|
||||
U.log("Two more checks print below as you get to them.")
|
||||
|
||||
local function npcNamed(name)
|
||||
for _, n in ipairs(game.overworld and game.overworld.npcs or {}) do
|
||||
if n.def and n.def.name == name then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local approachDone, battleSeen = false, false
|
||||
while true do
|
||||
if not approachDone then
|
||||
local j, s = npcNamed(JAMES), npcNamed(JESSIE)
|
||||
local ow2 = game.overworld
|
||||
if j and s and not j.moving and not s.moving and ow2
|
||||
and #(ow2.scriptMoves or {}) == 0
|
||||
and (j.cellY > 10 or s.cellY > 10) then
|
||||
approachDone = true
|
||||
check("James walked the full four tiles to (25,14) facing left",
|
||||
j.cellX == EXPECT[JAMES].x and j.cellY == EXPECT[JAMES].y
|
||||
and j.facing == EXPECT[JAMES].facing)
|
||||
check("Jessie stopped three down at (24,13) facing the player",
|
||||
s.cellX == EXPECT[JESSIE].x and s.cellY == EXPECT[JESSIE].y
|
||||
and s.facing == EXPECT[JESSIE].facing)
|
||||
U.log("James at", j.cellX, j.cellY, j.facing,
|
||||
"Jessie at", s.cellX, s.cellY, s.facing)
|
||||
U.shot(game, SHOT_DIR .. "/jj866_approach.png")
|
||||
end
|
||||
end
|
||||
if not battleSeen then
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == BattleState then
|
||||
battleSeen = true
|
||||
-- BattleState prints endBattleText between _TrainerDefeatedText and
|
||||
-- _MoneyForWinningText, so an armed field IS the ordering fix
|
||||
check("the battle carries the loss line as its end-battle text",
|
||||
type(top.endBattleText) == "string" and top.endBattleText ~= ""
|
||||
and top.endBattleText == texts[3])
|
||||
if type(top.endBattleText) == "string" then
|
||||
U.log("armed:", (top.endBattleText:gsub("\n", " / ")))
|
||||
end
|
||||
end
|
||||
end
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,188 @@
|
||||
-- Manual check of the ghost MAROWAK send-off on POKEMON_TOWER_6F (#867).
|
||||
-- PokemonTower6FMarowakDepartedText (pokered scripts/PokemonTower6F.asm) is
|
||||
-- two texts: the CUBONE's-mother line, then PlayCry RESTLESS_SOUL + 30 frames
|
||||
-- before the calmed line; the port showed only the calmed line and no cry.
|
||||
-- Never under POKEPORT_SPEED -- the cry-then-text beat is the thing under test.
|
||||
-- POKEPORT_DRIVER=tests/drivers/marowak_departed_bug867_test.lua POKEPORT_IDENTITY=bug867 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- machine checks ----------------------------------------------------
|
||||
-- Both keys come out of the stock extractor (data/generated/text.lua); a
|
||||
-- rename there would drop story3.lua onto its hand fallbacks, which still
|
||||
-- displays but is worth knowing about.
|
||||
local mother = game.data.text._PokemonTower6FGhostWasCubonesMotherText
|
||||
local calmed = game.data.text._PokemonTower6FSoulWasCalmedText
|
||||
check("_PokemonTower6FGhostWasCubonesMotherText extracted",
|
||||
type(mother) == "string" and mother ~= "")
|
||||
check("_PokemonTower6FSoulWasCalmedText extracted",
|
||||
type(calmed) == "string" and calmed ~= "")
|
||||
if type(mother) == "string" then
|
||||
U.log("first line reads:", (mother:gsub("[\n\011\012]", " / ")))
|
||||
end
|
||||
if type(calmed) == "string" then
|
||||
U.log("second line reads:", (calmed:gsub("[\n\011\012]", " / ")))
|
||||
end
|
||||
check("MAROWAK is a real species (RESTLESS_SOUL EQU MAROWAK)",
|
||||
game.data.pokemon.MAROWAK ~= nil)
|
||||
|
||||
local opts = game.save.options or {}
|
||||
U.log("sfxVol", tostring(opts.sfxVol), "musicVol", tostring(opts.musicVol))
|
||||
if opts.sfxVol == 0 then
|
||||
U.log("sfxVol is 0: raise it in OPTION or the cry cannot be judged")
|
||||
end
|
||||
|
||||
-- ---- reach the trigger -------------------------------------------------
|
||||
-- pokered scripts/PokemonTower6F.asm PokemonTower6FMarowakCoords:
|
||||
-- dbmapcoord 10, 16 (a coord array, not an object). Row 17 is solid wall
|
||||
-- and (9, 16) is the stairwell warp, so the approach is from (10, 15)
|
||||
-- facing down, same as tests/drivers/ghost_unveil_bug492_test.lua.
|
||||
local MAP = "POKEMON_TOWER_6F"
|
||||
local TRIGGER = { x = 10, y = 16 }
|
||||
local STAND = { x = 10, y = 15, facing = "down", step = "down" }
|
||||
|
||||
-- one mon, one damaging move: the A-mash below always picks FIGHT slot 1,
|
||||
-- and a stat move there stalls the run (route.lua learned this the hard way)
|
||||
local mon = Pokemon.new(game.data, "MEWTWO", 100)
|
||||
if game.data.moves.PSYCHIC_M then
|
||||
mon.moves = { { id = "PSYCHIC_M", pp = 99 } }
|
||||
end
|
||||
game.save.party = { mon }
|
||||
game.save.player.name = "RED"
|
||||
-- the scope buys the unveil so the ghost can be damaged at all (#492)
|
||||
game.save.inventory.SILPH_SCOPE = 1
|
||||
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = nil
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
check("the overworld is up on " .. MAP, ow ~= nil and ow.map ~= nil)
|
||||
|
||||
if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit moved the approach: stand on any walkable neighbour of the
|
||||
-- trigger that is not the stairwell warp and step back onto it.
|
||||
-- {dx, dy, facing} is the trigger-to-stand offset plus the direction
|
||||
-- that walks back onto the trigger cell.
|
||||
local sides = { { 0, -1, "down" }, { 1, 0, "left" },
|
||||
{ -1, 0, "right" }, { 0, 1, "up" } }
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow.map:warpAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is blocked, approaching from"):format(STAND.x, STAND.y),
|
||||
cx, cy, "stepping", s[3])
|
||||
STAND = { x = cx, y = cy, facing = s[3], step = s[3] }
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- walk onto the trigger; MapScripts onStep fires on the completed step
|
||||
U.hold(game, STAND.step, 20)
|
||||
U.wait(20)
|
||||
check("the Be-gone text opened", game.stack:top() ~= game.overworld)
|
||||
|
||||
-- ---- win the battle ----------------------------------------------------
|
||||
local sawBattle = false
|
||||
for _ = 1, 1500 do
|
||||
-- onFinish sets the flag and queues the departed rows in the same call,
|
||||
-- so break on the flag BEFORE tapping: a stray A here would eat the
|
||||
-- CUBONE's-mother box before it is recorded
|
||||
if game.save.flags.EVENT_BEAT_GHOST_MAROWAK then break end
|
||||
if getmetatable(game.stack:top()) == BattleState then sawBattle = true end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
check("the MAROWAK battle opened", sawBattle)
|
||||
check("the battle was won (EVENT_BEAT_GHOST_MAROWAK set)",
|
||||
game.save.flags.EVENT_BEAT_GHOST_MAROWAK == true)
|
||||
|
||||
-- ---- record the send-off boxes -----------------------------------------
|
||||
-- Each box is sampled the frame it is first seen: the calmed box carries
|
||||
-- the armed cry as opts.auto {sound, wait} (src/script/Commands.lua), and
|
||||
-- TextBox clears .auto once the cry has played, so a late read looks like
|
||||
-- no cry at all.
|
||||
local boxes = {}
|
||||
local lastBox = nil
|
||||
local budget = 1200
|
||||
while budget > 0 do
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == TextBox then
|
||||
if top ~= lastBox then
|
||||
lastBox = top
|
||||
local shown = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
for _, line in ipairs(page) do shown[#shown + 1] = line end
|
||||
end
|
||||
boxes[#boxes + 1] = {
|
||||
text = table.concat(shown, " / "),
|
||||
cry = top.auto ~= nil and top.auto.sound ~= nil,
|
||||
}
|
||||
U.log(("box %d reads:"):format(#boxes), boxes[#boxes].text)
|
||||
-- let the typewriter finish before the shot so the capture shows the
|
||||
-- line; the auto sample above already happened on the open frame
|
||||
for _ = 1, 240 do
|
||||
if top.waiting or top.done or game.stack:top() ~= top then break end
|
||||
U.wait(1)
|
||||
budget = budget - 1
|
||||
end
|
||||
U.shot(game, DIR .. ("/bug867_box%d.png"):format(#boxes))
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
budget = budget - 4
|
||||
else
|
||||
if #boxes >= 2 then break end
|
||||
U.wait(1)
|
||||
budget = budget - 1
|
||||
end
|
||||
end
|
||||
|
||||
check("the send-off is two boxes, not one (#867)", #boxes == 2)
|
||||
check("box 1 is the CUBONE's-mother line",
|
||||
boxes[1] ~= nil and boxes[1].text:find("CUBONE", 1, true) ~= nil)
|
||||
check("box 1 opens silent (the asm plays no cry before it)",
|
||||
boxes[1] ~= nil and not boxes[1].cry)
|
||||
check("box 2 is the calmed line",
|
||||
boxes[2] ~= nil and boxes[2].text:find("calmed", 1, true) ~= nil)
|
||||
check("box 2 opens with the MAROWAK cry armed",
|
||||
boxes[2] ~= nil and boxes[2].cry == true)
|
||||
|
||||
-- ---- the trigger is spent ----------------------------------------------
|
||||
-- step off and back onto (10, 16): with the flag set, onStep must pass
|
||||
local back = ({ down = "up", up = "down", left = "right", right = "left" })
|
||||
[STAND.step]
|
||||
U.hold(game, back, 20)
|
||||
U.wait(10)
|
||||
U.hold(game, STAND.step, 20)
|
||||
U.wait(20)
|
||||
check("re-stepping the trigger cell stays quiet",
|
||||
getmetatable(game.stack:top()) ~= TextBox)
|
||||
U.shot(game, DIR .. "/bug867_after.png")
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
|
||||
-- ---- hand off ----------------------------------------------------------
|
||||
U.log("The pad is yours on 6F with the ghost already sent off. What should")
|
||||
U.log("have happened: after the win, \"The GHOST was the / restless soul of /")
|
||||
U.log("CUBONE's mother!\" first, then the MAROWAK cry sounds as the second box")
|
||||
U.log("opens with \"The mother's soul / was calmed.\" The old bug jumped")
|
||||
U.log("straight to the calmed line, no mother line and no cry at all.")
|
||||
U.log("Screenshots: " .. DIR .. "/bug867_*.png")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,172 @@
|
||||
-- Manual check that a Pikachu taking the field says the short "Pika!" (#837).
|
||||
-- pokeyellow engine/battle/core.asm SendOutMon .starterPikachu (:1807-1817)
|
||||
-- voices PikachuCry11, or PikachuCry37 when IsPlayerPikachuAsleepInParty; the
|
||||
-- port called PlayCry bare and got clip 1, the long title "Pikachuuu"
|
||||
-- (engine/movie/title.asm:146). Never add POKEPORT_SPEED here: it scales the
|
||||
-- logic clock and not audio, so the cries stop lining up with what you see.
|
||||
-- POKEPORT_DRIVER=tests/drivers/pika_entrance_cry_bug837_test.lua POKEPORT_IDENTITY=bug837 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Sound = require("src.core.Sound")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function idle()
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
-- Red and Blue carry no PCM clips at all (RomExtractor extractPikachuCries
|
||||
-- only runs on Yellow), so playPikaCry returns nil there and every Pikachu
|
||||
-- keeps its chip cry from CryData: nothing below is observable off Yellow.
|
||||
local audio = game.data.audio
|
||||
local clips = audio and audio.pikaCries
|
||||
local onYellow = check("running Yellow", GameVersion.isYellow())
|
||||
local haveClips = check("the cache carries the PCM clip set",
|
||||
type(clips) == "number")
|
||||
if not (onYellow and haveClips) then
|
||||
U.log("Import a Yellow ROM and rerun with POKEPORT_VERSION=yellow. On Red")
|
||||
U.log("and Blue there is no voiced Pikachu to get wrong.")
|
||||
idle()
|
||||
end
|
||||
-- NUM_PIKA_CRIES is 42 (pokeyellow constants/music_constants.asm), and the
|
||||
-- importer writes cry_01..cry_42.wav in PikachuCriesPointerTable order, so
|
||||
-- clip 37 existing is what makes the asleep case reachable at all.
|
||||
check("clip 37 fits inside the " .. tostring(clips) .. " clips extracted",
|
||||
clips >= 37)
|
||||
|
||||
-- resolve the two asset keys for real: a missing or unreadable wav makes
|
||||
-- playPikaCry return nil and the cry falls through to the chip cry, which
|
||||
-- is a different wrong sound from the one this issue is about. Stopped in
|
||||
-- the same frame, so neither is audible here.
|
||||
local function resolves(n)
|
||||
local src = Sound.playPikaCry(game.data, n)
|
||||
if src then src:stop() end
|
||||
return src ~= nil
|
||||
end
|
||||
check("pika_cries/cry_11.wav loads", resolves(11))
|
||||
check("pika_cries/cry_37.wav loads", resolves(37))
|
||||
|
||||
local opts = game.save.options or {}
|
||||
check("sfxVol is not muted (it reads " .. tostring(opts.sfxVol) .. ")",
|
||||
(opts.sfxVol or 0) > 0)
|
||||
check("PIKACHU VOL is not muted (it reads " .. tostring(opts.pikaVol) .. ")",
|
||||
(opts.pikaVol or 0) > 0)
|
||||
|
||||
-- Sound.playPikaCry emits "sound.played" with name = "PIKACHU_PCM_<n>";
|
||||
-- this is the same feed mods read and tests/mod_audio_tests.lua subscribes
|
||||
-- to, so the number below is the clip the engine actually asked for.
|
||||
local heardCries = {}
|
||||
local events = game.mods and game.mods.events
|
||||
if not check("the sound.played feed is live", events ~= nil and events.on ~= nil) then
|
||||
idle()
|
||||
end
|
||||
events:on("sound.played", function(p)
|
||||
if p and p.kind == "cry" then heardCries[#heardCries + 1] = p.name end
|
||||
end, nil, "bug837driver")
|
||||
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "PIKACHU", 20),
|
||||
Pokemon.new(game.data, "CHARMANDER", 20),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
check("PIKACHU leads the party", game.save.party[1].species == "PIKACHU")
|
||||
|
||||
-- Route 1 so the handoff below has tall grass in reach. The route sign
|
||||
-- sits at (9, 27) (pokered data/maps/objects/Route1.asm bg_event), and the
|
||||
-- cell under it is the open path you read it from.
|
||||
local MAP = "ROUTE_1"
|
||||
local STAND = { x = 9, y = 28, facing = "up" }
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
if not check("the overworld is up on " .. MAP, ow ~= nil) then idle() end
|
||||
|
||||
-- a map edit or a mod can wall that cell off; widen out to any free
|
||||
-- walkable neighbour rather than stranding the player inside scenery
|
||||
local function freeNear(map, x, y)
|
||||
for r = 1, 6 do
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
local cx, cy = x + dx, y + dy
|
||||
if map:inBounds(cx, cy) and map:isWalkableCell(cx, cy)
|
||||
and not ow:npcAtCell(cx, cy) then
|
||||
return cx, cy
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
local cx, cy = freeNear(ow.map, STAND.x, STAND.y)
|
||||
if cx then
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
|
||||
cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, STAND.facing)
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
end
|
||||
end
|
||||
check("the player is standing somewhere walkable",
|
||||
ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY))
|
||||
|
||||
-- Push the encounter rather than walking into grass: the cry under test is
|
||||
-- the player's own send-out, and a stepped encounter would put the wild
|
||||
-- rolls and a second cry ahead of it. RATTATA keeps the enemy's cry a chip
|
||||
-- cry, so it can never be confused with the PCM clip being checked.
|
||||
local function runEntrance(asleep, label, shotPath)
|
||||
for i = #heardCries, 1, -1 do heardCries[i] = nil end
|
||||
game.save.party[1].status = asleep and "SLP" or nil
|
||||
local wild = BattleState.newWild(game, "RATTATA", 3)
|
||||
wild.onFinish = function() end
|
||||
game.overworld:pushBattle(wild)
|
||||
local pcm
|
||||
for _ = 1, 500 do
|
||||
for _, name in ipairs(heardCries) do
|
||||
if name:find("PIKACHU_PCM_", 1, true) == 1 then pcm = name end
|
||||
end
|
||||
if pcm then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
U.log(label .. " recorded:", table.concat(heardCries, ", "))
|
||||
if shotPath then U.shot(game, shotPath) end
|
||||
return pcm, wild
|
||||
end
|
||||
|
||||
-- asleep first, so the run ends on the everyday case and what is ringing
|
||||
-- during the handoff is the clip the issue is really about
|
||||
local slept = runEntrance(true, "asleep send-out",
|
||||
DIR .. "/bug837_1_asleep.png")
|
||||
check("an asleep PIKACHU is sent out with PCM clip 37 (PikachuCry37)",
|
||||
slept == "PIKACHU_PCM_37")
|
||||
|
||||
U.teleport(game, MAP, ow.player.cellX, ow.player.cellY, STAND.facing)
|
||||
U.wait(10)
|
||||
local awake = runEntrance(false, "awake send-out",
|
||||
DIR .. "/bug837_2_awake.png")
|
||||
check("a healthy PIKACHU is sent out with PCM clip 11 (PikachuCry11)",
|
||||
awake == "PIKACHU_PCM_11")
|
||||
check("clip 1, the long title-screen cry, is not what was played",
|
||||
awake ~= "PIKACHU_PCM_1")
|
||||
|
||||
U.log("You are in the second battle, the one with PIKACHU awake. The cry")
|
||||
U.log("as it grew out of the ball should be the short bright \"Pika!\", the")
|
||||
U.log("same one you hear pressing START on the Yellow title screen. The bug")
|
||||
U.log("played the other title cry instead: the long drawn-out \"Pikachuuu\"")
|
||||
U.log("that opens the title, roughly a second and a half of it, which is")
|
||||
U.log("easy to miss as merely slow rather than wrong. Run away and walk")
|
||||
U.log("into the grass north of here for as many more send-outs as you like;")
|
||||
U.log("put PIKACHU to sleep and it turns into the sleepy clip 37 instead.")
|
||||
U.log("Screenshots of both entrances are in " .. DIR .. ".")
|
||||
|
||||
idle()
|
||||
end
|
||||
@@ -0,0 +1,164 @@
|
||||
-- Manual check that a warp arrival hides Pikachu under the player (#863):
|
||||
-- pokeyellow spawns on the player's own coords and the follow buffer walks
|
||||
-- it out, but before the fix it popped in already beside him.
|
||||
-- Warp cells: pokered data/maps/objects/RedsHouse1F.asm / RedsHouse2F.asm.
|
||||
-- POKEPORT_DRIVER=tests/drivers/pikachu_warp_spawn_bug863_test.lua POKEPORT_IDENTITY=bug863 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
-- Never add POKEPORT_SPEED; the identity needs an imported Yellow cache.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local PF = require("src.world.PikachuFollower")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
check("running as Yellow (needs POKEPORT_VERSION=yellow)",
|
||||
GameVersion.isYellow())
|
||||
|
||||
-- the follower only spawns behind EVENT_GOT_STARTER with a healthy
|
||||
-- PIKACHU in the party (PikachuFollower shouldSpawn)
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) }
|
||||
game.save.onBike = false
|
||||
game.save.player.name = "bryan"
|
||||
|
||||
-- pokered RedsHouse1F.asm: warp_event 7, 1 -> REDS_HOUSE_2F, and
|
||||
-- RedsHouse2F.asm: warp_event 7, 1 back down. Read the live map data
|
||||
-- so a hack or mod that moved the stairs still points us at them.
|
||||
local function warpTo(fromMap, destMap)
|
||||
local def = game.data.maps[fromMap]
|
||||
for _, w in ipairs(def and def.warps or {}) do
|
||||
if w.destMap == destMap then return w.x, w.y end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local wx, wy = warpTo("REDS_HOUSE_1F", "REDS_HOUSE_2F")
|
||||
check("REDS_HOUSE_1F has a warp to REDS_HOUSE_2F", wx ~= nil)
|
||||
wx, wy = wx or 7, wy or 1
|
||||
|
||||
local follower = function() return PF.current(game.overworld) end
|
||||
|
||||
local function overlapsPlayer()
|
||||
local npc = follower()
|
||||
local p = game.overworld and game.overworld.player
|
||||
return npc and p and npc.cellX == p.cellX and npc.cellY == p.cellY
|
||||
end
|
||||
|
||||
-- press-and-hold dir until the map flips, releasing the instant it
|
||||
-- does so no queued step drags the player off the arrival warp cell
|
||||
local function walkUntilMap(dir, targetMap, maxFrames)
|
||||
for _ = 1, maxFrames do
|
||||
local ow = game.overworld
|
||||
if ow and ow.map and ow.map.id == targetMap then break end
|
||||
table.insert(game.input.pressQueue, dir)
|
||||
game.input.state[dir] = true
|
||||
coroutine.yield()
|
||||
end
|
||||
game.input.state[dir] = false
|
||||
U.wait(45) -- warp fade + arrival settle
|
||||
local ow = game.overworld
|
||||
return ow and ow.map and ow.map.id == targetMap
|
||||
end
|
||||
|
||||
-- stand two below the stairs facing up; fall back to any walkable cell
|
||||
-- below the warp if a mod reshaped the room
|
||||
local sx, sy = wx, wy + 2
|
||||
U.teleport(game, "REDS_HOUSE_1F", sx, sy, "up")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
if not ow.map:isWalkableCell(sx, sy) then
|
||||
for dy = 1, 3 do
|
||||
if ow.map:isWalkableCell(wx, wy + dy) then
|
||||
sx, sy = wx, wy + dy
|
||||
U.teleport(game, "REDS_HOUSE_1F", sx, sy, "up")
|
||||
U.wait(10)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
-- U.teleport is itself a fresh map load, so the fixed spawn already
|
||||
-- parks Pikachu on the player's cell here
|
||||
check("follower spawned on map load", follower() ~= nil)
|
||||
check("map-load spawn is on the player's own cell", overlapsPlayer())
|
||||
|
||||
-- climb the stairs
|
||||
check("walked up into REDS_HOUSE_2F",
|
||||
walkUntilMap("up", "REDS_HOUSE_2F", 240))
|
||||
check("warp arrival upstairs: Pikachu hidden under the player",
|
||||
overlapsPlayer())
|
||||
U.shot(game, SHOT_DIR .. "/bug863_2f_arrival.png")
|
||||
|
||||
-- the draw sort tie-break (#863): sharing the player's py must list the
|
||||
-- follower first so he draws over it. The sort runs in draw, and the
|
||||
-- shot above rendered a frame, so entities order is post-sort here.
|
||||
do
|
||||
local npc = follower()
|
||||
local p = game.overworld.player
|
||||
if npc and p and npc.py == p.py then
|
||||
local ni, pi
|
||||
for i, e in ipairs(game.overworld.entities) do
|
||||
if e == npc then ni = i elseif e == p then pi = i end
|
||||
end
|
||||
check("draw sort puts the hidden follower under the player",
|
||||
ni ~= nil and pi ~= nil and ni < pi)
|
||||
end
|
||||
end
|
||||
|
||||
-- walk off the stairs: the trail should pull Pikachu out one behind
|
||||
U.hold(game, "down", 20)
|
||||
U.wait(20)
|
||||
U.hold(game, "down", 20)
|
||||
U.wait(30)
|
||||
do
|
||||
local npc = follower()
|
||||
local p = game.overworld.player
|
||||
check("two steps later Pikachu trails one cell behind",
|
||||
npc and p and npc.cellX == p.cellX and npc.cellY == p.cellY - 1)
|
||||
check("and it faces down, walking out of the stairwell",
|
||||
npc and npc.facing == "down")
|
||||
end
|
||||
U.shot(game, SHOT_DIR .. "/bug863_2f_trailing.png")
|
||||
|
||||
-- back down the same stairs: descent must hide it the same way
|
||||
check("walked back down into REDS_HOUSE_1F",
|
||||
walkUntilMap("up", "REDS_HOUSE_1F", 240))
|
||||
check("warp arrival downstairs: Pikachu hidden under the player",
|
||||
overlapsPlayer())
|
||||
U.shot(game, SHOT_DIR .. "/bug863_1f_return.png")
|
||||
|
||||
-- regression: a connection seam is the keepPikachu path (#427), not a
|
||||
-- respawn, so the follower must ride across it, not vanish or repark
|
||||
U.teleport(game, "PALLET_TOWN", 10, 2, "up")
|
||||
U.wait(10)
|
||||
check("crossed the Pallet Town north seam into ROUTE_1",
|
||||
walkUntilMap("up", "ROUTE_1", 300))
|
||||
do
|
||||
local npc = follower()
|
||||
local p = game.overworld.player
|
||||
check("follower survived the connection crossing", npc ~= nil)
|
||||
check("and stayed within trailing range of the player",
|
||||
npc and p and math.abs(npc.cellX - p.cellX)
|
||||
+ math.abs(npc.cellY - p.cellY) <= 2)
|
||||
end
|
||||
U.shot(game, SHOT_DIR .. "/bug863_route1_seam.png")
|
||||
|
||||
local sfx = game.save.options and game.save.options.sfxVol
|
||||
if sfx == 0 then
|
||||
U.log("note: sfxVol is 0, Pikachu's steps and voice will be silent")
|
||||
end
|
||||
|
||||
U.log("The shots above tell the story: on both stair arrivals only the")
|
||||
U.log("player should be visible, Pikachu is tucked under him until he")
|
||||
U.log("steps away, then it follows one cell behind facing his way.")
|
||||
U.log("If a Pikachu sits beside him the moment a warp lands, that is")
|
||||
U.log("the old bug. The pad is yours; warp around and watch spawns.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,149 @@
|
||||
-- Manual check of the title main menu + CONTINUE info box colors (#870):
|
||||
-- both must follow the COLORS display mode (CLASSIC pea greens) instead of
|
||||
-- staying a raw white trueColor hole, while gbc keeps #133's white paper /
|
||||
-- black ink (pokered engine/menus/main_menu.asm RunDefaultPaletteCommand).
|
||||
-- Palette shading is the moment under test, so POKEPORT_SPEED stays unset.
|
||||
-- POKEPORT_DRIVER=tests/drivers/title_menu_palette_bug870_test.lua POKEPORT_IDENTITY=bug870 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local P = require("src.render.PaletteFX")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
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
|
||||
|
||||
-- Count near-white pixels in a captured frame. CLASSIC's lightest shade
|
||||
-- is (155,188,15) and no blend of the four pea greens (or the letterbox)
|
||||
-- reaches 250+, so any white here can only be an unshaded region -- the
|
||||
-- exact white hole #870 is about. Reads the PNG back through
|
||||
-- love.image so the check sees what actually hit the window.
|
||||
local function whiteCount(path)
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return nil end
|
||||
local bytes = f:read("*a")
|
||||
f:close()
|
||||
local ok, img = pcall(function()
|
||||
return love.image.newImageData(
|
||||
love.filesystem.newFileData(bytes, "shot.png"))
|
||||
end)
|
||||
if not ok or not img then return nil end
|
||||
local n = 0
|
||||
for y = 0, img:getHeight() - 1 do
|
||||
for x = 0, img:getWidth() - 1 do
|
||||
local r, g, b = img:getPixel(x, y)
|
||||
if r > 0.98 and g > 0.98 and b > 0.98 then n = n + 1 end
|
||||
end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
-- A real save on disk first: hasSave in TitleState:openMenu checks the
|
||||
-- save FILE, not the in-memory table, and only then lists CONTINUE.
|
||||
-- No map coordinates anywhere in this test -- the bug lives on the title
|
||||
-- screen, before any map.
|
||||
U.newGame(game)
|
||||
check("reached the overworld", game.overworld ~= nil)
|
||||
check("save written so the menu lists CONTINUE",
|
||||
require("src.core.SaveData").save(game.save))
|
||||
|
||||
-- flip COLORS the way the options screen does, then power-cycle to the
|
||||
-- title (returnToTitle skips the intro movie, unlike a cold boot)
|
||||
game.save.options.colors = "classic"
|
||||
P.applyOptions(game.save.options)
|
||||
game:returnToTitle()
|
||||
U.wait(30)
|
||||
|
||||
local TitleState = require("src.ui.TitleState")
|
||||
local title = game.stack:top()
|
||||
check("back on the title screen", getmetatable(title) == TitleState)
|
||||
|
||||
U.tap(game, "start")
|
||||
U.wait(10)
|
||||
local menu = game.stack:top()
|
||||
check("main menu opened and set a titleUiBox",
|
||||
menu ~= title and menu ~= nil and menu.titleUiBox ~= nil)
|
||||
|
||||
-- The fix itself: the box overlay must be a GRAYS palette zone the shade
|
||||
-- shader runs on. A colors == false zone would make Renderer:blitCanvas
|
||||
-- re-blit the rect with NO shader, so effectiveColors never substitutes
|
||||
-- the mono/inverted modes there -- the pre-#870 white hole.
|
||||
local zones = title.sgbPalettes and title:sgbPalettes(game)
|
||||
local boxZone, bare = nil, false
|
||||
for _, z in ipairs(zones or {}) do
|
||||
if z.colors == false then bare = true end
|
||||
if z.colors == P.GRAYS then boxZone = z end
|
||||
end
|
||||
check("no trueColor (colors == false) zone over the menu box", not bare)
|
||||
check("the titleUiBox rides a GRAYS palette zone", boxZone ~= nil)
|
||||
-- effectiveColors under classic substitutes CLASSIC; the trailing
|
||||
-- permute is the identity while no shade map is armed, so == holds
|
||||
check("CLASSIC substitutes the GRAYS box zone",
|
||||
P.effectiveColors(P.GRAYS) == P.CLASSIC)
|
||||
|
||||
local shot1 = SHOT_DIR .. "/bug870_menu_classic.png"
|
||||
if U.shot(game, shot1) then
|
||||
local n = whiteCount(shot1)
|
||||
U.log("white pixels in the menu shot:", tostring(n))
|
||||
check("CLASSIC main menu shot has zero raw-white pixels",
|
||||
n ~= nil and n == 0)
|
||||
end
|
||||
|
||||
-- with a save present CONTINUE is first, so the cursor is already on it
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
local info = game.stack:top()
|
||||
check("CONTINUE info box open with its titleUiBox",
|
||||
info ~= nil and info ~= menu and info.titleUiBox ~= nil
|
||||
and info.titleUiBox[1] == 4 and info.titleUiBox[2] == 7)
|
||||
|
||||
local shot2 = SHOT_DIR .. "/bug870_info_classic.png"
|
||||
if U.shot(game, shot2) then
|
||||
local n = whiteCount(shot2)
|
||||
U.log("white pixels in the info shot:", tostring(n))
|
||||
check("CLASSIC CONTINUE info shot has zero raw-white pixels",
|
||||
n ~= nil and n == 0)
|
||||
end
|
||||
|
||||
-- #133 regression gate: under gbc the GRAYS zone must pass through the
|
||||
-- shader unchanged, so the box comes back white paper / black ink while
|
||||
-- the LOGO zones keep the title colored around it
|
||||
P.applyOptions({ colors = "gbc" })
|
||||
U.wait(5)
|
||||
check("gbc leaves the GRAYS box zone alone (the #133 white box)",
|
||||
P.effectiveColors(P.GRAYS) == P.GRAYS)
|
||||
local shot3 = SHOT_DIR .. "/bug870_info_gbc.png"
|
||||
if U.shot(game, shot3) then
|
||||
local n = whiteCount(shot3)
|
||||
U.log("white pixels in the gbc shot:", tostring(n))
|
||||
check("gbc info box paper is white again", n ~= nil and n > 0)
|
||||
end
|
||||
|
||||
-- the inverted modes must now invert the box with the screen too
|
||||
P.applyOptions({ colors = "og_inv" })
|
||||
U.wait(5)
|
||||
local inv = P.effectiveColors(P.GRAYS)
|
||||
check("OG INV inverts the box paper to black",
|
||||
inv ~= nil and inv[1] ~= nil and inv[1][1] == 0)
|
||||
U.shot(game, SHOT_DIR .. "/bug870_info_oginv.png")
|
||||
|
||||
-- hand over in the bug's own mode
|
||||
game.save.options.colors = "classic"
|
||||
P.applyOptions(game.save.options)
|
||||
U.wait(2)
|
||||
|
||||
U.log(fails == 0 and "PASS all machine checks"
|
||||
or ("FAIL " .. fails .. " machine check(s), see above"))
|
||||
U.log("The CONTINUE info box on screen is in CLASSIC now; its paper should")
|
||||
U.log("be the same pea green as the title behind it, with dark green ink,")
|
||||
U.log("just like the in-game START menu. Before #870 this box and the")
|
||||
U.log("main menu were a pure white rectangle over the green title.")
|
||||
U.log("B backs out to the menu; OPTION flips COLORS to eyeball the rest.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
-- CacheFs stays headless-safe: plain luajit has no love global, and the
|
||||
-- modkit validate/pack driver reaches CacheFs.read through Data:load when
|
||||
-- an optional generated module (audio) is missing from the checkout
|
||||
-- (issue #850). With no portable root and no love there is no save
|
||||
-- directory to read from, so the read is a nil miss, not a crash.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check = T.check
|
||||
|
||||
check(_G.love == nil, "suite runs with no love global")
|
||||
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
|
||||
check(CacheFs.read("data/generated/audio.lua") == nil,
|
||||
"read is a nil miss headless, not a crash")
|
||||
check(CacheFs.readActive("data/generated/audio.lua") == nil,
|
||||
"readActive is a nil miss headless, not a crash")
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,211 @@
|
||||
-- Disable blocks the move the slower mon ALREADY selected, on the very
|
||||
-- turn the Disable lands (#860). pokered runs the test at execution
|
||||
-- time, not at selection time: CheckPlayerStatusConditions
|
||||
-- .TriedToUseDisabledMoveCheck (engine/battle/core.asm:3437-3447) compares
|
||||
-- wPlayerDisabledMoveNumber against wPlayerSelectedMove and jumps to
|
||||
-- ExecutePlayerMoveDone when they match -- "prevents a disabled move that
|
||||
-- was selected before being disabled from being used", in the asm's own
|
||||
-- comment. The enemy copy is .checkIfTriedToUseDisabledMove
|
||||
-- (core.asm:5752+). The port only refused a disabled move at menu time,
|
||||
-- so the second mover still fired the move it had latched before the
|
||||
-- Disable resolved.
|
||||
--
|
||||
-- The check sits after the confusion block and before the paralysis roll,
|
||||
-- so this suite also pins the neighbours: the counter tick that clears an
|
||||
-- expired Disable still runs first, and a move that was never disabled is
|
||||
-- untouched.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
local Font = require("src.render.Font")
|
||||
Font.load(Data)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load(Data)
|
||||
|
||||
-- the fixture dataset has no status move; this dataset is this file's own
|
||||
-- copy (fixtures.fresh), so registering one here cannot leak into another
|
||||
-- case. Accuracy 100 keeps DisableEffect's MoveHitTest out of the way.
|
||||
Data.moves.FIX_DISABLE = {
|
||||
id = "FIX_DISABLE", index = 90, name = "FIX DISABLE",
|
||||
type = "NORMAL", power = 0, accuracy = 100, pp = 20,
|
||||
effect = "DISABLE_EFFECT",
|
||||
}
|
||||
|
||||
-- Deterministic rolls: the minimum of every range, except DisableEffect's
|
||||
-- own "1-8 turns disabled" roll (effects.asm:1343-1345), which is pinned
|
||||
-- at 4. A rolled 1 would be spent by the disabled mon's own counter tick
|
||||
-- in the same CheckStatusConditions pass -- vanilla behaviour, but it
|
||||
-- clears the disable before .TriedToUseDisabledMoveCheck can see it, so it
|
||||
-- is not the case this suite is about. rng(0, 255) -> 0 makes every
|
||||
-- accuracy roll hit.
|
||||
local function rolls(disableTurns)
|
||||
return function(a, b)
|
||||
if a == 1 and b == 8 then return disableTurns end
|
||||
if a then return a end
|
||||
return 0
|
||||
end
|
||||
end
|
||||
|
||||
-- playerFirst decides who lands the Disable; the other side is the one
|
||||
-- whose already-selected move has to die. Both mons get FIX_TACKLE in
|
||||
-- slot 1 (the slot DisableEffect picks with the min roll) and FIX_SCRATCH
|
||||
-- in slot 2 as the never-disabled control.
|
||||
local function newBattle(playerFirst, disableTurns)
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
|
||||
local game = { data = Data, save = save,
|
||||
stack = { top = function() return nil end, push = function() end } }
|
||||
local battle = BattleState.newWild(game, "FIXMON_C", 30)
|
||||
battle.rng = rolls(disableTurns or 4)
|
||||
|
||||
local function loadout(battler)
|
||||
battler.mon.moves = {
|
||||
{ id = "FIX_TACKLE", pp = 35 },
|
||||
{ id = "FIX_SCRATCH", pp = 35 },
|
||||
{ id = "FIX_DISABLE", pp = 20 },
|
||||
}
|
||||
battler.curMoves = battler.mon.moves
|
||||
end
|
||||
loadout(battle.player)
|
||||
loadout(battle.enemy)
|
||||
|
||||
-- no speed tie to resolve: the disabler outruns its target outright
|
||||
battle.player.curStats.speed = playerFirst and 200 or 1
|
||||
battle.enemy.curStats.speed = playerFirst and 1 or 200
|
||||
return battle
|
||||
end
|
||||
|
||||
-- the move instances the sides actually own, so PP decrements land on
|
||||
-- the party copy the way DecrementPP mutates wBattleMonPP
|
||||
local function slot(battler, i) return battler.curMoves[i] end
|
||||
|
||||
-- consume the queue the way updateQueue does, minus the presentation
|
||||
local function drain(battle)
|
||||
local rows = {}
|
||||
for _ = 1, 400 do
|
||||
local item = table.remove(battle.queue, 1)
|
||||
if not item then return rows end
|
||||
if item.text then rows[#rows + 1] = { text = item.text } end
|
||||
if item.fn then
|
||||
battle.nextInsert = 0
|
||||
item.fn()
|
||||
end
|
||||
end
|
||||
error("the turn queue never drained")
|
||||
end
|
||||
|
||||
local function saidWith(rows, needle)
|
||||
for i, row in ipairs(rows) do
|
||||
if row.text and row.text:find(needle, 1, true) then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- "X's / MOVE is / disabled!" (PrintMoveIsDisabledText) versus
|
||||
-- DisableEffect's own "MOVE was / disabled!" -- the two lines differ only
|
||||
-- in that verb, so match on it
|
||||
local function blocked(rows) return saidWith(rows, "is\ndisabled!") end
|
||||
local function landed(rows) return saidWith(rows, "was\ndisabled!") end
|
||||
local function usedTackle(rows) return saidWith(rows, "used FIX TACKLE!") end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the player Disables first; the foe's latched FIX TACKLE dies this turn
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle(true)
|
||||
battle.enemyAction = function() return slot(battle.enemy, 1) end
|
||||
local hpBefore = battle.player.mon.hp
|
||||
|
||||
battle:resolveTurn(slot(battle.player, 3))
|
||||
local rows = drain(battle)
|
||||
|
||||
T.check(landed(rows) ~= nil, "the Disable lands")
|
||||
T.eq(battle.enemy.disabledSlot, 1, "and latches onto the foe's slot 1")
|
||||
T.check(blocked(rows) ~= nil,
|
||||
"the foe's already-selected move reports as disabled")
|
||||
T.check(landed(rows) and blocked(rows) and landed(rows) < blocked(rows),
|
||||
"in that order: disabled first, then the blocked attempt")
|
||||
T.check(usedTackle(rows) == nil,
|
||||
"the disabled move is never announced, so it never executed")
|
||||
T.eq(battle.player.mon.hp, hpBefore, "and it deals no damage")
|
||||
T.eq(battle.enemy.disabledTurns, 3,
|
||||
"the counter ticked once for this turn and the disable is still live")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the same, mirrored: the foe Disables first and the player's latched
|
||||
-- move dies (core.asm:5752 .checkIfTriedToUseDisabledMove)
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle(false)
|
||||
battle.enemyAction = function() return slot(battle.enemy, 3) end
|
||||
local hpBefore = battle.enemy.mon.hp
|
||||
local ppBefore = slot(battle.player, 1).pp
|
||||
|
||||
battle:resolveTurn(slot(battle.player, 1))
|
||||
local rows = drain(battle)
|
||||
|
||||
T.check(landed(rows) ~= nil, "the foe's Disable lands")
|
||||
T.eq(battle.player.disabledSlot, 1, "on the player's slot 1")
|
||||
T.check(blocked(rows) ~= nil, "the player's latched move reports as disabled")
|
||||
T.check(usedTackle(rows) == nil, "and is never announced")
|
||||
T.eq(battle.enemy.mon.hp, hpBefore, "the foe takes no damage")
|
||||
T.eq(slot(battle.player, 1).pp, ppBefore,
|
||||
"and the move that never executed spends no PP (DecrementPP is inside "
|
||||
.. "the move, past the status gauntlet)")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- no regression on the turns after: the disable keeps blocking that move
|
||||
-- while its counter runs, and a different move still works
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle(true)
|
||||
battle.enemyAction = function() return slot(battle.enemy, 1) end
|
||||
battle:resolveTurn(slot(battle.player, 3))
|
||||
drain(battle)
|
||||
T.eq(battle.enemy.disabledTurns, 3, "the disable is live going into turn 2")
|
||||
|
||||
-- turn 2: the foe picks the disabled move with no Disable in flight
|
||||
local hpBefore = battle.player.mon.hp
|
||||
battle:resolveTurn(slot(battle.player, 2))
|
||||
local rows = drain(battle)
|
||||
T.check(blocked(rows) ~= nil, "turn 2 still blocks the disabled move")
|
||||
T.check(usedTackle(rows) == nil, "still no execution")
|
||||
T.eq(battle.player.mon.hp, hpBefore, "still no damage")
|
||||
T.eq(battle.enemy.disabledTurns, 2, "and the counter keeps ticking down")
|
||||
|
||||
-- turn 3: the foe picks its OTHER move, which was never disabled
|
||||
battle.enemyAction = function() return slot(battle.enemy, 2) end
|
||||
hpBefore = battle.player.mon.hp
|
||||
rows = (function() battle:resolveTurn(slot(battle.player, 2)); return drain(battle) end)()
|
||||
T.check(blocked(rows) == nil, "an undisabled move is not blocked")
|
||||
T.check(saidWith(rows, "used FIX SCRATCH!") ~= nil, "it is announced")
|
||||
T.check(battle.player.mon.hp < hpBefore, "and it deals damage")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the counter tick still runs ahead of the check: a disable that expires
|
||||
-- on this turn frees the move it was holding (.DisabledCheck precedes
|
||||
-- .TriedToUseDisabledMoveCheck)
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle(true)
|
||||
battle.enemy.disabledSlot, battle.enemy.disabledTurns = 1, 1
|
||||
battle.enemyAction = function() return slot(battle.enemy, 1) end
|
||||
local hpBefore = battle.player.mon.hp
|
||||
|
||||
battle:resolveTurn(slot(battle.player, 2))
|
||||
local rows = drain(battle)
|
||||
|
||||
T.check(saidWith(rows, "disabled no more!") ~= nil, "the disable expires")
|
||||
T.check(blocked(rows) == nil, "so the move is not blocked")
|
||||
T.check(usedTackle(rows) ~= nil, "it executes")
|
||||
T.check(battle.player.mon.hp < hpBefore, "and deals damage")
|
||||
end
|
||||
|
||||
T.finish("disable blocks the already-selected move (#860)")
|
||||
@@ -0,0 +1,166 @@
|
||||
-- A stone evolution started from the bag must not be cancelable (#883).
|
||||
--
|
||||
-- engine/items/item_effects.asm ItemUseEvoStone sets wForceEvolution before
|
||||
-- `call TryEvolvingMon`, and engine/movie/evolution.asm
|
||||
-- Evolution_CheckForCancel reads the joypad but throws the B press away while
|
||||
-- that flag is set (#290). So the B abort is a level-up/rare-candy behavior
|
||||
-- only: a stone is removed from the bag the moment it is used, and an
|
||||
-- evolution the player can cancel out of would eat the stone for nothing.
|
||||
--
|
||||
-- src/ui/EvolutionState.lua encodes the flag as `via`: cancelable is
|
||||
-- (via ~= "TRADE" and via ~= "ITEM"). The bag's stone branch omitted the
|
||||
-- argument entirely, so `via` arrived nil and the movie accepted B. The
|
||||
-- assertion here is on the value that reaches the screen, which is the only
|
||||
-- thing standing between the two behaviors.
|
||||
--
|
||||
-- ROM-free: the fixture dataset plus a registry-supplied EvolutionState, so
|
||||
-- the real Screens.push resolution runs and no sprite is ever loaded.
|
||||
-- luajit tests/engine/evo_stone_cancel_bug883_test.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")
|
||||
|
||||
-- Lazily required inside the use branches; seeding package.loaded first keeps
|
||||
-- the suite silent and free of a real Font atlas.
|
||||
package.loaded["src.core.Sound"] = {
|
||||
play = function() end,
|
||||
playCry = function() end,
|
||||
}
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
|
||||
}
|
||||
-- BagMenu and PartyMenu bind TextBox at require time, so they load against the
|
||||
-- stub; Screens caches its factory per id and must be told to forget.
|
||||
package.loaded["src.ui.BagMenu"] = nil
|
||||
package.loaded["src.ui.PartyMenu"] = nil
|
||||
local BagMenu = require("src.ui.BagMenu")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local Screens = require("src.ui.Screens")
|
||||
Screens.invalidate()
|
||||
|
||||
local Fixtures = require("tests.modkit.fixtures")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local EvolutionState = require("src.ui.EvolutionState")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
-- The fixture item table carries no stone, and ItemEffects keys its stone
|
||||
-- branch on the id; BagMenu only reads name/keyItem off the def.
|
||||
Data.items.THUNDER_STONE = {
|
||||
id = "THUNDER_STONE", index = 33, name = "THUNDERSTONE", price = 2100,
|
||||
tossable = true,
|
||||
}
|
||||
-- and no fixture species evolves, so give A the stone evolution the branch
|
||||
-- looks for (evo.method == "ITEM" and evo.item == the stone used).
|
||||
Data.pokemon.FIXMON_A.evolutions = {
|
||||
{ method = "ITEM", item = "THUNDER_STONE", species = "FIXMON_B" },
|
||||
}
|
||||
|
||||
-- The seam: Screens resolves an id through game.data.screens before falling
|
||||
-- back to the builtin module, which is the same path a mod-replaced screen
|
||||
-- takes. Recording the factory here catches exactly what Evolution.evolve
|
||||
-- forwards, with no monkeypatching of Screens itself.
|
||||
local pushed
|
||||
Data.screens = Data.screens or {}
|
||||
Data.screens.EvolutionState = function(game, mon, newSpecies, onDone, via)
|
||||
pushed = { game = game, mon = mon, newSpecies = newSpecies,
|
||||
onDone = onDone, via = via }
|
||||
return { evoRecorder = true }
|
||||
end
|
||||
Screens.invalidate()
|
||||
|
||||
local function freshGame()
|
||||
local mon = Pokemon.new(Data, "FIXMON_A", 20)
|
||||
local game = {
|
||||
data = Data,
|
||||
save = {
|
||||
party = { mon },
|
||||
player = { name = "RED", id = 1 },
|
||||
inventory = {},
|
||||
options = {},
|
||||
flags = {},
|
||||
money = 0,
|
||||
},
|
||||
}
|
||||
game.stack = {
|
||||
states = {},
|
||||
push = function(self, s) table.insert(self.states, s) end,
|
||||
pop = function(self) return table.remove(self.states) end,
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
}
|
||||
-- one button edge per update, the way Input reports a fixed step
|
||||
game.input = { pressed = nil }
|
||||
function game.input:wasPressed(b) return self.pressed == b end
|
||||
Bag.add(game.save, "THUNDER_STONE", 1)
|
||||
return game, mon
|
||||
end
|
||||
|
||||
local function isPicker(s) return getmetatable(s) == PartyMenu end
|
||||
|
||||
local function rowFor(list, id)
|
||||
for i, r in ipairs(list.items) do
|
||||
if r.value == id then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Open the bag, put the cursor on the stone, choose it, take USE off the
|
||||
-- USE/TOSS box, then press A on the party picker.
|
||||
local function useStone(game)
|
||||
local list = BagMenu.new(game, {})
|
||||
game.stack:push(list)
|
||||
local row = rowFor(list, "THUNDER_STONE")
|
||||
if not row then return nil, "no THUNDER_STONE row in the bag" end
|
||||
list.index = row
|
||||
list.onChoose(list.items[row], list)
|
||||
local sub = game.stack:top()
|
||||
if sub and sub.items and sub.items[1] and sub.items[1].onSelect then
|
||||
game.stack:pop() -- the USE/TOSS Menu pops itself on select
|
||||
sub.items[1].onSelect()
|
||||
end
|
||||
local picker = game.stack:top()
|
||||
if not isPicker(picker) then return nil, "party picker never opened" end
|
||||
game.input.pressed = "a"
|
||||
picker:update(1 / 60)
|
||||
game.input.pressed = nil
|
||||
return list
|
||||
end
|
||||
|
||||
do
|
||||
local game, mon = freshGame()
|
||||
local list, why = useStone(game)
|
||||
if check(list ~= nil, "the bag opened and reached the picker: " .. tostring(why)) then
|
||||
if check(pushed ~= nil, "the stone use pushed the evolution screen") then
|
||||
eq(pushed.newSpecies, "FIXMON_B", "and it is the stone's evolution")
|
||||
eq(pushed.mon, mon, "for the mon the stone was used on")
|
||||
eq(pushed.via, "ITEM",
|
||||
"the evolution runs as via = \"ITEM\" (wForceEvolution), which is "
|
||||
.. "what makes it non-cancelable (#883)")
|
||||
end
|
||||
eq(game.save.inventory.THUNDER_STONE, nil,
|
||||
"the stone is already gone by then, so a cancel would cost it for "
|
||||
.. "nothing")
|
||||
end
|
||||
end
|
||||
|
||||
-- The value only matters because of what EvolutionState does with it, so
|
||||
-- assert that half against the real constructor rather than trusting the
|
||||
-- comment. new() loads sprites through pcall and plays music through the
|
||||
-- stubbed Sound, so it is safe headless.
|
||||
do
|
||||
local game = freshGame()
|
||||
local mon = game.save.party[1]
|
||||
local stoneEvo = EvolutionState.new(game, mon, "FIXMON_B", nil, "ITEM")
|
||||
check(stoneEvo.cancelable == false,
|
||||
"EvolutionState refuses B for a stone evolution (evolution.asm "
|
||||
.. "Evolution_CheckForCancel with wForceEvolution set)")
|
||||
local levelEvo = EvolutionState.new(game, mon, "FIXMON_B", nil, "LEVEL")
|
||||
check(levelEvo.cancelable == true,
|
||||
"and still honours B for a level-up evolution, so the fix did not "
|
||||
.. "silently disable the cancel everywhere (#290, #213)")
|
||||
end
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,87 @@
|
||||
-- Held directions must survive lifecycle resets while physically held
|
||||
-- (#799). Input state is event-driven, so any Input:reset (focus or
|
||||
-- visibility flip, joystick add/remove, resume) wipes a hold that never
|
||||
-- re-fires keypressed afterwards -- on macOS a Bluetooth controller
|
||||
-- re-enumerating mid-walk fired joystickadded under a held key and parked
|
||||
-- the player until the direction was released and pressed again. Game's
|
||||
-- lifecycle handlers rebuild holds from device ground truth after each
|
||||
-- reset; only what is physically down comes back, so the swallowed-release
|
||||
-- hazards the resets guard against stay cleared.
|
||||
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 = T.check
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
local Game = require("src.core.Game")
|
||||
|
||||
local realIsDown = love.keyboard.isDown
|
||||
local realJoystick = love.joystick
|
||||
local function restore()
|
||||
love.keyboard.isDown = realIsDown
|
||||
love.joystick = realJoystick
|
||||
end
|
||||
|
||||
Input:init()
|
||||
|
||||
-- Keyboard: direction still physically held across a spurious pad
|
||||
-- re-enumeration must keep walking (the #799 report).
|
||||
love.keyboard.isDown = function(key) return key == "up" end
|
||||
Input:reset()
|
||||
Input:keypressed("up")
|
||||
Input:step()
|
||||
check(Input:isDown("up"), "up held before joystick re-enumeration")
|
||||
Game:joystickadded({ getName = function() return "Wireless Controller" end })
|
||||
check(Input:isDown("up"), "held key survives a spurious joystickadded")
|
||||
|
||||
-- Same hold across a focus bounce (another reset source).
|
||||
Game:focus(false)
|
||||
check(not Input:isDown("up"), "focus loss still drops the hold")
|
||||
Game:focus(true)
|
||||
check(Input:isDown("up"), "held key re-arms on focus regain")
|
||||
|
||||
-- A key released while unfocused (keyup swallowed by the OS, the hazard
|
||||
-- the resets exist for) must NOT come back.
|
||||
love.keyboard.isDown = function() return false end
|
||||
Game:focus(false)
|
||||
Game:focus(true)
|
||||
check(not Input:isDown("up"), "swallowed release still clears the hold")
|
||||
|
||||
-- Controller: held d-pad across a disconnect/reconnect bounce.
|
||||
local pad = {
|
||||
isGamepad = function() return true end,
|
||||
isGamepadDown = function(_, button) return button == "dpleft" end,
|
||||
getGamepadAxis = function() return 0 end,
|
||||
}
|
||||
love.joystick = { getJoysticks = function() return { pad } end }
|
||||
love.keyboard.isDown = function() return false end
|
||||
Input:reset()
|
||||
Input:gamepadpressed(pad, "dpleft")
|
||||
Input:step()
|
||||
check(Input:isDown("left"), "d-pad held before reconnect")
|
||||
Game:joystickremoved(pad)
|
||||
Game:joystickadded(pad)
|
||||
check(Input:isDown("left"), "held d-pad survives a reconnect bounce")
|
||||
|
||||
-- Held stick across the same bounce (axis ground truth re-derived).
|
||||
pad.isGamepadDown = function() return false end
|
||||
pad.getGamepadAxis = function(_, axis) return axis == "leftx" and -0.9 or 0 end
|
||||
Input:reset()
|
||||
Input:gamepadaxis(pad, "leftx", -0.9)
|
||||
Input:step()
|
||||
check(Input:isDown("left"), "stick held before re-enumeration")
|
||||
Game:joystickadded(pad)
|
||||
check(Input:isDown("left"), "held stick survives re-enumeration")
|
||||
|
||||
-- A pad that vanished for real reports nothing held: its stale hold must
|
||||
-- stay cleared (the stuck-flag hazard reset-on-remove guards against).
|
||||
love.joystick = { getJoysticks = function() return {} end }
|
||||
Input:reset()
|
||||
Input:gamepadpressed(pad, "dpleft")
|
||||
Input:step()
|
||||
Game:joystickremoved(pad)
|
||||
check(not Input:isDown("left"), "vanished pad's hold stays cleared")
|
||||
|
||||
restore()
|
||||
T.finish()
|
||||
@@ -0,0 +1,112 @@
|
||||
-- One-column launcher reach (#852) and the safe-area launcher anchor (#810).
|
||||
-- No pokered cite: the launcher is port-only chrome.
|
||||
--
|
||||
-- #852: minPanelHeight in src/import/LauncherView.lua was a flat 460*s tuned
|
||||
-- for the two-column layout. A one-column window (portrait phone, squat 4:3
|
||||
-- device) stacks title + actions card + slot card + the pinned
|
||||
-- Play/Reset-rebinds/Touch-Controls block, which needs more room; the flat
|
||||
-- threshold read "tall enough", so the short-window page scroll never
|
||||
-- engaged, buildSlotCard was cut by Kit.pushClip against the pinned block,
|
||||
-- and Kit's clip-bounded hit-testing (src/ui/kit/Kit.lua) left every slot
|
||||
-- row, the pager and "+ New save slot" drawn-but-inert. The fix makes the
|
||||
-- threshold column-aware, so those windows scroll instead of clipping.
|
||||
--
|
||||
-- The seam is LauncherView.draw itself: it publishes the page-scroll extent
|
||||
-- on the importer (imp._pageScroll / imp._pageScrollMax, the values the
|
||||
-- touch-drag and wheel paths feed), so a headless draw shows whether the
|
||||
-- scroll engaged without reading any file-local constant. The 480x900
|
||||
-- window below is the discriminator: its natural panel space satisfies the
|
||||
-- old flat threshold (inert, slot card clipped) but not the one-column one.
|
||||
--
|
||||
-- #810 gets its unit-conversion pin in tests/engine/safe_area_units_test.lua;
|
||||
-- here the complementary end-to-end anchor: Layout.metrics must place the
|
||||
-- launcher at the corrected safe-area origin, not a DPI-inflated band down
|
||||
-- the screen.
|
||||
-- luajit tests/engine/launcher_one_column_reach_bug852.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")
|
||||
|
||||
-- The launcher touches two graphics calls the shared stub does not carry
|
||||
-- (focus-ring joins, the footer's BCG invert shader); both are draw-only, so
|
||||
-- inert fills are enough for the layout arithmetic under test.
|
||||
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
|
||||
love.graphics.newShader = love.graphics.newShader or function() return {} end
|
||||
|
||||
local Layout = require("src.ui.kit.Layout")
|
||||
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
|
||||
|
||||
-- A fresh launcher on the Red tab; no cache exists headless, so every
|
||||
-- version sits in its "ROM required" state, which still lays out the full
|
||||
-- one-column pile (title, actions card, slot card, pinned block).
|
||||
local function freshLauncher()
|
||||
return RomImporter.new(function() end, { launcher = true })
|
||||
end
|
||||
|
||||
-- ------------------------------------------------ #852: the scroll engages
|
||||
-- 480x900 one column: enough room for the old flat 460*s threshold, not for
|
||||
-- the one-column stack. Before the fix draw() left _pageScrollMax at 0 here
|
||||
-- and the slot card sat clipped inert against the pinned buttons.
|
||||
window(480, 900)
|
||||
local m = Layout.metrics(1200)
|
||||
eq(m.twoCol, false, "480-wide window lays out one column")
|
||||
local imp = freshLauncher()
|
||||
LauncherView.draw(imp)
|
||||
check((imp._pageScrollMax or 0) > 0,
|
||||
"one-column window short of the stack engages the page scroll")
|
||||
eq(imp._pageScroll, 0, "a fresh page starts at the top")
|
||||
|
||||
-- The wheel moves the page (the same offset the touch drag feeds), and the
|
||||
-- offset clamps to the extent, so the whole stack down to "+ New save slot"
|
||||
-- and the footer is reachable rather than clipped away.
|
||||
local extent = imp._pageScrollMax
|
||||
imp._wheelY = -1
|
||||
LauncherView.draw(imp)
|
||||
eq(imp._pageScroll, math.min(math.floor(48 * m.s), extent),
|
||||
"one wheel notch scrolls the page down by its step")
|
||||
imp._pageScroll = 1e6
|
||||
LauncherView.draw(imp)
|
||||
eq(imp._pageScroll, imp._pageScrollMax,
|
||||
"an offset past the end clamps to the extent, so the bottom is reachable")
|
||||
|
||||
-- The reporter's portrait phone (360x780 units) is shorter still and must
|
||||
-- also scroll; before the fix its slot list was unreachable.
|
||||
window(360, 780)
|
||||
local phone = freshLauncher()
|
||||
LauncherView.draw(phone)
|
||||
check((phone._pageScrollMax or 0) > 0,
|
||||
"portrait-phone one-column window engages the page scroll")
|
||||
|
||||
-- A one-column window tall enough for the whole stack stays inert: the
|
||||
-- column-aware minimum is a floor, not a permanent scroll.
|
||||
window(480, 1200)
|
||||
local tall = freshLauncher()
|
||||
LauncherView.draw(tall)
|
||||
eq(tall._pageScrollMax, 0,
|
||||
"a tall one-column window does not scroll for nothing")
|
||||
|
||||
-- --------------------------------------- #810: launcher anchored in units
|
||||
-- Layout.metrics anchors the launcher at SafeArea.rect's origin. Feed it
|
||||
-- the iOS 16 portrait frame that reported the safe rect in framebuffer
|
||||
-- pixels (3x DPI): the launcher must start at the 44-unit notch inset, not
|
||||
-- 132 units down with the top of the window black (the #810 report). The
|
||||
-- rescale itself is pinned in tests/engine/safe_area_units_test.lua.
|
||||
love.graphics.getDimensions = function() return 375, 812 end
|
||||
love.graphics.getPixelDimensions = function() return 1125, 2436 end
|
||||
local oldSafe = love.window.getSafeArea
|
||||
love.window.getSafeArea = function() return 0, 132, 1125, 2232 end
|
||||
local ios = Layout.metrics(1200)
|
||||
eq(ios.top, 44, "launcher anchors at the unit-space notch inset")
|
||||
eq(ios.h, 744, "launcher gets the full unit-space safe height")
|
||||
love.window.getSafeArea = oldSafe
|
||||
|
||||
T.finish("launcher one-column reach")
|
||||
@@ -0,0 +1,219 @@
|
||||
-- Animation-row SFX must obey PlaySound's channel-occupancy gate (#844).
|
||||
--
|
||||
-- Blizzard's animation is two rows -- `battle_anim BLIZZARD, ...` then
|
||||
-- `battle_anim HYDRO_PUMP, ...` (data/moves/animations.asm, BlizzardAnim) --
|
||||
-- and PlaySubanimation issues a PlaySound for every row
|
||||
-- (engine/battle/animations.asm, PlaySubanimation). The extracted data and
|
||||
-- the row timing are both faithful; what the port was missing is that the
|
||||
-- original never actually starts that second sound. Audio2_PlaySound's
|
||||
-- .playSfx/.sfxChannelLoop (audio/engine_2.asm) walks the channels the new
|
||||
-- sfx declares and, for each one already busy, does
|
||||
-- `ld a,[wSoundID] / cp [hl] / jr z,.playChannel / jr c,.playChannel / ret`:
|
||||
-- a channel held by a LOWER sound id aborts the whole request, while an
|
||||
-- equal or lower id takes the channel over (and .playChannel resets the
|
||||
-- channel, cutting the old sound off). SFX_BATTLE_29 (BLIZZARD, CHAN5+8) is
|
||||
-- still sounding when the HYDRO_PUMP row starts, and SFX_BATTLE_2A wants
|
||||
-- CHAN5+6+8, so on hardware it is dropped outright. Unguarded, the port
|
||||
-- layered it and its watery tail outlived the animation.
|
||||
--
|
||||
-- Sound ids order by header address: `DEF \1 EQUS "((\2 - SFX_Headers_1) / 3)"`
|
||||
-- (constants/music_constants.asm, music_const), so a def's `address` is the
|
||||
-- comparable rank inside one engine bank -- which is what Sound.playMove
|
||||
-- compares and what ChipSynth.effectChannels supplies the channel set for.
|
||||
--
|
||||
-- ROM-free: ChipAsm blobs stand in for the sfx headers, so nothing here
|
||||
-- reads data/generated/.
|
||||
-- luajit tests/engine/move_sfx_channel_gate_bug844.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
-- ------- love.audio stub
|
||||
-- tests/love_stub carries no love.audio (headless suites never play), and
|
||||
-- the gate reads Source:isPlaying on the previously accepted row sound.
|
||||
-- These stub sources never finish on their own, which is exactly the
|
||||
-- "previous sfx is still sounding" state a mid-animation row sees.
|
||||
local sources = {}
|
||||
|
||||
local Source = {}
|
||||
Source.__index = Source
|
||||
function Source:play() self.playing = true; self.plays = self.plays + 1 end
|
||||
function Source:stop() self.playing = false end
|
||||
function Source:isPlaying() return self.playing end
|
||||
function Source:pause() self.playing = false end
|
||||
function Source:setLooping(value) self.looping = value end
|
||||
function Source:setVolume(value) self.volume = value end
|
||||
function Source:setPitch(value) self.pitch = value end
|
||||
function Source:setFilter() end
|
||||
function Source:getDuration() return 1 end
|
||||
|
||||
love.audio = {
|
||||
newSource = function(what, mode)
|
||||
local src = setmetatable({
|
||||
file = what, mode = mode, plays = 0, playing = false,
|
||||
}, Source)
|
||||
sources[#sources + 1] = src
|
||||
return src
|
||||
end,
|
||||
}
|
||||
|
||||
local ChipAsm = require("src.audio.ChipAsm")
|
||||
local ChipSynth = require("src.core.ChipSynth")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
-- ------- sfx fixtures
|
||||
-- One audible note per channel. ChipAsm.sfx numbers effect channels hw+4,
|
||||
-- so hw 1/2/4 assemble as CHAN5/CHAN6/CHAN8 -- the same software channels
|
||||
-- the real sfx headers claim. `address` is the sound-id rank and `engine`
|
||||
-- names the bank the rank is comparable within.
|
||||
local function sfxDef(address, hws)
|
||||
local channels = {}
|
||||
for _, hw in ipairs(hws) do
|
||||
local program
|
||||
if hw == 4 then
|
||||
program = { { noiseNote = { len = 8, volume = 15, fade = 1,
|
||||
parameter = 0x11 } } }
|
||||
else
|
||||
program = { { squareNote = { len = 8, volume = 15, fade = 1,
|
||||
frequency = 0x600 } } }
|
||||
end
|
||||
channels[#channels + 1] = { hw = hw, program = program }
|
||||
end
|
||||
local def = ChipAsm.sfx{ channels = channels }
|
||||
def.address, def.engine = address, 2
|
||||
return def
|
||||
end
|
||||
|
||||
-- Battle_29 = CHAN5,8 at rank 16975; Battle_2A = CHAN5,6,8 at 16981. The
|
||||
-- ranks below are the same ordering, scaled small for readability.
|
||||
local defs = {
|
||||
Blizzard_Sfx = sfxDef(100, { 1, 4 }), -- SFX_BATTLE_29 shape
|
||||
HydroPump_Sfx = sfxDef(106, { 1, 2, 4 }), -- SFX_BATTLE_2A shape
|
||||
Disable_Sfx = sfxDef(100, { 4 }), -- SFX_BATTLE_1B shape: CHAN8
|
||||
Leer_Sfx = sfxDef(106, { 1, 2 }), -- SFX_BATTLE_31 shape: CHAN5,6
|
||||
Loud_Sfx = sfxDef(106, { 1, 4 }), -- a high-ranked incumbent
|
||||
Quiet_Sfx = sfxDef(100, { 1 }), -- a lower id that takes over
|
||||
Unranked_Sfx = ChipAsm.sfx{ channels = { { hw = 1, program = {
|
||||
{ squareNote = { len = 8, volume = 15, fade = 1, frequency = 0x600 } },
|
||||
} } } }, -- a mod def: no header address
|
||||
}
|
||||
|
||||
local data = { audio = { sfx = defs, cries = {}, songs = {} } }
|
||||
|
||||
-- the gate is only observable through what actually started, so watch the
|
||||
-- Runtime event the mod SDK exposes for exactly that
|
||||
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
||||
local events = require("src.mods.Events").new()
|
||||
Runtime.install(events, require("src.mods.Hooks").new())
|
||||
|
||||
local played = {}
|
||||
events:on("sound.played", function(p) played[#played + 1] = p end, nil, "test")
|
||||
|
||||
local function reset()
|
||||
Sound.invalidate() -- also clears the tracked row sound
|
||||
for index = #sources, 1, -1 do sources[index] = nil end
|
||||
for index = #played, 1, -1 do played[index] = nil end
|
||||
end
|
||||
|
||||
local function playMove(name)
|
||||
Sound.playMove(data, { sound = name, pitch = 0, tempo = 0x80 })
|
||||
end
|
||||
|
||||
local function names()
|
||||
local out = {}
|
||||
for _, p in ipairs(played) do out[#out + 1] = p.name end
|
||||
return table.concat(out, ",")
|
||||
end
|
||||
|
||||
-- ------- the channel sets the gate reads
|
||||
eq(table.concat(ChipSynth.effectChannels(data, defs.Blizzard_Sfx), ","),
|
||||
"5,8", "effectChannels reads CHAN5+8 off the Blizzard-shaped header")
|
||||
eq(table.concat(ChipSynth.effectChannels(data, defs.HydroPump_Sfx), ","),
|
||||
"5,6,8", "effectChannels reads CHAN5+6+8 off the Hydro Pump-shaped header")
|
||||
check(ChipSynth.effectChannels(data, "assets/beep.wav") == nil,
|
||||
"a file def has no knowable channel set")
|
||||
|
||||
-- ------- 1. higher id + overlapping channels is dropped (the Blizzard case)
|
||||
reset()
|
||||
playMove("Blizzard_Sfx")
|
||||
check(#sources == 1 and sources[1].playing, "the Blizzard row sound starts")
|
||||
playMove("HydroPump_Sfx")
|
||||
eq(#played, 1, "the second Blizzard row is dropped, not layered (" .. names() .. ")")
|
||||
eq(played[1] and played[1].name, "Blizzard_Sfx",
|
||||
"the sound that survives is the Blizzard row")
|
||||
eq(#sources, 1, "the dropped row never even builds a source")
|
||||
check(sources[1].playing, "the incumbent keeps sounding through the drop")
|
||||
|
||||
-- ------- 2. higher id + disjoint channels still plays (Disable/Leer)
|
||||
-- The regression guard: SFX_BATTLE_1B is CHAN8 and SFX_BATTLE_31 is
|
||||
-- CHAN5+6, so nothing is busy and both sounds are heard.
|
||||
reset()
|
||||
playMove("Disable_Sfx")
|
||||
playMove("Leer_Sfx")
|
||||
eq(#played, 2, "a higher id on disjoint channels is not gated (" .. names() .. ")")
|
||||
eq(played[2] and played[2].name, "Leer_Sfx", "the second sound is the later row")
|
||||
check(sources[1].playing and sources[2].playing,
|
||||
"neither disjoint sound cuts the other off")
|
||||
|
||||
-- ------- 3. a lower id takes the channels over
|
||||
-- .playChannel zeroes the channel state, which stops whatever held it.
|
||||
reset()
|
||||
playMove("Loud_Sfx")
|
||||
local incumbent = sources[1]
|
||||
playMove("Quiet_Sfx")
|
||||
eq(#played, 2, "a lower id is allowed to start (" .. names() .. ")")
|
||||
check(not incumbent.playing,
|
||||
"taking CHAN5 over stops the sound that held it")
|
||||
check(sources[2] and sources[2].playing, "the taking-over sound is playing")
|
||||
|
||||
-- ------- 4. an equal id restarts the sound
|
||||
-- Repeated rows of one sound (Wrap, Metronome) must not be swallowed.
|
||||
reset()
|
||||
playMove("Blizzard_Sfx")
|
||||
playMove("Blizzard_Sfx")
|
||||
eq(#played, 2, "the same sound replayed is not gated against itself")
|
||||
eq(#sources, 1, "the replay reuses the cached source")
|
||||
eq(sources[1].plays, 2, "the cached source is restarted")
|
||||
check(sources[1].playing, "and is sounding afterwards")
|
||||
|
||||
-- ------- 5. an unrankable def is left exactly as it was
|
||||
-- A mod's chip sfx has no header address, so there is no comparable sound
|
||||
-- id and the gate must not invent one.
|
||||
reset()
|
||||
playMove("Blizzard_Sfx")
|
||||
playMove("Unranked_Sfx")
|
||||
playMove("Unranked_Sfx")
|
||||
eq(#played, 3, "unrankable defs play regardless of what is sounding ("
|
||||
.. names() .. ")")
|
||||
|
||||
-- an unrankable def must also not become an incumbent that gates the next
|
||||
-- ranked row
|
||||
reset()
|
||||
playMove("Unranked_Sfx")
|
||||
playMove("HydroPump_Sfx")
|
||||
eq(#played, 2, "an unrankable def gates nothing after it (" .. names() .. ")")
|
||||
|
||||
-- ------- 6. a finished sound gates nothing
|
||||
reset()
|
||||
playMove("Blizzard_Sfx")
|
||||
sources[1].playing = false -- the incumbent ran out
|
||||
playMove("HydroPump_Sfx")
|
||||
eq(#played, 2, "a row sound that already ended blocks nothing")
|
||||
|
||||
-- ------- 7. an invalidate (hot reload / cache flush) drops the tracking
|
||||
-- Sound.invalidate stops and forgets the cached sources; a stale reference
|
||||
-- would gate the next row against a dead source.
|
||||
reset()
|
||||
playMove("Blizzard_Sfx")
|
||||
Sound.invalidate()
|
||||
playMove("HydroPump_Sfx")
|
||||
eq(#played, 2, "invalidate clears the tracked row sound")
|
||||
|
||||
Runtime.install(savedEvents, savedHooks)
|
||||
|
||||
T.finish("move sfx channel gate (#844)")
|
||||
@@ -0,0 +1,126 @@
|
||||
-- Empty confirm on the naming screen (#833). DisplayNamingScreen seeds
|
||||
-- wStringBuffer with '@' (engine/menus/naming_screen.asm), so a name the
|
||||
-- player never typed reads back as the terminator, and every caller checks
|
||||
-- that first byte: AskName falls through to .declinedNickname and copies the
|
||||
-- species name over the nick slot (vanilla's "un-nicknamed", which this port
|
||||
-- models as mon.nickname == nil, src/save_convert/GenSave.lua), while
|
||||
-- DisplayNameRaterScreen takes .playerCancelled and keeps the old nickname.
|
||||
-- Nothing in the original invents a letter, so NamingScreen:confirm must hand
|
||||
-- the caller "" rather than the literal "A" when nothing was typed -- both via
|
||||
-- START and via the ED cell. The two fallbacks that are load bearing stay:
|
||||
-- presets[1] for player/rival naming (oak_speech2.asm ChoosePlayerName never
|
||||
-- accepts an empty name) and opts.default for the Name Rater cancel.
|
||||
-- luajit tests/engine/naming_empty_confirm_bug833.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")
|
||||
|
||||
-- NamingScreen reaches for Sound at the top of the module; seeding
|
||||
-- package.loaded before it loads keeps the suite ROM-free and silent.
|
||||
package.loaded["src.core.Sound"] = { play = function() end }
|
||||
|
||||
local NamingScreen = require("src.ui.NamingScreen")
|
||||
|
||||
-- The three things the screen touches: a stack it pops itself off, an input
|
||||
-- queue that is exactly one fixed step of edges, and a non-nil `data` for the
|
||||
-- click cue.
|
||||
local function newGame()
|
||||
local game = { data = {} }
|
||||
game.stack = {
|
||||
states = {},
|
||||
push = function(self, s) table.insert(self.states, s) end,
|
||||
pop = function(self) return table.remove(self.states) end,
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
}
|
||||
game.input = {
|
||||
queue = {},
|
||||
wasPressed = function(self, btn) return self.queue[btn] or false end,
|
||||
isDown = function() return false end,
|
||||
}
|
||||
return game
|
||||
end
|
||||
|
||||
-- builds a pushed screen plus a `result` table the onDone writes into
|
||||
local function newScreen(opts)
|
||||
local game = newGame()
|
||||
local result = { fired = false, name = nil }
|
||||
opts = opts or {}
|
||||
opts.onDone = function(n)
|
||||
result.fired = true
|
||||
result.name = n
|
||||
end
|
||||
local ns = NamingScreen.new(game, opts)
|
||||
game.stack:push(ns)
|
||||
return ns, game, result
|
||||
end
|
||||
|
||||
-- one fixed step with `btn` on its edge
|
||||
local function press(ns, game, btn)
|
||||
game.input.queue = { [btn] = true }
|
||||
ns:update(1 / 60)
|
||||
game.input.queue = {}
|
||||
end
|
||||
|
||||
-- the ED cell's coordinates on whatever grid the screen is showing
|
||||
local function edCell(ns)
|
||||
for r, row in ipairs(ns:grid()) do
|
||||
for c, cell in ipairs(row) do
|
||||
if cell == "ED" then return r, c end
|
||||
end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- START, nothing typed
|
||||
-- The nickname callers (BattleState caught-mon, Commands gift/starter) push
|
||||
-- the screen with only title/maxLen/onDone: no presets, no default.
|
||||
local ns, game, res = newScreen({ title = "NICK?", maxLen = 10 })
|
||||
press(ns, game, "start")
|
||||
check(res.fired, "START confirms an untyped name")
|
||||
eq(res.name, "", "START with nothing typed delivers the empty name")
|
||||
check(res.name ~= "A", "an untyped confirm does not invent the letter A (#833)")
|
||||
eq(#game.stack.states, 0, "confirm pops the naming screen")
|
||||
|
||||
-- the caller-shaped guard both nickname sites use
|
||||
local mon = {}
|
||||
if res.name and #res.name > 0 then mon.nickname = res.name end
|
||||
check(mon.nickname == nil,
|
||||
"an empty name leaves the mon un-nicknamed, so evolution can rename it")
|
||||
|
||||
-- ---------------------------------------------------------------- ED cell, nothing typed
|
||||
ns, game, res = newScreen({ title = "NICK?", maxLen = 10 })
|
||||
local edRow, edCol = edCell(ns)
|
||||
eq(edRow, 5, "ED sits on row 5 of the vanilla grid (data/text/alphabets.asm)")
|
||||
eq(edCol, 9, "ED is the last cell of that row")
|
||||
ns.row, ns.col = edRow, edCol
|
||||
press(ns, game, "a")
|
||||
check(res.fired, "A on the ED cell confirms")
|
||||
eq(res.name, "", "ED with nothing typed delivers the empty name too")
|
||||
|
||||
-- ---------------------------------------------------------------- typed names are untouched
|
||||
ns, game, res = newScreen({ title = "NICK?", maxLen = 10 })
|
||||
ns.row, ns.col = 1, 1 -- "A"
|
||||
press(ns, game, "a")
|
||||
press(ns, game, "start")
|
||||
eq(res.name, "A", "a genuinely typed A still comes back as A")
|
||||
|
||||
-- ---------------------------------------------------------------- presets fallback (player / rival)
|
||||
-- ChoosePlayerName / ChooseRivalName (engine/movie/oak_speech/oak_speech2.asm)
|
||||
-- compare wStringBuffer to '@' and re-open rather than accept an empty name;
|
||||
-- the port answers the same need with its presets fallback, which #833 must
|
||||
-- not disturb.
|
||||
ns, game, res = newScreen({ title = "YOUR NAME?", maxLen = 7, presets = { "RED", "ASH" } })
|
||||
press(ns, game, "start")
|
||||
eq(res.name, "RED", "an empty confirm with presets still yields presets[1]")
|
||||
|
||||
-- ---------------------------------------------------------------- default fallback (Name Rater)
|
||||
-- DisplayNameRaterScreen jumps to .playerCancelled on '@' and keeps the
|
||||
-- existing nickname; data/scripts/story4.lua passes it as opts.default.
|
||||
ns, game, res = newScreen({ title = "RATTATA's name?", maxLen = 10, default = "SPLASH" })
|
||||
press(ns, game, "start")
|
||||
eq(res.name, "SPLASH", "an empty confirm with a default keeps the old nickname")
|
||||
|
||||
T.finish("naming_empty_confirm_bug833")
|
||||
@@ -0,0 +1,109 @@
|
||||
-- #828, the revert half: after the launcher's OG -> WIDE toggle, a play
|
||||
-- session rewrites options.lua with byte-identical content (play() re-stamps
|
||||
-- an unchanged lastVersion, SaveData.save flushes the attached table, and
|
||||
-- SaveSerializer's key-sorted encode makes equal tables equal bytes), so
|
||||
-- saveOptions' conditional pre-write roll skips and options.lua.bak kept the
|
||||
-- PRE-change file all session. Android and Steam Deck end sessions with a
|
||||
-- hard teardown (HostShell.restart restartApp kill / AppImage execv) that can
|
||||
-- eat the main file, and loadOptions then promoted that stale backup: the
|
||||
-- reported "launcher-only persists, going in-game reverts". The fix rolls
|
||||
-- the backup forward to the just-verified bytes after every landed write;
|
||||
-- this suite pins that at-rest invariant. ROM-free (T2 engine tier), same
|
||||
-- injected-fs shape as tests/engine/options_write_readback_bug828.lua, where
|
||||
-- these checks should eventually fold in.
|
||||
-- luajit tests/engine/options_backup_rollforward_bug828.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 SaveData = require("src.core.SaveData")
|
||||
|
||||
local OPTIONS = "options.lua"
|
||||
local BAK = OPTIONS .. ".bak"
|
||||
local TMP = OPTIONS .. ".tmp"
|
||||
|
||||
-- In-memory love.filesystem stub, the { getInfo, read, write, remove } shape
|
||||
-- SaveData.persistFs accepts. `dropping` is mutable so one fs can serve a
|
||||
-- healthy session and then a write that reports success without landing.
|
||||
local function memfs()
|
||||
local files = {}
|
||||
local fs
|
||||
fs = {
|
||||
files = files,
|
||||
dropping = false,
|
||||
write = function(path, content)
|
||||
if fs.dropping then return true end
|
||||
files[path] = content
|
||||
return true
|
||||
end,
|
||||
read = function(path) return files[path] end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] ~= nil then return { type = "file" } end
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
return fs
|
||||
end
|
||||
|
||||
-- ---- at rest, the backup holds the newest verified bytes
|
||||
|
||||
local fs = memfs()
|
||||
SaveData.saveOptions({ battleLayout = "wide" }, fs)
|
||||
check(fs.files[BAK] ~= nil, "the very first verified write already leaves a backup")
|
||||
eq(fs.files[BAK], fs.files[OPTIONS],
|
||||
"after a verified write the backup equals the main file (#828 roll-forward)")
|
||||
check(fs.files[TMP] == nil, "the staged witness is still dropped after verification")
|
||||
|
||||
-- ---- the reported session, write for write
|
||||
-- Launcher toggles OG -> WIDE, then two rewrites whose bytes match the file
|
||||
-- on disk: RomImporter:play re-stamping the same lastVersion (#835) and the
|
||||
-- in-game SaveData.save flush of the attached, unchanged table. Both go
|
||||
-- through loadOptions first, exactly as the shipping callers do, so the
|
||||
-- encoder sees identical tables and the conditional pre-roll skips.
|
||||
local live = memfs()
|
||||
SaveData.saveOptions({ battleLayout = "og", lastVersion = "red" }, live)
|
||||
|
||||
local toggled = SaveData.loadOptions(live)
|
||||
toggled.battleLayout = "wide"
|
||||
SaveData.saveOptions(toggled, live)
|
||||
local wideBytes = live.files[OPTIONS]
|
||||
|
||||
local stamped = SaveData.loadOptions(live)
|
||||
stamped.lastVersion = "red"
|
||||
SaveData.saveOptions(stamped, live)
|
||||
eq(live.files[OPTIONS], wideBytes,
|
||||
"the play() lastVersion re-stamp is a byte-identical rewrite (sorted encode)")
|
||||
SaveData.saveOptions(SaveData.loadOptions(live), live)
|
||||
eq(live.files[OPTIONS], wideBytes, "the in-game flush is byte-identical too")
|
||||
|
||||
eq(live.files[BAK], wideBytes,
|
||||
"identical rewrites still carry the backup forward past the skipped pre-roll")
|
||||
|
||||
-- the hard teardown eats the main file; recovery must answer the toggle
|
||||
live.files[OPTIONS] = nil
|
||||
eq(SaveData.loadOptions(live).battleLayout, "wide",
|
||||
"a lost main file recovers to WIDE, not the pre-toggle OG backup (#828)")
|
||||
check(live.files[OPTIONS] ~= nil, "and the main file is healed from that copy")
|
||||
|
||||
-- ---- a write that does not land must not poison the backup
|
||||
-- The roll-forward has to sit AFTER the readback verification: if the bytes
|
||||
-- never reached disk (the #828 external-storage failure mode) the backup
|
||||
-- keeps the last state that verifiably did.
|
||||
local flaky = memfs()
|
||||
SaveData.saveOptions({ battleLayout = "wide" }, flaky)
|
||||
local verified = flaky.files[BAK]
|
||||
flaky.dropping = true
|
||||
eq(SaveData.saveOptions({ battleLayout = "og" }, flaky), nil,
|
||||
"the vanished write still reports failure")
|
||||
flaky.dropping = false
|
||||
eq(flaky.files[BAK], verified,
|
||||
"a write that never landed leaves the backup at the last verified bytes")
|
||||
flaky.files[OPTIONS] = nil
|
||||
eq(SaveData.loadOptions(flaky).battleLayout, "wide",
|
||||
"so recovery after the failed write still answers the verified state")
|
||||
|
||||
T.finish("options_backup_rollforward_bug828")
|
||||
@@ -0,0 +1,226 @@
|
||||
-- #828: launcher settings "reset" on Android and Steam Deck, with nothing in
|
||||
-- the log. Every options write is a WHOLE-FILE rewrite out of the caller's
|
||||
-- table (src/core/SaveData.lua saveOptions), so a filesystem that reports a
|
||||
-- successful write without the bytes surviving -- an external-storage volume
|
||||
-- that went away mid-session (conf.lua sets t.externalstorage on Android), a
|
||||
-- read-only or full save dir -- is indistinguishable from "the launcher never
|
||||
-- saved at all". saveOptions therefore reads the file back and fails loudly.
|
||||
--
|
||||
-- This suite pins that contract against injected filesystem stubs, the same
|
||||
-- { getInfo, read, write, remove } shape tests/engine/save_slots.lua and
|
||||
-- tests/engine/save_file_io_tests.lua use. It is ROM-free (T2 engine tier).
|
||||
--
|
||||
-- What it does NOT do: prove #828 is fixed. The launcher -> options.lua ->
|
||||
-- bootGame chain already round-trips correctly on desktop, so the readback is
|
||||
-- instrumentation for the two platforms that report the loss, and the real
|
||||
-- verification is a platform run (see the issue). What is testable here is
|
||||
-- that a silent no-op write is now reported instead of swallowed.
|
||||
-- luajit tests/engine/options_write_readback_bug828.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 Logger = require("src.core.Logger")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
local OPTIONS = "options.lua"
|
||||
|
||||
-- An in-memory love.filesystem stub. `mode` decides what write() does with
|
||||
-- the bytes AFTER reporting success, which is the whole point of the suite:
|
||||
-- "honest" -- stores them (a working save dir)
|
||||
-- "drop" -- reports true, stores nothing (the volume vanished)
|
||||
-- "truncate" -- reports true, stores a short prefix (a full save dir)
|
||||
-- "fail" -- reports false plus an error string (the pre-existing path)
|
||||
local function memfs(mode)
|
||||
local files = {}
|
||||
return {
|
||||
files = files,
|
||||
write = function(path, content)
|
||||
if mode == "fail" then return false, "no space left on device" end
|
||||
if mode == "drop" then return true end
|
||||
if mode == "truncate" then
|
||||
files[path] = tostring(content):sub(1, 16)
|
||||
return true
|
||||
end
|
||||
files[path] = content
|
||||
return true
|
||||
end,
|
||||
read = function(path) return files[path] end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] ~= nil then return { type = "file" } end
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- SaveData.persistFs hands an injected fs straight back only when it differs
|
||||
-- from love.filesystem, so the suite never touches the real save directory.
|
||||
local function logged(pattern)
|
||||
for i = #Logger.history, 1, -1 do
|
||||
if Logger.history[i]:find(pattern, 1, true) then return Logger.history[i] end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ---- the write that lands: unchanged success contract
|
||||
|
||||
local fs = memfs("honest")
|
||||
local saved = SaveData.saveOptions({ battleLayout = "wide" }, fs)
|
||||
check(saved ~= nil, "a write that lands returns the merged options table")
|
||||
eq(saved and saved.battleLayout, "wide", "the caller's key survives the merge")
|
||||
eq(saved and saved.textSpeed ~= nil, true, "defaults are filled in around it")
|
||||
check(fs.files[OPTIONS] ~= nil, "options.lua is written to the injected fs")
|
||||
|
||||
local loaded = SaveData.loadOptions(fs)
|
||||
eq(loaded and loaded.battleLayout, "wide",
|
||||
"loadOptions reads back what saveOptions wrote (the launcher -> game hop)")
|
||||
|
||||
-- ---- the write that silently does not land: the #828 failure mode
|
||||
|
||||
local dropMark = #Logger.history
|
||||
local dropped = SaveData.saveOptions({ battleLayout = "wide" }, memfs("drop"))
|
||||
eq(dropped, nil, "a write that reports success but stores nothing returns nil")
|
||||
check(logged("options save did not land"),
|
||||
"the vanished write is logged, so the next Android report can carry it")
|
||||
check(#Logger.history > dropMark, "a log line was actually emitted")
|
||||
|
||||
-- ---- a partial write is just as lost, and just as loud
|
||||
|
||||
local truncated = SaveData.saveOptions({ battleLayout = "wide" }, memfs("truncate"))
|
||||
eq(truncated, nil, "a truncated write is treated as a failed write")
|
||||
check(logged("options save did not land"), "the truncated write is logged too")
|
||||
|
||||
-- ---- the pre-existing honest failure still behaves exactly as before
|
||||
|
||||
local failMark = #Logger.history
|
||||
local failed = SaveData.saveOptions({ battleLayout = "wide" }, memfs("fail"))
|
||||
eq(failed, nil, "a write that returns false still returns nil")
|
||||
check(logged("options save failed"),
|
||||
"the false-return path keeps its own distinct log line")
|
||||
check(#Logger.history > failMark, "the false-return path still logs")
|
||||
|
||||
-- A dropped write must not be reported through the false-return message:
|
||||
-- the two are different diagnoses and the platform reports need to tell
|
||||
-- them apart.
|
||||
local last = Logger.history[#Logger.history]
|
||||
check(last and last:find("options save failed", 1, true) ~= nil,
|
||||
"the last failure logged is the false-return one, not the readback one")
|
||||
|
||||
-- ---- an interrupted write no longer resets every setting
|
||||
-- The launcher wrote WIDE and a later write dies partway through (the
|
||||
-- process replaced by HostShell.restart on the way back to the launcher, an
|
||||
-- external-storage flush that never happened), leaving a corrupt
|
||||
-- options.lua. loadOptions must promote the staged/backup copy instead of
|
||||
-- answering defaults, which is what "closing the game reset all my
|
||||
-- settings" looked like.
|
||||
local live = memfs("honest")
|
||||
SaveData.saveOptions({ battleLayout = "wide" }, live)
|
||||
SaveData.saveOptions({ battleLayout = "wide", textSpeed = 1 }, live)
|
||||
check(live.files[OPTIONS .. ".bak"] ~= nil,
|
||||
"the previous good options.lua is rolled aside before the rewrite")
|
||||
check(live.files[OPTIONS .. ".tmp"] == nil,
|
||||
"the staged witness is dropped once the main write is verified")
|
||||
live.files[OPTIONS] = "return { battleLayout = " -- died mid-rewrite
|
||||
local healed = SaveData.loadOptions(live)
|
||||
eq(healed and healed.battleLayout, "wide",
|
||||
"a corrupt options.lua is recovered from the rolled-aside copy")
|
||||
check(live.files[OPTIONS] ~= "return { battleLayout = ",
|
||||
"the main options file is healed from the copy that parsed")
|
||||
|
||||
-- ---- a lost main file must recover to the NEWEST verified write
|
||||
-- The platforms that lose options.lua do it on the hard teardown out of a
|
||||
-- game session (HostShell.restart's restartApp kill on Android, execv on a
|
||||
-- SteamOS AppImage), after rewrites whose bytes matched the file already on
|
||||
-- disk: play()'s lastVersion stamp and the in-game save flush re-encode the
|
||||
-- same table, and the key-sorted encoder makes those byte-identical, so the
|
||||
-- conditional pre-write roll skips them. The backup is therefore rolled
|
||||
-- forward after every verified write; otherwise recovery handed back the
|
||||
-- file from BEFORE the launcher's change, which is exactly the reported
|
||||
-- "set BATTLE LAYOUT to WIDE, go in game, close, and it is OG again" (#828).
|
||||
local lost = memfs("honest")
|
||||
SaveData.saveOptions({ battleLayout = "og", lastVersion = "red" }, lost)
|
||||
local editedOpts = SaveData.loadOptions(lost)
|
||||
editedOpts.battleLayout = "wide"
|
||||
SaveData.saveOptions(editedOpts, lost) -- the launcher's toggle
|
||||
local replay = SaveData.loadOptions(lost)
|
||||
replay.lastVersion = "red" -- play() re-stamps the same value
|
||||
SaveData.saveOptions(replay, lost) -- byte-identical rewrite
|
||||
SaveData.saveOptions(SaveData.loadOptions(lost), lost) -- in-game save flush, identical too
|
||||
lost.files[OPTIONS] = nil -- the platform ate the main file
|
||||
local promoted = SaveData.loadOptions(lost)
|
||||
eq(promoted.battleLayout, "wide",
|
||||
"a lost main file recovers to the newest verified write, not the "
|
||||
.. "pre-change backup (#828)")
|
||||
|
||||
local gone = memfs("honest")
|
||||
SaveData.saveOptions({ battleLayout = "wide" }, gone)
|
||||
gone.files[OPTIONS] = nil
|
||||
gone.files[OPTIONS .. ".bak"] = nil
|
||||
gone.files[OPTIONS .. ".tmp"] = nil
|
||||
eq(SaveData.loadOptions(gone).battleLayout,
|
||||
SaveData.defaultOptions().battleLayout,
|
||||
"with no copy left the defaults are still the answer")
|
||||
|
||||
-- ---- the reported sequence end to end: launcher setting -> play -> quit
|
||||
-- #828 as the reporter walks it (issue steps 2-7, and the "so its partly
|
||||
-- fixed" comment): change BATTLE LAYOUT from OG to WIDE in the launcher, go
|
||||
-- in game, close, reopen the launcher. Every options write is a whole-file
|
||||
-- rewrite out of the caller's table (saveOptions above), so the only thing
|
||||
-- keeping the launcher's key alive across a game-side write is WHEN the game
|
||||
-- took its copy: SaveData.load re-attaches a fresh loadOptions() to the save
|
||||
-- it just read (src/core/SaveData.lua:1108, and SaveData.newGame does the
|
||||
-- same at :1458), which is after the launcher's last write because
|
||||
-- RomImporter:play hands off only once the settings modal has saved
|
||||
-- (src/import/LauncherSettings.lua open/save, src/import/RomImporter.lua
|
||||
-- play). This pins that ordering: it is the invariant, not the merge, that
|
||||
-- makes the launcher's change survive.
|
||||
local hop = memfs("honest")
|
||||
SaveData.saveOptions({ battleLayout = "og" }, hop)
|
||||
|
||||
-- launcher: the gear menu's edited table, persisted on close
|
||||
local launcherOpts = SaveData.loadOptions(hop)
|
||||
launcherOpts.battleLayout = "wide"
|
||||
launcherOpts.lastVersion = "blue" -- #835 rides the same file
|
||||
SaveData.saveOptions(launcherOpts, hop)
|
||||
|
||||
-- boot: the game's copy is taken here, never earlier
|
||||
local gameOpts = SaveData.loadOptions(hop)
|
||||
eq(gameOpts.battleLayout, "wide",
|
||||
"the game boots on the value the launcher just wrote")
|
||||
|
||||
-- play: an in-game OPTION menu change writes the whole table back
|
||||
gameOpts.textSpeed = 1
|
||||
check(SaveData.saveOptions(gameOpts, hop) ~= nil, "the game-side write lands")
|
||||
|
||||
local reopened = SaveData.loadOptions(hop)
|
||||
eq(reopened.battleLayout, "wide",
|
||||
"the launcher's BATTLE LAYOUT survives a game-side options write (#828)")
|
||||
eq(reopened.textSpeed, 1, "and the in-game change is persisted alongside it")
|
||||
eq(reopened.lastVersion, "blue",
|
||||
"launcher-only keys the game never reads are carried through its write")
|
||||
|
||||
-- The corollary, and the reason the copy has to come from loadOptions: a
|
||||
-- caller that writes a partial literal instead of a loaded table drops every
|
||||
-- key it does not mention, because mergeOptions only fills DEFAULTS in around
|
||||
-- what it is handed (SaveData.mergeOptions). Nothing on the boot path does
|
||||
-- this today; the assertion is the guard rail if someone shortcuts it.
|
||||
SaveData.saveOptions({ battleLayout = "og" }, hop)
|
||||
eq(SaveData.loadOptions(hop).lastVersion, nil,
|
||||
"a partial write drops launcher-only keys, so the game must write the "
|
||||
.. "table loadOptions handed it")
|
||||
|
||||
-- Known gap, deliberately not asserted: a copy taken BEFORE the launcher's
|
||||
-- write and flushed after it still wins, because saveOptions merges only
|
||||
-- modOptions from disk and every other key is last-writer-wins. Measured,
|
||||
-- not guessed (og beats a newer wide). No shipping path holds an options
|
||||
-- table across a launcher write -- HostShell.restart replaces the process on
|
||||
-- the way back to the launcher (#785, #575) and LauncherSettings.open notes
|
||||
-- its own cached table is only true while its modal covers the launcher --
|
||||
-- so closing that gap needs a three-way merge (baseline vs caller vs disk),
|
||||
-- not a straight "disk wins", which would throw away real in-game changes.
|
||||
|
||||
T.finish("options_write_readback_bug828")
|
||||
@@ -0,0 +1,198 @@
|
||||
-- A RARE CANDY used from the field bag must leave the bag open (#796).
|
||||
--
|
||||
-- engine/menus/start_sub_menus.asm, StartMenu_Item / .useOrTossItem sorts the
|
||||
-- chosen item with IsInArray against UsableItems_CloseMenu first and
|
||||
-- UsableItems_PartyMenu second. RARE_CANDY is in the party-menu array
|
||||
-- (data/items/use_party.asm), so it reaches .useItem_partyMenu, which after
|
||||
-- `call UseItem` -- when wActionResultOrTookBattleTurn is not $02 --
|
||||
-- restores the screen and `jp StartMenu_Item`, i.e. re-enters the item list
|
||||
-- instead of CloseStartMenu. StartMenu_Item reloads wBagSavedMenuItem into
|
||||
-- wCurrentMenuItem before DisplayListMenuID, so the cursor comes back on the
|
||||
-- row you just used: that is what lets a stack of candies be mashed through.
|
||||
-- engine/items/item_effects.asm ItemUseVitamin .useRareCandy ends with
|
||||
-- RedrawPartyMenu / PrintStatsBox / WaitForTextScrollButtonPress /
|
||||
-- LearnMoveFromLevelUp / TryEvolvingMon and `jp RemoveUsedItem` -- it never
|
||||
-- whites out and never closes the start menu. Only .useItem_closeMenu items
|
||||
-- (UsableItems_CloseMenu: bike, escape rope, rods) jump to CloseStartMenu.
|
||||
--
|
||||
-- The port popped the bag ListMenu at the head of the leveledTo branch, which
|
||||
-- also skipped the "xN" refresh below it, so the level text played over the
|
||||
-- overworld and the player was dumped out of the menu per candy.
|
||||
-- luajit tests/engine/rare_candy_bag_open_bug796.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")
|
||||
|
||||
-- Lazily-required inside the use branches, so seeding package.loaded before
|
||||
-- the UI modules load is enough to keep the suite silent and love-free.
|
||||
package.loaded["src.core.Sound"] = {
|
||||
play = function() end,
|
||||
playCry = function() end,
|
||||
}
|
||||
-- Real TextBoxes want a Font atlas. This flow only cares that a message
|
||||
-- opened, what it says, and what its onDone does.
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
|
||||
}
|
||||
-- BagMenu and PartyMenu bind TextBox at require time, so they load against
|
||||
-- the stub; Screens caches its factory per id and must be told to forget.
|
||||
package.loaded["src.ui.BagMenu"] = nil
|
||||
package.loaded["src.ui.PartyMenu"] = nil
|
||||
local BagMenu = require("src.ui.BagMenu")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
require("src.ui.Screens").invalidate()
|
||||
|
||||
local Fixtures = require("tests.modkit.fixtures")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
-- The fixture item table has no candy of its own; ItemEffects keys the
|
||||
-- level-up branch on the id, and BagMenu only reads name/keyItem off the def.
|
||||
Data.items.RARE_CANDY = {
|
||||
id = "RARE_CANDY", index = 90, name = "RARE CANDY", price = 4800,
|
||||
tossable = true,
|
||||
}
|
||||
|
||||
-- The mon: FIXMON_C has an empty `evolutions` and a learnset that stops at
|
||||
-- level 1, so the 5 -> 6 candy prints its line and nothing else follows it.
|
||||
-- That keeps the assertions on the bag rather than on the stat box, which
|
||||
-- would need real graphics.
|
||||
local function freshGame(candies)
|
||||
local mon = Pokemon.new(Data, "FIXMON_C", 5)
|
||||
local game = {
|
||||
data = Data,
|
||||
save = {
|
||||
party = { mon },
|
||||
player = { name = "RED", id = 1 },
|
||||
inventory = {},
|
||||
options = {},
|
||||
flags = {},
|
||||
money = 0,
|
||||
},
|
||||
}
|
||||
game.stack = {
|
||||
states = {},
|
||||
push = function(self, s) table.insert(self.states, s) end,
|
||||
pop = function(self) return table.remove(self.states) end,
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
}
|
||||
-- one button edge per update, the way Input reports a fixed step
|
||||
game.input = { pressed = nil }
|
||||
function game.input:wasPressed(b) return self.pressed == b end
|
||||
-- FIX POTION first so the candy is never row 1: an index that silently
|
||||
-- reset to the top would otherwise pass the cursor assertion by accident
|
||||
Bag.add(game.save, "FIX_POTION", 1)
|
||||
Bag.add(game.save, "RARE_CANDY", candies)
|
||||
return game, mon
|
||||
end
|
||||
|
||||
local function isPicker(s) return getmetatable(s) == PartyMenu end
|
||||
local function isBox(s) return type(s) == "table" and s.textBox == true end
|
||||
|
||||
local function inStack(stack, pred)
|
||||
for _, s in ipairs(stack.states) do
|
||||
if pred(s) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function rowFor(list, id)
|
||||
for i, r in ipairs(list.items) do
|
||||
if r.value == id then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Open the bag, put the cursor on `id`, choose it, take USE off the
|
||||
-- USE/TOSS box, then press A on the party picker. Returns the bag list.
|
||||
local function useFromBag(game, battle, id)
|
||||
local list = BagMenu.new(game, { battle = battle })
|
||||
game.stack:push(list)
|
||||
local row = rowFor(list, id)
|
||||
if not row then return nil, "no " .. id .. " row in the bag" end
|
||||
list.index = row
|
||||
list.onChoose(list.items[row], list)
|
||||
local sub = game.stack:top()
|
||||
if not battle and sub and sub.items and sub.items[1]
|
||||
and sub.items[1].onSelect then
|
||||
game.stack:pop() -- the USE/TOSS Menu pops itself on select
|
||||
sub.items[1].onSelect()
|
||||
end
|
||||
local picker = game.stack:top()
|
||||
if not isPicker(picker) then return nil, "party picker never opened" end
|
||||
game.input.pressed = "a"
|
||||
picker:update(1 / 60)
|
||||
game.input.pressed = nil
|
||||
return list
|
||||
end
|
||||
|
||||
-- The bug: three candies in the bag, use one in the field.
|
||||
do
|
||||
local game, mon = freshGame(3)
|
||||
local list, why = useFromBag(game, nil, "RARE_CANDY")
|
||||
if check(list ~= nil, "the bag opened and reached the picker: " .. tostring(why)) then
|
||||
eq(mon.level, 6, "the candy leveled the mon 5 -> 6")
|
||||
check(not inStack(game.stack, isPicker),
|
||||
"the pickOnly picker popped itself before onSwitch")
|
||||
check(inStack(game.stack, function(s) return s == list end),
|
||||
"the bag list is STILL on the stack (.useItem_partyMenu re-enters "
|
||||
.. "StartMenu_Item, it does not CloseStartMenu) (#796)")
|
||||
|
||||
local row = rowFor(list, "RARE_CANDY")
|
||||
if check(row ~= nil, "the RARE CANDY row survived the use") then
|
||||
eq(list.items[row].right, "x2", "and its count followed the inventory")
|
||||
eq(list.index, row, "with the cursor left on it (wBagSavedMenuItem), "
|
||||
.. "so the next candy is one A press away")
|
||||
end
|
||||
eq(game.save.inventory.RARE_CANDY, 2, "one candy was consumed")
|
||||
|
||||
local box = game.stack:top()
|
||||
if check(isBox(box), "the grew-to-level line prints over the open bag") then
|
||||
check(box.text:find("level 6", 1, true) ~= nil,
|
||||
"and it names the new level: " .. tostring(box.text))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- The last candy: the row goes away (RemoveUsedItem empties the slot) and the
|
||||
-- cursor clamps to a real row -- but the list itself still must not close.
|
||||
do
|
||||
local game = freshGame(1)
|
||||
local list = useFromBag(game, nil, "RARE_CANDY")
|
||||
if check(list ~= nil, "the bag reached the picker with a single candy") then
|
||||
check(inStack(game.stack, function(s) return s == list end),
|
||||
"the last candy does not close the bag either (#796)")
|
||||
check(rowFor(list, "RARE_CANDY") == nil, "its row was removed")
|
||||
eq(game.save.inventory.RARE_CANDY, nil, "and the slot is empty")
|
||||
check(list.index >= 1 and list.index <= #list.items,
|
||||
"the cursor clamped to a valid row (index " .. tostring(list.index)
|
||||
.. " of " .. #list.items .. ")")
|
||||
end
|
||||
end
|
||||
|
||||
-- Boundary the fix must not have moved: a candy is refused mid-battle, so the
|
||||
-- field behavior above can never be mistaken for a battle regression.
|
||||
-- item_effects.asm:800-803, ItemUseVitamin reads wIsInBattle and
|
||||
-- `jp nz, ItemUseNotTime` before it ever falls into ItemUseMedicine, which is
|
||||
-- why the port's battle-side branch is a guard rather than a live path. A
|
||||
-- bare table stands in for the battle: nothing on this route reads it.
|
||||
do
|
||||
local game, mon = freshGame(3)
|
||||
local list = useFromBag(game, { fakeBattle = true }, "RARE_CANDY")
|
||||
if check(list ~= nil, "the battle bag reached the picker") then
|
||||
eq(mon.level, 5, "a RARE CANDY mid-battle levels nothing (ItemUseVitamin "
|
||||
.. "-> ItemUseNotTime)")
|
||||
eq(game.save.inventory.RARE_CANDY, 3, "and is not consumed")
|
||||
local box = game.stack:top()
|
||||
if check(isBox(box), "the refusal prints") then
|
||||
check(box.text:find("time to use", 1, true) ~= nil,
|
||||
"with ItemUseNotTime's line: " .. tostring(box.text))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,51 @@
|
||||
-- SafeArea.rect unit sanity (#810): the iOS build reported the portrait
|
||||
-- safe rect in framebuffer PIXELS while love.graphics works in DPI-scaled
|
||||
-- units, and the old clamp kept the inflated top inset -- the launcher
|
||||
-- started a band down the screen and left the top of it black. A rect
|
||||
-- that cannot fit the unit window is converted back to units with
|
||||
-- per-axis ratios (the axes can disagree, #208). No pokered cite: the
|
||||
-- launcher is port-only chrome.
|
||||
-- luajit tests/engine/safe_area_units_test.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local eq = T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
|
||||
local oldDims = love.graphics.getDimensions
|
||||
local oldPix = love.graphics.getPixelDimensions
|
||||
local oldSafe = love.window.getSafeArea
|
||||
|
||||
local function frame(uw, uh, pw, ph, sx, sy, sw, sh)
|
||||
love.graphics.getDimensions = function() return uw, uh end
|
||||
love.graphics.getPixelDimensions = function() return pw, ph end
|
||||
love.window.getSafeArea = function() return sx, sy, sw, sh end
|
||||
return SafeArea.rect()
|
||||
end
|
||||
|
||||
-- a pixel-based rect on a 3x portrait phone comes back in units (#810)
|
||||
local x, y, w, h = frame(375, 812, 1125, 2436, 0, 132, 1125, 2232)
|
||||
eq(x, 0, "pixel-unit safe x rescales")
|
||||
eq(y, 44, "pixel-unit top inset rescales to the real notch")
|
||||
eq(w, 375, "pixel-unit safe width rescales")
|
||||
eq(h, 744, "pixel-unit safe height rescales")
|
||||
|
||||
-- a correct unit rect passes through untouched
|
||||
x, y, w, h = frame(375, 812, 1125, 2436, 0, 44, 375, 734)
|
||||
eq(y, 44, "a unit rect keeps its top inset")
|
||||
eq(h, 734, "a unit rect keeps its height")
|
||||
|
||||
-- dpi 1: no rescale, the oversized rect still clamps to the window
|
||||
x, y, w, h = frame(640, 576, 640, 576, 0, 100, 900, 900)
|
||||
eq(y, 100, "no rescale when units are pixels")
|
||||
eq(w, 640, "width clamps to the drawable window")
|
||||
eq(h, 476, "height clamps to the drawable window")
|
||||
|
||||
love.graphics.getDimensions = oldDims
|
||||
love.graphics.getPixelDimensions = oldPix
|
||||
love.window.getSafeArea = oldSafe
|
||||
|
||||
T.finish("safe area units")
|
||||
@@ -0,0 +1,187 @@
|
||||
-- Gen1 save codec (src/save_convert/GenSave.lua) for wToggleableObjectFlags
|
||||
-- (ram/wram.asm, flag_array $100): the ShowObject/HideObject persistence the
|
||||
-- codec used to skip entirely, so an import resurrected both Mt Moon fossils
|
||||
-- (#857) and reverted Cerulean's GUARD1/GUARD2/ROCKET swap so the officer
|
||||
-- blocked the robbed-house door again (#763). Bit numbering comes from
|
||||
-- ../pokered/data/maps/toggleable_objects.asm entry order (bit set = hidden,
|
||||
-- engine/overworld/toggleable_objects.asm IsObjectHidden), and the offset is
|
||||
-- re-derived here from the wram walk rather than read out of GenSave.OFFSETS.
|
||||
-- Also covers the Yellow-only wPikachuHappiness byte (#763, #838): the
|
||||
-- 0x271C offset is pinned from the pokeyellow symbol file, not a local
|
||||
-- pokeyellow checkout, so it still wants a confirmation against a real
|
||||
-- emulator-written Yellow .sav.
|
||||
-- luajit tests/engine/save_convert_toggle_objects.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 bit = require("bit")
|
||||
local GenSave = require("src.save_convert.GenSave")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
-- the codec crosswalks need the real dataset; CI has no ROM
|
||||
local loadPokemon = loadfile("data/generated/pokemon.lua")
|
||||
if not loadPokemon then
|
||||
print("save_convert_toggle_objects skipped (needs data/generated/ for the Gen1 save codec)")
|
||||
os.exit(0)
|
||||
end
|
||||
|
||||
GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")())
|
||||
local toggles = loadfile("src/save_convert/data/toggle_objects.lua")()
|
||||
local data = {
|
||||
pokemon = loadPokemon(),
|
||||
moves = loadfile("data/generated/moves.lua")(),
|
||||
items = loadfile("data/generated/items.lua")(),
|
||||
maps = loadfile("data/generated/maps.lua")(),
|
||||
eventFlags = loadfile("src/save_convert/data/event_flags.lua")(),
|
||||
toggleObjects = toggles,
|
||||
}
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- offset pins, independent of the codec's own arithmetic: wram.asm places
|
||||
-- wToggleableObjectFlags 2 bytes (wPlayerCoins) past wPlayerCoins' label,
|
||||
-- i.e. sav absolute 0x2852; the pokeyellow symbol file places
|
||||
-- wPikachuHappiness at d46f - wMainDataStart d2f6 = 377, absolute 0x271C
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local OFF = GenSave.OFFSETS
|
||||
eq(OFF.toggleObjectFlags, OFF.coins + 2,
|
||||
"wToggleableObjectFlags sits 2 bytes (wPlayerCoins) past O.coins")
|
||||
eq(OFF.toggleObjectFlags, 0x2852, "wToggleableObjectFlags is sav byte 0x2852")
|
||||
eq(OFF.pikachuHappiness, 0x271C, "wPikachuHappiness is sav byte 0x271C")
|
||||
|
||||
-- independent flag_array read (byte = index / 8, bit = index % 8), so nothing
|
||||
-- below trusts the writer it is checking
|
||||
local function flagGet(bytes, base, index)
|
||||
local byte = bytes:byte(base + math.floor(index / 8) + 1)
|
||||
return bit.band(bit.rshift(byte, index % 8), 1) == 1
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- crosswalk <-> maps.lua contract: every named toggle entry must resolve
|
||||
-- to a real object_event, or encode's itemsTaken/defeatedTrainers fold
|
||||
-- (which looks the object up by name) silently misses it
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local entries = 0
|
||||
for _, e in pairs(toggles.byBit) do
|
||||
entries = entries + 1
|
||||
local found
|
||||
for _, obj in ipairs((data.maps[e[1]] or {}).objects or {}) do
|
||||
if obj.name == e[2] then found = obj break end
|
||||
end
|
||||
check(found ~= nil, e[1] .. " has an object_event named " .. e[2])
|
||||
end
|
||||
-- toggleable_objects.asm has 228 rows; two are placeholders with no
|
||||
-- object_event in this port (SILPHCO7F_UNUSED, the UNUSED_MAP_F4 entry)
|
||||
eq(entries, 226, "the crosswalk carries every real toggle bit and no more")
|
||||
|
||||
-- the bits under test, straight from the crosswalk's own numbering
|
||||
eq(toggles.byBit[109][2], "MTMOONB2F_DOME_FOSSIL", "bit 109 is the dome fossil")
|
||||
eq(toggles.byBit[110][2], "MTMOONB2F_HELIX_FOSSIL", "bit 110 is the helix fossil")
|
||||
eq(toggles.byBit[7][2], "CERULEANCITY_GUARD1", "bit 7 is the door guard")
|
||||
eq(toggles.byBit[9][2], "CERULEANCITY_GUARD2", "bit 9 is the roof guard")
|
||||
eq(toggles.byBit[104][2], "MTMOON1F_MOON_STONE", "bit 104 is the moon stone")
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- round trip: templateless export of a save past the fossil pickup
|
||||
-- (data/scripts/story2.lua hides both balls) and the Cerulean robbery
|
||||
-- resolution (data/scripts/story5.lua rocketRows shows GUARD1, hides
|
||||
-- GUARD2/ROCKET), plus a taken overworld item, which vanilla folds into
|
||||
-- these same bits (engine/events/pick_up_item.asm)
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local function seedSave()
|
||||
local save = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" })
|
||||
save.party = { {
|
||||
species = "SQUIRTLE", level = 6, exp = 200,
|
||||
dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 },
|
||||
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = { hp = 22, attack = 12, defense = 13, speed = 11, special = 12 },
|
||||
hp = 22,
|
||||
moves = { { id = "TACKLE", pp = 35, ppUps = 0 } },
|
||||
nickname = "SQ", ot = "RED", otId = save.player.id, catchRate = 45,
|
||||
} }
|
||||
return save
|
||||
end
|
||||
|
||||
local set = seedSave()
|
||||
set.objectToggles = {
|
||||
MT_MOON_B2F = {
|
||||
MTMOONB2F_DOME_FOSSIL = false,
|
||||
MTMOONB2F_HELIX_FOSSIL = false,
|
||||
},
|
||||
CERULEAN_CITY = {
|
||||
CERULEANCITY_GUARD1 = true,
|
||||
CERULEANCITY_GUARD2 = false,
|
||||
},
|
||||
}
|
||||
-- the moon stone rides itemsTaken (src/world/OverworldController.lua
|
||||
-- force-hides picked items), never objectToggles, so encode must fold it in
|
||||
set.itemsTaken = { MT_MOON_1F_obj_9 = true }
|
||||
|
||||
local setBytes = GenSave.encode(set, data, nil)
|
||||
eq(#setBytes, GenSave.SAVE_SIZE, "the export is a 32768-byte save")
|
||||
|
||||
local TOG = OFF.toggleObjectFlags
|
||||
check(flagGet(setBytes, TOG, 109), "the taken dome fossil is hidden (bit 109)")
|
||||
check(flagGet(setBytes, TOG, 110), "the taken helix fossil is hidden (bit 110)")
|
||||
check(flagGet(setBytes, TOG, 9), "the swapped-out roof guard is hidden (bit 9)")
|
||||
check(not flagGet(setBytes, TOG, 7),
|
||||
"the officer now beside the door stays visible (bit 7 clear)")
|
||||
check(flagGet(setBytes, TOG, 104),
|
||||
"the taken moon stone folds from itemsTaken into bit 104")
|
||||
|
||||
-- untouched entries fall back to their compiled-in defaults, not to zero
|
||||
check(flagGet(setBytes, TOG, 0), "PALLETTOWN_OAK defaults hidden (bit 0 set)")
|
||||
check(not flagGet(setBytes, TOG, 1),
|
||||
"VIRIDIANCITY_OLD_MAN_SLEEPY defaults visible (bit 1 clear)")
|
||||
|
||||
local back = GenSave.decode(setBytes, data)
|
||||
eq(#(back.warnings or {}), 0, "the export decodes with no warnings")
|
||||
local reTog = back.objectToggles
|
||||
check(type(reTog) == "table", "an import populates save.objectToggles")
|
||||
eq(reTog.MT_MOON_B2F.MTMOONB2F_DOME_FOSSIL, false,
|
||||
"the dome fossil stays taken across export -> import")
|
||||
eq(reTog.MT_MOON_B2F.MTMOONB2F_HELIX_FOSSIL, false,
|
||||
"the helix fossil stays taken across export -> import")
|
||||
eq(reTog.CERULEAN_CITY.CERULEANCITY_GUARD1, true,
|
||||
"the officer stays beside the door across export -> import")
|
||||
eq(reTog.CERULEAN_CITY.CERULEANCITY_GUARD2, false,
|
||||
"the roof guard stays gone across export -> import")
|
||||
eq(reTog.PALLET_TOWN.PALLETTOWN_OAK, false,
|
||||
"Oak's roaming sprite imports at its hidden default")
|
||||
eq(reTog.VIRIDIAN_CITY.VIRIDIANCITY_OLD_MAN_SLEEPY, true,
|
||||
"the sleepy old man imports at his visible default")
|
||||
eq(reTog.MT_MOON_1F.MTMOON1F_MOON_STONE, false,
|
||||
"the folded moon stone imports hidden too")
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Yellow starter friendship: gated on the data set's game because the
|
||||
-- byte is current-map scratch in Red/Blue (see O.pikachuHappiness)
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
check(not flagGet(setBytes, OFF.pikachuHappiness, 0)
|
||||
and setBytes:byte(OFF.pikachuHappiness + 1) == 0,
|
||||
"a Red/Blue export leaves the scratch byte at 0x271C zeroed")
|
||||
eq(back.pikachuHappiness, nil, "a Red/Blue import never invents a happiness")
|
||||
|
||||
local dataYellow = {
|
||||
pokemon = data.pokemon, moves = data.moves, items = data.items,
|
||||
maps = data.maps, toggleObjects = toggles,
|
||||
eventFlags = loadfile("src/save_convert/data/event_flags_yellow.lua")(),
|
||||
gameVersion = "yellow",
|
||||
}
|
||||
local ySave = seedSave()
|
||||
ySave.pikachuHappiness = 200
|
||||
local yBytes = GenSave.encode(ySave, dataYellow, nil)
|
||||
eq(yBytes:byte(OFF.pikachuHappiness + 1), 200,
|
||||
"pikachuHappiness = 200 reaches sav byte 0x271C")
|
||||
local yBack = GenSave.decode(yBytes, dataYellow)
|
||||
eq(yBack.pikachuHappiness, 200,
|
||||
"the follower's happiness survives export -> import on Yellow")
|
||||
|
||||
T.finish("save_convert_toggle_objects")
|
||||
@@ -204,6 +204,112 @@ do
|
||||
check(type(erre) == "string", "the empty-export failure carries a message")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- oversize / truncated policy
|
||||
-- A .sav LARGER than 32768 bytes whose first 32768 bytes carry a valid
|
||||
-- main-data checksum is a cartridge save with a trailing emulator RTC footer
|
||||
-- (VBA appends 44/48 bytes -- bgb.bircd.org/rtcsave.html). Without force the
|
||||
-- import must NOT happen silently: it returns (false, nil, {needsConfirm})
|
||||
-- so the launcher can ask. With force the surplus is dropped. A file
|
||||
-- SHORTER than 32768 is refused unless its checksum region is intact, in
|
||||
-- which case it imports zero-padded (a truncated box region, not a loss).
|
||||
|
||||
-- DroppedFile-shaped source for arbitrary bytes (readSource disambiguates a
|
||||
-- raw string of length != 32768 as a path, so tests hand a file object).
|
||||
local function fileSource(bytes)
|
||||
return {
|
||||
_bytes = bytes,
|
||||
open = function() return true end,
|
||||
getSize = function(self) return #self._bytes end,
|
||||
read = function(self) return self._bytes end,
|
||||
close = function() return true end,
|
||||
}
|
||||
end
|
||||
|
||||
-- A realistic 44-byte VBA MBC3 RTC footer (4 dwords, 16-byte latched copies,
|
||||
-- 8-byte unix timestamp, 4-byte unix timestamp -- bgb.bircd.org/rtcsave.html).
|
||||
local function rtcFooter()
|
||||
local parts = {}
|
||||
local function pushLe(v)
|
||||
parts[#parts + 1] = string.char(v % 256, math.floor(v / 256) % 256,
|
||||
math.floor(v / 65536) % 256, math.floor(v / 16777216) % 256)
|
||||
end
|
||||
pushLe(27) -- days
|
||||
pushLe(29) -- hours
|
||||
pushLe(11) -- minutes
|
||||
pushLe(200) -- seconds
|
||||
parts[#parts + 1] = string.rep("\0", 16) -- latched RTC copies
|
||||
parts[#parts + 1] = string.rep("\0", 8) -- 64-bit unix timestamp
|
||||
pushLe(0x669A00BF) -- 32-bit unix timestamp (2024-07-09)
|
||||
return table.concat(parts)
|
||||
end
|
||||
|
||||
do
|
||||
local oversize = syntheticSave("OVS") .. rtcFooter()
|
||||
eq(#oversize, 32768 + 44, "the oversize fixture is 32812 bytes like the real VBA save")
|
||||
|
||||
local files = fresh()
|
||||
local ok, res, info = SaveFileIO.importToSlot(fileSource(oversize), "red")
|
||||
eq(ok, false, "an oversize valid save is not imported without confirmation")
|
||||
eq(res, nil, "the oversize result carries no error string")
|
||||
check(info ~= nil and info.needsConfirm == true, "the oversize result requests confirmation")
|
||||
eq(info and info.size, #oversize, "the confirmation carries the actual file size")
|
||||
eq(#SaveData.listSlots("red"), 0, "no slot is created before confirmation")
|
||||
|
||||
-- forcing the import truncates the footer away
|
||||
local fok, slotId = SaveFileIO.importToSlot(fileSource(oversize), "red", true)
|
||||
eq(fok, true, "force imports the truncated save")
|
||||
local loaded = SaveData.load("red")
|
||||
eq(loaded and loaded.player.name, "OVS", "the forced import keeps the player name")
|
||||
eq(SaveData.activeSlot("red"), slotId, "the forced import becomes active")
|
||||
|
||||
local eok, path = SaveFileIO.exportActiveSlot("red")
|
||||
eq(eok, true, "the forced import exports")
|
||||
local rel = path:gsub("^/fake/save/", "")
|
||||
local outBytes = files[rel]
|
||||
eq(outBytes and #outBytes, GenSave.SAVE_SIZE,
|
||||
"the export of a forced import is exactly 32768 bytes (footer dropped)")
|
||||
check(outBytes and mainChecksumValid(outBytes),
|
||||
"the forced-import export carries a valid main-data checksum")
|
||||
end
|
||||
|
||||
do
|
||||
-- oversize but corrupt: flip a byte inside the checksummed region
|
||||
local bad = syntheticSave("BAD") .. rtcFooter()
|
||||
bad = bad:sub(1, OFF.money)
|
||||
.. string.char((bad:byte(OFF.money + 1) + 1) % 256)
|
||||
.. bad:sub(OFF.money + 2)
|
||||
fresh()
|
||||
local ok, res = SaveFileIO.importToSlot(fileSource(bad), "red")
|
||||
eq(ok, false, "an oversize file with a bad checksum is rejected")
|
||||
check(type(res) == "string" and res:find("checksum", 1, true) ~= nil,
|
||||
"the oversize bad-checksum error mentions the checksum")
|
||||
eq(#SaveData.listSlots("red"), 0, "no slot is created for a bad oversize file")
|
||||
end
|
||||
|
||||
do
|
||||
-- truncated to 14000 bytes: >= 13572 keeps the whole checksum region and the
|
||||
-- stored checksum byte intact, so the checksum validates and the save imports
|
||||
-- with the missing tail (box banks) zero-filled.
|
||||
local truncated = syntheticSave("SHORT"):sub(1, 14000)
|
||||
check(truncated:len() >= OFF.mainChecksum + 1,
|
||||
"the truncated fixture still carries the stored checksum byte")
|
||||
fresh()
|
||||
local ok, slotId = SaveFileIO.importToSlot(fileSource(truncated), "red")
|
||||
eq(ok, true, "a truncated file with a valid checksum imports zero-padded")
|
||||
local loaded = SaveData.load("red")
|
||||
eq(loaded and loaded.player.name, "SHORT", "the truncated import keeps the player name")
|
||||
eq(#SaveData.listSlots("red"), 1, "the truncated import creates a slot")
|
||||
eq(SaveData.activeSlot("red"), slotId, "the truncated import becomes active")
|
||||
|
||||
-- truncated but too short to even carry the checksum byte -> refused
|
||||
local short = truncated:sub(1, OFF.mainChecksum)
|
||||
local sok, serr = SaveFileIO.importToSlot(fileSource(short), "red")
|
||||
eq(sok, false, "a file too short to hold a checksum byte is refused")
|
||||
check(type(serr) == "string" and serr:find("32", 1, true) ~= nil,
|
||||
"the too-short error names the required size")
|
||||
eq(#SaveData.listSlots("red"), 1, "the too-short refusal creates no new slot")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- fixture-gated real save
|
||||
|
||||
do
|
||||
|
||||
@@ -188,6 +188,43 @@ local leftover = 0
|
||||
for _ in pairs(stagedTemps) do leftover = leftover + 1 end
|
||||
eq(leftover, 0, "fallback cleans staged temp after install")
|
||||
|
||||
-- #801: a same-id copy under a different folder name is replaced too, so the
|
||||
-- update cannot leave a shadow copy for discover()'s first-id-wins race
|
||||
resetFs()
|
||||
files["mods/WildsOfKanto-1.5.0/manifest.json"] =
|
||||
('{"id":"%s","name":"Old Copy","version":"0.9.0","entry":"main.lua"}')
|
||||
:format(MOD_ID)
|
||||
files["mods/WildsOfKanto-1.5.0/main.lua"] = "return function() end\n"
|
||||
files["imports/mods/update.zip"] = "PK\3\4update"
|
||||
ok, err = LauncherMods.installZip("imports/mods/update.zip",
|
||||
{ replace = true, expectId = MOD_ID })
|
||||
check(ok == true, "replace install succeeds over an odd-named copy ("
|
||||
.. tostring(err) .. ")")
|
||||
check(files["mods/WildsOfKanto-1.5.0/manifest.json"] == nil,
|
||||
"odd-named same-id folder is removed by the replace")
|
||||
check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil,
|
||||
"replace still lands in mods/<id>")
|
||||
|
||||
-- #834: a manifest-less mods/<id> tree (interrupted copy debris) must not
|
||||
-- block a plain re-import as "already installed"
|
||||
resetFs()
|
||||
files["mods/" .. MOD_ID .. "/gfx/a.bin"] = "x"
|
||||
files["imports/mods/again.zip"] = "PK\3\4again"
|
||||
ok, err = LauncherMods.installZip("imports/mods/again.zip")
|
||||
check(ok == true, "debris tree does not block re-import ("
|
||||
.. tostring(err) .. ")")
|
||||
check(files["mods/" .. MOD_ID .. "/gfx/a.bin"] == nil,
|
||||
"debris is cleared by the re-import")
|
||||
|
||||
-- a real installed copy still refuses a plain duplicate import
|
||||
resetFs()
|
||||
files["mods/" .. MOD_ID .. "/manifest.json"] = ARCHIVE[MOD_ID .. "/manifest.json"]
|
||||
files["imports/mods/dup.zip"] = "PK\3\4dup"
|
||||
ok, err = LauncherMods.installZip("imports/mods/dup.zip")
|
||||
check(not ok, "a listed install still refuses a plain duplicate import")
|
||||
check(tostring(err):find("already installed", 1, true),
|
||||
"duplicate refusal still names already installed")
|
||||
|
||||
-- Restore
|
||||
love.filesystem = savedFs
|
||||
SaveData.portableBaseDir = savedSaveDataPortable
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
-- #801 / #834: what the PLAYER sees after an update over a shadow copy.
|
||||
-- The tier file (launcher_mods_install_zip_test.lua) asserts which folders
|
||||
-- survive installZip; this one asserts the panel-facing contracts built on
|
||||
-- top of them: LauncherMods.list() must report the NEW version after a
|
||||
-- replace even when a same-id copy sits under an archive-named folder that
|
||||
-- enumerates first (#801, "Updated ... to X" yet the old version kept
|
||||
-- loading), and uninstall()/re-import must both recover a manifest-less
|
||||
-- mods/<id> debris tree left by an interrupted copy (#834, "already
|
||||
-- installed" with nothing showing in the panel).
|
||||
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("launcher mods shadow copy #801/#834")
|
||||
local eq = S.eq
|
||||
local check = S.check
|
||||
|
||||
-- The real-world shape from the #801 report: a hand-unzipped copy kept the
|
||||
-- archive's folder name. "W" sorts before "o" in the stub's sorted
|
||||
-- enumeration, so the shadow folder enumerates first -- the exact ordering
|
||||
-- that made discover()'s first-id-wins dedupe resolve the stale copy.
|
||||
local MOD_ID = "overworld_wild_spawns"
|
||||
local SHADOW = "mods/WildsOfKanto-1.5.0"
|
||||
local NEW_VERSION = "1.7.1"
|
||||
|
||||
local ARCHIVE = {
|
||||
[MOD_ID .. "/manifest.json"] =
|
||||
('{"id":"%s","name":"Wilds of Kanto","version":"%s","entry":"main.lua"}')
|
||||
:format(MOD_ID, NEW_VERSION),
|
||||
[MOD_ID .. "/main.lua"] = "return function() end\n",
|
||||
}
|
||||
|
||||
local files, dirs, arch = {}, {}, {}
|
||||
|
||||
local function resetFs()
|
||||
for k in pairs(files) do files[k] = nil end
|
||||
for k in pairs(dirs) do dirs[k] = nil end
|
||||
for k in pairs(arch) do arch[k] = nil end
|
||||
end
|
||||
|
||||
local function dirChild(key, name)
|
||||
if name == nil or name == "" then return key:match("^[^/]+") end
|
||||
local prefix = name .. "/"
|
||||
if key:sub(1, #prefix) ~= prefix then return nil end
|
||||
return key:sub(#prefix + 1):match("^[^/]+")
|
||||
end
|
||||
|
||||
local function mapInfo(map, name, kind)
|
||||
if map[name] ~= nil then return { type = kind or "file" } end
|
||||
for key in pairs(map) do
|
||||
if dirChild(key, name) then return { type = "directory" } end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local vfs = {}
|
||||
|
||||
function vfs.write(name, data)
|
||||
files[name] = data
|
||||
return true
|
||||
end
|
||||
|
||||
function vfs.read(name)
|
||||
if arch[name] ~= nil then return arch[name] end
|
||||
return files[name]
|
||||
end
|
||||
|
||||
function vfs.remove(name)
|
||||
files[name] = nil
|
||||
dirs[name] = nil
|
||||
return true
|
||||
end
|
||||
|
||||
function vfs.createDirectory(name)
|
||||
dirs[name] = true
|
||||
return true
|
||||
end
|
||||
|
||||
function vfs.getInfo(name, kind)
|
||||
local info = mapInfo(arch, name)
|
||||
or mapInfo(files, name)
|
||||
or mapInfo(dirs, name, "directory")
|
||||
if info and kind and info.type ~= kind then return nil end
|
||||
return info
|
||||
end
|
||||
|
||||
function vfs.getDirectoryItems(name)
|
||||
local seen, items = {}, {}
|
||||
local function add(child)
|
||||
if child and not seen[child] then
|
||||
seen[child] = true
|
||||
items[#items + 1] = child
|
||||
end
|
||||
end
|
||||
for key in pairs(arch) do add(dirChild(key, name)) end
|
||||
for key in pairs(files) do add(dirChild(key, name)) end
|
||||
for key in pairs(dirs) do add(dirChild(key, name)) end
|
||||
table.sort(items)
|
||||
return items
|
||||
end
|
||||
|
||||
function vfs.mount(_, point)
|
||||
for rel, body in pairs(ARCHIVE) do
|
||||
arch[point .. "/" .. rel] = body
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function vfs.unmount()
|
||||
for k in pairs(arch) do arch[k] = nil end
|
||||
return true
|
||||
end
|
||||
|
||||
function vfs.newFileData(data, name)
|
||||
return { __filedata = true, data = data, name = name }
|
||||
end
|
||||
|
||||
function vfs.getSaveDirectory()
|
||||
return "/tmp/pokeport-shadow-copy-test"
|
||||
end
|
||||
|
||||
function vfs.getSource()
|
||||
return nil
|
||||
end
|
||||
|
||||
local savedFs = love.filesystem
|
||||
local savedCacheFs = package.loaded["src.import.CacheFs"]
|
||||
local savedLauncherMods = package.loaded["src.mods.LauncherMods"]
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local savedPortableBase = SaveData.portableBaseDir
|
||||
local savedPortableFs = SaveData.portableFs
|
||||
|
||||
love.filesystem = vfs
|
||||
package.loaded["src.import.CacheFs"] = nil
|
||||
package.loaded["src.mods.LauncherMods"] = nil
|
||||
-- Portable mode off: loadOptions/uninstall must stay on the stub vfs, or the
|
||||
-- checkout's real save directory would leak into the test.
|
||||
SaveData.portableBaseDir = function() return nil end
|
||||
SaveData.portableFs = function() return nil end
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
|
||||
local function versionOf(id)
|
||||
for _, row in ipairs(LauncherMods.list()) do
|
||||
if row.id == id then return row.version end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- #801: the shadow copy alone resolves as the mod (sanity for the setup),
|
||||
-- and after a replace-install the panel row flips to the zip's version.
|
||||
-- Pre-fix, installZip returned success but only rewrote mods/<id>; the
|
||||
-- shadow folder enumerated first and list() kept answering 1.5.0 forever.
|
||||
resetFs()
|
||||
files[SHADOW .. "/manifest.json"] =
|
||||
('{"id":"%s","name":"Wilds of Kanto","version":"1.5.0","entry":"main.lua"}')
|
||||
:format(MOD_ID)
|
||||
files[SHADOW .. "/main.lua"] = "return function() end\n"
|
||||
eq(versionOf(MOD_ID), "1.5.0", "shadow copy resolves before the update")
|
||||
|
||||
files["imports/mods/update.zip"] = "PK\3\4update"
|
||||
local ok, err = LauncherMods.installZip("imports/mods/update.zip",
|
||||
{ replace = true, expectId = MOD_ID })
|
||||
check(ok == true, "replace over a shadow copy succeeds (" .. tostring(err) .. ")")
|
||||
eq(err, MOD_ID, "replace reports the manifest id")
|
||||
eq(versionOf(MOD_ID), NEW_VERSION,
|
||||
"list() reports the zip's version after the replace")
|
||||
check(files[SHADOW .. "/manifest.json"] == nil,
|
||||
"shadow folder is gone, so no stale copy can win first-id-wins later")
|
||||
eq(#LauncherMods.list(), 1, "the update leaves exactly one panel row")
|
||||
|
||||
-- #801: Delete from the panel must take the shadow copy with it, or the
|
||||
-- next boot resurrects the mod from the odd-named folder.
|
||||
resetFs()
|
||||
files[SHADOW .. "/manifest.json"] = ARCHIVE[MOD_ID .. "/manifest.json"]
|
||||
files["mods/" .. MOD_ID .. "/manifest.json"] = ARCHIVE[MOD_ID .. "/manifest.json"]
|
||||
ok, err = LauncherMods.uninstall(MOD_ID)
|
||||
check(ok == true, "uninstall succeeds with a shadow copy present ("
|
||||
.. tostring(err) .. ")")
|
||||
check(files[SHADOW .. "/manifest.json"] == nil,
|
||||
"uninstall removes the same-id shadow folder too")
|
||||
check(files["mods/" .. MOD_ID .. "/manifest.json"] == nil,
|
||||
"uninstall removes mods/<id>")
|
||||
eq(#LauncherMods.list(), 0, "nothing is left for the panel to show")
|
||||
|
||||
-- #834: interrupted-copy debris (mods/<id> with no manifest.json) is
|
||||
-- invisible to list() yet used to hard-block every plain re-import with
|
||||
-- "a mod named '<id>' is already installed". Both recovery paths the
|
||||
-- player can reach must work: plain re-import, and Delete.
|
||||
resetFs()
|
||||
files["mods/" .. MOD_ID .. "/gfx/a.bin"] = "x"
|
||||
eq(#LauncherMods.list(), 0, "debris tree shows no panel row")
|
||||
files["imports/mods/again.zip"] = "PK\3\4again"
|
||||
ok, err = LauncherMods.installZip("imports/mods/again.zip")
|
||||
check(ok == true, "plain re-import over debris succeeds ("
|
||||
.. tostring(err) .. ")")
|
||||
check(files["mods/" .. MOD_ID .. "/gfx/a.bin"] == nil,
|
||||
"re-import clears the debris file")
|
||||
eq(versionOf(MOD_ID), NEW_VERSION, "re-import yields a listable mod")
|
||||
|
||||
resetFs()
|
||||
files["mods/" .. MOD_ID .. "/gfx/a.bin"] = "x"
|
||||
ok, err = LauncherMods.uninstall(MOD_ID)
|
||||
check(ok == true, "uninstall clears a debris-only tree ("
|
||||
.. tostring(err) .. ")")
|
||||
check(files["mods/" .. MOD_ID .. "/gfx/a.bin"] == nil,
|
||||
"debris file is gone after uninstall")
|
||||
|
||||
-- guard rail: with a healthy install and NO debris, the duplicate gate
|
||||
-- still refuses a plain import with the same wording the panel shows
|
||||
resetFs()
|
||||
files["mods/" .. MOD_ID .. "/manifest.json"] = ARCHIVE[MOD_ID .. "/manifest.json"]
|
||||
files["imports/mods/dup.zip"] = "PK\3\4dup"
|
||||
ok, err = LauncherMods.installZip("imports/mods/dup.zip")
|
||||
check(not ok, "healthy duplicate import is still refused")
|
||||
check(tostring(err):find("already installed", 1, true),
|
||||
"refusal keeps the already installed wording")
|
||||
|
||||
-- Restore
|
||||
love.filesystem = savedFs
|
||||
SaveData.portableBaseDir = savedPortableBase
|
||||
SaveData.portableFs = savedPortableFs
|
||||
package.loaded["src.import.CacheFs"] = savedCacheFs
|
||||
package.loaded["src.mods.LauncherMods"] = savedLauncherMods
|
||||
|
||||
S.finish()
|
||||
+11
-5
@@ -700,8 +700,14 @@ do
|
||||
end
|
||||
|
||||
-- issue #133: title menu / continue overlays must not inherit LOGO2/LOGO1
|
||||
-- (blue/red UI ink). A trailing trueColor zone covers the overlay box.
|
||||
-- (blue/red UI ink). A trailing GRAYS zone covers the overlay box: through
|
||||
-- the shade-remap shader it is the identity for the box's DMG shades, so
|
||||
-- pass-through modes keep #133's white paper / black ink, while the mono
|
||||
-- and inverted display modes still recolor it with the rest of the screen
|
||||
-- (a trueColor rect skipped the shader and left a raw white hole over a
|
||||
-- CLASSIC pea-green title, #870).
|
||||
do
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local logo2 = {
|
||||
{ 255, 255, 255 }, { 230, 197, 0 }, { 148, 156, 148 }, { 41, 99, 181 },
|
||||
}
|
||||
@@ -730,8 +736,8 @@ do
|
||||
menu.titleUiBox = { 0, 0, 12, 3 }
|
||||
game.stack:push(menu)
|
||||
local withMenu = TitleState.sgbPalettes(title, game)
|
||||
check(withMenu and #withMenu == 4 and withMenu[4].colors == false,
|
||||
"title menu adds a trueColor overlay zone")
|
||||
check(withMenu and #withMenu == 4 and withMenu[4].colors == PaletteFX.GRAYS,
|
||||
"title menu adds a DMG-grays overlay zone (#870)")
|
||||
check(withMenu[4].x == 0 and withMenu[4].y == 0
|
||||
and withMenu[4].w == 13 * 8 and withMenu[4].h == 4 * 8,
|
||||
"menu overlay covers the CONTINUE/NEW GAME box")
|
||||
@@ -739,8 +745,8 @@ do
|
||||
game.stack:pop()
|
||||
game.stack:push({ titleUiBox = { 4, 7, 19, 16 } })
|
||||
local withCont = TitleState.sgbPalettes(title, game)
|
||||
check(withCont and #withCont == 4 and withCont[4].colors == false,
|
||||
"continue-info overlay adds a trueColor zone")
|
||||
check(withCont and #withCont == 4 and withCont[4].colors == PaletteFX.GRAYS,
|
||||
"continue-info overlay adds a DMG-grays zone (#870)")
|
||||
check(withCont[4].x == 4 * 8 and withCont[4].y == 7 * 8
|
||||
and withCont[4].w == 16 * 8 and withCont[4].h == 10 * 8,
|
||||
"continue overlay matches DisplayContinueGameInfo's box")
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
-- screen.render_visible through the public mod API: a mirrored native screen
|
||||
-- may leave the main render without leaving the active state stack.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Game = require("src.core.Game")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
|
||||
local FIXTURE = {
|
||||
["mods/fix_screen_mirror/manifest.json"] = [[{
|
||||
"id": "fix_screen_mirror",
|
||||
"name": "Fixture Screen Mirror",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/fix_screen_mirror/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.hooks:wrap("screen.render_visible", function(nextFn, state)
|
||||
if state.screenId == "BagMenu" then return false end
|
||||
return nextFn(state)
|
||||
end)
|
||||
]],
|
||||
}
|
||||
|
||||
local savedSetUISize, savedBegin, savedEnd, savedTouch =
|
||||
Renderer.setUISize, Renderer.beginFrame, Renderer.endFrame,
|
||||
TouchControls.draw
|
||||
local presentedZones
|
||||
Renderer.setUISize = function() end
|
||||
Renderer.beginFrame = function() end
|
||||
Renderer.endFrame = function(_, zones)
|
||||
presentedZones = zones
|
||||
return {}
|
||||
end
|
||||
TouchControls.draw = function() end
|
||||
|
||||
local function scene()
|
||||
local stack = setmetatable({}, { __index = StateStack })
|
||||
stack:init()
|
||||
local base = {
|
||||
isOpaque = true,
|
||||
draws = 0,
|
||||
draw = function(self) self.draws = self.draws + 1 end,
|
||||
sgbPalettes = function() return "base zones" end,
|
||||
}
|
||||
local menu = {
|
||||
screenId = "BagMenu",
|
||||
isOpaque = true,
|
||||
draws = 0,
|
||||
updates = 0,
|
||||
draw = function(self) self.draws = self.draws + 1 end,
|
||||
update = function(self) self.updates = self.updates + 1 end,
|
||||
sgbPalettes = function() return "menu zones" end,
|
||||
}
|
||||
stack:push(base)
|
||||
stack:push(menu)
|
||||
return { stack = stack, overworld = base, save = { options = {} } },
|
||||
base, menu
|
||||
end
|
||||
|
||||
-- no-mod parity
|
||||
do
|
||||
local run = T.sdk.loadNone({})
|
||||
local game, base, menu = scene()
|
||||
T.eq(Runtime.wantsHook("screen.render_visible"), false,
|
||||
"no subscriber leaves the render hook cold")
|
||||
Game.draw(game)
|
||||
T.eq(base.draws, 0, "the opaque menu still covers the state beneath")
|
||||
T.eq(menu.draws, 1, "the opaque menu still draws")
|
||||
T.eq(presentedZones, "menu zones", "the visible menu still owns palettes")
|
||||
run.release()
|
||||
end
|
||||
|
||||
-- subscribed path, registered by a real fixture mod
|
||||
do
|
||||
local run = T.sdk.loadMods({ "mods/fix_screen_mirror" },
|
||||
{ fs = T.sdk.memfs(FIXTURE) })
|
||||
T.eq(#run.errors, 0,
|
||||
"the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
local game, base, menu = scene()
|
||||
Game.draw(game)
|
||||
T.eq(base.draws, 1, "the state beneath the hidden menu draws")
|
||||
T.eq(menu.draws, 0, "the mirrored menu is omitted from the main draw")
|
||||
T.eq(presentedZones, "base zones",
|
||||
"a hidden state cannot own the main-screen palette")
|
||||
T.check(game.stack:top() == menu,
|
||||
"the hidden menu remains the active top state")
|
||||
game.stack:update(1 / 60)
|
||||
T.eq(menu.updates, 1, "the hidden menu keeps its update ownership")
|
||||
run.release()
|
||||
end
|
||||
|
||||
Renderer.setUISize, Renderer.beginFrame, Renderer.endFrame,
|
||||
TouchControls.draw = savedSetUISize, savedBegin, savedEnd, savedTouch
|
||||
|
||||
T.finish("screen_render_visible")
|
||||
+60
-4
@@ -183,7 +183,7 @@ do
|
||||
new = function(game, text, done) return { text = text, onDone = done } end,
|
||||
}
|
||||
|
||||
local function driveLeader(mapId, textConst, beatFlag)
|
||||
local function driveLeader(mapId, textConst, beatFlag, gotFlag)
|
||||
local pushed, engaged
|
||||
local game = {
|
||||
save = { flags = {} },
|
||||
@@ -198,6 +198,9 @@ do
|
||||
mapId .. " leader talk (no badge) engages the leader battle")
|
||||
engaged, pushed = nil, nil
|
||||
game.save.flags[beatFlag] = true
|
||||
-- the advice/farewell branch is pokered's .afterBeat, reached only
|
||||
-- once EVENT_GOT_TM* is set (the TM went into the bag)
|
||||
if gotFlag then game.save.flags[gotFlag] = true end
|
||||
local state = {}
|
||||
script(game, ow, { def = {} }, function() state.doneCalled = true end)
|
||||
check(pushed and not engaged,
|
||||
@@ -206,17 +209,17 @@ do
|
||||
end
|
||||
|
||||
local _, box = driveLeader("CERULEAN_GYM", "TEXT_CERULEANGYM_MISTY",
|
||||
"EVENT_BEAT_MISTY")
|
||||
"EVENT_BEAT_MISTY", "EVENT_GOT_TM11")
|
||||
eq(box and box.text, Data.text._CeruleanGymMistyTM11ExplanationText,
|
||||
"Misty (beaten) shows the TM11 explanation text")
|
||||
|
||||
_, box = driveLeader("CINNABAR_GYM", "TEXT_CINNABARGYM_BLAINE",
|
||||
"EVENT_BEAT_BLAINE")
|
||||
"EVENT_BEAT_BLAINE", "EVENT_GOT_TM38")
|
||||
eq(box and box.text, Data.text._CinnabarGymBlainePostBattleAdviceText,
|
||||
"Blaine (beaten) shows his post-battle advice text")
|
||||
|
||||
local game, gbox, state = driveLeader("VIRIDIAN_GYM", "TEXT_VIRIDIANGYM_GIOVANNI",
|
||||
"EVENT_BEAT_GIOVANNI")
|
||||
"EVENT_BEAT_GIOVANNI", "EVENT_GOT_TM27")
|
||||
eq(gbox and gbox.text, Data.text._ViridianGymGiovanniPostBattleAdviceText,
|
||||
"Giovanni (beaten) shows his farewell text")
|
||||
|
||||
@@ -392,6 +395,59 @@ do
|
||||
check(cinnabarNpc and ow:trainerDefeated(cinnabarNpc),
|
||||
"unfought Cinnabar trainer is defeated via seeded header event")
|
||||
|
||||
-- #797: a full bag at the victory skips the TM hand-over (pokered's
|
||||
-- `call GiveItem` / `jr nc, .BagFull`): badge and beat flag still land,
|
||||
-- but EVENT_GOT_TM34 stays unset and the "make room" line replaces the
|
||||
-- received/explanation texts. Talking to Brock afterwards re-runs the
|
||||
-- ReceiveTM script and grants the TM once there is room.
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.flags = {}
|
||||
Game.save.inventory = {}
|
||||
Game.save.defeatedTrainers = {}
|
||||
local Bag = require("src.inventory.Bag")
|
||||
for i = 1, Bag.capacity(Data) do
|
||||
Bag.add(Game.save, "FULLBAG_" .. i, 1, Data)
|
||||
end
|
||||
eq(Bag.slots(Game.save), Bag.capacity(Data), "bag is full before Brock")
|
||||
Game.stack:push(OW, "PEWTER_GYM", 4, 13, "up")
|
||||
ow = Game.stack:top()
|
||||
ow:checkVictoryRewards("OPP_BROCK", 1)
|
||||
local fullBagText = stackedDialogue()
|
||||
check(Game.save.flags.EVENT_BEAT_BROCK,
|
||||
"full bag: Brock victory still sets EVENT_BEAT_BROCK")
|
||||
check(Game.save.inventory.BOULDERBADGE == 1,
|
||||
"full bag: Brock victory still awards BOULDERBADGE")
|
||||
check(not Game.save.flags.EVENT_GOT_TM34,
|
||||
"full bag: EVENT_GOT_TM34 stays unset (bag_full branch)")
|
||||
check(Game.save.inventory.TM_BIDE == nil,
|
||||
"full bag: TM34 is not forced into the bag")
|
||||
check(fullBagText:find("room", 1, true) ~= nil,
|
||||
"full bag: victory dialogue shows Brock's make-room line")
|
||||
check(fullBagText:find("BIDE", 1, true) == nil,
|
||||
"full bag: received/explanation texts are skipped")
|
||||
|
||||
-- make room, then talk to Brock: the middle branch re-runs ReceiveTM34
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Bag.remove(Game.save, "FULLBAG_1", 1)
|
||||
local brockTalk = init.talkScript("PEWTER_GYM", "TEXT_PEWTERGYM_BROCK")
|
||||
check(brockTalk ~= nil, "Brock's talk script is registered")
|
||||
brockTalk(Game, ow, { def = {} }, function() end)
|
||||
local retryText = stackedDialogue()
|
||||
check(retryText:find("Wait!", 1, true) ~= nil,
|
||||
"retry: ReceiveTM34 lead-in (Wait! Take this!) shows again")
|
||||
check(retryText:find("BIDE", 1, true) ~= nil,
|
||||
"retry: received/explanation texts show once the TM fits")
|
||||
eq(Game.save.inventory.TM_BIDE, 1, "retry: TM34 goes into the bag")
|
||||
check(Game.save.flags.EVENT_GOT_TM34, "retry: EVENT_GOT_TM34 is set")
|
||||
|
||||
-- once the TM is handed over, Brock falls back to his advice text
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
brockTalk(Game, ow, { def = {} }, function() end)
|
||||
local adviceText = stackedDialogue()
|
||||
check(adviceText:find("CERULEAN", 1, true) ~= nil,
|
||||
"after the TM: Brock shows his post-battle advice text")
|
||||
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
end
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user