Compare commits

..

14 Commits

Author SHA1 Message Date
bryanthaboi f4a1dd9e16 Merge pull request #900 from bryanthaboi/dev
some bugs
2026-08-06 07:18:33 -04:00
bryanthaboi 6f75a64c46 deleting stale tests 2026-08-06 07:11:16 -04:00
bryanthaboi cd37aa175c Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-06 06:36:46 -04:00
bryanthaboi cb6cfb5556 CLOSES #883, CLOSES #887, CLOSES #894 2026-08-06 06:36:12 -04:00
github-actions 439e97aee6 chore(ios): update app-repo.json [skip ci] 2026-08-06 05:23:19 -04:00
bryanthaboi 5528525888 Merge pull request #892 from MyFriendPKMN/dev 2026-08-06 05:18:15 -04:00
bryanthaboi a49ba66c36 Merge pull request #871 from ratherDashing/linux-arm64-appimage 2026-08-06 05:17:55 -04:00
bryanthaboi 9de4db8531 Merge pull request #882 from bryanthaboi/dev 2026-08-06 05:17:04 -04:00
Marcelo Machado dbc48f377a feat(trainerCard): enhance player portrait handling with trueColor support 2026-08-06 00:15:07 -03:00
bryanthaboi 1d77d42fe2 Merge pull request #881 from AverageConsumer/codex/upstream-screen-render-visible
Mod API: allow active screen states to be hidden from the main render
2026-08-05 17:13:14 -04:00
github-actions 683fee8028 chore(ios): update app-repo.json [skip ci] 2026-08-05 16:49:41 -04:00
AverageConsumer b9e8b00af0 feat(mods): add screen render visibility hook 2026-08-05 22:42:37 +02:00
ratherDashing f92364a002 Build SDL2, OpenAL and the codecs from source for the arm64 AppImage
CI on a headless ubuntu-24.04-arm runner caught what a desktop Pi could not:
the AppImage only started on a machine that already had a full desktop stack
installed. Three distinct causes, all from bundling Debian's builds of
libraries that Debian builds for a co-versioned system, which is the opposite
of an AppImage's situation.

1. Hard-linked backends. Debian's libSDL2 lists libpulse, libasound, libX11
   and libwayland-client as DT_NEEDED rather than dlopening them, so the
   loader demanded all four at startup; the CI job failed with
   "libpulse.so.0 => not found". Debian's OpenAL does the same through
   libsndio, which itself hard-links libasound. Built from source with
   --enable-*-shared and ALSOFT_DLOPEN, both dlopen their backends, so the
   image now runs on a Wayland-only session, a KMSDRM handheld with no X
   server, or a box with ALSA and no PulseAudio.

2. A stray link. Debian's libtheoradec is linked against libcairo, which
   drags in X11, xcb, fontconfig and freetype for a video decoder.
   --disable-examples leaves it needing only libogg.

3. SONAME collision with the host. OpenAL dlopens ALSA, ALSA's config loads
   its PulseAudio hook plugin, and that plugin pulls the host's libsndfile
   into the process. libsndfile links libogg, libvorbis and libmpg123 -- the
   same three we bundle -- and since the loader resolves a SONAME once per
   process it bound to our bullseye copies. A bullseye libmpg123 has no
   mpg123_info2 (added in 1.32), so the plugin failed to relocate, ALSA
   config collapsed, and the game ran with no audio device at all. Building
   them current means our copies satisfy the host's libsndfile instead of
   starving it.

The general rule, now stated as an assertion instead of a comment: never
bundle a library the host's own stack may also load unless ours is at least
as new as theirs. build_appimage.sh fails if any shipped object hard-requires
anything beyond glibc, libstdc++ and the font stack, and CI re-checks it on
the extracted artifact.

Host requirements drop from "a working desktop" to glibc 2.29+, libstdc++,
libfreetype6 and zlib. Bundled libraries drop from 13 to 10: libcairo,
libpixman and libsndio are gone entirely.

Verified on a Raspberry Pi 5 (trixie, Wayland): boots, imports, plays, and
audio works -- SDL 2.30 now picks the native Wayland backend rather than
falling back to XWayland as bullseye's 2.0.14 did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:24:53 -04:00
ratherDashing 24c5114745 Ship a Linux arm64 (aarch64) AppImage
scripts/build.sh's `linux` target only ever produces x86_64: it unpacks
LOVE's official love-11.5-x86_64.AppImage and re-fuses game.love into it.
There is no aarch64 equivalent to unpack -- LOVE 11.5 publishes win32,
win64, macOS, Android, iOS and exactly one x86_64 AppImage -- so arm64
desktop Linux (Raspberry Pi 4/5, Armbian, arm64 VMs on Apple Silicon) had
no artifact at all.

Compile LOVE 11.5 from the official linux-src tarball instead, inside a
Debian bullseye arm64 container, and assemble the AppImage from scratch.
Both pinned inputs (the LOVE source tarball and the AppImage type-2
runtime, on a dated tag rather than `continuous`) are SHA-256 verified on
the host, so the container runs with no network access.

Bullseye is the compile environment, not a claim about where the artifact
runs: glibc is backward but not forward compatible, so linking against the
oldest supported glibc is the only thing that makes one artifact work
everywhere. The binaries come out needing only glibc 2.29 / GLIBCXX_3.4.21,
covering Raspberry Pi OS bullseye through trixie and Ubuntu 20.04 onward.

The dependency walker copies in LOVE's own libraries and leaves the
driver-coupled, loader-coupled and font-stack libraries to the host. That
last category is not cosmetic: Debian's libtheoradec is linked against
libcairo, so a host cairo gets loaded into the process, and because the
loader resolves one SONAME once per process it then binds to whatever
libfreetype we bundled -- bullseye's 2.10.4 has no FT_Get_Transform, which
cairo 1.18 needs, and the game died at startup with a symbol lookup error.
Excluding the whole font stack makes the process self-consistent.

CI gets three path-gated jobs: an offline selftest on ubuntu-latest (pins,
the host-arch guard, the exclude list, the AppRun fusion contract), a real
build on ubuntu-24.04-arm that asserts the layout, that every bundled
object resolves under AppRun's LD_LIBRARY_PATH, and that the glibc floor is
still <= 2.31, and a release job that reuses the shared game.love payload.
None of it needs secrets or self-hosted hardware, so it runs on fork PRs.

Verified end to end on a Raspberry Pi 5 (Debian trixie, Wayland): the
launcher boots from the AppImage and renders correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:39:48 -04:00
41 changed files with 2096 additions and 1977 deletions
+111
View File
@@ -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
+48 -1
View File
@@ -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"
+33
View File
@@ -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
+202
View File
@@ -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`.
+7
View File
@@ -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.
+5 -3
View File
@@ -625,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.
+54
View File
@@ -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.
+33 -2
View File
@@ -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
@@ -793,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
+14
View File
@@ -12,6 +12,20 @@
"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 Wont 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",
+181
View File
@@ -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")"
+45
View File
@@ -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
+427
View File
@@ -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))"
+180
View File
@@ -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
View File
@@ -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"
+6 -2
View File
@@ -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
+35 -1
View File
@@ -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.
+11 -5
View File
@@ -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
+17
View File
@@ -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
+14 -2
View File
@@ -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
+24 -21
View File
@@ -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")
@@ -2917,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")
@@ -3223,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")
+8 -2
View File
@@ -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
@@ -518,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) }
+7 -1
View File
@@ -267,7 +267,13 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
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
+20 -2
View File
@@ -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)
@@ -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,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")
+123
View File
@@ -0,0 +1,123 @@
-- Parity test: the per-class trainer switch rolls (#890).
--
-- Reports keep landing that Jugglers and Agatha "never switch". The rolls
-- are exact byte compares in pokered, so they are machine-assertable: sweep
-- every one of the 256 random bytes through TrainerAI.classAction and count
-- the switch outcomes.
--
-- JugglerAI (engine/battle/trainer_ai.asm:324-327)
-- cp 25 percent + 1 / ret nc / jp AISwitchIfEnoughMons
-- `percent` is `* $ff / 100` (macros/data.asm:3), so the threshold is
-- 25 * 255 / 100 + 1 = 64 and the switch fires on rolls 0..63.
-- AgathaAI (engine/battle/trainer_ai.asm:429-437)
-- cp 8 percent / jp c, AISwitchIfEnoughMons -> 8 * 255 / 100 = 20, so
-- rolls 0..19 switch; the SAME byte then feeds cp 50 percent + 1 = 128
-- for the SUPER POTION branch, which is why the two outcomes partition
-- the byte range instead of rolling twice.
--
-- Self-contained; run via `luajit tests/parity_ai_switch_rate.lua`.
-- Also picked up by tests/run_tests.lua's parity_* glob.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end
local Pokemon = require("src.pokemon.Pokemon")
local TrainerAI = require("src.battle.TrainerAI")
local BattleState = require("src.battle.BattleState")
local S = require("tests.harness").suite("parity ai switch rate")
local check, eq = S.check, S.eq
-- Just the fields classAction reads: the class lookup goes through
-- trainer.id, the HP fraction through enemy.mon, the reserve scan through
-- enemyParty/enemyIndex. hpFrac is current/max for the item branches.
local function stubBattle(id, roll, hpFrac)
local maxHp = 100
return {
kind = "trainer", trainer = { id = id, name = id }, data = Data,
aiUses = 3,
enemy = { mon = { hp = math.floor(maxHp * hpFrac), stats = { hp = maxHp } },
stages = {}, name = "MON" },
enemyParty = { { hp = maxHp }, { hp = maxHp }, { hp = maxHp } },
enemyIndex = 1,
rng = function() return roll end,
}
end
-- Sweep the whole byte range: the counts ARE the thresholds.
local function sweep(id, hpFrac)
local switches, items = 0, 0
for roll = 0, 255 do
local act = TrainerAI.classAction(stubBattle(id, roll, hpFrac))
if act and act.special == "aiSwitch" then switches = switches + 1
elseif act and act.special == "aiItem" then items = items + 1 end
end
return switches, items
end
do
local sw, it = sweep("OPP_JUGGLER", 1.0)
eq(sw, 64, "Juggler switches on 64 of 256 rolls (cp 25 percent + 1)")
eq(it, 0, "Juggler never reaches for an item")
local swLow = sweep("OPP_JUGGLER", 0.05)
eq(swLow, 64, "the Juggler roll does not depend on the enemy's HP")
end
do
-- above 1/4 max HP the item branch is refused, so only the switch fires
local sw, it = sweep("OPP_AGATHA", 1.0)
eq(sw, 20, "Agatha switches on 20 of 256 rolls (cp 8 percent)")
eq(it, 0, "Agatha holds the SUPER POTION above 1/4 HP")
-- below 1/4 the shared byte splits: 0..19 switch, 20..127 potion
local swLow, itLow = sweep("OPP_AGATHA", 0.1)
eq(swLow, 20, "the switch roll still wins the low rolls at low HP")
eq(itLow, 108, "the same byte leaves 20..127 for the SUPER POTION")
end
-- AISwitchIfEnoughMons (engine/battle/trainer_ai.asm:554-582) counts every
-- unfainted party mon including the active one and needs 2 or more, so a
-- one-mon roster never switches however low the roll lands.
do
local b = stubBattle("OPP_JUGGLER", 0, 1.0)
b.enemyParty = { { hp = 100 } }
check(TrainerAI.classAction(b) == nil,
"a lone enemy mon never switches (cp 2 / jp nc)")
local b2 = stubBattle("OPP_JUGGLER", 0, 1.0)
b2.enemyParty = { { hp = 100 }, { hp = 0 }, { hp = 100 } }
local act = TrainerAI.classAction(b2)
check(act and act.index == 3,
"the switch takes the first living reserve, skipping the fainted slot")
end
-- End to end through the real battle: the action a Juggler picks has to
-- reach executeAction and actually swap the active mon plus print
-- _AIBattleWithdrawText, otherwise a correct roll is invisible in play.
do
local Game = {
data = Data,
save = { party = { Pokemon.new(Data, "BULBASAUR", 50) },
player = { name = "RED" }, inventory = {},
options = { battleStyle = "set" },
pokedex = { seen = {}, owned = {} }, flags = {}, money = 0 },
stack = { push = function() end, pop = function() end, top = function() end },
}
-- Juggler party 2 is the four-mon Victory Road roster
local b = BattleState.newTrainer(Game, "OPP_JUGGLER", 2)
eq(b.aiUses, 3, "wAICount seeded from the class record on send-out")
b.rng = function(lo) return lo end -- roll 0: inside every threshold
local act = b:enemyAction()
check(act and act.special == "aiSwitch", "the enemy turn resolves to a switch")
local outgoing = b.enemy.name
b:executeAction(b.enemy, b.player, act)
eq(b.enemyIndex, 2, "the active enemy slot moved to the reserve")
check(b.enemy.name ~= outgoing, "a different mon is out")
eq(b.aiUses, 3, "EnemySendOutFirstMon reseeds wAICount (core.asm:1305-1307)")
local withdrew = false
for _, item in ipairs(b.queue) do
if item.text and item.text:find("with%-\ndrew") then withdrew = true end
end
check(withdrew, "_AIBattleWithdrawText is queued for the player to read")
end
S.finish()
+19 -10
View File
@@ -14,6 +14,15 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
-- Scoped suite, not the module-level counters: run_tests.lua dofiles this
-- file in its own process, and T.finish ends in os.exit, which took the
-- parent runner down with it. The run still exited 0, so it read as a pass
-- while every alphabetically later parity file and the three tiers chained
-- after them silently never ran. S.finish raises instead, which is what the
-- rest of the parity files do. modkit does not re-export suite, so it comes
-- off the shared harness it wraps.
local S = T.harness.suite("parity faint cry bug709")
local Data = T.fixtures.fresh()
local Font = require("src.render.Font")
Font.load(Data)
@@ -70,10 +79,10 @@ do
battle.playVictoryMusic = function() end
battle:onFaint(battle.player)
pump(battle, 1)
T.eq(cries[1], "FIXMON_A", "the player mon's faint plays its species cry")
T.eq(#cries, 1, "no other cry on the player faint")
S.eq(cries[1], "FIXMON_A", "the player mon's faint plays its species cry")
S.eq(#cries, 1, "no other cry on the player faint")
for _, name in ipairs(sfx) do
T.check(name ~= "Faint_Fall",
S.check(name ~= "Faint_Fall",
"the player faint never plays Faint_Fall (#709)")
end
end
@@ -87,18 +96,18 @@ do
battle.playVictoryMusic = function() end
battle:onFaint(battle.enemy)
pump(battle, 2)
T.eq(#cries, 0, "the enemy faint plays no species cry")
S.eq(#cries, 0, "the enemy faint plays no species cry")
local fall, thud = false, false
for i, name in ipairs(sfx) do
if name == "Faint_Fall" then
T.check(not fall, "Faint_Fall plays once")
S.check(not fall, "Faint_Fall plays once")
fall = true
T.check(not thud, "Faint_Fall precedes Faint_Thud")
S.check(not thud, "Faint_Fall precedes Faint_Thud")
elseif name == "Faint_Thud" then
thud = true
end
end
T.check(fall and thud, "trainer enemy faint plays Faint_Fall and Faint_Thud")
S.check(fall and thud, "trainer enemy faint plays Faint_Fall and Faint_Thud")
end
-- enemy faint, wild battle: no faint sfx at all (victory music only)
@@ -110,11 +119,11 @@ do
battle.playVictoryMusic = function() end
battle:onFaint(battle.enemy)
pump(battle)
T.eq(#cries, 0, "the wild enemy faint plays no species cry")
S.eq(#cries, 0, "the wild enemy faint plays no species cry")
for _, name in ipairs(sfx) do
T.check(name ~= "Faint_Fall" and name ~= "Faint_Thud",
S.check(name ~= "Faint_Fall" and name ~= "Faint_Thud",
"the wild enemy faint plays no faint sfx (.wild_win)")
end
end
T.finish("parity faint cry bug709")
S.finish()
-91
View File
@@ -1,91 +0,0 @@
-- Parity: the Fly overworld animation (#702).
--
-- Oracle: engine/overworld/player_animations.asm. Departure
-- (_LeaveMapAnim .flyAnimation) flaps the bird in place for 8 x Delay3,
-- plays SFX_FLY, flies FlyAnimationScreenCoords1 up and off to the right
-- (12 pairs, 3 frames each), waits 40 frames, then exits over the
-- top-left along FlyAnimationScreenCoords2 (11 pairs). Arrival
-- (EnterMapAnim .flyAnimation) plays SFX_FLY again and swoops in along
-- FlyAnimationEnterScreenCoords (12 pairs), and only then does
-- LoadPlayerSpriteGraphics bring the player back.
--
-- Self-contained: `luajit tests/parity_fly_anim.lua`; also globbed by
-- tests/run_tests.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity fly anim (#702)")
local check, eq = S.check, S.eq
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local OW = require("src.world.OverworldController")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
-- record SFX without touching the audio backend
local plays = {}
local Sound = require("src.core.Sound")
local realPlay = Sound.play
Sound.play = function(_, key) plays[#plays + 1] = key end
local function popAll() while Game.stack:top() do Game.stack:pop() end end
local function frame()
Input.pressed = {}
StateStack:update(1 / 60)
end
local function frames(n) for _ = 1, n do frame() end end
Game.stack:push(OW, "ROUTE_17", 4, 10, "down")
local ow = Game.stack:top()
ow:flyTo("PALLET_TOWN")
check(ow.flyAnim ~= nil, "the bird lead-in starts on FLY")
eq(ow.flyAnim and ow.flyAnim.phase, "flap", "the bird flaps in place first")
eq(ow.player.inputLocked, true, "input is locked for the flight")
eq(#plays, 0, "no SFX during the in-place flap")
frames(23)
eq(ow.flyAnim and ow.flyAnim.phase, "flap", "still flapping 23 frames in")
frame()
eq(ow.flyAnim and ow.flyAnim.phase, "path1",
"the up-right path starts after 8 x Delay3")
eq(plays[#plays], "Fly", "SFX_FLY plays as the bird takes off")
frames(36)
eq(ow.flyAnim and ow.flyAnim.phase, "hold",
"the bird parks off screen after the 12-pair path")
frames(40)
eq(ow.flyAnim and ow.flyAnim.phase, "path2",
"the top-left exit follows the 40-frame beat")
frames(33)
check(ow.flyAnim == nil, "the departure ends after the 11-pair exit")
-- the warp transition runs its fade out/in; the map switches inside it
local guard = 0
while ow.map.id == "ROUTE_17" and guard < 400 do
guard = guard + 1
frame()
end
eq(ow.map.id, "PALLET_TOWN", "the warp lands in Pallet Town")
check(ow.flyArrive ~= nil, "the landing swoop starts on arrival")
eq(plays[#plays], "Fly", "SFX_FLY plays again for the landing")
eq(ow.player.inputLocked, true, "input stays locked for the swoop")
frames(35)
check(ow.flyArrive ~= nil, "the swoop is still flying 35 frames in")
frame()
check(ow.flyArrive == nil, "the swoop ends after the 12-pair path")
eq(ow.player.inputLocked, false, "and hands input back")
Sound.play = realPlay
S.finish()
-185
View File
@@ -1,185 +0,0 @@
-- Parity test, gift atomicity: a mon handed over by give_pokemon and the
-- event that closes its offer must land in the same script step, so a
-- script torn down between the two cannot hand the gift out twice (#426).
--
-- asm sources:
-- pokeyellow scripts/Route24.asm (Route24CooltrainerM4Text: CheckEvent
-- EVENT_54F -> YesNoChoice -> GivePokemon -> `jp nc, TextScriptEnd`
-- (party + box full leaves the event clear so the offer repeats) ->
-- PrintText Route24Text_515e3 -> SetEvent EVENT_54F)
-- pokeyellow scripts/CeruleanMelaniesHouse.asm (same shape plus predef
-- HideObject TOGGLE_CERULEAN_BULBASAUR, then SetEvent
-- EVENT_GOT_BULBASAUR_IN_CERULEAN)
-- pokeyellow scripts/VermilionCity_2.asm (CheckEvent / SetEvent
-- EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY)
-- scripts/CeladonMansionRoofHouse.asm (Eevee ball: GivePokemon with no
-- confirm, HideObject on success)
-- On hardware the event write trails the received text because no step in
-- between can abort. The port yields there (AskName, NamingScreen, the
-- text box) and wraps every row in the script.command mod hook, so the
-- write is hoisted ahead of the text: the event is only read at script
-- entry, and the failed-give path still leaves it clear.
--
-- Self-contained: run via `luajit tests/parity_gift_atomicity.lua`; also
-- dofile'd by tests/run_tests.lua's aggregator.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity gift atomicity")
local check, eq = S.check, S.eq
local Commands = require("src.script.Commands")
local Events = require("src.mods.Events")
local Flags = require("src.script.Flags")
local Game = require("src.core.Game")
local Hooks = require("src.mods.Hooks")
local Input = require("src.core.Input")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner")
local StateStack = require("src.core.StateStack")
Game.data = Data
Game.input = Input; Input:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
require("src.render.Font").load(Data)
local gifts = require("data.scripts.yellow_gifts")
local eevee = require("data.scripts.celadon_eevee")
-- === 1) row-order audit: on every gift site the carry guard follows
-- give_pokemon immediately and the bookkeeping (event, and the
-- HideObject that clears a ball or a pen mon) comes before any
-- received text ===
local function audit(label, rows)
local give
for i, row in ipairs(rows) do
if row[1] == "give_pokemon" then give = i break end
end
if not give then
check(false, label .. ": has a give_pokemon row")
return
end
eq(rows[give + 1] and rows[give + 1][1], "jump_if_false",
label .. ": carry guard sits right after give_pokemon")
local flag, text, hide
for i = give + 2, #rows do
local name = rows[i][1]
if name == "set_flag" and not flag then flag = i end
if name == "hide_object" and not hide then hide = i end
if (name == "show_text" or name == "ask") and not text then text = i end
if name == "jump" and rows[i][2] ~= nil and text then break end
end
eq(flag, give + 2, label .. ": event write is the first row past the guard")
check(text and flag < text,
label .. ": event write precedes the received text")
if hide then
check(hide < text, label .. ": HideObject precedes the received text")
end
end
-- the two function-form scripts build their rows per talk; run them with
-- the gift branch's preconditions and keep what they hand the runner
local function capture(fn, save)
local rows
local ow = { runner = { run = function(_, r) rows = r end } }
fn({ save = save }, ow, { def = {}, facePlayer = function() end },
function() end)
return rows or {}
end
audit("Route 24 Damian",
gifts.ROUTE_24.talk.TEXT_ROUTE24_COOLTRAINER_M4)
audit("Melanie's BULBASAUR",
capture(gifts.CERULEAN_MELANIES_HOUSE.talk
.TEXT_CERULEANMELANIESHOUSE_MELANIE,
{ flags = {}, pikachuHappiness = 200 }))
audit("Officer Jenny's SQUIRTLE",
capture(gifts.VERMILION_CITY.talk.TEXT_VERMILIONCITY_OFFICER_JENNY,
{ flags = {}, inventory = { THUNDERBADGE = 1 } }))
audit("Celadon EEVEE ball",
eevee.talk.TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL)
-- === harness: run a row list headless, A-mashing through the yes/no,
-- the nickname prompt and every text box, recording show_text ids
-- (Yellow's gift text is not in a Red cache, so show_text takes
-- its literal-id fallback: the ids are still what we assert on) ===
local shown = {}
local origShow = Commands.show_text
Commands.show_text = function(ctx, textId, subs)
shown[#shown + 1] = textId
return origShow(ctx, textId, subs)
end
local function runRows(rows)
shown = {}
StateStack:init()
local ow = { map = { id = "ROUTE_24", def = { label = "ROUTE_24" } },
npcs = {}, entities = {} }
local r = ScriptRunner.new(Game, ow)
r:run(rows, { npc = { def = {}, facePlayer = function() end },
overworld = ow })
local guard = 0
while r:isRunning() and guard < 3000 do
guard = guard + 1
Input.pressed = { a = true }
StateStack:update(1 / 60)
r:update()
end
Input.pressed = {}
return not r:isRunning()
end
local DAMIAN = gifts.ROUTE_24.talk.TEXT_ROUTE24_COOLTRAINER_M4
-- === 2) plain accept: one CHARMANDER, EVENT_54F set, and the next talk
-- is Damian's after-text only ===
Game.save = SaveData.newGame()
check(runRows(DAMIAN), "Damian gift script completes")
eq(#Game.save.party, 1, "CHARMANDER joins the party")
eq(Game.save.party[1].species, "CHARMANDER", "gift species is CHARMANDER")
check(Flags.get(Game.save, "EVENT_54F"), "accepting sets EVENT_54F")
check(runRows(DAMIAN), "post-gift talk completes")
eq(table.concat(shown, ","), "_Route24DamianText4",
"a closed offer shows only the after-text")
eq(#Game.save.party, 1, "no second CHARMANDER")
-- === 3) the regression itself: every row runs inside the script.command
-- hook, and a mod that mishandles the row after the give (the
-- reporter was running a third-party UI mod) tears the coroutine
-- down mid-gift -- here by sending the pc at a label that is not
-- there. The mon is already in the party, so EVENT_54F has to be
-- set by then or the next talk re-runs the whole offer ===
local savedEvents, savedHooks, savedErrors =
Runtime.events, Runtime.hooks, Runtime.errors
local hooks = Hooks.new()
Runtime.install(Events.new(), hooks, {})
local remove = hooks:wrap("script.command", function(nextFn, _, name, args)
if name == "show_text" and args[1] == "_Route24DamianText2" then
return "no_such_label"
end
return nextFn()
end, 0, "t")
Game.save = SaveData.newGame()
local origError = Logger.error -- the tear-down logs; the test expects it
Logger.error = function() end
runRows(DAMIAN)
Logger.error = origError
eq(#Game.save.party, 1, "the killed script still handed the CHARMANDER over")
check(Flags.get(Game.save, "EVENT_54F"),
"EVENT_54F survives a tear-down after the give")
remove()
Runtime.install(savedEvents, savedHooks, savedErrors)
check(runRows(DAMIAN), "talk after the tear-down completes")
eq(table.concat(shown, ","), "_Route24DamianText4",
"the interrupted gift is not offered again")
eq(#Game.save.party, 1, "still exactly one CHARMANDER")
S.finish()
-188
View File
@@ -1,188 +0,0 @@
-- Parity test: a ball thrown at the POKEMON_TOWER_6F RESTLESS SOUL is
-- always dodged, scope or no scope.
--
-- ItemUseBall reaches the $10 "can't be caught" anim data by TWO
-- independent routes (engine/items/item_effects.asm):
--
-- :149-153 callfar IsGhostBattle / ld b, $10 / jp z, .setAnimData
-- :166-175 .notOldManBattle -- wCurMap == POKEMON_TOWER_6F and
-- wEnemyMonSpecies2 == RESTLESS_SOUL -> the same $10
--
-- The port only had the first, as the scope-less disguise flag
-- self.ghost. Once the SILPH_SCOPE revealed the MAROWAK the battle was
-- an ordinary wild one, so throwBall ran the capture roll and a MASTER
-- BALL caught it outright. That result is "caught", not "win" or the
-- POKE DOLL escape, so PokemonTower6F's script never set
-- EVENT_BEAT_GHOST_MAROWAK and the (10,16) trigger re-fired forever
-- (#444). The map+species half sits BEFORE .loop, hence before the
-- MASTER_BALL shortcut, so even a Master Ball is dodged.
--
-- Run-away parity is the other side of this: only IsGhostBattle grants
-- the free escape (engine/battle/core.asm TryRunningFromBattle), so a
-- revealed MAROWAK keeps normal flee rolls and self.ghost stays the sole
-- gate there.
--
-- Self-contained; run via `luajit tests/parity_marowak_ball.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity marowak ball")
local check, eq = S.check, S.eq
local BattleState = require("src.battle.BattleState")
-- ---- 1. the 6F script arms noCatch with and without the scope -----------
do
local realTextBox = package.loaded["src.render.TextBox"]
local realBattleState = package.loaded["src.battle.BattleState"]
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, done = done } end,
}
local made = {}
package.loaded["src.battle.BattleState"] = {
newWild = function(_, species, level)
local b = { species = species, level = level, ghost = false }
b.makeGhost = function(self) self.ghost = true end
-- the scope's branch (#492): disguised on entry, but IsGhostBattle
-- false, which is exactly the state the dodge below has to survive
b.makeUnveiledGhost = function(self) self.scopeReveal = true end
made[#made + 1] = b
return b
end,
}
local tower = dofile("data/scripts/story3.lua").POKEMON_TOWER_6F
local function trigger(inventory)
local pushed = {}
local game = {
save = { inventory = inventory, flags = {} },
data = { text = {} },
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
}
local ow = {
player = {},
scriptMove = function() end,
afterBattle = function() end,
}
check(tower.onStep(game, ow, 10, 16), "the trigger fires on (10,16)")
pushed[1].done()
return made[#made]
end
local noScope = trigger({})
check(noScope.ghost, "without the scope the battle is still disguised")
check(noScope.noCatch, "and noCatch is set")
local withScope = trigger({ SILPH_SCOPE = 1 })
check(not withScope.ghost, "with the scope IsGhostBattle is false")
check(withScope.scopeReveal, "and the unveil plays instead (#492)")
check(withScope.noCatch,
"but noCatch survives it -- balls are dodged either way")
package.loaded["src.render.TextBox"] = realTextBox
package.loaded["src.battle.BattleState"] = realBattleState
end
-- ---- 2. throwBall takes the dodge branch on noCatch alone ---------------
local realSound = package.loaded["src.core.Sound"]
package.loaded["src.core.Sound"] = { play = function() end }
-- A real BattleState minus the pieces the decision does not touch: the
-- capture roll and the ball chain record that they were reached, which is
-- exactly the bug (a MASTER BALL catching the revealed MAROWAK).
local function throw(flags, ball)
local self = setmetatable({
kind = "wild",
ghost = flags.ghost or false,
noCatch = flags.noCatch or false,
queue = {},
rolled = false,
chained = false,
enemyMoved = false,
turnEnded = false,
data = { items = { MASTER_BALL = { name = "MASTER BALL" },
POKE_BALL = { name = "POKé BALL" } },
text = {} },
game = { save = { player = { name = "RED" } } },
player = {},
enemy = {},
}, BattleState)
self.ballDef = function() return nil end
self.catchAttempt = function(s) s.rolled = true return false, 3 end
self.ballChain = function(s) s.chained = true end
self.enemyAction = function() return {} end
self.executeAction = function(s) s.enemyMoved = true end
self.endOfTurn = function(s) s.turnEnded = true end
self:throwBall(ball)
-- the whole outcome lives in the act() closure throwBall queues, and
-- that closure queues more rows, so drain like updateQueue does: run
-- each fn row once, with nextInsert pointing at it.
local ran = {}
local more = true
while more do
more = false
for i, row in ipairs(self.queue) do
if row.fn and not ran[row] then
ran[row] = true
self.nextInsert = i
row.fn()
more = true
break
end
end
end
local texts = {}
for _, row in ipairs(self.queue) do
if row.text then texts[#texts + 1] = tostring(row.text) end
end
self.texts = table.concat(texts, "|")
return self
end
local function assertDodge(b, label)
check(not b.rolled, label .. ": no capture roll")
check(not b.chained, label .. ": no wobble chain")
check(b.texts:find("It dodged the", 1, true) ~= nil,
label .. ": ItemUseBallText00 line 1")
check(b.texts:find("can't be caught", 1, true) ~= nil,
label .. ": ItemUseBallText00 line 2")
check(b.enemyMoved, label .. ": the turn is spent, the foe moves")
check(b.turnEnded, label .. ": and the turn ends")
end
assertDodge(throw({ ghost = true }, "POKE_BALL"), "IsGhostBattle exit")
assertDodge(throw({ noCatch = true }, "POKE_BALL"), ".notOldManBattle exit")
-- the regression itself: revealed by the scope, so ghost is false
assertDodge(throw({ noCatch = true }, "MASTER_BALL"), "MASTER BALL")
do
local plain = throw({}, "MASTER_BALL")
check(plain.rolled,
"an ordinary wild mon still rolls -- the guard is not global")
end
-- The dodged toss keeps the arc the thrown ball picked (TossBallAnimation
-- reads wCurItem), so the Master Ball flicker is not lost.
do
local b = throw({ noCatch = true }, "MASTER_BALL")
local anim
for _, row in ipairs(b.queue) do
if row.anim then anim = row.anim break end
end
eq("ULTRATOSS_ANIM", anim, "a dodged MASTER BALL still tosses as ULTRATOSS")
end
package.loaded["src.core.Sound"] = realSound
-- ---- 3. noCatch grants no free escape ----------------------------------
do
local function roll(flags)
local b = { ghost = flags.ghost or false, noCatch = flags.noCatch or false,
runAttempts = 1, rng = function() return 255 end }
return BattleState.runRollVanilla(b, 10, 100)
end
check(roll({ ghost = true }), "IsGhostBattle still always escapes")
check(not roll({ noCatch = true }),
"a revealed MAROWAK takes the normal flee roll")
end
S.finish()
-154
View File
@@ -1,154 +0,0 @@
-- Parity test: A/START are never handled mid-step (#286).
-- Self-contained: run via `luajit tests/parity_midstep_buttons.lua`; also
-- dofile'd by tests/run_tests.lua's aggregator.
--
-- Oracle: home/overworld.asm OverworldLoop reads wWalkCounter and, when it
-- is nonzero ("the player sprite has not yet completed the walking
-- animation"), jumps straight to .moveAhead -- JoypadOverworld, and with
-- it the START check, the A check, and every direction initiation, only
-- ever runs while the player stands on a tile.
--
-- The port ran handleInput() every frame regardless of player.moving, so a
-- mid-step A/START press pushed its TextBox/StartMenu right there and
-- froze Red between tiles, mid-animation (#286: running up to Nurse Joy
-- and mashing A stops him half off the tile).
--
-- Second oracle, engine/joypad.asm _Joypad: hJoyPressed is
-- (hJoyLast ^ hJoyInput) & hJoyInput, and hJoyLast only advances on an
-- explicit `call Joypad`. vblank's per-frame ReadJoypad writes hJoyInput
-- alone, and the mid-step path never calls Joypad, so hJoyLast is FROZEN
-- for the whole animation. A button pressed mid-step and still held when
-- the step lands therefore reads as a fresh press at the next poll; one
-- released before the step lands is genuinely lost. The port used to drop
-- both, which on the Cycling Road roll made START a coin flip (#525).
--
-- The invariant: while a step is in progress, A and START change nothing
-- (no TextBox, no StartMenu, the step completes). On the landing frame a
-- still-held A or START is acted on, a released one is not.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity midstep buttons")
local check, eq = S.check, S.eq
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local OW = require("src.world.OverworldController")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack
StateStack:init()
-- PALLET_TOWN (6,9) facing down: open grass, several free tiles south
Game.save = SaveData.newGame()
Game.stack:push(OW, "PALLET_TOWN", 6, 9, "down")
local ow = Game.stack:top()
local function step(pressedBtn)
-- the real driver: Game:step promotes pressQueue edges via Input:step()
-- (which also expires them) before stack:update
if pressedBtn then table.insert(Input.pressQueue, pressedBtn) end
Input:step()
ow:update(1 / 60)
end
-- A synthetic pressQueue inject has no source entry, so Input:step sets
-- state[btn] = true and nothing ever clears it (src/core/Input.lua) -- the
-- harness models a HELD button. Most cases below want a tap, so release it
-- explicitly; the held cases are called out where they matter.
local function tap(btn)
step(btn)
Input.state[btn] = false
end
-- start a step south (held direction, like hJoyHeld)
Input.state.down = true
step()
Input.state.down = false
check(ow.player.moving, "held direction starts a step")
local startY = ow.player.cellY
-- spy on interact(): a mid-step A press must not even reach it
local interactCalls = 0
local baseInteract = ow.interact
ow.interact = function(self, ...)
interactCalls = interactCalls + 1
return baseInteract(self, ...)
end
-- mid-step A press: nothing may happen (the original acts on nothing here)
tap("a")
eq(interactCalls, 0, "mid-step A never reaches interact()")
check(Game.stack:top() == ow, "mid-step A pushes no TextBox")
check(ow.player.moving, "mid-step A does not interrupt the step")
-- mid-step START press: no start menu either
tap("start")
check(Game.stack:top() == ow, "mid-step START opens no menu")
check(ow.player.moving, "mid-step START does not interrupt the step")
-- run the step out: the player lands on the next tile, unfrozen
local guard = 0
while ow.player.moving and guard < 60 do step(); guard = guard + 1 end
eq(ow.player.cellY, startY + 1, "the step completes onto the next tile")
-- the issue's actual repro ("press A quickly/early" running up to Nurse
-- Joy): start another step and press A on its FINAL mid-step frame, then
-- RELEASE it before the step lands. hJoyLast is frozen through the
-- animation, so the next poll sees the button already up and computes no
-- edge (engine/joypad.asm) -- this press really is lost.
Input.state.down = true
step()
Input.state.down = false
check(ow.player.moving, "second step starts")
guard = 0
while ow.player.moving and guard < 60 do
guard = guard + 1
if guard == (ow.player.stepFramesCur or 16) - 1 then
tap("a") -- the last frame before landing, released immediately
else
step()
end
end
check(not ow.player.moving, "the second step completes")
step() -- the landing frame, where a still-held button would be polled
eq(interactCalls, 0, "a mid-step A released before landing is still lost")
check(Game.stack:top() == ow, "the released last-frame A pushes no TextBox")
-- ...but a mid-step A that is STILL HELD when the step lands is delivered
-- on the landing frame, because hJoyLast never advanced (#525). Nothing
-- happens mid-step either way: the poll is deferred, not the action.
Input.state.down = true
step()
Input.state.down = false
check(ow.player.moving, "third step starts")
step("a") -- pressed mid-step and left held
eq(interactCalls, 0, "the held A still does nothing mid-step")
check(ow.player.moving, "the held A does not interrupt the step")
guard = 0
while ow.player.moving and guard < 60 do step(); guard = guard + 1 end
eq(interactCalls, 0, "still nothing while the step runs out")
step() -- landing frame
eq(interactCalls, 1, "a held mid-step A is polled on the landing frame")
Input.state.a = false
-- standing on the tile again, START and A work as always
interactCalls = 0
tap("start")
check(Game.stack:top() ~= ow, "START opens the start menu on a tile")
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "PALLET_TOWN", 6, 9, "down")
ow = Game.stack:top()
interactCalls = 0 -- OW is a singleton: the spy survives the re-push
step("a")
eq(interactCalls, 1, "A on a tile runs interact() (the gate is movement-only)")
S.finish()
-129
View File
@@ -1,129 +0,0 @@
-- Parity / regression for #73: Gen 1 fight-menu SELECT reorders moves.
--
-- Select marks a slot, move the cursor, Select (or A) swaps. Defaults:
-- Tab / either Shift / gamepad Back. Self-contained; also picked up by
-- tests/run_tests.lua's parity_* glob.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local Input = require("src.core.Input")
local S = require("tests.harness").suite("parity move swap")
local check, eq = S.check, S.eq
local function freshGame()
local mon = Pokemon.new(Data, "NIDORAN_M", 8)
mon.moves = {
{ id = "TACKLE", pp = 35 },
{ id = "LEER", pp = 30 },
{ id = "HORN_ATTACK", pp = 25 },
{ id = "POISON_STING", pp = 35 },
}
return {
data = Data,
input = Input,
save = {
party = { mon },
player = { name = "RED" },
inventory = {},
options = {},
pokedex = { seen = {}, owned = {} },
flags = {},
money = 0,
},
stack = { push = function() end, pop = function() end, top = function() end },
}
end
local function tapKey(battle, key)
Input:keypressed(key)
Input:step()
battle:update(0)
Input:keyreleased(key)
end
local function tapPad(battle, button)
Input:gamepadpressed(nil, button)
Input:step()
battle:update(0)
Input:gamepadreleased(nil, button)
end
-- Default Select sources all edge the logical select button.
do
Input:init()
for _, key in ipairs({ "tab", "rshift", "lshift" }) do
Input:reset()
Input:keypressed(key)
Input:step()
check(Input:wasPressed("select"), key .. " maps to select")
end
Input:reset()
Input:gamepadpressed(nil, "back")
Input:step()
check(Input:wasPressed("select"), "gamepad back maps to select")
end
-- Fight menu: Select, move, Select swaps slots 1 and 2.
do
Input:init()
local game = freshGame()
local battle = BattleState.newWild(game, "PIDGEY", 5)
battle.phase = "moveSelect"
battle.moveIndex = 1
battle.moveSwapIndex = nil
local a = battle.player.curMoves[1].id
local b = battle.player.curMoves[2].id
tapKey(battle, "tab")
eq(battle.moveSwapIndex, 1, "first Select marks the current slot")
tapKey(battle, "down")
eq(battle.moveIndex, 2, "cursor moved to slot 2")
tapKey(battle, "tab")
check(battle.moveSwapIndex == nil, "second Select clears the mark")
eq(battle.player.curMoves[1].id, b, "slot 1 holds the former slot 2 move")
eq(battle.player.curMoves[2].id, a, "slot 2 holds the former slot 1 move")
eq(battle.player.mon.moves[1].id, b, "party moves table stays in sync")
end
-- Same reorder via gamepad Back (SDL "back" = controller Select/View).
do
Input:init()
local game = freshGame()
local battle = BattleState.newWild(game, "PIDGEY", 5)
battle.phase = "moveSelect"
battle.moveIndex = 1
battle.moveSwapIndex = nil
local a = battle.player.curMoves[1].id
local b = battle.player.curMoves[2].id
tapPad(battle, "back")
tapPad(battle, "dpdown")
tapPad(battle, "back")
eq(battle.player.curMoves[1].id, b, "pad Select swaps slot 1")
eq(battle.player.curMoves[2].id, a, "pad Select swaps slot 2")
end
-- A confirms a pending swap (bag-style), without starting the turn.
do
Input:init()
local game = freshGame()
local battle = BattleState.newWild(game, "PIDGEY", 5)
battle.phase = "moveSelect"
battle.moveIndex = 1
battle.moveSwapIndex = nil
local a = battle.player.curMoves[1].id
local b = battle.player.curMoves[2].id
tapKey(battle, "tab")
tapKey(battle, "down")
tapKey(battle, "z") -- A
eq(battle.phase, "moveSelect", "A completes a pending swap without attacking")
eq(battle.player.curMoves[1].id, b, "A-confirm swapped slot 1")
eq(battle.player.curMoves[2].id, a, "A-confirm swapped slot 2")
end
S.finish()
+24 -4
View File
@@ -14,8 +14,29 @@ local check, eq = S.check, S.eq
local RomImporter = require("src.import.RomImporter")
-- ---------------------------------------------------------------- the funnel
-- The release lives in commandOutput because all three pickers reach popen
-- through it; a fourth picker calling io.popen directly would bring #254 back.
-- The release lives in HostShell.popen because every host spawn reaches the
-- OS through it; a caller reaching for io.popen directly would bring #254
-- back. This assertion used to count io.popen calls in RomImporter, which is
-- where the release started out, and it went red the day the call was hoisted
-- into HostShell and nobody moved the check with it: RomImporter has held
-- zero io.popen calls since, so the count could never be the 1 it wanted.
-- Point it at the funnel that actually exists now.
-- Matched as pcall(io.popen rather than io.popen( because the spawn is
-- wrapped to swallow lua errors, so the call form never appears bare.
do
local f = io.open("src/core/HostShell.lua", "rb")
check(f ~= nil, "HostShell source is readable")
if f then
local src = f:read("*a")
f:close()
local calls = 0
for _ in src:gmatch("pcall%(io%.popen") do calls = calls + 1 end
eq(calls, 1, "every host spawn still funnels through the one io.popen"
.. " call, which is where the pointer grab is released (#254)")
end
end
-- RomImporter must not grow a picker that goes around HostShell.
do
local f = io.open("src/import/RomImporter.lua", "rb")
check(f ~= nil, "RomImporter source is readable")
@@ -24,8 +45,7 @@ do
f:close()
local calls = 0
for _ in src:gmatch("io%.popen%(") do calls = calls + 1 end
eq(calls, 1, "every desktop picker still funnels through the one io.popen"
.. " call, which is where the pointer grab is released (#254)")
eq(calls, 0, "no picker calls io.popen behind HostShell's back (#254)")
end
end
-206
View File
@@ -1,206 +0,0 @@
-- Parity test: the SHIFT free switch hands the WHOLE exp share to the mon
-- coming in (#275). EnemySendOutFirstMon zeroes wPartyGainExpFlags and
-- wPartyFoughtCurrentEnemyFlags before jumping to SwitchPlayerMon, which sets
-- only the incoming mon's bit (engine/battle/core.asm:1436-1443, 2424-2433);
-- GiveExperiencePoints divides by the set bits (experience.asm:295-300), so a
-- leftover flag halves the payout. The reset was never ported.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local Experience = require("src.battle.Experience")
local Screens = require("src.ui.Screens")
local S = require("tests.harness").suite("parity shift exp share")
local check, eq = S.check, S.eq
-- Minimal game stub: what BattleState.newTrainer / enemyMonFainted touch.
-- battleStyle is per-scenario, so the caller sets it.
local function freshGame(style)
return {
data = Data,
save = {
party = {
Pokemon.new(Data, "BULBASAUR", 50),
Pokemon.new(Data, "SQUIRTLE", 40),
},
player = { name = "RED" },
inventory = {},
options = { battleStyle = style },
pokedex = { seen = {}, owned = {} },
flags = {},
money = 0,
},
stack = { push = function() end, pop = function() end, top = function() end },
}
end
-- Drain the queue, running act rows and answering the SHIFT prompt. `yes`
-- picks YES (the free switch) or NO; `pick` is the party mon the battle
-- PartyMenu would hand back. Text rows are collected in order so the exp
-- line can be read the way the player reads it.
local function pump(b, yes, pick, seen)
local origPush = Screens.push
Screens.push = function(_, id, opts)
if id == "PartyMenu" and opts and opts.onSwitch and pick then
opts.onSwitch(pick)
end
end
local ok, err = pcall(function()
local n = 0
while #b.queue > 0 and n < 500 do
n = n + 1
local item = table.remove(b.queue, 1)
if item.fn then
b.nextInsert = 0
item.fn()
elseif item.text then
seen[#seen + 1] = item.text
if item.choice and item.text:find("change POKéMON", 1, true) then
item.choice(yes)
end
end
end
end)
Screens.push = origPush
return ok, err
end
-- the number _ExpPointsText prints (wExpAmountGained), out of the port's
-- "%s gained\n%d EXP. Points!" row
local function expLine(seen)
for _, t in ipairs(seen) do
local n = t:match("gained\n(%d+) EXP%. Points!")
if n then return tonumber(n), t end
end
return nil
end
-- OPP_YOUNGSTER 1 is RATTATA 11 / EKANS 11 in both versions: two slots, so
-- there is a second mon to KO after the switch.
local YOUNGSTER, ROSTER = "OPP_YOUNGSTER", 1
-- Set up the fight at the moment the first enemy mon drops, with the lead the
-- only participant (as markParticipant left it), so the caller only has to pump.
local function atFirstKO(style)
local Game = freshGame(style)
local b = BattleState.newTrainer(Game, YOUNGSTER, ROSTER)
b.enemyParty[1].hp = 0
b.enemyIndex = 1
b.enemy.mon = b.enemyParty[1]
b.participants = { [Game.save.party[1]] = true }
b:enemyMonFainted()
return Game, b
end
-- KO whatever is out now and read back the exp line for it.
local function koAndRead(b)
local before = {}
for i, mon in ipairs(b.game.save.party) do before[i] = mon.exp end
local seen = {}
b.enemy.mon.hp = 0
-- updateQueue zeroes this before every act row it runs; calling
-- enemyMonFainted straight from the test has to do the same, or the *Next
-- inserters index past the end of the drained queue and leave a hole
b.nextInsert = 0
b:enemyMonFainted()
local ok, err = pump(b, false, nil, seen)
local delta = {}
for i, mon in ipairs(b.game.save.party) do delta[i] = mon.exp - before[i] end
return ok, err, seen, delta
end
do
local Game, b = atFirstKO("shift")
eq(#b.enemyParty, 2, "OPP_YOUNGSTER roster " .. ROSTER .. " has two mons")
local lead, reserve = Game.save.party[1], Game.save.party[2]
-- KO one: the SHIFT prompt, answered YES with the reserve picked.
local seen = {}
local ok, err = pump(b, true, reserve, seen)
check(ok, "the SHIFT switch pumped without error: " .. tostring(err))
check(b.player.mon == reserve, "the free switch put the reserve on the field")
check(b.enemy.mon.hp > 0, "the foe's second mon is out")
-- The participant set is the mechanism; the exp number below is the symptom.
check(b.participants[reserve] == true, "the switch-in is a participant")
check(b.participants[lead] == nil,
"the mon that was out when the foe fainted is no longer one (#275)")
-- KO two: the reserve fights alone, so it must be paid as a single
-- participant.
local ok2, err2, seen2, delta = koAndRead(b)
check(ok2, "the second KO pumped without error: " .. tostring(err2))
local foeDef = Data.pokemon[b.enemyParty[2].species]
local solo = Experience.gainFor(foeDef, b.enemyParty[2].level, true, 1, nil,
Data.constants)
local halved = Experience.gainFor(foeDef, b.enemyParty[2].level, true, 2, nil,
Data.constants)
check(solo > halved,
"the two divisors are distinguishable for this foe (" ..
solo .. " vs " .. halved .. ")")
local shown, line = expLine(seen2)
check(shown ~= nil, "the KO printed an EXP. Points! line")
eq(shown, solo, "the switch-in is paid a whole share, not a split one (#275)")
check(shown ~= halved,
"the printed number is not the two-way split (" .. tostring(line) .. ")")
eq(delta[2], solo, "the reserve's exp rose by exactly that share")
eq(delta[1], 0, "the mon left behind is paid nothing for a KO it missed")
local lines = 0
for _, t in ipairs(seen2) do
if t:find("EXP%. Points!") then lines = lines + 1 end
end
eq(lines, 1, "exactly one mon is announced as gaining exp")
end
-- Control: SET style has no free switch, so the lead fights both mons and is
-- paid a whole share for each. Pin it here: the SHIFT switch-in above must
-- earn the same number.
do
local Game, b = atFirstKO("set")
local seen = {}
local ok = pump(b, false, nil, seen)
check(ok, "SET style pumped without error")
check(b.player.mon == Game.save.party[1], "SET style never offered a switch")
local ok2, _, seen2, delta = koAndRead(b)
check(ok2, "the SET second KO pumped without error")
local foeDef = Data.pokemon[b.enemyParty[2].species]
local solo = Experience.gainFor(foeDef, b.enemyParty[2].level, true, 1, nil,
Data.constants)
local shown = expLine(seen2)
eq(shown, solo, "SET style pays the lead a whole share")
eq(delta[1], solo, "and the lead's exp rises by it")
end
-- The path the reset must NOT touch: the party-menu SwitchPlayerMon
-- (core.asm:2424-2433, from PartyMenuOrRockOrRun) sets the incoming mon's bit
-- without zeroing the flag bytes, which is the exp-share trick every player
-- uses: send a weak mon in, switch it straight out, it still splits the KO.
do
local Game = freshGame("shift")
local b = BattleState.newTrainer(Game, YOUNGSTER, ROSTER)
local lead, reserve = Game.save.party[1], Game.save.party[2]
b.participants = { [lead] = true }
b:resolveSwitch(reserve)
local n = 0
while #b.queue > 0 and n < 200 do
n = n + 1
local item = table.remove(b.queue, 1)
if item.fn then b.nextInsert = 0; item.fn() end
end
check(b.player.mon == reserve, "the voluntary switch went through")
check(b.participants[reserve] == true, "the mon coming in participates")
check(b.participants[lead] == true,
"a VOLUNTARY switch keeps the outgoing mon flagged (the exp share)")
end
S.finish()
-117
View File
@@ -1,117 +0,0 @@
-- Parity: Oak's lab starter-ball Pokédex preview (#110).
-- pret StarterDex (engine/events/starter_dex.asm) temporarily sets the
-- owned bits so ShowPokedexData prints the full entry before the player
-- has caught anything. Also: English R/B prints only the kind string
-- (no " POKéMON" suffix -- that clipped "LIZARD" to "LIZARD POKé").
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity starter dex")
local check, eq = S.check, S.eq
local Font = require("src.render.Font")
Font.load(Data)
local DexEntryMenu = require("src.ui.DexEntryMenu")
local SaveData = require("src.core.SaveData")
local mapScripts = require("data.scripts.init")
local function fakeGame()
return {
data = Data,
save = SaveData.newGame(),
input = { wasPressed = function() return false end },
stack = { pop = function() end },
}
end
local function drawCapture(menu)
local drawn = {}
local saved = Font.draw
Font.draw = function(text, x, y)
drawn[#drawn + 1] = { text = tostring(text), x = x, y = y }
return Font.width(text)
end
menu:draw()
Font.draw = saved
return drawn
end
local function findText(drawn, needle)
for _, d in ipairs(drawn) do
if d.text == needle or d.text:find(needle, 1, true) then return d end
end
return nil
end
-- === 1) unowned entry without forceOwned stays "Data unknown." ===
do
local game = fakeGame()
game.save.pokedex = { seen = {}, owned = {} }
local menu = DexEntryMenu.new(game, "CHARMANDER")
local drawn = drawCapture(menu)
check(findText(drawn, "Data unknown."),
"unowned Charmander shows Data unknown without forceOwned")
check(not findText(drawn, "Obviously prefers"),
"unowned Charmander hides description without forceOwned")
check(not findText(drawn, "HT "),
"unowned Charmander hides height without forceOwned")
end
-- === 2) forceOwned shows full entry without mutating save ===
do
local game = fakeGame()
game.save.pokedex = { seen = {}, owned = {} }
local menu = DexEntryMenu.new(game, { species = "CHARMANDER", forceOwned = true })
check(menu.forceOwned, "forceOwned flag sticks on the menu")
local drawn = drawCapture(menu)
check(findText(drawn, "Obviously prefers"),
"forceOwned Charmander shows dex description")
check(findText(drawn, "HT "),
"forceOwned Charmander shows height")
check(not findText(drawn, "Data unknown."),
"forceOwned Charmander does not show Data unknown")
check(not game.save.pokedex.owned.CHARMANDER,
"forceOwned preview does not mark Charmander owned")
end
-- === 3) kind is the bare English string (no POKéMON suffix) ===
do
local game = fakeGame()
game.save.pokedex = { seen = {}, owned = { CHARMANDER = true } }
local menu = DexEntryMenu.new(game, "CHARMANDER")
local drawn = drawCapture(menu)
local kind = findText(drawn, "LIZARD")
check(kind and kind.text == "LIZARD",
"kind draws as LIZARD only (English R/B PlaceString)")
check(not findText(drawn, "POKéMON"),
"kind line does not append POKéMON")
check(kind.x + Font.width(kind.text) <= 160,
"LIZARD kind fits on-screen (no clip)")
end
-- === 4) Oak's lab starter scripts request forceOwned ===
do
local balls = {
"TEXT_OAKSLAB_CHARMANDER_POKE_BALL",
"TEXT_OAKSLAB_SQUIRTLE_POKE_BALL",
"TEXT_OAKSLAB_BULBASAUR_POKE_BALL",
}
for _, textId in ipairs(balls) do
local script = mapScripts.talkScript("OAKS_LAB", textId)
local found
for _, row in ipairs(script) do
if row[1] == "push_screen" and row[2] == "DexEntryMenu" then
found = row[3]
break
end
end
check(type(found) == "table" and found.forceOwned == true
and type(found.species) == "string",
textId .. " pushes DexEntryMenu with forceOwned")
end
end
S.finish()
-187
View File
@@ -1,187 +0,0 @@
-- Parity test: getting on SURF ends the bike (#846).
--
-- pokered keeps walking / biking / surfing in ONE state byte,
-- wWalkBikeSurfState. ItemUseSurfboard (engine/items/item_effects.asm)
-- copies the old state aside, refuses when it is already 2, and on a
-- successful mount does `ld a, 2 / ld [wWalkBikeSurfState], a ; change
-- player state to surfing` followed by PlayDefaultMusic -- the bike state
-- is overwritten, so no bike can survive a surf: not its 8-frame step
-- cadence, not its theme. The port splits that byte into two independent
-- flags (Game.save.onBike and player.surfing) and nothing used to clear
-- the first when the second went up, so a player who surfed off the bike
-- paddled at bike speed with Music_BikeRiding still playing.
--
-- The mirror direction is explicit in the same asm file: ItemUseBicycle
-- opens `ld a, [wWalkBikeSurfState] / cp 2 ; is the player surfing? /
-- jp z, ItemUseNotTime`, so the bag cannot re-raise the bike on water.
--
-- Self-contained; run via `luajit tests/parity_surf_clears_bike_bug846.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity surf clears bike")
local check, eq = S.check, S.eq
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local ItemEffects = require("src.inventory.ItemEffects")
local Music = require("src.core.Music")
local Pokemon = require("src.pokemon.Pokemon")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local OW = require("src.world.OverworldController")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
Game.overworld = OW
-- tests/parity_bills_pc.lua swaps the OverworldController chunk's TextBox
-- upvalue for a stub and never puts it back, and run_tests.lua runs every
-- parity suite from one file: point it back at the real module so trySurf
-- pushes a real box here (same guard as parity_field_move_layering.lua).
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
setUpvalue(OW.trySurf, "TextBox", require("src.render.TextBox"))
local function frame(btns)
Input.pressed = {}
for _, b in ipairs(btns or {}) do Input.pressed[b] = true; Input.state[b] = true end
StateStack:update(1 / 60)
for _, b in ipairs(btns or {}) do Input.state[b] = false end
end
local function popAll() while Game.stack:top() do Game.stack:pop() end end
local function pushOW(mapId, x, y, facing)
popAll()
Game.stack:push(OW, mapId, x, y, facing)
return Game.stack:top()
end
local function mkMon(species, ...)
local m = Pokemon.new(Data, species, 20)
m.moves = {}
for _, id in ipairs({ ... }) do m.moves[#m.moves + 1] = { id = id, pp = 15 } end
return m
end
-- Music.play no-ops on the headless audio stub, so the song the bike/surf
-- override actually resolves to is only observable by intercepting it
-- (the Music.playMap stub pattern in parity_seam_walk_anim.lua)
local playedSong
local realPlay = Music.play
Music.play = function(_, song) playedSong = song end
local bikeSong = Music.special(Data, "bike")
local surfSong = Music.special(Data, "surf")
check(bikeSong ~= nil and surfSong ~= nil and bikeSong ~= surfSong,
"the bike and surf themes are two distinct songs")
-- =====================================================================
-- control: on land, onBike really does buy the halved step cadence, so
-- the assertion after the mount below is not vacuous
-- =====================================================================
local ow = pushOW("PALLET_TOWN", 5, 6, "up")
local p = ow.player
eq(p.stepFrames, 16, "a walking step is 16 frames")
eq(p.bikeStepFrames, 8, "the bicycle doubles walking speed (8 frames)")
Game.save.onBike = true
p.turnTimer = 0
p.stepFramesCur = nil
eq(p:tryMove("up", ow.map, ow.entities), "moved", "riding north out of the spawn cell")
eq(p.stepFramesCur, p.bikeStepFrames, "onBike hands out the bike cadence on land")
-- =====================================================================
-- the mount: bike state is gone the moment the got-on text closes, and
-- the first step onto the water is a WALK-length step, not a bike one
-- =====================================================================
ow = pushOW("PALLET_TOWN", 4, 13, "down")
p = ow.player
p.surfing = false
Game.save.onBike = true
Game.save.party = { mkMon("SQUIRTLE", "SURF") }
-- HM03's badge gate (FieldDefaults hmBadges): without it partyKnows("SURF")
-- refuses and trySurf never prints anything
Game.save.inventory.SOULBADGE = true
check(ow:partyKnows("SURF") ~= nil, "the party can use SURF here")
check(ow.map:isWaterCell(4, 14), "Pallet's south shore faces water at (4,14)")
-- what is playing when the mount starts: the bike override over the
-- outdoor Pallet theme
Music.playMap(Data, "PALLET_TOWN", true, false)
eq(playedSong, bikeSong, "the bike theme plays while riding through Pallet")
p.stepFramesCur = nil
ow:trySurf(4, 14, nil)
local box = Game.stack:top()
check(box ~= nil and box.pages ~= nil, "SURF prints _SurfingGotOnText")
local guard = 0
while Game.stack:top() == box and guard < 400 do
guard = guard + 1
frame({ "a" })
end
check(Game.stack:top() ~= box, "the got-on text closes")
eq(p.surfing, true, "the mount raises the surf state")
eq(Game.save.onBike, false,
"ItemUseSurfboard writes surfing OVER the bike state, so the bike ends (#846)")
eq(playedSong, surfSong,
"PlayDefaultMusic after the mount picks the surf theme, not the bike theme")
-- the blink carries the mount forward onto the water; let that scripted
-- step land (it is queued through scriptMove, which drives the entity
-- directly and never touches Player:tryMove)
guard = 0
while (Game.stack:top() ~= ow or p.moving or #ow.scriptMoves > 0) and guard < 240 do
guard = guard + 1
frame({})
end
eq(Game.stack:top(), ow, "the mount ends back on the map")
eq(p.cellY, 14, "the mount steps forward onto the water")
-- the symptom in #846: the first paddled step the player takes. It runs
-- through Player:tryMove, which reads Game.save.onBike for its step
-- length -- a stale bike flag paddles at 8 frames per cell.
check(ow.map:isWaterCell(4, 15), "the next cell south is water too")
p.turnTimer = 0
p.stepFramesCur = nil
eq(p:tryMove("down", ow.map, ow.entities), "moved", "paddling south from (4,14)")
eq(p.stepFramesCur, p.stepFrames,
"the paddled step uses the walk cadence, the exact symptom in #846")
check(p.stepFramesCur ~= p.bikeStepFrames, "no bike cadence survives onto the water")
-- =====================================================================
-- the mirror hole: the bag cannot put the bike back on under a surfer
-- (ItemUseBicycle's `cp 2` -> jp z, ItemUseNotTime), or the bug returns
-- by another route
-- =====================================================================
local save = SaveData.newGame()
local surfingOw = { player = { surfing = true } }
local result, msgs = ItemEffects.use(Data, save, "BICYCLE", nil, false, nil, surfingOw)
eq(result, "failed", "the BICYCLE is refused while surfing")
check(result ~= "bicycle", "a surfing BICYCLE never reaches the mount path")
check(msgs and msgs[1] and msgs[1]:find("isn't the", 1, true) ~= nil,
"the surfing BICYCLE refusal uses the OAK 'not the time' text")
local landOw = { player = { surfing = false } }
eq((ItemEffects.use(Data, save, "BICYCLE", nil, false, nil, landOw)), "bicycle",
"the BICYCLE still mounts normally on land")
Music.play = realPlay
popAll()
S.finish()
-49
View File
@@ -1,49 +0,0 @@
-- Parity: a player send-out zeroes both battle cursors (#737). SendOutMon
-- (engine/battle/core.asm:1733-1735) clears wBattleAndStartSavedMenuItem and,
-- with the same hli/hl pair, wPlayerMoveListIndex behind it (wram.asm:242-244),
-- so the menu reopens on FIGHT and the move list on the first slot.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not Data.maps then Data:load() end
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local BattleState = require("src.battle.BattleState")
local S = require("tests.harness").suite("parity switch cursor reset")
local eq = S.eq
local pressed = {}
local save = SaveData.newGame()
save.party = {
Pokemon.new(Data, "BULBASAUR", 10),
Pokemon.new(Data, "PIDGEY", 10),
}
local game = {
data = Data,
save = save,
input = {
wasPressed = function(_, key) return pressed[key] == true end,
isDown = function(_, key) return pressed[key] == true end,
},
stack = { push = function() end, pop = function() end, top = function() end },
}
local battle = BattleState.newWild(game, "RATTATA", 3)
battle.phase = "menu"
battle.menuIndex = 4
battle.moveIndex = 3
battle:resolveSwitch(save.party[2])
for i = 1, 4000 do
if battle.phase == "menu" then break end
pressed.a = (i % 4 == 0)
battle:update(1 / 60)
pressed.a = nil
end
eq(battle.moveIndex, 1, "the move cursor is back on the first slot")
eq(battle.menuIndex, 1, "the battle menu is back on FIGHT")
S.finish()
-198
View File
@@ -1,198 +0,0 @@
-- Parity: the beaten trainer's own loss line prints ON the battle screen,
-- between the pic scrolling back in and the prize money (#282).
-- TrainerBattleVictory (engine/battle/core.asm:915-949) runs TrainerDefeatedText,
-- ScrollTrainerPicAfterBattle, PrintEndBattleText, then MoneyForWinningText.
-- The scroll (scroll_draw_trainer_pic.asm:1-31) rewrites tilemap columns only,
-- so the pokeball row ClearSprites emptied does not come back with the pic.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity trainer victory text")
local check, eq = S.check, S.eq
local Data = require("src.core.Data")
if not Data.maps then Data:load() end
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 Sound = require("src.core.Sound")
local Music = require("src.core.Music")
Sound.playCry = function() end
Sound.play = function() end
Sound.playMove = function() end
Sound.playMoveCry = function() end
Sound.stopLoop = function() end
Music.playBattle = function() end
Music.play = function() end
local press = {}
local function makeGame(party)
local save = SaveData.newGame()
save.party = party
local stack = { states = {} }
function stack:push(state) self.states[#self.states + 1] = state end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
-- isDown as well as wasPressed: battle text collapses PrintLetterDelay
-- while A or B is held, and the typing path reads it every frame
return { data = Data, save = save, stack = stack,
input = { wasPressed = function(_, b) return press[b] == true end,
isDown = function(_, b) return press[b] == true end } }
end
-- A held: updateQueue only reads the button once a page is typed out, so an
-- early press is ignored and the queue drains at a player's pace.
local function step(battle)
press.a = true
battle:update(1 / 60)
press.a = false
end
-- Fight a YOUNGSTER, wipe its party, and record every message row in the order
-- it reached the screen plus what the foe's pic slot was doing at the time.
local function fightAndWin(endBattleText)
local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 60) })
local battle = BattleState.newTrainer(game, "OPP_YOUNGSTER", 1)
battle.endBattleText = endBattleText
local result, resultAt
battle.onFinish = function(r) result = r end
battle:enter()
for _ = 1, 500 do
step(battle)
if battle.phase == "menu" then break end
end
-- the KO itself, through the real faint path (onFaint queues the slide,
-- the faint text and the enemyMonFainted act)
for _, mon in ipairs(battle.enemyParty) do mon.hp = 0 end
battle.enemy.mon.hp = 0
battle.phase = "messages"
battle.nextInsert = 0
battle:onFaint(battle.enemy)
local pages, foeOffAt, foeShownAt = {}, {}, {}
local ballRowsSeen, frame, foeMax, foeSteps = 0, 0, 0, 0
local realRow = battle.drawBallRow
battle.drawBallRow = function() ballRowsSeen = ballRowsSeen + 1 end
local lastOff = battle:picOffset("foe")
for f = 1, 2000 do
frame = f
step(battle)
local cur = battle.current
local text = cur and cur.text
if text and pages[#pages] ~= text then
pages[#pages + 1] = text
foeOffAt[text] = battle:picOffset("foe")
foeShownAt[text] = battle.showEnemyTrainer and true or false
end
local off = battle:picOffset("foe")
if off > foeMax then foeMax = off end
-- count only the inward frames; the jump from 0 to 64 is the program
-- being armed off-screen, not a step of the scroll
if off < lastOff then foeSteps = foeSteps + 1 end
lastOff = off
-- drawHUDs is the only place a ball row can come from; sample it while
-- the beaten trainer is back on screen
if battle.showEnemyTrainer then pcall(battle.drawHUDs, battle, 0) end
if result then resultAt = f break end
end
battle.drawBallRow = realRow
return {
battle = battle, pages = pages, result = result, resultAt = resultAt,
foeOffAt = foeOffAt, foeShownAt = foeShownAt, ballRowsSeen = ballRowsSeen,
frames = frame, foeMax = foeMax, foeSteps = foeSteps,
}
end
local function indexOf(pages, fragment)
for i, p in ipairs(pages) do
if p:find(fragment, 1, true) then return i end
end
return nil
end
-- ------------------------------------------------- the full victory order
local LOSS = "What a total\nwaste of time!"
local run = fightAndWin(LOSS)
eq(run.result, "win", "the battle resolves as a win")
local defeated = indexOf(run.pages, "defeated")
local loss = indexOf(run.pages, "waste of time")
local money = indexOf(run.pages, "for winning")
check(defeated ~= nil, "TrainerDefeatedText prints (\"RED defeated YOUNGSTER!\")")
check(loss ~= nil,
"the trainer's own EndBattleText prints INSIDE the battle (#282)")
check(money ~= nil, "MoneyForWinningText prints")
check(defeated and loss and defeated < loss,
"the defeat line comes before the trainer's loss line")
check(loss and money and loss < money,
"PrintEndBattleText comes before MoneyForWinningText (core.asm:942-949)")
check(money == #run.pages,
"the prize money is the LAST thing on the battle screen")
-- the pic is back, at rest, with no ball row beside it
if loss then
local text = run.pages[loss]
eq(run.foeShownAt[text], true,
"the beaten trainer's pic is on screen for his loss line")
eq(run.foeOffAt[text], 16,
"the pic has come to rest two tiles right of the battle slot "
.. "(_ScrollTrainerPicAfterBattle ends at hlcoord 14,0)")
end
eq(run.ballRowsSeen, 0,
"no pokeball row comes back with the pic (ClearSprites emptied that OAM; "
.. "_ScrollTrainerPicAfterBattle only rewrites tilemap columns)")
eq(run.battle.introBalls, nil, "the DrawAllPokeballs window stays closed")
-- The pic is on screen well before the LAST page: the plain act() this used to
-- ride appended to the end of the queue, so the trainer flashed up one row
-- before finish() popped the battle.
if money then
eq(run.foeShownAt[run.pages[money]], true,
"the trainer's pic is already back for the money line, not flashed up "
.. "one row before the battle pops (#282)")
end
-- and it really scrolls rather than popping into place: 64px off the right
-- edge, then 2px a frame down to the resting 16
eq(run.foeMax, 64, "the scroll-in starts 8 tiles off the right edge")
eq(run.foeSteps, 24,
"it takes 24 frames to walk in (six 4-frame columns, "
.. "scroll_draw_trainer_pic.asm:1-31)")
-- ------------------------------------------------------ ordering vs onFinish
-- finish() pops the battle, so anything the overworld pushes afterwards is a
-- second screen cut. Every trainer-victory row must be consumed before it.
check(run.resultAt ~= nil and run.resultAt >= run.frames,
"onFinish fires only once the whole sequence has drained")
-- ------------------------------------------------------------- \f pages
-- Five EndBattleTexts carry a `para` (e.g. _Route9Youngster1EndBattleText).
-- BattleState:startMessage only splits \n and \v, so an unsplit \f would
-- render as a garbage glyph instead of starting a new page.
local para = fightAndWin("Oh well.\fI give up!")
check(indexOf(para.pages, "Oh well.") ~= nil,
"a \\f EndBattleText prints its first page")
check(indexOf(para.pages, "I give up!") ~= nil,
"a \\f EndBattleText prints its second page")
local p1, p2 = indexOf(para.pages, "Oh well."), indexOf(para.pages, "I give up!")
check(p1 and p2 and p2 == p1 + 1, "the two pages are consecutive rows")
for _, page in ipairs(para.pages) do
check(page:find("\f", 1, true) == nil,
"no page still carries a raw \\f: " .. (page:gsub("\n", " / ")))
end
-- --------------------------------------------------- scripted battles
-- Commands.start_battle never sets endBattleText; those scripts print their
-- own follow-up, so the sequence must simply skip the row.
local none = fightAndWin(nil)
eq(none.result, "win", "a battle with no EndBattleText still resolves")
local d2, m2 = indexOf(none.pages, "defeated"), indexOf(none.pages, "for winning")
check(d2 and m2 and d2 < m2,
"defeat text then money, with nothing between them")
eq(m2, #none.pages, "the prize money is still last")
S.finish()
-127
View File
@@ -1,127 +0,0 @@
-- Regression (#535): after handing over the GOLD TEETH and receiving
-- HM04, every later talk to the Warden must still say something.
--
-- data/scripts/story.lua's TEXT_WARDENSHOUSE_WARDEN pointed the
-- EVENT_GOT_HM04 branch (row 3, jump_if_true) at the same silent-end jump
-- the give-then-thank fallthrough uses (row 13), so ScriptRunner's pc ran
-- straight past the end of the row list with zero show_text calls -- the
-- Warden went mute on every visit after the trade. pokered's .got_item
-- branch (scripts/WardensHouse.asm) instead prints .HM04ExplanationText
-- (text/WardensHouse.asm: "HM04 teaches STRENGTH ... SECRET HOUSE in
-- SAFARI ZONE") on every subsequent talk.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity wardens house (#535)")
local check, eq = S.check, S.eq
local Commands = require("src.script.Commands")
local Flags = require("src.script.Flags")
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner")
local StateStack = require("src.core.StateStack")
Game.data = Data
Game.input = Input; Input:init()
Game.stack = StateStack; StateStack:init()
require("src.render.Font").load(Data)
local story = require("data.scripts.story")
local script = story.WARDENS_HOUSE.talk.TEXT_WARDENSHOUSE_WARDEN
-- instrument show_text the way parity_gift_atomicity.lua does, to record
-- exactly which text ids actually printed
local shown = {}
-- forward EVERY argument: the 4th is extraOpts, which is how Commands.ask
-- hands down its `choice` callback. A wrapper that stops at `subs` silently
-- turns every ask in the script back into a plain show_text -- no YES/NO box,
-- and ctx.lastCheck left holding whatever the previous check_* put there.
local origShow = Commands.show_text
Commands.show_text = function(ctx, textId, ...)
shown[#shown + 1] = textId
return origShow(ctx, textId, ...)
end
-- `button` drives the whole conversation: both A and B page a text box, and
-- on the YES/NO box A takes the cursor's default (YES) while B snaps to NO
-- and answers false (ChoiceBox:update, .choseSecondMenuItem). So holding A
-- runs the yes branch and holding B runs the no branch, with no reaching
-- into the choice box from the test.
local function runScript(button)
shown = {}
StateStack:init()
local ow = { map = { id = "WARDENS_HOUSE", def = { label = "WardensHouse" } },
npcs = {}, entities = {} }
local r = ScriptRunner.new(Game, ow)
r:run(script, { npc = { def = {}, facePlayer = function() end },
overworld = ow })
local guard = 0
while r:isRunning() and guard < 3000 do
guard = guard + 1
Input.pressed = { [button or "a"] = true }
StateStack:update(1 / 60)
r:update()
end
Input.pressed = {}
return not r:isRunning()
end
-- === 1) first talk, holding the GOLD TEETH: gives HM04, sets the flag ===
Game.save = SaveData.newGame()
Game.save.inventory.GOLD_TEETH = 1
check(runScript(), "give-the-teeth talk completes")
eq(table.concat(shown, ","),
"_WardensHouseWardenGaveTheGoldTeethText,_WardensHouseWardenThanksText,"
.. "_WardensHouseWardenReceivedHM04Text",
"handing over the teeth shows the give/thanks/received sequence, nothing after")
check(Flags.get(Game.save, "EVENT_GOT_HM04"), "EVENT_GOT_HM04 is set")
check(Flags.get(Game.save, "EVENT_GAVE_GOLD_TEETH"), "EVENT_GAVE_GOLD_TEETH is set")
check(Game.save.inventory.HM_STRENGTH ~= nil, "HM04 (Strength) lands in the bag")
check(not Game.save.inventory.GOLD_TEETH, "the GOLD TEETH is taken")
-- === 2) the regression itself: every later talk, once EVENT_GOT_HM04 is
-- set, must print the explanation text instead of nothing ===
check(runScript(), "post-gift talk completes")
eq(table.concat(shown, ","), "_WardensHouseWardenHM04ExplanationText",
"every subsequent talk now prints the HM04/Safari Zone explanation (#535)")
-- run it again to confirm this is not a one-shot: it repeats every visit
check(runScript(), "a third talk completes")
eq(table.concat(shown, ","), "_WardensHouseWardenHM04ExplanationText",
"the explanation text repeats on every later talk, not just the first")
-- === 3) no GOLD TEETH yet: the gibberish question, then a YES/NO, then the
-- warden's answer -- Gibberish2 on yes, Gibberish3 on no (#645).
-- The port used to stop dead after the question. ===
Game.save = SaveData.newGame()
check(runScript("a"), "empty-handed talk completes on yes")
eq(table.concat(shown, ","),
"_WardensHouseWardenGibberish1Text,_WardensHouseWardenGibberish2Text",
"answering YES gets the warden's reply, not silence (#645)")
check(not Flags.get(Game.save, "EVENT_GOT_HM04"), "no HM04 yet")
Game.save = SaveData.newGame()
check(runScript("b"), "empty-handed talk completes on no")
eq(table.concat(shown, ","),
"_WardensHouseWardenGibberish1Text,_WardensHouseWardenGibberish3Text",
"and answering NO gets the other reply (#645)")
-- the question is asked, not just printed: `ask` is what puts the YES/NO box
-- up, so a future edit that downgrades it back to show_text fails here
local askRow
for _, row in ipairs(script) do
if row[2] == "_WardensHouseWardenGibberish1Text" then askRow = row[1] end
end
eq(askRow, "ask", "the gibberish line is asked with a YES/NO, not just shown")
-- neither answer touches the teeth trade
check(not Flags.get(Game.save, "EVENT_GAVE_GOLD_TEETH"),
"and neither answer hands over teeth the player does not have")
Commands.show_text = origShow
S.finish()
-290
View File
@@ -1,290 +0,0 @@
-- Parity (#617): Yellow's Viridian old man is the OLD_MAN2 at (18,9),
-- not the Red/Blue OLD_MAN at (17,5), and his dialog has no yes/no
-- choice -- the apology speech runs the RATTATA demo battle straight
-- away, the post-battle line is the losing-my-touch text, and he walks
-- off and hides.
--
-- Oracle: pokeyellow scripts/OaksLab.asm (OaksLabOakGivesPokedexScript:
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN_2),
-- scripts/ViridianCity.asm (ViridianCityCheckWaitingOldMan,
-- ViridianCityOldMan2Text, ...InitialCatchTrainingScript,
-- ...PostInitialCatchTraining) and scripts/ViridianCity_2.asm
-- (ViridianCityPrintOldManText). The Red/Blue "Are you in a hurry?"
-- script was running against Yellow's text: YES printed the TimeIsMoney
-- alias (_ViridianCityOldManLosingMyTouchText) and NO ran the demo --
-- every talk, forever.
--
-- Self-contained: `luajit tests/parity_yellow_old_man.lua`; also globbed
-- by tests/run_tests.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local GameVersion = require("src.core.GameVersion")
local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner")
local TextBox = require("src.render.TextBox")
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local S = require("tests.harness").suite("parity Yellow old man (#617)")
local check, eq = S.check, S.eq
local oldVersion = GameVersion.get()
local MAP = "VIRIDIAN_CITY"
local SLEEPER = "VIRIDIANCITY_OLD_MAN_SLEEPY"
local WALKER = "VIRIDIANCITY_OLD_MAN"
local OLD_MAN2 = "VIRIDIANCITY_OLD_MAN2"
local DONE_FLAG = "EVENT_COMPLETED_CATCH_TRAINING"
-- The Yellow wiring must be attached before anything else caches the
-- map-script registry: data.scripts.init branches on GameVersion at
-- load, so flip it first (this file owns its own process when run
-- standalone). Under tests/run_tests.lua the registry is already
-- cached with the Red wiring, so attach the Yellow modules directly
-- afterwards -- attachBase merges per TEXT constant and replaces hooks,
-- which is a no-op on a fresh process and the fix on a shared one.
GameVersion.set("yellow")
local mapScripts = require("data.scripts.init")
local MapScripts = require("src.script.MapScripts")
MapScripts.attachBase(MAP,
require("data.scripts.yellow_viridian_old_man").VIRIDIAN_CITY)
MapScripts.attachBase("OAKS_LAB",
require("data.scripts.oaks_lab_yellow"))
local oldManMod = require("data.scripts.yellow_viridian_old_man")
-- ------------------------------------------------------- the demo species
-- The catch demo is a RATTATA in Yellow (SetupBattle sets wCurOpponent
-- = RATTATA) but the Yellow manifest inherited Red's WEEDLE; the runtime
-- override in Data:applyVersionedFieldData repairs old caches. Kept
-- active until the end of this file so the demo-battle assertions below
-- run against the Yellow value; restored before S.finish() like
-- parity_yellow_trades does for its trades table.
local originalOldManBattle = Data.field.oldManBattle
or { species = "WEEDLE", level = 5 } -- the fixture carries no oldManBattle
local originalTrades = Data.field.trades
eq(originalOldManBattle.species, "WEEDLE",
"Red/Blue's old man still demos a Weedle")
GameVersion.set("yellow")
Data:applyVersionedFieldData()
eq(Data.field.oldManBattle.species, "RATTATA",
"Yellow's old man demos a Rattata (#617)")
local manifestFile = assert(io.open("tools/rom_manifest_yellow.json", "r"))
local yellowManifest = manifestFile:read("*a")
manifestFile:close()
check(yellowManifest:find('"species": "RATTATA"', 1, true) ~= nil,
"the Yellow manifest stamps RATTATA for fresh imports")
local redManifestFile = assert(io.open("tools/rom_manifest.json", "r"))
local redManifest = redManifestFile:read("*a")
redManifestFile:close()
check(redManifest:find('"species": "WEEDLE"', 1, true) ~= nil,
"and the Red/Blue manifest keeps WEEDLE")
-- ------------------------------------------------------- the Pokedex swap
-- OaksLabOakGivesPokedexScript shows TOGGLE_OLD_MAN_2 (the tutorial old
-- man standing on the sleeper's cell), never the Red/Blue walker
local oaksRows = mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")
check(type(oaksRows) == "table",
"the Yellow OaksLab Oak talk resolves to rows")
local sawSleepHide, sawOldMan2Show, sawOldManShow = false, false, false
for _, row in ipairs(oaksRows or {}) do
if row[1] == "hide_object" and row[3] == SLEEPER then sawSleepHide = true end
if row[1] == "show_object" and row[3] == OLD_MAN2 then sawOldMan2Show = true end
if row[1] == "show_object" and row[3] == WALKER then sawOldManShow = true end
end
check(sawSleepHide, "the Pokédex hand-over hides the lying old man")
check(sawOldMan2Show, "it shows OLD_MAN2 on the sleeper's cell")
check(not sawOldManShow, "it never shows the Red/Blue OLD_MAN (#617)")
-- both Yellow gamblers default hidden (toggle OFF), like pokeyellow
-- data/maps/toggleable_objects.asm. OLD_MAN2 only exists in a Yellow
-- import -- a Red-imported checkout carries just OLD_MAN -- so the
-- dataset checks tolerate its absence and the Yellow manifest carries
-- the OLD_MAN2 default instead.
local walkerDef, oldMan2Def
if Data.maps[MAP] then
for _, o in ipairs(Data.maps[MAP].objects or {}) do
if o.name == WALKER then walkerDef = o end
if o.name == OLD_MAN2 then oldMan2Def = o end
end
end
check(walkerDef == nil or walkerDef.hidden == true,
"VIRIDIANCITY_OLD_MAN defaults hidden in Yellow")
check(oldMan2Def == nil or oldMan2Def.hidden == true,
"VIRIDIANCITY_OLD_MAN2 defaults hidden in Yellow")
local om2Name = yellowManifest:find('"name": "VIRIDIANCITY_OLD_MAN2"', 1, true)
local om2Hidden = om2Name and yellowManifest:sub(
math.max(1, om2Name - 40), om2Name):find('"hidden": true', 1, true)
check(om2Hidden ~= nil,
"the Yellow manifest ships OLD_MAN2 with the toggle OFF")
-- ------------------------------------------------------- script registry
local talk = mapScripts.talkScript(MAP, "TEXT_VIRIDIANCITY_OLD_MAN2")
check(type(talk) == "function",
"TEXT_VIRIDIANCITY_OLD_MAN2 resolves to the Yellow handler")
check(type(mapScripts.talkScript(MAP, "TEXT_VIRIDIANCITY_OLD_MAN")) == "table",
"the Red/Blue OLD_MAN talk is still registered (unreachable in Yellow)")
local hooks = mapScripts.get(MAP)
check(hooks and type(hooks.onEnter) == "function",
"VIRIDIAN_CITY.onEnter is the Yellow swap")
check(hooks and type(hooks.onStep) == "function",
"VIRIDIAN_CITY.onStep chains the gym lock and sleeper gate")
check(oldManMod.VIRIDIAN_CITY and oldManMod.VIRIDIAN_CITY.talk
and oldManMod.VIRIDIAN_CITY.talk.TEXT_VIRIDIANCITY_OLD_MAN2 == talk,
"the handler is the module's own, not a leftover merge")
-- ------------------------------------------------------- completed branch
do
local pushed = {}
local game = {
data = Data,
save = SaveData.newGame(),
stack = { push = function(_, s) pushed[#pushed + 1] = s end },
}
game.save.flags.EVENT_COMPLETED_CATCH_TRAINING = true
local done = false
talk(game, nil, {}, function() done = true end)
eq(#pushed, 1, "a second talk only prints one box")
eq(getmetatable(pushed[1]), TextBox, "the losing-my-touch line, in a box")
pushed[1].onDone()
check(done, "closing it hands input back")
end
-- ------------------------------- the initial tutorial, end to end
-- Needs real species in the dataset (the fixture carries only FIX_*);
-- the engine's old-man demo machinery itself is parity_J's territory.
if Data.pokemon.RATTATA and Data.pokemon.PIKACHU then
do
require("src.render.Font").load(Data)
local pushed = {}
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "PIKACHU", 12) }
local moves = {}
local man = { def = { index = 8, name = OLD_MAN2 } }
local ow = {
map = { id = MAP, def = { label = "ViridianCity" } },
npcs = { man }, entities = { man },
player = { cellX = 19, cellY = 9, facing = "left" },
scriptMove = function(_, _, dir, _, cb) moves[#moves + 1] = dir; cb() end,
npcByIndex = function(_, i) if i == 8 then return man end end,
}
local game = {
data = Data,
save = save,
stack = { push = function(_, s) pushed[#pushed + 1] = s end },
}
local runner = ScriptRunner.new(game, ow)
ow.runner = runner
local done = false
talk(game, ow, man, function() done = true end)
eq(#pushed, 1, "the initial talk opens the apology speech")
eq(getmetatable(pushed[1]), TextBox, "in a text box")
pushed[1].onDone() -- A: the apology closes, the demo battle starts
eq(#pushed, 2, "the demo battle starts with no choice in between")
local battle = pushed[2]
check(battle and battle.demo, "it is the old-man demo battle")
eq(battle and battle.enemy and battle.enemy.mon.species, "RATTATA",
"the demo is a RATTATA in Yellow (#617)")
check(battle and battle.demoFails,
"the initial training throw breaks out, never catches (#636)")
eq(save.flags[DONE_FLAG], nil, "the flag is still clear mid-demo")
battle.onFinish() -- the battle ends, the post-battle text prints
eq(save.flags[DONE_FLAG], true, "EVENT_COMPLETED_CATCH_TRAINING is set")
eq(#pushed, 3, "the losing-my-touch line follows the demo")
pushed[3].onDone() -- A: the old man walks off
eq(#moves, 6, "with the player on (19,9) he walks down 6 tiles")
check(moves[1] == "down" and moves[6] == "down",
"all six steps are the ViridianCityOldManMovementData2 walk")
eq(save.objectToggles[MAP] and save.objectToggles[MAP][OLD_MAN2], false,
"TOGGLE_OLD_MAN_2 hides once the walk finishes")
check(done, "and the talk hands input back")
end
-- ---------------------------------- side talk: player not on (19,9) cell
do
local pushed = {}
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "PIKACHU", 12) }
local moves = {}
local man = { def = { index = 8, name = OLD_MAN2 } }
local pika = { def = { index = 99, name = "PIKACHU_FOLLOWER" },
pikachuFollower = true }
local ow = {
map = { id = MAP, def = { label = "ViridianCity" } },
npcs = { man, pika }, entities = { man, pika },
player = { cellX = 18, cellY = 8, facing = "down" },
scriptMove = function(_, _, dir, _, cb) moves[#moves + 1] = dir; cb() end,
npcByIndex = function(_, i) if i == 8 then return man elseif i == 99 then return pika end end,
}
local game = {
data = Data,
save = save,
stack = { push = function(_, s) pushed[#pushed + 1] = s end },
}
local runner = ScriptRunner.new(game, ow)
ow.runner = runner
talk(game, ow, man, function() end)
pushed[1].onDone()
pushed[2].onFinish()
pushed[3].onDone()
eq(moves[1], "right", "Pikachu steps aside first (ViridianCityMovePikachu)")
eq(moves[2], "right", "then the old man turns right one tile")
eq(#moves, 2, "and no more")
end
else
check(true, "fixture dataset: demo-battle flow skipped (no RATTATA)")
end
-- --------------------------------------------------------- the (19,9) step
do
local pushed = {}
local save = SaveData.newGame()
local man = { def = { index = 8, name = OLD_MAN2 } }
local ow = {
map = { id = MAP, def = { label = "ViridianCity" } },
npcs = { man }, entities = { man },
player = { cellX = 19, cellY = 9, facing = "down" },
scriptMove = function(_, _, _, _, cb) cb() end,
npcByIndex = function() end,
}
local game = {
data = Data,
save = save,
stack = { push = function(_, s) pushed[#pushed + 1] = s end },
}
local runner = ScriptRunner.new(game, ow)
ow.runner = runner
check(not hooks.onStep(game, ow, 5, 5),
"off the trigger cell the step passes through")
check(hooks.onStep(game, ow, 19, 9),
"pre-Pokedex the sleeper gate owns (19,9)")
eq(#pushed, 1, "with the sleepy text box")
check(save.flags[DONE_FLAG] ~= true, "the tutorial is not running")
save.flags.EVENT_GOT_POKEDEX = true
check(hooks.onStep(game, ow, 19, 9),
"with the Pokedex, (19,9) starts the tutorial")
eq(man.facing, "right", "the old man faces the player")
eq(ow.player.facing, "left", "and the player turns to face him")
eq(#pushed, 2, "the apology box is up")
check(save.flags[DONE_FLAG] ~= true,
"no flag until the demo battle actually runs")
save.flags.EVENT_COMPLETED_CATCH_TRAINING = true
check(not hooks.onStep(game, ow, 19, 9),
"once the tutorial is done the cell is quiet again")
end
Data.field.trades = originalTrades
Data.field.oldManBattle = originalOldManBattle
GameVersion.set(oldVersion)
S.finish()
+10
View File
@@ -283,6 +283,16 @@ do
eq(ItemEffects.use(Data, save, "HM_SURF", pikachu, {}), "failed",
"HM refuses mid-battle")
end
do
local rRepel, repelMsg = ItemEffects.use(Data, save, "MAX_REPEL", nil, {})
eq(rRepel, "failed", "Max Repel refuses mid-battle (#894)")
check(repelMsg and repelMsg[1] and repelMsg[1]:find("isn't the", 1, true),
"Max Repel mid-battle Oak text")
eq(ItemEffects.use(Data, save, "REPEL", nil, {}), "failed",
"Repel refuses mid-battle")
eq(ItemEffects.use(Data, save, "SUPER_REPEL", nil, {}), "failed",
"Super Repel refuses mid-battle")
end
local r5, _, extra = ItemEffects.use(Data, save, "THUNDER_STONE", pikachu)
eq(r5, "consumed", "Thunder Stone works on Pikachu")
eq(extra.evolveTo, "RAICHU", "Thunder Stone evolves Pikachu to Raichu")