mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4a1dd9e16 | |||
| 6f75a64c46 | |||
| cd37aa175c | |||
| cb6cfb5556 | |||
| 439e97aee6 | |||
| 5528525888 | |||
| a49ba66c36 | |||
| 9de4db8531 | |||
| dbc48f377a | |||
| 1d77d42fe2 | |||
| 683fee8028 | |||
| b9e8b00af0 | |||
| f92364a002 | |||
| 24c5114745 |
@@ -260,6 +260,117 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
linux-arm64-changes:
|
||||
name: detect Linux arm64 changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.paths.outputs.changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: paths
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_linux_arm64\.sh$|scripts/linux-arm64/|scripts/pack_love\.sh$|docs/linux-arm64-build\.md$|\.github/workflows/(ci|release)\.yml$)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
linux-arm64-selftest:
|
||||
name: Linux arm64 offline selftest
|
||||
needs: linux-arm64-changes
|
||||
if: needs.linux-arm64-changes.outputs.changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# Deliberately on x86_64: everything this gate checks (pins, the
|
||||
# host-arch guard, the dependency exclude list, the AppRun fusion
|
||||
# contract) is answerable without a container or an aarch64 machine,
|
||||
# so the slow native job below only ever starts on a sane tree.
|
||||
- name: Linux arm64 offline selftest
|
||||
run: bash scripts/linux-arm64/selftest_build_linux_arm64.sh
|
||||
|
||||
linux-arm64-build:
|
||||
name: Linux arm64 AppImage build
|
||||
needs: [linux-arm64-changes, linux-arm64-selftest]
|
||||
if: |
|
||||
always()
|
||||
&& needs.linux-arm64-changes.outputs.changed == 'true'
|
||||
&& needs.linux-arm64-selftest.result == 'success'
|
||||
# No fork restriction, unlike switch-build: this needs no secrets and no
|
||||
# self-hosted hardware, just GitHub's free arm64 runner for public repos,
|
||||
# so contributors get the same coverage on their own PRs.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build the aarch64 AppImage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_linux_arm64.sh --version 0.0.0
|
||||
- name: Verify the AppImage is self-contained and bullseye-compatible
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image="dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage"
|
||||
|
||||
# --appimage-extract needs no FUSE, so this works on a runner
|
||||
# without /dev/fuse and still exercises the real payload.
|
||||
"$image" --appimage-extract >/dev/null
|
||||
for required in AppRun bin/love game.love lib/liblove-11.5.so; do
|
||||
[ -e "squashfs-root/$required" ] \
|
||||
|| { echo "::error::AppImage is missing $required"; exit 1; }
|
||||
done
|
||||
|
||||
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
|
||||
# applied; an unresolved soname here is a user-visible launch crash.
|
||||
#
|
||||
# This runs on a HEADLESS runner on purpose, and that is the point.
|
||||
# The first version of this build bundled Debian's SDL2, which
|
||||
# hard-links libpulse/libasound/libX11/libwayland, so it only ever
|
||||
# started on a full desktop -- a bare runner is what exposed it.
|
||||
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
|
||||
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
||||
| grep 'not found' || true)"
|
||||
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
|
||||
|
||||
# Nothing may hard-link a driver, session or audio-stack library:
|
||||
# those must be reached through dlopen so the AppImage runs on a box
|
||||
# with only ALSA, only Wayland, or only KMSDRM.
|
||||
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
|
||||
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
|
||||
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
|
||||
[ -z "$linked" ] \
|
||||
|| { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; }
|
||||
|
||||
# The whole point of compiling on bullseye. If a future change moves
|
||||
# the builder to a newer base, the glibc floor silently rises and
|
||||
# every user on an older distro gets "GLIBC_2.xx not found" -- catch
|
||||
# it here instead of in a release.
|
||||
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
||||
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
|
||||
echo "highest required glibc symbol version: $floor"
|
||||
[ -n "$floor" ] \
|
||||
|| { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; }
|
||||
highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
|
||||
[ "$highest" = "GLIBC_2.31" ] \
|
||||
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
|
||||
- name: Upload the AppImage
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-linux-arm64
|
||||
path: |
|
||||
dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage
|
||||
dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
headless:
|
||||
name: headless suites (no ROM)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -140,6 +140,36 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
linux-arm64:
|
||||
name: build Linux arm64 AppImage
|
||||
needs: [version, love-payload]
|
||||
# GitHub's free arm64 runner for public repos. It has to be arm64: the
|
||||
# AppImage compiles LÖVE natively inside a Debian bullseye arm64
|
||||
# container, and the qemu-emulated alternative takes hours.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Download shared payload
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-release-love
|
||||
path: .bazinga/work
|
||||
- name: Build Linux arm64 AppImage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_linux_arm64.sh \
|
||||
--version "${{ needs.version.outputs.version }}" \
|
||||
--game-love .bazinga/work/game.love
|
||||
- name: Upload Linux arm64 release
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-linux-arm64-release
|
||||
path: |
|
||||
dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage
|
||||
dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
xbox-uwp:
|
||||
name: build Xbox UWP release
|
||||
needs: [version, love-payload]
|
||||
@@ -222,7 +252,7 @@ jobs:
|
||||
}
|
||||
|
||||
release:
|
||||
needs: [version, xbox-uwp]
|
||||
needs: [version, xbox-uwp, linux-arm64]
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
|
||||
steps:
|
||||
@@ -367,6 +397,13 @@ jobs:
|
||||
name: gen1recomp-xbox-uwp-release
|
||||
path: dist/xbox-uwp
|
||||
|
||||
- name: Download Linux arm64 release
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-linux-arm64-release
|
||||
path: dist/linux-arm64
|
||||
|
||||
- name: Stage release assets
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
id: assets
|
||||
@@ -379,6 +416,15 @@ jobs:
|
||||
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
|
||||
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
|
||||
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
|
||||
|
||||
# arm64 desktop Linux (Raspberry Pi, Armbian, arm64 VMs). Built on
|
||||
# its own runner because LÖVE publishes no aarch64 binary and the
|
||||
# AppImage has to be compiled natively; ships as a runnable
|
||||
# AppImage rather than a zip so `chmod +x && ./it` just works.
|
||||
arm64_appimage="dist/linux-arm64/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
|
||||
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||
@@ -512,6 +558,7 @@ jobs:
|
||||
"dist/release/gen1recomp-${v}-macos.zip"
|
||||
"dist/release/gen1recomp-${v}-windows.zip"
|
||||
"dist/release/gen1recomp-${v}-linux.zip"
|
||||
"dist/release/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
"dist/release/gen1recomp-${v}-android.apk"
|
||||
"dist/release/gen1recomp-${v}-ios.ipa"
|
||||
"dist/release/gen1recomp-${v}-switch.zip"
|
||||
|
||||
@@ -205,6 +205,39 @@ even on a different computer, as long as the same folder comes along.
|
||||
already written to either location is touched automatically, so copy files
|
||||
over yourself if you want to carry existing progress across the switch.
|
||||
|
||||
## Launch Options
|
||||
|
||||
By default the app opens the launcher so you can pick a game. Launch options
|
||||
skip it and start one game directly, which is what you want for a one-click
|
||||
entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
|
||||
|
||||
| Option | Effect |
|
||||
| --- | --- |
|
||||
| `--game=red` | boot Red, skipping the launcher (`blue` and `yellow` too, or just `r` / `b` / `y`) |
|
||||
| `--slot=2` | load that save slot; takes a slot number or a slot id |
|
||||
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
|
||||
|
||||
|
||||
## Linux on arm64 (Raspberry Pi)
|
||||
|
||||
Alongside the x86_64 `gen1recomp-*-linux.zip`, every release ships
|
||||
`gen1recomp-*-linux-arm64.AppImage` for 64-bit ARM desktop Linux — Raspberry
|
||||
Pi 4/5, Armbian and other SBC distros, and arm64 VMs on Apple Silicon:
|
||||
|
||||
```sh
|
||||
chmod +x gen1recomp-*-linux-arm64.AppImage
|
||||
./gen1recomp-*-linux-arm64.AppImage
|
||||
```
|
||||
|
||||
LÖVE publishes no aarch64 binary of any kind, so this artifact compiles the
|
||||
engine — and SDL2, OpenAL and the codecs — from source inside a Debian
|
||||
bullseye arm64 container. It needs only glibc 2.29+, libstdc++, freetype and
|
||||
zlib on the host; OpenGL, X11, Wayland, KMSDRM, ALSA and PulseAudio are all
|
||||
dlopened, so the same image runs on a full desktop, a Wayland-only session or
|
||||
a KMSDRM handheld with no X server. Build instructions and the reasoning are
|
||||
in [docs/linux-arm64-build.md](docs/linux-arm64-build.md).
|
||||
|
||||
|
||||
## iOS
|
||||
|
||||
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# Linux arm64 (aarch64) AppImage
|
||||
|
||||
Releases ship `gen1recomp-<version>-linux-arm64.AppImage` alongside the
|
||||
existing x86_64 `gen1recomp-<version>-linux.zip`. It targets 64-bit ARM
|
||||
desktop Linux: Raspberry Pi 4/5 running Raspberry Pi OS, Armbian and other
|
||||
SBC distros, arm64 VMs on Apple Silicon, Ampere/Graviton desktops, and the
|
||||
aarch64 handhelds that run a full distro.
|
||||
|
||||
> The Anbernic RG34XXSP has its own PortMaster-style pack
|
||||
> (`gen1recomp-*-rg34xxsp-stockos64-mod.zip`, see
|
||||
> [anbernic-rg34xxsp.md](anbernic-rg34xxsp.md)). That one bundles PortMaster's
|
||||
> LÖVE runtime and expects the device's own SDL; this AppImage is the generic
|
||||
> desktop-Linux artifact and shares nothing with it but the `game.love`.
|
||||
|
||||
## For players
|
||||
|
||||
```sh
|
||||
chmod +x gen1recomp-*-linux-arm64.AppImage
|
||||
./gen1recomp-*-linux-arm64.AppImage
|
||||
```
|
||||
|
||||
Then use **Import ROM** in the launcher to point it at your own legal Red /
|
||||
Blue / Yellow cartridge dump, exactly as on every other platform.
|
||||
|
||||
If your system has no FUSE (`dlopen(): error loading libfuse.so.2`), either
|
||||
install it (`sudo apt install libfuse2`) or run without it:
|
||||
|
||||
```sh
|
||||
./gen1recomp-*-linux-arm64.AppImage --appimage-extract-and-run
|
||||
```
|
||||
|
||||
### What the host has to provide
|
||||
|
||||
Very little, and this is enforced by an assertion in the build rather than by
|
||||
good intentions. The only libraries the AppImage requires at startup are:
|
||||
|
||||
```
|
||||
glibc 2.29+ libstdc++ libfreetype6 zlib
|
||||
```
|
||||
|
||||
Everything else — OpenGL/Mesa, X11, Wayland, KMSDRM, ALSA, PulseAudio — is
|
||||
**dlopened**, so it is used when present and skipped when absent. That means
|
||||
one image runs on a full desktop, on a Wayland-only session, on a
|
||||
KMSDRM-only handheld with no X server, and on a box with ALSA but no
|
||||
PulseAudio, without a different build for each.
|
||||
|
||||
That property does not come for free from Debian's packages, and getting it
|
||||
is most of what the build below is doing; see
|
||||
[Why five libraries are built from source](#why-five-libraries-are-built-from-source).
|
||||
|
||||
## For builders
|
||||
|
||||
```sh
|
||||
scripts/build_linux_arm64.sh --version 0.1.0
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage
|
||||
dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage.sha256
|
||||
```
|
||||
|
||||
Useful flags: `--game-love PATH` reuses an already-packed payload (CI does
|
||||
this so every platform ships identical bytes), `--rebuild-image` forces the
|
||||
builder container to rebuild, `--clean-cache` throws away the pinned
|
||||
downloads and the compiled LÖVE prefix.
|
||||
|
||||
### Requirements
|
||||
|
||||
An **aarch64 host** with **docker or podman**. A Raspberry Pi 5 is the
|
||||
reference machine (a cold build takes about 10 minutes on one — six libraries
|
||||
plus the engine; rebuilds reuse the cached prefix and take seconds). Apple Silicon with Docker
|
||||
Desktop and GitHub's `ubuntu-24.04-arm` runner both work too.
|
||||
|
||||
The script refuses to run on x86_64 rather than falling back to qemu-user
|
||||
emulation: that path takes hours and has produced miscompiled LuaJIT.
|
||||
|
||||
### Why this is not just another `scripts/build.sh` target
|
||||
|
||||
`scripts/build.sh linux` downloads LÖVE's official `love-11.5-x86_64.AppImage`,
|
||||
unpacks its squashfs, drops `game.love` in, and glues it back together. That
|
||||
trick is not available here — **LÖVE publishes no aarch64 binary at all.** The
|
||||
11.5 release has win32, win64, macOS, Android, iOS and one x86_64 AppImage,
|
||||
and that is the entire list.
|
||||
|
||||
So this build compiles LÖVE 11.5 from the official `linux-src` tarball and
|
||||
assembles the AppImage from scratch. Every pinned input — the LÖVE source, the
|
||||
five libraries built alongside it, and the AppImage type-2 runtime — is
|
||||
SHA-256 verified on the host before the container ever sees it, and the
|
||||
container itself runs with no network access.
|
||||
|
||||
### Why the build happens in a Debian bullseye container
|
||||
|
||||
glibc is backward compatible but not forward compatible: a binary linked
|
||||
against glibc 2.41 will not start on a system with 2.31, and there is no way
|
||||
to fix that after the fact. Compiling on the oldest base we support is
|
||||
therefore the only thing that makes one artifact work everywhere.
|
||||
|
||||
Bullseye (glibc 2.31) is that base. The resulting binaries actually come out
|
||||
needing only **glibc 2.29** and **GLIBCXX_3.4.21**, so the AppImage covers
|
||||
everything from Ubuntu 20.04 and Raspberry Pi OS bullseye through current
|
||||
trixie.
|
||||
|
||||
This is a statement about the *compile environment*, not about where the
|
||||
artifact runs — building on your own newer distro would silently raise that
|
||||
floor and strand every user on an older one, with no symptom until they
|
||||
download it. CI enforces the floor: `linux-arm64-build` fails if the highest
|
||||
required glibc symbol version climbs above 2.31.
|
||||
|
||||
### Why five libraries are built from source
|
||||
|
||||
SDL2, OpenAL, libtheora, libogg/libvorbis and libmpg123 are compiled rather
|
||||
than installed from bullseye. In every case the reason is *correctness*, not
|
||||
a newer version number — Debian builds these for a system where every
|
||||
dependency is installed and co-versioned, which is the opposite of an
|
||||
AppImage's situation. Each one broke the build in a different way, and all
|
||||
three failure modes are now assertions that fail the build instead of
|
||||
shipping.
|
||||
|
||||
**1. Hard-linked backends (SDL2, OpenAL).** Debian's `libSDL2` lists
|
||||
`libpulse`, `libasound`, `libX11` and `libwayland-client` as `DT_NEEDED` —
|
||||
resolved by the loader at startup, not dlopened. An AppImage bundling it
|
||||
refuses to start unless the host has *all four*. It appeared to work in
|
||||
testing only because a desktop Pi has all four; a headless CI runner is what
|
||||
exposed it. Debian's OpenAL does the same via `libsndio`, which itself
|
||||
hard-links `libasound`. Built from source with `--enable-*-shared` and
|
||||
`ALSOFT_DLOPEN`, both dlopen their backends instead.
|
||||
|
||||
**2. A stray link (libtheora).** Debian's `libtheoradec.so.1` is linked
|
||||
against `libcairo.so.2` — a packaging artifact, since a video decoder has no
|
||||
business drawing vector graphics — and cairo drags in X11, xcb, fontconfig
|
||||
and freetype. `--disable-examples` produces a `libtheoradec` needing only
|
||||
`libogg`.
|
||||
|
||||
**3. SONAME collision with the host (ogg, vorbis, mpg123).** The subtle one.
|
||||
OpenAL dlopens ALSA, ALSA's config loads its PulseAudio hook plugin, and that
|
||||
plugin pulls the *host's* `libsndfile` into our process. `libsndfile` links
|
||||
`libogg`, `libvorbis` and `libmpg123` — the same three we bundle. The loader
|
||||
resolves a SONAME exactly once per process, so the host's `libsndfile` binds
|
||||
to *our* copies:
|
||||
|
||||
```
|
||||
openal -> libasound -> libasound_module_conf_pulse -> libsndfile (host, new)
|
||||
`-> mpg123_info2 -> libmpg123 (ours, bullseye 1.26)
|
||||
```
|
||||
|
||||
`mpg123_info2` arrived in mpg123 1.32, so the plugin failed to relocate, ALSA
|
||||
config collapsed, and the game ran with **no audio device at all**. Not
|
||||
bundling these instead would make `libogg`/`libvorbis`/`libmpg123` mandatory
|
||||
host packages; building them current means our copies *satisfy* the host's
|
||||
`libsndfile` rather than starving it.
|
||||
|
||||
The same collision is why the font stack — freetype, fontconfig, libpng,
|
||||
brotli, zlib — is left to the host entirely. Bundling a bullseye freetype
|
||||
2.10.4 meant a host `libcairo` could not find `FT_Get_Transform` (added in
|
||||
2.11) and the game died at startup. Leaving the whole stack to the host keeps
|
||||
it self-consistent, while `liblove` — compiled against 2.10.4 — only ever
|
||||
asks for symbols every supported host already has.
|
||||
|
||||
The general rule this all reduces to: **never bundle a library the host's own
|
||||
stack may also load, unless yours is at least as new as theirs.**
|
||||
|
||||
### CI
|
||||
|
||||
Three jobs, path-gated on `scripts/build_linux_arm64.sh`,
|
||||
`scripts/linux-arm64/`, `scripts/pack_love.sh` and this document:
|
||||
|
||||
- **`linux-arm64-selftest`** (`ubuntu-latest`, x86_64) — offline gate. Checks
|
||||
the pins are real digests on a dated tag rather than the moving
|
||||
`continuous` one, that the Dockerfile still builds on bullseye, that the
|
||||
exclude list still classifies known sonames correctly, that AppRun still
|
||||
launches `game.love` with `--fused`, and that the host-arch guard actually
|
||||
fires. Needs no container and no arm64 machine.
|
||||
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then extracts
|
||||
the artifact and asserts the layout, that every bundled object resolves
|
||||
under AppRun's `LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31.
|
||||
Uploads the AppImage for 7 days.
|
||||
- **release** — `linux-arm64` runs on `ubuntu-24.04-arm`, reuses the shared
|
||||
`game.love` from the `love-payload` job, and the AppImage is staged and
|
||||
published like every other release asset.
|
||||
|
||||
Unlike the Switch job, none of this needs secrets or self-hosted hardware, so
|
||||
it runs on fork PRs too.
|
||||
|
||||
### Updating the pins
|
||||
|
||||
Both pins live in `scripts/linux-arm64/common.sh`:
|
||||
|
||||
- `LOVE_VERSION` / `LOVE_SRC_SHA256` — bumping any version invalidates the
|
||||
cached prefix automatically (its name is keyed by every source version at
|
||||
once, so a partial rebuild cannot mix vintages). Check that bullseye still
|
||||
has `-dev` packages new enough for the new release; `build_appimage.sh`
|
||||
asserts every optional module actually linked, because LÖVE's `configure`
|
||||
exits 0 and silently drops a module when one is missing.
|
||||
- `SDL2_*`, `OPENAL_*`, `THEORA_*`, `OGG_*`, `VORBIS_*`, `MPG123_*` — the
|
||||
source-built libraries. Bumping these is usually safe and occasionally
|
||||
necessary: `libmpg123` in particular must stay at least as new as what a
|
||||
target host's `libsndfile` expects, which is asserted for `mpg123_info2`.
|
||||
- `APPIMAGE_RUNTIME_TAG` / `APPIMAGE_RUNTIME_SHA256` — always a dated tag
|
||||
from [AppImage/type2-runtime](https://github.com/AppImage/type2-runtime/releases).
|
||||
The selftest fails the build if this ever points at `continuous`.
|
||||
@@ -226,5 +226,12 @@ for driving a second physical display. This is what lets a mod lay the two
|
||||
passes out as two stacked Game Boy screens, or push one onto a second screen,
|
||||
without the engine knowing the layout.
|
||||
|
||||
`screen.render_visible` receives `(next, state)` while the main screen is being
|
||||
composed. Return `false` to omit that state from drawing, opacity selection and
|
||||
palette-zone ownership. The state remains on the stack and keeps its normal
|
||||
update and input ownership, so a mod can mirror a native menu on another
|
||||
display without reimplementing it. The default is `true`. Treat the wrapper as
|
||||
a pure predicate: the renderer may ask it more than once per frame.
|
||||
|
||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||
that reach outside their permission set.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# RFC 0002 — Let mods hide an active screen state from the main render
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `StateStack.lua`, `Game.lua`. Tests:
|
||||
`screen_render_visible.lua`.
|
||||
|
||||
## Motivation
|
||||
|
||||
A mod can render a native menu on a companion display through
|
||||
`render.compose`, but it cannot remove that menu from the main display without
|
||||
also popping it. Popping transfers update and input ownership and forces the
|
||||
mod to reimplement native menu behavior.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
No prior D-number. Extends the render-hook plan in `docs/modding.md` and the
|
||||
state-stack rendering contract in `docs/architecture.md`.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only.
|
||||
|
||||
### `screen.render_visible`
|
||||
|
||||
New hook called with `(state) -> boolean` through the public wrapper signature
|
||||
`(next, state)`. Its vanilla result is `true`.
|
||||
|
||||
Returning `false` excludes the state from the main draw, from opaque-base
|
||||
selection and from palette-zone ownership. It does not remove the state or
|
||||
change update, input, push or pop behavior. The call sites are
|
||||
`StateStack:visibleBase`, `StateStack:draw` and the equivalent draw and palette
|
||||
walks in `Game:draw`.
|
||||
|
||||
The hook is guarded by `Runtime.wantsHook`, so the no-subscriber path allocates
|
||||
nothing. It is a pure render predicate and may be evaluated more than once per
|
||||
frame.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing.** With no subscriber every state remains visible, and the existing
|
||||
state-stack, event and hook behavior is unchanged.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod:** the topmost opaque state still owns drawing and palette zones,
|
||||
and `Runtime.wantsHook("screen.render_visible")` stays false.
|
||||
- **Mod-API:** a fixture mod registers through `mod.hooks:wrap`, hides one
|
||||
opaque state and proves the state beneath draws and owns the palette while
|
||||
the hidden state remains topmost and continues updating.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. This is one additive hook with a `true` vanilla default.
|
||||
@@ -30,6 +30,21 @@ end
|
||||
|
||||
local Game, EditorApp, Importer, TouchEditor
|
||||
|
||||
-- #887: quit-to-launcher state, shared by love.load and love.quit (both need
|
||||
-- it, so it is declared here rather than next to love.quit).
|
||||
-- * launchedIntoGame -- a --game / POKEPORT_GAME shortcut booted this
|
||||
-- session straight into a game, so there is no launcher behind it and a
|
||||
-- window close must exit. Restarting instead re-read the same shortcut
|
||||
-- and came right back into the game, and the next close did it again:
|
||||
-- the app could not be closed at all (macOS feels this worst, where the
|
||||
-- red X, Cmd+Q and the Dock's Quit are all the same quit event).
|
||||
-- * RELAUNCH_MARKER -- written in the save dir just before the #785
|
||||
-- restart, so the fresh boot ignores any boot-straight-into-a-game
|
||||
-- option exactly once and keeps #785's promise of landing in the
|
||||
-- launcher, whatever put the game on screen this time.
|
||||
local launchedIntoGame = false
|
||||
local RELAUNCH_MARKER = "relaunch_to_launcher.txt"
|
||||
|
||||
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
|
||||
local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
|
||||
-- coroutine that receives `Game` and yields once per
|
||||
@@ -334,10 +349,19 @@ function love.load(args)
|
||||
-- EmulationStation needs: one click into the game the player wants, with no
|
||||
-- menu in between. A game that is not imported falls through to the
|
||||
-- launcher on its tab rather than booting into nothing.
|
||||
-- A window close that restarted us into the launcher (#785) leaves the
|
||||
-- marker behind: consume it and stay on the launcher, or the shortcut below
|
||||
-- would boot the same game again and that close would restart again,
|
||||
-- forever (#887). Consumed on read, so the very next launch is normal.
|
||||
local relaunched = love.filesystem.getInfo(RELAUNCH_MARKER) ~= nil
|
||||
if relaunched then pcall(love.filesystem.remove, RELAUNCH_MARKER) end
|
||||
|
||||
local launchGame, launchSlot = LaunchOptions.resolve(arg)
|
||||
if launchGame and not LaunchOptions.forceLauncher(arg) then
|
||||
if launchGame and not relaunched and not LaunchOptions.forceLauncher(arg) then
|
||||
if RomImporter.isReady(launchGame) then
|
||||
if launchSlot then LaunchOptions.selectSlot(launchGame, launchSlot) end
|
||||
-- No launcher behind this session: love.quit must exit, not restart.
|
||||
launchedIntoGame = true
|
||||
bootGame(launchGame)
|
||||
return
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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 Won’t Disappear.\n- #863 [Yellow] When you use stairs, Pikachu shouldn't be next to you in the new area\n- #864 Faithful Ratio\n- #867 Missing Dialogue after defeating Marowak in Pokemon Tower\n- #869 Giovanni moves up to the player too early\n- #870 Start Menu on Classic Color\n- #872 Missing text when finding an item with full inventory\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.71",
|
||||
"date": "2026-08-05",
|
||||
|
||||
Executable
+181
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the aarch64 (arm64) Linux AppImage.
|
||||
#
|
||||
# scripts/build.sh's `linux` target only produces x86_64: it unpacks LÖVE's
|
||||
# official x86_64 AppImage and re-fuses it, and no aarch64 equivalent is
|
||||
# published. This script compiles LÖVE 11.5 from the official linux-src
|
||||
# tarball inside a Debian bullseye arm64 container and fuses game.love into a
|
||||
# type-2 AppImage, so one artifact covers Raspberry Pi OS, Armbian, Ubuntu
|
||||
# arm64 and the aarch64 handhelds.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/build_linux_arm64.sh [--version X.Y.Z] [--game-love PATH]
|
||||
# [--rebuild-image] [--clean-cache]
|
||||
#
|
||||
# Output:
|
||||
# dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage
|
||||
# dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage.sha256
|
||||
#
|
||||
# Requirements: docker or podman on an aarch64 host (a Raspberry Pi 5, an
|
||||
# ubuntu-24.04-arm runner or Apple Silicon Docker all work). Nothing is
|
||||
# cross-compiled and no qemu emulation is involved.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
. "$ROOT/scripts/linux-arm64/common.sh"
|
||||
|
||||
HERE="$ROOT/.bazinga"
|
||||
CACHE="$HERE/cache/linux-arm64"
|
||||
WORK="$HERE/work/linux-arm64"
|
||||
DIST="$ROOT/dist/linux-arm64"
|
||||
|
||||
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||
GAME_LOVE=""
|
||||
REBUILD_IMAGE=0
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version) VERSION="${2:?--version needs a value}"; shift ;;
|
||||
--game-love) GAME_LOVE="${2:?--game-love needs a path}"; shift ;;
|
||||
--rebuild-image) REBUILD_IMAGE=1 ;;
|
||||
--clean-cache) rm -rf "$CACHE" ;;
|
||||
-h|--help)
|
||||
sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown argument: $1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# --------------------------------------------------------------- host checks
|
||||
# aarch64 only. The container is arch-native; running it under qemu-user on an
|
||||
# x86_64 host "works" but takes hours and has produced miscompiled LuaJIT
|
||||
# before, so refuse rather than hand back a build nobody can trust.
|
||||
host_arch="$(uname -m)"
|
||||
case "$host_arch" in
|
||||
aarch64|arm64) ;;
|
||||
*) fail "this build must run on an aarch64 host (found: $host_arch).
|
||||
Use a Raspberry Pi 5 / arm64 VM / Apple Silicon, or the ubuntu-24.04-arm CI runner." ;;
|
||||
esac
|
||||
|
||||
RUNTIME="$(container_runtime)" || fail_need_container
|
||||
say "container runtime: $RUNTIME"
|
||||
|
||||
mkdir -p "$CACHE" "$WORK" "$DIST"
|
||||
|
||||
# --------------------------------------------------------------- game.love
|
||||
# Shared packer, same include/exclude set and the same verification gates as
|
||||
# every other platform, so this artifact can never drift from the desktop one.
|
||||
if [ -n "$GAME_LOVE" ]; then
|
||||
[ -f "$GAME_LOVE" ] || fail "--game-love: no such file: $GAME_LOVE"
|
||||
say "using prebuilt payload: $GAME_LOVE"
|
||||
else
|
||||
GAME_LOVE="$WORK/game.love"
|
||||
"$ROOT/scripts/pack_love.sh" \
|
||||
--output "$GAME_LOVE" \
|
||||
--listing "$WORK/love-listing.txt" \
|
||||
--version "$VERSION"
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------- icon
|
||||
# One source of truth for every platform's launcher icon (scripts/build.sh
|
||||
# resizes the same file with sips on macOS). Pillow is already a project
|
||||
# dependency via tools/build_data.py; without it, ship the 1024px original
|
||||
# rather than failing the build over an icon.
|
||||
IN_DIR="$WORK/in"
|
||||
rm -rf "$IN_DIR"; mkdir -p "$IN_DIR"
|
||||
ICON_SRC="$ROOT/assets/logo/gen1recomp_cover.png"
|
||||
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
|
||||
if ! python3 - "$ICON_SRC" "$IN_DIR/icon.png" <<'PY' 2>/dev/null
|
||||
import sys
|
||||
from PIL import Image
|
||||
with Image.open(sys.argv[1]) as image:
|
||||
image.convert("RGBA").resize((512, 512), Image.LANCZOS).save(sys.argv[2])
|
||||
PY
|
||||
then
|
||||
warn "Pillow not available, shipping the unresized icon"
|
||||
cp "$ICON_SRC" "$IN_DIR/icon.png"
|
||||
fi
|
||||
cp "$GAME_LOVE" "$IN_DIR/game.love"
|
||||
|
||||
# --------------------------------------------------------------- downloads
|
||||
# Fetched on the host and checksum-pinned here so the container never needs
|
||||
# network access and every input is verified in exactly one place.
|
||||
download_pinned "$LOVE_SRC_URL" "$CACHE/$LOVE_SRC_TARBALL" "$LOVE_SRC_SHA256"
|
||||
download_pinned "$SDL2_URL" "$CACHE/$SDL2_TARBALL" "$SDL2_SHA256"
|
||||
download_pinned "$OPENAL_URL" "$CACHE/$OPENAL_TARBALL" "$OPENAL_SHA256"
|
||||
download_pinned "$THEORA_URL" "$CACHE/$THEORA_TARBALL" "$THEORA_SHA256"
|
||||
download_pinned "$OGG_URL" "$CACHE/$OGG_TARBALL" "$OGG_SHA256"
|
||||
download_pinned "$VORBIS_URL" "$CACHE/$VORBIS_TARBALL" "$VORBIS_SHA256"
|
||||
download_pinned "$MPG123_URL" "$CACHE/$MPG123_TARBALL" "$MPG123_SHA256"
|
||||
download_pinned "$APPIMAGE_RUNTIME_URL" "$CACHE/$APPIMAGE_RUNTIME_NAME" \
|
||||
"$APPIMAGE_RUNTIME_SHA256"
|
||||
|
||||
# --------------------------------------------------------------- builder image
|
||||
if [ "$REBUILD_IMAGE" = 1 ] || ! "$RUNTIME" image inspect "$BUILDER_IMAGE" >/dev/null 2>&1; then
|
||||
say "building $BUILDER_IMAGE ($BUILDER_BASE_IMAGE)"
|
||||
"$RUNTIME" build -t "$BUILDER_IMAGE" \
|
||||
-f "$ROOT/scripts/linux-arm64/Dockerfile" "$ROOT/scripts/linux-arm64" \
|
||||
|| fail "failed to build the $BUILDER_BASE_IMAGE builder image"
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------- build
|
||||
OUT_DIR="$WORK/out"
|
||||
rm -rf "$OUT_DIR"; mkdir -p "$OUT_DIR"
|
||||
|
||||
# --user keeps the AppImage owned by the invoking user instead of root; podman
|
||||
# maps root in the container to the host user already, so only docker needs it.
|
||||
user_args=()
|
||||
if [ "$RUNTIME" = "docker" ]; then
|
||||
user_args=(--user "$(id -u):$(id -g)")
|
||||
fi
|
||||
|
||||
say "compiling and packaging inside $BUILDER_BASE_IMAGE"
|
||||
"$RUNTIME" run --rm ${user_args[@]+"${user_args[@]}"} \
|
||||
-e LOVE_VERSION="$LOVE_VERSION" \
|
||||
-e SDL2_VERSION="$SDL2_VERSION" \
|
||||
-e SDL2_TARBALL="$SDL2_TARBALL" \
|
||||
-e OPENAL_VERSION="$OPENAL_VERSION" \
|
||||
-e OPENAL_TARBALL="$OPENAL_TARBALL" \
|
||||
-e THEORA_VERSION="$THEORA_VERSION" \
|
||||
-e THEORA_TARBALL="$THEORA_TARBALL" \
|
||||
-e OGG_VERSION="$OGG_VERSION" \
|
||||
-e OGG_TARBALL="$OGG_TARBALL" \
|
||||
-e VORBIS_VERSION="$VORBIS_VERSION" \
|
||||
-e VORBIS_TARBALL="$VORBIS_TARBALL" \
|
||||
-e MPG123_VERSION="$MPG123_VERSION" \
|
||||
-e MPG123_TARBALL="$MPG123_TARBALL" \
|
||||
-e APP_NAME="$APP_NAME" \
|
||||
-e VERSION="$VERSION" \
|
||||
-v "$CACHE:/cache" \
|
||||
-v "$IN_DIR:/in:ro" \
|
||||
-v "$OUT_DIR:/out" \
|
||||
-v "$ROOT/scripts/linux-arm64:/scripts:ro" \
|
||||
"$BUILDER_IMAGE" bash /scripts/build_appimage.sh
|
||||
|
||||
# --------------------------------------------------------------- publish
|
||||
built="$OUT_DIR/$APP_NAME-$VERSION-linux-arm64.AppImage"
|
||||
[ -f "$built" ] || fail "container produced no AppImage at $built"
|
||||
|
||||
# The runtime is a static-pie ELF and the payload starts where its section
|
||||
# headers end; a truncated cat would still be "a file", so prove both halves
|
||||
# survived before shipping.
|
||||
head -c 4 "$built" | od -An -tx1 | tr -d ' \n' | grep -q '^7f454c46$' \
|
||||
|| fail "built AppImage is not an ELF"
|
||||
e_shoff=$(od -An -j40 -N8 -tu8 "$built" | tr -d ' ')
|
||||
e_shentsize=$(od -An -j58 -N2 -tu2 "$built" | tr -d ' ')
|
||||
e_shnum=$(od -An -j60 -N2 -tu2 "$built" | tr -d ' ')
|
||||
sfs_offset=$((e_shoff + e_shentsize * e_shnum))
|
||||
[ "$(dd if="$built" bs=1 skip="$sfs_offset" count=4 2>/dev/null)" = "hsqs" ] \
|
||||
|| fail "no squashfs payload at offset $sfs_offset (runtime/payload fusion failed)"
|
||||
|
||||
out="$DIST/$(basename "$built")"
|
||||
rm -f "$out" "$out.sha256"
|
||||
mv "$built" "$out"
|
||||
chmod +x "$out"
|
||||
printf '%s %s\n' "$(sha256_file "$out")" "$(basename "$out")" > "$out.sha256"
|
||||
|
||||
say "Linux arm64 build: $out ($(du -h "$out" | cut -f1))"
|
||||
say "sha256: $(cut -d' ' -f1 "$out.sha256")"
|
||||
@@ -0,0 +1,45 @@
|
||||
# Build environment for the aarch64 Linux AppImage.
|
||||
#
|
||||
# Debian bullseye on purpose: it ships glibc 2.31, the oldest runtime we
|
||||
# promise to support. Everything linked here therefore runs on bullseye and
|
||||
# every later distro (glibc is backward compatible, not forward), which is
|
||||
# what makes the resulting AppImage portable across Raspberry Pi OS, Armbian,
|
||||
# Ubuntu 20.04+, and the aarch64 handheld distros.
|
||||
#
|
||||
# This image is arch-native: build it on an aarch64 host (Raspberry Pi 5,
|
||||
# ubuntu-24.04-arm runner, Apple Silicon Docker) — no qemu emulation.
|
||||
FROM debian:bullseye
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# build-essential/autoconf: LÖVE 11.5's linux-src tarball is autotools.
|
||||
# squashfs-tools: packs the AppDir into the AppImage payload.
|
||||
#
|
||||
# Note what is deliberately ABSENT: libsdl2-dev, libtheora-dev and
|
||||
# libopenal-dev. All three are built from source instead (see common.sh for
|
||||
# why), and having Debian's copies installed would let pkg-config hand LÖVE's
|
||||
# configure the system ones and silently undo it.
|
||||
#
|
||||
# The remaining lib*-dev set is LÖVE's optional-module surface. A missing one
|
||||
# does not fail configure, it silently drops a module (love.sound decoders,
|
||||
# love.font, love.video), so they are pinned here deliberately and asserted
|
||||
# after the build.
|
||||
#
|
||||
# The X11/Wayland/audio -dev packages are here for SDL2's *build*, not for
|
||||
# runtime linkage: SDL detects each backend at compile time and then dlopens
|
||||
# it, so these headers decide which backends exist at all while adding no
|
||||
# DT_NEEDED entry to the shipped library.
|
||||
RUN apt-get update -qq \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config autoconf automake libtool cmake \
|
||||
ca-certificates curl file xz-utils bzip2 zip unzip squashfs-tools \
|
||||
libogg-dev libvorbis-dev \
|
||||
libmodplug-dev libmpg123-dev libfreetype6-dev libluajit-5.1-dev \
|
||||
zlib1g-dev libgl1-mesa-dev libgles2-mesa-dev libegl1-mesa-dev \
|
||||
libasound2-dev libpulse-dev libudev-dev libdbus-1-dev \
|
||||
libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev \
|
||||
libxinerama-dev libxss-dev libxkbcommon-dev \
|
||||
libwayland-dev wayland-protocols libdrm-dev libgbm-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /work
|
||||
Executable
+427
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compiles LÖVE for aarch64 and fuses game.love into a self-contained
|
||||
# AppImage. Runs INSIDE the Debian bullseye container from Dockerfile --
|
||||
# scripts/build_linux_arm64.sh is the entry point on the host.
|
||||
#
|
||||
# Mounts the host provides:
|
||||
# /cache pinned downloads + the compiled LÖVE prefix (persists between runs)
|
||||
# /in read-only inputs: game.love, icon.png
|
||||
# /out the finished AppImage lands here
|
||||
#
|
||||
# Environment:
|
||||
# LOVE_VERSION, APP_NAME, VERSION passed through from the host script
|
||||
# JOBS make -j (defaults to nproc)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LOVE_VERSION="${LOVE_VERSION:?}"
|
||||
SDL2_VERSION="${SDL2_VERSION:?}"
|
||||
SDL2_TARBALL="${SDL2_TARBALL:?}"
|
||||
OPENAL_VERSION="${OPENAL_VERSION:?}"
|
||||
OPENAL_TARBALL="${OPENAL_TARBALL:?}"
|
||||
THEORA_VERSION="${THEORA_VERSION:?}"
|
||||
THEORA_TARBALL="${THEORA_TARBALL:?}"
|
||||
OGG_VERSION="${OGG_VERSION:?}"
|
||||
OGG_TARBALL="${OGG_TARBALL:?}"
|
||||
VORBIS_VERSION="${VORBIS_VERSION:?}"
|
||||
VORBIS_TARBALL="${VORBIS_TARBALL:?}"
|
||||
MPG123_VERSION="${MPG123_VERSION:?}"
|
||||
MPG123_TARBALL="${MPG123_TARBALL:?}"
|
||||
APP_NAME="${APP_NAME:?}"
|
||||
VERSION="${VERSION:?}"
|
||||
JOBS="${JOBS:-$(nproc)}"
|
||||
|
||||
CACHE="/cache"
|
||||
IN="/in"
|
||||
OUT="/out"
|
||||
WORK="/tmp/build"
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$WORK"
|
||||
|
||||
# --------------------------------------------------------------- prefix
|
||||
# Everything we compile lands in one prefix, cached because the compiling is
|
||||
# the only slow part (~5 min cold on a Pi 5) and is identical for every game
|
||||
# version. The key includes every source version, so bumping any of them
|
||||
# invalidates the cache instead of silently reusing a stale mix.
|
||||
PREFIX="$CACHE/prefix-love$LOVE_VERSION-sdl$SDL2_VERSION-al$OPENAL_VERSION-theora$THEORA_VERSION-ogg$OGG_VERSION-vorbis$VORBIS_VERSION-mpg$MPG123_VERSION"
|
||||
export PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig"
|
||||
# Our own libraries must win over the system ones during LÖVE's configure and
|
||||
# link, or the whole point of building them is lost.
|
||||
export LD_LIBRARY_PATH="$PREFIX/lib"
|
||||
|
||||
# ------------------------------------------------------ compile audio codecs
|
||||
# Ordered by dependency: vorbis needs ogg, and theora needs ogg too. All three
|
||||
# are small, plain autotools builds -- well under a minute each.
|
||||
build_autotools() { # $1 = label $2 = version $3 = tarball $4 = probe lib $5.. = configure args
|
||||
local label="$1" version="$2" tarball="$3" probe="$4"; shift 4
|
||||
if [ -f "$PREFIX/lib/$probe" ]; then
|
||||
say "reusing cached $label $version"
|
||||
return 0
|
||||
fi
|
||||
say "compiling $label $version"
|
||||
local src="$WORK/$label-src"
|
||||
rm -rf "$src"; mkdir -p "$src"
|
||||
case "$tarball" in
|
||||
*.tar.bz2) tar -xjf "$CACHE/$tarball" -C "$src" --strip-components=1 ;;
|
||||
*) tar -xzf "$CACHE/$tarball" -C "$src" --strip-components=1 ;;
|
||||
esac
|
||||
(
|
||||
cd "$src"
|
||||
# Several of these tarballs predate aarch64's entry in config.guess; the
|
||||
# distro's copies know about it, so refresh them or configure bails out
|
||||
# with "cannot guess build type".
|
||||
for helper in config.guess config.sub; do
|
||||
[ -f "$helper" ] && cp "/usr/share/misc/$helper" . 2>/dev/null
|
||||
done
|
||||
./configure --prefix="$PREFIX" --disable-static "$@" >/dev/null
|
||||
make -j"$JOBS" >/dev/null
|
||||
make install >/dev/null
|
||||
)
|
||||
}
|
||||
|
||||
build_autotools ogg "$OGG_VERSION" "$OGG_TARBALL" libogg.so.0
|
||||
build_autotools vorbis "$VORBIS_VERSION" "$VORBIS_TARBALL" libvorbis.so.0
|
||||
# mpg123's ports/ tree and the command-line player are irrelevant here; only
|
||||
# libmpg123 gets linked, and --disable-modules keeps the output-backend
|
||||
# plugins (and their dlopen of ALSA/pulse) out of the shipped library.
|
||||
build_autotools mpg123 "$MPG123_VERSION" "$MPG123_TARBALL" libmpg123.so.0 \
|
||||
--disable-modules --with-audio=dummy --disable-lfs-alias
|
||||
|
||||
# The symbol that was missing when this was bullseye's copy. Assert it, so a
|
||||
# version bump that quietly regresses below the host's expectations fails the
|
||||
# build instead of silently killing audio again.
|
||||
objdump -T "$PREFIX/lib/libmpg123.so.0" | grep -q 'mpg123_info2' \
|
||||
|| fail "bundled libmpg123 lacks mpg123_info2; the host's libsndfile will fail to relocate"
|
||||
|
||||
# ------------------------------------------------------------ compile SDL2
|
||||
# --enable-*-shared (the defaults, made explicit so a future SDL release
|
||||
# cannot flip them under us) is the entire reason this is built from source:
|
||||
# each backend is dlopened at runtime rather than becoming a DT_NEEDED entry,
|
||||
# so the AppImage starts on a host with only ALSA, or only Wayland, or only
|
||||
# KMSDRM, instead of demanding all of them at once the way Debian's build does.
|
||||
if [ -f "$PREFIX/lib/libSDL2-2.0.so.0" ]; then
|
||||
say "reusing cached SDL2 $SDL2_VERSION"
|
||||
else
|
||||
say "compiling SDL2 $SDL2_VERSION (jobs: $JOBS)"
|
||||
rm -rf "$WORK/sdl-src"; mkdir -p "$WORK/sdl-src"
|
||||
tar -xzf "$CACHE/$SDL2_TARBALL" -C "$WORK/sdl-src" --strip-components=1
|
||||
(
|
||||
cd "$WORK/sdl-src"
|
||||
./configure --prefix="$PREFIX" --disable-static \
|
||||
--enable-alsa --enable-alsa-shared \
|
||||
--enable-pulseaudio --enable-pulseaudio-shared \
|
||||
--enable-video-x11 --enable-x11-shared \
|
||||
--enable-video-wayland --enable-wayland-shared \
|
||||
--enable-video-kmsdrm --enable-kmsdrm-shared \
|
||||
--enable-libudev --disable-sndio --disable-jack --disable-esd \
|
||||
--disable-arts --disable-nas --disable-oss >/dev/null
|
||||
make -j"$JOBS" >/dev/null
|
||||
make install >/dev/null
|
||||
)
|
||||
fi
|
||||
|
||||
# Prove the dlopen intent actually took. If SDL ever hard-links an audio or
|
||||
# video backend again, the AppImage silently regains a startup dependency on
|
||||
# the host having that exact stack -- which is the bug this replaced.
|
||||
sdl_lib="$PREFIX/lib/libSDL2-2.0.so.0"
|
||||
[ -f "$sdl_lib" ] || fail "SDL2 build produced no libSDL2-2.0.so.0"
|
||||
for forbidden in libpulse libasound libX11 libwayland libdrm libgbm libsndio; do
|
||||
if objdump -p "$sdl_lib" | grep -q "NEEDED.*$forbidden"; then
|
||||
fail "SDL2 hard-links $forbidden; it must dlopen its backends (--enable-*-shared)"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---------------------------------------------------- compile openal-soft
|
||||
# ALSOFT_DLOPEN keeps the ALSA and PulseAudio backends behind dlopen, and
|
||||
# sndio is switched off outright -- Debian enables it, which is what chained
|
||||
# libopenal -> libsndio -> libasound into a mandatory startup dependency.
|
||||
if [ -f "$PREFIX/lib/libopenal.so.1" ]; then
|
||||
say "reusing cached openal-soft $OPENAL_VERSION"
|
||||
else
|
||||
say "compiling openal-soft $OPENAL_VERSION (jobs: $JOBS)"
|
||||
rm -rf "$WORK/openal-src"; mkdir -p "$WORK/openal-src"
|
||||
tar -xzf "$CACHE/$OPENAL_TARBALL" -C "$WORK/openal-src" --strip-components=1
|
||||
(
|
||||
cd "$WORK/openal-src"
|
||||
cmake -S . -B build \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX="$PREFIX" \
|
||||
-DALSOFT_DLOPEN=ON \
|
||||
-DALSOFT_BACKEND_SNDIO=OFF \
|
||||
-DALSOFT_BACKEND_OSS=OFF \
|
||||
-DALSOFT_BACKEND_JACK=OFF \
|
||||
-DALSOFT_EXAMPLES=OFF \
|
||||
-DALSOFT_UTILS=OFF \
|
||||
-DALSOFT_TESTS=OFF \
|
||||
-DLIBTYPE=SHARED >/dev/null
|
||||
cmake --build build -j"$JOBS" >/dev/null
|
||||
cmake --install build >/dev/null
|
||||
)
|
||||
fi
|
||||
|
||||
openal_lib="$PREFIX/lib/libopenal.so.1"
|
||||
[ -f "$openal_lib" ] || fail "openal-soft build produced no libopenal.so.1"
|
||||
for forbidden in libsndio libasound libpulse libjack; do
|
||||
if objdump -p "$openal_lib" | grep -q "NEEDED.*$forbidden"; then
|
||||
fail "openal hard-links $forbidden; backends must stay behind dlopen"
|
||||
fi
|
||||
done
|
||||
|
||||
# --------------------------------------------------- compile libtheora
|
||||
# --disable-examples is what drops Debian's libcairo link (and with it libX11,
|
||||
# libxcb, libfontconfig and libfreetype as startup dependencies). The encoder
|
||||
# is dead weight for a player, but libtheoradec is what LÖVE actually links.
|
||||
if [ -f "$PREFIX/lib/libtheoradec.so.1" ]; then
|
||||
say "reusing cached libtheora $THEORA_VERSION"
|
||||
else
|
||||
say "compiling libtheora $THEORA_VERSION"
|
||||
rm -rf "$WORK/theora-src"; mkdir -p "$WORK/theora-src"
|
||||
tar -xjf "$CACHE/$THEORA_TARBALL" -C "$WORK/theora-src" --strip-components=1
|
||||
(
|
||||
cd "$WORK/theora-src"
|
||||
# theora 1.1.1 predates the aarch64 config.guess, so refresh the autotools
|
||||
# helper scripts or configure rejects the host outright.
|
||||
for helper in config.guess config.sub; do
|
||||
cp "/usr/share/misc/$helper" . 2>/dev/null || true
|
||||
done
|
||||
./configure --prefix="$PREFIX" --disable-static \
|
||||
--disable-examples --disable-spec --disable-doc >/dev/null
|
||||
make -j"$JOBS" >/dev/null
|
||||
make install >/dev/null
|
||||
)
|
||||
fi
|
||||
|
||||
theora_lib="$PREFIX/lib/libtheoradec.so.1"
|
||||
[ -f "$theora_lib" ] || fail "libtheora build produced no libtheoradec.so.1"
|
||||
if objdump -p "$theora_lib" | grep -q "NEEDED.*libcairo"; then
|
||||
fail "libtheoradec still links libcairo (--disable-examples stopped working)"
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------ compile LÖVE
|
||||
if [ -x "$PREFIX/bin/love" ] && [ -f "$PREFIX/lib/liblove-$LOVE_VERSION.so" ]; then
|
||||
say "reusing cached LÖVE $LOVE_VERSION aarch64 build"
|
||||
else
|
||||
say "compiling LÖVE $LOVE_VERSION for aarch64 (jobs: $JOBS)"
|
||||
rm -rf "$WORK/love-src"
|
||||
mkdir -p "$WORK/love-src"
|
||||
tar -xzf "$CACHE/love-$LOVE_VERSION-linux-src.tar.gz" \
|
||||
-C "$WORK/love-src" --strip-components=1
|
||||
(
|
||||
cd "$WORK/love-src"
|
||||
# No --disable-* flags on purpose: configure silently drops a love module
|
||||
# when its -dev package is absent, so the Dockerfile pins the full set and
|
||||
# the assertions below prove each one actually linked. CPPFLAGS/LDFLAGS
|
||||
# point at our prefix so the SDL2 and theora just built above win over
|
||||
# anything the base image might still provide.
|
||||
./configure --prefix="$PREFIX" --disable-static \
|
||||
CPPFLAGS="-I$PREFIX/include" LDFLAGS="-L$PREFIX/lib" >/dev/null
|
||||
make -j"$JOBS" >/dev/null
|
||||
make install >/dev/null
|
||||
# Keep LÖVE's license inside the cached prefix: the unpacked source tree
|
||||
# is thrown away, so a later cache-hit run would otherwise have nothing
|
||||
# to ship and the AppImage would go out without its engine license.
|
||||
cp license.txt "$PREFIX/license.txt"
|
||||
)
|
||||
fi
|
||||
|
||||
love_bin="$PREFIX/bin/love"
|
||||
love_lib="$PREFIX/lib/liblove-$LOVE_VERSION.so"
|
||||
[ -x "$love_bin" ] || fail "LÖVE build produced no bin/love"
|
||||
[ -f "$love_lib" ] || fail "LÖVE build produced no lib/liblove-$LOVE_VERSION.so"
|
||||
file "$love_bin" | grep -q 'ARM aarch64' \
|
||||
|| fail "built love is not an aarch64 ELF (got: $(file -b "$love_bin"))"
|
||||
|
||||
# A configure run that lost an optional dependency still exits 0 and still
|
||||
# builds -- the loss only shows up as a missing love module at runtime, i.e.
|
||||
# in a shipped artifact. Assert the decoder/font/video libs really linked.
|
||||
for soname in libSDL2-2.0.so.0 libopenal.so.1 libfreetype.so.6 \
|
||||
libmodplug.so.1 libmpg123.so.0 libvorbisfile.so.3 \
|
||||
libtheoradec.so.1 libluajit-5.1.so.2; do
|
||||
objdump -p "$love_lib" | grep -q "NEEDED.*$soname" \
|
||||
|| fail "liblove is not linked against $soname (a -dev package went missing)"
|
||||
done
|
||||
|
||||
# --------------------------------------------------------------- AppDir
|
||||
# Layout mirrors LÖVE's own x86_64 AppImage exactly (bin/ lib/ share/ at the
|
||||
# AppDir root, not usr/-prefixed), so the AppRun contract below -- and the
|
||||
# FUSE_PATH fusion scripts/build.sh performs on the x86_64 image -- stay the
|
||||
# same idea on both architectures.
|
||||
APPDIR="$WORK/AppDir"
|
||||
rm -rf "$APPDIR"
|
||||
mkdir -p "$APPDIR/bin" "$APPDIR/lib" "$APPDIR/share"
|
||||
|
||||
cp "$love_bin" "$APPDIR/bin/love"
|
||||
chmod +x "$APPDIR/bin/love"
|
||||
|
||||
# ------------------------------------------------------ bundle dependencies
|
||||
# Walk the DT_NEEDED graph from love + liblove, copying in everything that is
|
||||
# not host-provided. Recursion stops at excluded libraries, so the driver and
|
||||
# session subtrees behind SDL2 are never pulled in.
|
||||
#
|
||||
# Three reasons a library MUST come from the host, and every entry below is
|
||||
# one of them:
|
||||
#
|
||||
# 1. Driver/session coupled. A bundled libGL would bypass Mesa's V3D driver
|
||||
# on the Pi; a bundled libpulse/libdbus would fight the user's running
|
||||
# session. GL/EGL/gbm/drm, X11/xcb/wayland/xkbcommon, dbus, pulse, alsa,
|
||||
# systemd/udev. Note that after the source builds above, none of these are
|
||||
# DT_NEEDED of anything we ship -- SDL2 and OpenAL dlopen them, so they are
|
||||
# used when present and skipped when absent.
|
||||
#
|
||||
# 2. Loader coupled. glibc's pieces cannot be mixed with the host's ld.so at
|
||||
# all, and libstdc++/libgcc_s must be at least as new as the compiler --
|
||||
# bullseye's gcc 10 is older than any supported host's, so the host copy
|
||||
# always satisfies us.
|
||||
#
|
||||
# 3. The font/compression stack: freetype, fontconfig, libpng, brotli, zlib.
|
||||
# These are shared with whatever the host's own graphics libraries have
|
||||
# already loaded, and mixing vintages inside one process breaks the older
|
||||
# copy. Bundling a bullseye freetype 2.10.4 is what made a host cairo fail
|
||||
# to find FT_Get_Transform (added in 2.11) and killed the game at startup.
|
||||
# Leaving the whole stack to the host keeps it self-consistent, and
|
||||
# liblove -- compiled against 2.10.4 -- only ever asks for symbols every
|
||||
# supported host already has.
|
||||
EXCLUDE_RE='^(ld-linux-aarch64\.so\.1|libc\.so\.6|libm\.so\.6|libdl\.so\.2|libpthread\.so\.0|librt\.so\.1|libresolv\.so\.2|libutil\.so\.1|libanl\.so\.1|libnsl\.so\.[0-9]+|libstdc\+\+\.so\.6|libgcc_s\.so\.1|lib(GL|GLX|GLdispatch|OpenGL|EGL|GLESv[12]|glapi|gbm|drm)\..*|libX[a-z0-9]*\..*|libxcb.*|libwayland-.*|libxkbcommon.*|libdbus-1\..*|libpulse.*|libasound\..*|libsndfile\..*|libFLAC\..*|libopus\..*|libsystemd\..*|libudev\..*|libselinux\..*|libcap\..*|libgcrypt\..*|libgpg-error\..*|liblzma\..*|libzstd\..*|liblz4\..*|libffi\..*|libexpat\..*|libbsd\..*|libmd\..*|libuuid\..*|libg(lib|object|module|thread)-2\..*|libfontconfig\..*|libfreetype\..*|libpng[0-9]*\..*|libbrotli.*|libz\.so\..*|libwrap\..*|libasyncns\..*|libtirpc\..*|lib(gssapi_krb5|krb5|k5crypto|com_err|krb5support|keyutils)\..*|libpcre.*)$'
|
||||
|
||||
# soname -> absolute path, harvested from the full ldd closure of both roots.
|
||||
declare -A RESOLVED=()
|
||||
while read -r soname _arrow path _addr; do
|
||||
[ -n "${path:-}" ] || continue
|
||||
[ -e "$path" ] || continue
|
||||
RESOLVED["$soname"]="$path"
|
||||
done < <(ldd "$love_bin" "$love_lib" | awk '/=>/ {print $1, $2, $3, $4}')
|
||||
|
||||
declare -A BUNDLED=()
|
||||
bundle_needed() { # $1 = ELF whose DT_NEEDED entries to walk
|
||||
local soname target
|
||||
while read -r soname; do
|
||||
[ -n "$soname" ] || continue
|
||||
if [[ "$soname" =~ $EXCLUDE_RE ]]; then continue; fi
|
||||
if [ -n "${BUNDLED[$soname]:-}" ]; then continue; fi
|
||||
target="${RESOLVED[$soname]:-}"
|
||||
[ -n "$target" ] || fail "cannot resolve $soname (needed by $(basename "$1"))"
|
||||
# Copy dereferenced and under the soname: the AppDir must not depend on
|
||||
# the builder's libSDL2-2.0.so.0 -> libSDL2-2.0.so.0.14.0 symlink chain.
|
||||
cp -L "$target" "$APPDIR/lib/$soname"
|
||||
chmod 0644 "$APPDIR/lib/$soname"
|
||||
BUNDLED["$soname"]=1
|
||||
bundle_needed "$APPDIR/lib/$soname"
|
||||
done < <(objdump -p "$1" | awk '/NEEDED/ {print $2}')
|
||||
}
|
||||
|
||||
say "bundling shared libraries"
|
||||
cp "$love_lib" "$APPDIR/lib/liblove-$LOVE_VERSION.so"
|
||||
chmod 0644 "$APPDIR/lib/liblove-$LOVE_VERSION.so"
|
||||
BUNDLED["liblove-$LOVE_VERSION.so"]=1
|
||||
bundle_needed "$APPDIR/bin/love"
|
||||
bundle_needed "$APPDIR/lib/liblove-$LOVE_VERSION.so"
|
||||
say "bundled $(ls "$APPDIR/lib" | wc -l) libraries: $(ls "$APPDIR/lib" | tr '\n' ' ')"
|
||||
|
||||
# ------------------------------------------------- host dependency contract
|
||||
# The portability promise, stated as an assertion instead of a paragraph in a
|
||||
# README: these are the ONLY sonames the shipped objects may require from the
|
||||
# host. Everything driver-, session- or audio-related has to be reached
|
||||
# through dlopen, so the AppImage starts on a box with no PulseAudio, no X11
|
||||
# or no ALSA and simply uses whatever it does find.
|
||||
#
|
||||
# The original build failed exactly here and nobody noticed until CI ran on a
|
||||
# headless runner: Debian's SDL2 hard-links libpulse/libasound/libX11/
|
||||
# libwayland, so the image only ever started on a full desktop.
|
||||
HOST_ALLOWED_RE='^(ld-linux-aarch64\.so\.1|libc\.so\.6|libm\.so\.6|libdl\.so\.2|libpthread\.so\.0|librt\.so\.1|libstdc\+\+\.so\.6|libgcc_s\.so\.1|libatomic\.so\.1|libfreetype\.so\.6|libpng[0-9]*\.so\.[0-9]+|libz\.so\.1|libbrotli(dec|common)\.so\.1)$'
|
||||
|
||||
unexpected=""
|
||||
for object in "$APPDIR/bin/love" "$APPDIR"/lib/*.so*; do
|
||||
while read -r soname; do
|
||||
[ -n "$soname" ] || continue
|
||||
# Satisfied from inside the AppDir, so not a host requirement at all.
|
||||
if [ -n "${BUNDLED[$soname]:-}" ]; then continue; fi
|
||||
if [[ "$soname" =~ $HOST_ALLOWED_RE ]]; then continue; fi
|
||||
unexpected="$unexpected $(basename "$object") -> $soname"$'\n'
|
||||
done < <(objdump -p "$object" | awk '/NEEDED/ {print $2}')
|
||||
done
|
||||
[ -z "$unexpected" ] || fail "$(printf '%s\n%s' \
|
||||
"these objects hard-require host libraries outside the allowed set (they must be dlopened, not linked):" \
|
||||
"$unexpected")"
|
||||
say "host dependency contract holds (glibc, libstdc++ and the font stack only)"
|
||||
|
||||
# LÖVE loads jit.* (jit.status, the profiler) through LUA_PATH; without these
|
||||
# the modules are simply absent, so ship them the way upstream's image does.
|
||||
jit_share="$(ls -d /usr/share/luajit-* 2>/dev/null | head -1)"
|
||||
[ -n "$jit_share" ] || fail "luajit jit/*.lua modules not found under /usr/share"
|
||||
LUAJIT_SHARE_DIR="$(basename "$jit_share")"
|
||||
mkdir -p "$APPDIR/share/$LUAJIT_SHARE_DIR" "$APPDIR/share/lua/5.1" "$APPDIR/lib/lua/5.1"
|
||||
cp -R "$jit_share/jit" "$APPDIR/share/$LUAJIT_SHARE_DIR/"
|
||||
|
||||
# --------------------------------------------------------------- branding
|
||||
cp "$IN/game.love" "$APPDIR/game.love"
|
||||
# The .desktop's Icon= resolves against the AppDir root by basename, and
|
||||
# .DirIcon is what appimaged and file-manager thumbnailers read.
|
||||
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png"
|
||||
cp "$IN/icon.png" "$APPDIR/.DirIcon"
|
||||
|
||||
cat > "$APPDIR/$APP_NAME.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=gen1recomp
|
||||
Comment=Pokémon Gen 1 recompilation
|
||||
Exec=$APP_NAME
|
||||
Icon=$APP_NAME
|
||||
Categories=Game;
|
||||
Terminal=false
|
||||
EOF
|
||||
|
||||
# AppRun follows LÖVE's own, with FUSE_PATH committed to instead of shipped
|
||||
# commented out: this image is a game, not the engine, so it must never fall
|
||||
# through to LÖVE's "no game" screen.
|
||||
cat > "$APPDIR/AppRun" <<EOF
|
||||
#!/bin/sh
|
||||
# gen1recomp aarch64 AppImage launcher.
|
||||
|
||||
if [ -z "\$APPDIR" ]; then
|
||||
APPDIR="\$(dirname "\$(readlink -f "\$0")")"
|
||||
fi
|
||||
|
||||
export LD_LIBRARY_PATH="\$APPDIR/lib/:\$LD_LIBRARY_PATH"
|
||||
|
||||
if [ -z "\$XDG_DATA_DIRS" ]; then
|
||||
XDG_DATA_DIRS="/usr/local/share/:/usr/share/"
|
||||
fi
|
||||
export XDG_DATA_DIRS="\$APPDIR/share/:\$XDG_DATA_DIRS"
|
||||
|
||||
if [ -z "\$LUA_PATH" ]; then
|
||||
LUA_PATH=";"
|
||||
fi
|
||||
export LUA_PATH="\$APPDIR/share/$LUAJIT_SHARE_DIR/?.lua;\$APPDIR/share/lua/5.1/?.lua;\$LUA_PATH"
|
||||
|
||||
if [ -z "\$LUA_CPATH" ]; then
|
||||
LUA_CPATH=";"
|
||||
fi
|
||||
export LUA_CPATH="\$APPDIR/lib/lua/5.1/?.so;\$LUA_CPATH"
|
||||
|
||||
exec "\$APPDIR/bin/love" --fused "\$APPDIR/game.love" "\$@"
|
||||
EOF
|
||||
chmod +x "$APPDIR/AppRun"
|
||||
|
||||
[ -f "$PREFIX/license.txt" ] || fail "LÖVE license.txt missing from the build prefix"
|
||||
cp "$PREFIX/license.txt" "$APPDIR/license.love2d.txt"
|
||||
|
||||
# --------------------------------------------------------------- fuse image
|
||||
# An AppImage is just <runtime ELF><squashfs>. gzip at 128K blocks matches what
|
||||
# LÖVE's official image uses and what every type-2 runtime can read; zstd would
|
||||
# be smaller but is not universally supported by older runtimes users may have
|
||||
# registered through appimaged.
|
||||
say "packing squashfs"
|
||||
sfs="$WORK/payload.squashfs"
|
||||
rm -f "$sfs"
|
||||
mksquashfs "$APPDIR" "$sfs" \
|
||||
-comp gzip -b 131072 -noappend -all-root -no-xattrs -quiet >/dev/null
|
||||
|
||||
out="$OUT/$APP_NAME-$VERSION-linux-arm64.AppImage"
|
||||
rm -f "$out"
|
||||
cat "$CACHE/runtime-aarch64" "$sfs" > "$out"
|
||||
chmod +x "$out"
|
||||
|
||||
say "AppImage: $(basename "$out") ($(du -h "$out" | cut -f1))"
|
||||
Executable
+180
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared helpers and pins for the aarch64 Linux AppImage build.
|
||||
# Source from other scripts: . "$(dirname "$0")/common.sh"
|
||||
|
||||
# shellcheck disable=SC2034
|
||||
if [ -z "${ROOT:-}" ]; then
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
fi
|
||||
export ROOT
|
||||
|
||||
# ---------------------------------------------------------------- pins
|
||||
# LÖVE ships no aarch64 binary of any kind -- the 11.5 release has win32/win64,
|
||||
# macOS, Android, iOS and an x86_64 AppImage, and that is the whole list. So
|
||||
# this port compiles the official linux-src tarball instead of unpacking a
|
||||
# prebuilt image the way scripts/build.sh does for x86_64.
|
||||
LOVE_VERSION="11.5"
|
||||
LOVE_SRC_TARBALL="love-$LOVE_VERSION-linux-src.tar.gz"
|
||||
LOVE_SRC_URL="https://github.com/love2d/love/releases/download/$LOVE_VERSION/$LOVE_SRC_TARBALL"
|
||||
LOVE_SRC_SHA256="066e0843f71aa9fd28b8eaf27d41abb74bfaef7556153ac2e3cf08eafc874c39"
|
||||
|
||||
# SDL2 is built from source rather than taken from bullseye, and this is a
|
||||
# correctness requirement, not a version preference. Debian's libSDL2 lists
|
||||
# libpulse, libasound, libX11 and libwayland-client as DT_NEEDED -- hard links
|
||||
# resolved by the loader at startup -- so an AppImage bundling it refuses to
|
||||
# launch unless the host has ALL FOUR installed. That is wrong for an artifact
|
||||
# whose whole job is to run on arbitrary arm64 systems: an ALSA-only handheld
|
||||
# or a minimal Wayland box would die before main(). Built from source, SDL
|
||||
# defaults to dlopening every audio and video backend (--enable-*-shared), so
|
||||
# it loads whichever the host actually has and degrades gracefully.
|
||||
# The newer version is a bonus: 2.30 has a far better controller database and
|
||||
# real KMSDRM support, both of which matter on Pi-class and handheld hardware.
|
||||
SDL2_VERSION="2.30.12"
|
||||
SDL2_TARBALL="SDL2-$SDL2_VERSION.tar.gz"
|
||||
SDL2_URL="https://github.com/libsdl-org/SDL/releases/download/release-$SDL2_VERSION/$SDL2_TARBALL"
|
||||
SDL2_SHA256="ac356ea55e8b9dd0b2d1fa27da40ef7e238267ccf9324704850d5d47375b48ea"
|
||||
|
||||
# libtheora likewise. Debian's libtheoradec.so.1 is linked against libcairo --
|
||||
# a packaging artifact, since a video decoder has no business drawing vector
|
||||
# graphics -- and cairo drags in libX11, libxcb, libfontconfig and libfreetype
|
||||
# as hard dependencies. LOVE needs theora for love.video, so that link would
|
||||
# put the entire X11 and font stack on the critical path at startup, and it is
|
||||
# what caused the FT_Get_Transform crash this build hit on a trixie host.
|
||||
# Upstream's tarball with --disable-examples produces a libtheoradec that
|
||||
# needs only libogg.
|
||||
THEORA_VERSION="1.1.1"
|
||||
THEORA_TARBALL="libtheora-$THEORA_VERSION.tar.bz2"
|
||||
THEORA_URL="https://downloads.xiph.org/releases/theora/$THEORA_TARBALL"
|
||||
THEORA_SHA256="b6ae1ee2fa3d42ac489287d3ec34c5885730b1296f0801ae577a35193d3affbc"
|
||||
|
||||
# OpenAL for the same reason as SDL2, one level down. Debian's libopenal is
|
||||
# openal-soft built with the sndio backend enabled, so it hard-links
|
||||
# libsndio, which itself hard-links libasound -- reintroducing exactly the
|
||||
# mandatory-ALSA dependency the SDL2 source build exists to remove. Upstream
|
||||
# openal-soft dlopens its backends, so building it here leaves the shipped
|
||||
# library with no audio-stack dependency at all.
|
||||
OPENAL_VERSION="1.23.1"
|
||||
OPENAL_TARBALL="openal-soft-$OPENAL_VERSION.tar.gz"
|
||||
OPENAL_URL="https://github.com/kcat/openal-soft/archive/refs/tags/$OPENAL_VERSION.tar.gz"
|
||||
OPENAL_SHA256="dfddf3a1f61059853c625b7bb03de8433b455f2f79f89548cbcbd5edca3d4a4a"
|
||||
|
||||
# The audio codecs are built from source for a third, different reason: SONAME
|
||||
# collision with the host's audio stack.
|
||||
#
|
||||
# OpenAL dlopens ALSA, ALSA's config loads its PulseAudio hook plugin, and that
|
||||
# plugin pulls the HOST's libsndfile into our process. libsndfile links
|
||||
# libogg, libvorbis and libmpg123 -- the same three we bundle. The loader
|
||||
# resolves a SONAME once per process, so the host's libsndfile binds to OUR
|
||||
# copies, and a bullseye libmpg123 has no mpg123_info2 (added in 1.32):
|
||||
#
|
||||
# openal -> libasound -> libasound_module_conf_pulse -> libsndfile (host)
|
||||
# `-> mpg123_info2 -> libmpg123 (ours, bullseye)
|
||||
#
|
||||
# which failed to relocate and left the game with no audio device at all.
|
||||
# Not bundling them instead would make libogg/libvorbis/libmpg123 mandatory
|
||||
# host packages; building them current means our copies satisfy the host's
|
||||
# libsndfile rather than starving it. libvorbisfile ships in the vorbis
|
||||
# tarball.
|
||||
OGG_VERSION="1.3.5"
|
||||
OGG_TARBALL="libogg-$OGG_VERSION.tar.gz"
|
||||
OGG_URL="https://downloads.xiph.org/releases/ogg/$OGG_TARBALL"
|
||||
OGG_SHA256="0eb4b4b9420a0f51db142ba3f9c64b333f826532dc0f48c6410ae51f4799b664"
|
||||
|
||||
VORBIS_VERSION="1.3.7"
|
||||
VORBIS_TARBALL="libvorbis-$VORBIS_VERSION.tar.gz"
|
||||
VORBIS_URL="https://downloads.xiph.org/releases/vorbis/$VORBIS_TARBALL"
|
||||
VORBIS_SHA256="0e982409a9c3fc82ee06e08205b1355e5c6aa4c36bca58146ef399621b0ce5ab"
|
||||
|
||||
MPG123_VERSION="1.32.10"
|
||||
MPG123_TARBALL="mpg123-$MPG123_VERSION.tar.bz2"
|
||||
MPG123_URL="https://www.mpg123.de/download/$MPG123_TARBALL"
|
||||
MPG123_SHA256="87b2c17fe0c979d3ef38eeceff6362b35b28ac8589fbf1854b5be75c9ab6557c"
|
||||
|
||||
# AppImage type-2 runtime: the ~900 KB static-pie ELF that gets prepended to
|
||||
# the squashfs payload. Pinned to a dated tag, never "continuous", so a
|
||||
# rebuild months from now produces the same bytes.
|
||||
APPIMAGE_RUNTIME_TAG="20251108"
|
||||
APPIMAGE_RUNTIME_NAME="runtime-aarch64"
|
||||
APPIMAGE_RUNTIME_URL="https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_TAG/$APPIMAGE_RUNTIME_NAME"
|
||||
APPIMAGE_RUNTIME_SHA256="00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444"
|
||||
|
||||
# Debian bullseye (glibc 2.31) is the compile environment, NOT a statement
|
||||
# about where the artifact runs. glibc is backward compatible but not forward
|
||||
# compatible, so linking against the oldest glibc we support is what lets one
|
||||
# AppImage cover Raspberry Pi OS bullseye/bookworm/trixie, Ubuntu 20.04+ and
|
||||
# the aarch64 handheld distros. Building on a newer base would silently
|
||||
# restrict the artifact to that base and newer.
|
||||
BUILDER_BASE_IMAGE="debian:bullseye"
|
||||
BUILDER_IMAGE="${GEN1_LINUX_ARM64_IMAGE:-gen1recomp-linux-arm64-builder}"
|
||||
|
||||
APP_NAME="gen1recomp"
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# Print SHA-256 hex digest of PATH. Prefers sha256sum, falls back to shasum
|
||||
# (same order-agnostic pair scripts/switch/common.sh uses).
|
||||
sha256_file() {
|
||||
local path="$1"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$path" | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$path" | awk '{print $1}'
|
||||
else
|
||||
fail "need sha256sum or shasum (install coreutils)"
|
||||
fi
|
||||
}
|
||||
|
||||
# download_pinned URL DEST EXPECTED_SHA256
|
||||
#
|
||||
# A cache hit is only trusted if it still hashes to the pin: a download
|
||||
# truncated by a network drop would otherwise be reused forever, which is the
|
||||
# same trap scripts/build.sh guards for the win64 zip and the x86_64 AppImage.
|
||||
download_pinned() {
|
||||
local url="$1" dest="$2" want="$3" got=""
|
||||
if [ -f "$dest" ]; then
|
||||
got="$(sha256_file "$dest")"
|
||||
if [ "$got" = "$want" ]; then
|
||||
return 0
|
||||
fi
|
||||
warn "cached $(basename "$dest") has the wrong digest, re-downloading"
|
||||
rm -f "$dest"
|
||||
fi
|
||||
say "downloading $(basename "$dest")"
|
||||
curl -fL --progress-bar "$url" -o "$dest.tmp" || fail "download failed: $url"
|
||||
got="$(sha256_file "$dest.tmp")"
|
||||
[ "$got" = "$want" ] || fail "$(printf '%s\n expected %s\n got %s' \
|
||||
"checksum mismatch for $(basename "$dest")" "$want" "$got")"
|
||||
mv "$dest.tmp" "$dest"
|
||||
}
|
||||
|
||||
# Echo the container runtime to use: docker, else podman.
|
||||
container_runtime() {
|
||||
if [ -n "${GEN1_CONTAINER_RUNTIME:-}" ]; then
|
||||
printf '%s' "$GEN1_CONTAINER_RUNTIME"
|
||||
return 0
|
||||
fi
|
||||
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
||||
printf 'docker'
|
||||
elif command -v podman >/dev/null 2>&1; then
|
||||
printf 'podman'
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
fail_need_container() {
|
||||
fail "$(cat <<'EOF'
|
||||
the aarch64 AppImage is compiled inside a Debian bullseye container and needs
|
||||
docker or podman on an aarch64 host.
|
||||
|
||||
Raspberry Pi OS / Debian / Ubuntu: sudo apt install docker.io && sudo usermod -aG docker "$USER"
|
||||
Fedora / Asahi: sudo dnf install podman
|
||||
macOS (Apple Silicon): brew install --cask docker
|
||||
|
||||
Override the runtime with GEN1_CONTAINER_RUNTIME=podman.
|
||||
See docs/linux-arm64-build.md.
|
||||
EOF
|
||||
)"
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env bash
|
||||
# Offline checks for the aarch64 Linux AppImage build.
|
||||
#
|
||||
# Runs anywhere -- no container, no network, no aarch64 host -- so PR CI can
|
||||
# gate the parts of this build that do not need three minutes of compiling.
|
||||
# The real build is exercised separately by the linux-arm64-build job.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=common.sh
|
||||
. "$SCRIPT_DIR/common.sh"
|
||||
|
||||
require_command() {
|
||||
command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"
|
||||
}
|
||||
require_command unzip
|
||||
require_command zip
|
||||
|
||||
say "checking shell entry points"
|
||||
bash -n "$ROOT/scripts/build_linux_arm64.sh" "$SCRIPT_DIR"/*.sh
|
||||
help="$(bash "$ROOT/scripts/build_linux_arm64.sh" --help)"
|
||||
printf '%s' "$help" | grep -q -- '--version X.Y.Z' \
|
||||
|| fail "build help does not document --version"
|
||||
printf '%s' "$help" | grep -q 'linux-arm64\.AppImage' \
|
||||
|| fail "build help does not name the artifact it produces"
|
||||
|
||||
say "checking the host-architecture guard"
|
||||
# The guard is what stops someone from kicking off a qemu-emulated build that
|
||||
# takes hours and miscompiles LuaJIT. Prove it fires rather than trusting it.
|
||||
# The guard is what stops someone from kicking off a qemu-emulated build that
|
||||
# takes hours and has miscompiled LuaJIT before. Prove it fires by shadowing
|
||||
# uname, rather than trusting the branch is reachable.
|
||||
fake_bin="$(mktemp -d "${TMPDIR:-/tmp}/gen1recomp-fake-uname.XXXXXX")"
|
||||
printf '#!/bin/sh\necho x86_64\n' > "$fake_bin/uname"
|
||||
chmod +x "$fake_bin/uname"
|
||||
guard_out="$(PATH="$fake_bin:$PATH" \
|
||||
bash "$ROOT/scripts/build_linux_arm64.sh" --version 0.0.0 2>&1 || true)"
|
||||
rm -rf "$fake_bin"
|
||||
printf '%s' "$guard_out" | grep -q 'aarch64 host' \
|
||||
|| fail "build script does not refuse to run on a non-aarch64 host"
|
||||
|
||||
say "checking pinned inputs"
|
||||
# Pins must be real digests, and the AppImage runtime must come from a dated
|
||||
# tag: "continuous" is a moving target and would make rebuilds unreproducible.
|
||||
for pin_name in LOVE_SRC_SHA256 SDL2_SHA256 OPENAL_SHA256 THEORA_SHA256 \
|
||||
OGG_SHA256 VORBIS_SHA256 MPG123_SHA256 APPIMAGE_RUNTIME_SHA256; do
|
||||
pin_value="${!pin_name}"
|
||||
printf '%s' "$pin_value" | grep -Eq '^[0-9a-f]{64}$' \
|
||||
|| fail "$pin_name is not a sha256 digest: $pin_value"
|
||||
done
|
||||
if printf '%s' "$APPIMAGE_RUNTIME_URL" | grep -q '/continuous/'; then
|
||||
fail "the AppImage runtime is pinned to the moving 'continuous' tag"
|
||||
fi
|
||||
printf '%s' "$APPIMAGE_RUNTIME_URL" | grep -q "/$APPIMAGE_RUNTIME_TAG/$APPIMAGE_RUNTIME_NAME\$" \
|
||||
|| fail "APPIMAGE_RUNTIME_URL does not match the pinned tag/asset"
|
||||
printf '%s' "$LOVE_SRC_URL" | grep -q "/$LOVE_VERSION/$LOVE_SRC_TARBALL\$" \
|
||||
|| fail "LOVE_SRC_URL does not match LOVE_VERSION/LOVE_SRC_TARBALL"
|
||||
|
||||
say "checking the builder base image"
|
||||
# Building on anything newer than bullseye silently raises the glibc floor and
|
||||
# strands every user on an older distro, with no symptom until they run it.
|
||||
grep -q '^FROM debian:bullseye$' "$SCRIPT_DIR/Dockerfile" \
|
||||
|| fail "Dockerfile no longer builds on debian:bullseye (that raises the glibc floor)"
|
||||
[ "$BUILDER_BASE_IMAGE" = "debian:bullseye" ] \
|
||||
|| fail "BUILDER_BASE_IMAGE disagrees with the Dockerfile"
|
||||
|
||||
say "checking the dependency exclude list"
|
||||
# Extract the live regex from the build script and classify known sonames
|
||||
# through it, so a future edit cannot quietly start bundling glibc or stop
|
||||
# bundling the engine's own dependencies.
|
||||
EXCLUDE_RE="$(
|
||||
# shellcheck disable=SC1090
|
||||
grep -m1 "^EXCLUDE_RE=" "$SCRIPT_DIR/build_appimage.sh" | sed "s/^EXCLUDE_RE='//; s/'\$//"
|
||||
)"
|
||||
[ -n "$EXCLUDE_RE" ] || fail "could not read EXCLUDE_RE out of build_appimage.sh"
|
||||
|
||||
must_exclude=(libc.so.6 ld-linux-aarch64.so.1 libstdc++.so.6 libgcc_s.so.1
|
||||
libGL.so.1 libEGL.so.1 libgbm.so.1 libdrm.so.2 libX11.so.6
|
||||
libwayland-client.so.0 libpulse.so.0 libasound.so.2
|
||||
libfreetype.so.6 libfontconfig.so.1 libpng16.so.16 libz.so.1)
|
||||
must_bundle=(libSDL2-2.0.so.0 libopenal.so.1 libluajit-5.1.so.2 libmodplug.so.1
|
||||
libmpg123.so.0 libogg.so.0 libvorbis.so.0 libvorbisfile.so.3
|
||||
libtheoradec.so.1 liblove-11.5.so)
|
||||
|
||||
for soname in "${must_exclude[@]}"; do
|
||||
[[ "$soname" =~ $EXCLUDE_RE ]] \
|
||||
|| fail "$soname must be host-provided but the exclude list would bundle it"
|
||||
done
|
||||
for soname in "${must_bundle[@]}"; do
|
||||
if [[ "$soname" =~ $EXCLUDE_RE ]]; then
|
||||
fail "$soname is an engine dependency but the exclude list drops it"
|
||||
fi
|
||||
done
|
||||
|
||||
say "checking AppRun and the fusion contract"
|
||||
# The AppImage must boot straight into the game. If AppRun ever loses --fused,
|
||||
# users get vanilla LÖVE's "no game" screen instead, and nothing else catches
|
||||
# that before someone downloads a release.
|
||||
grep -qF -- '--fused "\$APPDIR/game.love"' "$SCRIPT_DIR/build_appimage.sh" \
|
||||
|| fail "AppRun no longer launches game.love with --fused"
|
||||
grep -qF 'LD_LIBRARY_PATH="\$APPDIR/lib/' "$SCRIPT_DIR/build_appimage.sh" \
|
||||
|| fail "AppRun no longer puts the bundled lib directory on LD_LIBRARY_PATH"
|
||||
grep -qF 'comp gzip -b 131072' "$SCRIPT_DIR/build_appimage.sh" \
|
||||
|| fail "squashfs payload is no longer gzip/128K (older type-2 runtimes cannot read it)"
|
||||
|
||||
say "checking the linked-module assertions"
|
||||
# configure exits 0 when an optional -dev package is missing and just drops the
|
||||
# module, so these assertions are the only thing standing between a missing
|
||||
# build dependency and a release that cannot play sound.
|
||||
for soname in libSDL2-2.0.so.0 libopenal.so.1 libfreetype.so.6 libmodplug.so.1 \
|
||||
libmpg123.so.0 libvorbisfile.so.3 libtheoradec.so.1; do
|
||||
grep -qF "$soname" "$SCRIPT_DIR/build_appimage.sh" \
|
||||
|| fail "build_appimage.sh no longer asserts liblove links $soname"
|
||||
done
|
||||
|
||||
say "checking the dlopen guarantees"
|
||||
# SDL2, OpenAL and libtheora are compiled from source for correctness, not for
|
||||
# a newer version number: Debian's builds hard-link libpulse/libasound/libX11/
|
||||
# libwayland (SDL2), libsndio (OpenAL) and libcairo (libtheora), each of which
|
||||
# turns an optional runtime capability into a mandatory startup dependency.
|
||||
# If a future edit drops the source build and reaches for the -dev package
|
||||
# again, the AppImage silently stops starting on lean systems.
|
||||
for forbidden_pkg in libsdl2-dev libtheora-dev libopenal-dev; do
|
||||
if grep -qE "^ +.*\b$forbidden_pkg\b" "$SCRIPT_DIR/Dockerfile"; then
|
||||
fail "Dockerfile installs $forbidden_pkg; that library is built from source on purpose"
|
||||
fi
|
||||
done
|
||||
grep -qF -- '--enable-alsa-shared' "$SCRIPT_DIR/build_appimage.sh" \
|
||||
|| fail "SDL2 is no longer configured to dlopen its audio backends"
|
||||
grep -qF -- '--enable-x11-shared' "$SCRIPT_DIR/build_appimage.sh" \
|
||||
|| fail "SDL2 is no longer configured to dlopen its video backends"
|
||||
grep -qF 'ALSOFT_DLOPEN=ON' "$SCRIPT_DIR/build_appimage.sh" \
|
||||
|| fail "openal-soft is no longer configured to dlopen its backends"
|
||||
grep -qF -- '--disable-examples' "$SCRIPT_DIR/build_appimage.sh" \
|
||||
|| fail "libtheora is no longer built with --disable-examples (it regains the libcairo link)"
|
||||
|
||||
say "checking the host dependency contract"
|
||||
# The shipped objects may require nothing from the host beyond glibc,
|
||||
# libstdc++ and the font stack. Everything driver-, session- or audio-related
|
||||
# has to be dlopened. This is the invariant a headless CI runner proved was
|
||||
# broken the first time round.
|
||||
HOST_ALLOWED_RE="$(
|
||||
grep -m1 "^HOST_ALLOWED_RE=" "$SCRIPT_DIR/build_appimage.sh" \
|
||||
| sed "s/^HOST_ALLOWED_RE='//; s/'\$//"
|
||||
)"
|
||||
[ -n "$HOST_ALLOWED_RE" ] || fail "could not read HOST_ALLOWED_RE out of build_appimage.sh"
|
||||
for soname in libpulse.so.0 libasound.so.2 libX11.so.6 libwayland-client.so.0 \
|
||||
libGL.so.1 libcairo.so.2 libsndio.so.7.0 libdbus-1.so.3; do
|
||||
if [[ "$soname" =~ $HOST_ALLOWED_RE ]]; then
|
||||
fail "$soname is allowed as a hard host dependency; it must be dlopened"
|
||||
fi
|
||||
done
|
||||
for soname in libc.so.6 libstdc++.so.6 libfreetype.so.6 libz.so.1; do
|
||||
[[ "$soname" =~ $HOST_ALLOWED_RE ]] \
|
||||
|| fail "$soname must be allowed as a host dependency but the contract rejects it"
|
||||
done
|
||||
|
||||
say "checking the shared game.love payload"
|
||||
temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/gen1recomp-linux-arm64-selftest.XXXXXX")"
|
||||
trap 'rm -rf "$temp_dir"' EXIT
|
||||
"$ROOT/scripts/pack_love.sh" \
|
||||
--output "$temp_dir/game.love" \
|
||||
--listing "$temp_dir/love-listing.txt" \
|
||||
--version 1.2.3 \
|
||||
--dry-run >/dev/null
|
||||
unzip -p "$temp_dir/game.love" src/core/Version.lua \
|
||||
| grep -Eq 'engine[[:space:]]*=[[:space:]]*"1\.2\.3"' \
|
||||
|| fail "shared payload version was not stamped"
|
||||
|
||||
say "Linux arm64 self-test passed"
|
||||
+6
-2
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user