Compare commits
156 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3a215e081 | |||
| 7fa758602a | |||
| bb979911c8 | |||
| f225f8a6e7 | |||
| ee891fb8fa | |||
| 9d73ff4110 | |||
| 0b7ecf1599 | |||
| aa45f05c63 | |||
| 9234f12ed0 | |||
| 26e0bd227e | |||
| 64ce66a619 | |||
| 9302bbcbf3 | |||
| 1855f838ef | |||
| 11af06a752 | |||
| 05668591ef | |||
| e74beec32d | |||
| 3efb2c9a6c | |||
| d3af63e013 | |||
| ca6bee64b9 | |||
| b52d183dba | |||
| 23e1877a62 | |||
| d8736fe33e | |||
| a74365a4fd | |||
| 1c2163bb5f | |||
| 12c2677dc2 | |||
| ae1b59ce7f | |||
| 02c4de897d | |||
| 3c6242717e | |||
| 3c71afb9fa | |||
| e79107c644 | |||
| 0afce96c8d | |||
| d881997acd | |||
| 8906133b93 | |||
| b9e3c4a689 | |||
| 81f18e244d | |||
| ff91481018 | |||
| fdb3184c71 | |||
| aa3b2a18ec | |||
| d498016a47 | |||
| 492e0344b5 | |||
| a710c64909 | |||
| 943ba5dcbf | |||
| 371edd1a02 | |||
| 9fc6ba5b01 | |||
| 983bea61aa | |||
| ab74f7adf7 | |||
| bdd0bf45bd | |||
| 4b950fc23d | |||
| af183d95ad | |||
| 1d4d5d2337 | |||
| 653b72d86a | |||
| 12b6ef6350 | |||
| c979685a1f | |||
| 5b1d0261ff | |||
| 5bffc89a2f | |||
| 2ce612d5b1 | |||
| 6999e5aecf | |||
| 44a7910c69 | |||
| 85c6fde443 | |||
| 67b6dcc293 | |||
| 24d9f279ec | |||
| 83f93c27a1 | |||
| d756b7cd43 | |||
| 1e6f504a84 | |||
| abb4f3f9f0 | |||
| 5b0209a7ce | |||
| c1e0685e97 | |||
| 3c57b28c6d | |||
| cab62ff7b3 | |||
| 5b6dfed359 | |||
| 91a85a065b | |||
| 32e2c51365 | |||
| 1992fdf398 | |||
| 59ac27971a | |||
| cd0ace2a39 | |||
| 274c728e77 | |||
| 8547127f54 | |||
| 45d799aa98 | |||
| 660c90626d | |||
| d9dc42895f | |||
| 02fd21bcdb | |||
| af00d6ad14 | |||
| 0d4a518a08 | |||
| 05b43ca258 | |||
| 9d6ea845d7 | |||
| bbca4e8e39 | |||
| 49954ec4ad | |||
| 6e94625f2a | |||
| 0399ad040f | |||
| 726ed1102e | |||
| 5bc7d203a5 | |||
| ce204aaf16 | |||
| ed8a89c5ce | |||
| 66e7d9432e | |||
| d59a9522ee | |||
| f1661b62b3 | |||
| a98562f46b | |||
| 365bd24740 | |||
| d50ea4d067 | |||
| 52c96437fa | |||
| 2a7c8ec81b | |||
| bd6c606303 | |||
| 112120e8fe | |||
| 00f6e3a7b4 | |||
| 9cc72f951b | |||
| 0f484682d8 | |||
| 60cf07fb0a | |||
| 493caee715 | |||
| aa6217e581 | |||
| a640f5504f | |||
| e01672b97d | |||
| 9e3d487a08 | |||
| 506cd2c5c5 | |||
| 6cb0ac5666 | |||
| b7d0ff2e16 | |||
| 984cefdc7b | |||
| 6c892cb7c1 | |||
| 52d1ddc5af | |||
| 75cd28435c | |||
| 9aa8b4e371 | |||
| e94d8e32ef | |||
| 3fae3088ca | |||
| cbb08f76ae | |||
| dd503706b0 | |||
| eeb181bcc0 | |||
| 287534cf26 | |||
| d08fce5fd5 | |||
| 0ae29744c8 | |||
| 84e1a5177b | |||
| c7e72149f1 | |||
| ecfca19f11 | |||
| 90a53e509a | |||
| 15fc97a996 | |||
| 35ed326dda | |||
| f4a1dd9e16 | |||
| 6f75a64c46 | |||
| dc04a4e848 | |||
| cd37aa175c | |||
| cb6cfb5556 | |||
| 439e97aee6 | |||
| 5528525888 | |||
| a49ba66c36 | |||
| 9de4db8531 | |||
| dbc48f377a | |||
| 1d77d42fe2 | |||
| 683fee8028 | |||
| a83d18fc51 | |||
| b9e8b00af0 | |||
| e2820e02c5 | |||
| f92364a002 | |||
| 9696c8d3e2 | |||
| 1bfc89fdd4 | |||
| 24c5114745 | |||
| ae5276b2d1 | |||
| 4dd4c5c463 | |||
| cafa6f3d61 |
@@ -199,7 +199,7 @@ jobs:
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(ports/uwp/|scripts/build_xbox_uwp\.sh$|scripts/xbox-uwp/|scripts/pack_love\.sh$|\.github/workflows/(ci|release)\.yml$|src/core/Platform\.lua$|src/import/(CacheFs|RomImporter)\.lua$|src/update/Check\.lua$|tests/engine/(platform_nx|uwp_native_picker)_test\.lua$|tests/rom_importer_double_pick_test\.lua$)'; then
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(ports/uwp/|scripts/build_xbox_uwp\.sh$|scripts/xbox-uwp/|scripts/pack_love\.sh$|\.github/workflows/(ci|release)\.yml$|src/core/Platform\.lua$|src/import/(CacheFs|LauncherView|RomImporter)\.lua$|src/update/Check\.lua$|tests/engine/(platform_nx|uwp_baseroms|uwp_native_picker)_test\.lua$|tests/rom_importer_double_pick_test\.lua$)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
@@ -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
|
||||
@@ -272,6 +383,9 @@ jobs:
|
||||
- name: install luajit
|
||||
run: sudo apt-get update && sudo apt-get install -y luajit
|
||||
|
||||
- name: install Pillow
|
||||
run: python3 -m pip install --upgrade pillow
|
||||
|
||||
- name: interpreter version
|
||||
run: luajit -v
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -31,12 +31,29 @@ mobile/ios/love-src/
|
||||
mobile/ios/cache/
|
||||
mobile/ios/build/
|
||||
|
||||
# love-nx vendor binaries (fetch per docs/switch-development.md; also covered by .*)
|
||||
# love-nx vendor binaries (fetch per docs/switch-build.md; also covered by .*)
|
||||
.bazinga/love-nx/
|
||||
|
||||
# Final packaged build artifacts (mac/win/web/android/ios/switch) — see scripts/build.sh
|
||||
/dist/
|
||||
|
||||
# Switch OTA launcher build outputs
|
||||
ports/switch/ota-launcher/build/
|
||||
ports/switch/ota-launcher/build-host/
|
||||
ports/switch/ota-launcher/*.nro
|
||||
ports/switch/ota-launcher/*.nacp
|
||||
ports/switch/ota-launcher/*.elf
|
||||
ports/switch/ota-launcher/*.map
|
||||
ports/switch/ota-launcher/romfs/logo.rgba
|
||||
ports/switch/ota-launcher/romfs/logo.png
|
||||
ports/switch/ota-launcher/romfs/cacert.pem
|
||||
ports/switch/ota-launcher/romfs/ota-bootstrap.nro
|
||||
ports/switch/ota-bootstrap/build/
|
||||
ports/switch/ota-bootstrap/*.nro
|
||||
ports/switch/ota-bootstrap/*.nacp
|
||||
ports/switch/ota-bootstrap/*.elf
|
||||
ports/switch/ota-bootstrap/*.map
|
||||
|
||||
# Legacy manual convenience-copy location (superseded by /dist/android/)
|
||||
mobile/dist/
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -222,6 +255,40 @@ build and install from source on a Mac instead, see
|
||||
<a href="https://github.com/bryanthaboi/gen1recomp/releases/latest"><img src="./.github/resources/github-badge.png" alt="Download from GitHub" height="60"></a>
|
||||
</div>
|
||||
|
||||
## Xbox Dev Mode
|
||||
|
||||
Every release ships `gen1recomp-*-xbox-uwp.zip` for Xbox One and Xbox Series
|
||||
consoles in Developer Mode. It cannot be installed in retail mode.
|
||||
|
||||
Extract the archive, then use Xbox Device Portal to install the `.msix` and
|
||||
the x64 package under `Dependencies`.
|
||||
|
||||
### External setup
|
||||
|
||||
1. Put your legally obtained Red, Blue, or Yellow ROMs on an external drive.
|
||||
Mod ZIPs can go on the same drive.
|
||||
2. Connect the drive to the Xbox and open Gen1Recomp.
|
||||
3. Select **Import ROM** or **Import Mod**, then choose the file with the Xbox
|
||||
file picker.
|
||||
4. Repeat the ROM import for each version you want to use.
|
||||
|
||||
### Internal setup
|
||||
|
||||
1. Create a folder named `baseroms` on your PC and place your legally obtained
|
||||
Red, Blue, or Yellow ROMs inside it.
|
||||
2. ZIP the folder, keeping `baseroms` at the top level of the archive.
|
||||
3. Launch Gen1Recomp once, then close it.
|
||||
4. Open Xbox Device Portal and upload the ZIP to
|
||||
`Gen1Recomp/LocalState/pokemon-love2d/`.
|
||||
5. Choose **Yes** when Device Portal asks whether to extract the archive.
|
||||
6. Open Gen1Recomp. The launcher checks baseroms once at startup. When it finds a compatible ROM, that game’s tab shows ROM FOUND and an Import detected ROM button.
|
||||
|
||||
ROMs, generated game data, saves, and mods remain in LocalState and are not
|
||||
included in the app.
|
||||
|
||||
Source builds and package details are covered in
|
||||
[the Xbox UWP build notes](ports/uwp/BUILD.md).
|
||||
|
||||
## Handhelds
|
||||
|
||||
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
||||
@@ -231,24 +298,18 @@ Install steps, controls, and troubleshooting live in
|
||||
|
||||
## Nintendo Switch
|
||||
|
||||
Releases ship an SD-ready `gen1recomp-*-switch.zip` (issue
|
||||
[#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Runtime target
|
||||
is pinned [love-nx](https://github.com/retronx-team/love-nx) `11.5-nx1`.
|
||||
Requires a console that can run Switch homebrew. Hardware evidence: **OLED**
|
||||
(author) and **V1 / Erista** boot (community).
|
||||
Releases ship an SD-ready `gen1recomp-*-switch.zip`. Runtime target is pinned
|
||||
[love-nx](https://github.com/retronx-team/love-nx) `11.5-nx1`. Requires a
|
||||
console that can run Switch homebrew.
|
||||
|
||||
- Players: [docs/switch-install.md](docs/switch-install.md) — download the
|
||||
- Players: [docs/switch-install.md](docs/switch-install.md). Download the
|
||||
zip, extract at the microSD root (install or update), title-override
|
||||
launch, import your own legal ROM, Joy-Con controls and shortcuts.
|
||||
- Builders: [docs/switch-build.md](docs/switch-build.md) — `--fetch` /
|
||||
`--loose` / `--fused`, toolchain, Docker fallback, and **CI vs release**
|
||||
(path-gated ubuntu selftest, canonical fused PR artifact, release hard-fail).
|
||||
|
||||
Limitations, Dusklight-derived method, and how we tested:
|
||||
[docs/switch-development.md](docs/switch-development.md) and
|
||||
[docs/switch-hardware-evidence.md](docs/switch-hardware-evidence.md). Community
|
||||
help — especially HOS / love-nx packaging and broader hardware coverage — is
|
||||
welcome.
|
||||
- Builders: [docs/switch-build.md](docs/switch-build.md). `--fetch` /
|
||||
`--loose` / `--fused`, toolchain, Docker fallback, and CI vs release
|
||||
(path-gated ubuntu selftest, fused PR artifact on the main repo, release
|
||||
hard-fail).
|
||||
- File transfer (MTP / SD / FTP): [docs/switch-transfer.md](docs/switch-transfer.md).
|
||||
|
||||
## Modding
|
||||
|
||||
@@ -297,7 +358,4 @@ This project would not be possible without [pret](https://github.com/pret) >
|
||||
the pret band of decompiling maniacs > and their
|
||||
[pokered](https://github.com/pret/pokered) disassembly.
|
||||
|
||||
Nintendo Switch port: [andrewqsantos](https://github.com/andrewqsantos).
|
||||
Switch hardware testing (V1 boot): [booshankles](https://github.com/booshankles).
|
||||
|
||||
<p align="center"><a href="https://boisclub.games"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/bcg.png"></a></p>
|
||||
|
||||
@@ -800,9 +800,13 @@ M.SILPH_CO_11F = {
|
||||
-- line) would touch, and the whole Silph ending -- the flag, the Master
|
||||
-- Ball, the Saffron streets clearing -- silently never happened.
|
||||
--
|
||||
-- engageTrainer shows TEXT_SILPHCO11F_GIOVANNI as the battle text and,
|
||||
-- via victories.lua OPP_GIOVANNI#2, sets the event on a win; a loss
|
||||
-- sets nothing, so the trigger re-arms exactly as vanilla does.
|
||||
-- SilphCo11FDefaultScript orders it DisplayTextID TEXT_SILPHCO11F_GIOVANNI
|
||||
-- FIRST, then MoveSprite .GiovanniMovement: he speaks from behind the desk
|
||||
-- and only then walks the three tiles down. Moving him before the box made
|
||||
-- him cross the room in silence and deliver the speech point-blank (#869),
|
||||
-- so the box comes first here and engageTrainer skips its own battle text.
|
||||
-- victories.lua OPP_GIOVANNI#2 sets the event on a win; a loss sets
|
||||
-- nothing, so the trigger re-arms exactly as vanilla does.
|
||||
onStep = function(game, ow, x, y)
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return false end
|
||||
if not ((x == 6 and y == 13) or (x == 7 and y == 12)) then return false end
|
||||
@@ -811,21 +815,28 @@ M.SILPH_CO_11F = {
|
||||
if npc.def and npc.def.name == "SILPHCO11F_GIOVANNI" then gio = npc break end
|
||||
end
|
||||
if not gio or ow:trainerDefeated(gio) then return false end
|
||||
ow:scriptMove(gio, "down", 3, function()
|
||||
gio:facePlayer(ow.player)
|
||||
ow:engageTrainer(gio, function()
|
||||
-- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!" speech,
|
||||
-- then SilphCo11FTeamRocketLeavesScript behind a fade so every Silph
|
||||
-- rocket leaves off-screen (the street rockets are handled by
|
||||
-- M.SAFFRON_CITY.onEnter in story4.lua). Queued, not run here: the
|
||||
-- battle's own callbacks are still unwinding, so queueScript starts
|
||||
-- it on the first idle overworld frame -- after the end-battle
|
||||
-- "Arrgh!!" box victories.lua OPP_GIOVANNI#2 pushes (#722).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
ow:queueScript(silphAftermathRows())
|
||||
end
|
||||
end)
|
||||
end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._SilphCo11FGiovanniText
|
||||
or "Ah {PLAYER}!\nSo we meet again!",
|
||||
function()
|
||||
ow:scriptMove(gio, "down", 3, function()
|
||||
gio:facePlayer(ow.player)
|
||||
ow:engageTrainer(gio, function()
|
||||
-- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!"
|
||||
-- speech, then SilphCo11FTeamRocketLeavesScript behind a fade so
|
||||
-- every Silph rocket leaves off-screen (the street rockets are
|
||||
-- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not
|
||||
-- run here: the battle's own callbacks are still unwinding, so
|
||||
-- queueScript starts it on the first idle overworld frame --
|
||||
-- after the end-battle "Arrgh!!" box victories.lua OPP_GIOVANNI#2
|
||||
-- pushes (#722).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
ow:queueScript(silphAftermathRows())
|
||||
end
|
||||
end, nil, true)
|
||||
end)
|
||||
end))
|
||||
return true
|
||||
end,
|
||||
onEnter = function(game, ow)
|
||||
@@ -1056,23 +1067,18 @@ local championsRoomRivalScript = {
|
||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
|
||||
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
|
||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
|
||||
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement
|
||||
-- (PAD_UP 4, PAD_LEFT 1): the player walks out after Oak instead of the
|
||||
-- screen just fading on the spot (#704). The entrance walk leaves the
|
||||
-- player at (4,3) and both north-wall warps sit on row 0, so the original
|
||||
-- only ever spends three of those simulated steps -- CheckWarpsNoCollision
|
||||
-- takes the HALL_OF_FAME warp the moment the walk lands on (4,0) and the
|
||||
-- trailing UP/LEFT are dropped. Scripted steps ignore collision here just
|
||||
-- as they do in the original (CollisionCheckOnLand skips its checks while
|
||||
-- wSimulatedJoypadStatesIndex is non-zero), so stepping through the
|
||||
-- rival's cell at (4,2) is the ported behavior, not a clip. Re-reported
|
||||
-- as a clip in #847 and re-checked against home/overworld.asm
|
||||
-- CollisionCheckOnLand, which is still the authority: do not "fix" it.
|
||||
{ "move_player", "up", 3 }, -- 26
|
||||
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement.
|
||||
-- The player walks out after Oak instead of the screen just fading on the
|
||||
-- spot (#704). Route one tile right before walking north so the player
|
||||
-- reaches the north-wall HALL_OF_FAME warp without sharing the rival's
|
||||
-- (4,2) cell. The original simulated movement bypasses entity collision,
|
||||
-- but this scene should not visibly walk through the defeated rival.
|
||||
{ "move_player", "right", 1 }, -- 26
|
||||
{ "move_player", "up", 3 }, -- 27
|
||||
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
||||
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
|
||||
{ "set_field", "pendingHallOfFame", true }, -- 27
|
||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 28
|
||||
{ "set_field", "pendingHallOfFame", true }, -- 28
|
||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 29
|
||||
}
|
||||
|
||||
M.CHAMPIONS_ROOM = {
|
||||
|
||||
@@ -151,9 +151,27 @@ M.POKEMON_TOWER_6F = {
|
||||
-- trick, and the speedrun route this bot follows depends on it.
|
||||
if result == "win" or battle.pokeDollEscape then
|
||||
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._PokemonTower6FSoulWasCalmedText
|
||||
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!"))
|
||||
-- PokemonTower6FMarowakDepartedText (scripts/PokemonTower6F.asm)
|
||||
-- is two texts, not one: the CUBONE's-mother line first, then
|
||||
-- PlayCry RESTLESS_SOUL (EQU MAROWAK, constants/pokemon_constants
|
||||
-- .asm:209) + WaitForSoundToFinish + DelayFrames 30 before the
|
||||
-- calmed line; the port dropped the first text and the cry
|
||||
-- (#867). play_cry arms the next show_text, so the cry rides
|
||||
-- the calmed box's open with the button prompt kept, and the
|
||||
-- wait row stands in for the asm's 30-frame gap.
|
||||
local rows = {
|
||||
{ "show_text", t._PokemonTower6FGhostWasCubonesMotherText
|
||||
or "The GHOST was the\nrestless soul of\vCUBONE's mother!" },
|
||||
{ "play_cry", "MAROWAK", true },
|
||||
{ "wait", 30 },
|
||||
{ "show_text", t._PokemonTower6FSoulWasCalmedText
|
||||
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!" },
|
||||
}
|
||||
if ow.runner then
|
||||
ow.runner:run(rows)
|
||||
elseif ow.queueScript then
|
||||
ow:queueScript(rows)
|
||||
end
|
||||
elseif result ~= "lose" then
|
||||
-- .did_not_defeat: one simulated step right, off the trigger,
|
||||
-- so fleeing does not leave you standing on a cell that
|
||||
|
||||
@@ -38,7 +38,7 @@ the same core data and graphics into the source tree for verification.
|
||||
| | `src/core/SaveData.lua` | Lua-serialized save in the LÖVE save dir |
|
||||
| render | `src/render/Renderer.lua` | 160x144 canvas, integer nearest scaling |
|
||||
| | `src/render/TileRenderer.lua` | one SpriteBatch per map (8x8 quads) + border-block ring |
|
||||
| | `src/render/SpriteRenderer.lua` | 6-frame walker sheets, flipped right facing |
|
||||
| | `src/render/SpriteRenderer.lua` | variable-size anchored sprite sheets, 6-frame walkers and flipped right facing |
|
||||
| | `src/render/Font.lua` | glyph rendering via charmap (greedy longest match) |
|
||||
| | `src/render/TextBox.lua` | dialogue box: typewriter, `\n` line, `\v` scroll, `\f` page |
|
||||
| | `src/render/Camera.lua`, `Transition.lua` | follow camera, warp fades |
|
||||
|
||||
@@ -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`.
|
||||
@@ -35,6 +35,16 @@ An edited vanilla map becomes a `mod.content.maps:patch` carrying only the
|
||||
fields that moved; a new map becomes a `:register`. See
|
||||
`docs/new-features.md` and the extension's own README.
|
||||
|
||||
## Read-only map overviews
|
||||
|
||||
`mod.world:mapOverview()` returns collision `rows` at map-cell resolution,
|
||||
optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
|
||||
4x resolution. Visual rows contain Game Boy shades from `"0"` (lightest) to
|
||||
`"3"` (darkest); their matching width and height fields describe the grid.
|
||||
`markers` contains active `{ kind, x, y }` points in map-cell coordinates for
|
||||
`warp`, visible `item`, and untaken `hidden` locations. All fields are
|
||||
read-only snapshots; mods choose which layers to render.
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||
@@ -100,6 +110,44 @@ Three rules worth knowing:
|
||||
Returning `nil` from `drawWorld` is a normal answer meaning "not this
|
||||
frame"; the engine draws the vanilla world instead.
|
||||
|
||||
## Variable-size overworld sprites
|
||||
|
||||
The `sprites` registry keeps the vanilla 16x16 grounded walker as its default,
|
||||
but a mod can describe any frame rectangle and anchor for player characters,
|
||||
NPCs, followers, mounts, vehicles, bosses, or other field actors:
|
||||
|
||||
```lua
|
||||
mod.content.sprites:register("SPRITE_COMPANION", {
|
||||
image = "mods/example/companion.png", -- one frame per row
|
||||
frames = 6,
|
||||
walker = true,
|
||||
frameWidth = 32,
|
||||
frameHeight = 32,
|
||||
anchorX = 16, -- frame-relative bottom-center anchor
|
||||
anchorY = 32,
|
||||
})
|
||||
```
|
||||
|
||||
`frameWidth` and `frameHeight` are sheet pixels. `anchorX` and `anchorY` are
|
||||
measured from each frame's top-left; when omitted they default to the frame's
|
||||
horizontal center and bottom edge, so a larger sprite grows upward while its
|
||||
feet stay on the same world cell. Omitting all four fields is exactly the
|
||||
vanilla 16x16 placement. The normal player/NPC/follower draw paths consume
|
||||
these values automatically, including horizontal flips and the fishing pose.
|
||||
|
||||
Custom render pipelines can use the same geometry without reproducing the
|
||||
pose rules:
|
||||
|
||||
```lua
|
||||
local geometry = sprite:getPoseGeometry(facing, walkPhase, stepFlip)
|
||||
-- geometry.quad, .x/.y/.width/.height, .anchorX/.anchorY, .mirror
|
||||
local originX, originY = sprite:getScreenOrigin(px, py, camX, camY)
|
||||
```
|
||||
|
||||
`getFrameGeometry(frame)` is the corresponding accessor for a specific
|
||||
zero-based sheet frame. Both accessors return fresh tables and share the
|
||||
renderer’s frame selection and mirror conventions.
|
||||
|
||||
## Battle sprite scaling
|
||||
|
||||
The enemy's front pic draws at 1x and the player's back pic at 2x, the way
|
||||
@@ -140,6 +188,90 @@ default** (1x front, 2x back).
|
||||
ball-to-pic grow multiplies your scale through each stage, so a rescaled
|
||||
mon still grows into place from the ball, grounded the whole way.
|
||||
|
||||
## Durable tool storage and runtime checkpoints
|
||||
|
||||
`mod.save` remains the right place for state that should travel with the next
|
||||
normal Pokémon SAVE. Tools that need independently written, larger data-only
|
||||
records can use `mod.storage`; the engine scopes every logical key by game
|
||||
version, opaque playthrough identity, and mod id, and routes it through the same
|
||||
standard or portable persistence backend as saves:
|
||||
|
||||
```lua
|
||||
local context, code, message = mod.storage:context(game)
|
||||
local ok, code, message = mod.storage:write(game, "history/quick/q0001", {
|
||||
format = 1, createdAt = os.time(), payload = { money = 3000 },
|
||||
})
|
||||
local value, code, message = mod.storage:read(game, "history/quick/q0001")
|
||||
local keys, code, message = mod.storage:list(game, "history/quick")
|
||||
local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
|
||||
```
|
||||
|
||||
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
|
||||
version is compatibility metadata; physical launcher-slot and path identity stays
|
||||
private.
|
||||
|
||||
Values must be tables containing serializable data only. Keys are conservative
|
||||
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem
|
||||
handles are never exposed. Writes are staged and decode-verified, reads recover
|
||||
from a valid staged/backup generation, and methods return structured errors for
|
||||
normal data or I/O failures. The playthrough identity is allocated lazily on the
|
||||
first storage/checkpoint call, so an unused API changes no save bytes.
|
||||
|
||||
`mod.checkpoints` captures and reconstructs engine-owned semantic runtime state:
|
||||
|
||||
```lua
|
||||
local capability = mod.checkpoints:inspect(game)
|
||||
if capability.canCapture then
|
||||
local checkpoint, code, message = mod.checkpoints:capture(game)
|
||||
-- Store the detached data-only checkpoint through mod.storage.
|
||||
end
|
||||
|
||||
local ok, code, message = mod.checkpoints:restore(game, checkpoint)
|
||||
```
|
||||
|
||||
Checkpoint format 1 supports settled overworld control and proven battle
|
||||
player-decision safe points. Battle checkpoints are limited to ordinary
|
||||
single-player wild/trainer origins with no suspended script; link, Safari,
|
||||
ghost, demo, scripted, animation, message, queue, and forced-action phases fail
|
||||
closed. New checkpoints preserve gameplay RNG, while legacy overworld records
|
||||
without RNG remain loadable. Capture excludes global options and runtime
|
||||
objects. Restore validates format, game/playthrough identity, content,
|
||||
coordinates, battle relationships, continuation, and RNG before mutation;
|
||||
preserves current options; suppresses normal map-entry/save-load/intro side
|
||||
effects; verifies a recapture; and rolls back runtime plus RNG in memory if
|
||||
reconstruction fails. Callers that need crash recovery should durably capture
|
||||
their own recovery checkpoint before restore.
|
||||
|
||||
Checkpoint ownership follows the persistence model rather than mod identity:
|
||||
|
||||
- canonical `game.save` progress, including every mod's `save.modData` /
|
||||
`mod.save` bucket and data-only fields added to saved Pokémon, rewinds;
|
||||
- global and per-mod options remain at their current values;
|
||||
- independently written `mod.storage` records do not rewind; and
|
||||
- mod-owned runtime objects, references, and caches are never serialized.
|
||||
|
||||
Successful restore emits `checkpoint.restored` only after reconstruction and
|
||||
differential recapture have committed. Mods that cache rewound progress or hold
|
||||
references to reconstructed runtime objects can re-read their own public state
|
||||
and rebuild at that point:
|
||||
|
||||
```lua
|
||||
mod.events:on("checkpoint.restored", function(ev)
|
||||
-- ev.kind is "overworld" or "battle"; ev.game is fully reconstructed.
|
||||
cachedQuestStage = mod.save:get("quest_stage", 0)
|
||||
rebuildRuntimeFor(ev.game, ev.kind)
|
||||
end)
|
||||
```
|
||||
|
||||
The event is not emitted for validation failure, failed reconstruction, or a
|
||||
successful rollback. Its payload contains no checkpoint data or other mod's
|
||||
private state. A mod that deliberately stores progress-coupled truth in
|
||||
`mod.storage` must version and reconcile that relationship itself; the engine
|
||||
cannot distinguish it safely from independent history, configuration, or cache
|
||||
data.
|
||||
|
||||
See RFC 0003, RFC 0004, and RFC 0005 for exact contracts and error codes.
|
||||
|
||||
## Developer console
|
||||
|
||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||
@@ -221,10 +353,76 @@ the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
|
||||
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
|
||||
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
|
||||
palette-correct blit of either canvas into an arbitrary screen rect, and the
|
||||
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`)
|
||||
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.
|
||||
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `pollTouch()` /
|
||||
`setEnabled`) for driving a second physical display. `pollTouch()` returns the
|
||||
oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`.
|
||||
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.
|
||||
|
||||
Scrollable list states expose `state.kind` for use with this hook. Generic
|
||||
lists fall back to their title; PC lists use stable, localization-independent
|
||||
identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
|
||||
`pc_box_change`, `pc_item_withdraw`, `pc_item_deposit`, and `pc_item_toss`.
|
||||
|
||||
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently
|
||||
control the battle text/menu layer and the HP/status panels. Both receive
|
||||
`(next, state)` and default to `true`, so vanilla rendering is unchanged.
|
||||
Pushed text boxes also pass through `battle.bottom_ui_visible`; a wrapper that
|
||||
only owns battle presentation should return `false` only for its active battle
|
||||
or text-box state.
|
||||
|
||||
`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call
|
||||
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
|
||||
option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever
|
||||
category `Game.speedCategoryInStack` says is active right now. A mod may
|
||||
call `next(game)` and return its result to pass that resolution through, or
|
||||
return a different number outright to override it for that frame (a bot mod
|
||||
forcing 1X for one route segment, say, regardless of the category or saved
|
||||
option). The result is clamped to the nearest valid `GameSpeed.LEVELS` entry
|
||||
regardless of what a subscriber returns, so a bad value (0, negative, `nil`)
|
||||
cannot destabilize the fixed-step accumulator. This hook runs *after* link
|
||||
play's 1X lock and the `--speed`/equivalent run-argument override, both of
|
||||
which stay unconditional and are never visible to a subscriber.
|
||||
|
||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||
that reach outside their permission set.
|
||||
|
||||
## Process-lifecycle hooks
|
||||
|
||||
These exist so a platform-specific launcher integration (a native shell
|
||||
that embeds this engine and wraps its window in platform UI) can live
|
||||
entirely in a mod instead of hand-patching `main.lua`, which every other
|
||||
engine change also touches.
|
||||
|
||||
`core.update` receives `(next, game, dt)` once per frame from
|
||||
`love.update`. Vanilla behavior is `game:update(dt)`, unconditionally. A
|
||||
mod may skip calling `next(game, dt)` to pause the simulation for that
|
||||
frame (e.g. while a native settings sheet is on top), and may run
|
||||
additional per-frame polling before or after that call regardless of
|
||||
whether it calls `next` -- useful for one-shot flags that must be observed
|
||||
every frame even while paused.
|
||||
|
||||
`core.quit_to_launcher` receives `(next)` once from `love.quit()`. `next()`
|
||||
returns the engine's own decision for whether closing the window should
|
||||
return to the Lua launcher instead of exiting; a mod may return `false`
|
||||
outright, without ever calling `next`, to veto that and let the process
|
||||
really quit -- for a platform host that owns its own "return to launcher"
|
||||
UI and would otherwise get looped straight back into the game it just
|
||||
quit.
|
||||
|
||||
A manifest may also declare `force_enable_env`, an environment variable
|
||||
name that re-enables the mod regardless of a saved disable in
|
||||
`options.mods` when that variable is set to `"1"`. This is for a mod that
|
||||
cannot function disabled on the one build where its env var is set (a
|
||||
platform-bridge mod bundled only with that build's launcher, for example).
|
||||
|
||||
Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call`
|
||||
already falls straight through to the vanilla function when no mod has
|
||||
wrapped the name, at negligible cost.
|
||||
|
||||
@@ -1,634 +1,26 @@
|
||||
# New features (deliberate additions beyond the original)
|
||||
|
||||
Intentional enhancements this port adds on top of faithful Pokémon Red, Blue,
|
||||
and Yellow behavior. They have no Game Boy equivalent and are kept by design.
|
||||
Genuine divergences from the original (things still missing, wrong, or
|
||||
approximated) live in docs/known-differences.md; faithfully-ported behavior is
|
||||
in docs/behavior-porting-notes.md.
|
||||
|
||||
## Survey zoom
|
||||
|
||||
The mouse wheel (or `-`/`=`), the Options **ZOOM** row, or hotkey `4`
|
||||
zooms the overworld between 1 pixel per world pixel (full survey) and 2×
|
||||
the window fit scale (close-up), in crisp integer steps. This has no Game
|
||||
Boy equivalent:
|
||||
|
||||
- Connected maps render their full bodies, and their NPCs appear as
|
||||
visual-only "ghosts", they wander but have no sight lines, triggers,
|
||||
dialogue, or collision until the map is actually entered.
|
||||
- Menus, text boxes, and battles draw at normal scale on top of the
|
||||
zoomed world. Zoom input is ignored while a script, menu, or battle is
|
||||
active; the zoom offset is persisted as `save.options.zoom` (default
|
||||
`0` = FIT) and survives New Game via `options.lua`.
|
||||
- Hotkey `4` ticks through every integer zoom level (survey → FIT →
|
||||
close-up → wrap). The Options row shows `FIT` / `OUTn` / `INn`.
|
||||
- Beyond the border ring the void fill repeats indefinitely (see VOID
|
||||
FILL below); interiors keep their own border block. Each visible map
|
||||
area is colorized with its own SGB palette (the original recolored the
|
||||
whole screen per map).
|
||||
- Neighbor maps load two connection hops out so corner-adjacent maps
|
||||
don't pop in and out, and ghost NPCs share instances with the real ones
|
||||
so their wander positions persist across seamless connection crossings
|
||||
(a warp or fresh map entry still respawns everything at its script
|
||||
position, like the original's per-entry sprite init).
|
||||
|
||||
## VOID FILL
|
||||
|
||||
The Options **VOID FILL** row picks what paints the infinite beyond-edge
|
||||
space on OVERWORLD-tileset maps during survey zoom:
|
||||
|
||||
- **TREES** (default): solid tree wall block `$0F`.
|
||||
- **WATER**: animated water tile `$14` (same hshift cycle as on-map water).
|
||||
- **BLACK**: solid black.
|
||||
|
||||
Other tilesets are unchanged (house/cave borders stay as authored).
|
||||
Persisted as `save.options.voidFill`.
|
||||
|
||||
## Tilt mode
|
||||
|
||||
The `3` key (and the Options menu TILT row) cycles a visual-only perspective
|
||||
tilt of the overworld through **OFF → 15° → 35° → 50° → OFF** for an HD-2D /
|
||||
diorama look. Like survey zoom this is purely presentational and has no
|
||||
Game Boy equivalent:
|
||||
|
||||
- The entire map tilts as one rigid ground plane, paths, grass, water,
|
||||
floors, and every background-tile structure (buildings, trees, fences,
|
||||
signs; in Gen 1 these are baked into the tile layer, not sprites), so
|
||||
rows above the player recede and rows below come toward the viewer. Only
|
||||
things that actually *stand* on the ground draw as upright billboards,
|
||||
unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
|
||||
and the standing FX attached to them (emote bubbles, the fishing rod,
|
||||
the FLY bird). The Poké Center heal-machine overlay stays on the ground
|
||||
plane with the machine tiles (it is OAM glued to a BG graphic, not a
|
||||
standing sprite). An earlier revision tried
|
||||
billboarding buildings/trees/signs too (cutting them out of the ground
|
||||
per hand-curated per-tileset tables); that chased an endless tail of
|
||||
special cases, dense tree canopy, fences fused into grass, building
|
||||
facades with their own baked-in fake perspective, because Gen 1's art
|
||||
was never drawn with a clean seam between ground and standing scenery. It
|
||||
wasn't merged; tilting everything but the characters as one plane is the
|
||||
simpler, shipped tradeoff (buildings recede/foreshorten with the ground
|
||||
like a photo of a diorama, rather than standing fully upright next to
|
||||
a full-height character).
|
||||
- Cycling tweens the angle between levels over ~0.25s rather than snapping;
|
||||
with tilt fully off the world pass drops back onto the flat blit path, so
|
||||
flat rendering stays pixel-identical to tilt-off and off costs nothing.
|
||||
- Tilt input is gated exactly like survey zoom, honored only while
|
||||
free-roaming, ignored while a script, menu, or battle is active, and it
|
||||
composes with survey zoom (the zoom scale feeds the projection). The tilt
|
||||
level is persisted in `save.options.tilt` (default OFF).
|
||||
- It applies everywhere the overworld draws, interiors and caves included.
|
||||
Menus, text boxes, and battles render flat on top, unaffected, and the
|
||||
infinite beyond-the-border-ring fill stays flat by design.
|
||||
- Collision, movement, sight lines, triggers, encounters, and scripts are
|
||||
untouched; nothing about the tilt reaches gameplay.
|
||||
|
||||
## Colors mode
|
||||
|
||||
The `2` key (and the Options menu COLORS row) cycles the display mode
|
||||
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**
|
||||
(on Blue the first slot labels **OG BLUE**; on Yellow, **OG YELLOW**).
|
||||
The first three are the real colorizations; the rest are DMG-shade novelties:
|
||||
|
||||
- **OG RED** / **OG BLUE**: the Game Boy Color boot-ROM look for that cart --
|
||||
one global BG palette + one OBJ palette, every map, no per-map variation
|
||||
(Red/Blue ship no CGB code, so on a GBC the boot ROM colors them globally).
|
||||
The player/NPCs keep the boot-ROM OBJ color over the terrain via the OBP
|
||||
bake + post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`, or Blue's blue/pink
|
||||
pair).
|
||||
- **OG YELLOW** (Yellow playthrough, same `ogred` save id): Pokemon Yellow's
|
||||
authentic GBC look from `CGBBasePalettes` (`data/palettes_yellow.lua`,
|
||||
sourced from pret/pokeyellow). Per-map / per-species colors, not a single
|
||||
boot-ROM ramp -- Yellow was CGB-enhanced.
|
||||
- **SGB** (default): the per-map Super Game Boy region palettes
|
||||
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
|
||||
real SGB. (This is the mode formerly mislabeled "GBC".)
|
||||
- **ADVANCED**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
|
||||
per-species mon colors (`data/palettes_gbc.lua`). (Formerly labeled
|
||||
"RED++"; it is the richest colorization rather than anything Red-specific.)
|
||||
- **OG**: force the four DMG grays (colorization off).
|
||||
- **OG INV**: inverted DMG grays.
|
||||
- **SGB INV**: each SGB zone palette with shade order reversed.
|
||||
- **CLASSIC**: original Game Boy pea-soup greens
|
||||
(`#9BBC0F` / `#8BAC0F` / `#306230` / `#0F380F`).
|
||||
|
||||
The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so
|
||||
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
|
||||
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
|
||||
override in the overworld pass). Persisted as `save.options.colors`; the
|
||||
`gbc` / `gbc_inv` / `redpp` save ids are kept for back-compat under the new
|
||||
labels.
|
||||
|
||||
## GBC FX
|
||||
|
||||
The `5` key (and the Options menu GBC FX row) cycles a "played on real
|
||||
unlit-GBC hardware" post-process through **OFF → 1 → 2 → 3 → 4**. The
|
||||
levels are a cumulative ladder:
|
||||
|
||||
- **1**: reflective-screen backing transparency.
|
||||
- **2**: + LCD pixel grid.
|
||||
- **3**: + pixel drop shadows.
|
||||
- **4**: + sunlight glare and rainbow shimmer with a drifting light.
|
||||
|
||||
It runs as a final present pass after world + UI composite in
|
||||
`Renderer:endFrame`, inspired by the Pixel Transparency RetroArch shader
|
||||
([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)).
|
||||
Default OFF; persisted as `save.options.gbcfx`.
|
||||
|
||||
Mobile GPUs often compile the pass but present a black frame, so Android and
|
||||
iOS hide the row entirely, pin the level to OFF, and rewrite a level already
|
||||
persisted in `options.lua` (issue #136). `POKEPORT_GBCFX` overrides that
|
||||
decision either way, same tri-state as `POKEPORT_TOUCH`: `=0` refuses the
|
||||
effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
|
||||
its launcher because the device reports `"Linux"` while its GPU is in the
|
||||
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
|
||||
|
||||
## Performance tier (low-end devices)
|
||||
|
||||
The Options **PERFORMANCE** row scales the port's optional presentation
|
||||
extras down for weaker hardware. The extras it governs are the three
|
||||
heaviest things the port adds on top of the original -- the whole-screen 3D
|
||||
**TILT** (transforms the entire map as a ground plane), the **GBC FX**
|
||||
post-process shader (a fullscreen pass), and survey **ZOOM** (zooming out
|
||||
renders the connected neighbor maps, a lot of extra overdraw) -- plus a hard
|
||||
FPS ceiling. None of this touches game logic, which is fixed-step off `dt`
|
||||
(`src/core/FixedStep.lua`), so every tier plays identically; they differ
|
||||
only in how much eye-candy the renderer is allowed to do.
|
||||
|
||||
| Tier | TILT | GBC FX | Survey ZOOM | Extra FPS ceiling |
|
||||
| ------------ | ---- | ------ | ----------- | ----------------- |
|
||||
| **HIGH** | on | on | on | none |
|
||||
| **BALANCED** | off | off | on | none |
|
||||
| **LOW** | off | off | off | 60 |
|
||||
| **AUTO** | picks a default from the device (below) |||
|
||||
|
||||
- **AUTO** (the default) reads the device once at boot: ARM Linux handhelds
|
||||
(e.g. the RG34XXSP) resolve to **LOW**, phones/tablets and very-low-core
|
||||
desktops to **BALANCED**, and everything else -- a normal desktop, and
|
||||
every existing `options.lua` that predates this option -- to **HIGH**,
|
||||
so the common case is unchanged. See `src/core/Performance.detect`.
|
||||
- AUTO only chooses the *default*; all four tiers are selectable, so a
|
||||
wrong guess is one row away from being overridden.
|
||||
- The clamps are applied **live** against your stored options and never
|
||||
rewrite them (`Game:applyOptions`), so a lower tier hides your TILT / GBC
|
||||
FX / ZOOM without forgetting them -- raising the tier restores exactly
|
||||
what you had. (This is why the TILT / GBC FX / ZOOM rows still show your
|
||||
saved choice on a clamped tier: it's your preference, waiting for a tier
|
||||
that can afford it.)
|
||||
- Persisted as `save.options.performance` (`auto` | `high` | `balanced` |
|
||||
`low`); unit-tested in `tests/engine/performance_tiers.lua`.
|
||||
|
||||
## Peer-to-peer link play (lua-enet)
|
||||
|
||||
Trades and link battles connect two copies of the game directly over
|
||||
lua-enet (ENet ships inside LÖVE, nothing to install, no server to run)
|
||||
on a reliable-ordered channel, replacing the original standalone Python
|
||||
room-code relay (`tools/relay_server.py`, deleted). HOST A GAME shows the
|
||||
host's LAN address (UDP 7777; `POKEPORT_LINK_PORT` overrides); JOIN A
|
||||
GAME enters it. Closing performs a graceful ENet disconnect so the final
|
||||
confirm/bye always lands; a vanished peer exits with "The link was
|
||||
broken." Internet play needs a forwarded UDP port or a VPN (deliberate
|
||||
tradeoff vs. the relay). Headless tests drive the protocol over an
|
||||
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
|
||||
also exercises real UDP pairing.
|
||||
|
||||
Red, Blue, and Yellow copies link with each other, as the real cable
|
||||
does. The compatibility fingerprint hashes only data a link mode can
|
||||
actually read, so Yellow's Dragonair/Dragonite catch-rate retunes (the
|
||||
only R/B/Y link-surface difference) no longer read as different games
|
||||
(issue #511). Moving the fingerprint is a link parity change: builds
|
||||
from before this fix will refuse to pair with builds after it.
|
||||
|
||||
## Fair play in link and online matches
|
||||
|
||||
A link session is decided by the battle and nothing else, so for its
|
||||
duration:
|
||||
|
||||
- **Game speed is pinned to normal.** The GAME SPEED option and
|
||||
`POKEPORT_SPEED` are ignored from the moment LINK PLAY opens until it
|
||||
closes, and apply again after. Fast-forward otherwise runs one peer's
|
||||
queue faster than the peer it is locked to and drains a tournament shot
|
||||
clock faster than the opponent racing it.
|
||||
- **Online play runs vanilla, except for your language.** Picking ONLINE
|
||||
MATCH or TOURNAMENT with mods enabled offers to switch the gameplay ones
|
||||
off and relaunch (mods merge at boot, so a restart is the only way). The
|
||||
restart is confirmed, not silent. They stay listed as disabled, ready to
|
||||
switch back on. A mod that declares itself a translation and provably
|
||||
writes nothing but text stays on: the two games hash the same link
|
||||
surface, so a Spanish install and an English one can battle and trade,
|
||||
each reading the game in its own language and naming the other player's
|
||||
party out of its own text.
|
||||
- **Only a meaningful split ends a match.** The per-turn state signature
|
||||
both peers exchange is split three ways: `actives` and `bench` carry
|
||||
species, HP, status, stat stages, PP and the rest of the party, and a
|
||||
divergence there ends the match as a draw. `volatile` carries per-turn
|
||||
flags both sides recompute anyway - a divergence there is logged and
|
||||
reported to mods, and play continues.
|
||||
|
||||
The relay logs which component diverged on which turn, so a desync report
|
||||
names something specific.
|
||||
|
||||
## Custom boot text
|
||||
|
||||
The boot sequence replaces the Nintendo / GAME FREAK identifiers with
|
||||
"bois club" / "bryanthaboi", a deliberate branding customization. The
|
||||
rest of the boot beats (copyright splash, "presents" shooting-star, the
|
||||
Nidorino-vs-Gengar attract scene) mirror the original.
|
||||
|
||||
|
||||
## Custom Options
|
||||
|
||||
Options persist in a standalone `options.lua` (separate from the game
|
||||
progress `save.lua`), so audio/display/battle preferences survive New Game
|
||||
and aren't wiped when a save slot is cleared. Changing a row in the Options
|
||||
menu or cycling hotkeys `2`/`3`/`4`/`5` writes immediately; an in-game save also
|
||||
flushes the live options. Old saves that still embed an `options` table are
|
||||
migrated once into `options.lua` on load.
|
||||
|
||||
- Music / SFX volume
|
||||
- PIKACHU VOL (0-7, Yellow only): trims Pikachu's PCM voice clips under the
|
||||
SFX level, so the follower's constant chatter, the title-screen cry and
|
||||
every in-battle "Pika!" can be pulled down (or muted at 0) without
|
||||
quieting the rest of the sound effects. The row is hidden on Red/Blue,
|
||||
which have no voice clips.
|
||||
- Music Filter
|
||||
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
||||
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
|
||||
- COLORS (OG RED / SGB / ADVANCED / OG / OG INV / SGB INV / CLASSIC), also
|
||||
hotkey `2` (OG RED = GBC boot-ROM look; ADVANCED uses pokered-gbc
|
||||
SuperPalettes + per-species mon colors)
|
||||
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
|
||||
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
|
||||
`-`/`=` step one level and save
|
||||
- VOID FILL (TREES / WATER / BLACK) for OVERWORLD beyond-edge space
|
||||
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
|
||||
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
|
||||
a hard render frame-rate cap (`save.options.fpsCap`).
|
||||
|
||||
## Battle transition cascade + white battle letterbox
|
||||
|
||||
Into-battle wipes still run the original eight styles inside the classic
|
||||
160×144 letterbox. On wide/tall windows (survey zoom), matching black 8×8
|
||||
blocks cascade outward from that square into the surrounding world so the
|
||||
void outside the OG wipe fills in lockstep. Once the battle state is up,
|
||||
letterbox voids around the battle canvas fill **white** instead of black
|
||||
so the whole window reads as one continuous battle screen.
|
||||
|
||||
## Widescreen battle layout
|
||||
|
||||
Options **BATTLE LAYOUT** picks the battle screen's composition: **OG**
|
||||
(the default: the original 160×144 arrangement, unchanged) or **WIDE**,
|
||||
which gives battles a 304×144 native-pixel surface and a Gen 3-style
|
||||
arrangement on it:
|
||||
|
||||
- the foe's status box upper left, the foe's picture upper right;
|
||||
- the player's picture lower left, the player's status box lower right,
|
||||
with a longer HP bar and the numeric HP under it;
|
||||
- a full-width message window;
|
||||
- a split "What will X do?" prompt / 2×2 command window;
|
||||
- a 2×2 move menu, navigated with all four directions, with a PP and type
|
||||
panel attached to its right.
|
||||
|
||||
Only the composition changes. Pictures, palettes, HP-bar colors, font
|
||||
pages, window borders, sounds, animations, timing and every battle rule
|
||||
stay the engine's, so a COLORS mode or an asset mod still owns the look.
|
||||
Each side's picture keeps its original pixels and placement math and is
|
||||
composited into its own region of the wider battlefield -- nothing is
|
||||
scaled or squeezed -- and animations, which are authored in the original
|
||||
160-pixel space, shift as one rigid group onto whichever side they play
|
||||
on. The whole screen is drawn at the window's integer fit scale for the
|
||||
wider surface, so a 304-pixel screen is drawn a step smaller than a
|
||||
160-pixel one in the same window.
|
||||
|
||||
The wide surface is live only while the battle itself is the screen on
|
||||
top: a party menu, the bag or a nickname prompt is a 160×144 screen and
|
||||
brings the classic surface back with it.
|
||||
|
||||
## On-screen touch controls (mobile)
|
||||
|
||||
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
|
||||
buttons (bottom-right, Game Boy diagonal), and +/- START/SELECT (bottom
|
||||
center) over the frame, using Xelu's CC0 controller prompts
|
||||
(`assets/touch/`). Real buttons, not gestures: press lands the frame the
|
||||
finger does, sliding on the d-pad changes direction without lifting, and
|
||||
multi-touch chords (e.g. hold a direction + tap B) work. The overlay only
|
||||
appears while no controller is being used: the first gamepad button or
|
||||
stick push hides it, the next screen touch brings it back, and unplugging
|
||||
the last controller restores it immediately. Layout re-derives from the
|
||||
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
|
||||
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
||||
|
||||
The launcher's **Touch Controls** button opens a drag editor: move each
|
||||
button freely, resize the whole pad with **-/+** (60% to 160%), **Disable**
|
||||
to hide the overlay permanently (for controllers / emulation handhelds --
|
||||
distinct from the temporary gamepad auto-hide), **Reset** for defaults,
|
||||
**Done** to save into `options.lua` as normalized window fractions so a
|
||||
different screen keeps the relative placement.
|
||||
|
||||
Portrait and landscape are edited and saved separately (#633): the editor
|
||||
follows whichever orientation is on screen, and **Reset** only clears that
|
||||
one, so a layout that works held upright does not have to double as the
|
||||
one used sideways. An `options.lua` from before this split keeps its single
|
||||
layout in both orientations until one of them is edited. In-game, Options →
|
||||
**TOUCH PAD** toggles the same on/off flag without leaving a play session.
|
||||
|
||||
## Haptic feedback (mobile)
|
||||
|
||||
Options → **VIBRATION** (also in the launcher's gear menu) buzzes the device
|
||||
the instant an on-screen control takes a button (#806). A glass pad has no
|
||||
edges under a thumb, so the pulse is what tells you the press landed:
|
||||
sliding the d-pad from one direction to the next buzzes again, a second
|
||||
finger landing on a button that is already held does not, and releasing
|
||||
never does.
|
||||
|
||||
Four levels: **OFF**, **LIGHT** (the default), **MEDIUM**, **HEAVY**.
|
||||
"Intensity" is really a pulse length -- the platform call takes a duration
|
||||
and nothing else -- so LIGHT is a 12 ms tick, MEDIUM 25 ms, HEAVY 45 ms.
|
||||
Stepping the row fires one sample pulse at the level you land on, so the
|
||||
three can be compared without leaving the menu. On iOS the system
|
||||
vibration has one fixed length, so all three levels feel the same there and
|
||||
the row is effectively on/off. The setting lives in `options.lua` and the
|
||||
row only appears where the on-screen pad can (Android/iOS, or desktop with
|
||||
`POKEPORT_TOUCH=1`, where it does nothing since desktop LOVE has no
|
||||
vibrator).
|
||||
|
||||
## Screen orientation lock (Android)
|
||||
|
||||
Options → **ORIENTATION** (also in the launcher's gear menu) locks the
|
||||
screen to **PORTRAIT**, **LANDSCAPE** (either landscape, following the
|
||||
device), or **REVERSE LANDSCAPE**, or leaves it on **AUTO** (#592). AUTO
|
||||
allows every orientation but defers to the system: with auto-rotate turned
|
||||
off in Android's quick settings, the game stays put instead of following
|
||||
the sensor (#716). Changes apply immediately -- the screen rotates as the
|
||||
row is stepped -- and persist in `options.lua`. Android only: iOS follows
|
||||
the app's fixed orientation list, and desktop windows rotate nothing.
|
||||
|
||||
## Translation support
|
||||
|
||||
Every string the player can read is now reachable from a mod, so a
|
||||
translation is an ordinary content mod rather than a fork.
|
||||
|
||||
Two things had to change. Text layout stopped counting bytes: the dialogue
|
||||
box measures a line in glyphs (charmap sequences), so a 3-byte character
|
||||
costs one column, a cut never lands inside a character, and a page with a
|
||||
non-default `advance` re-measures instead of overflowing. That also fixed
|
||||
25 vanilla English lines that were wrapping early because `é` in POKéMON
|
||||
and POKéDEX costs two bytes ("I study POKéMON as" is 19 bytes and 18
|
||||
glyphs, and the box was breaking it).
|
||||
|
||||
Second, the text the engine writes itself - battle messages, item results,
|
||||
menu labels, the link-play screens - moved behind `src/core/Strings.lua`
|
||||
and the new `strings` registry. Extracted script text was already
|
||||
overridable through `text`; this covers the other half. Entries are keyed
|
||||
by the English source, so a translation that has not reached a string yet
|
||||
keeps rendering in English and a half-finished translation stays playable.
|
||||
|
||||
Authors generate the whole thing:
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py translation francais --language "Francais"
|
||||
```
|
||||
|
||||
That scaffolds a mod with every translatable string as an empty catalog,
|
||||
plus a glyph-page and charmap stub, a naming-grid stub, and a
|
||||
`francais-worksheet/` directory holding the English to translate from
|
||||
(deliberately outside the mod: extracted text is ROM content and must not
|
||||
be packed). `--refresh` re-harvests after an engine update, keeping
|
||||
existing translations and parking orphaned keys rather than dropping them.
|
||||
|
||||
A translation can also skip glyph pages entirely: scaffolding with
|
||||
`--pixel-font` (or registering `mod.content.font:register("ttf", {})` in
|
||||
an existing mod) renders text through a bundled TTF covering Latin with
|
||||
diacritics, Cyrillic, kana and CJK, while box borders and `<PK>`-style
|
||||
macro glyphs keep their tiles. The font is "Plain Pixel Font" by Douglas
|
||||
Vautour (Burpy Fresh), licensed under CC-BY 4.0 (5x11 base characters,
|
||||
11x11 double-width; see `assets/fonts/plainpixel/README.md`). Options on
|
||||
the registry entry: `file` for a mod-shipped TTF, `size` (the font's
|
||||
design em; Plain Pixel rasterizes cleanly only at multiples of 15),
|
||||
`spacing` added to every advance, `yOffset` for vertical alignment
|
||||
against the 8px cell grid, `bold`, which double-prints at a 1px
|
||||
offset for fonts whose strokes read too light, and `tiles`, the
|
||||
characters that keep their ROM tile instead of coming from the TTF.
|
||||
|
||||
`tiles` matters for a CJK translation. Sizing the font so a kana fills
|
||||
the 8px cell leaves Latin narrower than the tile font it replaces, which
|
||||
pulls the numeric columns out of line: the party menu's `:L12` stops
|
||||
sitting over `34/ 34`. Naming `"0123456789/:"` keeps those on the
|
||||
vanilla tiles, so numbers render exactly as they do in English while
|
||||
kana still come from the font. It takes a string of characters, or a
|
||||
list when a multi-character charmap sequence is meant.
|
||||
|
||||
See the wiki's Translations guide.
|
||||
|
||||
## Save editor (bundled, reachable from the launcher)
|
||||
|
||||
The save editor ships inside every build instead of being a developer-only
|
||||
script, and the launcher's SAVE SLOT card grows an **Edit** label next to
|
||||
Delete on every slot that actually holds a save. Edit suspends the
|
||||
launcher, opens that slot's file in the editor, and **Close** hands the
|
||||
process back to the launcher with the slot list re-read (a rename, a badge
|
||||
or a dex change shows up on the row immediately). Unsaved edits arm a
|
||||
confirm first, so leaving cannot lose work. `love . --editor` still opens
|
||||
it standalone, where Close quits instead; `--save <path>` points it at any
|
||||
file, and a save can be dragged onto the window.
|
||||
|
||||
The editor now wears the launcher's visual language - the same navy radial
|
||||
field, 16px translucent cards, tri-colour version rail and green/yellow/red
|
||||
semantics - so the two windows read as one app. Six tabs:
|
||||
|
||||
- **Party**: the roster with sprites, HP bars and level chips on the left,
|
||||
and the mon inspector permanently docked on the right instead of floating
|
||||
over the list. Species, level, DVs and moves all round-trip through the
|
||||
Gen 1 formulas, so the inspector can never show illegal stats.
|
||||
- **Boxes**: the 12 PC boxes as a 5x4 grid with a fill meter per box and a
|
||||
party dock, so deposit and withdraw live in one place. Empty slots are
|
||||
clickable and create a mon there.
|
||||
- **Items**: money, a searchable item picker (replacing the arrows that
|
||||
cycled one id at a time through ~250 items), the configurable bag (20 slots
|
||||
by default), PC storage
|
||||
with no slot cap, and the eight badges as toggle chips. The picker, the bag
|
||||
and PC storage all scroll under the mouse wheel, so the whole catalog is
|
||||
reachable one-handed without typing a query.
|
||||
- **Events**: flags, defeated trainers, taken items and per-map object
|
||||
toggles, with a real filter field and a two-column paged grid.
|
||||
- **Map**: any map rendered with the game's own renderer, warps followable,
|
||||
and the player / lastHeal / lastOutdoor spawn points settable by clicking
|
||||
a cell. Setting lastOutdoor on a map the game would not accept as an
|
||||
outdoor source is refused with the reason.
|
||||
- **Dex**: seen / owned completion meters and a four-column grid; owning
|
||||
implies seen and un-seeing clears owned, exactly as the game requires.
|
||||
|
||||
Two rules run through all of it. Every mutation goes through one funnel
|
||||
that sets the dirty flag and writes the status line together, so nothing
|
||||
changes silently and no branch can quietly no-op - "Party is full", "Bag is
|
||||
full", "click a cell first" all say so. And every destructive verb (Remove,
|
||||
Release, Clear all, Wipe dex) arms on the first click and commits on the
|
||||
second, relabelling itself to `Confirm?` in between.
|
||||
|
||||
A validation pill in the tab rail mirrors what the running game would
|
||||
quarantine on load; clicking it jumps to the tab holding the first problem.
|
||||
|
||||
## Tiled map editing (mod authoring)
|
||||
|
||||
`tools/tiled_export.py` turns the imported ROM cache into a Tiled workspace,
|
||||
so maps can be edited in a real map editor and exported back out as a mod.
|
||||
It has its own document: docs/tiled-map-editing.md.
|
||||
|
||||
## Pokédex diploma (both versions)
|
||||
|
||||
The Celadon Mansion 3F game designer shows the dex-completion diploma
|
||||
once 150 species are owned. On Yellow, the graphic artist next to him
|
||||
then offers to print it, saving the certificate as a PNG under `prints/`
|
||||
in the save directory, and Bill's PC gains Yellow's PRINT BOX item which
|
||||
exports the current box list the same way.
|
||||
|
||||
## Pokédex printing (Yellow)
|
||||
|
||||
Yellow's Game Boy Printer PRNT option in the Pokédex side menu is stood in
|
||||
for by an image export: choosing PRNT renders the mon's entry page (sprite,
|
||||
kind, number, height/weight, dex text) to a PNG at 4x scale under
|
||||
`prints/` in the save directory, then reports the filename in a dialog.
|
||||
No printer hardware or link cable emulation involved; the file is the
|
||||
printout.
|
||||
|
||||
## Find Mods (community mod indexes)
|
||||
|
||||
A FIND MODS tab sits beside MODS in the launcher and browses a published
|
||||
mod index: a metadata-only feed listing mods that live in their authors'
|
||||
own repositories. No index ships with the launcher and none is ever added
|
||||
automatically, so the tab opens on an "Add an index" prompt until you name
|
||||
one; paste an index URL or its `owner/repo` and it is remembered in
|
||||
`options.lua`. More than one index can be added, and the listings merge.
|
||||
|
||||
A feed author can publish per-mod release stats by adding three optional
|
||||
fields to an entry -- `downloads` (total across every release), and
|
||||
`first_release` / `last_release` (ISO days) -- which the listing shows in
|
||||
the same gold line the MODS tab uses. When a feed does not carry them,
|
||||
the row fetches the mod's own GitHub releases instead -- the same cached
|
||||
`ModUpdate` fetch the MODS tab uses, one entry per frame -- so the stats
|
||||
appear for any mod with a `github` field regardless of feed maintenance.
|
||||
The fields are additive: feeds that carry them stay readable by every
|
||||
build that predates them, and feeds that do not render exactly as before.
|
||||
|
||||
## Soft reset (all versions)
|
||||
|
||||
Holding A, B, START and SELECT together restarts the game the way flicking
|
||||
a Game Boy's power switch did, dropping straight back to the title screen.
|
||||
It works from anywhere, including mid-battle, which the QUIT entry on the
|
||||
start menu cannot do: the original combo is how stationary and gift
|
||||
Pokemon get their stats rerolled without sitting through a full relaunch.
|
||||
Unsaved progress is discarded, exactly as on hardware.
|
||||
|
||||
As on the original, the four buttons have to stay held for 16 straight
|
||||
polls (better than a quarter of a second) and any direction in the mix
|
||||
cancels it, so it is hard to hit by accident -- including on the on-screen
|
||||
touch controls, where it would take four fingers held on four separate
|
||||
controls.
|
||||
|
||||
## Controls rebinding (CONTROLS screen)
|
||||
|
||||
OPTIONS -> CONTROLS lists every Game Boy button with its current keyboard
|
||||
key and controller button side by side (Z/A). Press A on a row, then press
|
||||
and release the key or pad button you want; the rebind commits on the
|
||||
release. If that input already belongs to another row, the two rows swap,
|
||||
so no button is ever stranded without an input and no input ever serves
|
||||
two buttons. Holding a second key or pad button while the first is still
|
||||
down backs out of the capture without touching a keyboard; Escape still
|
||||
cancels too. SELECT clears one row back to its default, and START resets
|
||||
every binding after a confirmation.
|
||||
|
||||
Controllers a system has no mapping for (common on Linux handhelds and
|
||||
off-brand pads) report bare button numbers rather than names. Those are
|
||||
rebindable on the same screen and show up as JOY1, JOY2 and so on in the
|
||||
controller column. Recognized controllers are read only through their
|
||||
named buttons, so a rebind on those is never shadowed by the factory
|
||||
layout underneath it.
|
||||
|
||||
## Mod profiles (#593)
|
||||
|
||||
The mod manager's PROFILES tab holds named setups. A profile remembers which
|
||||
mods are on, every mod's own options, and which save slot each game version
|
||||
plays, so swapping profiles swaps the whole playthrough and not just the mod
|
||||
list. The setup that existed before profiles shipped becomes PROFILE 1 the
|
||||
first time the manager opens.
|
||||
|
||||
EXPORT.. writes the selected profile to `profiles/<NAME>.g1rmodlist` in the
|
||||
save directory; drop a `.g1rmodlist` someone shared into that folder and
|
||||
IMPORT.. adds it. Imported profiles never overwrite an existing one (a name
|
||||
clash gets a number). Mods the shared profile names but that are not installed
|
||||
are reported when the profile is applied; installing them is still a manual
|
||||
trip through the mods list or Find Mods.
|
||||
|
||||
## Windows: no console windows on launcher actions
|
||||
|
||||
Checking for updates, browsing a mod index, adding a mod repo, installing a
|
||||
mod and picking a ROM all run a host tool (curl, PowerShell) in a child
|
||||
process. On Windows those children used to each open their own console
|
||||
window, so a session could end up buried under half a dozen of them. The
|
||||
game now claims one console for itself at boot and hides it; the children
|
||||
inherit that invisible console and nothing pops up. Nothing else changes:
|
||||
file pickers are ordinary desktop dialogs and still appear normally, and a
|
||||
run started from a terminal (`lovec.exe`, what `scripts\run.ps1` prefers)
|
||||
keeps its terminal and its printed output. Set `POKEPORT_CONSOLE=1` to opt
|
||||
out.
|
||||
|
||||
## A faster, higher-contrast launcher
|
||||
|
||||
The launcher and the save editor were rebuilt on one small immediate-mode UI
|
||||
kit (`src/ui/kit/`), replacing the vendored FlexLove layout engine. The
|
||||
visible result is that the launcher is quick: building and drawing a frame
|
||||
went from about 9 ms to under 1 ms on the same machine and the same data, at
|
||||
every window size, so the window keeps up with the pointer instead of
|
||||
trailing it. Measure it yourself with `POKEPORT_LAUNCHER_PROF=200 love .`.
|
||||
|
||||
**Nothing blocks the window any more.** Fetching a mod index, checking a mod
|
||||
for updates, listing versions, downloading an install and pulling thumbnails
|
||||
all run on background threads. Opening FIND MODS on a cold cache used to
|
||||
freeze the launcher for as long as the server took -- often minutes, with no
|
||||
indication anything was happening. Mod indexes are now also fetched at boot,
|
||||
so the tab is usually already populated by the time you reach it.
|
||||
|
||||
**Anything you wait on says so.** Every operation that takes time raises a
|
||||
loading panel with a spinner or a progress bar that cannot be clicked around
|
||||
or dismissed, so a half-finished install can never be interrupted by a stray
|
||||
click. Work that only affects one row (a mod's update check) shows a small
|
||||
spinner on that row instead and leaves the rest of the list usable.
|
||||
|
||||
**Lists page instead of scrolling.** Mods, Find Mods, save slots, settings,
|
||||
release notes and the version list all show a fixed number of rows with a
|
||||
pager underneath, and the number of rows comes from the window height -- a
|
||||
tall window shows more, a phone shows fewer. A long list costs exactly what a
|
||||
short one does. The mouse wheel turns pages.
|
||||
|
||||
**Updates live in the top right.** The in-app updater moved next to the
|
||||
settings gear and pulses when an update is waiting, instead of sitting in a
|
||||
banner at the bottom of the page that you had to scroll to notice. Checking
|
||||
for updates from there shows a loader like everything else.
|
||||
|
||||
**A quit button.** An X sits to the right of the settings gear and closes
|
||||
the app cleanly, the same shutdown path as the window's close button. Mostly
|
||||
for platforms where reaching the window chrome is awkward (Android, Steam
|
||||
Deck, fullscreen desktops).
|
||||
|
||||
**The look.** Black background, white outlines, no gradients or glows, and
|
||||
buttons that are solid colour-coded keys: green commits, blue navigates, red
|
||||
destroys, yellow wants attention. The three game tabs keep their red, blue
|
||||
and gold cartridge colours. Everything is about a third larger than before.
|
||||
The save editor follows the same theme, and adding an item there is now a
|
||||
searchable pop-up like adding a Pokemon, rather than a cramped list wedged
|
||||
into the tab.
|
||||
|
||||
**Reset rebinds.** Input rebinds are additive, so there was no in-game way to
|
||||
undo one. A RESET REBINDS row in Settings, and a matching button under Touch
|
||||
Controls on each game tab, restore the stock keyboard, gamepad and touch
|
||||
layout. Both ask twice.
|
||||
|
||||
## Launch options: boot straight into a game
|
||||
|
||||
`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
|
||||
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.
|
||||
# New Features
|
||||
|
||||
Features intentionally added beyond the original Pokémon Red, Blue, and Yellow games:
|
||||
|
||||
* **Survey zoom** with connected-map rendering and configurable void fill
|
||||
* **Perspective tilt mode** for an HD-2D-style overworld
|
||||
* **Multiple color modes**, including original, SGB, advanced GBC, monochrome, and classic green
|
||||
* **Optional GBC screen effects**, including pixel grids, shadows, glare, and transparency
|
||||
* **Performance presets** and configurable FPS limits
|
||||
* **Peer-to-peer link play** for trades and battles between Red, Blue, and Yellow
|
||||
* **Persistent custom options** stored separately from game saves
|
||||
* **Optional widescreen battle layout**
|
||||
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
||||
* **Translation and custom font support**
|
||||
* **Built-in save editor** for parties, boxes, items, events, maps, and Pokédex data
|
||||
* **Tiled map editing tools** for mod authors
|
||||
* **Pokédex diploma and printer image exports**
|
||||
* **Community mod browser**
|
||||
* **Soft reset button combination**
|
||||
* **Keyboard and controller rebinding**
|
||||
* **Mod profiles** with separate mod settings and save slots
|
||||
* **Improved launcher and save editor UI**, including background downloads and update checks
|
||||
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
|
||||
* **Custom boot branding**
|
||||
|
||||
Actual approximations, and missing original behavior are documented separately in `docs/known-differences.md`.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,114 @@
|
||||
# RFC 0003 — Add a reusable multiplayer session layer
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `Session.lua`, `Net.lua`, `LinkState.lua`,
|
||||
`Tournament.lua`. Tests: `link_session.lua`.
|
||||
|
||||
## Motivation
|
||||
|
||||
Link play and tournaments currently own transport lifecycle details and
|
||||
temporarily remove and reinsert packets in `Net.inbox` when a handshake or
|
||||
battle starts. That makes packet ownership fragile and gives a future
|
||||
shared-world mode no stable host/guest-aware boundary to reuse.
|
||||
|
||||
The engine needs one small layer that preserves today's wire protocol while
|
||||
owning received-packet order and terminal cleanup. Pokémon, battle, tournament,
|
||||
save, and overworld rules remain outside that layer.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the existing split between `Net` (backend setup, framing, and relay
|
||||
controls), `Handshake`/`Protocol` (mode payloads), and the states that
|
||||
interpret those payloads. It does not replace any of those components.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible and internal-only.
|
||||
|
||||
### `Session.new(transport, options)`
|
||||
|
||||
Wraps one successfully configured Net-compatible transport. `options.role`
|
||||
is exactly `"host"` or `"guest"`; `options.kind` is a non-empty
|
||||
local label such as `"link"` or `"tournament"`. Role and kind are
|
||||
immutable session metadata selected locally and are never inferred from peer
|
||||
packets.
|
||||
|
||||
The facade forwards the narrow fields current consumers need:
|
||||
`paired`, `code`, `address`, `target`,
|
||||
`error`, and `closed`.
|
||||
It forwards valid outbound tables unchanged through `send(message)`.
|
||||
|
||||
### Receive and lifecycle methods
|
||||
|
||||
- `update()` pumps the transport, validates decoded inbound values, and
|
||||
appends accepted messages to a private FIFO.
|
||||
- `pollOne()` removes the oldest queued message.
|
||||
- `poll()` removes every queued message in order.
|
||||
- `take(type)` removes the first queued message with that type without
|
||||
disturbing any other message.
|
||||
- `hasPending()` reports whether the FIFO is non-empty.
|
||||
- `getRole()`, `getKind()`, `getStatus()`, and
|
||||
`getFailure()` expose local metadata and lifecycle.
|
||||
- `close()` closes the underlying transport once and is safe to repeat.
|
||||
|
||||
Statuses are `connecting`, `paired`, `draining`, `closed`,
|
||||
and `failed`. A transport close or failure becomes `draining` while
|
||||
accepted packets remain queued. The terminal `closed`/`error`
|
||||
compatibility projection appears only after that FIFO drains, so a last packet
|
||||
travelling with a disconnect remains observable.
|
||||
|
||||
An inbound value is structurally valid only when it is a table with a string
|
||||
`type`. Invalid decoded values end the session with a protocol failure.
|
||||
Unknown but structurally valid types remain queued for the owning mode; the
|
||||
session does not contain a packet allowlist.
|
||||
|
||||
## Authority direction
|
||||
|
||||
A later `WorldSession` may compose this facade. In that mode the host
|
||||
will own the world snapshot, map state, NPC state, event results, and shared
|
||||
progression. A guest will bring a trainer identity plus their Pokémon party,
|
||||
inventory, and other explicitly selected profile snapshot.
|
||||
|
||||
Guest profile data and commands will be untrusted input. The host must validate
|
||||
them and must authorize every world mutation before rebroadcasting the result.
|
||||
The concrete snapshot schema, command vocabulary, conflict rules, and
|
||||
persistence policy require a separate RFC and are not introduced here.
|
||||
|
||||
## Compatibility and security
|
||||
|
||||
No packet envelope, message name, payload shape, framing rule, relay protocol,
|
||||
save schema, or engine protocol version changes. Existing valid outbound
|
||||
messages encode exactly as before, and existing link and tournament screens
|
||||
keep their current player-facing behavior.
|
||||
|
||||
The layer does not authenticate players or encrypt traffic. Existing LAN and
|
||||
relay access assumptions remain unchanged; knowledge of a join address or code
|
||||
still grants the same access it grants today. Authentication, reconnect
|
||||
identity, rate limits, and abuse controls remain future protocol decisions.
|
||||
|
||||
## Migration note for players, mods, and peers
|
||||
|
||||
**Nothing.** `LinkState` and `Tournament` adopt the facade
|
||||
internally. Existing peers receive the same messages, mods gain no new API, and
|
||||
players do not migrate saves or settings.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **ROM-free facade:** constructor validation, immutable role/kind, unchanged
|
||||
send shape, FIFO ordering, typed retrieval, unknown typed packets, draining,
|
||||
terminal failure latching, protected transport calls, and decoded-value
|
||||
rejection.
|
||||
- **Existing modes:** source guards prohibit direct inbox mutation; headless
|
||||
module loads and the complete engine tier cover both migrated states.
|
||||
- **ROM-backed link play:** run the existing link driver when generated ROM
|
||||
data is available; the normal quick suite remains the required baseline.
|
||||
|
||||
## Deprecation etiquette and non-goals
|
||||
|
||||
Nothing deprecated. This RFC adds an internal facade and removes no transport
|
||||
method.
|
||||
|
||||
It does not add shared-world packets, co-op screens, a remote actor, save
|
||||
transfer, server persistence, matchmaking, reconnect, or a protocol-version
|
||||
bump. Those changes require the world-specific layer and its own review.
|
||||
@@ -0,0 +1,124 @@
|
||||
# RFC 0003 — Playthrough-scoped mod storage
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `SaveData.lua`, `SaveSerializer.lua`, `Storage.lua`,
|
||||
`Loader.lua`. Tests: `playthrough_identity.lua`, `storage.lua`, the existing
|
||||
save-slot and mod-save suites.
|
||||
|
||||
## Motivation
|
||||
|
||||
`mod.save` intentionally lives inside the normal progress record. That is the
|
||||
right home for quest state, but not for independent tool data such as replay
|
||||
captures, checkpoint histories, or recovery records: writing it would require a
|
||||
normal Pokémon SAVE, and storing copies of progress beneath `save.modData` would
|
||||
recursively embed the save that contains them.
|
||||
|
||||
Mods also cannot safely infer which launcher slot or portable filesystem backs
|
||||
the active playthrough. Direct filesystem access would expose private paths and
|
||||
make isolation dependent on engine implementation details.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the per-mod persistence contract documented in `docs/modding.md` and the
|
||||
wiki's Save Model. `mod.save` and `mod.options` keep their existing behavior.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only. `Loader:_api` binds a new `mod.storage`
|
||||
facade to the calling mod id. Mods receive logical keys and decoded values, never
|
||||
filesystem handles or physical paths.
|
||||
|
||||
### Lazy opaque playthrough identity
|
||||
|
||||
`SaveData.ensurePlaythroughId(save[, fs]) -> id | nil` allocates an opaque
|
||||
32-hex-character identity without consuming gameplay RNG. It is called only when
|
||||
`mod.storage` or `mod.checkpoints` first needs a scope; New Game, ordinary SAVE,
|
||||
and ordinary load remain byte-compatible when no caller uses either API.
|
||||
|
||||
The id is stored in `save.meta.playthroughId` after allocation. Until the next
|
||||
ordinary SAVE writes it into progress, a mapping in `options.lua` keeps legacy
|
||||
saves stable by game version and active launcher slot (or the legacy flat-save
|
||||
scope). A newly created playthrough never adopts the previous playthrough's
|
||||
mapping for that slot.
|
||||
|
||||
`SaveData.persistenceFs([fs])` is engine-only routing used by the storage
|
||||
implementation. It follows the same standard/portable backend as progress and
|
||||
honors injected test filesystems; it is not exposed on the mod object.
|
||||
|
||||
### `mod.storage:context(game)`
|
||||
|
||||
Returns:
|
||||
|
||||
```lua
|
||||
{ engineVersion = "0.9.0", gameVersion = "red", playthroughId = "..." }
|
||||
```
|
||||
|
||||
or `nil, code, message`. `engineVersion` is warning-grade compatibility metadata;
|
||||
the context intentionally omits launcher slot ids and paths.
|
||||
|
||||
### `mod.storage:write(game, key, value)`
|
||||
|
||||
Accepts a data-only table and returns `true`, or
|
||||
`false, code, message`. Keys are nonempty slash-separated segments containing
|
||||
letters, digits, underscore, or dash. Empty segments, leading/trailing slash,
|
||||
`.`/`..`, and other characters are rejected.
|
||||
|
||||
The engine encodes deterministically, stages and decodes a `.tmp` witness,
|
||||
preserves the previous valid generation, writes and decodes the main record,
|
||||
then rolls the verified bytes to `.bak`. A failed stage or replacement leaves a
|
||||
verified prior generation readable.
|
||||
|
||||
### `mod.storage:read(game, key)`
|
||||
|
||||
Returns a freshly decoded table, or `nil, code, message`. It tries main, staged,
|
||||
then backup data. A valid staged/backup value is returned and promoted
|
||||
best-effort; corrupt bytes are never executed.
|
||||
|
||||
### `mod.storage:list(game[, prefix])`
|
||||
|
||||
Returns sorted logical keys beneath a valid prefix, an exact key when the prefix
|
||||
names one, or `nil, code, message`. Physical witness filenames are hidden.
|
||||
|
||||
### `mod.storage:delete(game, key)`
|
||||
|
||||
Deletes only that key's main, backup, and staged witnesses. Returns `true`, or
|
||||
`false, code, message`.
|
||||
|
||||
### Scope and errors
|
||||
|
||||
Physical records are scoped as:
|
||||
|
||||
`persistence root / mod_storage / game version / playthrough id / mod id`
|
||||
|
||||
Stable error codes are `not_in_playthrough`, `storage_unavailable`,
|
||||
`invalid_key`, `encode_failed`, `write_failed`, `verify_failed`, and
|
||||
`not_found`. Ordinary data and I/O failures are return values, not callback-
|
||||
terminating errors.
|
||||
|
||||
The restricted serializer's recursive writer runs outside LuaJIT traces. A
|
||||
1,000-process GC stress regression found compiled recursion could intermittently
|
||||
drop a newly inserted nested identity entry and produce undecodable bytes; save
|
||||
encoding is infrequent and I/O-bound, so interpreter execution is the safe
|
||||
boundary.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing.** No API is removed, no manifest field changes, and no storage path or
|
||||
playthrough id is created unless a mod invokes `mod.storage` or
|
||||
`mod.checkpoints`. Existing save bytes remain unchanged on the no-caller path.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod:** New Game plus ordinary save/load creates no identity or storage
|
||||
file; the existing save-slot and mod-save suites remain green.
|
||||
- **Engine identity:** lazy allocation, save/load preservation, stable legacy
|
||||
mapping, fresh-playthrough replacement, and version/slot isolation.
|
||||
- **Public Mod API:** two real API-2 entry chunks prove data-only roundtrip,
|
||||
deterministic listing, key rejection, mod/game/playthrough isolation,
|
||||
corrupt-main recovery, failure retention, exact delete, and no-mod no-write.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. The additions are one bound public facade and engine-private
|
||||
persistence/identity helpers.
|
||||
@@ -0,0 +1,138 @@
|
||||
# RFC 0004 — Stable runtime checkpoints for mods
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `Checkpoint.lua`, `Game.lua`, `OverworldController.lua`,
|
||||
`Loader.lua`. Tests: `checkpoints.lua`, existing world and engine suites.
|
||||
|
||||
## Motivation
|
||||
|
||||
Mods can observe world events and request semantic actions, but no supported API
|
||||
can capture canonical progress at a proven-safe runtime boundary or reconstruct
|
||||
the overworld without replaying map-entry scripts. Reaching into the state stack,
|
||||
controller, ScriptRunner, or save restore internals would bind distributable mods
|
||||
to private objects and can duplicate story side effects.
|
||||
|
||||
The engine is the only component that can authoritatively decide whether the
|
||||
runtime is settled and rebuild its controller objects. A generic checkpoint seam
|
||||
lets tools store data-only records while keeping those responsibilities private.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the public world/tool surfaces in `docs/modding.md`. It does not change
|
||||
`mod.world`, normal CONTINUE, vanilla SAVE, or save lifecycle hooks/events.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only. `Loader:_api` binds `mod.checkpoints`; mods
|
||||
never receive `Game`, StateStack, controller, coroutine, renderer, or filesystem
|
||||
internals inside a checkpoint.
|
||||
|
||||
### `mod.checkpoints:inspect(game)`
|
||||
|
||||
Returns a capability record. Stable overworld control returns:
|
||||
|
||||
```lua
|
||||
{ canCapture = true, canRestore = true, kind = "overworld" }
|
||||
```
|
||||
|
||||
A refusal returns the same booleans as `false` plus `kind`, `reason`, and a
|
||||
player-readable `message`. Format-1 supports only an overworld whose controller
|
||||
is topmost, player movement has settled on a tile, and no transition, foreground
|
||||
or parallel ScriptRunner, queued script, scripted move, engagement, emote,
|
||||
teleport, field animation, or similar partial controller mutation is active.
|
||||
|
||||
Refusal reasons are `not_in_playthrough`, `not_overworld`, `screen_busy`,
|
||||
`transition_busy`, `script_busy`, `animation_busy`, and `movement_busy`.
|
||||
Identity allocation is lazy and happens only after an active topmost overworld
|
||||
has been established.
|
||||
|
||||
### `mod.checkpoints:capture(game)`
|
||||
|
||||
Returns a detached data-only format-1 checkpoint, or
|
||||
`nil, code, message`:
|
||||
|
||||
```lua
|
||||
{
|
||||
format = 1,
|
||||
kind = "overworld",
|
||||
identity = {
|
||||
engineVersion = "...", gameVersion = "red", playthroughId = "...",
|
||||
},
|
||||
save = { -- canonical dynamic progress, excluding global options },
|
||||
runtime = { overworld = {
|
||||
map = "PALLET_TOWN", x = 5, y = 6,
|
||||
facing = "down", surfing = false,
|
||||
} },
|
||||
}
|
||||
```
|
||||
|
||||
`engineVersion` is metadata for caller compatibility warnings; the engine does
|
||||
not reject patch/minor mismatches on restore. Capture deep-copies through the restricted serializer before and after
|
||||
`OverworldController:captureSave` synchronizes live map, tile, facing, and surf
|
||||
state. It excludes `save.options`, functions, userdata, threads, metatables as
|
||||
behavior, controller instances, and static content registries. Failure code
|
||||
`capture_failed` covers non-data progress and synchronization errors.
|
||||
|
||||
### `mod.checkpoints:restore(game, checkpoint)`
|
||||
|
||||
Returns `true`, or `false, code, message`. Before mutation it requires the current
|
||||
runtime to be capturable and validates a detached copy of the complete record:
|
||||
format, kind, internal identity consistency, current game/playthrough identity,
|
||||
map availability, integral in-bounds tile, facing, surfing, and synchronized save
|
||||
position.
|
||||
|
||||
Validation codes are `invalid_checkpoint`, `invalid_content`, `unsupported_format`,
|
||||
`unsupported_runtime_kind`, `wrong_game`, `wrong_playthrough`, `invalid_map`, and
|
||||
`invalid_position`, in addition to the capability refusal reasons.
|
||||
|
||||
The canonical save validator runs against the detached record. Unlike ordinary
|
||||
CONTINUE, a checkpoint never accepts a quarantine, remap, reclaim, clamp, or
|
||||
repair: any such content change returns `invalid_content` before live mutation.
|
||||
|
||||
The engine captures an in-memory rollback checkpoint, preserves current global
|
||||
options, then reconstructs semantic overworld state through
|
||||
`Game:restoreCheckpointSave`. Checkpoint entry suppresses normal map exit/entry
|
||||
events, `onEnter` scripts, forced-movement/current checks, and last-map rewrites;
|
||||
it does not emit normal `save.loading`/`save.loaded` lifecycle events. After
|
||||
reconstruction, the engine recaptures and byte-compares normalized data. A failed
|
||||
apply rolls back and returns `restore_failed`; failure of that rollback returns
|
||||
`rollback_failed`. Only after a successful comparison does the engine emit
|
||||
`checkpoint.restored` with `{ game = game, kind = "overworld" }`. Validation
|
||||
failure, failed apply, and successful rollback emit nothing.
|
||||
|
||||
Durable recovery remains a caller responsibility: in-memory rollback handles a
|
||||
runtime exception, not process termination.
|
||||
|
||||
## Runtime boundary and future kinds
|
||||
|
||||
This RFC's original Level A contract intentionally rejects battles, menus,
|
||||
transitions, animations, and suspended/queued scripts. RFC 0005 subsequently
|
||||
adds a separately inventoried `battle` kind with deterministic RNG and
|
||||
differential reconstruction tests; it does not broaden script or arbitrary-frame
|
||||
support implied here.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing required.** No existing hook, save, controller, or world action changes
|
||||
when `mod.checkpoints` is unused. The reconstruction path is called only by a
|
||||
successful public restore after validation. Mods whose runtime caches derive from
|
||||
rewound `game.save` or `mod.save` state may optionally subscribe to
|
||||
`checkpoint.restored` and rebuild from their own public state.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod:** the complete ROM-free engine suite and existing world behavior stay
|
||||
green; ordinary New Game/save/load allocates no checkpoint identity.
|
||||
- **Public Mod API:** a real API-2 entry chunk proves stable inspection and every
|
||||
unsafe refusal, detached data-only capture, exact map/tile/facing/surf sync,
|
||||
`A -> mutate B -> restore A -> recapture A2` equality across representative
|
||||
progress, settings preservation, compatibility rejection without mutation,
|
||||
map-side-effect suppression, injected reconstruction rollback, mod-owned
|
||||
metadata and `mod.save` rewind, independent `mod.storage`/options preservation,
|
||||
and success-only runtime-cache reconciliation through `checkpoint.restored`.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. This adds one public facade and a checkpoint-only semantic
|
||||
reconstruction route.
|
||||
@@ -0,0 +1,140 @@
|
||||
# RFC 0005 — Persistent battle safe-point checkpoints
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Extends RFC 0004. Engine: `BattleCheckpoint.lua`, `Checkpoint.lua`,
|
||||
`Game.lua`, `BattleState.lua`, and `OverworldController.lua`. Tests:
|
||||
`battle_checkpoint_*.lua`, `checkpoints.lua`, and the existing no-mod suites.
|
||||
|
||||
## Motivation
|
||||
|
||||
RFC 0004 lets a tool capture and reconstruct settled overworld progress without
|
||||
private engine access. A battle is a different runtime: its queue can hold Lua
|
||||
functions and UI factories, its controller contains renderer objects and live
|
||||
references, completion is currently an `onFinish` closure, and scripted battles
|
||||
resume a suspended `ScriptRunner` coroutine. Copying the controller would create
|
||||
a record that is neither data-only nor process-independent.
|
||||
|
||||
The engine can instead expose a narrow semantic safe point. This gives all mods
|
||||
the strongest persistent battle checkpoint the current architecture can prove,
|
||||
without claiming mid-animation or suspended-script support.
|
||||
|
||||
## API delta
|
||||
|
||||
No new facade is added. The existing additive `mod.checkpoints` API gains a
|
||||
second format-1 runtime kind.
|
||||
|
||||
### Capability
|
||||
|
||||
`mod.checkpoints:inspect(game)` returns this only when an ordinary single-player
|
||||
wild or trainer battle is settled at the player command menu:
|
||||
|
||||
```lua
|
||||
{ canCapture = true, canRestore = true, kind = "battle" }
|
||||
```
|
||||
|
||||
The action/message queue, waits, UI, animations, HP/status presentation, and
|
||||
faint processing must be settled. The player must actually control the menu.
|
||||
The underlying overworld must have no running/queued script or scripted move,
|
||||
and the battle must carry an engine-owned semantic continuation descriptor.
|
||||
|
||||
Additional refusal codes are `battle_phase_busy`, `battle_origin_unsupported`,
|
||||
`battle_variant_unsupported`, and `link_battle_unsupported`. Link, Safari,
|
||||
ghost, old-man/demo, fishing, static-object, script-suspended, and mod-created
|
||||
closure continuations remain rejected.
|
||||
|
||||
### Capture
|
||||
|
||||
A battle checkpoint remains detached and data-only:
|
||||
|
||||
```lua
|
||||
{
|
||||
format = 1,
|
||||
kind = "battle",
|
||||
identity = { engineVersion = "...", gameVersion = "red",
|
||||
playthroughId = "..." },
|
||||
save = { -- canonical dynamic progress, excluding global options },
|
||||
runtime = {
|
||||
overworld = { map = "ROUTE_1", x = 7, y = 8,
|
||||
facing = "left", surfing = false },
|
||||
battle = { -- normalized semantic model and continuation },
|
||||
},
|
||||
rng = { love = "..." },
|
||||
}
|
||||
```
|
||||
|
||||
The model carries player/enemy roster indices, dynamic enemy Pokémon, turn and
|
||||
escape state, HP/PP/status/stages/volatiles, participants, level-up tracking,
|
||||
trainer AI state, battle ruleset identity, side/field extension data, and
|
||||
normalized pointer relationships such as multi-turn move slots and Mimic
|
||||
restoration entries. Definitions, sprites, canvases, queues, callbacks, and
|
||||
controller objects are reconstructed or excluded.
|
||||
|
||||
Callback-bearing battle extension tokens fail with `battle_extension_unsafe`;
|
||||
invalid live reference relationships fail with `battle_state_invalid`. Nothing
|
||||
is silently stripped.
|
||||
|
||||
New overworld checkpoints also carry the LÖVE gameplay RNG state. Legacy
|
||||
format-1 overworld checkpoints without `rng` remain loadable and leave the
|
||||
current stream untouched.
|
||||
|
||||
### Restore
|
||||
|
||||
Battle restore validates the detached save, map, content references, ruleset,
|
||||
roster indices, move references, continuation identity, and RNG before live
|
||||
mutation. The engine then:
|
||||
|
||||
1. reconstructs the saved overworld return point without entry side effects;
|
||||
2. creates a fresh `BattleState` from current content registries;
|
||||
3. applies the normalized battle model and rebuilds object-reference relations;
|
||||
4. binds an engine-owned wild/trainer completion continuation;
|
||||
5. installs the battle directly at the settled menu without replaying its intro;
|
||||
6. restores the RNG after reconstruction has finished; and
|
||||
7. recaptures and compares the complete checkpoint; and
|
||||
8. emits `checkpoint.restored` with `{ game = game, kind = "battle" }` after the
|
||||
comparison succeeds.
|
||||
|
||||
The pre-operation checkpoint is the transaction rollback. A failed post-install
|
||||
RNG restore is covered: both battle runtime and RNG are reconstructed back to
|
||||
their original values. Validation failure, failed reconstruction, and successful
|
||||
rollback emit no checkpoint lifecycle event.
|
||||
|
||||
## Continuation decision
|
||||
|
||||
Ordinary random wild battles resume through `OverworldState:afterBattle`.
|
||||
Ordinary trainer battles use a descriptor containing map id, stable NPC id,
|
||||
trainer class/party, and optional header event; a win reapplies the same defeated
|
||||
flag, event, reward, and `afterBattle` path. Reconstructed overworld input and
|
||||
NPC freeze state are normalized instead of reviving the old closure.
|
||||
|
||||
`Commands.start_battle` is deliberately unsupported: its completion closure
|
||||
mutates script context and resumes a coroutine whose program counter and Lua
|
||||
stack cannot be serialized. Existing script rejection remains the correct safe
|
||||
contract until a separate semantic ScriptRunner checkpoint RFC exists.
|
||||
|
||||
## Migration note
|
||||
|
||||
**Existing mods require no changes.** The facade and format number are unchanged;
|
||||
the new kind, RNG field, and success-only lifecycle event are additive.
|
||||
Overworld-only callers may continue to filter `capability.kind`. Mods with derived
|
||||
runtime caches may rebuild them from restored public state when the event fires.
|
||||
No-mod behavior is unchanged when checkpoints are unused.
|
||||
|
||||
## Verification
|
||||
|
||||
- settled/unsafe boundary and every variant refusal;
|
||||
- data-only wild and trainer capture, including callback-bearing extension
|
||||
rejection;
|
||||
- process-independent controller and continuation reconstruction;
|
||||
- exact differential recapture for wild and trainer states;
|
||||
- HP, PP, status/stages/volatiles, AI layer, participants, enemy roster,
|
||||
multi-turn move references, and Mimic restore pointers;
|
||||
- exact damage, critical, accuracy, random AI, escape, next encounter, and next
|
||||
raw RNG result after reload;
|
||||
- corrupt content/continuation rejection before mutation;
|
||||
- injected post-install failure with full runtime and RNG rollback;
|
||||
- mod-added Pokémon metadata and `mod.save` rewind while independent
|
||||
`mod.storage` and options remain current;
|
||||
- exactly one post-verification `checkpoint.restored` event and none on failure;
|
||||
- legacy overworld checkpoint compatibility;
|
||||
- complete ROM-free engine and public mod-API suites.
|
||||
@@ -0,0 +1,107 @@
|
||||
# RFC 0006 — Generic process-lifecycle hooks for platform launcher integrations
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `PlatformHooks.lua` (new), `main.lua`, `Manifest.lua`,
|
||||
`Loader.lua`. Tests: `tests/modkit/cases/platform_lifecycle_hooks.lua`,
|
||||
`tests/mod_loader_tests.lua`, `tests/mod_manifest_tests.lua`.
|
||||
|
||||
## Motivation
|
||||
|
||||
A platform-specific launcher wrapper -- a native shell that embeds this
|
||||
engine and owns its own UI around the game window (a mobile app shell,
|
||||
say, presenting its own settings/import/save screens and only handing
|
||||
control to the LÖVE window once play starts) needs three things no
|
||||
current hook covers:
|
||||
|
||||
1. Pause the simulation while its own UI is on top of the game window.
|
||||
2. Live-reload options it wrote from outside any Lua UI.
|
||||
3. Veto `main.lua`'s "closing the window returns to the Lua launcher"
|
||||
behavior when the platform shell owns that job itself -- without this,
|
||||
a shell that re-fronts its own launcher UI on quit gets looped straight
|
||||
back into `HostShell.restart()`'s in-process reboot instead.
|
||||
|
||||
Implementing this by hand-patching `main.lua`'s `love.update`/`love.quit`
|
||||
directly ties every such integration to editing the one file every other
|
||||
engine change also touches, guaranteeing merge conflicts for any second
|
||||
platform integration (or any unrelated engine PR landing around the same
|
||||
time). No existing hook covers "should the per-frame simulation step run"
|
||||
or "should closing the window return to the Lua launcher."
|
||||
|
||||
## The decision it extends
|
||||
|
||||
No prior D-number. Extends the hook-contract section of `docs/modding.md`
|
||||
alongside `input.step`, `render.hud`, `screen.render_visible`, etc.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only.
|
||||
|
||||
### `core.update`
|
||||
|
||||
New hook, `(game, dt) -> nil` through the public wrapper signature
|
||||
`(next, game, dt)`, called once per frame from `love.update` via
|
||||
`src/core/PlatformHooks.lua`'s `PlatformHooks.update(game, dt)`. Vanilla
|
||||
behavior (used when no mod claims the hook) is `game:update(dt)`,
|
||||
unconditionally -- identical to `love.update`'s behavior before this hook
|
||||
existed. A subscriber may skip calling `next(game, dt)` to pause the
|
||||
simulation for that frame, or do additional per-frame work before/after
|
||||
calling it regardless of whether it calls `next`.
|
||||
|
||||
### `core.quit_to_launcher`
|
||||
|
||||
New hook, `() -> boolean` through the public wrapper signature `(next)`,
|
||||
called once from `love.quit()` via
|
||||
`PlatformHooks.quitToLauncher(vanilla)`. `vanilla` is the pre-existing
|
||||
non-platform-specific decision (`Game and not Importer and not
|
||||
quitToLauncher and not scripted and not launchedIntoGame`). A subscriber
|
||||
may return `false` outright to veto returning to the Lua launcher (without
|
||||
ever calling `next`, so the vanilla condition is never evaluated), or call
|
||||
`next()` and return its result to pass the vanilla decision through
|
||||
unchanged.
|
||||
|
||||
Neither hook is guarded by `Runtime.wantsHook` -- both fire unconditionally
|
||||
every call, matching the existing `input.step` precedent
|
||||
(`src/core/Game.lua`), since `Hooks:call` already fast-paths to a bare
|
||||
`vanilla(...)` call when no mod has wrapped the name.
|
||||
|
||||
### `Manifest.force_enable_env`
|
||||
|
||||
New optional manifest field, a bare env-var name. `Loader:load` re-enables
|
||||
a mod carrying this field whenever that variable is set to `"1"`,
|
||||
regardless of a saved disable in `options.mods`. This exists for exactly
|
||||
the mod class this RFC is for: a platform-bridge mod that ships only with
|
||||
one build and cannot function disabled there, but must still behave like
|
||||
every other mod (a manifest opt-in, not an engine special case) on every
|
||||
build that doesn't set its variable.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing.** With no subscriber, `love.update` still calls `Game:update(dt)`
|
||||
unconditionally every frame and `love.quit()`'s restart-to-launcher
|
||||
decision is exactly the pre-existing condition -- bit-identical to today's
|
||||
behavior on every platform where no mod wraps either hook. A manifest with
|
||||
no `force_enable_env` field behaves exactly as before.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod:** `core.update`'s vanilla runs exactly once per call with the
|
||||
hook chain empty; `core.quit_to_launcher`'s vanilla return value passes
|
||||
through unchanged. Both hooks are picked up automatically by the
|
||||
catalog-driven no-mod gate (`tests/engine/gate_hooks.lua`, which scans
|
||||
for `Runtime.call("...")` call sites), so neither needs a dedicated
|
||||
no-mod test file.
|
||||
- **Mod-API:** `tests/modkit/cases/platform_lifecycle_hooks.lua` proves,
|
||||
through a fixture mod loaded via the public loader (not the engine's
|
||||
internals), that a subscriber can skip the vanilla update call (pause),
|
||||
run extra per-frame polling regardless of pause state, and veto the
|
||||
quit-to-launcher decision without the vanilla condition ever running.
|
||||
- `tests/mod_loader_tests.lua` and `tests/mod_manifest_tests.lua` cover
|
||||
`force_enable_env`: a matching env var re-enables a mod saved as
|
||||
disabled, and an unset one leaves the saved disable alone.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. These are two additive hooks and one additive manifest
|
||||
field; `main.lua`'s only footprint is one `require` and two call sites
|
||||
into `src/core/PlatformHooks.lua`.
|
||||
@@ -0,0 +1,212 @@
|
||||
# RFC 0007 — Per-category GAME SPEED and the `core.logic_speed` hook
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `GameSpeed.lua`, `Game.lua`, `BattleState.lua`,
|
||||
`OptionsMenu.lua`, `SaveData.lua`, `LauncherSettings.lua`. Tests:
|
||||
`tests/engine/game_speed_categories_test.lua`,
|
||||
`tests/engine/gate_hooks.lua` (structural, automatic), `tests/run_tests.lua`
|
||||
(OptionsMenu row walk), `tests/mod_ui_tests.lua` (row id/order).
|
||||
|
||||
## Motivation
|
||||
|
||||
`GameSpeed` (`src/core/GameSpeed.lua`) is a single fast-forward multiplier
|
||||
applied uniformly to the whole logic clock in `Game:logicSpeed()` /
|
||||
`Game:update()` -- overworld walking, menu navigation and battle turns all
|
||||
scale together. A player who wants 4X battles (grinding, a long gym fight)
|
||||
but 1X overworld (so a scripted cutscene or NPC dialogue doesn't blur past)
|
||||
has no way to get both; the one GAME SPEED row is a single ladder that
|
||||
applies everywhere at once.
|
||||
|
||||
This needs to be an engine change, not a mod: there is no per-frame seam a
|
||||
mod can use to swap the multiplier mid-step, and no public event granular
|
||||
enough to say "which category is active" (`screen.pushed`/`screen.popped`
|
||||
and `battle.started`/`battle.ended` are the closest and are not enough --
|
||||
see Decisions below). The engine's own speed resolution has to become
|
||||
category-aware.
|
||||
|
||||
A category-aware speed resolution is also the general seam a
|
||||
platform-launcher integration or automation tool needs to read or override
|
||||
the effective multiplier for a given frame without caring which category
|
||||
produced it -- this RFC's `core.logic_speed` hook is written for that case
|
||||
alongside the player-facing Options rows.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
No prior D-number. Extends `GameSpeed.lua`'s multiplier ladder (unchanged)
|
||||
with per-category resolution.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible except for one save-data field rename, which ships with
|
||||
an automatic migration (see below) -- nothing in the public mod API (hooks,
|
||||
events, registries, `mod.*`) is renamed or removed.
|
||||
|
||||
### `save.options`: `speed` -> `speedOverworld` / `speedBattle` / `speedMenu`
|
||||
|
||||
`GameSpeed.CATEGORIES = { "overworld", "battle", "menu" }` is the new list
|
||||
of categories, and `GameSpeed.optionKey(category)` maps a category to its
|
||||
`save.options` field name (`"overworld"` -> `"speedOverworld"`, etc.).
|
||||
`GameSpeed.LEVELS`, `.DEFAULT`, `.levelLabel`, `.clamp` and `.cycle` are
|
||||
unchanged -- the ladder and its behavior are exactly what they were, just
|
||||
applied three times instead of once.
|
||||
|
||||
`SaveData.defaultOptions()` drops `speed = 1` and adds `speedOverworld = 1`,
|
||||
`speedBattle = 1`, `speedMenu = 1`. `SaveData.mergeOptions()` migrates: a
|
||||
loaded options table that still has `speed` and none of the three new
|
||||
fields seeds all three from it, so an existing player's fast-forward
|
||||
preference carries over instead of two of the three categories silently
|
||||
resetting to 1X. `speed` is dropped on the way out (not carried forward),
|
||||
so a re-save never re-triggers the migration.
|
||||
|
||||
### `Game.speedCategoryInStack(stack)`
|
||||
|
||||
New static helper, `(stack) -> "battle" | "overworld" | "menu"`. Walks the
|
||||
whole state stack top-down -- the same idiom `Game.wideBattleInStack` and
|
||||
`Game.fillScaleInStack` already use -- looking for `state.isBattle` (new
|
||||
marker, `BattleState.isBattle = true`, covering every battle: wild,
|
||||
trainer, link, safari, the old-man demo) or `state.isOverworld` (existing
|
||||
marker, `OverworldController`'s `OverworldState.isOverworld = true`). The
|
||||
first match wins; a state with neither marker (a menu, a text box, a
|
||||
naming screen, a cutscene) is transparent to the walk and falls through to
|
||||
whatever is under it. Nothing in the stack matching either falls back to
|
||||
`"menu"`.
|
||||
|
||||
### `Game:logicSpeed()` / `Game:_resolveLogicSpeed()`
|
||||
|
||||
`Game:_resolveLogicSpeed()` is new: it resolves `Game.speedCategoryInStack`
|
||||
against the live stack, maps the category to its `save.options` key via
|
||||
`GameSpeed.optionKey`, and returns `GameSpeed.clamp` of that option (or
|
||||
`GameSpeed.DEFAULT`). This is the exact category-resolution logic the new
|
||||
hook wraps.
|
||||
|
||||
`Game:logicSpeed()` keeps its existing early returns -- link play forces
|
||||
`1`, a run-argument speed override wins over the saved option -- unchanged,
|
||||
and in the same order, before ever calling the hook. Only once neither
|
||||
applies does it call the `core.logic_speed` hook.
|
||||
|
||||
### `Game:_cycleSpeed(dir)`
|
||||
|
||||
The keyboard hotkey and the gamepad shoulders/triggers that used to cycle
|
||||
the single `speed` option now cycle whichever category
|
||||
`Game.speedCategoryInStack` says is active: pressing the hotkey during a
|
||||
battle speeds up just the battle, on the overworld just the walk, in a menu
|
||||
just the menu. This is the natural per-category answer for a control that
|
||||
used to have one option to reach and now has three -- see Decisions below
|
||||
for why this reading was chosen over, say, always cycling `overworld`.
|
||||
|
||||
### `core.logic_speed`
|
||||
|
||||
New hook, `(game) -> number` through the public wrapper signature
|
||||
`(next, game)`, called once per `Game:logicSpeed()` (i.e. once per frame).
|
||||
Vanilla behavior (used when no mod claims the hook) is
|
||||
`Game:_resolveLogicSpeed()` -- exactly the category resolution above,
|
||||
nothing else. A subscriber may call `next(game)` and return its result to
|
||||
pass the vanilla multiplier through, or return a different number outright
|
||||
to override it for that frame (e.g. a bot mod forcing `1` during one route
|
||||
segment regardless of what category or option is active).
|
||||
|
||||
This intentionally sits *after* the link and speed-override checks in
|
||||
`Game:logicSpeed()`, not around them: link play staying locked to 1X "no
|
||||
matter what either player set this to" is exactly the invariant that would
|
||||
break if a mod's hook could override it, and the run-argument override
|
||||
exists so a bot/screenshot run's speed does not depend on a mod any more
|
||||
than on the player's saved option. Both stay unconditional early returns a
|
||||
mod never sees.
|
||||
|
||||
Not guarded by `Runtime.wantsHook`: `Hooks:call` already fast-paths to a
|
||||
bare `vanilla(...)` call when no mod has wrapped the name, and this hook
|
||||
fires every frame regardless.
|
||||
|
||||
## Decisions on the issue's open questions
|
||||
|
||||
**1. Overlays on top of another category's state (a party menu, a choice
|
||||
box, a naming screen opened mid-battle or mid-overworld).** Resolved by
|
||||
making the category a property of stack *position*, not of the overlay's
|
||||
own type: an overlay with no `isBattle`/`isOverworld` marker is transparent
|
||||
to `Game.speedCategoryInStack`'s walk and inherits whatever is under it. A
|
||||
party swap opened mid-battle reads as `"battle"`; a bag opened while
|
||||
walking reads as `"overworld"`. This was chosen over giving every UI state
|
||||
its own fixed category (which would make a fast-forwarded battle visibly
|
||||
stutter back to 1X every time its party menu opens) because it matches
|
||||
what the player is actually doing moment to moment, and it reuses a
|
||||
pattern the codebase already leans on for exactly this "menus opened over
|
||||
X should behave like X" class of problem (`Game.fillScaleInStack`,
|
||||
`Game.wideBattleInStack`).
|
||||
|
||||
**2. Cutscenes/scripts.** No fourth category. A scripted sequence runs
|
||||
through the owning state's own machinery -- the overworld's script runner
|
||||
or a battle's message queue -- rather than pushing a state of its own, so
|
||||
it is already covered by decision 1: it inherits whatever category the
|
||||
state driving it resolves to. A cutscene state that genuinely has nothing
|
||||
under it (a pre-game intro) falls to `"menu"`, the default for anything
|
||||
that is not battle or overworld gameplay -- consistent with those being
|
||||
pre-game presentation, not something a player is likely to want scaled
|
||||
differently from menu navigation.
|
||||
|
||||
**3. Category granularity (splitting "menu" further).** Deferred. Start
|
||||
with the three named here; `GameSpeed.CATEGORIES` and `GameSpeed.optionKey`
|
||||
are written so adding a fourth later (a Pokédex/Bag category, say) is one
|
||||
entry plus one new `save.options` field, not a resolution-logic rewrite.
|
||||
No current request motivates it.
|
||||
|
||||
**4. The GAME SPEED hotkey/shoulder buttons, once "the" speed is three
|
||||
things.** `Game:_cycleSpeed` now cycles whichever category is currently
|
||||
active (`Game.speedCategoryInStack`), rather than, say, always cycling
|
||||
`overworld` or requiring a modifier key to pick a category. A single
|
||||
physical control that means "speed up whatever I'm looking at right now"
|
||||
is the reading that needs no new UI and matches what a player pressing it
|
||||
mid-battle almost certainly wants.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing**, for the mod API surface: `content.X:register/override/get`,
|
||||
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, manifest v1 fields are
|
||||
untouched, and `GameSpeed.LEVELS`/`.DEFAULT`/`.levelLabel`/`.clamp`/`.cycle`
|
||||
keep their exact signatures and behavior.
|
||||
|
||||
**One save-data field**, for anything that read `save.options.speed`
|
||||
directly (not a formal registry/hook surface, but worth naming): it is
|
||||
superseded by `speedOverworld`/`speedBattle`/`speedMenu`, migrated
|
||||
automatically on load (see above) so a save from before this RFC keeps its
|
||||
player's chosen speed. A mod reading `save.options.speed` after this change
|
||||
sees `nil` (the key is dropped on migration, not kept as a stale alias) and
|
||||
should read the per-category fields, or hook `core.logic_speed` to observe
|
||||
the resolved multiplier directly regardless of which category produced it.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod:** `core.logic_speed` needs no dedicated no-mod test file --
|
||||
`tests/engine/gate_hooks.lua` walks the live hook catalog (which scans
|
||||
`src` for `Runtime.call("...")` call sites), so the new
|
||||
`Runtime.call("core.logic_speed", ...)` site is picked up and gated
|
||||
automatically: vanilla runs exactly once with an empty hook chain, an
|
||||
unsubscribed-but-live bus passes values and multiple returns through
|
||||
unchanged, and `Runtime.wantsHook` reads `false`.
|
||||
- **Mod-API:** `tests/engine/game_speed_categories_test.lua` exercises the
|
||||
hook through the public API (`Hooks.new()` + `bus:wrap("core.logic_speed",
|
||||
...)` + `Runtime.call`, the same idiom other hooks' tests use) -- a
|
||||
subscriber can read the vanilla category resolution via `next(game)` and
|
||||
can override it outright -- plus direct coverage of
|
||||
`Game.speedCategoryInStack` (battle-on-top, overworld-on-top, an overlay
|
||||
inheriting each, an empty/unmatched stack falling to `"menu"`) and
|
||||
`Game:logicSpeed()`'s precedence (link forces 1X over all three
|
||||
categories and over a hook override; the run-argument override wins over
|
||||
the category resolution).
|
||||
- `tests/run_tests.lua`'s OptionsMenu walk exercises the three new rows
|
||||
(OVERWORLD SPEED / BATTLE SPEED / MENU SPEED) cycling and wrapping
|
||||
independently, in place of the old single GAME SPEED row.
|
||||
- `tests/mod_ui_tests.lua`'s row-id/order check and hardcoded row-index
|
||||
activations (MODS, CONTROLS) are updated for the two extra rows.
|
||||
- A link-play driver should set all three per-category speeds high before
|
||||
asserting `game:logicSpeed()` reads `1` during a real link session,
|
||||
proving the lock wins over every category at once, not just whichever
|
||||
one happens to be active.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated in the mod-facing hook/event/registry catalog -- this
|
||||
adds one hook, additive. The `save.options.speed` field is superseded with
|
||||
an automatic migration rather than a deprecation notice, since it was never
|
||||
a registered mod-API surface (no schema entry, no registry) -- the same
|
||||
treatment any other `save.options` field would get if it needed reshaping.
|
||||
@@ -1,38 +1,66 @@
|
||||
# Build the Nintendo Switch NRO — contributor guide
|
||||
# Build Gen1Recomp for Nintendo Switch
|
||||
|
||||
Want to play a release build instead? Download the SD-ready zip and extract it
|
||||
at your microSD root — see [switch-install.md](switch-install.md).
|
||||
at your microSD root. See [switch-install.md](switch-install.md).
|
||||
|
||||
This guide is for contributors who build Gen1Recomp for Switch from source.
|
||||
Hardware evidence, MTP operator loops, and deeper notes live in
|
||||
[switch-development.md](switch-development.md).
|
||||
|
||||
> Releases ship `gen1recomp-*-switch.zip` (SD tree under `switch/gen1recomp/`;
|
||||
> issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Hardware
|
||||
> evidence: **OLED** (author) and **V1 boot** (community). See
|
||||
> [switch-development.md](switch-development.md) for known limitations.
|
||||
> Releases ship `gen1recomp-*-switch.zip` (SD tree under `switch/gen1recomp/`).
|
||||
> Runtime target is pinned [love-nx](https://github.com/retronx-team/love-nx)
|
||||
> `11.5-nx1`. Player install and limitations: [switch-install.md](switch-install.md).
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites by OS
|
||||
|
||||
All packaging entrypoints are **bash**. On Windows, use Git Bash, MSYS2, or
|
||||
WSL — not cmd.exe or PowerShell (AD-008).
|
||||
WSL, not cmd.exe or PowerShell (AD-008).
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
1. Install [devkitPro pacman](https://devkitpro.org/wiki/devkitPro_pacman).
|
||||
2. Install Switch tools:
|
||||
2. Install Switch tools (**required for `--fused`**):
|
||||
|
||||
```sh
|
||||
sudo dkp-pacman -S switch-dev
|
||||
```
|
||||
|
||||
3. Ensure `nacptool` and `elf2nro` are on `PATH` (or under
|
||||
`$DEVKITPRO/tools/bin` — the fused script prepends that when set).
|
||||
3. OTA launcher toolchain, **native or Docker** (either is fine):
|
||||
|
||||
**Optional:** Install [Docker](https://docs.docker.com/get-docker/) so fused
|
||||
builds can fall back to the pinned image when native tools are missing.
|
||||
```sh
|
||||
bash scripts/switch/install_devkitpro_deps.sh # native
|
||||
# or install Docker (same pin as fused builds)
|
||||
```
|
||||
|
||||
4. Ensure `DEVKITPRO` is exported (typical macOS: `/opt/devkitpro`) and
|
||||
`nacptool` / `elf2nro` are on `PATH` (or under `$DEVKITPRO/tools/bin`).
|
||||
|
||||
Fused game builds can also use Docker when native `nacptool`/`elf2nro` are absent.
|
||||
|
||||
### Native OTA launcher (included in `--fused`)
|
||||
|
||||
In-console OTA uses a **separate DEVKITPRO NRO** (not LÖVE). The LÖVE
|
||||
self-updater (`Check.lua`) is disabled on NX. Source:
|
||||
`ports/switch/ota-launcher/`. Host protocol tests (no toolchain):
|
||||
|
||||
```sh
|
||||
make -C ports/switch/ota-launcher host-test
|
||||
# or
|
||||
scripts/switch/build_ota_launcher.sh # host-test first; NRO needs DEVKITPRO/Docker
|
||||
```
|
||||
|
||||
`--fused` always builds the fused game, native OTA launcher, and dual-NRO SD
|
||||
zip. The same `*-switch.zip` is the OTA download asset. **DEVKITPRO is
|
||||
required.** OTA launcher: native packages **or** Docker. Both are supported.
|
||||
|
||||
Release-like build from repo root:
|
||||
|
||||
```sh
|
||||
scripts/build_switch.sh --fetch --fused --version X.Y.Z
|
||||
```
|
||||
|
||||
See `ports/switch/ota-launcher/README.md` and
|
||||
`scripts/switch/ota_launcher.manifest`.
|
||||
|
||||
### Windows (Git Bash / MSYS2 / WSL)
|
||||
|
||||
@@ -42,16 +70,15 @@ builds can fall back to the pinned image when native tools are missing.
|
||||
- **WSL** (Ubuntu/etc.) with the Linux pacman flow above, or
|
||||
- **Git Bash** for `--fetch` / `--loose`; for `--fused` prefer MSYS2 or
|
||||
WSL if Docker bind-mounts from Git Bash paths misbehave.
|
||||
2. Install `switch-dev` (or rely on Docker fallback — see below).
|
||||
2. Install `switch-dev` (or rely on Docker fallback; see below).
|
||||
3. Do **not** expect `scripts/build_switch.sh` to run under cmd/PowerShell.
|
||||
|
||||
### What you must install yourself
|
||||
|
||||
| You install | Script does **not** install |
|
||||
| ----------- | --------------------------- |
|
||||
| bash, git, zip tooling the repo already expects | — |
|
||||
| `dkp-pacman` + `switch-dev` (native fused) | `dkp-pacman -S …` |
|
||||
| Docker (optional fused fallback) | Docker Engine |
|
||||
| bash, git, zip tooling the repo already expects | (none) |
|
||||
| `dkp-pacman` + `switch-dev` + OTA packages **or** Docker | `dkp-pacman -S …` |
|
||||
| A legal `.gb` ROM (to play) | Any ROM or game data |
|
||||
|
||||
---
|
||||
@@ -64,12 +91,12 @@ builds can fall back to the pinned image when native tools are missing.
|
||||
| ---- | ------------ |
|
||||
| `--fetch` | Downloads pinned **love.nro** + **love.elf** into `.bazinga/love-nx/11.5-nx1/` and verifies SHA-256 against `scripts/switch/love-nx-11.5-nx1.sha256`. |
|
||||
| `--loose` | Packs `game.love`, copies pinned `love.nro` → `dist/switch/loose/` as `gen1recomp.nro` + `game.love` side by side. Needs the pin. |
|
||||
| `--fused` | Builds `dist/switch/gen1recomp-<ver>-switch.nro` (game in romfs) via `nacptool` + `elf2nro`, then packs `dist/switch/gen1recomp-<ver>-switch.zip` (SD-ready tree). Needs the pin + toolchain (native or Docker). GitHub Releases publish the **zip only**. |
|
||||
| `--fused` | Builds fused game NRO, OTA launcher NRO, and dual-NRO SD zip. **Requires DEVKITPRO** + `switch-dev`. OTA launcher: native packages or Docker. GitHub Releases publish the **zip only**. |
|
||||
|
||||
Rules:
|
||||
|
||||
- `--fetch` alone is fine; combine as `--fetch --loose` or `--fetch --fused`.
|
||||
- `--loose` and `--fused` are **XOR** — pick one packaging path per run.
|
||||
- `--loose` and `--fused` are **XOR**. Pick one packaging path per run.
|
||||
- `--version X.Y.Z` sets the NACP / filename version (defaults to short git SHA).
|
||||
|
||||
### What `--fetch` downloads
|
||||
@@ -109,13 +136,15 @@ scripts/build_switch.sh --fetch
|
||||
# Loose pair for iteration (fetch + assemble)
|
||||
scripts/build_switch.sh --fetch --loose
|
||||
|
||||
# Single fused NRO + SD-ready zip for a release-like artifact
|
||||
# Fused game + OTA launcher + dual-NRO SD zip for a release-like artifact
|
||||
scripts/build_switch.sh --fetch --fused --version 0.2.0
|
||||
```
|
||||
|
||||
Outputs land under `dist/switch/` (and `dist/switch/loose/` for loose mode).
|
||||
The fused path also writes `gen1recomp-<ver>-switch.nro.sha256` and
|
||||
`gen1recomp-<ver>-switch.zip` (+ `.sha256` sidecar for the zip).
|
||||
The fused path also writes `gen1recomp-<ver>-switch.nro` (game),
|
||||
`gen1recomp-<ver>-launcher.nro`, `gen1recomp-<ver>-game.nro`,
|
||||
`gen1recomp-<ver>-switch.nro.sha256`, and `gen1recomp-<ver>-switch.zip`
|
||||
(+ `.sha256` sidecar for the zip).
|
||||
|
||||
Offline packaging smoke (no network, no nacptool required):
|
||||
|
||||
@@ -143,7 +172,7 @@ the NX runtime modules `src/core/NxAssetOverlay.lua`, `src/core/Platform.lua`,
|
||||
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
|
||||
or the Switch-related workflow YAML), CI runs:
|
||||
|
||||
1. **Offline selftest** on `ubuntu-latest` (forks **and** the canonical repo):
|
||||
1. **Offline selftest** on `ubuntu-latest` (forks **and** the main repo):
|
||||
`scripts/switch/selftest_build_switch.sh`,
|
||||
`scripts/switch/verify_payload.sh --self-test`,
|
||||
`luajit tests/switch_ci_workflows_test.lua`,
|
||||
@@ -151,14 +180,10 @@ or the Switch-related workflow YAML), CI runs:
|
||||
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
|
||||
`luajit tests/engine/nx_generated_guard_test.lua`,
|
||||
`luajit tests/engine/nx_yellow_boot_test.lua`).
|
||||
2. **Fused NRO build** only on the **canonical** repository
|
||||
2. **Fused NRO build** only on the **main** repository
|
||||
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
|
||||
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow
|
||||
head is that repo (same-repo push/PR). **Fork repository** CI never runs
|
||||
fused. **Fork → canonical PRs** also skip Switch fused (offline selftest
|
||||
still runs) so untrusted head code is not executed on the self-hosted Mac;
|
||||
iOS device build eligibility is unchanged. Fused also waits for a successful
|
||||
offline selftest before starting on the Mac runner.
|
||||
head is that repo (same-repo push/PR). Fork CI never runs fused. Fork PRs into the main repo also skip Switch fused (offline selftest still runs) so untrusted head code is not executed on the self-hosted Mac; iOS device build eligibility is unchanged. Fused also waits for a successful offline selftest before starting on the Mac runner.
|
||||
3. On successful PR fused builds, a follow-up workflow posts a PR comment
|
||||
linking the Actions artifact named `gen1recomp-switch-nro`
|
||||
(comment tag `switch-build-result`; see
|
||||
@@ -169,7 +194,7 @@ Unrelated PRs do not burn the self-hosted Mac on Switch packaging.
|
||||
### Release hard-fail (`.github/workflows/release.yml`)
|
||||
|
||||
GitHub Releases always build Switch on the same self-hosted Mac runner as the
|
||||
other platforms — this is a **hard gate** (no `continue-on-error`):
|
||||
other platforms. This is a **hard gate** (no `continue-on-error`):
|
||||
|
||||
```sh
|
||||
scripts/build_switch.sh --fetch --fused --version "<release version>"
|
||||
@@ -181,9 +206,26 @@ A Switch packaging failure fails the entire release job. The release asset is
|
||||
|
||||
### Runner provisioning
|
||||
|
||||
The self-hosted Mac runner must have **native switch-tools** (`nacptool` /
|
||||
`elf2nro`) **and/or Docker** available. CI and release do not silently run
|
||||
`dkp-pacman -S`; keep the runner image/host provisioned per this guide.
|
||||
The self-hosted Mac runner **must** have **DEVKITPRO** installed and exported.
|
||||
`--fused` preflight fails early with setup steps if it is missing.
|
||||
|
||||
**One-time setup on the runner** (if not already present):
|
||||
|
||||
```sh
|
||||
# devkitPro pacman installer from https://devkitpro.org/wiki/devkitPro_pacman
|
||||
sudo dkp-pacman -S switch-dev
|
||||
export DEVKITPRO=/opt/devkitpro
|
||||
export PATH="$DEVKITPRO/tools/bin:$PATH"
|
||||
|
||||
# OTA launcher: pick one
|
||||
bash scripts/switch/install_devkitpro_deps.sh # native
|
||||
# or ensure Docker is installed (same pin as fused builds)
|
||||
```
|
||||
|
||||
CI and release still run `scripts/build_switch.sh --fetch --fused`. Preflight
|
||||
requires DEVKITPRO and either native OTA packages or Docker. Without all of
|
||||
that, the job fails with the setup steps above. Scripts never auto-run
|
||||
`dkp-pacman -S` during CI.
|
||||
|
||||
---
|
||||
|
||||
@@ -194,11 +236,9 @@ These scripts and this guide do **not**:
|
||||
- Push files to the console (no automated MTP / FTP / SD scripting)
|
||||
- Bundle or download any Pokémon ROM
|
||||
- Install `dkp-pacman` / `switch-dev` for you
|
||||
- Provide `nxlink` / netloader deploy (deferred — see [switch-transfer.md](switch-transfer.md))
|
||||
- Validate **Applet Mode** — use title override (hold **R**) for full memory
|
||||
- Provide `nxlink` / netloader deploy (deferred; see [switch-transfer.md](switch-transfer.md))
|
||||
- Validate **Applet Mode**. Use title override (hold **R**) for full memory
|
||||
|
||||
Player install steps: [switch-install.md](switch-install.md).
|
||||
Manual transfer (MTP / SD / FTP, macOS / Linux / Windows):
|
||||
[switch-transfer.md](switch-transfer.md).
|
||||
Hardware depth and evidence: [switch-development.md](switch-development.md),
|
||||
[switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
[switch-transfer.md](switch-transfer.md).
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
# Nintendo Switch development (love-nx)
|
||||
|
||||
> Fused NRO support for issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531).
|
||||
> Releases ship `gen1recomp-*-switch.zip` (SD-ready tree). Console copy is
|
||||
> extract/merge at microSD root; title override required. See
|
||||
> [Known limitations](#known-limitations-read-before-reviewing).
|
||||
|
||||
**Canonical install / build / transfer docs** (start here unless you need hardware depth):
|
||||
|
||||
- Players → [switch-install.md](switch-install.md)
|
||||
- Builders → [switch-build.md](switch-build.md) (`scripts/build_switch.sh --fetch` downloads the pinned love-nx pair)
|
||||
- Transfer (MTP / SD / FTP on macOS, Linux, Windows) → [switch-transfer.md](switch-transfer.md)
|
||||
|
||||
This document covers what landed, known limitations, how hardware was tested,
|
||||
vendor layout, build/deploy, and the contributor transfer loop (detail lives in
|
||||
the transfer runbook).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- **Port / love-nx packaging:** [andrewqsantos](https://github.com/andrewqsantos)
|
||||
- **Community hardware testing** (Switch V1 / Erista boot): [booshankles](https://github.com/booshankles)
|
||||
- **Method guidance:** [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch) / love-nx
|
||||
- **Upstream project:** [bryanthaboi](https://github.com/bryanthaboi) / Gen1Recomp
|
||||
|
||||
## Status
|
||||
|
||||
| Area | State |
|
||||
| ---- | ----- |
|
||||
| Feature | **Available** — playable fused NRO path (issue #531) |
|
||||
| Runtime | Pinned love-nx **`11.5-nx1`** |
|
||||
| Product artifact | Releases: SD-ready `gen1recomp-*-switch.zip`; local/PR: fused `.nro`; loose `nro`+`game.love` for iteration |
|
||||
| Hardware | **OLED** validated (author, title override); **V1 / Erista** boot confirmed (community). Lite, docked soak, and Pro Controller matrices welcome |
|
||||
| Deploy / install | Releases publish SD-ready zip; **extract/merge at microSD root** (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)); no `nxlink` path yet |
|
||||
| Contributor transfer | Documented for **macOS, Linux, and Windows**; OpenMTP on Mac is one example, not the only contract |
|
||||
| Network features on NX | Self-update / remote mod download **disabled** (`networkValidated == false`) |
|
||||
| Community help | Welcome — especially HOS / love-nx packaging and broader hardware coverage |
|
||||
|
||||
### What landed
|
||||
|
||||
- Detect `NX` via `src/core/Platform.lua` without reusing Android flags
|
||||
- Writable ROM inbox under `getSaveDirectory()/imports/` + per-tab “Scan again” (SHA-1 match for the open game)
|
||||
- Joy-Con / gamepad mapping shared by launcher and gameplay (Nintendo A/B UX on NX)
|
||||
- Launcher L/R tab switch; gameplay L/R game-speed cycle; Select+face display chords
|
||||
- Focus loss / joystick reconnect recovery; opt-in `switch-debug.txt` diagnostics
|
||||
- Loose assemble + fused NRO build scripts (`scripts/build_switch.sh`, `scripts/switch/*`)
|
||||
- Payload gates so ROM / generated cache / saves never enter `game.love`
|
||||
- Community mod zip inbox at `imports/mods/` (rescan installs; FIND MODS stays network-gated)
|
||||
- Raw `.sav` inbox at `imports/saves/{red,blue,yellow}/` (**Import save** rescan) + export pull path `exports/{red,blue,yellow}/` (MTP hint; no openURL)
|
||||
- Hardware evidence for Phase 0 probe, ROM import, naming A/B, save/suspend, fused NRO — see `docs/switch-hardware-evidence.md`
|
||||
- Path-gated CI selftest + canonical fused PR artifact; release Switch hard-fail
|
||||
- Save editor pad/touch input (virtual cursor, A click, B close) — see `tools/save-editor/README.md`
|
||||
- Dynamic display size on NX only: handheld **1280×720**, docked/TV **1920×1080** (`src/core/NxDisplay.lua` + resizable conf so love-nx SDL can follow dock/undock at runtime)
|
||||
|
||||
### Known gaps / welcome contributions
|
||||
|
||||
- Docked vs handheld soak (≥30 min) and Lite coverage — resolution switch is implemented; long soak still welcome
|
||||
- Switch Lite and fuller Pro Controller / third-party pad matrices
|
||||
- Applet Mode remains unsupported by design (title override required)
|
||||
- `nxlink` / netloader contrib fast-loop (deferred — see [switch-transfer.md](switch-transfer.md))
|
||||
|
||||
Transfer runbooks for Linux/Windows (and SD/FTP alternatives) are in
|
||||
[switch-transfer.md](switch-transfer.md). Community mod zip install OLED smoke
|
||||
is **pass** — see NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
|
||||
## Design references (Dusklight)
|
||||
|
||||
This work borrowed method — not the native stack — from the [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch), especially [`LESSONS_AND_REUSE.md`](https://github.com/HayatoG/dusklight/blob/main/platforms/switch/LESSONS_AND_REUSE.md):
|
||||
|
||||
| Dusklight lesson | How Gen1Recomp applied it |
|
||||
| ---------------- | ------------------------- |
|
||||
| Emulators hide Tegra failures | Gate milestones on **real OLED hardware**, not Ryujinx/Yuzu alone |
|
||||
| Prove the lower layer first | `tools/switch-probe` before full launcher |
|
||||
| Know which binary ran | Embedded `build-info.json` (commit / love-nx tag) |
|
||||
| Cap continuous logs | Opt-in diagnostics, ≤1 Hz flush; Lua error log rotation |
|
||||
| Crash symbolization needs the exact ELF | Keep pinned `love.elf` with the NRO under test |
|
||||
| Full memory matters | Title override; Applet Mode is not the validation path |
|
||||
| Do not treat SD FS like desktop POSIX | Lua stays on `love.filesystem`; inbox + MTP for user files |
|
||||
| Isolate platform code | Capability module instead of Android flag overload |
|
||||
| NVK / WSI / `audren` stacks | **Not** copied — love-nx already supplies video/audio/input/FS |
|
||||
|
||||
The packaging goal matches Dusklight’s **single self-contained `.nro`**; contributor transfer stays multi-host (not Mac-only).
|
||||
|
||||
## Known limitations (read before reviewing)
|
||||
|
||||
1. **Transfer is manual and multi-method.** Runtime only needs files under the LÖVE save directory / NRO install folder. Use MTP, direct SD, or FTP per [switch-transfer.md](switch-transfer.md). macOS + OpenMTP is a documented example for OLED evidence — not “Switch requires a Mac.”
|
||||
2. **Deploy is manual.** There is no automated push to the console and no `nxlink` path yet. Operators build locally, transfer files, then title-override launch.
|
||||
3. **Hardware coverage.** Author P0/P1 pass rows were recorded on one Switch OLED; Switch V1 boot was confirmed independently. Treat Lite, docked soak, and other hosts as unknown until someone re-runs the checklist.
|
||||
4. **No ROM/save/mod zip bytes in git.** Legal dumps and third-party mods stay on the console (or local untracked folders).
|
||||
5. **AppleDouble sidecars** (`._*`) from some MTP clients can break zip/ROM/`.sav` scans — the launcher skips hidden `.*` names (including `._*.sav`); still prefer clean copies.
|
||||
|
||||
## How we tested
|
||||
|
||||
| Layer | What | Where |
|
||||
| ----- | ---- | ----- |
|
||||
| Unit / headless | Platform NX flags, RomImporter inbox, dual-path input, mod zip inbox, save `.sav` inbox, display chords, payload/self-tests | `tests/*`, `scripts/test.sh` |
|
||||
| Switch CI / packaging | Path-gated offline selftest (`selftest_build_switch.sh`, `verify_payload.sh --self-test`, `switch_ci_workflows_test.lua`); canonical fused PR artifact | `.github/workflows/ci.yml`, [switch-build.md](switch-build.md) § CI and release |
|
||||
| Probe on hardware | `getOS()==NX`, 1280×720, save path, Joy-Con events | `tools/switch-probe` → OLED |
|
||||
| Integration on hardware | MTP inbox ROM import, Play Red/Blue, naming A/B, quit/reopen save, suspend×10, reboot, fused NRO alone + NRO-only update | `docs/switch-hardware-evidence.md` |
|
||||
| Community hardware | Switch V1 / Erista boot with prebuilt NRO | [booshankles](https://github.com/booshankles) — see evidence log |
|
||||
| Known gaps | Docked soak, ≥30 min long-play, Lite, automated/`nxlink` deploy | Matrix deferred / absent rows |
|
||||
|
||||
Operator evidence must stay in `docs/switch-hardware-evidence.md`. **Do not invent passes** for hardware not run.
|
||||
|
||||
## love-nx 11.5-nx1 (pinned)
|
||||
|
||||
**Tag:** [11.5-nx1](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1)
|
||||
|
||||
**Local layout (not committed):**
|
||||
|
||||
```text
|
||||
.bazinga/love-nx/11.5-nx1/
|
||||
├── love.nro # homebrew launcher binary (loose mode: copied to gen1recomp.nro)
|
||||
└── love.elf # required for fused NRO builds (devkitPro nacptool/elf2nro)
|
||||
```
|
||||
|
||||
**Manifest:** `scripts/switch/love-nx-11.5-nx1.sha256` lists expected artifact names and SHA-256 checksums. Checksums are filled when binaries are fetched (`TBD_*` placeholders until then).
|
||||
|
||||
### Fetch instructions
|
||||
|
||||
Preferred (automated checksum verify):
|
||||
|
||||
```bash
|
||||
scripts/build_switch.sh --fetch
|
||||
```
|
||||
|
||||
That downloads pinned `love.nro` + `love.elf` into `.bazinga/love-nx/11.5-nx1/`
|
||||
and checks them against `scripts/switch/love-nx-11.5-nx1.sha256`. See
|
||||
[switch-build.md](switch-build.md) for the full mode glossary.
|
||||
|
||||
Manual fallback:
|
||||
|
||||
1. Open the [11.5-nx1 release](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1) and download `love.nro` and `love.elf`.
|
||||
2. Create the directory: `mkdir -p .bazinga/love-nx/11.5-nx1`
|
||||
3. Move both files into that directory.
|
||||
4. Confirm checksums match the manifest:
|
||||
|
||||
```bash
|
||||
shasum -a 256 .bazinga/love-nx/11.5-nx1/love.nro \
|
||||
.bazinga/love-nx/11.5-nx1/love.elf
|
||||
```
|
||||
|
||||
**Never commit** love-nx binaries, ROM dumps, or generated cache into git. The repo `.gitignore` excludes `.bazinga/` (vendor cache) and `/dist/` (build output).
|
||||
|
||||
## Loose-mode dist layout
|
||||
|
||||
Development builds place `gen1recomp.nro` and `game.love` side by side:
|
||||
|
||||
```text
|
||||
dist/switch/loose/
|
||||
├── gen1recomp.nro
|
||||
└── game.love
|
||||
```
|
||||
|
||||
Assemble with:
|
||||
|
||||
```bash
|
||||
scripts/build_switch.sh --loose
|
||||
```
|
||||
|
||||
(See `scripts/switch/assemble_loose.sh` for the underlying copy + checksum step.)
|
||||
|
||||
## Transfer & deploy (current contributor loop)
|
||||
|
||||
Detail for **macOS / Linux / Windows** and **MTP / SD / FTP** lives in
|
||||
[switch-transfer.md](switch-transfer.md). Summary:
|
||||
|
||||
| Layer | Intent |
|
||||
| ----- | ------ |
|
||||
| **Runtime / players** | Extract the release zip at microSD root (`switch/gen1recomp/`) and land ROMs/mods under the save-dir inboxes. The game does not hard-depend on OpenMTP or macOS. |
|
||||
| **Contributor loop** | Manual copy via MTP (DBI responder), direct SD (Hekate UMS / reader), or FTP. Fully manual — no CI deploy, no `nxlink` yet. |
|
||||
|
||||
The Mac + OpenMTP steps that remain below are the **OLED evidence reproduction** path; prefer the transfer runbook for day-to-day contrib on other hosts.
|
||||
|
||||
**Still avoided for routine evidence** (keeps SD handling honest):
|
||||
|
||||
- Treating `nxlink` / netloader as the release deploy story (deferred)
|
||||
- DBI `MicroSD install` / `NAND install` / NSP-style virtual folders for the `.love`/`.nro` pair
|
||||
|
||||
If MTP fails: check cable, USB port, DBI state, and that only one MTP client holds the device — then retry or switch to SD/FTP. Do not silently rewrite evidence using an untested path and claim parity with recorded SHA-256 round-trips.
|
||||
|
||||
### Manual deploy checklist (today)
|
||||
|
||||
1. Build on the contributor host (`scripts/build_switch.sh --loose` or fused).
|
||||
2. Close Gen1Recomp on the Switch; open DBI → `Run MTP responder`.
|
||||
3. Copy artifacts with your MTP client into `1: SD Card/switch/gen1recomp/` (and ROMs/mods into the save-dir inboxes when needed).
|
||||
4. Wait for the transfer queue; refresh; optionally round-trip SHA-256 on first artifacts of a type.
|
||||
5. Exit MTP; launch via **title override** (hold **R** on a title → hbmenu, not Applet Mode).
|
||||
|
||||
## OpenMTP + DBI transfer (loose build, Mac evidence example)
|
||||
|
||||
Full multi-OS / multi-method steps: [switch-transfer.md](switch-transfer.md).
|
||||
The numbered Mac loop below reproduces the OLED evidence path.
|
||||
|
||||
### On the Switch
|
||||
|
||||
1. Close Gen1Recomp if it is running.
|
||||
2. Open **DBI** from hbmenu.
|
||||
3. Select **`Run MTP responder`** (DBI documents `X` on the main screen).
|
||||
4. Keep DBI on that screen for the entire transfer.
|
||||
5. Connect the Switch to the Mac with a USB-C data cable.
|
||||
|
||||
### On the Mac
|
||||
|
||||
1. Close any other MTP clients.
|
||||
2. Open **OpenMTP** and select the DBI device.
|
||||
3. In the remote pane, open **`1: SD Card`**.
|
||||
4. Navigate to **`switch/`** and create **`gen1recomp/`** if needed.
|
||||
5. Enter **`1: SD Card/switch/gen1recomp/`**.
|
||||
6. Drag from the local pane:
|
||||
|
||||
```text
|
||||
dist/switch/loose/gen1recomp.nro
|
||||
dist/switch/loose/game.love
|
||||
```
|
||||
|
||||
7. Wait for the OpenMTP queue to finish completely.
|
||||
8. Refresh the remote listing and confirm file sizes match the local files.
|
||||
9. On the Switch, exit MTP responder normally in DBI before launching the app.
|
||||
|
||||
Expected layout on SD:
|
||||
|
||||
```text
|
||||
1: SD Card/
|
||||
└── switch/
|
||||
└── gen1recomp/
|
||||
├── gen1recomp.nro
|
||||
└── game.love
|
||||
```
|
||||
|
||||
## Round-trip SHA-256 verification
|
||||
|
||||
For the **first deploy** of each artifact type (loose pair, later fused NRO), verify MTP integrity:
|
||||
|
||||
1. **Before send** — record local hashes:
|
||||
|
||||
```bash
|
||||
shasum -a 256 dist/switch/loose/gen1recomp.nro \
|
||||
dist/switch/loose/game.love
|
||||
```
|
||||
|
||||
2. **After send** — in OpenMTP, copy the same files from `1: SD Card/switch/gen1recomp/` back to an empty local folder, e.g. `dist/switch/mtp-roundtrip/`.
|
||||
|
||||
3. **Compare** round-trip hashes:
|
||||
|
||||
```bash
|
||||
shasum -a 256 dist/switch/mtp-roundtrip/gen1recomp.nro \
|
||||
dist/switch/mtp-roundtrip/game.love
|
||||
```
|
||||
|
||||
4. Local pre-send and round-trip hashes **must match**. Record results in the test report template below.
|
||||
|
||||
Repeat whenever a cable glitch or interrupted transfer is suspected.
|
||||
|
||||
## Title override launch (full memory)
|
||||
|
||||
Applet Mode is **not** the primary validation path. Use **title override** so hbmenu runs with full memory:
|
||||
|
||||
1. Confirm the OpenMTP transfer queue finished.
|
||||
2. Exit MTP responder in DBI; disconnect USB if desired.
|
||||
3. Hold **`R`** while launching any legitimately installed title.
|
||||
4. Keep holding until **hbmenu** appears.
|
||||
5. Confirm hbmenu does **not** show **Applet Mode**.
|
||||
6. Launch **`gen1recomp`** (or the probe NRO during Phase 0).
|
||||
|
||||
Album / applet launches are only useful to document applet-specific limitations; P0/P1 gates use title override.
|
||||
|
||||
## Phase 0 hardware checklist
|
||||
|
||||
Complete **in order** on OLED hardware. Operator fills evidence fields — leave blank until tested.
|
||||
|
||||
| Step | Action | Pass | Evidence / notes |
|
||||
| ---- | ------ | ---- | ---------------- |
|
||||
| P0-0a | Fetch love-nx 11.5-nx1; record manifest SHA-256 | yes | See `scripts/switch/love-nx-11.5-nx1.sha256` |
|
||||
| P0-0b | Build `switch-probe.love` per `tools/switch-probe/README.md` | yes | |
|
||||
| P0-0c | Assemble loose probe (`game.love` = probe) to `dist/switch/loose/` | yes | |
|
||||
| P0-0d | MTP deploy to `1: SD Card/switch/gen1recomp/`; round-trip SHA-256 | yes | nro `8290ac15…5918f5`; love `9f198637…fa2e34f` |
|
||||
| P0-0e | Title override → probe boots; `getOS()` shows `NX` | yes | `getOS()`=`NX`, `love._os`=`NX` |
|
||||
| P0-0f | Probe lists 1280×720 (or documented dims), save path, gamepad/touch log | yes | save `sdmc:/switch/gen1recomp/switch-probe`; Joy-Con Y→#3 X→#4 |
|
||||
| P0-1a | Replace `game.love` with unpatched Gen1Recomp build | yes | feat/switch-nx inbox build |
|
||||
| P0-1b | MTP replace `game.love` only; round-trip SHA-256 | yes | |
|
||||
| P0-1c | Title override → launcher reaches import screen | yes | |
|
||||
| P0-1d | Joy-Con: can navigate launcher (no touch-only) | yes | Full report: `docs/switch-hardware-evidence.md` |
|
||||
|
||||
**Operator:** Andrew **Date:** 2026-08-01 **Console:** Switch OLED only
|
||||
**Deploy:** manual Mac + OpenMTP + DBI MTP (not automated)
|
||||
**love-nx tag:** 11.5-nx1 **gen1recomp commit:** `df7cea4`
|
||||
|
||||
## Phase 0 test report template
|
||||
|
||||
Copy this block into your hardware notes or PR evidence. **Do not commit ROM files or ROM hashes of private dumps.**
|
||||
|
||||
```markdown
|
||||
## Switch Phase 0 — hardware report
|
||||
|
||||
- Operator:
|
||||
- Date:
|
||||
- Console model:
|
||||
- Atmosphère / HOS version:
|
||||
- gen1recomp commit:
|
||||
- love-nx tag: 11.5-nx1
|
||||
- love.nro SHA-256 (local):
|
||||
- game.love SHA-256 (local, pre-send):
|
||||
- MTP round-trip SHA-256 (gen1recomp.nro):
|
||||
- MTP round-trip SHA-256 (game.love):
|
||||
- Title override used: yes / no
|
||||
- Applet Mode observed: yes / no (should be no for P0)
|
||||
- Probe getOS():
|
||||
- Probe dimensions:
|
||||
- Probe save directory shown:
|
||||
- Gamepad events logged: yes / no
|
||||
- Touch events logged: yes / no
|
||||
- Unpatched launcher boot: pass / fail
|
||||
- Joy-Con launcher navigation: pass / fail / not tested
|
||||
- Notes:
|
||||
```
|
||||
|
||||
## Fast dev loop (loose mode)
|
||||
|
||||
While iterating on Lua/assets:
|
||||
|
||||
1. Edit on Mac; run `scripts/test.sh --quick`.
|
||||
2. Rebuild `.bazinga/work/game.love` (`scripts/build.sh mac --no-notarize` or project pack step).
|
||||
3. Close Gen1Recomp on Switch.
|
||||
4. DBI → `Run MTP responder`.
|
||||
5. OpenMTP → `1: SD Card/switch/gen1recomp/`.
|
||||
6. Replace **only** `game.love`; wait for queue + refresh listing.
|
||||
7. Exit MTP responder; launch via title override.
|
||||
8. Keep `gen1recomp.nro` unchanged until the love-nx pin changes.
|
||||
|
||||
```bash
|
||||
scripts/test.sh --quick
|
||||
scripts/build.sh mac --no-notarize
|
||||
scripts/build_switch.sh --loose
|
||||
shasum -a 256 .bazinga/work/game.love
|
||||
```
|
||||
|
||||
## Controller input mapping (NX)
|
||||
|
||||
Measured on Switch OLED (`feat/switch-nx`, love-nx `11.5-nx1`, 1280×720). Both `joystickpressed` and `gamepadpressed` fire for Joy-Con; prefer the gamepad path when `joystick:isGamepad()` is true.
|
||||
|
||||
| Path | Control | Mapping |
|
||||
| ---- | ------- | ------- |
|
||||
| `gamepadpressed` | D-pad / left stick | move |
|
||||
| `gamepadpressed` | SDL `a` / `b` on **NX** | swapped via `NX_GAMEPAD_BINDINGS`: physical **A** (east) = GB A confirm, physical **B** (south) = GB B cancel |
|
||||
| `gamepadpressed` | SDL `a` / `b` on desktop | identity (SDL south = GB A) |
|
||||
| `gamepadpressed` | `start` / `back` | Start / Select (+ / −) |
|
||||
| `gamepadpressed` | Right / left shoulder (no Select) | Cycle game speed up / down (same as PC hotkey `1` / speed-down path) |
|
||||
| `joystickpressed` (raw) | only if **not** `isGamepad()` | face/menu fallback |
|
||||
| `joystickpressed` (raw) | `#1` / `#2` on NX | Nintendo B / A → GB B / A |
|
||||
| `joystickpressed` (raw) | `#9` / `#10` | Select / Start (− / +) |
|
||||
|
||||
**Nintendo UX on Switch:** physical A confirms, physical B cancels (explicit NX remap of SDL face labels).
|
||||
|
||||
**Launcher extras** (`RomImporter`): physical **A** clicks at the virtual cursor; **L** / **R** switch tabs; **Start** / **Select** start Play when a ROM is ready (else open Choose ROM). D-pad / left stick move the virtual cursor.
|
||||
|
||||
**Dual-path rule:** love-nx emits both `gamepadpressed` and `joystickpressed` for Joy-Con. When `joystick:isGamepad()` is true, Input and RomImporter **ignore raw** face/menu so NamingScreen does not see A+B in one frame. `NamingScreen` also prefers A over B if both edges still fire.
|
||||
|
||||
Implementation: `src/core/GamepadMap.lua` (`NX_RAW_*`, `ignoreRawForJoystick`, `displayChordDigit`), `src/core/Game.lua` (shoulder speed), `src/import/RomImporter.lua` (launcher tabs). Launcher and gameplay share the same converter.
|
||||
|
||||
## ROM inbox (NX)
|
||||
|
||||
Legal dumps land in a shared MTP inbox; **Scan again** is tab-scoped:
|
||||
|
||||
| Item | Value |
|
||||
| ---- | ----- |
|
||||
| Save-relative path | `imports/` (also accepts loose `.gb`/`.gbc` at the save-dir root) |
|
||||
| MTP destination | `1: SD Card/<save identity>/imports/` (see launcher notice for the live `getSaveDirectory()` path) |
|
||||
| Candidates | `*.gb` / `*.gbc` (hidden `.*` AppleDouble names skipped) |
|
||||
| Rescan | Game tab → **Scan again** — imports only the dump whose SHA-1 matches that tab (`GameVersion.forSha1`). Other known dumps stay for their own tabs |
|
||||
| Already ready | Same SHA already imported → “No new ROM found.” |
|
||||
|
||||
Players may drop Red, Blue, and Yellow into the same folder. Opening Yellow and pressing **Scan again** must not start a Red import.
|
||||
|
||||
## Mod zip inbox (NX)
|
||||
|
||||
Community mods install from a **separate** MTP inbox (not mixed into the ROM `imports/` scan):
|
||||
|
||||
| Item | Value |
|
||||
| ---- | ----- |
|
||||
| Save-relative path | `imports/mods/` |
|
||||
| MTP destination | `1: SD Card/<save identity>/imports/mods/` (see launcher notice for the live `getSaveDirectory()` path) |
|
||||
| Candidates | `*.zip` only |
|
||||
| Rescan | MODS tab → **Scan again** (installs each zip via `LauncherMods.installZip`; source zips are retained on success and failure) |
|
||||
| FIND MODS | Remains network-gated / hidden on NX (`networkValidated == false`) |
|
||||
|
||||
Do **not** commit third-party mod zip bytes into git. Drop the zip over MTP, rescan, enable in MODS, then Play.
|
||||
|
||||
**MTP tip (esp. macOS clients):** OpenMTP/Finder often creates AppleDouble sidecars named `._Something.zip` / `._cart.gb` / `._foo.sav`. Those are not real archives, ROMs, or saves — the launcher ignores hidden `.*` names under `imports/`, `imports/mods/`, and `imports/saves/<game>/`. If install still fails with “could not be opened” / “not a zip file”, delete any `._*` under the inbox and confirm the real zip starts with the `PK` magic (re-copy the release asset if unsure). This is a host-side annoyance of the current manual MTP loop, not something players should need forever.
|
||||
|
||||
Drop any community release `.zip` into `imports/mods/`, rescan, enable.
|
||||
Player-facing install steps: [switch-install.md](switch-install.md#community-mods).
|
||||
Mods own their OPTIONS / rebinds — do not duplicate third-party control tables here.
|
||||
|
||||
## Save `.sav` inbox (NX)
|
||||
|
||||
Raw Gen1 battery images use a **separate** MTP inbox (not mixed into ROM `imports/` or mod `imports/mods/`):
|
||||
|
||||
| Item | Value |
|
||||
| ---- | ----- |
|
||||
| Save-relative path | `imports/saves/red/`, `imports/saves/blue/`, `imports/saves/yellow/` |
|
||||
| MTP destination | `1: SD Card/<save identity>/imports/saves/<game>/` (see launcher notice for the live `getSaveDirectory()` path) |
|
||||
| Candidates | non-hidden `*.sav` only in **that game’s** folder |
|
||||
| Rescan | SAVE FILES → **Import save** on the matching game tab (scans only that folder) |
|
||||
| After success | Retire to `*.sav.imported` + append content hash to `imports/saves/<game>/.imported-sha1` |
|
||||
| Exports | **Export save** writes under `exports/<game>/gen1recomp-<game>-<slot>.sav`; NX shows an MTP path notice (no `openURL`) |
|
||||
|
||||
Do **not** commit `.sav` bytes into git. Drop the file into the matching game folder over MTP, press **Import save** on that tab, then play. Pull exports from `exports/<game>/`.
|
||||
|
||||
**MTP tip:** the same AppleDouble `._*.sav` rule applies — see the mod inbox tip above.
|
||||
|
||||
## Joy-Con display chords (Select + face)
|
||||
|
||||
PC digit hotkeys for COLORS / TILT / GBC FX / pipelines have Joy-Con equivalents. Hold **Select** (`back` / −) and press a face/shoulder button; the engine runs the same path as `Game:keypressed` for that digit (including `writeOptions` / Pipelines parity).
|
||||
|
||||
| Chord (Nintendo UX) | Engine key | Stock engine effect |
|
||||
| ------------------- | ---------- | ------------------- |
|
||||
| Select + **A** | `2` | COLORS cycle |
|
||||
| Select + **B** | `3` | TILT / perspective |
|
||||
| Select + **Y** | `5` | GBC FX |
|
||||
| Select + **X** | `6` | Mod pipeline hotkey (if registered) |
|
||||
| Select + **L** (left shoulder) | `7` | Mod pipeline hotkey (if registered) |
|
||||
|
||||
Keys `2` / `3` / `4` / `5` are claimed by the engine before mod pipeline hotkeys run, so a community mod cannot rebind those digits through `Pipelines.hotkey`. Mods that need their own controls should use OPTIONS rows or unclaimed hotkeys.
|
||||
|
||||
Without Select held, face buttons keep normal GB A/B gameplay mapping (no accidental color/tilt cycles). The **Options** menu remains available for the same settings — chords are optional shortcuts, not the only path.
|
||||
|
||||
On NX, A/B chords resolve through the Nintendo UX face remap so physical **A** → key `2` and physical **B** → key `3` match this table.
|
||||
|
||||
**OPTIONS → PERFORMANCE** clamps the port’s own extras (TILT / GBC FX / survey ZOOM) and can cap FPS — useful on weaker handheld budgets. Details: [new-features.md — Performance tier](new-features.md#performance-tier-low-end-devices).
|
||||
|
||||
Community mod zip install smoke (MODS inbox + Play): NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
|
||||
**Opt-in diagnostics:** create an empty `switch-debug.txt` in the save directory; events flush to `switch.log` at ≤1 Hz with build identity (no ROM/save bytes).
|
||||
|
||||
**NX asset probe (always on Play):** every Switch Play writes `nx-asset-probe.log` in the save directory (`pokemon-love2d/`). It lists whether `assets/generated/…` vs `yellow|blue/assets/generated/…` exist, what `Assets.resolve` returns, and whether `newImage` / `newImageData` open — for Yellow/Blue blank-sprite triage. No ROM bytes.
|
||||
|
||||
**Blue/Yellow cache overlay (NX):** fused love-nx cannot reliably mount `yellow|blue/assets/generated` onto the un-prefixed path, so `src/core/NxAssetOverlay.lua` wraps EVERY read-side love API that accepts a filesystem path (`filesystem.read/load/lines/newFileData/getInfo`, `graphics.newImage/newFont`, `image.newImageData`, `audio.newSource`, `sound.newSoundData`, `font.newFontData`) once at boot — only when `Platform.isNX()`. Covering the whole read surface (not just the loaders the boot needs today) keeps future states and mods inside the fallback automatically; write-side functions stay stock. Core code must NOT call love loaders on literal `assets/generated` paths (enforced by `tests/engine/nx_generated_guard_test.lua`); the chip-audio worker is a separate Lua state and gets the prefix explicitly via `audio.programPrefix` from `ChipAudio.slimAudio`.
|
||||
|
||||
**Hardware re-test:** T16 **pass** @ `2699c9a` (naming A=confirm / B=cancel). T19 **pass** (quit/reopen, suspend×10, reboot) — operator 2026-08-01.
|
||||
|
||||
**Suspend/resume audio:** after resume, chip music is stopped to avoid duplicate streams; confirm on hardware during P0-09/10 (T19).
|
||||
|
||||
## Lua error log (save directory)
|
||||
|
||||
On any uncaught Lua error, Gen1Recomp appends a redacted trace to `lua-error.log` in the LÖVE save directory (`love.filesystem.getSaveDirectory()`). The on-screen error overlay includes a hint pointing at that file. Logs rotate to `lua-error.log.1` when the active file exceeds 32 KiB. ROM/save bytes and non-printable data are stripped — never commit or share logs that might contain private paths without reviewing them first.
|
||||
|
||||
## Native crash triage (love-nx / Atmosphère)
|
||||
|
||||
love-nx native faults land under the console’s `crash_reports/` folder on SD (reachable via the same manual MTP workflow used for game deploys).
|
||||
|
||||
1. **Collect** — DBI → `Run MTP responder`; copy `sdmc:/crash_reports/*.bin` (or the dated subfolder) to the contributor host. Prefer keeping the microSD in-console for routine pulls.
|
||||
2. **Redact** — delete any attached screenshots or notes that mention ROM filenames, save paths, or private hashes before sharing logs publicly.
|
||||
3. **Symbolize** — use the **pinned** `love.elf` from `.bazinga/love-nx/11.5-nx1/` that matches `build-info.json` / `scripts/switch/love-nx-11.5-nx1.sha256`. Never use a “latest” download.
|
||||
|
||||
```bash
|
||||
# Example: aarch64-none-elf-addr2line from devkitPro
|
||||
aarch64-none-elf-addr2line -e .bazinga/love-nx/11.5-nx1/love.elf -f -C 0xADDRESS_FROM_CRASH_REPORT
|
||||
```
|
||||
|
||||
4. **Correlate** — compare `gitCommit` / `loveNxTag` from embedded `build-info.json` with the operator’s hardware notes.
|
||||
|
||||
If `addr2line` cannot resolve an address, archive the crash `.bin` with the exact `love.elf` SHA-256 used for the build — addresses are only meaningful against that ELF.
|
||||
|
||||
## P0 / P1 hardware matrix (ADR §9)
|
||||
|
||||
Operator evidence lives in `docs/switch-hardware-evidence.md`. **Do not invent passes** for rows that require hardware not yet run.
|
||||
|
||||
| ID | Requirement | Status | Evidence |
|
||||
| -- | ----------- | ------ | -------- |
|
||||
| P0-0a–f | love-nx pin, probe, MTP, title override | **pass** | Phase 0 checklist above; T4 |
|
||||
| P0-1a–d | Unpatched launcher boot + Joy-Con nav | **pass** | T4 / `docs/switch-hardware-evidence.md` |
|
||||
| P0-02 | MTP inbox import path shown | **pass** | T12 |
|
||||
| P0-03 | Rescan imports ROM | **pass** | T12 |
|
||||
| P0-04 | Canonical hash routes version | **pass** | T12 |
|
||||
| P0-05 | Source dump retained in inbox | **pass** | T12 |
|
||||
| P0-06 | Play reaches game after import | **pass** | T12 |
|
||||
| P0-07 | Joy-Con launcher navigation | **pass** | T16 @ `2699c9a` |
|
||||
| P0-08 | Joy-Con gameplay (incl. naming A/B) | **pass** | T16 @ `2699c9a` |
|
||||
| P0-09 | Save survives quit + reopen | **pass** | T19 |
|
||||
| P0-10 | ≥10 suspend cycles, no stuck input/dup audio | **pass** | T19 (operator 2026-08-01) |
|
||||
| P0-12 | Fused NRO boots without adjacent `game.love` | **pass** | T24 — `docs/switch-hardware-evidence.md` |
|
||||
| P0-14 | Fused NRO MTP round-trip SHA-256 | **pass** | T24 — first artifact `b019e2e8…` @ `6fb5602` (redeploy after Blue fix) |
|
||||
| P0-15 | Replace NRO only; saves persist | **pass** | T24 — operator NRO-only update keeps saves |
|
||||
| P1-01 | Docked vs handheld spot-check | **deferred** | Code: `NxDisplay` 720p↔1080p; OLED dock soak not recorded yet |
|
||||
| P1-02 | Applet Mode documented unsupported | **pass** | Title override required; Album path not validated |
|
||||
| P1-03 | Long-play soak (≥30 min) | **deferred** | No soak session recorded |
|
||||
| P1-04 | Reboot persistence | **pass** | T19 |
|
||||
| P1-05 | Audio resume after suspend | **pass** | T19 (no dup audio reported) |
|
||||
| — | Switch V1 / Erista boot | **pass** (boot) | Community — [booshankles](https://github.com/booshankles); see evidence log |
|
||||
| — | Switch Lite / docked soak | **untested** / **deferred** | Welcome contributions |
|
||||
| — | Automated / `nxlink` deploy | **absent** | Manual MTP / SD / FTP only (AD-009) |
|
||||
| — | Multi-OS transfer runbooks | **pass** | [switch-transfer.md](switch-transfer.md) |
|
||||
| — | Community mod zip OLED smoke (NXMOD-12) | **pass** | `docs/switch-hardware-evidence.md` |
|
||||
|
||||
## Review guidance
|
||||
|
||||
Maintainers may review as one PR or split later. Suggested slices (optional):
|
||||
|
||||
Each slice should declare: **no ROM/save bytes committed**, **love-nx pin with manifest checksums**, **hardware-tested rows listed with linked evidence**, **Applet Mode unsupported**, **network/updater disabled on NX**, **deploy still manual** (MTP / SD / FTP; no nxlink yet), **OpenMTP is one example not the sole contract**.
|
||||
|
||||
### Slice 1 — Platform + import (`platform/import`)
|
||||
|
||||
- `src/core/Platform.lua`, `conf.lua` NX branch
|
||||
- `src/import/RomImporter.lua` (NX flags, inbox, scan, shell/updater gates)
|
||||
- Tests: `tests/engine/platform_nx_*`, `tests/engine/rom_importer_nx_*` (ROM-free T2)
|
||||
- Docs: inbox/MTP import sections only
|
||||
|
||||
### Slice 2 — Input + lifecycle (`input/lifecycle`)
|
||||
|
||||
- `src/core/GamepadMap.lua`, `Input.lua`, `main.lua` focus/joystick hooks
|
||||
- `src/debug/SwitchDiagnostics.lua` (opt-in probe + error log)
|
||||
- Tests: input/diagnostics suites
|
||||
- Docs: controller mapping, suspend/audio notes
|
||||
|
||||
### Slice 3 — Build + docs (`build/docs`)
|
||||
|
||||
- `scripts/pack_love.sh`, `scripts/build_switch.sh`, `scripts/switch/*`
|
||||
- `assets/switch/icon.jpg`, `docs/switch-development.md`, hardware evidence templates
|
||||
- Gates: `pack_love.sh --dry-run`, `verify_payload.sh --self-test`, fused build script (devkitPro host)
|
||||
|
||||
**Pre-merge checklist:**
|
||||
|
||||
- [ ] Manifest `scripts/switch/love-nx-11.5-nx1.sha256` filled; binaries not in git
|
||||
- [ ] `verify_payload.sh` rejects generated cache / ROM / `.sav` / `.bak`
|
||||
- [ ] P0 matrix rows marked pass only with linked hardware evidence
|
||||
- [x] Fused NRO P0-12/14/15 pass with T24 evidence (`docs/switch-hardware-evidence.md`)
|
||||
- [ ] Updater / remote mod download hidden on NX (`networkValidated == false`)
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
# Switch hardware evidence (Phase 0 + import + input)
|
||||
|
||||
> **Hardware evidence log.** Author passes below were recorded on **one
|
||||
> Nintendo Switch OLED** with a **manual** Mac → DBI MTP deploy loop. A
|
||||
> separate community row records Switch V1 / Erista boot. These rows do
|
||||
> **not** claim Lite, docked soak, or automated install. See
|
||||
> `docs/switch-development.md` for status and limitations.
|
||||
|
||||
**love-nx:** `11.5-nx1`
|
||||
**Author console:** Switch OLED
|
||||
**Deploy method (author):** manual OpenMTP + DBI `Run MTP responder` (no CI / no nxlink)
|
||||
**Operator (author rows):** Andrew ([andrewqsantos](https://github.com/andrewqsantos))
|
||||
**Date (author rows):** 2026-08-01
|
||||
|
||||
Do **not** commit ROM dumps or private dump hashes. Do **not** mark a row **pass** without hardware notes for that row.
|
||||
|
||||
---
|
||||
|
||||
## Community — Switch V1 / Erista boot — pass (boot)
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Console | Nintendo Switch V1 (Erista) |
|
||||
| Check | Prebuilt fused NRO boots under title override |
|
||||
| Tester | [booshankles](https://github.com/booshankles) |
|
||||
| Notes | Community confirmation only — not a full P0/P1 matrix re-run on V1 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — probe (T4) — pass
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Commit (import era) | `df7cea4` |
|
||||
| `getOS()` / `love._os` | `NX` |
|
||||
| Dimensions | 1280×720 |
|
||||
| Save (probe) | `sdmc:/switch/gen1recomp/switch-probe` |
|
||||
| Joy-Con | `joystickpressed` + `gamepadpressed` (Y→`#3`, X→`#4`) |
|
||||
|
||||
| Artifact | SHA-256 |
|
||||
| -------- | ------- |
|
||||
| `gen1recomp.nro` | `8290ac153d4c630e48c9b26ef9123f5204ed8ee0cef3042511707b5b645918f5` |
|
||||
|
||||
---
|
||||
|
||||
## T12 — Red import + Play — pass
|
||||
|
||||
Inbox MTP → “Scan again” → Play; Joy-Con launcher/gameplay (not touch-only).
|
||||
|
||||
---
|
||||
|
||||
# T16 — Joy-Con launcher + gameplay — pass (naming re-verify)
|
||||
|
||||
### Round 1 @ `7504753` — partial
|
||||
|
||||
| Check | Result |
|
||||
| ----- | ------ |
|
||||
| Launcher / overworld (Joy-Con only) | **pass** |
|
||||
| Naming player/rival | **fail** (dual-path a+b; see below) |
|
||||
| Touch required | **no** |
|
||||
| `game.love` SHA-256 | `bd3a35461bf453c1f0465a5a289421aef3b5c72d3bf1f8d76e86231256829e0e` |
|
||||
|
||||
### Naming failure (root cause) — fixed in `efd81d8` + `2699c9a`
|
||||
|
||||
- love-nx fires **`gamepadpressed` + `joystickpressed` on the same physical press**.
|
||||
- `NamingScreen` tested `wasPressed("b")` before `"a"` → if both true in one frame, always deletes.
|
||||
- Dual-path fix: ignore raw when `isGamepad()` (`efd81d8`).
|
||||
- SDL-only UX then had physical B confirm / A erase; NX face remap (`2699c9a`) restores Nintendo A=confirm / B=cancel.
|
||||
|
||||
### Round 2 @ `2699c9a` — pass (Nintendo UX)
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Commit tested | `2699c9a` |
|
||||
| `game.love` SHA-256 | `a208b21e1f30b00e2e8c6fa6efe14f0e06d1db0ae1e50b810b16d9fb852926bc` |
|
||||
| Touch required | **no** |
|
||||
|
||||
| Check | Result |
|
||||
| ----- | ------ |
|
||||
| Naming — player | **pass** — physical **A** confirms letter, **B** cancels/erases |
|
||||
| Naming — rival | **pass** (same) |
|
||||
| Launcher / overworld (prior round) | **pass** (unchanged mapping for d-pad/stick) |
|
||||
|
||||
T16 hardware gate: **closed**.
|
||||
|
||||
---
|
||||
|
||||
## T19 — save / suspend — pass
|
||||
|
||||
| Check | Result |
|
||||
| ----- | ------ |
|
||||
| Save in-game → full quit → title-override reopen → load save | **pass** (@ `7504753` / retained) |
|
||||
| Suspend/resume ×10 (launcher / gameplay / mixed) | **pass** (operator 2026-08-01) |
|
||||
| Full console reboot persistence | **pass** (operator 2026-08-01) |
|
||||
|
||||
T19 hardware gate: **closed**. No stuck input, duplicate audio, or crash reported.
|
||||
|
||||
---
|
||||
|
||||
## T24 — fused NRO alone + NRO-only update — **pass**
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| First fused attempt | `6fb5602` (Blue Play failed — mount) |
|
||||
| Fix commits | `b1ad7c7` (logs/generated overlay), `ac6dfe7` (Blue/Yellow mount) |
|
||||
| Deploy | isolated folder, no adjacent `game.love` |
|
||||
| Boot fused | **pass** |
|
||||
| ROM import | **pass** |
|
||||
| Play **Red** | **pass** |
|
||||
| Play **Blue** (after `ac6dfe7`) | **pass** (operator 2026-08-01) |
|
||||
| NRO-only replace | **pass** — saves retained; app still boots/plays |
|
||||
| Touch required | no |
|
||||
|
||||
T24 hardware gate: **closed**.
|
||||
|
||||
---
|
||||
|
||||
## SWBLD — `build_switch.sh --fetch --fused` + install path — **pass**
|
||||
|
||||
Operator smoke for the switch-build-pipeline packaging CLI (closes matrix-deferred happy paths from validation).
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Command | `scripts/build_switch.sh --fetch --fused --version 0.0.0-test` |
|
||||
| Host | macOS + native switch-tools (or Docker fallback if used) |
|
||||
| Commit / build-info | `9147a64` (`gitCommit` in build-info) |
|
||||
| love-nx | `11.5-nx1` (manifest checksums match) |
|
||||
| Artifact | `dist/switch/gen1recomp-0.0.0-test-switch.nro` |
|
||||
| NRO SHA-256 | `210efb884a8d27443dc1c64ed8f071b0f862d8d0c9b140ad8185093c4e4027db` |
|
||||
| Install doc | `docs/switch-install.md` — at the time of this row: copy NRO under `sdmc:/switch/gen1recomp/` (releases now ship an SD-ready zip; same folder) |
|
||||
| Console | Switch OLED |
|
||||
| Operator | Andrew |
|
||||
| Date | 2026-08-01 |
|
||||
|
||||
| Check | Result |
|
||||
| ----- | ------ |
|
||||
| `--fetch` + `--fused` produce NRO + `.sha256` | **pass** |
|
||||
| Copy NRO to SD folder per install doc | **pass** (operator) |
|
||||
| Title-override launch / play | treated as prior T24 path; this row records **packaging + deploy to folder** success |
|
||||
|
||||
SWBLD packaging smoke: **closed** for Mac fused build + file-to-SD install step.
|
||||
|
||||
---
|
||||
|
||||
## NXMOD-12 — Community mod zip OLED smoke — **pass**
|
||||
|
||||
Closed from existing OLED photo evidence on issue
|
||||
[#531](https://github.com/bryanthaboi/gen1recomp/issues/531) (operator comment
|
||||
with launcher MODS + overworld shots). Photos live on the orphan branch
|
||||
[`switch-oled-photos`](https://github.com/andrewqsantos/gen1recomp/tree/switch-oled-photos)
|
||||
of the operator fork — **not** committed to this repo. Do **not** commit
|
||||
third-party mod `.zip` bytes. Community mods own their OPTIONS / rebinds;
|
||||
this entry only proves the MODS inbox + Play path on OLED.
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Status | **pass** |
|
||||
| gen1recomp commit | evidence era on `feat/switch-nx` (see #531); packaging pin love-nx `11.5-nx1` |
|
||||
| love-nx tag | `11.5-nx1` |
|
||||
| Console | Switch OLED |
|
||||
| Mod | community release `.zip` (not vendored; not named here) |
|
||||
| Zip committed to git? | **no** |
|
||||
| Photo evidence | [#531 comment](https://github.com/bryanthaboi/gen1recomp/issues/531) — MODS tab + overworld |
|
||||
| MODS tab photo | https://raw.githubusercontent.com/andrewqsantos/gen1recomp/switch-oled-photos/IMG_1766.jpg |
|
||||
| Overworld photo | https://raw.githubusercontent.com/andrewqsantos/gen1recomp/switch-oled-photos/IMG_1771.jpg |
|
||||
| Operator | Andrew |
|
||||
| Date | 2026-08-01 |
|
||||
|
||||
### Checklist
|
||||
|
||||
| Step | Pass / fail / pending | Notes |
|
||||
| ---- | --------------------- | ----- |
|
||||
| MTP zip into save `imports/mods/` | **pass** | Photo evidence + prior inbox path |
|
||||
| MODS → Scan again → mod listed | **pass** | IMG_1766 — community mod installed |
|
||||
| Enable mod + Play Red boots without crash | **pass** | Overworld / Pallet / Oak lab photos on #531 |
|
||||
| Overworld Select+A → visible colors change | **pass** | Stock COLORS chord path exercised |
|
||||
| Overworld Select+B → visible tilt/perspective change | **pass** | Stock TILT chord path exercised (IMG_1771) |
|
||||
|
||||
### Evidence notes
|
||||
|
||||
```text
|
||||
Operator: Andrew
|
||||
Date: 2026-08-01
|
||||
Commit tested: feat/switch-nx era documented on issue #531
|
||||
Pass / fail summary: PASS — MODS zip install + Play on Switch OLED
|
||||
Photo branch: andrewqsantos/gen1recomp@switch-oled-photos
|
||||
```
|
||||
@@ -2,15 +2,11 @@
|
||||
|
||||
Every GitHub Release that includes Switch support ships an SD-ready zip:
|
||||
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
|
||||
**or** update — same steps), launch with **title override**, then import your
|
||||
or update, same steps), launch with **title override**, then import your
|
||||
own legal `.gb` ROM.
|
||||
|
||||
> You need a console that can run Switch homebrew (custom firmware / hbmenu).
|
||||
> This project does not help you set that up. Tracks issue
|
||||
> [#531](https://github.com/bryanthaboi/gen1recomp/issues/531).
|
||||
> Hardware: **OLED** validated by the porter; **V1 / Erista** boot confirmed
|
||||
> by the community. Lite and other setups welcome more reports.
|
||||
> See [switch-development.md](switch-development.md) for limitations.
|
||||
> This project does not help you set that up.
|
||||
|
||||
Prefer building from source? See [switch-build.md](switch-build.md).
|
||||
|
||||
@@ -29,12 +25,18 @@ help from [booshankles](https://github.com/booshankles).
|
||||
Extract the zip at the **root** of the microSD so you get:
|
||||
|
||||
```text
|
||||
sdmc:/switch/gen1recomp/gen1recomp.nro
|
||||
sdmc:/switch/gen1recomp/gen1recomp.nro # native OTA launcher (hbmenu entry)
|
||||
sdmc:/switch/gen1recomp/gen1recomp-game.nro # fused LÖVE game
|
||||
sdmc:/switch/gen1recomp/version.txt
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/mods/
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/...
|
||||
```
|
||||
|
||||
Older single-NRO zips only had `gen1recomp.nro` (the fused game). Current
|
||||
releases use the dual-NRO layout above. Open `gen1recomp` in hbmenu (the
|
||||
launcher).
|
||||
|
||||
Merge folders if your OS asks. Any method works: **MTP** (DBI → Run MTP
|
||||
responder + a client), **direct SD** (Hekate UMS or a card reader), or **FTP**.
|
||||
Exit MTP / unmount / stop FTP cleanly before launching. Step-by-step for
|
||||
@@ -42,17 +44,44 @@ macOS, Linux, and Windows: [switch-transfer.md](switch-transfer.md).
|
||||
|
||||
### Updating
|
||||
|
||||
Use the **same** extract/merge. It replaces `gen1recomp.nro` (and the small
|
||||
help `README.txt` / `INSTALL.txt` files). Saves, imported ROMs, mods, and
|
||||
options live under `pokemon-love2d/` — **do not delete that folder** when
|
||||
updating, or you will lose progress.
|
||||
#### Native OTA launcher (in-console)
|
||||
|
||||
Switch OTA runs in a separate **native launcher NRO** (libnx + curl), not the
|
||||
LÖVE self-updater (`src/update/Check.lua`). hbmenu opens `gen1recomp.nro`.
|
||||
|
||||
When a newer release exists, the launcher downloads the same install zip
|
||||
(`gen1recomp-*-switch.zip`), checks SHA-256 against `sha256sums.txt`, replaces
|
||||
both `gen1recomp-game.nro` and `gen1recomp.nro` (keeps NACP version in sync
|
||||
for hbmenu and Sphaira), then loads the game with `envSetNextLoad`.
|
||||
|
||||
If you are up to date or offline, it skips straight to the game with no
|
||||
prompt. If an update is available, you get a short prompt styled like the
|
||||
in-game launcher: black background, RGB rail, logo, A/B buttons. Saves under
|
||||
`pokemon-love2d/` are not touched. See `src/update/SwitchOta.lua` for the
|
||||
wire format.
|
||||
|
||||
The LÖVE self-updater stays **disabled** on NX (`networkValidated == false`).
|
||||
|
||||
**Sphaira forwarder (HOME shortcut):** Sphaira copies name/version/icon into
|
||||
the installed forwarder at creation time. After an OTA (or zip) update, the
|
||||
`.nro` on the microSD already has the new version, but the HOME shortcut
|
||||
keeps the old badge until you **reinstall the forwarder once** in Sphaira
|
||||
(Install Forwarder again on `gen1recomp.nro`). Browsing the NRO in Sphaira /
|
||||
hbmenu always shows the live file version.
|
||||
|
||||
#### Manual zip (fallback)
|
||||
|
||||
Use the **same** extract/merge of `gen1recomp-*-switch.zip`. It replaces the
|
||||
NROs (and the small help `README.txt` / `INSTALL.txt` files). Saves,
|
||||
imported ROMs, mods, and options live under `pokemon-love2d/`. **Do not
|
||||
delete that folder** when updating, or you will lose progress.
|
||||
|
||||
## 3. Launch with title override
|
||||
|
||||
**Applet Mode is not supported** for this game (not enough memory).
|
||||
|
||||
1. On the Switch HOME menu, highlight any installed title.
|
||||
2. Hold **R** and launch that title — this opens hbmenu with full memory
|
||||
2. Hold **R** and launch that title. This opens hbmenu with full memory
|
||||
(title override).
|
||||
3. From hbmenu, open `gen1recomp`.
|
||||
|
||||
@@ -66,14 +95,14 @@ This project ships **no** game data. On first launch:
|
||||
(`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
||||
launcher also shows the live save-dir path). All three can sit in the
|
||||
same folder.
|
||||
2. Use **Scan again** on that game’s tab (Red / Blue / Yellow). Rescan
|
||||
matches by ROM SHA-1 for the open tab only — a Red dump never imports
|
||||
2. Use **Scan again** on that game's tab (Red / Blue / Yellow). Rescan
|
||||
matches by ROM SHA-1 for the open tab only. A Red dump never imports
|
||||
from the Yellow tab (and vice versa).
|
||||
|
||||
## 5. Import / Export a raw `.sav`
|
||||
|
||||
Continue a cart or PC battery save (or pull a slot off-console) via MTP /
|
||||
SD / FTP — same transfer methods as ROMs. Paths are **per game**:
|
||||
SD / FTP, same transfer methods as ROMs. Paths are **per game**:
|
||||
|
||||
| Game | Import inbox | Export folder |
|
||||
| ---- | ------------ | ------------- |
|
||||
@@ -81,19 +110,19 @@ SD / FTP — same transfer methods as ROMs. Paths are **per game**:
|
||||
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
||||
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
||||
|
||||
(Under the save dir `pokemon-love2d/` — the zip already creates these folders.)
|
||||
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.)
|
||||
|
||||
1. Copy a Gen1 `.sav` (32 KB) into that game’s inbox under the save dir
|
||||
1. Copy a Gen1 `.sav` (32 KB) into that game's inbox under the save dir
|
||||
([switch-transfer.md](switch-transfer.md)).
|
||||
2. With the game’s ROM already imported, open **that game’s tab** →
|
||||
2. With the game's ROM already imported, open **that game's tab** →
|
||||
**SAVE FILES** → **Import save**. Only that folder is scanned.
|
||||
3. A successful import retires the file to `*.sav.imported` and records its
|
||||
content hash so pressing **Import save** again does not clone slots.
|
||||
Failed imports leave the original `.sav` in place.
|
||||
4. To pull a slot off the console, use **Export save**, then copy the file
|
||||
from that game’s **`exports/<game>/`** folder via MTP / SD / FTP.
|
||||
from that game's **`exports/<game>/`** folder via MTP / SD / FTP.
|
||||
|
||||
Do not put `.sav` files into git. Prefer clean copies — some MTP clients
|
||||
Do not put `.sav` files into git. Prefer clean copies. Some MTP clients
|
||||
create `._*.sav` AppleDouble sidecars that are not real saves.
|
||||
|
||||
## Controls
|
||||
@@ -130,12 +159,12 @@ create `._*.sav` AppleDouble sidecars that are not real saves.
|
||||
Mods install from a zip inbox (same transfer methods as ROMs):
|
||||
|
||||
1. Copy a release `.zip` into the save-dir **`imports/mods/`** path the
|
||||
launcher shows (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)).
|
||||
launcher shows (MTP / SD / FTP. See [switch-transfer.md](switch-transfer.md)).
|
||||
2. In the launcher, open **MODS** → **Scan again** → enable the mod →
|
||||
**Play**.
|
||||
|
||||
Remote **FIND MODS** / GitHub download stays **off** on Switch. Do not put
|
||||
mod zips into git. Community mods ship their own OPTIONS / rebinds — this port
|
||||
mod zips into git. Community mods ship their own OPTIONS / rebinds. This port
|
||||
does not document third-party control tables.
|
||||
|
||||
### Joy-Con shortcuts (Select + face)
|
||||
@@ -153,12 +182,23 @@ hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
|
||||
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
|
||||
|
||||
If the handheld stutters with extras on, try **OPTIONS → PERFORMANCE** →
|
||||
`LOW` or `BALANCED`. Full chord notes for contributors:
|
||||
[switch-development.md](switch-development.md#joy-con-display-chords-select--face).
|
||||
`LOW` or `BALANCED`.
|
||||
|
||||
## Limitations
|
||||
|
||||
- You need homebrew (custom firmware, hbmenu). This project does not set that
|
||||
up.
|
||||
- Launch with title override (hold **R** on a title). Applet Mode (Album) is
|
||||
not supported. The game needs full memory.
|
||||
- ROMs, mods, and saves are copied manually via MTP, direct SD, or FTP. There
|
||||
is no automated deploy.
|
||||
- Updates use the native OTA launcher only. The LÖVE self-updater and remote
|
||||
**FIND MODS** stay off on Switch.
|
||||
- Tested on Switch OLED. Switch V1 / Erista boot confirmed by the community.
|
||||
Other models may work but are less tested.
|
||||
|
||||
## Prefer building it yourself?
|
||||
|
||||
Building the fused NRO (and SD-ready zip) from source is covered in
|
||||
[switch-build.md](switch-build.md). Copying artifacts and inbox files
|
||||
(MTP / SD / FTP on macOS, Linux, Windows): [switch-transfer.md](switch-transfer.md).
|
||||
Status, limitations, and how we tested: [switch-development.md](switch-development.md).
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
# Switch file transfer (MTP / SD / FTP)
|
||||
|
||||
Canonical ways to put Gen1Recomp artifacts and inbox files onto a Nintendo
|
||||
Switch. **Any method is valid** if the bytes land in the destinations below.
|
||||
Ways to put Gen1Recomp artifacts and inbox files onto a Nintendo Switch.
|
||||
**Any method is valid** if the bytes land in the destinations below.
|
||||
|
||||
This is the home runbook for contributors on **macOS, Linux, and Windows**.
|
||||
This is the runbook for contributors on **macOS, Linux, and Windows**.
|
||||
Player install (what to download, title override) stays in
|
||||
[switch-install.md](switch-install.md). Packaging stays in
|
||||
[switch-build.md](switch-build.md). Hardware evidence lives in
|
||||
[switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
[switch-build.md](switch-build.md).
|
||||
|
||||
> **Not supported yet:** `nxlink` / hbmenu netloader automation. Useful later
|
||||
> for a fast contrib rebuild loop; deferred on purpose (AD-009). Do not treat
|
||||
> netloader as the release or ROM/mod install path.
|
||||
> for a fast contrib rebuild loop; deferred (AD-009). Do not treat netloader as
|
||||
> the release or ROM/mod install path.
|
||||
|
||||
---
|
||||
|
||||
@@ -23,7 +22,7 @@ Player install (what to download, title override) stays in
|
||||
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
||||
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
||||
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that game’s SAVE FILES → **Import save** |
|
||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that game's SAVE FILES → **Import save** |
|
||||
| Save exports | Same save dir → `exports/red\|blue\|yellow/` (pull after **Export save**; MTP / SD / FTP) |
|
||||
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
||||
| Lua error log | `lua-error.log` in the save dir |
|
||||
@@ -34,7 +33,7 @@ files, or third-party mod zips to git.
|
||||
|
||||
---
|
||||
|
||||
## Canonical methods
|
||||
## Transfer methods
|
||||
|
||||
### 1. MTP (DBI responder + host client)
|
||||
|
||||
@@ -47,8 +46,8 @@ before launching.
|
||||
|
||||
#### macOS (example: OpenMTP)
|
||||
|
||||
[OpenMTP](https://github.com/ganeshrvel/openmtp) is the loop used for OLED
|
||||
hardware evidence — **one contributor example**, not a Mac-only product rule.
|
||||
[OpenMTP](https://github.com/ganeshrvel/openmtp) is a documented example for
|
||||
macOS, not a Mac-only requirement.
|
||||
|
||||
1. Quit other MTP clients.
|
||||
2. Open OpenMTP → select the DBI device → **`1: SD Card`**.
|
||||
@@ -60,25 +59,25 @@ hardware evidence — **one contributor example**, not a Mac-only product rule.
|
||||
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
||||
|
||||
macOS clients often create AppleDouble sidecars (`._Something.zip`,
|
||||
`._cart.gb`, `._foo.sav`). Those are not real archives or saves — the
|
||||
`._cart.gb`, `._foo.sav`). Those are not real archives or saves. The
|
||||
launcher skips hidden `.*` names. Delete `._*` junk if a zip/ROM/`.sav`
|
||||
fails to open.
|
||||
|
||||
#### Linux
|
||||
|
||||
1. Install desktop MTP support if needed (e.g. `gvfs-mtp` on GNOME/GTK
|
||||
desktops, or your distro’s KDE MTP stack).
|
||||
desktops, or your distro's KDE MTP stack).
|
||||
2. With DBI MTP active, open **Files** / **Dolphin** / **Thunar** and select
|
||||
the Switch / DBI device → **`1: SD Card`**.
|
||||
3. Extract the release zip at SD root (merge), or copy into `switch/gen1recomp/`
|
||||
and the save-dir inboxes as above.
|
||||
4. Use **only one** MTP accessor at a time. If `mtp-tools` / `mtpfs` reports
|
||||
“device is busy”, close the file manager’s MTP mount (or the CLI mount)
|
||||
"device is busy", close the file manager's MTP mount (or the CLI mount)
|
||||
and retry with a single client.
|
||||
5. Eject/unmount cleanly; exit MTP on the Switch; title-override launch.
|
||||
|
||||
If MTP is unavailable or flaky on Linux, use **direct SD** (Hekate UMS or a
|
||||
card reader) or **FTP** instead — same destinations in the table above.
|
||||
card reader) or **FTP** instead. Same destinations in the table above.
|
||||
|
||||
#### Windows
|
||||
|
||||
@@ -93,7 +92,7 @@ card reader) or **FTP** instead — same destinations in the table above.
|
||||
5. Safely disconnect; exit MTP on the Switch; title-override launch.
|
||||
|
||||
If MTP is unavailable or flaky on Windows, use **direct SD** (Hekate UMS or a
|
||||
card reader) or **FTP** instead — same destinations in the table above.
|
||||
card reader) or **FTP** instead. Same destinations in the table above.
|
||||
|
||||
### 2. Direct SD (Hekate UMS or card reader)
|
||||
|
||||
@@ -109,9 +108,9 @@ Do not yank the card or unplug UMS mid-write.
|
||||
|
||||
### 3. FTP (any SD-exposing Switch FTP)
|
||||
|
||||
Any homebrew FTP server that can write the microSD is fine — for example
|
||||
**DBI’s own FTP**, **sys-ftpd-light**, or **Sphaira** (names are illustrations
|
||||
only; pick what your CFW setup already uses).
|
||||
Any homebrew FTP server that can write the microSD is fine. For example
|
||||
**DBI's own FTP**, **sys-ftpd-light**, or **Sphaira**. Names are illustrations
|
||||
only; pick what your CFW setup already uses.
|
||||
|
||||
1. Start the FTP server on the Switch; note IP/port/credentials from that app.
|
||||
2. From the host, connect with any FTP client and upload to the same
|
||||
@@ -154,7 +153,7 @@ Copy the file back from the SD and compare hashes. Round-trip must match.
|
||||
| Symptom | What to try |
|
||||
| ------- | ----------- |
|
||||
| Device busy / no MTP volume | One client only; different cable/port; Windows MTP USB Device driver; alternate method (SD or FTP) |
|
||||
| Zip/ROM/`.sav` “could not be opened” | Delete `._*` sidecars (including `._*.sav`); confirm real zip starts with `PK` |
|
||||
| Zip/ROM/`.sav` "could not be opened" | Delete `._*` sidecars (including `._*.sav`); confirm real zip starts with `PK` |
|
||||
| Half-copied NRO / crash on boot | Re-copy; verify SHA-256; exit transfer mode before launch |
|
||||
| App opens in Applet Mode | Use title override (hold **R**), not Album |
|
||||
|
||||
@@ -164,5 +163,3 @@ Copy the file back from the SD and compare hashes. Round-trip must match.
|
||||
|
||||
- Players: [switch-install.md](switch-install.md)
|
||||
- Builders: [switch-build.md](switch-build.md)
|
||||
- Status / hardware matrix: [switch-development.md](switch-development.md)
|
||||
- Evidence log: [switch-hardware-evidence.md](switch-hardware-evidence.md)
|
||||
|
||||
@@ -124,3 +124,8 @@ bundled game, in that case.
|
||||
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
|
||||
`"0.0.0-dev"` placeholder that always reports up to date, so a source
|
||||
checkout is always "the game" itself; updating it means pulling the repo.
|
||||
- **Nintendo Switch does not use this LÖVE self-updater.** On NX,
|
||||
`Platform.networkValidated()` is `false`, so `Boot.run` / `Check` never
|
||||
download `.love` payloads. In-console OTA uses the **native OTA launcher**
|
||||
(DEVKITPRO), documented in [switch-install.md](switch-install.md). Wire
|
||||
format: `src/update/SwitchOta.lua`. Manual zip install remains the fallback.
|
||||
|
||||
@@ -13,6 +13,7 @@ local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE =
|
||||
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
||||
local LaunchOptions = require("src.core.LaunchOptions")
|
||||
local NxDisplay = require("src.core.NxDisplay")
|
||||
local PlatformHooks = require("src.core.PlatformHooks")
|
||||
|
||||
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
||||
do
|
||||
@@ -30,6 +31,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 +350,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
|
||||
@@ -402,7 +427,11 @@ function love.update(dt)
|
||||
end
|
||||
return
|
||||
end
|
||||
Game:update(dt)
|
||||
-- Mods may wrap or veto the per-frame simulation step (pause it, react
|
||||
-- to external platform state, etc.) -- see docs/modding.md's core.update
|
||||
-- entry. Vanilla behavior (used when no mod claims the hook) is just
|
||||
-- Game:update(dt), unconditionally, exactly as before this hook existed.
|
||||
PlatformHooks.update(Game, dt)
|
||||
end
|
||||
|
||||
function love.draw()
|
||||
@@ -667,7 +696,33 @@ function love.wheelmoved(x, y)
|
||||
Game:wheelmoved(x, y)
|
||||
end
|
||||
|
||||
-- #781: Linux X11 multi-monitor with the primary display away from desktop
|
||||
-- (0,0): SDL's polled mouse state can come back in desktop-virtual
|
||||
-- coordinates while the event stream stays window-relative, which strands
|
||||
-- every polled consumer (launcher Kit rising-edge clicks, the pad-cursor
|
||||
-- motion yield, PadCursor) on coordinates no hit test can match. Sanitize
|
||||
-- the poll once here: remember the last window-relative event coordinates
|
||||
-- and substitute them whenever the polled value falls outside the window.
|
||||
-- Linux only -- macOS / Windows / mobile keep the stock function, and the
|
||||
-- NX launcher shim still composes because it captures whatever
|
||||
-- love.mouse.getPosition is at bridge time (_ensureNxPointerBridge).
|
||||
local eventMouseX, eventMouseY
|
||||
if love.system and love.system.getOS() == "Linux"
|
||||
and love.mouse and love.mouse.getPosition then
|
||||
local polledGetPosition = love.mouse.getPosition
|
||||
love.mouse.getPosition = function()
|
||||
local x, y = polledGetPosition()
|
||||
local w, h = love.graphics.getDimensions()
|
||||
if x < 0 or y < 0 or x > w or y > h then
|
||||
if eventMouseX then return eventMouseX, eventMouseY end
|
||||
return math.max(0, math.min(x, w)), math.max(0, math.min(y, h))
|
||||
end
|
||||
return x, y
|
||||
end
|
||||
end
|
||||
|
||||
function love.mousepressed(x, y, button, istouch)
|
||||
if not istouch then eventMouseX, eventMouseY = x, y end
|
||||
if TouchEditor then
|
||||
-- Android primary touch already arrived via love.touchpressed; a second
|
||||
-- mouse path would double-fire Done / begin a second drag.
|
||||
@@ -720,6 +775,7 @@ function love.mousereleased(x, y, button, istouch)
|
||||
end
|
||||
|
||||
function love.mousemoved(x, y, dx, dy, istouch)
|
||||
if not istouch then eventMouseX, eventMouseY = x, y end
|
||||
if TouchEditor then
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousemoved(x, y)
|
||||
@@ -748,7 +804,13 @@ local quitToLauncher = false
|
||||
|
||||
function love.quit()
|
||||
if editorMode and EditorApp.quit then
|
||||
return EditorApp.quit() -- return true to abort quit
|
||||
-- true blocks the quit (unsaved-changes prompt). A quit that proceeds
|
||||
-- must fall through to the worker shutdowns below instead of returning:
|
||||
-- the bundled editor opens from a live launcher whose update-check and
|
||||
-- fetch-pool workers are still parked in Channel:demand(), and returning
|
||||
-- here skipped their "quit" push, so the process outlived the closed
|
||||
-- window and kept the install folder locked on Windows (#727).
|
||||
if EditorApp.quit() then return true end
|
||||
end
|
||||
-- Closing the window of a running game returns to the launcher instead of
|
||||
-- exiting the app, so testing a mod does not need a relaunch every time
|
||||
@@ -760,8 +822,23 @@ 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.
|
||||
--
|
||||
-- A platform launcher that owns "return to launcher" itself (see
|
||||
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to
|
||||
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
|
||||
-- claims the hook) is exactly the condition below.
|
||||
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
|
||||
return Game and not Importer and not quitToLauncher and not scripted
|
||||
and not launchedIntoGame
|
||||
end)
|
||||
if wouldReturnToLauncher then
|
||||
quitToLauncher = true
|
||||
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
||||
-- once, so the restart really does land in the launcher (#887). A failed
|
||||
-- write only costs that suppression, so it must never block the restart.
|
||||
pcall(love.filesystem.write, RELAUNCH_MARKER, "1")
|
||||
require("src.core.HostShell").restart()
|
||||
return true -- abort this quit; the restart lands back in the launcher
|
||||
end
|
||||
|
||||
@@ -37,6 +37,13 @@
|
||||
|
||||
#include "filesystem/physfs/PhysfsIo.h"
|
||||
|
||||
// #604 / #839: the SAF bridges below must hand GameActivity the exact
|
||||
// directory physfs mounted as the save dir -- the same contract the iOS
|
||||
// GRPickerBridge already gets (mobile/ios/patch_love_src.py,
|
||||
// gr_saveDirectory) -- instead of letting Java recompute the root on its
|
||||
// own, which can name a different volume on merged / adopted-SD storage.
|
||||
#include "filesystem/Filesystem.h"
|
||||
|
||||
namespace love
|
||||
{
|
||||
namespace android
|
||||
@@ -183,6 +190,19 @@ void vibrate(double seconds)
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
// The directory physfs actually mounted as the save dir, or "" before the
|
||||
// filesystem module is up. GameActivity must copy SAF picks HERE: its own
|
||||
// getExternalFilesDir(null) recomputation can disagree with the mounted
|
||||
// root on merged / adopted-SD storage (#604, #839).
|
||||
static const char *bridgeSaveDirectory()
|
||||
{
|
||||
auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM);
|
||||
if (fs == nullptr)
|
||||
return "";
|
||||
const char *dir = fs->getSaveDirectory();
|
||||
return dir != nullptr ? dir : "";
|
||||
}
|
||||
|
||||
bool showFilePicker(const char *destFilename)
|
||||
{
|
||||
if (destFilename == nullptr || destFilename[0] == '\0')
|
||||
@@ -192,9 +212,11 @@ bool showFilePicker(const char *destFilename)
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
|
||||
"(Ljava/lang/String;)Z");
|
||||
"(Ljava/lang/String;Ljava/lang/String;)Z");
|
||||
jstring jname = env->NewStringUTF(destFilename);
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
|
||||
jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory());
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname, jsavedir);
|
||||
env->DeleteLocalRef(jsavedir);
|
||||
env->DeleteLocalRef(jname);
|
||||
|
||||
env->DeleteLocalRef(activity);
|
||||
@@ -210,9 +232,11 @@ bool showCreateDocument(const char *suggestedName)
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument",
|
||||
"(Ljava/lang/String;)Z");
|
||||
"(Ljava/lang/String;Ljava/lang/String;)Z");
|
||||
jstring jname = env->NewStringUTF(suggestedName);
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
|
||||
jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory());
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname, jsavedir);
|
||||
env->DeleteLocalRef(jsavedir);
|
||||
env->DeleteLocalRef(jname);
|
||||
|
||||
env->DeleteLocalRef(activity);
|
||||
@@ -979,4 +1003,33 @@ void love_android_secondary_enable(int on)
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
const char *love_android_poll_secondary_touch()
|
||||
{
|
||||
static thread_local std::string event;
|
||||
event.clear();
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
jmethodID method = env->GetStaticMethodID(activity, "pollSecondaryDisplayTouch",
|
||||
"()Ljava/lang/String;");
|
||||
if (!method)
|
||||
env->ExceptionClear();
|
||||
else
|
||||
{
|
||||
jstring value = (jstring) env->CallStaticObjectMethod(activity, method);
|
||||
if (value)
|
||||
{
|
||||
const char *utf = env->GetStringUTFChars(value, nullptr);
|
||||
if (utf)
|
||||
{
|
||||
event = utf;
|
||||
env->ReleaseStringUTFChars(value, utf);
|
||||
}
|
||||
env->DeleteLocalRef(value);
|
||||
}
|
||||
}
|
||||
env->DeleteLocalRef(activity);
|
||||
return event.empty() ? nullptr : event.c_str();
|
||||
}
|
||||
|
||||
#endif // LOVE_ANDROID
|
||||
|
||||
@@ -116,6 +116,18 @@ public class GameActivity extends SDLActivity {
|
||||
// bad ROM instead of installing it (#553).
|
||||
private String pendingPickFilename = PICKED_ROM_FILENAME;
|
||||
private static final String STATE_PENDING_PICK = "pendingPickFilename";
|
||||
// Absolute save directory physfs actually mounted, as reported by the
|
||||
// native bridge call that opened the picker (love/src/common/android.cpp,
|
||||
// bridgeSaveDirectory). This activity used to recompute
|
||||
// getExternalFilesDir(null)/save/<identity> on its own at result time; on
|
||||
// merged / adopted-SD storage that can name a different volume than the
|
||||
// one LOVE mounted, so the copied pick (and pick_error.flag) landed where
|
||||
// Lua never scans -- the launcher then "did nothing" after a pick (#604)
|
||||
// and the folders a file manager can browse stayed empty while the game
|
||||
// saved fine elsewhere (#839). Empty string means "not told yet": fall
|
||||
// back to the historical computation.
|
||||
private String pendingPickSaveDir = "";
|
||||
private static final String STATE_PENDING_PICK_DIR = "pendingPickSaveDir";
|
||||
private static final String STATE_PENDING_CREATE = "pendingCreateSuggestedName";
|
||||
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
|
||||
private String pendingCreateSuggestedName = "export.sav";
|
||||
@@ -186,6 +198,8 @@ public class GameActivity extends SDLActivity {
|
||||
// a recreated activity still lands under the basename it asked for.
|
||||
String pick = savedInstanceState.getString(STATE_PENDING_PICK);
|
||||
if (pick != null) pendingPickFilename = pick;
|
||||
String pickDir = savedInstanceState.getString(STATE_PENDING_PICK_DIR);
|
||||
if (pickDir != null) pendingPickSaveDir = pickDir;
|
||||
String create = savedInstanceState.getString(STATE_PENDING_CREATE);
|
||||
if (create != null) pendingCreateSuggestedName = create;
|
||||
}
|
||||
@@ -467,13 +481,23 @@ public class GameActivity extends SDLActivity {
|
||||
* @param destFilename basename under the app save identity (e.g.
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav)
|
||||
*/
|
||||
/** Legacy single-argument entry; resolves the save dir itself. */
|
||||
@Keep
|
||||
public static boolean showFilePicker(String destFilename) {
|
||||
return showFilePicker(destFilename, null);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean showFilePicker(String destFilename, String saveDir) {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
if (destFilename == null || destFilename.length() == 0) {
|
||||
destFilename = PICKED_ROM_FILENAME;
|
||||
}
|
||||
// Remember where LOVE's filesystem is really mounted so
|
||||
// onActivityResult copies the pick there, not into a recomputed
|
||||
// (possibly different-volume) root (#604, #839).
|
||||
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
|
||||
// Reject path separators so a hostile JNI caller cannot escape the
|
||||
// save identity directory.
|
||||
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
|
||||
@@ -644,8 +668,14 @@ public class GameActivity extends SDLActivity {
|
||||
* return degrades on the Lua side (RomImporter export) to "Exported
|
||||
* inside the app folder", which is the correct pre-KitKat behavior.
|
||||
*/
|
||||
/** Legacy single-argument entry; resolves the save dir itself. */
|
||||
@Keep
|
||||
public static boolean showCreateDocument(String suggestedName) {
|
||||
return showCreateDocument(suggestedName, null);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean showCreateDocument(String suggestedName, String saveDir) {
|
||||
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
||||
// (see showFilePicker for why the import side got a pre-19 path)
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
@@ -657,9 +687,12 @@ public class GameActivity extends SDLActivity {
|
||||
Log.d("GameActivity", "refusing unsafe create name: " + suggestedName);
|
||||
return false;
|
||||
}
|
||||
File source = new File(
|
||||
new File(self.getExternalFilesDir(null), "save"),
|
||||
ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME);
|
||||
// Route through the mounted save dir (#604, #839): Lua staged
|
||||
// pending_export.sav where physfs writes, which is not necessarily
|
||||
// where a fresh getExternalFilesDir(null) points on merged /
|
||||
// adopted-SD storage.
|
||||
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
|
||||
File source = new File(self.saveIdentityDir(), PENDING_EXPORT_FILENAME);
|
||||
if (!source.isFile()) {
|
||||
Log.d("GameActivity", "no pending export at " + source);
|
||||
return false;
|
||||
@@ -680,7 +713,21 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
|
||||
private File saveIdentityDir() {
|
||||
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
|
||||
// Prefer the mounted save dir the last bridge call reported: the
|
||||
// recomputation below can name a different volume than the one LOVE
|
||||
// mounted on merged / adopted-SD storage (#604, #839).
|
||||
if (pendingPickSaveDir != null && pendingPickSaveDir.length() > 0) {
|
||||
return new File(pendingPickSaveDir);
|
||||
}
|
||||
File ext = getExternalFilesDir(null);
|
||||
if (ext == null) {
|
||||
// Shared storage unavailable (ejected / mid-adoption): without
|
||||
// this guard File(null, "save") silently built the RELATIVE
|
||||
// path save/<identity>, mkdirs() failed against "/", and the
|
||||
// pick was dropped with no message at all (#604).
|
||||
ext = getFilesDir();
|
||||
}
|
||||
return new File(new File(ext, "save"), ROM_SAVE_IDENTITY);
|
||||
}
|
||||
|
||||
/** Drops a small flag file in the save identity for Lua to consume on focus. */
|
||||
@@ -875,6 +922,7 @@ public class GameActivity extends SDLActivity {
|
||||
protected void onSaveInstanceState(Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
outState.putString(STATE_PENDING_PICK, pendingPickFilename);
|
||||
outState.putString(STATE_PENDING_PICK_DIR, pendingPickSaveDir);
|
||||
outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName);
|
||||
}
|
||||
|
||||
@@ -1277,6 +1325,9 @@ public class GameActivity extends SDLActivity {
|
||||
// in src/jni/love/src/common/android.cpp.
|
||||
private static volatile SecondaryPresentation secondaryPresentation;
|
||||
private static volatile boolean secondaryEnabled = false;
|
||||
private static final int MAX_SECONDARY_TOUCHES = 32;
|
||||
private static final java.util.ArrayDeque<String> secondaryTouches =
|
||||
new java.util.ArrayDeque<>();
|
||||
|
||||
@Keep
|
||||
public static void setSecondaryEnabled(final boolean on) {
|
||||
@@ -1329,6 +1380,7 @@ public class GameActivity extends SDLActivity {
|
||||
private static void teardownSecondaryDisplay() {
|
||||
SecondaryPresentation p = secondaryPresentation;
|
||||
secondaryPresentation = null;
|
||||
synchronized (secondaryTouches) { secondaryTouches.clear(); }
|
||||
if (p != null) {
|
||||
try { p.dismiss(); } catch (Throwable t) {}
|
||||
}
|
||||
@@ -1347,6 +1399,13 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static String pollSecondaryDisplayTouch() {
|
||||
synchronized (secondaryTouches) {
|
||||
return secondaryTouches.pollFirst();
|
||||
}
|
||||
}
|
||||
|
||||
private static class SecondaryPresentation extends android.app.Presentation {
|
||||
private final FrameView frameView;
|
||||
|
||||
@@ -1413,6 +1472,7 @@ public class GameActivity extends SDLActivity {
|
||||
private final android.graphics.Paint paint = new android.graphics.Paint();
|
||||
private final Object lock = new Object();
|
||||
private int fw, fh;
|
||||
private int activePointer = -1;
|
||||
|
||||
FrameView(Context context) {
|
||||
super(context);
|
||||
@@ -1434,6 +1494,52 @@ public class GameActivity extends SDLActivity {
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
private void enqueueTouch(String event) {
|
||||
synchronized (secondaryTouches) {
|
||||
if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) {
|
||||
secondaryTouches.clear();
|
||||
secondaryTouches.addLast("cancel,0,0");
|
||||
} else {
|
||||
secondaryTouches.addLast(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int logicalX(float x) {
|
||||
return Math.min(fw - 1, Math.max(0,
|
||||
(int) ((x - dst.left) * fw / dst.width())));
|
||||
}
|
||||
|
||||
private int logicalY(float y) {
|
||||
return Math.min(fh - 1, Math.max(0,
|
||||
(int) ((y - dst.top) * fh / dst.height())));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(android.view.MotionEvent event) {
|
||||
synchronized (lock) {
|
||||
int action = event.getActionMasked();
|
||||
if (action == android.view.MotionEvent.ACTION_DOWN && fw > 0
|
||||
&& dst.contains((int) event.getX(), (int) event.getY())) {
|
||||
activePointer = event.getPointerId(0);
|
||||
enqueueTouch("down," + logicalX(event.getX()) + ","
|
||||
+ logicalY(event.getY()));
|
||||
} else if (action == android.view.MotionEvent.ACTION_UP
|
||||
&& activePointer >= 0) {
|
||||
int index = event.findPointerIndex(activePointer);
|
||||
if (index >= 0 && fw > 0) {
|
||||
enqueueTouch("up," + logicalX(event.getX(index)) + ","
|
||||
+ logicalY(event.getY(index)));
|
||||
}
|
||||
activePointer = -1;
|
||||
} else if (action == android.view.MotionEvent.ACTION_CANCEL) {
|
||||
activePointer = -1;
|
||||
enqueueTouch("cancel,0,0");
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(android.graphics.Canvas canvas) {
|
||||
synchronized (lock) {
|
||||
|
||||
@@ -12,6 +12,41 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.75",
|
||||
"date": "2026-08-06",
|
||||
"size": 9574086,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.75/gen1recomp-0.1.75-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #826 \"Super effective\" and \"not very effective\" SFX are reversed\n- #848 Roms not showing up\n- #876 Remote mod download is unavailable on this platform\n- #899 Importing Pokémon red leads to the contents extracted without a folder called 'red'\n- #902 Audio is wrong!\n\n## Contributors\n\n- @andrewqsantos\n- @bryanthaboi\n- @caorthann-celt\n- @johnjohto\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.74",
|
||||
"date": "2026-08-06",
|
||||
"size": 9588946,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.74/gen1recomp-0.1.74-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #883 Evolution Stones consumed if evolution is cancelled\n- #887 MacOS Closing game just keeps reopening\n- #894 Max Repel usable in battle\n\n## Contributors\n\n- @bryanthaboi\n- @MarceloMachadoxD\n- @ratherDashing"
|
||||
},
|
||||
{
|
||||
"version": "0.1.73",
|
||||
"date": "2026-08-06",
|
||||
"size": 9585729,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.73/gen1recomp-0.1.73-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #878 Mod API: allow active screen states to be hidden from the main render\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.72",
|
||||
"date": "2026-08-05",
|
||||
"size": 9586678,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.72/gen1recomp-0.1.72-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #604 Android, retroid pocket 2+ Rom won't import\n- #666 Pikachu emotions are not working on Android\n- #716 Lock Auto Rotate Mobile\n- #727 [Bug] [Windows] Gen1 Recomp \"still in use\" after closing\n- #763 Some EVENTS are turned off\n- #781 Mouse cursor broken on Linux with multi-monitor X11 setup\n- #784 Leech Seed effect\n- #799 Held direction randomly stops player movement (requires re-input)\n- #801 Cannot update mods from the launcher (MacOS)\n- #810 Launcher menu cuts off in vertical mode iOS\n- #828 Closing the app causes settings in launcher to reset\n- #834 Mod import failing\n- #838 Exporting save file Pokemon Yellow\n- #839 AYN Thor Misplaced Data files\n- #849 Public folder support on iOS\n- #852 Cannot switch between saves states on smaller 4:3 screen or in vertical mode\n- #857 Mt. Moon Fossils Reappeared and Won’t Disappear.\n- #863 [Yellow] When you use stairs, Pikachu shouldn't be next to you in the new area\n- #864 Faithful Ratio\n- #867 Missing Dialogue after defeating Marowak in Pokemon Tower\n- #869 Giovanni moves up to the player too early\n- #870 Start Menu on Classic Color\n- #872 Missing text when finding an item with full inventory\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.71",
|
||||
"date": "2026-08-05",
|
||||
"size": 9569738,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.71/gen1recomp-0.1.71-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #806 Haptic Feedback for On-screen controls (mobile)\n- #809 NPC Stuck in Rock when battle triggered and pushing rock towards it\n- #853 When selecting one of the two pokemon after defeating the Karate Master it should show the Pokedex entry\n- #854 Textbox disappears when the yes/no dialogue appears\n- #860 Disabled moves can still be used in the turn they were disabled\n- #862 Casino Poster Rocket Grunt walks into the poster and doesn't \"Dang!\"\n- #865 [Yellow] James doesn't move in multiple encounters\n- #866 [Yellow] Dialogue in wrong \"order\" after multiple encounters (J+J, Giovanni, probably more)\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.70",
|
||||
"date": "2026-08-05",
|
||||
|
||||
@@ -85,11 +85,30 @@ public final class GRPickerBridge: NSObject {
|
||||
types = [.zip]
|
||||
case "sav":
|
||||
destName = "picked_save.sav"
|
||||
default:
|
||||
// A Nintendo 64 cartridge, for mods that build assets out of one --
|
||||
// the voxel mod's Pokemon Stadium battle models are the caller this
|
||||
// was added for. Its own filename on purpose: an N64 ROM landing on
|
||||
// picked_rom.gb is swept up by the Game Boy importer, deleted, and
|
||||
// reported to the player as a broken cartridge.
|
||||
case "stadium":
|
||||
destName = "picked_stadium.z64"
|
||||
for ext in ["z64", "n64", "v64"] {
|
||||
if let t = UTType(filenameExtension: ext) { types.append(t) }
|
||||
}
|
||||
case "rom", "":
|
||||
destName = "picked_rom.gb"
|
||||
for ext in ["gb", "gbc"] {
|
||||
if let t = UTType(filenameExtension: ext) { types.append(t) }
|
||||
}
|
||||
// An unknown kind is REFUSED rather than treated as a Game Boy ROM.
|
||||
//
|
||||
// It used to fall through to picked_rom.gb, so a caller asking for a
|
||||
// kind this build had never heard of got its file deleted and
|
||||
// reported as a broken cartridge -- the worst possible answer to
|
||||
// "I do not know that one". Returning false lets the caller find out
|
||||
// and offer its own fallback.
|
||||
default:
|
||||
return false
|
||||
}
|
||||
// .gb/.gbc/.sav resolve to dynamic UTTypes on most devices; offering
|
||||
// .data as well keeps every real file selectable. The importer
|
||||
@@ -107,6 +126,20 @@ public final class GRPickerBridge: NSObject {
|
||||
return present(picker, with: delegate)
|
||||
}
|
||||
|
||||
// Which kinds presentPicker understands, comma separated.
|
||||
//
|
||||
// So a CALLER can ask before it calls. A mod that wants a kind this build
|
||||
// predates cannot otherwise tell "refused" from "the picker would not
|
||||
// open", and guessing wrong used to cost the player their ROM (see the
|
||||
// default case above). Asking first turns that into a fallback the caller
|
||||
// chooses rather than a file it loses.
|
||||
//
|
||||
// Kept beside the switch it describes, because the two drifting apart is
|
||||
// the only way this can lie.
|
||||
@objc public static func supportedPickerKinds() -> NSString {
|
||||
return "rom,mod,sav,stadium" as NSString
|
||||
}
|
||||
|
||||
@objc(presentExportWithName:saveDir:)
|
||||
public static func presentExport(name: UnsafePointer<CChar>?,
|
||||
saveDir: UnsafePointer<CChar>?) -> Bool {
|
||||
|
||||
@@ -84,6 +84,49 @@ int w_pickFile(lua_State *L)
|
||||
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
|
||||
}
|
||||
|
||||
// love.system.pickFileKinds() -> "rom,mod,sav,stadium", or nil off iOS.
|
||||
//
|
||||
// So a caller can ask what this build's picker understands BEFORE opening it.
|
||||
// An unknown kind is refused (GRPickerBridge), and a refusal looks exactly
|
||||
// like a picker that would not open -- so a caller with a fallback worth
|
||||
// showing needs to know which it is facing. A mod that guesses instead has
|
||||
// no way back: before the refusal landed, an unrecognised kind wrote
|
||||
// picked_rom.gb and the ROM importer deleted it.
|
||||
//
|
||||
// nil where there is no bridge at all, which reads the same as "no kinds".
|
||||
int w_pickFileKinds(lua_State *L)
|
||||
{
|
||||
Class cls = objc_getClass("GRPickerBridge");
|
||||
if (cls == nullptr)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
// Fetched through the runtime: wrap_System.cpp is compiled as C++ rather
|
||||
// than Objective-C++, so no Foundation type may be NAMED here -- writing
|
||||
// `NSString` alone breaks the whole translation unit. objc_msgSend is a
|
||||
// plain C entry point and `id` comes from objc/runtime.h, so the string
|
||||
// is asked for its UTF8 bytes without ever being typed.
|
||||
typedef id (*GRObj)(Class, SEL);
|
||||
id kinds = ((GRObj)objc_msgSend)(cls,
|
||||
sel_registerName("supportedPickerKinds"));
|
||||
if (kinds == nullptr)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
typedef const char *(*GRUTF8)(id, SEL);
|
||||
const char *bytes = ((GRUTF8)objc_msgSend)(kinds,
|
||||
sel_registerName("UTF8String"));
|
||||
if (bytes == nullptr || bytes[0] == '\0')
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
lua_pushstring(L, bytes);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_createFile(lua_State *L)
|
||||
{
|
||||
const char *name = luaL_optstring(L, 1, "export.sav");
|
||||
@@ -101,6 +144,7 @@ int w_syncHealthSteps(lua_State *L)
|
||||
|
||||
WRAP_REGISTRATION = """#ifdef LOVE_IOS
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "pickFileKinds", w_pickFileKinds },
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "httpDownload", w_httpDownload },
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# spanish_ui
|
||||
|
||||
A Espanol translation of the game.
|
||||
|
||||
Generated with `python3 tools/modkit.py translation spanish_ui`. See
|
||||
`TRANSLATING.md` for how to work on it.
|
||||
|
||||
## Status
|
||||
|
||||
Nothing is translated yet: 601 strings are waiting in `lang/`.
|
||||
|
||||
| Catalog | Entries |
|
||||
|---|---|
|
||||
| `lang/dialogue.lua` | 6 |
|
||||
| `lang/strings.lua` | 577 |
|
||||
| `lang/species_names.lua` | 3 |
|
||||
| `lang/move_names.lua` | 4 |
|
||||
| `lang/item_names.lua` | 5 |
|
||||
| `lang/trainer_names.lua` | 1 |
|
||||
| `lang/status_labels.lua` | 5 |
|
||||
|
||||
## Layout
|
||||
|
||||
- `manifest.json` - identity and the engine version range
|
||||
- `main.lua` - registers whatever is filled in and skips whatever is not
|
||||
- `lang/` - the catalogs; this is the whole job
|
||||
- `assets/font/` - your glyph sheet
|
||||
@@ -0,0 +1,111 @@
|
||||
# Translating into Espanol
|
||||
|
||||
Everything the player can read is one of two kinds of string, and they live
|
||||
in different places for a reason.
|
||||
|
||||
| lang/ file | What it is | Key |
|
||||
|---|---|---|
|
||||
| `dialogue.lua` | Every line of extracted script text | the original label, e.g. `_PalletTownText1` |
|
||||
| `strings.lua` | Text the engine itself writes: battle messages, menus, link play | the English source string |
|
||||
| `species.lua` `moves.lua` `items.lua` `trainers.lua` | Names | the vanilla id |
|
||||
| `statuses.lua` | `PSN`, `BRN`, ... as they appear in the HUD | the status id |
|
||||
| `font.lua` `charmap.lua` | Your glyph sheet and what draws what | see below |
|
||||
| `naming.lua` | The letter grid for entering names | - |
|
||||
|
||||
Fill in a value and it takes effect. Leave it `""` and that string stays in
|
||||
English, so the game is playable at every point along the way.
|
||||
|
||||
## Where the English is
|
||||
|
||||
The catalogs hold keys and *your* text, never the original English. The
|
||||
English lives next door, in `spanish_ui-worksheet/`, one tab-separated file per
|
||||
catalog:
|
||||
|
||||
```
|
||||
"_AbandonLearningText" "Abandon learning\n{RAM:wStringBuffer}?"
|
||||
```
|
||||
|
||||
That directory is deliberately outside the mod. Extracted script text and
|
||||
the vanilla names are ROM content, and `modkit pack` zips everything under
|
||||
the mod directory, so a worksheet kept inside would end up in your release
|
||||
whatever a `.gitignore` said. Keep it beside the mod, never in it.
|
||||
|
||||
`lang/strings.lua` is the exception: those sources are the engine's own Lua
|
||||
rather than anything out of the ROM, so there the key *is* the English and
|
||||
you can translate straight from it.
|
||||
|
||||
## Start with the font, not the text
|
||||
|
||||
The engine draws from **glyph pages**: an image of 8x8 cells plus a charmap
|
||||
saying which byte sequence draws which cell. The vanilla pages sit at `$60`
|
||||
and `$80`. Anything from `0x100` up is free, so a new alphabet is added
|
||||
rather than swapped in:
|
||||
|
||||
```lua
|
||||
-- lang/font.lua
|
||||
return {
|
||||
spanish_ui = {
|
||||
image = "assets/font/spanish_ui.png",
|
||||
base = 0x100, -- first code this page owns
|
||||
glyphsPerRow = 16,
|
||||
-- advance = 8, -- set this if your glyphs are not 8px wide
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```lua
|
||||
-- lang/charmap.lua: sequence -> code, in the same order as the sheet
|
||||
return {
|
||||
["A"] = 0x100,
|
||||
["B"] = 0x101,
|
||||
}
|
||||
```
|
||||
|
||||
The sheet is a plain PNG, 16 glyphs to a row by default, each cell 8x8,
|
||||
black on white like `assets/generated/font.png`. Codes run left to right,
|
||||
top to bottom from `base`.
|
||||
|
||||
Sequences are matched **longest first**, so a multi-byte character and a
|
||||
multi-character ligature both work and neither shadows the other:
|
||||
|
||||
```lua
|
||||
["\u{3042}"] = 0x120, -- one 3-byte character, one glyph
|
||||
["ch"] = 0x121, -- two ASCII letters, one glyph
|
||||
```
|
||||
|
||||
## Line length is counted in glyphs
|
||||
|
||||
The dialogue box fits 18 glyphs a line, not 18 bytes. A 3-byte character
|
||||
costs one column, and the engine will never cut a character in half. Your
|
||||
own `\n` line breaks are respected exactly as written, so break lines where
|
||||
they read best rather than where they fit English.
|
||||
|
||||
If your glyphs are not 8px wide, set `advance` on the page and the box
|
||||
re-measures.
|
||||
|
||||
## Format directives must survive
|
||||
|
||||
Some sources carry `%s` or `%d`:
|
||||
|
||||
```lua
|
||||
["Wild %s\nappeared!"] = "...",
|
||||
```
|
||||
|
||||
Keep every directive, in a count that matches. Word order is yours to
|
||||
change; the engine substitutes in the order the directives appear, so if
|
||||
your language needs the name last, write the sentence with the `%s` last.
|
||||
A translation whose directive count does not match the English is refused
|
||||
at runtime and the English is drawn instead, with a line in the log saying
|
||||
so - it will not crash a battle.
|
||||
|
||||
## Checking your work
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py validate spanish_ui --base imported
|
||||
python3 tools/modkit.py translation spanish_ui --refresh # pick up new engine strings
|
||||
POKEPORT_DEV=1 scripts/run.sh # F5 hot-reloads lang/
|
||||
```
|
||||
|
||||
`--refresh` rewrites the catalogs from the current engine, keeping every
|
||||
translation you have already written and reporting what changed. Run it
|
||||
after pulling a new engine version.
|
||||
@@ -0,0 +1,11 @@
|
||||
Put your glyph sheet here.
|
||||
|
||||
A page is a PNG of 8x8 cells, 16 per row by default, black on white. Codes
|
||||
run left to right and top to bottom starting at the page's `base`, so the
|
||||
first cell is `base`, the second `base + 1`, and so on.
|
||||
|
||||
`assets/generated/font.png` in the player's cache is the vanilla sheet at
|
||||
the same scale; open it alongside yours to match weight and baseline.
|
||||
|
||||
Declare the sheet in `lang/font.lua` and map sequences to codes in
|
||||
`lang/charmap.lua`.
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Which byte sequence draws which glyph code.
|
||||
--
|
||||
-- Sequences are matched longest-first, so a multi-byte character and a
|
||||
-- multi-character ligature both work: "ch" can be one glyph even though
|
||||
-- "c" is also mapped. Codes here must land inside a page declared in
|
||||
-- lang/font.lua.
|
||||
return {
|
||||
-- ["A"] = 0x100,
|
||||
-- ["B"] = 0x101,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Script text
|
||||
--
|
||||
-- Keyed by the original text label. The English is in the comment.
|
||||
|
||||
return {
|
||||
["_FixMartText"] = "",
|
||||
["_FixRouteTrainerAfterText"] = "",
|
||||
["_FixRouteTrainerBattleText"] = "",
|
||||
["_FixRouteTrainerEndText"] = "",
|
||||
["_FixTownGreeterText"] = "",
|
||||
["_FixTownSignText"] = "",
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Glyph pages this translation adds. Delete the entry if the vanilla
|
||||
-- alphabet already covers your language.
|
||||
--
|
||||
-- base is the first glyph code the page owns. 0x100 and up is free space
|
||||
-- above the vanilla $60/$80 pages, so this adds an alphabet rather than
|
||||
-- replacing one. Set `advance` if your glyphs are not 8px wide.
|
||||
return {
|
||||
-- spanish_ui = {
|
||||
-- image = "assets/font/spanish_ui.png",
|
||||
-- base = 0x100,
|
||||
-- glyphsPerRow = 16,
|
||||
-- },
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Item names
|
||||
--
|
||||
-- Item names for Espanol.
|
||||
|
||||
return {
|
||||
["FIX_BADGE_1"] = "",
|
||||
["FIX_BADGE_2"] = "",
|
||||
["FIX_BALL"] = "",
|
||||
["FIX_POTION"] = "",
|
||||
["FIX_TM"] = "",
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Move names
|
||||
--
|
||||
-- Move names for Espanol.
|
||||
|
||||
return {
|
||||
["FIX_CUT"] = "",
|
||||
["FIX_EMBERISH"] = "",
|
||||
["FIX_SCRATCH"] = "",
|
||||
["FIX_TACKLE"] = "",
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
-- The naming screen's letter grid. Return an empty table to keep the
|
||||
-- English alphabet.
|
||||
--
|
||||
-- Each entry is a row of cells; a cell is whatever sequence your charmap
|
||||
-- maps, so a multi-byte character is one cell. The row holding a single
|
||||
-- "lower case" / "UPPER CASE" cell is the case switch, and the cell
|
||||
-- spelled "ED" is the confirm.
|
||||
--
|
||||
-- The screen is 160x144 and NamingScreen draws cell `c` of row `r` at
|
||||
-- (c * 16, 32 + r * 16), so the grid is capped at **9 columns and 6 rows**:
|
||||
-- a 10th column lands at x=160 and a 7th row at y=144, both off screen.
|
||||
-- That leaves 44 usable cells, exactly what vanilla uses, so Spanish
|
||||
-- letters have to displace something rather than being added.
|
||||
--
|
||||
-- What gives way is vanilla's `× ( ) : ; [ ]` row. Those are legal in a
|
||||
-- Gen-1 nickname but nobody reaches for them, whereas Ñ is not optional in
|
||||
-- Spanish -- and here it sits in its alphabetical place after N, which is
|
||||
-- where a Spanish speaker will look for it. Space, <PK> and <MN> are kept.
|
||||
--
|
||||
-- These glyphs exist in the Spanish cartridge's font ($CA Ñ, $BF Á, $C7 É,
|
||||
-- $C9 Í, $CC Ó, $CE Ú, $C2 Ü and their lowercase). On an English ROM they
|
||||
-- do not, so main.lua checks the running game's charmap first and keeps the
|
||||
-- English grid rather than drawing blank cells.
|
||||
return {
|
||||
upper = {
|
||||
{ "A", "B", "C", "D", "E", "F", "G", "H", "I" },
|
||||
{ "J", "K", "L", "M", "N", "Ñ", "O", "P", "Q" },
|
||||
{ "R", "S", "T", "U", "V", "W", "X", "Y", "Z" },
|
||||
{ "Á", "É", "Í", "Ó", "Ú", "Ü", " ", "<PK>", "<MN>" },
|
||||
{ "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" },
|
||||
{ "lower case" },
|
||||
},
|
||||
lower = {
|
||||
{ "a", "b", "c", "d", "e", "f", "g", "h", "i" },
|
||||
{ "j", "k", "l", "m", "n", "ñ", "o", "p", "q" },
|
||||
{ "r", "s", "t", "u", "v", "w", "x", "y", "z" },
|
||||
{ "á", "é", "í", "ó", "ú", "ü", " ", "<PK>", "<MN>" },
|
||||
{ "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" },
|
||||
{ "UPPER CASE" },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Species names
|
||||
--
|
||||
-- Species names for Espanol.
|
||||
|
||||
return {
|
||||
["FIXMON_A"] = "",
|
||||
["FIXMON_B"] = "",
|
||||
["FIXMON_C"] = "",
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Status labels
|
||||
--
|
||||
-- Short enough for the battle HUD: the vanilla ones are three glyphs.
|
||||
|
||||
return {
|
||||
["BRN"] = "",
|
||||
["FRZ"] = "",
|
||||
["PAR"] = "",
|
||||
["PSN"] = "",
|
||||
["SLP"] = "",
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
-- Engine text
|
||||
--
|
||||
-- Keyed by the English source, which is also what draws if you leave
|
||||
-- an entry empty. Keep any %s / %d directives.
|
||||
|
||||
return {
|
||||
["%s\nflew up high!"] = "¡%s\nvoló muy alto!",
|
||||
["%s\ndug a hole!"] = "¡%s\ncavó un hoyo!",
|
||||
["%s\nmade a whirlwind!"] = "¡%s\ncreó un torbellino!",
|
||||
["%s\ntook in sunlight!"] = "¡%s\nabsorbió luz!",
|
||||
["%s\nlowered its head!"] = "¡%s\nbajó la cabeza!",
|
||||
["%s\nis glowing!"] = "¡%s\nestá brillando!",
|
||||
["The hooked\n%s\nattacked!"] = "¡El %s\nenganchado atacó!",
|
||||
["Wild %s\nappeared!"] = "¡Un %s\nsalvaje apareció!",
|
||||
["%s wants\nto fight!"] = "¡%s\nquiere luchar!",
|
||||
["The GHOST\nappeared!"] = "¡Apareció el\nFANTASMA!",
|
||||
["Go! %s!"] = "¡Ve, %s!",
|
||||
["Do it! %s!"] = "¡Hazlo, %s!",
|
||||
["Get'm! %s!"] = "¡A por él, %s!",
|
||||
["The enemy's weak!\nGet'm! %s!"] = "¡Está débil!\n¡A por él, %s!",
|
||||
["%s is out of\nuseable POKéMON!"] = "¡%s no tiene\nPOKéMON útiles!",
|
||||
["%s blacked\nout!"] = "¡%s se\ndebilitó!",
|
||||
["%s sent\nout %s!"] = "¡%s envió\na %s!",
|
||||
["PA: You're out of\nSAFARI BALLs!\nGame over!"] = "AV: ¡No te quedan\nSAFARI BALLs!\n¡Fin del juego!",
|
||||
["%s is too\nscared to move!"] = "¡%s tiene\ndemasiado miedo!",
|
||||
["%s has no\nmoves left!"] = "¡%s no tiene\nmovimientos!",
|
||||
["The move is\ndisabled!"] = "¡El movimiento\nestá anulado!",
|
||||
["No PP left for\nthis move!"] = "¡No quedan PP para\neste movimiento!",
|
||||
["But, it failed!"] = "¡Pero falló!",
|
||||
["%s\nlearned\n%s!"] = "¡%s\naprendió\n%s!",
|
||||
["POKé BALL"] = "POKé BALL",
|
||||
["%s used\nPOKé BALL!"] = "¡%s usó\nPOKé BALL!",
|
||||
["All right!\n%s was\ncaught!"] = "¡Bien!\n¡%s fue\ncapturado!",
|
||||
["GHOST: Get out...\nGet out..."] = "FANTASMA: Fuera...\nFuera...",
|
||||
["%s with-\ndrew %s!"] = "¡%s retiró\na %s!",
|
||||
["%s\nmust recharge!"] = "¡%s debe\nrecargarse!",
|
||||
["%s\nis fast asleep!"] = "¡%s está\nprofundamente dormido!",
|
||||
["%s\nis confused!"] = "¡%s está\nconfuso!",
|
||||
["%s\nwoke up!"] = "¡%s se\ndespertó!",
|
||||
["%s\nis frozen solid!"] = "¡%s está\ncongelado!",
|
||||
["%s\ncan't move!"] = "¡%s no\npuede moverse!",
|
||||
["%s\nflinched!"] = "¡%s se\namedrentó!",
|
||||
["It hurt itself in\nits confusion!"] = "¡Se hirió a sí\nmismo por confusión!",
|
||||
["%s\nused %s!"] = "¡%s usó\n%s!",
|
||||
["%s\nis charging up!"] = "¡%s está\ncargando energía!",
|
||||
["%s's\nattack missed!"] = "¡El ataque de %s\nfalló!",
|
||||
["%s's\nattack continues!"] = "¡El ataque de %s\ncontinúa!",
|
||||
["%s\nis storing energy!"] = "¡%s está\nacumulando energía!",
|
||||
["%s\nunleashed energy!"] = "¡%s liberó\nsu energía!",
|
||||
["%s's\nSUBSTITUTE broke!"] = "¡El SUSTITUTO de\n%s se rompió!",
|
||||
["The SUBSTITUTE\ntook damage for\n%s!"] = "¡El SUSTITUTO\nrecibió el daño\nde %s!",
|
||||
["%s's\nRAGE is building!"] = "¡La FURIA de %s\nva creciendo!",
|
||||
["%s\nfainted!"] = "¡%s se\ndebilitó!",
|
||||
["%s gained\n%d EXP. Points!"] = "¡%s ganó\n%d P. EXP.!",
|
||||
["%s gained\nwith EXP.ALL,\v%d EXP. Points!"] = "¡%s ganó\ncon EXP.TODOS,\v%d P. EXP.!",
|
||||
["%s gained\na boosted\v%d EXP. Points!"] = "¡%s ganó\nun extra de\v%d P. EXP.!",
|
||||
["%s grew\nto level %d!"] = "¡%s subió\nal nivel %d!",
|
||||
["%s is\nabout to use"] = "%s va a usar",
|
||||
["%s!"] = "¡%s!",
|
||||
["Will %s\nchange POKéMON?"] = "¿%s va a\ncambiar de POKéMON?",
|
||||
["%s defeated\n%s!"] = "¡%s venció\na %s!",
|
||||
["%s got ¥%d\nfor winning!"] = "¡%s ganó\n¥%d!",
|
||||
["%s learned\n%s!"] = "¡%s aprendió\n%s!",
|
||||
["{RIVAL}: Yeah! Am\nI great or what?"] = "{RIVAL}: ¡Sí! ¿Soy\ngenial o qué?",
|
||||
["Use next POKéMON?"] = "¿Sacar al siguiente?",
|
||||
["Got away safely!"] = "¡Escapaste!",
|
||||
["Can't escape!"] = "¡No puedes escapar!",
|
||||
["There's no will\nto fight!"] = "¡No hay ganas de\nluchar!",
|
||||
["%s used\nSAFARI BALL!"] = "¡%s usó\nSAFARI BALL!",
|
||||
["%s threw some\nBAIT."] = "%s echó\nCEBO.",
|
||||
["%s threw a\nROCK."] = "%s tiró una\nPIEDRA.",
|
||||
["Wild %s\nis eating!"] = "¡El %s\nsalvaje come!",
|
||||
["Wild %s\nis angry!"] = "¡El %s\nsalvaje se enfadó!",
|
||||
["Wild %s\nran!"] = "¡El %s\nsalvaje huyó!",
|
||||
["No! There's no\nrunning from a\vtrainer battle!"] = "¡No! ¡No puedes\nhuir de un combate\vcontra un entrenador!",
|
||||
["You missed the\nPOKéMON!"] = "¡Fallaste el tiro!",
|
||||
["Darn! The POKéMON\nbroke free!"] = "¡Vaya! ¡El POKéMON\nse escapó!",
|
||||
["Aww! It appeared\nto be caught!"] = "¡Oh! ¡Parecía que\nestaba capturado!",
|
||||
["Shoot! It was so\nclose too!"] = "¡Vaya! ¡Estuvo\nmuy cerca!",
|
||||
["Do you want to\ngive a nickname\nto %s?"] = "¿Quieres poner un\nmote a\n%s?",
|
||||
["NICKNAME?"] = "MOTE?",
|
||||
["New POKéDEX data\nwill be added for\n%s!"] = "¡Se añadirán datos\nnuevos a la POKéDEX\nde %s!",
|
||||
["someone's PC"] = "el PC de alguien",
|
||||
["%s was\ntransferred to\n%s!"] = "¡%s fue\ntransferido a\n%s!",
|
||||
["But every BOX\nis full!"] = "¡Pero todas las\nCAJAS están llenas!",
|
||||
["%s used\n%s!"] = "¡%s usó\n%s!",
|
||||
["The trainer\nblocked the BALL!"] = "¡El entrenador\nbloqueó la BALL!",
|
||||
["Don't be a thief!"] = "¡No seas ladrón!",
|
||||
["It dodged the\nthrown BALL!"] = "¡Esquivó la BALL!",
|
||||
["This POKéMON\ncan't be caught!"] = "¡Este POKéMON no\nse puede capturar!",
|
||||
["%s is\nalready out!"] = "¡%s ya\nestá fuera!",
|
||||
["%s picked up\n¥%d!"] = "¡%s recogió\n¥%d!",
|
||||
["FIGHT"] = "LUCHAR",
|
||||
["ITEM"] = "OBJETO",
|
||||
["RUN"] = "HUIR",
|
||||
["BALLx"] = "BALLx",
|
||||
["BAIT"] = "CEBO",
|
||||
["THROW ROCK"] = "TIRAR PIEDRA",
|
||||
["disabled!"] = "¡anulado!",
|
||||
["TYPE/"] = "TIPO/",
|
||||
["It doesn't affect\n%s!"] = "¡No afecta a\n%s!",
|
||||
["Critical hit!"] = "¡Golpe crítico!",
|
||||
["One-hit KO!"] = "¡KO en un golpe!",
|
||||
["It's super\neffective!"] = "¡Es muy eficaz!",
|
||||
["It's not very\neffective..."] = "¡No es muy\neficaz...",
|
||||
["Hit the enemy\n%d times!"] = "¡Golpeó al enemigo\n%d veces!",
|
||||
["Hit %d times!"] = "¡Golpeó %d veces!",
|
||||
["%s's\nhit with recoil!"] = "¡%s sufrió\nel retroceso!",
|
||||
["%s is\nprotected by MIST!"] = "¡%s está\nprotegido por NIEBLA!",
|
||||
["Nothing happened!"] = "¡No pasó nada!",
|
||||
["%s's\n%s\ngreatly rose!"] = "¡%s\nmejoró mucho su\n%s!",
|
||||
["%s's\n%s rose!"] = "¡%s mejoró\nsu %s!",
|
||||
["%s's\n%s fell!"] = "¡%s bajó\nsu %s!",
|
||||
["%s's\n%s\ngreatly fell!"] = "¡%s\nbajó mucho su\n%s!",
|
||||
["Fire defrosted\n%s!"] = "¡El fuego descongeló\na %s!",
|
||||
["%s\nbecame confused!"] = "¡%s se\nconfundió!",
|
||||
["%s\nwas seeded!"] = "¡%s recibió\nla DRENADORA!",
|
||||
["%s\nstarted sleeping!"] = "¡%s se\nquedó dormido!",
|
||||
["%s\nregained health!"] = "¡%s recuperó\nsalud!",
|
||||
["%s's\nprotected against\nspecial attacks!"] = "¡%s está\nprotegido de los\nataques especiales!",
|
||||
["%s\ngained armor!"] = "¡%s ganó\narmadura!",
|
||||
["%s's\nshrouded in mist!"] = "¡%s se\ncubrió de niebla!",
|
||||
["%s's\ngetting pumped!"] = "¡%s se\nestá animando!",
|
||||
["All STATUS changes\nare eliminated!"] = "¡Los cambios de\nESTADO desaparecen!",
|
||||
["%s\nhas a SUBSTITUTE!"] = "¡%s tiene\nun SUSTITUTO!",
|
||||
["Too weak to make\na SUBSTITUTE!"] = "¡Muy débil para\nhacer un SUSTITUTO!",
|
||||
["It created a\nSUBSTITUTE!"] = "¡Creó un SUSTITUTO!",
|
||||
["Converted type to\n%s's!"] = "¡Cambió su tipo al\nde %s!",
|
||||
["%s\ntransformed into\n%s!"] = "¡%s se\ntransformó en\n%s!",
|
||||
["%s's\n%s was\ndisabled!"] = "¡El %s\nde %s\nfue anulado!",
|
||||
["No effect!"] = "¡Sin efecto!",
|
||||
["Sucked health from\n%s!"] = "¡Absorbió salud de\n%s!",
|
||||
["%s's\ndream was eaten!"] = "¡Devoró el sueño\nde %s!",
|
||||
["%s\nkept going and\ncrashed!"] = "¡%s siguió\nadelante y se\nestrelló!",
|
||||
["Coins scattered\neverywhere!"] = "¡Las monedas se\ndesparramaron!",
|
||||
["%s\nran away scared!"] = "¡%s huyó\nasustado!",
|
||||
["%s\nwas blown away!"] = "¡%s salió\nvolando!",
|
||||
["%s\nran from battle!"] = "¡%s huyó\ndel combate!",
|
||||
["It didn't affect\n%s!"] = "¡No afectó a\n%s!",
|
||||
["%s\nis unaffected!"] = "¡%s no se\nvio afectado!",
|
||||
["The MIRROR MOVE\nfailed!"] = "¡El MOVIMIENTO\nESPEJO falló!",
|
||||
["%s\nfell asleep!"] = "¡%s se\nquedó dormido!",
|
||||
["%s\nwas frozen solid!"] = "¡%s se\ncongeló!",
|
||||
["%s's\nhurt by poison!"] = "¡El veneno hiere a\n%s!",
|
||||
["%s's\nbadly poisoned!"] = "¡%s está\ngravemente envenenado!",
|
||||
["%s\nwas poisoned!"] = "¡%s fue\nenvenenado!",
|
||||
["%s's\nhurt by the burn!"] = "¡La quemadura hiere\na %s!",
|
||||
["%s\nwas burned!"] = "¡%s se\nquemó!",
|
||||
["%s's\nfully paralyzed!"] = "¡%s está\ntotalmente paralizado!",
|
||||
["%s's\nparalyzed! It may\nnot attack!"] = "¡%s está\nparalizado! ¡Puede\nque no ataque!",
|
||||
["%s's\ndisabled no more!"] = "¡%s ya no\nestá anulado!",
|
||||
["%s\nsnapped out of\nconfusion!"] = "¡%s salió\nde su confusión!",
|
||||
["LEECH SEED saps\n%s!"] = "¡La DRENADORA\nabsorbe a %s!",
|
||||
["%s\nwas afflicted\nby %s!"] = "¡%s sufre\n%s!",
|
||||
["%s's\nprotected against\nstat changes!"] = "¡%s está\nprotegido de los\ncambios de estado!",
|
||||
["What will"] = "¿Qué va a hacer",
|
||||
[" do?"] = "?",
|
||||
["You can't get off\nhere."] = "No puedes bajarte\naquí.",
|
||||
["%s got off\nthe BICYCLE."] = "%s se bajó\nde la BICICLETA.",
|
||||
["%s got on\nthe BICYCLE!"] = "¡%s se subió\na la BICICLETA!",
|
||||
["No cycling\nallowed here."] = "No se puede montar\naquí.",
|
||||
["No good! It's not\neven near water."] = "¡No sirve! No hay\nagua cerca.",
|
||||
["OAK: %s!\nThis isn't the\ntime to use that!"] = "OAK: ¡%s!\n¡No es momento\nde usar eso!",
|
||||
["The TOWN MAP is\nunreadable here."] = "El MAPA PUEBLO no\nse puede leer aquí.",
|
||||
["Yes! ITEMFINDER\nindicates there's\nan item nearby."] = "¡Sí! El BUSCAOBJ.\nindica que hay algo\ncerca.",
|
||||
["Nope! ITEMFINDER\nisn't responding."] = "¡No! El BUSCAOBJ.\nno responde.",
|
||||
["Booted up a TM!"] = "¡Se activó una MT!",
|
||||
["It contained\n%s!"] = "¡Contenía\n%s!",
|
||||
["USE"] = "USAR",
|
||||
["TOSS"] = "TIRAR",
|
||||
["That's too impor-\ntant to toss!"] = "¡Es demasiado\nimportante!",
|
||||
["Threw away\n%s."] = "Tiraste\n%s.",
|
||||
["PRESS A BUTTON"] = "PULSA UN BOTON",
|
||||
["ESC TO CANCEL"] = "ESC PARA CANCELAR",
|
||||
["%s :L%d"] = "%s :N%d",
|
||||
["STATS"] = "DATOS",
|
||||
["CANCEL"] = "CANCELAR",
|
||||
["What? There are\nno POKéMON here!"] = "¿Qué? ¡Aquí no hay\nningún POKéMON!",
|
||||
["You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst."] = "No puedes llevar\nmás POKéMON.\fGuarda alguno\nprimero.",
|
||||
["BOX %d (WITHDRAW)"] = "CAJA %d (RETIRAR)",
|
||||
["%s is\ntaken out.\vGot %s."] = "Retirado\n%s.\vRecibes %s.",
|
||||
["You can't deposit\nthe last POKéMON!"] = "¡No puedes guardar\nel último POKéMON!",
|
||||
["Oops! This Box is\nfull of POKéMON."] = "¡Uups! Esta CAJA\nestá llena.",
|
||||
["You need at least\none POKéMON!"] = "¡Necesitas al menos\nun POKéMON!",
|
||||
["BOX %d is full!"] = "¡La CAJA %d está\nllena!",
|
||||
["%s was\nstored in Box %s."] = "%s se\nguardó en la CAJA %s.",
|
||||
["BOX %d (RELEASE)"] = "CAJA %d (SOLTAR)",
|
||||
["Once released,\n%s is\ngone forever. OK?"] = "Si lo sueltas,\n%s se\nirá para siempre. ¿OK?",
|
||||
["%s was\nreleased outside.\fBye %s!"] = "%s fue\nliberado.\f¡Adiós, %s!",
|
||||
["%sBOX %2d"] = "%sCAJA %2d",
|
||||
["When you change a\nPOKéMON BOX, data\nwill be saved. OK?"] = "Al cambiar de CAJA\nse guardarán los\ndatos. ¿OK?",
|
||||
["What?"] = "¿Qué?",
|
||||
["BOX No."] = "CAJA No.",
|
||||
["BOX No.%d"] = "CAJA No.%d",
|
||||
["Empty."] = "Vacía.",
|
||||
[":L%d No.%03d"] = ":N%d No.%03d",
|
||||
["Printed BOX %d!\fSaved as\n%s\vin the save\nfolder."] = "¡CAJA %d impresa!\fGuardada como\n%s\ven la carpeta de\nguardado.",
|
||||
["Printer error!\n%s"] = "¡Error de impresión!\n%s",
|
||||
["WITHDRAW <PK><MN>"] = "RETIRAR <PK><MN>",
|
||||
["DEPOSIT <PK><MN>"] = "GUARDAR <PK><MN>",
|
||||
["RELEASE <PK><MN>"] = "SOLTAR <PK><MN>",
|
||||
["CHANGE BOX"] = "CAMBIAR CAJA",
|
||||
["PRINT BOX"] = "IMPRIMIR CAJA",
|
||||
["SEE YA!"] = "HASTA LUEGO!",
|
||||
["YES"] = "SI",
|
||||
["NO"] = "NO",
|
||||
["GAME FREAK"] = "",
|
||||
["Nintendo"] = "",
|
||||
["Creatures inc."] = "",
|
||||
["GAME FREAK inc."] = "",
|
||||
["T H E E N D"] = "F I N",
|
||||
["HT %d′%02d″"] = "AL %d′%02d″",
|
||||
["WT %.1flb"] = "PE %.1flb",
|
||||
["Data unknown."] = "Datos desconocidos.",
|
||||
["<Diploma>"] = "<Diploma>",
|
||||
["Player"] = "Jugador",
|
||||
["Huh? %s\nstopped evolving!"] = "¿Eh? ¡%s\ndejó de evolucionar!",
|
||||
["Congratulations!\nYour %s\nevolved into\n%s!"] = "¡Enhorabuena!\n¡Tu %s\nevolucionó a\n%s!",
|
||||
["evolving!"] = "evolucionando",
|
||||
["POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"] = "POKéDEX Vistos:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Capturados:{NUM:wDexRatingNumMonsOwned, 1, 3}",
|
||||
["POKéDEX Rating{COLON}"] = "Nota POKéDEX{COLON}",
|
||||
["Keep it up!"] = "¡Sigue así!",
|
||||
["LEVEL/"] = "NIVEL/",
|
||||
["TYPE1/"] = "TIPO1/",
|
||||
["TYPE2/"] = "TIPO2/",
|
||||
["HALL OF FAME"] = "SALON DE LA FAMA",
|
||||
["PLAY TIME"] = "TIEMPO",
|
||||
["MONEY"] = "DINERO",
|
||||
["bois club games"] = "bois club games",
|
||||
["GENGAR VS NIDORINO"] = "",
|
||||
["bois club"] = "bois club",
|
||||
["Nothing here."] = "Aquí no hay nada.",
|
||||
["%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f"] = "¡%s está\nintentando aprender\v%s!\f¡Pero %s\nno puede aprender\vmás de 4!\f",
|
||||
["Delete an older\nmove to make room\vfor %s?"] = "¿Borrar un movi-\nmiento antiguo para\vaprender %s?",
|
||||
["HM techniques\ncan't be deleted!"] = "¡Los movimientos MO\nno se pueden borrar!",
|
||||
["Abandon learning\n%s?"] = "¿Dejar de aprender\n%s?",
|
||||
["1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!"] = "¡1, 2 y... plaf!\f¡%s olvidó\n%s!\f¡Y...\f%s aprendió\n%s!",
|
||||
["%s\ndid not learn\v%s!"] = "¡%s no\naprendió\v%s!",
|
||||
["Which move should"] = "¿Qué movimiento",
|
||||
["be forgotten?"] = "hay que olvidar?",
|
||||
["YOUR NAME?"] = "TU NOMBRE?",
|
||||
["NEW NAME"] = "NUEVO NOMBRE",
|
||||
["Hello there!\nWelcome to the\vworld of POKéMON!\fMy name is OAK!\nPeople call me\vthe POKéMON PROF!"] = "¡Hola!\n¡Bienvenido al\vmundo POKéMON!\fMe llamo OAK.\nMe llaman el\vPROF. POKéMON.",
|
||||
["This world is\ninhabited by\vcreatures called\vPOKéMON!"] = "¡Este mundo está\nhabitado por unas\vcriaturas llamadas\vPOKéMON!",
|
||||
["\fFor some people,\nPOKéMON are\vpets. Others use\vthem for fights.\fMyself...\fI study POKéMON\nas a profession."] = "\fPara algunos, los\nPOKéMON son masco-\vtas. Otros luchan\vcon ellos.\fYo...\fEstudio los POKéMON\ncomo profesión.",
|
||||
["{PLAYER}!\fYour very own\nPOKéMON legend is\vabout to unfold!\fA world of dreams\nand adventures\vwith POKéMON\vawaits! Let's go!"] = "¡{PLAYER}!\f¡Tu propia leyenda\nPOKéMON está a\vpunto de comenzar!\f¡Un mundo de sueños\ny aventuras con\vPOKéMON te espera!\v¡Vamos!",
|
||||
["First, what is\nyour name?"] = "¿Cómo te llamas?",
|
||||
["This is my grand-\nson. He's been\vyour rival since\vyou were a baby.\f...Erm, what is\nhis name again?"] = "Este es mi nieto.\nHa sido tu rival\vdesde que erais\vbebés.\f...Mmm, ¿cómo se\nllamaba?",
|
||||
["HIS NAME?"] = "SU NOMBRE?",
|
||||
["_OakSpeechText2A"] = "",
|
||||
["TEXT SPEED"] = "VEL TEXTO",
|
||||
["BATTLE ANIMATION"] = "ANIMACIONES",
|
||||
["OFF"] = "NO",
|
||||
["ON"] = "SI",
|
||||
["BATTLE STYLE"] = "ESTILO COMBATE",
|
||||
["SET"] = "FIJO",
|
||||
["SHIFT"] = "CAMBIO",
|
||||
["BATTLE LAYOUT"] = "DISENO COMBATE",
|
||||
["WIDE"] = "ANCHO",
|
||||
["OG"] = "OG",
|
||||
["RULESET"] = "REGLAS",
|
||||
["MUSIC VOL"] = "VOL MUSICA",
|
||||
["SFX VOL"] = "VOL SONIDO",
|
||||
["PIKACHU VOL"] = "VOL PIKACHU",
|
||||
["MUSIC FILTER"] = "FILTRO MUSICA",
|
||||
["COLORS"] = "COLORES",
|
||||
["TILT"] = "INCLINACION",
|
||||
["GBC FX"] = "EFECTO GBC",
|
||||
["ZOOM"] = "ZOOM",
|
||||
["VOID FILL"] = "RELLENO VACIO",
|
||||
["VIDEO MODE"] = "MODO VIDEO",
|
||||
["MAX FPS"] = "FPS MAXIMO",
|
||||
["GAME SPEED"] = "VELOCIDAD JUEGO",
|
||||
["MODS"] = "MODS",
|
||||
["%d INSTALLED"] = "%d INSTALADOS",
|
||||
["CONTROLS"] = "CONTROLES",
|
||||
["TOUCH PAD"] = "CONTROL TACTIL",
|
||||
["SURE? AGAIN"] = "SEGURO? OTRA VEZ",
|
||||
["AUTO HIDE PAD"] = "OCULTAR AUTO",
|
||||
["A blinding FLASH\nlights the area!"] = "¡Un DESTELLO\nilumina la zona!",
|
||||
["No SURFing here!"] = "¡Aquí no se puede\nSURFEAR!",
|
||||
["Nothing to CUT!"] = "¡Nada que CORTAR!",
|
||||
["{RAM:wNameBuffer} used\nSTRENGTH."] = "{RAM:wNameBuffer} usó\nFUERZA.",
|
||||
["{RAM:wNameBuffer} can\nmove boulders."] = "{RAM:wNameBuffer} puede\nmover rocas.",
|
||||
["It won't have\nany effect."] = "No tendrá ningún\nefecto.",
|
||||
["%s's HP\nwas restored!"] = "¡Los PS de %s\nse recuperaron!",
|
||||
["SWITCH"] = "CAMBIAR",
|
||||
["FLY"] = "VUELO",
|
||||
["FLASH"] = "DESTELLO",
|
||||
["CUT"] = "CORTE",
|
||||
["SURF"] = "SURF",
|
||||
["STRENGTH"] = "FUERZA",
|
||||
["SOFTBOILED"] = "HUEVO SUERTE",
|
||||
["TELEPORT"] = "TELETRANSPORTE",
|
||||
["DIG"] = "EXCAVAR",
|
||||
["Use TM on which\nPOKéMON?"] = "¿Usar la MT en qué\nPOKéMON?",
|
||||
["Bring out which\nPOKéMON?"] = "¿Qué POKéMON\nquieres sacar?",
|
||||
["Choose a POKéMON."] = "Elige un POKéMON.",
|
||||
["No POKéMON!"] = "¡Ningún POKéMON!",
|
||||
["ABLE"] = "PUEDE",
|
||||
["NOT ABLE"] = "NO PUEDE",
|
||||
["FNT"] = "DEB",
|
||||
["Move to where?"] = "¿Mover a dónde?",
|
||||
["Use on which one?"] = "¿Usar en cuál?",
|
||||
["You can't carry\nany more items."] = "No puedes llevar\nmás objetos.",
|
||||
["Withdrew\n%s."] = "Retirado\n%s.",
|
||||
["No room left to\nstore items."] = "No queda sitio para\nguardar objetos.",
|
||||
["%s was\nstored via PC."] = "%s se\nguardó en el PC.",
|
||||
["Toss %s?"] = "¿Tirar %s?",
|
||||
["Threw away %s."] = "Tiraste %s.",
|
||||
["WITHDRAW ITEM"] = "RETIRAR OBJETO",
|
||||
["DEPOSIT ITEM"] = "GUARDAR OBJETO",
|
||||
["TOSS ITEM"] = "TIRAR OBJETO",
|
||||
["LOG OFF"] = "SALIR",
|
||||
["SEEN %d OWNED %d"] = "VISTOS %d CAPT. %d",
|
||||
["DATA"] = "DATOS",
|
||||
["CRY"] = "VOZ",
|
||||
["AREA"] = "ZONA",
|
||||
["PRNT"] = "IMPR",
|
||||
["Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder."] = "¡Datos de %s\nimpresos!\fGuardado como\n%s\ven la carpeta de\nguardado.",
|
||||
["QUIT"] = "SALIR",
|
||||
["%s (%s)"] = "%s (%s)",
|
||||
["%s x%d"] = "%s x%d",
|
||||
["%s to box %d"] = "%s a caja %d",
|
||||
["LOAD REPORT"] = "CARGAR PARTIDA",
|
||||
["A:CONTINUE"] = "A:CONTINUAR",
|
||||
["You don't have\nenough money."] = "No tienes dinero\nsuficiente.",
|
||||
["%s?\nThat will be\n¥%d. OK?"] = "¿%s?\nSon ¥%d.\n¿OK?",
|
||||
["Here you are!\nThank you!"] = "¡Aquí tienes!\n¡Gracias!",
|
||||
["I can't put a\nprice on that."] = "No puedo ponerle\nprecio a eso.",
|
||||
["I can pay you\n¥%d for that."] = "Te doy ¥%d\npor eso.",
|
||||
["BUY"] = "COMPRAR",
|
||||
["SELL"] = "VENDER",
|
||||
["%s lined up!\nScored %d coins!"] = "¡%s alineados!\n¡%d fichas!",
|
||||
["Darn!\nRan out of coins!"] = "¡Vaya!\n¡Sin fichas!",
|
||||
["Not enough\ncoins!"] = "¡Fichas\ninsuficientes!",
|
||||
["SLOT MACHINE"] = "MAQUINA TRAGAPERRAS",
|
||||
["COINS %4d"] = "FICHAS %4d",
|
||||
["POKéDEX"] = "POKéDEX",
|
||||
["POKéMON"] = "POKéMON",
|
||||
["SAVE"] = "GUARDAR",
|
||||
["PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d"] = "JUGADOR %s\nMEDALLAS %d\nPOKéDEX %3d\nTIEMPO %6d:%02d",
|
||||
["\fWould you like to\nSAVE the game?"] = "\f¿Quieres GUARDAR\nla partida?",
|
||||
["Now saving..."] = "Guardando...",
|
||||
["%s saved\nthe game!"] = "¡%s guardó\nla partida!",
|
||||
["OPTION"] = "OPCION",
|
||||
["LINK"] = "LINK",
|
||||
["RETURN TO MAIN\nMENU?"] = "¿VOLVER AL MENU\nPRINCIPAL?",
|
||||
["BALL"] = "BALL",
|
||||
["STATUS/"] = "ESTADO/",
|
||||
["OT/"] = "EO/",
|
||||
["EXP POINTS"] = "P. EXP.",
|
||||
["LEVEL UP"] = "SUBE NIVEL",
|
||||
["PP"] = "PP",
|
||||
["SCORE %d"] = "PUNTOS %d",
|
||||
["New record!"] = "¡Nuevo récord!",
|
||||
["HI %d"] = "MAX %d",
|
||||
["A: done"] = "A: listo",
|
||||
["PLAYER"] = "JUGADOR",
|
||||
["BADGES"] = "MEDALLAS",
|
||||
["TIME"] = "TIEMPO",
|
||||
["CONTINUE"] = "CONTINUAR",
|
||||
["NEW GAME"] = "NUEVA PARTIDA",
|
||||
["EXIT GAME"] = "SALIR DEL JUEGO",
|
||||
["POKéMON RED"] = "",
|
||||
["2026 bois club games"] = "",
|
||||
["OT/%s"] = "EO/%s",
|
||||
["NAME/%s"] = "NOMBRE/%s",
|
||||
["In battle"] = "En combate",
|
||||
["Wild battle"] = "Combate salvaje",
|
||||
["Trainer battle"] = "Combate entrenador",
|
||||
["Link battle"] = "Combate link",
|
||||
["Title screen"] = "Pantalla de título",
|
||||
["Level %d"] = "Nivel %d",
|
||||
["What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!"] = "¿Qué?\n¡%s está\nevolucionando!\f¡Enhorabuena!\n¡Tu %s\nevolucionó a\n%s!",
|
||||
["Not even a nibble!"] = "¡Ni un mordisco!",
|
||||
["Oh!\nIt's a bite!"] = "¡Oh!\n¡Ha picado!",
|
||||
["It's a sculpture\nof DIGLETT."] = "Es una escultura\nde DIGLETT.",
|
||||
["Crammed full of\nPOKéMON books!"] = "¡Repleto de libros\nsobre POKéMON!",
|
||||
["There's a slew of\nPOKéMON stuff!"] = "¡Hay un montón de\ncosas POKéMON!",
|
||||
["An elevator!"] = "¡Un ascensor!",
|
||||
["INDIGO PLATEAU"] = "MESETA ANIL",
|
||||
["POKéMON LEAGUE HQ"] = "SEDE DE LA LIGA\nPOKéMON",
|
||||
["You can't carry\nany more items!"] = "¡No puedes llevar\nmás objetos!",
|
||||
["%s found\n%s!"] = "¡%s encontró\n%s!",
|
||||
["%s found\n%d coins!"] = "¡%s encontró\n%d fichas!",
|
||||
["OUT OF ORDER\nThis is broken."] = "FUERA DE SERVICIO\nEsto está roto.",
|
||||
["OUT TO LUNCH\nThis is reserved."] = "CERRADO POR COMIDA\nEsto está reservado.",
|
||||
["Someone's keys!\nThey'll be back."] = "¡Las llaves de\nalguien! Volverá.",
|
||||
["A COIN CASE is\nrequired!"] = "¡Se necesita un\nMONEDERO!",
|
||||
["You don't have\nany coins!"] = "¡No tienes fichas!",
|
||||
["{RAM}\nPOKéMON GYM\nLEADER: {RAM}"] = "{RAM}\nGIMNASIO POKéMON\nLIDER: {RAM}",
|
||||
["Nope, there's\nonly trash here."] = "No, aquí solo hay\nbasura.",
|
||||
["Darn! It needs a\nCARD KEY!"] = "¡Vaya! ¡Necesita\nuna LLAVE MAGNET.!",
|
||||
["Bingo!"] = "¡Bingo!",
|
||||
["\nThe CARD KEY\nopened the door!"] = "\n¡La LLAVE MAGNET.\nabrió la puerta!",
|
||||
["Hey! There's a\nswitch under the\ntrash!\fThe 1st electric\nlock opened!"] = "¡Hay un interruptor\nbajo la basura!\f¡Se abrió el 1er\ncierre eléctrico!",
|
||||
["The 2nd electric\nlock opened!\fThe motorized door\nopened!"] = "¡Se abrió el 2o\ncierre eléctrico!\f¡La puerta se\nabrió!",
|
||||
["Nope! There's\nonly trash here.\fHey! The electric\nlocks were reset!"] = "¡No! Aquí solo hay\nbasura.\f¡Los cierres se\nreiniciaron!",
|
||||
["TELEPORTER is\ndisplayed on the\nPC monitor."] = "El TELETRANSPORTE\naparece en el\nmonitor del PC.",
|
||||
["{PLAYER} initiated\nTELEPORTER's Cell\nSeparator!"] = "¡{PLAYER} activó el\nSeparador de Células\ndel TELETRANSPORTE!",
|
||||
["BILL's favorite\nPOKéMON list!"] = "¡La lista de POKéMON\nfavoritos de BILL!",
|
||||
["{PLAYER} got on\n{RAM:wNameBuffer}!"] = "¡{PLAYER} se subió\na {RAM:wNameBuffer}!",
|
||||
["{RAM:wNameBuffer} hacked\naway with CUT!"] = "¡{RAM:wNameBuffer} cortó\ncon CORTE!",
|
||||
["Gyaoo!"] = "¡Gyaoo!",
|
||||
["Hi there!\nMay I help you?"] = "¡Hola!\n¿Puedo ayudarte?",
|
||||
["SOMEONE'S PC"] = "EL PC DE ALGUIEN",
|
||||
["PROF.OAK's PC"] = "EL PC DEL PROF.OAK",
|
||||
["POKéDEX comp-\nletion is:\f{NUM:hDexRatingNumMonsSeen} POKéMON seen\n{NUM:hDexRatingNumMonsOwned} POKéMON owned\fPROF.OAK's\nRating:"] = "La POKéDEX está\nasí:\f{NUM:hDexRatingNumMonsSeen} POKéMON vistos\n{NUM:hDexRatingNumMonsOwned} POKéMON capturados\fNota del\nPROF.OAK:",
|
||||
["We hope to see\nyou again!"] = "¡Esperamos verte\nde nuevo!",
|
||||
["Welcome to our\nPOKéMON CENTER!"] = "¡Bienvenido a\nnuestro CENTRO\nPOKéMON!",
|
||||
["Shall we heal your\nPOKéMON?"] = "¿Curamos a tus\nPOKéMON?",
|
||||
["OK. We'll need\nyour POKéMON."] = "Bien. Necesitamos\ntus POKéMON.",
|
||||
["Your POKéMON are\nfighting fit!"] = "¡Tus POKéMON están\nen plena forma!",
|
||||
["Welcome to the\nCable Club!"] = "¡Bienvenido al Club\nde Cable!",
|
||||
["We're making\npreparations.\vPlease wait."] = "Estamos preparando\ntodo.\vEspera un momento.",
|
||||
["Please apply here.\fBefore opening\nthe link, we have\vto save the game."] = "Solicítalo aquí.\fAntes de abrir el\nlink hay que\vguardar la partida.",
|
||||
["Please come\nagain!"] = "¡Vuelve pronto!",
|
||||
["I like shorts!\nThey're comfy and\neasy to wear!"] = "¡Me gustan los\npantalones cortos!\n¡Son cómodos!",
|
||||
["%s received\nthe %s!"] = "¡%s recibió\nel %s!",
|
||||
["%s received\n%s!"] = "¡%s recibió\n%s!",
|
||||
["REPEL's effect\nwore off."] = "El efecto del REPEL\nse ha pasado.",
|
||||
["Go right ahead!"] = "¡Adelante!",
|
||||
["You don't have the\nBOULDERBADGE yet!"] = "¡Aún no tienes la\nMEDALLA ROCA!",
|
||||
["Oh! That is the\n{RAM}!"] = "¡Oh! ¡Eso es el\n{RAM}!",
|
||||
["You don't have the\n{RAM} yet!"] = "¡Aún no tienes el\n{RAM}!",
|
||||
["You need a\nBICYCLE for the\nCycling Road!"] = "¡Necesitas una\nBICICLETA para el\nCarril Bici!",
|
||||
["The boulder fell\nthrough the hole!"] = "¡La roca cayó por\nel agujero!",
|
||||
["PA: Ding-dong!\nTime's up!"] = "AV: ¡Ding-dong!\n¡Se acabó el tiempo!",
|
||||
["PA: Your SAFARI\nGAME is over!"] = "AV: ¡Tu JUEGO\nSAFARI ha terminado!",
|
||||
["PA: You're out of\nSAFARI BALLs!"] = "AV: ¡No te quedan\nSAFARI BALLs!",
|
||||
["{PLAYER} got\n%s!"] = "¡{PLAYER} consiguió\n%s!",
|
||||
["There's no more\nroom for POKéMON!\v%s was\vsent to POKéMON\vBOX %s on PC!"] = "¡No hay sitio para\nmás POKéMON!\v¡%s fue\venviado a la CAJA\vPOKéMON %s del PC!",
|
||||
["contribution is not a table"] = "",
|
||||
[" [%s %s.%s]"] = " [%s %s.%s]",
|
||||
["Link battle needs\nthe same mods on\nboth games."] = "El combate link\nnecesita los mismos\nmods en los dos\njuegos.",
|
||||
["Your %s can't\nbattle on the\nother game."] = "Tu %s no puede\nluchar en el otro\njuego.",
|
||||
["Their %s isn't\nin this game.\n(%s)"] = "Su %s no está\nen este juego.\n(%s)",
|
||||
["%s wants\nto battle!"] = "¡%s quiere\nluchar!",
|
||||
["Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?"] = "¡Link desincroni-\nzado!\n%s difiere.\f¿Están los dos\njuegos con los\nmismos mods?",
|
||||
["%s ran from\nthe battle!"] = "¡%s huyó del\ncombate!",
|
||||
["Items can't be\nused in a link\nbattle!"] = "¡No se pueden usar\nobjetos en un\ncombate link!",
|
||||
["%s is out of\nPOKéMON!\f%s wins!"] = "¡%s no tiene\nPOKéMON!\f¡%s gana!",
|
||||
["%s left the\nbattle."] = "%s dejó el\ncombate.",
|
||||
["%s ran out of\ntime!"] = "¡%s se quedó\nsin tiempo!",
|
||||
["Time's up! You\nforfeit the match."] = "¡Se acabó el tiempo!\nPierdes el combate.",
|
||||
["%s's %s can't\nbattle on this\ngame."] = "El %s de %s\nno puede luchar en\neste juego.",
|
||||
["%s's %s can't\nbattle on this\ngame.\n(%s)"] = "El %s de %s\nno puede luchar en\neste juego.\n(%s)",
|
||||
["%s vs %s!"] = "¡%s contra %s!",
|
||||
["Link error:\n%s"] = "Error de link:\n%s",
|
||||
["Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?"] = "El juego en línea\nva sin mods para\nlos dos jugadores.\f¿Desactivar %s\ny reiniciar?",
|
||||
["The link was\nbroken."] = "Se ha perdido el\nlink.",
|
||||
["Link battle\ncan't start."] = "El combate link no\npuede empezar.",
|
||||
["The trade stopped:\n%s."] = "El intercambio se\ndetuvo:\n%s.",
|
||||
["The trade was\ncancelled."] = "El intercambio se\nha cancelado.",
|
||||
["Trade completed!\f%s received\n%s!"] = "¡Intercambio hecho!\f¡%s recibió\n%s!",
|
||||
["LINK CABLE (LAN)"] = "CABLE LINK (LAN)",
|
||||
["ONLINE MATCH"] = "PARTIDA EN LINEA",
|
||||
["TOURNAMENT"] = "TORNEO",
|
||||
["HOST A GAME"] = "CREAR PARTIDA",
|
||||
["JOIN A GAME"] = "UNIRSE A PARTIDA",
|
||||
["UDP port %s"] = "Puerto UDP %s",
|
||||
["HOST ONLINE"] = "CREAR EN LINEA",
|
||||
["JOIN ONLINE"] = "UNIRSE EN LINEA",
|
||||
["Tell your friend"] = "Dile a tu amigo",
|
||||
["the code:"] = "el código:",
|
||||
["Waiting for join..."] = "Esperando...",
|
||||
["A: connect B: back"] = "A: conectar B: atrás",
|
||||
["Calling..."] = "Llamando...",
|
||||
["Friend joins at:"] = "Tu amigo entra en:",
|
||||
["Port: %s"] = "Puerto: %s",
|
||||
["TRADE"] = "INTERCAMBIO",
|
||||
["BATTLE"] = "COMBATE",
|
||||
["LEVELS:"] = "NIVELES:",
|
||||
["A: continue B: back"] = "A: seguir B: atrás",
|
||||
["Checking the"] = "Comprobando el",
|
||||
["other game..."] = "otro juego...",
|
||||
["Waiting for the"] = "Esperando a que",
|
||||
["host to choose..."] = "el anfitrión elija...",
|
||||
["A: trade anyway"] = "A: intercambiar igual",
|
||||
["YOURS"] = "TUYO",
|
||||
["THEIRS"] = "SUYO",
|
||||
["X: not on theirs"] = "X: no en el suyo",
|
||||
["A: trade B: cancel"] = "A: cambiar B: cancelar",
|
||||
["Exchanging data..."] = "Intercambiando...",
|
||||
["can't reach relay %s:%d\n(%s)"] = "",
|
||||
["That code wasn't\nfound."] = "Ese código no se\nha encontrado.",
|
||||
["That game already\nhas two players."] = "Esa partida ya\ntiene dos jugadores.",
|
||||
["That code has\nexpired."] = "Ese código ha\ncaducado.",
|
||||
["Couldn't join:\n%s"] = "No se pudo unir:\n%s",
|
||||
["no answer from\n%s"] = "",
|
||||
["That tournament\nhas already begun."] = "Ese torneo ya ha\nempezado.",
|
||||
["Can't host:\nneed %d Pokemon\nLv %s-%s."] = "No puedes crearlo:\nnecesitas %d Pokemon\nNv %s-%s.",
|
||||
["Couldn't host\nthat tournament."] = "No se pudo crear\nese torneo.",
|
||||
["Your party needs\n%d Pokemon, Lv\n%s-%s."] = "Tu equipo necesita\n%d Pokemon, Nv\n%s-%s.",
|
||||
["Couldn't join\nthat tournament."] = "No se pudo unir a\nese torneo.",
|
||||
["Link error:\nversion mismatch\nwith opponent."] = "Error de link:\nversión distinta a\nla del rival.",
|
||||
["The tournament\nconnection was\nlost."] = "Se perdió la\nconexión del torneo.",
|
||||
["Can't watch this\nmatch."] = "No se puede ver\neste combate.",
|
||||
["HOST"] = "CREAR",
|
||||
["JOIN"] = "UNIRSE",
|
||||
["START: create"] = "START: crear",
|
||||
["A: join B: back"] = "A: unirse B: atrás",
|
||||
["B: cancel"] = "B: cancelar",
|
||||
["TOURNAMENT %s"] = "TORNEO %s",
|
||||
["ROUND %d"] = "RONDA %d",
|
||||
["%s (bye)"] = "%s (pasa)",
|
||||
["%s%s vs %s%s"] = "%s%s contra %s%s",
|
||||
["(organizing --"] = "(organizando --",
|
||||
["not playing)"] = "no juega)",
|
||||
["Waiting for"] = "Esperando a que",
|
||||
["players to join:"] = "entren jugadores:",
|
||||
["A: START B: cancel"] = "A: START B: cancelar",
|
||||
["%s is the"] = "¡%s es el",
|
||||
["champion!"] = "campeón!",
|
||||
["A: continue"] = "A: continuar",
|
||||
["{PLAYER} played the\nPOKé FLUTE."] = "{PLAYER} tocó la\nFLAUTA POKé.",
|
||||
["Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!"] = "Tocaste la FLAUTA\nPOKé.\f¡Qué melodía tan\npegadiza!",
|
||||
["%s played the\nPOKé FLUTE."] = "%s tocó la\nFLAUTA POKé.",
|
||||
["All sleeping\nPOKéMON woke up!"] = "¡Todos los POKéMON\ndormidos despertaron!",
|
||||
["%s's\nhits will never\nmiss!"] = "¡Los golpes de %s\nnunca fallarán!",
|
||||
["The wild POKéMON\nran away!"] = "¡El POKéMON salvaje\nhuyó!",
|
||||
["%s's PP\nwas restored!"] = "¡Los PP de %s\nse recuperaron!",
|
||||
["%s's\nstatus returned\nto normal!"] = "¡El estado de %s\nvolvió a la\nnormalidad!",
|
||||
["%s\nis revitalized!"] = "¡%s se ha\nrevitalizado!",
|
||||
["%s\nis refusing!"] = "¡%s se\nniega!",
|
||||
["%s's %s\nrose!"] = "¡El %s de %s\nsubió!",
|
||||
["%s's PP\nincreased!"] = "¡Los PP de %s\naumentaron!",
|
||||
["%s can't\nlearn that move!"] = "¡%s no puede\naprender ese\nmovimiento!",
|
||||
["It knows that\nmove already!"] = "¡Ya conoce ese\nmovimiento!",
|
||||
["Coin count:\n%d"] = "Fichas:\n%d",
|
||||
["NO MODS INSTALLED"] = "NO HAY MODS",
|
||||
["SAVE CURRENT AS.."] = "GUARDAR ACTUAL..",
|
||||
["OPTIONS.."] = "OPCIONES..",
|
||||
["PERMISSIONS.."] = "PERMISOS..",
|
||||
["VIEW ERROR.."] = "VER ERROR..",
|
||||
["BACK"] = "ATRAS",
|
||||
["APPLY & RESTART"] = "APLICAR Y REINICIAR",
|
||||
["DISCARD CHANGES"] = "DESCARTAR CAMBIOS",
|
||||
["DATA & API ONLY"] = "SOLO DATOS Y API",
|
||||
["DISABLE BOTH?"] = "DESACTIVAR AMBOS?",
|
||||
["PROFILE NAME?"] = "NOMBRE DEL PERFIL?",
|
||||
["RENAME?"] = "RENOMBRAR?",
|
||||
["RESET DEFAULTS"] = "VALORES POR DEFECTO",
|
||||
["NO CHANGES"] = "SIN CAMBIOS",
|
||||
["A:OK"] = "A:OK",
|
||||
["B:DONE (NO RESTART)"] = "B:LISTO (SIN REINICIAR)",
|
||||
["MOD MANAGER"] = "GESTOR DE MODS",
|
||||
["Choose a mod .zip"] = "Elige un .zip de mod",
|
||||
["Choose a .sav save file"] = "Elige un archivo .sav",
|
||||
["An update is available"] = "Hay una actualización",
|
||||
["Name save slot"] = "Nombra la ranura",
|
||||
["Enter to save - Esc to cancel - empty clears"] = "Enter para guardar - Esc para cancelar - vacío la borra",
|
||||
["Add a mod index"] = "Añadir un índice de mods",
|
||||
["Paste the index URL, or its owner/repo."] = "Pega la URL del índice, o su owner/repo.",
|
||||
["Enter to add - Esc to cancel"] = "Enter para añadir - Esc para cancelar",
|
||||
["Import a ROM to play"] = "Importa una ROM para jugar",
|
||||
["RED"] = "ROJO",
|
||||
["BLUE"] = "AZUL",
|
||||
["YELLOW"] = "AMARILLO",
|
||||
["FIND MODS"] = "BUSCAR MODS",
|
||||
["%d of 3 ready"] = "%d de 3 listos",
|
||||
["Or drop the .gb/.gbc file here."] = "O arrastra aquí el archivo .gb/.gbc.",
|
||||
["ROM imported"] = "ROM importada",
|
||||
["That ROM could not be imported."] = "No se pudo importar esa ROM.",
|
||||
["Open folder"] = "Abrir carpeta",
|
||||
["%d badges - %s - %d caught"] = "%d medallas - %s - %d capturados",
|
||||
["%d of %d enabled"] = "%d de %d activados",
|
||||
["Or drop a mod .zip onto the window."] = "O arrastra un .zip de mod a la ventana.",
|
||||
["No mods installed - drop a mod .zip here to add one."] = "No hay mods - arrastra aquí un .zip para añadir uno.",
|
||||
["Refreshed - %d mods listed"] = "Actualizado - %d mods listados",
|
||||
["Added %s"] = "Añadido %s",
|
||||
["Index removed"] = "Índice eliminado",
|
||||
["Downloading %s..."] = "Descargando %s...",
|
||||
["Installed %s %s"] = "Instalado %s %s",
|
||||
["%d mods listed"] = "%d mods listados",
|
||||
["%d of %d mods"] = "%d de %d mods",
|
||||
["Mods here are listed, not reviewed - read the source and trust the author."] = "Los mods aquí se listan, no se revisan - lee el código y confía en el autor.",
|
||||
["No mod index added"] = "No hay índice de mods",
|
||||
["Add an index to browse mods. An index is a published list; paste its URL or its owner/repo."] = "Añade un índice para explorar mods. Un índice es una lista publicada; pega su URL o su owner/repo.",
|
||||
["Search mods"] = "Buscar mods",
|
||||
["This index lists no mods yet."] = "Este índice aún no lista mods.",
|
||||
["No mods match that search."] = "Ningún mod coincide con esa búsqueda.",
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Trainer class names
|
||||
--
|
||||
-- Trainer class names for Espanol.
|
||||
|
||||
return {
|
||||
["OPP_FIX_YOUNGSTER"] = "",
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
-- spanish_ui: a translation of the game into Espanol.
|
||||
--
|
||||
-- Nothing here is translated yet. Every table under lang/ starts with
|
||||
-- empty strings; fill one in and it takes effect on the next boot, and
|
||||
-- anything still empty keeps rendering in English. That means a
|
||||
-- half-finished translation is always playable, so you can ship early and
|
||||
-- fill the long tail in later.
|
||||
--
|
||||
-- Read TRANSLATING.md before the first edit; the font is the part people
|
||||
-- get wrong.
|
||||
return function(mod)
|
||||
-- mod:read is the supported way into your own directory; the catalogs are
|
||||
-- plain Lua tables, so read and run them rather than require()ing them.
|
||||
local function catalog(name)
|
||||
local rel = "lang/" .. name .. ".lua"
|
||||
local body = mod:read(rel)
|
||||
if not body then return {} end
|
||||
local chunk, err = loadstring(body, rel)
|
||||
if not chunk then
|
||||
mod.log:warn("%s has a syntax error: %s", rel, tostring(err))
|
||||
return {}
|
||||
end
|
||||
local ok, table_ = pcall(chunk)
|
||||
if not ok or type(table_) ~= "table" then
|
||||
mod.log:warn("%s did not return a table: %s", rel, tostring(table_))
|
||||
return {}
|
||||
end
|
||||
return table_
|
||||
end
|
||||
|
||||
-- An empty value means "not translated yet", never "translate to blank".
|
||||
local function each(name, apply)
|
||||
local n = 0
|
||||
for key, value in pairs(catalog(name)) do
|
||||
if type(value) == "string" and value ~= "" then
|
||||
apply(key, value)
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
-- ---- glyphs -------------------------------------------------------
|
||||
-- Register the sheet BEFORE anything asks for a glyph on it. base is
|
||||
-- the first code the page owns; 0x100 and up is free space above the
|
||||
-- vanilla pages, so a new alphabet never collides with them.
|
||||
for id, page in pairs(catalog("font")) do
|
||||
mod.content.font:register(id, page)
|
||||
end
|
||||
-- charmap: which byte sequence draws which code
|
||||
for seq, code in pairs(catalog("charmap")) do
|
||||
mod.content.font:register("charmap:" .. seq, { seq = seq, code = code })
|
||||
end
|
||||
|
||||
-- ---- text ---------------------------------------------------------
|
||||
local counts = {}
|
||||
counts.dialogue = each("dialogue", function(id, value)
|
||||
mod.content.text:override(id, value)
|
||||
end)
|
||||
counts.strings = each("strings", function(source, value)
|
||||
mod.content.strings:override(source, value)
|
||||
end)
|
||||
counts.species = each("species_names", function(id, value)
|
||||
mod.content.pokemon:patch(id, { name = value })
|
||||
end)
|
||||
counts.moves = each("move_names", function(id, value)
|
||||
mod.content.moves:patch(id, { name = value })
|
||||
end)
|
||||
counts.items = each("item_names", function(id, value)
|
||||
mod.content.items:patch(id, { name = value })
|
||||
end)
|
||||
counts.trainers = each("trainer_names", function(id, value)
|
||||
mod.content.trainers:patch(id, { name = value })
|
||||
end)
|
||||
counts.statuses = each("status_labels", function(id, value)
|
||||
mod.content.statuses:patch(id, { label = value })
|
||||
end)
|
||||
|
||||
-- ---- name entry ---------------------------------------------------
|
||||
-- The naming screen's letter grid. Leave lang/naming.lua returning nil
|
||||
-- to keep the English alphabet.
|
||||
local grid = catalog("naming")
|
||||
if grid.upper then
|
||||
-- Only offer the accented cells when the running cartridge can actually
|
||||
-- draw them. A Spanish ROM has Ñ and the accented vowels in its font
|
||||
-- and the manifest maps them; an English one does not, and an
|
||||
-- unmappable cell renders blank -- a naming screen with six empty keys
|
||||
-- is worse than an English one. So check the charmap and fall back.
|
||||
local function drawable(cells, ctx)
|
||||
local font = ((ctx.game or {}).data or {}).font
|
||||
local charmap = font and font.charmap
|
||||
if not charmap then return false end
|
||||
local have = {}
|
||||
for _, entry in ipairs(charmap) do have[entry.seq] = true end
|
||||
for _, row in ipairs(cells) do
|
||||
for _, cell in ipairs(row) do
|
||||
-- Only the non-ASCII cells are at risk; A-Z and punctuation are
|
||||
-- on every page.
|
||||
if cell:byte(1) and cell:byte(1) > 127 and not have[cell] then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
local warned = false
|
||||
mod.hooks:on("ui.naming.grid", function(base, ctx)
|
||||
local want = ctx.lower and grid.lower or grid.upper
|
||||
if not want then return base end
|
||||
if not drawable(want, ctx) then
|
||||
if not warned then
|
||||
warned = true
|
||||
mod.log:info("naming grid: this ROM has no accented glyphs, "
|
||||
.. "keeping the English alphabet")
|
||||
end
|
||||
return base
|
||||
end
|
||||
return want
|
||||
end)
|
||||
end
|
||||
|
||||
mod.events:on("game.ready", function()
|
||||
local total = 0
|
||||
for _, n in pairs(counts) do total = total + n end
|
||||
mod.log:info("Espanol: %d strings translated", total)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "spanish_ui",
|
||||
"name": "Espanol (interfaz)",
|
||||
"version": "0.1.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"game_version": ">=0.0.0-dev <1.0.0",
|
||||
"category": "LANGUAGE",
|
||||
"priority": 100,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"incompatible": [],
|
||||
"experimental": false,
|
||||
"description": "Spanish for the app's own settings and menus. The game's text comes from your ROM and is untouched, so an English cartridge stays an English adventure with Spanish menus."
|
||||
}
|
||||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,72 @@
|
||||
#---------------------------------------------------------------------------------
|
||||
# One-shot OTA bootstrap NRO (copied into the main launcher's romfs).
|
||||
#---------------------------------------------------------------------------------
|
||||
.SUFFIXES:
|
||||
|
||||
ifeq ($(strip $(DEVKITPRO)),)
|
||||
|
||||
.PHONY: all
|
||||
all:
|
||||
@echo "DEVKITPRO not set — bootstrap builds with the launcher only."
|
||||
@false
|
||||
|
||||
else
|
||||
|
||||
TOPDIR ?= $(CURDIR)
|
||||
include $(DEVKITPRO)/libnx/switch_rules
|
||||
|
||||
TARGET := ota-bootstrap
|
||||
BUILD := build
|
||||
SOURCES := .
|
||||
INCLUDES := include
|
||||
ROMFS :=
|
||||
|
||||
APP_TITLE := gen1recomp OTA
|
||||
APP_AUTHOR := bryanthaboi, port by andrewqsantos
|
||||
APP_VERSION := 0.0.0
|
||||
|
||||
ARCH := -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE
|
||||
CFLAGS := -g -Wall -O2 -ffunction-sections $(ARCH) $(DEFINES)
|
||||
CFLAGS += $(INCLUDE) -D__SWITCH__
|
||||
ASFLAGS := -g $(ARCH)
|
||||
LDFLAGS = -specs=$(DEVKITPRO)/libnx/switch.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map)
|
||||
LIBS := -lnx
|
||||
LIBDIRS := $(LIBNX)
|
||||
|
||||
ifneq ($(BUILD),$(notdir $(CURDIR)))
|
||||
export OUTPUT := $(CURDIR)/$(TARGET)
|
||||
export TOPDIR := $(CURDIR)
|
||||
export VPATH := $(foreach dir,$(SOURCES),$(CURDIR)/$(dir))
|
||||
export DEPSDIR := $(CURDIR)/$(BUILD)
|
||||
CFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c)))
|
||||
export LD := $(CC)
|
||||
export OFILES := $(CFILES:.c=.o)
|
||||
export INCLUDE := $(foreach dir,$(INCLUDES),-I$(CURDIR)/$(dir)) \
|
||||
$(foreach dir,$(LIBDIRS),-I$(dir)/include) \
|
||||
-I$(CURDIR)/$(BUILD)
|
||||
export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib)
|
||||
export APP_ICON := $(LIBNX)/default_icon.jpg
|
||||
export NROFLAGS += --icon=$(APP_ICON)
|
||||
export NROFLAGS += --nacp=$(CURDIR)/$(TARGET).nacp
|
||||
|
||||
.PHONY: $(BUILD) clean all
|
||||
|
||||
all: $(BUILD)
|
||||
|
||||
$(BUILD):
|
||||
@[ -d $@ ] || mkdir -p $@
|
||||
@$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile
|
||||
|
||||
clean:
|
||||
@echo clean ...
|
||||
@rm -fr $(BUILD) $(TARGET).nro $(TARGET).nacp $(TARGET).elf
|
||||
|
||||
else
|
||||
.PHONY: all
|
||||
DEPENDS := $(OFILES:.o=.d)
|
||||
all: $(OUTPUT).nro
|
||||
$(OUTPUT).nro: $(OUTPUT).elf $(OUTPUT).nacp
|
||||
$(OUTPUT).elf: $(OFILES)
|
||||
-include $(DEPENDS)
|
||||
endif
|
||||
endif
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Tiny one-shot helper: swap a staged launcher NRO into place, then load the game.
|
||||
* The main OTA launcher chainloads here because a running NRO cannot replace itself
|
||||
* on sdmc/FAT.
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
#include <switch.h>
|
||||
#endif
|
||||
|
||||
#define INSTALL_DIR "sdmc:/switch/gen1recomp"
|
||||
#define STAGED INSTALL_DIR "/gen1recomp.nro.staged"
|
||||
#define LAUNCHER INSTALL_DIR "/gen1recomp.nro"
|
||||
#define GAME INSTALL_DIR "/gen1recomp-game.nro"
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
remove(LAUNCHER);
|
||||
if (rename(STAGED, LAUNCHER) != 0) {
|
||||
return 1;
|
||||
}
|
||||
Result rc = envSetNextLoad(GAME, GAME);
|
||||
if (R_FAILED(rc)) return 1;
|
||||
return 0;
|
||||
#else
|
||||
fprintf(stderr, "ota-bootstrap: host stub (would rename %s -> %s, load %s)\n", STAGED, LAUNCHER,
|
||||
GAME);
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
#---------------------------------------------------------------------------------
|
||||
# gen1recomp Switch native OTA launcher
|
||||
# Requires: DEVKITPRO + switch-curl, switch-mbedtls, switch-zlib, switch-zziplib
|
||||
# Host protocol tests: make host-test (no DEVKITPRO needed)
|
||||
#---------------------------------------------------------------------------------
|
||||
.SUFFIXES:
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
# Host-only path when DEVKITPRO is unset
|
||||
#---------------------------------------------------------------------------------
|
||||
ifeq ($(strip $(DEVKITPRO)),)
|
||||
|
||||
.PHONY: host-test clean-host
|
||||
host-test:
|
||||
@mkdir -p build-host
|
||||
cc -std=c11 -Wall -Wextra -Iinclude -o build-host/test_ota_protocol \
|
||||
src/ota_protocol.c host/test_ota_protocol.c
|
||||
./build-host/test_ota_protocol
|
||||
|
||||
clean-host:
|
||||
rm -rf build-host
|
||||
|
||||
%:
|
||||
@echo "DEVKITPRO not set — only 'make host-test' is available."
|
||||
@echo "Run: bash scripts/switch/install_devkitpro_deps.sh"
|
||||
@false
|
||||
|
||||
else
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
TOPDIR ?= $(CURDIR)
|
||||
include $(DEVKITPRO)/libnx/switch_rules
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
TARGET := gen1recomp
|
||||
BUILD := build
|
||||
SOURCES := src
|
||||
DATA := data
|
||||
INCLUDES := include
|
||||
ROMFS := romfs
|
||||
|
||||
APP_TITLE := gen1recomp
|
||||
APP_AUTHOR := bryanthaboi, port by andrewqsantos
|
||||
# Overridable: scripts/switch/build_ota_launcher.sh passes release X.Y.Z
|
||||
APP_VERSION ?= 0.0.0
|
||||
|
||||
# Prefer project Switch icon if present
|
||||
ICON := $(TOPDIR)/../assets/icon.jpg
|
||||
LOGO_RGBA_SRC := $(TOPDIR)/../assets/logo.rgba
|
||||
LOGO_ROMFS := $(CURDIR)/$(ROMFS)/logo.rgba
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
ARCH := -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE
|
||||
|
||||
CFLAGS := -g -Wall -O2 -ffunction-sections $(ARCH) $(DEFINES)
|
||||
CFLAGS += $(INCLUDE) -D__SWITCH__
|
||||
|
||||
CXXFLAGS := $(CFLAGS) -fno-rtti -fno-exceptions
|
||||
|
||||
ASFLAGS := -g $(ARCH)
|
||||
LDFLAGS = -specs=$(DEVKITPRO)/libnx/switch.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map)
|
||||
|
||||
LIBS := -lcurl -lzzip -lmbedtls -lmbedx509 -lmbedcrypto -lz -lnx
|
||||
|
||||
LIBDIRS := $(PORTLIBS) $(LIBNX)
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
ifneq ($(BUILD),$(notdir $(CURDIR)))
|
||||
#---------------------------------------------------------------------------------
|
||||
|
||||
export OUTPUT := $(CURDIR)/$(TARGET)
|
||||
export TOPDIR := $(CURDIR)
|
||||
|
||||
export VPATH := $(foreach dir,$(SOURCES),$(CURDIR)/$(dir)) \
|
||||
$(foreach dir,$(DATA),$(CURDIR)/$(dir))
|
||||
|
||||
export DEPSDIR := $(CURDIR)/$(BUILD)
|
||||
|
||||
CFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c)))
|
||||
CPPFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.cpp)))
|
||||
SFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.s)))
|
||||
BINFILES := $(foreach dir,$(DATA),$(notdir $(wildcard $(dir)/*.*)))
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
# use CXX for linking C++ projects, CC for standard C
|
||||
#---------------------------------------------------------------------------------
|
||||
ifeq ($(strip $(CPPFILES)),)
|
||||
export LD := $(CC)
|
||||
else
|
||||
export LD := $(CXX)
|
||||
endif
|
||||
#---------------------------------------------------------------------------------
|
||||
|
||||
export OFILES_BIN := $(addsuffix .o,$(BINFILES))
|
||||
export OFILES_SRC := $(CPPFILES:.cpp=.o) $(CFILES:.c=.o) $(SFILES:.s=.o)
|
||||
export OFILES := $(OFILES_BIN) $(OFILES_SRC)
|
||||
|
||||
export INCLUDE := $(foreach dir,$(INCLUDES),-I$(CURDIR)/$(dir)) \
|
||||
$(foreach dir,$(LIBDIRS),-I$(dir)/include) \
|
||||
-I$(CURDIR)/$(BUILD)
|
||||
|
||||
export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib)
|
||||
|
||||
ifeq ($(strip $(ICON)),)
|
||||
icons := $(wildcard *.jpg)
|
||||
ifneq (,$(findstring $(TARGET).jpg,$(icons)))
|
||||
export APP_ICON := $(TOPDIR)/$(TARGET).jpg
|
||||
else
|
||||
ifneq (,$(findstring icon.jpg,$(icons)))
|
||||
export APP_ICON := $(TOPDIR)/icon.jpg
|
||||
endif
|
||||
endif
|
||||
else
|
||||
ifeq ($(wildcard $(ICON)),)
|
||||
# fall back to libnx default if project icon missing
|
||||
export APP_ICON := $(LIBNX)/default_icon.jpg
|
||||
else
|
||||
export APP_ICON := $(ICON)
|
||||
endif
|
||||
endif
|
||||
|
||||
ifeq ($(strip $(NO_ICON)),)
|
||||
export NROFLAGS += --icon=$(APP_ICON)
|
||||
endif
|
||||
|
||||
ifeq ($(strip $(NO_NACP)),)
|
||||
export NROFLAGS += --nacp=$(CURDIR)/$(TARGET).nacp
|
||||
endif
|
||||
|
||||
ifneq ($(ROMFS),)
|
||||
export NROFLAGS += --romfsdir=$(CURDIR)/$(ROMFS)
|
||||
endif
|
||||
|
||||
BOOTSTRAP_DIR := $(TOPDIR)/../ota-bootstrap
|
||||
BOOTSTRAP_ROMFS := $(CURDIR)/$(ROMFS)/ota-bootstrap.nro
|
||||
|
||||
.PHONY: $(BUILD) clean all host-test sync-romfs bootstrap-romfs
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
all: sync-romfs $(BUILD)
|
||||
|
||||
CACERT_URL := https://curl.se/ca/cacert.pem
|
||||
CACERT_ROMFS := $(CURDIR)/$(ROMFS)/cacert.pem
|
||||
|
||||
bootstrap-romfs:
|
||||
@$(MAKE) --no-print-directory -C $(BOOTSTRAP_DIR) all
|
||||
@mkdir -p $(CURDIR)/$(ROMFS)
|
||||
@cp -f $(BOOTSTRAP_DIR)/ota-bootstrap.nro $(BOOTSTRAP_ROMFS)
|
||||
|
||||
sync-romfs: bootstrap-romfs
|
||||
@mkdir -p $(CURDIR)/$(ROMFS)
|
||||
@[ -f "$(LOGO_RGBA_SRC)" ] || (echo "missing $(LOGO_RGBA_SRC) — run: python3 scripts/switch/bake_ota_logo.py" && exit 1)
|
||||
@cp -f "$(LOGO_RGBA_SRC)" "$(LOGO_ROMFS)"
|
||||
@if ! curl -sfL --time-cond $(CACERT_ROMFS) -o $(CACERT_ROMFS) $(CACERT_URL); then \
|
||||
[ -f $(CACERT_ROMFS) ] || (echo "sync-romfs: failed to fetch cacert.pem" && exit 1); \
|
||||
fi
|
||||
|
||||
$(BUILD): sync-romfs
|
||||
@[ -d $@ ] || mkdir -p $@
|
||||
@$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
clean:
|
||||
@echo clean ...
|
||||
@$(MAKE) --no-print-directory -C $(BOOTSTRAP_DIR) clean || true
|
||||
@rm -fr $(BUILD) $(TARGET).nro $(TARGET).nacp $(TARGET).elf $(TARGET).lst build-host
|
||||
@rm -f $(CURDIR)/$(ROMFS)/logo.rgba $(CURDIR)/$(ROMFS)/logo.png
|
||||
|
||||
host-test:
|
||||
@mkdir -p build-host
|
||||
cc -std=c11 -Wall -Wextra -Iinclude -o build-host/test_ota_protocol \
|
||||
src/ota_protocol.c host/test_ota_protocol.c
|
||||
./build-host/test_ota_protocol
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
else
|
||||
.PHONY: all
|
||||
|
||||
DEPENDS := $(OFILES:.o=.d)
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
all: $(OUTPUT).nro
|
||||
|
||||
$(OUTPUT).nro: $(OUTPUT).elf $(OUTPUT).nacp
|
||||
|
||||
$(OUTPUT).elf: $(OFILES)
|
||||
|
||||
-include $(DEPENDS)
|
||||
|
||||
#---------------------------------------------------------------------------------
|
||||
endif
|
||||
#---------------------------------------------------------------------------------
|
||||
endif
|
||||
@@ -0,0 +1,89 @@
|
||||
# Native Switch OTA launcher
|
||||
|
||||
In-console updates for Gen1Recomp on Nintendo Switch. This NRO is the **hbmenu
|
||||
entry** (`gen1recomp.nro`). It checks GitHub Releases quietly (no UI when you
|
||||
are already up to date or offline). Only if a newer release exists does it show
|
||||
a **launcher-style screen** (black + RGB rail + project logo + flat A/B
|
||||
buttons), download the same `gen1recomp-*-switch.zip` used for install, verify
|
||||
SHA-256 from `sha256sums.txt`, replace **both** `gen1recomp-game.nro` and
|
||||
`gen1recomp.nro` (matching NACP version for hbmenu/Sphaira), then load the game
|
||||
with `envSetNextLoad`.
|
||||
|
||||
The LÖVE self-updater (`src/update/Check.lua`) stays **disabled** on NX.
|
||||
Wire format (also in Lua): `src/update/SwitchOta.lua`.
|
||||
NACP icon: `ports/switch/assets/icon.jpg`.
|
||||
|
||||
## Layout on microSD
|
||||
|
||||
```text
|
||||
sdmc:/switch/gen1recomp/gen1recomp.nro <- this launcher
|
||||
sdmc:/switch/gen1recomp/gen1recomp-game.nro <- fused LÖVE game
|
||||
sdmc:/switch/gen1recomp/version.txt <- installed X.Y.Z
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/ <- saves (never touched by OTA)
|
||||
```
|
||||
|
||||
## Host tests (no DEVKITPRO)
|
||||
|
||||
```bash
|
||||
cd ports/switch/ota-launcher
|
||||
make host-test
|
||||
```
|
||||
|
||||
## Switch build (DEVKITPRO)
|
||||
|
||||
Needs `DEVKITPRO` with packages roughly:
|
||||
|
||||
```bash
|
||||
(dkp-)pacman -S --needed switch-curl switch-mbedtls switch-zlib switch-zziplib
|
||||
# or: bash scripts/switch/install_devkitpro_deps.sh
|
||||
```
|
||||
|
||||
```bash
|
||||
export DEVKITPRO=/opt/devkitpro # typical
|
||||
cd ports/switch/ota-launcher
|
||||
make
|
||||
# -> gen1recomp.nro
|
||||
```
|
||||
|
||||
Or from repo root (as part of `--fused`):
|
||||
|
||||
```bash
|
||||
scripts/build_switch.sh --fetch --fused --version X.Y.Z
|
||||
```
|
||||
|
||||
Standalone launcher build:
|
||||
|
||||
```bash
|
||||
scripts/switch/build_ota_launcher.sh
|
||||
```
|
||||
|
||||
Docker fallback uses the same pin as fused builds (`scripts/switch/dkp-docker.image`).
|
||||
|
||||
## OTA logo asset
|
||||
|
||||
The launcher draws a pre-scaled logo from `romfs:/logo.rgba` (no PNG decoder in
|
||||
the NRO). The baked blob lives at `../assets/logo.rgba` and is copied into romfs at
|
||||
build time. After changing `assets/logo/logo.png`, regenerate:
|
||||
|
||||
```bash
|
||||
python3 scripts/switch/bake_ota_logo.py
|
||||
```
|
||||
|
||||
Requires Pillow, or on macOS uses `sips` when Pillow is not installed.
|
||||
|
||||
## Packaging
|
||||
|
||||
`scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP LAUNCHER_NRO` writes both
|
||||
NROs into the SD zip. That zip is also what OTA downloads.
|
||||
Manifest: `scripts/switch/ota_launcher.manifest`.
|
||||
|
||||
## Status / known gaps
|
||||
|
||||
- Zip extraction uses `switch-zziplib` (`ota_unzip.c`) on device.
|
||||
- OTA replaces game + launcher from the install zip (NACP versions stay aligned).
|
||||
The running launcher cannot overwrite its own NRO on sdmc/FAT; a tiny
|
||||
`ota-bootstrap.nro` (embedded in romfs) chainloads once to swap the staged
|
||||
launcher, then loads the game.
|
||||
- HTTPS uses Mozilla CA bundle in romfs (`cacert.pem`, fetched at build time). `ota_net_init()` mounts romfs before the quiet release check.
|
||||
- Sphaira HOME forwarders cache metadata until reinstalled (see docs/switch-install.md).
|
||||
- Release runner: `switch-dev` + (`install_devkitpro_deps.sh` **or** Docker)
|
||||
@@ -0,0 +1,144 @@
|
||||
/* Host-only unit tests for ota_protocol.c — no libnx. */
|
||||
#include "ota_protocol.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static int g_fail = 0;
|
||||
|
||||
static void expect(int cond, const char *msg) {
|
||||
if (!cond) {
|
||||
fprintf(stderr, "FAIL: %s\n", msg);
|
||||
g_fail++;
|
||||
} else {
|
||||
printf("PASS: %s\n", msg);
|
||||
}
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
expect(ota_compare_semver("1.2.0", "1.1.0") == 1, "semver newer");
|
||||
expect(ota_compare_semver("1.1.0", "1.1.0") == 0, "semver equal");
|
||||
expect(ota_compare_semver("1.0.0", "1.1.0") == -1, "semver older");
|
||||
expect(ota_is_ota_asset_name("gen1recomp-1.5.0-switch.zip"), "ota asset name");
|
||||
expect(!ota_is_ota_asset_name("gen1recomp-1.5.0-switch-ota.zip"), "reject legacy ota zip name");
|
||||
expect(!ota_is_ota_asset_name("gen1recomp-1.5.0.love"), "reject love payload");
|
||||
|
||||
const char *json =
|
||||
"{"
|
||||
"\"tag_name\":\"v1.5.0\","
|
||||
"\"assets\":["
|
||||
"{\"name\":\"gen1recomp-1.5.0-switch.zip\","
|
||||
"\"browser_download_url\":\"https://example/switch.zip\"},"
|
||||
"{\"name\":\"sha256sums.txt\",\"browser_download_url\":\"https://example/sums\"}"
|
||||
"]"
|
||||
"}";
|
||||
ota_release_t rel;
|
||||
expect(ota_parse_release(json, &rel) == 1, "parse release");
|
||||
expect(strcmp(rel.version, "1.5.0") == 0, "release version");
|
||||
expect(strcmp(rel.asset_name, "gen1recomp-1.5.0-switch.zip") == 0, "release asset");
|
||||
|
||||
ota_decision_t d;
|
||||
ota_decide_update("1.4.0", &rel, &d);
|
||||
expect(strcmp(d.status, "available") == 0, "decide available");
|
||||
ota_decide_update("1.5.0", &rel, &d);
|
||||
expect(strcmp(d.status, "uptodate") == 0, "decide uptodate");
|
||||
|
||||
ota_release_t bad;
|
||||
expect(ota_parse_release("{\"tag_name\":\"v1.5.0\",\"assets\":[]}", &bad) == 0,
|
||||
"missing ota asset");
|
||||
expect(strcmp(bad.reason, "missing_ota_asset") == 0, "missing_ota_asset reason");
|
||||
|
||||
/* GitHub releases/latest shape: release-level name + fat uploader before browser_download_url. */
|
||||
const char *github_json =
|
||||
"{"
|
||||
"\"tag_name\":\"v0.1.70\","
|
||||
"\"name\":\"0.1.70\","
|
||||
"\"assets\":["
|
||||
"{"
|
||||
"\"url\":\"https://api.github.com/repos/bryanthaboi/gen1recomp/releases/assets/502823880\","
|
||||
"\"id\":502823880,"
|
||||
"\"name\":\"gen1recomp-0.1.70-switch.zip\","
|
||||
"\"label\":\"\","
|
||||
"\"uploader\":{"
|
||||
"\"login\":\"github-actions[bot]\","
|
||||
"\"id\":41898282,"
|
||||
"\"node_id\":\"MDM6Qm90NDE4OTgyODI=\","
|
||||
"\"avatar_url\":\"https://avatars.githubusercontent.com/in/15368?v=4\","
|
||||
"\"gravatar_id\":\"\","
|
||||
"\"url\":\"https://api.github.com/users/github-actions%5Bbot%5D\","
|
||||
"\"html_url\":\"https://github.com/apps/github-actions\","
|
||||
"\"followers_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/followers\","
|
||||
"\"following_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}\","
|
||||
"\"gists_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}\","
|
||||
"\"starred_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}\","
|
||||
"\"subscriptions_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions\","
|
||||
"\"organizations_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/orgs\","
|
||||
"\"repos_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/repos\","
|
||||
"\"events_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}\","
|
||||
"\"received_events_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/received_events\","
|
||||
"\"type\":\"Bot\","
|
||||
"\"user_view_type\":\"public\","
|
||||
"\"site_admin\":false"
|
||||
"},"
|
||||
"\"content_type\":\"application/zip\","
|
||||
"\"state\":\"uploaded\","
|
||||
"\"size\":9000573,"
|
||||
"\"browser_download_url\":\"https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip\""
|
||||
"}"
|
||||
"]"
|
||||
"}";
|
||||
ota_release_t gh;
|
||||
expect(ota_parse_release(github_json, &gh) == 1, "parse github-shaped release");
|
||||
expect(strcmp(gh.version, "0.1.70") == 0, "github release version");
|
||||
expect(strcmp(gh.asset_name, "gen1recomp-0.1.70-switch.zip") == 0, "github release asset");
|
||||
expect(strcmp(gh.download_url,
|
||||
"https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip") ==
|
||||
0,
|
||||
"github release download url");
|
||||
ota_decide_update("0.1.69", &gh, &d);
|
||||
expect(strcmp(d.status, "available") == 0, "github release decide 0.1.69->0.1.70");
|
||||
|
||||
const char *sums = "abc123 gen1recomp-1.5.0-switch.zip\n";
|
||||
ota_verify_t v;
|
||||
ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "abc123", sums, &v);
|
||||
expect(v.ok == 1, "sha ok");
|
||||
ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "deadbeef", sums, &v);
|
||||
expect(v.ok == 0 && strcmp(v.reason, "hash_mismatch") == 0, "sha mismatch");
|
||||
ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "abc123", "", &v);
|
||||
expect(v.ok == 0 && strcmp(v.reason, "sum_not_found") == 0, "sha missing sum rejected");
|
||||
|
||||
ota_apply_plan_t plan;
|
||||
ota_plan_atomic_apply("switch/gen1recomp", "/tmp/x", &plan);
|
||||
expect(strstr(plan.steps[0], "copy_to_part:") != NULL, "plan copy");
|
||||
expect(strstr(plan.steps[1], "rename:") != NULL, "plan rename");
|
||||
expect(strstr(plan.steps[2], "copy_to_part:launcher") != NULL, "plan launcher copy");
|
||||
expect(strstr(plan.steps[3], "rename:") != NULL, "plan launcher rename");
|
||||
expect(strstr(plan.steps[4], "env_set_next_load:") != NULL, "plan handoff");
|
||||
expect(strstr(plan.preserve, "pokemon-love2d") != NULL, "preserve saves");
|
||||
expect(strstr(plan.forbidden_delete, "delete:") != NULL, "forbid delete saves");
|
||||
expect(strstr(plan.forbidden_direct, "write_direct:") != NULL, "forbid direct write");
|
||||
|
||||
ota_offline_t off;
|
||||
ota_offline_events_t ev = {0};
|
||||
ev.user_skip = 1;
|
||||
ota_offline_policy(1.0, &ev, &off);
|
||||
expect(strcmp(off.action, "play_installed") == 0, "skip plays installed");
|
||||
ev.user_skip = 0;
|
||||
ev.network_ok = 0;
|
||||
ota_offline_policy(1.0, &ev, &off);
|
||||
expect(strcmp(off.action, "play_installed") == 0, "offline plays installed");
|
||||
ev.network_ok = 1;
|
||||
ota_offline_policy(6.0, &ev, &off);
|
||||
expect(strcmp(off.reason, "timeout") == 0, "timeout 6s");
|
||||
ota_offline_policy(2.0, &ev, &off);
|
||||
expect(strcmp(off.action, "keep_checking") == 0, "still checking");
|
||||
|
||||
expect(OTA_CHECK_TIMEOUT_SEC == 6, "timeout constant 6");
|
||||
|
||||
if (g_fail) {
|
||||
fprintf(stderr, "%d failure(s)\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
printf("all ota_protocol host tests passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#ifndef GEN1_OTA_FS_H
|
||||
#define GEN1_OTA_FS_H
|
||||
|
||||
#include "ota_protocol.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Read installed game version from sibling version.txt or NRO nacp-less stamp file.
|
||||
* Expects switch/gen1recomp/version.txt containing X.Y.Z. Returns 0 on success. */
|
||||
int ota_fs_read_installed_version(const char *install_dir, char *out, size_t out_len);
|
||||
|
||||
/* SHA-256 hex (lowercase) of file. Returns 0 on success. */
|
||||
int ota_fs_sha256_file(const char *path, char *out_hex, size_t out_len);
|
||||
|
||||
/* Apply verified temp payload (extracted game NRO) using atomic plan. Returns 0 on success. */
|
||||
int ota_fs_atomic_replace_game(const char *install_dir, const char *verified_game_nro,
|
||||
char *err, size_t err_len);
|
||||
|
||||
/* Atomically replace any named NRO under install_dir (copy→.part→rename). */
|
||||
int ota_fs_atomic_replace_nro(const char *install_dir, const char *nro_name,
|
||||
const char *verified_nro, char *err, size_t err_len);
|
||||
|
||||
/* Stage a new launcher + copy romfs bootstrap; chainload bootstrap_out next.
|
||||
* The running launcher cannot replace its own NRO on sdmc/FAT. */
|
||||
int ota_fs_stage_launcher_bootstrap(const char *install_dir, const char *verified_launcher,
|
||||
char *bootstrap_out, size_t bootstrap_out_len, char *err,
|
||||
size_t err_len);
|
||||
|
||||
/* Hand off to game NRO via envSetNextLoad (Switch) or no-op stub (host). */
|
||||
int ota_fs_handoff_to_game(const char *game_nro_path, char *err, size_t err_len);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,29 @@
|
||||
#ifndef GEN1_OTA_NET_H
|
||||
#define GEN1_OTA_NET_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* fraction is 0..1 when Content-Length is known, else -1 for indeterminate. */
|
||||
typedef void (*ota_net_progress_fn)(void *userdata, double fraction);
|
||||
|
||||
/* Mount romfs (CA bundle) and init libcurl. Call before any HTTPS download. */
|
||||
int ota_net_init(void);
|
||||
void ota_net_shutdown(void);
|
||||
|
||||
/* Download URL into memory buffer (caller frees *out). Returns 0 on success. */
|
||||
int ota_net_download_buffer(const char *url, long timeout_ms, char **out, size_t *out_len,
|
||||
char *err, size_t err_len);
|
||||
|
||||
/* Download URL to a filesystem path. progress may be NULL. Returns 0 on success. */
|
||||
int ota_net_download_file(const char *url, const char *path, long timeout_ms, char *err,
|
||||
size_t err_len, ota_net_progress_fn progress, void *progress_ud);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,95 @@
|
||||
#ifndef GEN1_OTA_PROTOCOL_H
|
||||
#define GEN1_OTA_PROTOCOL_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define OTA_CHECK_TIMEOUT_SEC 6
|
||||
#define OTA_GAME_NRO_NAME "gen1recomp-game.nro"
|
||||
#define OTA_LAUNCHER_NRO_NAME "gen1recomp.nro"
|
||||
#define OTA_SAVE_DIR_NAME "pokemon-love2d"
|
||||
#define OTA_INSTALL_DIR "switch/gen1recomp"
|
||||
#define OTA_LAUNCHER_STAGED_SUFFIX ".staged"
|
||||
#define OTA_BOOTSTRAP_ROMFS "romfs:/ota-bootstrap.nro"
|
||||
#define OTA_BOOTSTRAP_SD_NAME "ota-bootstrap.nro"
|
||||
#define OTA_RELEASES_API \
|
||||
"https://api.github.com/repos/bryanthaboi/gen1recomp/releases/latest"
|
||||
|
||||
/* Mirrors src/update/SwitchOta.lua — keep semantics in lockstep. */
|
||||
|
||||
int ota_compare_semver(const char *a, const char *b);
|
||||
int ota_is_ota_asset_name(const char *name);
|
||||
int ota_version_from_ota_asset(const char *name, char *out, size_t out_len);
|
||||
|
||||
typedef struct {
|
||||
int ok; /* 1 on success */
|
||||
char reason[64];
|
||||
char tag[64];
|
||||
char version[32];
|
||||
char asset_name[128];
|
||||
char download_url[512];
|
||||
} ota_release_t;
|
||||
|
||||
int ota_parse_release(const char *json_text, ota_release_t *out);
|
||||
|
||||
typedef struct {
|
||||
char status[32]; /* uptodate | available | error */
|
||||
char reason[64];
|
||||
char version[32];
|
||||
char asset_name[128];
|
||||
char download_url[512];
|
||||
} ota_decision_t;
|
||||
|
||||
void ota_decide_update(const char *installed_version, const ota_release_t *release,
|
||||
ota_decision_t *out);
|
||||
|
||||
/* sums: newline-separated sha256sums.txt body */
|
||||
int ota_lookup_sum(const char *sums_text, const char *asset_name, char *out_hex,
|
||||
size_t out_len);
|
||||
|
||||
typedef struct {
|
||||
int ok;
|
||||
char reason[64];
|
||||
} ota_verify_t;
|
||||
|
||||
void ota_verify_sha256(const char *asset_name, const char *actual_hex,
|
||||
const char *sums_text, ota_verify_t *out);
|
||||
|
||||
typedef struct {
|
||||
char steps[5][520]; /* human-readable ops */
|
||||
char preserve[256];
|
||||
char forbidden_delete[256];
|
||||
char forbidden_direct[256];
|
||||
char part_path[256];
|
||||
char game_nro[240];
|
||||
char launcher_nro[240];
|
||||
char launcher_part[256];
|
||||
char next_load[240];
|
||||
} ota_apply_plan_t;
|
||||
|
||||
void ota_plan_atomic_apply(const char *install_dir, const char *verified_temp,
|
||||
ota_apply_plan_t *out);
|
||||
|
||||
typedef struct {
|
||||
char action[32]; /* play_installed | keep_checking */
|
||||
char reason[64];
|
||||
char message[128];
|
||||
} ota_offline_t;
|
||||
|
||||
typedef struct {
|
||||
int user_skip;
|
||||
int network_ok; /* 0 = offline/fail, 1 = ok, -1 = unknown */
|
||||
int api_error;
|
||||
} ota_offline_events_t;
|
||||
|
||||
void ota_offline_policy(double elapsed_sec, const ota_offline_events_t *events,
|
||||
ota_offline_t *out);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* GEN1_OTA_PROTOCOL_H */
|
||||
@@ -0,0 +1,37 @@
|
||||
#ifndef GEN1_OTA_UI_H
|
||||
#define GEN1_OTA_UI_H
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Framebuffer UI matching the in-game launcher look (black + RGB rail +
|
||||
* flat semantic buttons). Silent by default — only call when user-facing. */
|
||||
|
||||
/* Returns 1 = update, 0 = skip / play installed. */
|
||||
int ota_ui_prompt_update(const char *installed, const char *latest);
|
||||
|
||||
/* Status screen (download / install). Redraws each call. */
|
||||
void ota_ui_show_status(const char *title, const char *detail);
|
||||
|
||||
/* Progress 0..1; detail optional. */
|
||||
void ota_ui_show_progress(const char *title, const char *detail, float progress01);
|
||||
|
||||
/* Error / info + wait for B. */
|
||||
void ota_ui_alert(const char *title, const char *line1, const char *line2);
|
||||
|
||||
/* User-friendly error with optional technical detail and installed-version footer. */
|
||||
void ota_ui_alert_error(const char *title, const char *friendly, const char *technical,
|
||||
const char *installed_version);
|
||||
|
||||
/* Missing game install screen; wait for +. */
|
||||
void ota_ui_missing_game(void);
|
||||
|
||||
/* Tear down framebuffer if active. Safe to call when UI never opened. */
|
||||
void ota_ui_shutdown(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,20 @@
|
||||
#ifndef GEN1_OTA_UNZIP_H
|
||||
#define GEN1_OTA_UNZIP_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Extract member_name from zip_path into dest_path.
|
||||
* Tries exact member_name, then common prefixes (switch/gen1recomp/).
|
||||
* Returns 0 on success. */
|
||||
int ota_unzip_extract_file(const char *zip_path, const char *member_name, const char *dest_path,
|
||||
char *err, size_t err_len);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
* gen1recomp Switch OTA launcher
|
||||
*
|
||||
* Quiet by default: checks GitHub Releases with no UI. Only shows the
|
||||
* branded launcher-style screen when an update is available. Downloads the
|
||||
* same SD zip (gen1recomp-*-switch.zip), verifies SHA-256, replaces game +
|
||||
* launcher NROs, then envSetNextLoad. Never touches pokemon-love2d/.
|
||||
* LÖVE self-updater stays off on NX.
|
||||
*/
|
||||
|
||||
#include "ota_fs.h"
|
||||
#include "ota_net.h"
|
||||
#include "ota_protocol.h"
|
||||
#include "ota_ui.h"
|
||||
#include "ota_unzip.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
#include <sys/stat.h>
|
||||
#include <switch.h>
|
||||
#include <unistd.h>
|
||||
#else
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
#define SD_INSTALL_DIR "sdmc:/switch/gen1recomp"
|
||||
#define CHECK_TIMEOUT_MS (OTA_CHECK_TIMEOUT_SEC * 1000L)
|
||||
#define GAME_MEMBER_IN_ZIP "switch/gen1recomp/" OTA_GAME_NRO_NAME
|
||||
#define LAUNCHER_MEMBER_IN_ZIP "switch/gen1recomp/" OTA_LAUNCHER_NRO_NAME
|
||||
|
||||
typedef struct {
|
||||
const char *detail;
|
||||
float base;
|
||||
float span;
|
||||
} download_progress_ctx_t;
|
||||
|
||||
static void on_download_progress(void *userdata, double fraction) {
|
||||
download_progress_ctx_t *ctx = (download_progress_ctx_t *)userdata;
|
||||
if (!ctx) return;
|
||||
float p = ctx->base;
|
||||
if (fraction >= 0.0) p += ctx->span * (float)fraction;
|
||||
else p += ctx->span * 0.1f;
|
||||
if (p > ctx->base + ctx->span) p = ctx->base + ctx->span;
|
||||
ota_ui_show_progress("Step 1/3: Downloading...", ctx->detail, p);
|
||||
}
|
||||
|
||||
static void show_update_error(const char *title, const char *friendly, const char *technical,
|
||||
const char *installed) {
|
||||
ota_ui_alert_error(title, friendly, technical, installed);
|
||||
}
|
||||
|
||||
static int run_update_flow(const char *install_dir) {
|
||||
char installed[64] = "0.0.0";
|
||||
(void)ota_fs_read_installed_version(install_dir, installed, sizeof(installed));
|
||||
|
||||
char *json = NULL;
|
||||
size_t json_len = 0;
|
||||
char err[256];
|
||||
err[0] = '\0';
|
||||
|
||||
if (ota_net_download_buffer(OTA_RELEASES_API, CHECK_TIMEOUT_MS, &json, &json_len, err,
|
||||
sizeof(err)) != 0) {
|
||||
free(json);
|
||||
return 0; /* offline / timeout — play installed, no UI */
|
||||
}
|
||||
|
||||
ota_release_t rel;
|
||||
if (!ota_parse_release(json, &rel)) {
|
||||
free(json);
|
||||
return 0;
|
||||
}
|
||||
free(json);
|
||||
|
||||
ota_decision_t dec;
|
||||
ota_decide_update(installed, &rel, &dec);
|
||||
if (strcmp(dec.status, "uptodate") == 0 || strcmp(dec.status, "error") == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!ota_ui_prompt_update(installed, dec.version)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
char updates_dir[192];
|
||||
char zip_path[256];
|
||||
char sums_path[256];
|
||||
snprintf(updates_dir, sizeof(updates_dir), "%s/updates", install_dir);
|
||||
snprintf(zip_path, sizeof(zip_path), "%s/updates/%s", install_dir, dec.asset_name);
|
||||
snprintf(sums_path, sizeof(sums_path), "%s/updates/sha256sums.txt", install_dir);
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
mkdir(updates_dir, 0755);
|
||||
#endif
|
||||
|
||||
download_progress_ctx_t dl_ctx = {"This may take a minute.", 0.f, 0.5f};
|
||||
ota_ui_show_progress("Step 1/3: Downloading...", dl_ctx.detail, dl_ctx.base);
|
||||
if (ota_net_download_file(dec.download_url, zip_path, 180000L, err, sizeof(err),
|
||||
on_download_progress, &dl_ctx) != 0) {
|
||||
show_update_error("Download failed",
|
||||
"Could not download the update. Check Wi-Fi and try again.", err, installed);
|
||||
return 0;
|
||||
}
|
||||
|
||||
ota_ui_show_progress("Step 2/3: Verifying...", "Checking file integrity.", 0.55f);
|
||||
char sums_url[512];
|
||||
snprintf(sums_url, sizeof(sums_url),
|
||||
"https://github.com/bryanthaboi/gen1recomp/releases/download/%s/sha256sums.txt",
|
||||
rel.tag);
|
||||
if (ota_net_download_file(sums_url, sums_path, CHECK_TIMEOUT_MS, err, sizeof(err), NULL,
|
||||
NULL) != 0) {
|
||||
remove(zip_path);
|
||||
show_update_error("Could not verify", "Downloaded file could not be checked.", err, installed);
|
||||
return 0;
|
||||
}
|
||||
|
||||
FILE *sf = fopen(sums_path, "rb");
|
||||
if (!sf) {
|
||||
remove(zip_path);
|
||||
show_update_error("Could not verify", "Could not read checksum file.", "fopen sums failed",
|
||||
installed);
|
||||
return 0;
|
||||
}
|
||||
fseek(sf, 0, SEEK_END);
|
||||
long slen = ftell(sf);
|
||||
fseek(sf, 0, SEEK_SET);
|
||||
char *sums = (char *)malloc((size_t)slen + 1);
|
||||
if (!sums) {
|
||||
fclose(sf);
|
||||
remove(zip_path);
|
||||
show_update_error("Could not verify", "Not enough memory to verify update.", "malloc failed",
|
||||
installed);
|
||||
return 0;
|
||||
}
|
||||
fread(sums, 1, (size_t)slen, sf);
|
||||
sums[slen] = '\0';
|
||||
fclose(sf);
|
||||
|
||||
ota_ui_show_progress("Step 2/3: Verifying...", "Computing checksum...", 0.65f);
|
||||
char hex[96];
|
||||
if (ota_fs_sha256_file(zip_path, hex, sizeof(hex)) != 0) {
|
||||
free(sums);
|
||||
remove(zip_path);
|
||||
show_update_error("Could not verify", "Could not read downloaded update file.",
|
||||
"sha256 file read failed", installed);
|
||||
return 0;
|
||||
}
|
||||
ota_verify_t ver;
|
||||
ota_verify_sha256(dec.asset_name, hex, sums, &ver);
|
||||
free(sums);
|
||||
if (!ver.ok) {
|
||||
remove(zip_path);
|
||||
show_update_error("Update check failed",
|
||||
"Downloaded file did not match expected checksum.", ver.reason, installed);
|
||||
return 0;
|
||||
}
|
||||
|
||||
char extracted[256];
|
||||
char extracted_launcher[256];
|
||||
snprintf(extracted, sizeof(extracted), "%s/updates/gen1recomp-game.nro.verified", install_dir);
|
||||
snprintf(extracted_launcher, sizeof(extracted_launcher), "%s/updates/gen1recomp.nro.verified",
|
||||
install_dir);
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
ota_ui_show_progress("Step 3/3: Installing...", "Keeping your saves safe.", 0.8f);
|
||||
if (ota_unzip_extract_file(zip_path, GAME_MEMBER_IN_ZIP, extracted, err, sizeof(err)) != 0) {
|
||||
if (ota_unzip_extract_file(zip_path, OTA_GAME_NRO_NAME, extracted, err, sizeof(err)) != 0) {
|
||||
remove(zip_path);
|
||||
show_update_error("Could not extract",
|
||||
"Update zip is missing game files or is corrupted.", err, installed);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (ota_fs_atomic_replace_game(install_dir, extracted, err, sizeof(err)) != 0) {
|
||||
remove(extracted);
|
||||
remove(zip_path);
|
||||
show_update_error("Could not install",
|
||||
"Could not replace game on microSD. Free up space and try again.", err,
|
||||
installed);
|
||||
return 0;
|
||||
}
|
||||
remove(extracted);
|
||||
|
||||
int have_launcher = 0;
|
||||
if (ota_unzip_extract_file(zip_path, LAUNCHER_MEMBER_IN_ZIP, extracted_launcher, err,
|
||||
sizeof(err)) == 0 ||
|
||||
ota_unzip_extract_file(zip_path, OTA_LAUNCHER_NRO_NAME, extracted_launcher, err,
|
||||
sizeof(err)) == 0) {
|
||||
have_launcher = 1;
|
||||
}
|
||||
if (!have_launcher) {
|
||||
remove(zip_path);
|
||||
show_update_error("Could not extract",
|
||||
"Update zip is missing launcher files.", err, installed);
|
||||
return 0;
|
||||
}
|
||||
|
||||
char bootstrap_path[256];
|
||||
bootstrap_path[0] = '\0';
|
||||
if (ota_fs_stage_launcher_bootstrap(install_dir, extracted_launcher, bootstrap_path,
|
||||
sizeof(bootstrap_path), err, sizeof(err)) != 0) {
|
||||
remove(extracted_launcher);
|
||||
remove(zip_path);
|
||||
show_update_error("Could not install",
|
||||
"Game updated but launcher could not be staged. Reinstall from the SD zip.",
|
||||
err, installed);
|
||||
return 0;
|
||||
}
|
||||
remove(extracted_launcher);
|
||||
|
||||
char vpath[192];
|
||||
snprintf(vpath, sizeof(vpath), "%s/version.txt", install_dir);
|
||||
FILE *vf = fopen(vpath, "wb");
|
||||
if (vf) {
|
||||
fprintf(vf, "%s\n", dec.version);
|
||||
fclose(vf);
|
||||
}
|
||||
|
||||
ota_ui_show_progress("Ready", "Finishing update...", 1.0f);
|
||||
svcSleepThread(600000000ULL);
|
||||
remove(zip_path);
|
||||
ota_ui_shutdown();
|
||||
|
||||
if (ota_fs_handoff_to_game(bootstrap_path, err, sizeof(err)) != 0) {
|
||||
show_update_error("Could not install", "Launcher bootstrap failed.", err, installed);
|
||||
return 0;
|
||||
}
|
||||
return 2; /* chainload bootstrap; main must not hand off to game */
|
||||
#else
|
||||
(void)extracted;
|
||||
(void)extracted_launcher;
|
||||
#endif
|
||||
remove(zip_path);
|
||||
ota_ui_shutdown();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
|
||||
const char *install = SD_INSTALL_DIR;
|
||||
int update_rc = 0;
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
socketInitializeDefault();
|
||||
padConfigureInput(1, HidNpadStyleSet_NpadStandard);
|
||||
if (ota_net_init() == 0) {
|
||||
update_rc = run_update_flow(install);
|
||||
ota_net_shutdown();
|
||||
}
|
||||
#else
|
||||
update_rc = run_update_flow(install);
|
||||
#endif
|
||||
|
||||
if (update_rc == 2) {
|
||||
#if defined(__SWITCH__)
|
||||
socketExit();
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
char game[192];
|
||||
snprintf(game, sizeof(game), "%s/%s", install, OTA_GAME_NRO_NAME);
|
||||
char err[128];
|
||||
err[0] = '\0';
|
||||
if (ota_fs_handoff_to_game(game, err, sizeof(err)) != 0) {
|
||||
ota_ui_missing_game();
|
||||
}
|
||||
|
||||
ota_ui_shutdown();
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
socketExit();
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
#include "ota_fs.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
#include <switch.h>
|
||||
#include <mbedtls/sha256.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
int ota_fs_read_installed_version(const char *install_dir, char *out, size_t out_len) {
|
||||
if (!out || out_len == 0) return -1;
|
||||
out[0] = '\0';
|
||||
char path[256];
|
||||
snprintf(path, sizeof(path), "%s/version.txt", install_dir ? install_dir : OTA_INSTALL_DIR);
|
||||
FILE *fp = fopen(path, "rb");
|
||||
if (!fp) return -1;
|
||||
if (!fgets(out, (int)out_len, fp)) {
|
||||
fclose(fp);
|
||||
return -1;
|
||||
}
|
||||
fclose(fp);
|
||||
/* trim */
|
||||
size_t n = strlen(out);
|
||||
while (n > 0 && (out[n - 1] == '\n' || out[n - 1] == '\r' || out[n - 1] == ' ')) {
|
||||
out[--n] = '\0';
|
||||
}
|
||||
return n > 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
int ota_fs_sha256_file(const char *path, char *out_hex, size_t out_len) {
|
||||
if (!path || !out_hex || out_len < 65) return -1;
|
||||
out_hex[0] = '\0';
|
||||
FILE *fp = fopen(path, "rb");
|
||||
if (!fp) return -1;
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
mbedtls_sha256_context ctx;
|
||||
mbedtls_sha256_init(&ctx);
|
||||
mbedtls_sha256_starts(&ctx, 0);
|
||||
unsigned char buf[8192];
|
||||
size_t n;
|
||||
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) {
|
||||
mbedtls_sha256_update(&ctx, buf, n);
|
||||
}
|
||||
fclose(fp);
|
||||
unsigned char digest[32];
|
||||
mbedtls_sha256_finish(&ctx, digest);
|
||||
mbedtls_sha256_free(&ctx);
|
||||
for (int i = 0; i < 32; i++) snprintf(out_hex + i * 2, out_len - (size_t)(i * 2), "%02x", digest[i]);
|
||||
return 0;
|
||||
#else
|
||||
/* Host: shell out to shasum/sha256sum for the host test path if needed.
|
||||
* Protocol host tests do not call this; return not-implemented. */
|
||||
fclose(fp);
|
||||
(void)out_len;
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
static int copy_file(const char *from, const char *to) {
|
||||
FILE *in = fopen(from, "rb");
|
||||
if (!in) return -1;
|
||||
FILE *out = fopen(to, "wb");
|
||||
if (!out) {
|
||||
fclose(in);
|
||||
return -1;
|
||||
}
|
||||
char buf[8192];
|
||||
size_t n;
|
||||
while ((n = fread(buf, 1, sizeof(buf), in)) > 0) {
|
||||
if (fwrite(buf, 1, n, out) != n) {
|
||||
fclose(in);
|
||||
fclose(out);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
fclose(in);
|
||||
fclose(out);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ota_fs_atomic_replace_game(const char *install_dir, const char *verified_game_nro,
|
||||
char *err, size_t err_len) {
|
||||
return ota_fs_atomic_replace_nro(install_dir, OTA_GAME_NRO_NAME, verified_game_nro, err,
|
||||
err_len);
|
||||
}
|
||||
|
||||
int ota_fs_atomic_replace_nro(const char *install_dir, const char *nro_name,
|
||||
const char *verified_nro, char *err, size_t err_len) {
|
||||
if (!nro_name || !*nro_name || !verified_nro || !*verified_nro) {
|
||||
if (err && err_len) snprintf(err, err_len, "missing nro paths");
|
||||
return -1;
|
||||
}
|
||||
char dest[240];
|
||||
char part[256];
|
||||
snprintf(dest, sizeof(dest), "%s/%s", install_dir ? install_dir : OTA_INSTALL_DIR, nro_name);
|
||||
snprintf(part, sizeof(part), "%s.part", dest);
|
||||
if (copy_file(verified_nro, part) != 0) {
|
||||
if (err && err_len) snprintf(err, err_len, "copy to .part failed (%s)", nro_name);
|
||||
return -1;
|
||||
}
|
||||
/* sdmc/FAT (Switch) and Windows do not replace an existing dest on rename. */
|
||||
remove(dest);
|
||||
if (rename(part, dest) != 0) {
|
||||
if (err && err_len) snprintf(err, err_len, "rename .part -> %s failed", nro_name);
|
||||
remove(part);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ota_fs_stage_launcher_bootstrap(const char *install_dir, const char *verified_launcher,
|
||||
char *bootstrap_out, size_t bootstrap_out_len, char *err,
|
||||
size_t err_len) {
|
||||
if (!verified_launcher || !*verified_launcher) {
|
||||
if (err && err_len) snprintf(err, err_len, "missing staged launcher path");
|
||||
return -1;
|
||||
}
|
||||
if (!bootstrap_out || bootstrap_out_len == 0) {
|
||||
if (err && err_len) snprintf(err, err_len, "missing bootstrap output buffer");
|
||||
return -1;
|
||||
}
|
||||
bootstrap_out[0] = '\0';
|
||||
|
||||
const char *base = install_dir ? install_dir : OTA_INSTALL_DIR;
|
||||
char staged[256];
|
||||
char bootstrap[256];
|
||||
char updates[192];
|
||||
snprintf(staged, sizeof(staged), "%s/%s%s", base, OTA_LAUNCHER_NRO_NAME,
|
||||
OTA_LAUNCHER_STAGED_SUFFIX);
|
||||
snprintf(updates, sizeof(updates), "%s/updates", base);
|
||||
snprintf(bootstrap, sizeof(bootstrap), "%s/updates/%s", base, OTA_BOOTSTRAP_SD_NAME);
|
||||
|
||||
remove(staged);
|
||||
if (copy_file(verified_launcher, staged) != 0) {
|
||||
if (err && err_len) snprintf(err, err_len, "stage launcher failed");
|
||||
return -1;
|
||||
}
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
mkdir(updates, 0755);
|
||||
#endif
|
||||
if (copy_file(OTA_BOOTSTRAP_ROMFS, bootstrap) != 0) {
|
||||
remove(staged);
|
||||
if (err && err_len) snprintf(err, err_len, "extract bootstrap failed");
|
||||
return -1;
|
||||
}
|
||||
|
||||
snprintf(bootstrap_out, bootstrap_out_len, "%s", bootstrap);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ota_fs_handoff_to_game(const char *game_nro_path, char *err, size_t err_len) {
|
||||
if (!game_nro_path || !*game_nro_path) {
|
||||
if (err && err_len) snprintf(err, err_len, "missing game path");
|
||||
return -1;
|
||||
}
|
||||
#if defined(__SWITCH__)
|
||||
/* argv for next load: empty args string is fine */
|
||||
Result rc = envSetNextLoad(game_nro_path, game_nro_path);
|
||||
if (R_FAILED(rc)) {
|
||||
if (err && err_len) snprintf(err, err_len, "envSetNextLoad failed: 0x%x", rc);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
#else
|
||||
if (err && err_len)
|
||||
snprintf(err, err_len, "handoff stub (host): would envSetNextLoad %s", game_nro_path);
|
||||
return 0; /* host stub succeeds for flow tests */
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
#include "ota_net.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
#include <curl/curl.h>
|
||||
#include <switch.h>
|
||||
#endif
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
#define OTA_CA_BUNDLE "romfs:/cacert.pem"
|
||||
|
||||
static int g_net_ready = 0;
|
||||
|
||||
static int ota_ca_bundle_ready(void) {
|
||||
FILE *f = fopen(OTA_CA_BUNDLE, "rb");
|
||||
if (!f) return 0;
|
||||
fclose(f);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int ota_net_init(void) {
|
||||
if (g_net_ready) return 0;
|
||||
if (R_FAILED(romfsInit())) return -1;
|
||||
if (!ota_ca_bundle_ready()) return -1;
|
||||
if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) return -1;
|
||||
g_net_ready = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void ota_net_shutdown(void) {
|
||||
if (!g_net_ready) return;
|
||||
curl_global_cleanup();
|
||||
romfsExit();
|
||||
g_net_ready = 0;
|
||||
}
|
||||
|
||||
static void ota_net_configure_tls(CURL *curl) {
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
curl_easy_setopt(curl, CURLOPT_CAINFO, OTA_CA_BUNDLE);
|
||||
}
|
||||
|
||||
struct mem_buf {
|
||||
char *data;
|
||||
size_t len;
|
||||
};
|
||||
|
||||
struct file_progress {
|
||||
ota_net_progress_fn fn;
|
||||
void *userdata;
|
||||
};
|
||||
|
||||
static size_t write_mem(char *ptr, size_t size, size_t nmemb, void *userdata) {
|
||||
struct mem_buf *m = (struct mem_buf *)userdata;
|
||||
size_t n = size * nmemb;
|
||||
char *p = (char *)realloc(m->data, m->len + n + 1);
|
||||
if (!p) return 0;
|
||||
m->data = p;
|
||||
memcpy(m->data + m->len, ptr, n);
|
||||
m->len += n;
|
||||
m->data[m->len] = '\0';
|
||||
return n;
|
||||
}
|
||||
|
||||
static size_t write_file(char *ptr, size_t size, size_t nmemb, void *userdata) {
|
||||
return fwrite(ptr, size, nmemb, (FILE *)userdata);
|
||||
}
|
||||
|
||||
static int xfer_progress(void *clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t ultotal,
|
||||
curl_off_t ulnow) {
|
||||
(void)ultotal;
|
||||
(void)ulnow;
|
||||
struct file_progress *fp = (struct file_progress *)clientp;
|
||||
if (!fp || !fp->fn) return 0;
|
||||
if (dltotal > 0) {
|
||||
double frac = (double)dlnow / (double)dltotal;
|
||||
if (frac < 0.0) frac = 0.0;
|
||||
if (frac > 1.0) frac = 1.0;
|
||||
fp->fn(fp->userdata, frac);
|
||||
} else if (dlnow > 0) {
|
||||
fp->fn(fp->userdata, -1.0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
#else
|
||||
|
||||
int ota_net_init(void) { return 0; }
|
||||
void ota_net_shutdown(void) {}
|
||||
|
||||
#endif
|
||||
|
||||
int ota_net_download_buffer(const char *url, long timeout_ms, char **out, size_t *out_len,
|
||||
char *err, size_t err_len) {
|
||||
if (out) *out = NULL;
|
||||
if (out_len) *out_len = 0;
|
||||
if (!url || !out) {
|
||||
if (err && err_len) snprintf(err, err_len, "bad args");
|
||||
return -1;
|
||||
}
|
||||
#if defined(__SWITCH__)
|
||||
CURL *curl = curl_easy_init();
|
||||
if (!curl) {
|
||||
if (err && err_len) snprintf(err, err_len, "curl_easy_init failed");
|
||||
return -1;
|
||||
}
|
||||
struct mem_buf mem = {0};
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "gen1recomp-switch-ota");
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout_ms);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, timeout_ms);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_mem);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &mem);
|
||||
ota_net_configure_tls(curl);
|
||||
CURLcode rc = curl_easy_perform(curl);
|
||||
curl_easy_cleanup(curl);
|
||||
if (rc != CURLE_OK) {
|
||||
free(mem.data);
|
||||
if (err && err_len) snprintf(err, err_len, "%s", curl_easy_strerror(rc));
|
||||
return -1;
|
||||
}
|
||||
*out = mem.data;
|
||||
if (out_len) *out_len = mem.len;
|
||||
return 0;
|
||||
#else
|
||||
(void)timeout_ms;
|
||||
if (err && err_len)
|
||||
snprintf(err, err_len, "ota_net_download_buffer only available on __SWITCH__");
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
int ota_net_download_file(const char *url, const char *path, long timeout_ms, char *err,
|
||||
size_t err_len, ota_net_progress_fn progress, void *progress_ud) {
|
||||
if (!url || !path) {
|
||||
if (err && err_len) snprintf(err, err_len, "bad args");
|
||||
return -1;
|
||||
}
|
||||
#if defined(__SWITCH__)
|
||||
FILE *fp = fopen(path, "wb");
|
||||
if (!fp) {
|
||||
if (err && err_len) snprintf(err, err_len, "fopen failed: %s", path);
|
||||
return -1;
|
||||
}
|
||||
CURL *curl = curl_easy_init();
|
||||
if (!curl) {
|
||||
fclose(fp);
|
||||
if (err && err_len) snprintf(err, err_len, "curl_easy_init failed");
|
||||
return -1;
|
||||
}
|
||||
struct file_progress fp_cb = {progress, progress_ud};
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "gen1recomp-switch-ota");
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout_ms);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, timeout_ms);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_file);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
|
||||
ota_net_configure_tls(curl);
|
||||
if (progress) {
|
||||
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xfer_progress);
|
||||
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &fp_cb);
|
||||
}
|
||||
CURLcode rc = curl_easy_perform(curl);
|
||||
curl_easy_cleanup(curl);
|
||||
fclose(fp);
|
||||
if (rc != CURLE_OK) {
|
||||
if (err && err_len) snprintf(err, err_len, "%s", curl_easy_strerror(rc));
|
||||
return -1;
|
||||
}
|
||||
if (progress) progress(progress_ud, 1.0);
|
||||
return 0;
|
||||
#else
|
||||
(void)timeout_ms;
|
||||
(void)progress;
|
||||
(void)progress_ud;
|
||||
if (err && err_len)
|
||||
snprintf(err, err_len, "ota_net_download_file only available on __SWITCH__");
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
#include "ota_protocol.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef struct {
|
||||
int major;
|
||||
int minor;
|
||||
int patch;
|
||||
int ok;
|
||||
} semver_t;
|
||||
|
||||
static void set_reason(char *buf, size_t n, const char *r) {
|
||||
if (!buf || n == 0) return;
|
||||
snprintf(buf, n, "%s", r ? r : "");
|
||||
}
|
||||
|
||||
static int parse_semver(const char *s, semver_t *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
if (!s || !*s) return 0;
|
||||
if (s[0] == 'v' || s[0] == 'V') s++;
|
||||
int maj = 0, min = 0, pat = 0;
|
||||
char trail = 0;
|
||||
if (sscanf(s, "%d.%d.%d%c", &maj, &min, &pat, &trail) != 3) return 0;
|
||||
if (maj < 0 || min < 0 || pat < 0) return 0;
|
||||
out->major = maj;
|
||||
out->minor = min;
|
||||
out->patch = pat;
|
||||
out->ok = 1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int ota_compare_semver(const char *a, const char *b) {
|
||||
semver_t pa, pb;
|
||||
int oa = parse_semver(a, &pa);
|
||||
int ob = parse_semver(b, &pb);
|
||||
if (!oa && !ob) return 0;
|
||||
if (!oa) return -1;
|
||||
if (!ob) return 1;
|
||||
if (pa.major != pb.major) return pa.major < pb.major ? -1 : 1;
|
||||
if (pa.minor != pb.minor) return pa.minor < pb.minor ? -1 : 1;
|
||||
if (pa.patch != pb.patch) return pa.patch < pb.patch ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ota_is_ota_asset_name(const char *name) {
|
||||
int maj = 0, min = 0, pat = 0;
|
||||
if (!name) return 0;
|
||||
/* Unified SD/OTA asset: gen1recomp-X.Y.Z-switch.zip */
|
||||
size_t len = strlen(name);
|
||||
const char *suffix = "-switch.zip";
|
||||
size_t slen = strlen(suffix);
|
||||
if (len <= slen) return 0;
|
||||
if (strcmp(name + len - slen, suffix) != 0) return 0;
|
||||
if (strncmp(name, "gen1recomp-", 11) != 0) return 0;
|
||||
if (sscanf(name + 11, "%d.%d.%d", &maj, &min, &pat) != 3) return 0;
|
||||
char rebuilt[128];
|
||||
snprintf(rebuilt, sizeof(rebuilt), "gen1recomp-%d.%d.%d-switch.zip", maj, min, pat);
|
||||
return strcmp(name, rebuilt) == 0;
|
||||
}
|
||||
|
||||
int ota_version_from_ota_asset(const char *name, char *out, size_t out_len) {
|
||||
int maj = 0, min = 0, pat = 0;
|
||||
if (!out || out_len == 0) return 0;
|
||||
out[0] = '\0';
|
||||
if (!ota_is_ota_asset_name(name)) return 0;
|
||||
if (sscanf(name + 11, "%d.%d.%d", &maj, &min, &pat) != 3) return 0;
|
||||
snprintf(out, out_len, "%d.%d.%d", maj, min, pat);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static const char *find_json_string(const char *json, const char *key, char *out, size_t out_len) {
|
||||
char pattern[128];
|
||||
snprintf(pattern, sizeof(pattern), "\"%s\"", key);
|
||||
const char *p = strstr(json, pattern);
|
||||
if (!p) return NULL;
|
||||
p = strchr(p + strlen(pattern), ':');
|
||||
if (!p) return NULL;
|
||||
p++;
|
||||
while (*p && isspace((unsigned char)*p)) p++;
|
||||
if (*p != '"') return NULL;
|
||||
p++;
|
||||
size_t i = 0;
|
||||
while (*p && *p != '"' && i + 1 < out_len) {
|
||||
if (*p == '\\' && p[1]) {
|
||||
p++;
|
||||
out[i++] = *p++;
|
||||
continue;
|
||||
}
|
||||
out[i++] = *p++;
|
||||
}
|
||||
out[i] = '\0';
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Opening { of the JSON object that contains pos (walk backward). */
|
||||
static const char *find_json_object_start(const char *pos, const char *json_start) {
|
||||
if (!pos || !json_start || pos < json_start) return NULL;
|
||||
int depth = 0;
|
||||
const char *p = pos;
|
||||
while (p >= json_start) {
|
||||
if (*p == '}') depth++;
|
||||
else if (*p == '{') {
|
||||
if (depth == 0) return p;
|
||||
depth--;
|
||||
}
|
||||
p--;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Pointer just past the closing } of the object that starts at object_start. */
|
||||
static const char *find_json_object_end(const char *object_start) {
|
||||
if (!object_start || *object_start != '{') return NULL;
|
||||
int depth = 1;
|
||||
const char *p = object_start + 1;
|
||||
while (*p) {
|
||||
if (*p == '{') depth++;
|
||||
else if (*p == '}') {
|
||||
depth--;
|
||||
if (depth == 0) return p + 1;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int ota_parse_release(const char *json_text, ota_release_t *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
if (!json_text || !*json_text) {
|
||||
set_reason(out->reason, sizeof(out->reason), "empty_json");
|
||||
return 0;
|
||||
}
|
||||
if (!find_json_string(json_text, "tag_name", out->tag, sizeof(out->tag))) {
|
||||
set_reason(out->reason, sizeof(out->reason), "missing_tag");
|
||||
return 0;
|
||||
}
|
||||
semver_t sv;
|
||||
if (!parse_semver(out->tag, &sv)) {
|
||||
set_reason(out->reason, sizeof(out->reason), "bad_tag");
|
||||
return 0;
|
||||
}
|
||||
snprintf(out->version, sizeof(out->version), "%d.%d.%d", sv.major, sv.minor, sv.patch);
|
||||
|
||||
/* Scan for OTA asset name then browser_download_url inside the same asset object. */
|
||||
const char *cursor = json_text;
|
||||
while ((cursor = strstr(cursor, "\"name\"")) != NULL) {
|
||||
char name[128];
|
||||
if (!find_json_string(cursor, "name", name, sizeof(name))) {
|
||||
cursor += 6;
|
||||
continue;
|
||||
}
|
||||
if (!ota_is_ota_asset_name(name)) {
|
||||
cursor += 6;
|
||||
continue;
|
||||
}
|
||||
const char *asset_start = find_json_object_start(cursor, json_text);
|
||||
const char *asset_end =
|
||||
asset_start ? find_json_object_end(asset_start) : NULL;
|
||||
if (!asset_start || !asset_end || asset_end <= cursor) {
|
||||
cursor += 6;
|
||||
continue;
|
||||
}
|
||||
char url[512];
|
||||
const char *u = NULL;
|
||||
const char *scan = cursor;
|
||||
while (scan < asset_end &&
|
||||
(scan = strstr(scan, "\"browser_download_url\"")) != NULL && scan < asset_end) {
|
||||
if (find_json_string(scan, "browser_download_url", url, sizeof(url))) {
|
||||
u = url;
|
||||
break;
|
||||
}
|
||||
scan += 21;
|
||||
}
|
||||
if (!u || !*u) {
|
||||
cursor += 6;
|
||||
continue;
|
||||
}
|
||||
snprintf(out->asset_name, sizeof(out->asset_name), "%s", name);
|
||||
snprintf(out->download_url, sizeof(out->download_url), "%s", u);
|
||||
out->ok = 1;
|
||||
return 1;
|
||||
}
|
||||
set_reason(out->reason, sizeof(out->reason), "missing_ota_asset");
|
||||
return 0;
|
||||
}
|
||||
|
||||
void ota_decide_update(const char *installed_version, const ota_release_t *release,
|
||||
ota_decision_t *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
semver_t inst;
|
||||
if (!parse_semver(installed_version, &inst)) {
|
||||
snprintf(out->status, sizeof(out->status), "error");
|
||||
set_reason(out->reason, sizeof(out->reason), "bad_installed_version");
|
||||
return;
|
||||
}
|
||||
if (!release || !release->ok) {
|
||||
snprintf(out->status, sizeof(out->status), "error");
|
||||
set_reason(out->reason, sizeof(out->reason), "bad_release");
|
||||
return;
|
||||
}
|
||||
if (ota_compare_semver(release->version, installed_version) <= 0) {
|
||||
snprintf(out->status, sizeof(out->status), "uptodate");
|
||||
snprintf(out->version, sizeof(out->version), "%s", installed_version);
|
||||
return;
|
||||
}
|
||||
snprintf(out->status, sizeof(out->status), "available");
|
||||
snprintf(out->version, sizeof(out->version), "%s", release->version);
|
||||
snprintf(out->asset_name, sizeof(out->asset_name), "%s", release->asset_name);
|
||||
snprintf(out->download_url, sizeof(out->download_url), "%s", release->download_url);
|
||||
}
|
||||
|
||||
int ota_lookup_sum(const char *sums_text, const char *asset_name, char *out_hex, size_t out_len) {
|
||||
if (out_hex && out_len) out_hex[0] = '\0';
|
||||
if (!sums_text || !asset_name || !out_hex || out_len < 65) return 0;
|
||||
const char *p = sums_text;
|
||||
while (*p) {
|
||||
char hex[96];
|
||||
char name[256];
|
||||
int n = 0;
|
||||
if (sscanf(p, "%95s %255s%n", hex, name, &n) >= 2 && n > 0) {
|
||||
const char *nm = name;
|
||||
if (nm[0] == '*') nm++;
|
||||
if (strncmp(nm, "./", 2) == 0) nm += 2;
|
||||
if (strcmp(nm, asset_name) == 0) {
|
||||
/* normalize hex to lowercase */
|
||||
size_t i;
|
||||
for (i = 0; hex[i] && i + 1 < out_len; i++)
|
||||
out_hex[i] = (char)tolower((unsigned char)hex[i]);
|
||||
out_hex[i] = '\0';
|
||||
return 1;
|
||||
}
|
||||
p += n;
|
||||
while (*p && *p != '\n') p++;
|
||||
if (*p == '\n') p++;
|
||||
continue;
|
||||
}
|
||||
while (*p && *p != '\n') p++;
|
||||
if (*p == '\n') p++;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void ota_verify_sha256(const char *asset_name, const char *actual_hex, const char *sums_text,
|
||||
ota_verify_t *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
if (!asset_name || !*asset_name) {
|
||||
set_reason(out->reason, sizeof(out->reason), "bad_asset_name");
|
||||
return;
|
||||
}
|
||||
if (!sums_text) {
|
||||
set_reason(out->reason, sizeof(out->reason), "missing_sums");
|
||||
return;
|
||||
}
|
||||
char expected[96];
|
||||
if (!ota_lookup_sum(sums_text, asset_name, expected, sizeof(expected))) {
|
||||
set_reason(out->reason, sizeof(out->reason), "sum_not_found");
|
||||
return;
|
||||
}
|
||||
if (!actual_hex || !*actual_hex) {
|
||||
set_reason(out->reason, sizeof(out->reason), "missing_actual_hash");
|
||||
return;
|
||||
}
|
||||
char actual[96];
|
||||
size_t i;
|
||||
for (i = 0; actual_hex[i] && i + 1 < sizeof(actual); i++)
|
||||
actual[i] = (char)tolower((unsigned char)actual_hex[i]);
|
||||
actual[i] = '\0';
|
||||
if (strcmp(actual, expected) != 0) {
|
||||
set_reason(out->reason, sizeof(out->reason), "hash_mismatch");
|
||||
return;
|
||||
}
|
||||
out->ok = 1;
|
||||
}
|
||||
|
||||
void ota_plan_atomic_apply(const char *install_dir, const char *verified_temp,
|
||||
ota_apply_plan_t *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
if (!install_dir || !*install_dir) install_dir = OTA_INSTALL_DIR;
|
||||
if (!verified_temp) verified_temp = "";
|
||||
snprintf(out->game_nro, sizeof(out->game_nro), "%s/%s", install_dir, OTA_GAME_NRO_NAME);
|
||||
snprintf(out->part_path, sizeof(out->part_path), "%s.part", out->game_nro);
|
||||
snprintf(out->launcher_nro, sizeof(out->launcher_nro), "%s/%s", install_dir,
|
||||
OTA_LAUNCHER_NRO_NAME);
|
||||
snprintf(out->launcher_part, sizeof(out->launcher_part), "%s.part", out->launcher_nro);
|
||||
snprintf(out->next_load, sizeof(out->next_load), "%s", out->game_nro);
|
||||
/* verified_temp is the game NRO; launcher replace uses a sibling verified path */
|
||||
snprintf(out->steps[0], sizeof(out->steps[0]), "copy_to_part:%s->%s", verified_temp,
|
||||
out->part_path);
|
||||
snprintf(out->steps[1], sizeof(out->steps[1]), "rename:%s->%s", out->part_path, out->game_nro);
|
||||
snprintf(out->steps[2], sizeof(out->steps[2]), "copy_to_part:launcher->%s", out->launcher_part);
|
||||
snprintf(out->steps[3], sizeof(out->steps[3]), "rename:%s->%s", out->launcher_part,
|
||||
out->launcher_nro);
|
||||
snprintf(out->steps[4], sizeof(out->steps[4]), "env_set_next_load:%s", out->game_nro);
|
||||
snprintf(out->preserve, sizeof(out->preserve), "%s/%s", install_dir, OTA_SAVE_DIR_NAME);
|
||||
snprintf(out->forbidden_delete, sizeof(out->forbidden_delete), "delete:%s/%s", install_dir,
|
||||
OTA_SAVE_DIR_NAME);
|
||||
snprintf(out->forbidden_direct, sizeof(out->forbidden_direct), "write_direct:%s", out->game_nro);
|
||||
}
|
||||
|
||||
void ota_offline_policy(double elapsed_sec, const ota_offline_events_t *events, ota_offline_t *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
ota_offline_events_t ev = {0, -1, 0};
|
||||
if (events) ev = *events;
|
||||
if (ev.user_skip) {
|
||||
snprintf(out->action, sizeof(out->action), "play_installed");
|
||||
set_reason(out->reason, sizeof(out->reason), "user_skip");
|
||||
snprintf(out->message, sizeof(out->message), "update skipped");
|
||||
return;
|
||||
}
|
||||
if (ev.api_error || ev.network_ok == 0) {
|
||||
snprintf(out->action, sizeof(out->action), "play_installed");
|
||||
set_reason(out->reason, sizeof(out->reason), "offline_or_error");
|
||||
snprintf(out->message, sizeof(out->message),
|
||||
"offline or update check failed — play installed version");
|
||||
return;
|
||||
}
|
||||
if (elapsed_sec >= (double)OTA_CHECK_TIMEOUT_SEC) {
|
||||
snprintf(out->action, sizeof(out->action), "play_installed");
|
||||
set_reason(out->reason, sizeof(out->reason), "timeout");
|
||||
snprintf(out->message, sizeof(out->message), "update check timed out after %ds",
|
||||
OTA_CHECK_TIMEOUT_SEC);
|
||||
return;
|
||||
}
|
||||
snprintf(out->action, sizeof(out->action), "keep_checking");
|
||||
set_reason(out->reason, sizeof(out->reason), "in_flight");
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
/*
|
||||
* Switch OTA UI — matches in-game launcher language (Theme.lua):
|
||||
* black field, RGB version rail, flat yellow/white buttons, white ink.
|
||||
* Quiet until called. Uses framebuffer + 8x8 font + optional romfs logo.
|
||||
*/
|
||||
#include "ota_ui.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
#include <switch.h>
|
||||
|
||||
#include "../third_party/font8x8_basic.h"
|
||||
|
||||
#define OTA_LOGO_ROMFS "romfs:/logo.rgba"
|
||||
|
||||
#define FB_W 1280
|
||||
#define FB_H 720
|
||||
|
||||
#define TITLE_Y 200
|
||||
#define LINE_TITLE 44
|
||||
#define LINE_BODY 36
|
||||
#define LINE_SMALL 28
|
||||
#define WRAP_MAX_PX 720
|
||||
|
||||
/* Theme.PAL (0-255) -> RGBA8 */
|
||||
#define COL_BG RGBA8_MAXALPHA(0, 0, 0)
|
||||
#define COL_INK RGBA8_MAXALPHA(255, 255, 255)
|
||||
#define COL_DETAIL RGBA8_MAXALPHA(200, 200, 200)
|
||||
#define COL_MUTED RGBA8_MAXALPHA(150, 150, 150)
|
||||
#define COL_INVERSE RGBA8_MAXALPHA(0, 0, 0)
|
||||
#define COL_YELLOW RGBA8_MAXALPHA(255, 214, 0)
|
||||
#define COL_GREEN RGBA8_MAXALPHA(0, 255, 140)
|
||||
#define COL_RAIL_R RGBA8_MAXALPHA(255, 60, 72)
|
||||
#define COL_RAIL_B RGBA8_MAXALPHA(70, 150, 255)
|
||||
#define COL_RAIL_G RGBA8_MAXALPHA(255, 203, 5)
|
||||
#define COL_LINE RGBA8_MAXALPHA(90, 90, 90)
|
||||
#define COL_RAISED RGBA8_MAXALPHA(20, 20, 20)
|
||||
|
||||
static int g_ready = 0;
|
||||
static Framebuffer g_fb;
|
||||
static PadState g_pad;
|
||||
static u32 *g_logo = NULL;
|
||||
static int g_logo_w = 0, g_logo_h = 0;
|
||||
|
||||
static void fill_rect(u32 *fb, u32 stride_px, int x, int y, int w, int h, u32 color) {
|
||||
if (w <= 0 || h <= 0) return;
|
||||
if (x < 0) {
|
||||
w += x;
|
||||
x = 0;
|
||||
}
|
||||
if (y < 0) {
|
||||
h += y;
|
||||
y = 0;
|
||||
}
|
||||
if (x + w > FB_W) w = FB_W - x;
|
||||
if (y + h > FB_H) h = FB_H - y;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
for (int row = 0; row < h; row++) {
|
||||
u32 *line = fb + (y + row) * stride_px + x;
|
||||
for (int col = 0; col < w; col++) line[col] = color;
|
||||
}
|
||||
}
|
||||
|
||||
/* Normalize UTF-8 punctuation to ASCII; drop other non-ASCII bytes. */
|
||||
static size_t ota_ui_sanitize_ascii(const char *src, char *dst, size_t dst_len) {
|
||||
if (!dst || dst_len == 0) return 0;
|
||||
dst[0] = '\0';
|
||||
if (!src) return 0;
|
||||
size_t w = 0;
|
||||
for (size_t i = 0; src[i] && w + 1 < dst_len;) {
|
||||
unsigned char c = (unsigned char)src[i];
|
||||
if (c < 0x80) {
|
||||
dst[w++] = (char)c;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c == 0xE2 && src[i + 1] && src[i + 2]) {
|
||||
unsigned char b2 = (unsigned char)src[i + 1];
|
||||
unsigned char b3 = (unsigned char)src[i + 2];
|
||||
if (b2 == 0x80 && b3 == 0xA6) {
|
||||
if (w + 3 < dst_len) {
|
||||
dst[w++] = '.';
|
||||
dst[w++] = '.';
|
||||
dst[w++] = '.';
|
||||
}
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
if (b2 == 0x80 && (b3 == 0x94 || b3 == 0x93)) {
|
||||
dst[w++] = '-';
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
if (b2 == 0x80 && (b3 == 0x98 || b3 == 0x99)) {
|
||||
dst[w++] = '\'';
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((c & 0xE0) == 0xC0) i += 2;
|
||||
else if ((c & 0xF0) == 0xE0) i += 3;
|
||||
else if ((c & 0xF8) == 0xF0) i += 4;
|
||||
else i++;
|
||||
}
|
||||
dst[w] = '\0';
|
||||
return w;
|
||||
}
|
||||
|
||||
static void draw_glyph(u32 *fb, u32 stride_px, int x, int y, char ch, u32 color, int scale) {
|
||||
unsigned char c = (unsigned char)ch;
|
||||
if (c > 127) return;
|
||||
const char *bits = font8x8_basic[c];
|
||||
for (int row = 0; row < 8; row++) {
|
||||
unsigned char line = (unsigned char)bits[row];
|
||||
for (int col = 0; col < 8; col++) {
|
||||
if (line & (1 << col)) {
|
||||
fill_rect(fb, stride_px, x + col * scale, y + row * scale, scale, scale, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static int text_width(const char *s, int scale) {
|
||||
if (!s) return 0;
|
||||
return (int)strlen(s) * 8 * scale;
|
||||
}
|
||||
|
||||
static void draw_text(u32 *fb, u32 stride_px, int x, int y, const char *s, u32 color, int scale) {
|
||||
if (!s) return;
|
||||
char buf[512];
|
||||
ota_ui_sanitize_ascii(s, buf, sizeof(buf));
|
||||
int cx = x;
|
||||
for (const char *p = buf; *p; p++) {
|
||||
if (*p == '\n') {
|
||||
cx = x;
|
||||
y += 8 * scale + 4;
|
||||
continue;
|
||||
}
|
||||
draw_glyph(fb, stride_px, cx, y, *p, color, scale);
|
||||
cx += 8 * scale;
|
||||
}
|
||||
}
|
||||
|
||||
static void draw_text_centered(u32 *fb, u32 stride_px, int y, const char *s, u32 color, int scale) {
|
||||
char buf[512];
|
||||
ota_ui_sanitize_ascii(s, buf, sizeof(buf));
|
||||
int w = text_width(buf, scale);
|
||||
draw_text(fb, stride_px, (FB_W - w) / 2, y, buf, color, scale);
|
||||
}
|
||||
|
||||
static int draw_text_wrapped_centered(u32 *fb, u32 stride_px, int y, const char *s, u32 color,
|
||||
int scale, int max_px) {
|
||||
if (!s || !*s) return y;
|
||||
char buf[512];
|
||||
ota_ui_sanitize_ascii(s, buf, sizeof(buf));
|
||||
int char_w = 8 * scale;
|
||||
int max_chars = max_px / char_w;
|
||||
if (max_chars < 8) max_chars = 8;
|
||||
|
||||
char line[128];
|
||||
int line_len = 0;
|
||||
const char *word = buf;
|
||||
int lines = 0;
|
||||
|
||||
while (*word) {
|
||||
while (*word == ' ') word++;
|
||||
if (!*word) break;
|
||||
|
||||
const char *end = word;
|
||||
while (*end && *end != ' ' && *end != '\n') end++;
|
||||
int wlen = (int)(end - word);
|
||||
|
||||
if (line_len > 0 && line_len + 1 + wlen > max_chars) {
|
||||
line[line_len] = '\0';
|
||||
draw_text_centered(fb, stride_px, y, line, color, scale);
|
||||
y += 8 * scale + 4;
|
||||
lines++;
|
||||
line_len = 0;
|
||||
}
|
||||
|
||||
if (wlen > max_chars) {
|
||||
if (line_len > 0) {
|
||||
line[line_len] = '\0';
|
||||
draw_text_centered(fb, stride_px, y, line, color, scale);
|
||||
y += 8 * scale + 4;
|
||||
lines++;
|
||||
line_len = 0;
|
||||
}
|
||||
while (wlen > 0) {
|
||||
int chunk = wlen > max_chars ? max_chars : wlen;
|
||||
memcpy(line, word, (size_t)chunk);
|
||||
line[chunk] = '\0';
|
||||
draw_text_centered(fb, stride_px, y, line, color, scale);
|
||||
y += 8 * scale + 4;
|
||||
lines++;
|
||||
word += chunk;
|
||||
wlen -= chunk;
|
||||
}
|
||||
word = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line_len > 0) line[line_len++] = ' ';
|
||||
memcpy(line + line_len, word, (size_t)wlen);
|
||||
line_len += wlen;
|
||||
line[line_len] = '\0';
|
||||
word = (*end == '\n') ? end + 1 : end;
|
||||
if (*(end - 1) == '\n' || *end == '\n') {
|
||||
draw_text_centered(fb, stride_px, y, line, color, scale);
|
||||
y += 8 * scale + 4;
|
||||
lines++;
|
||||
line_len = 0;
|
||||
if (*end == '\n') word = end + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (line_len > 0) {
|
||||
draw_text_centered(fb, stride_px, y, line, color, scale);
|
||||
y += 8 * scale + 4;
|
||||
lines++;
|
||||
}
|
||||
if (lines == 0) return y;
|
||||
return y;
|
||||
}
|
||||
|
||||
static void draw_rail(u32 *fb, u32 stride_px) {
|
||||
int h = 6;
|
||||
int third = FB_W / 3;
|
||||
fill_rect(fb, stride_px, 0, 0, third, h, COL_RAIL_R);
|
||||
fill_rect(fb, stride_px, third, 0, third, h, COL_RAIL_B);
|
||||
fill_rect(fb, stride_px, third * 2, 0, FB_W - third * 2, h, COL_RAIL_G);
|
||||
}
|
||||
|
||||
static void blit_logo(u32 *fb, u32 stride_px, int dst_x, int dst_y, int max_w) {
|
||||
if (!g_logo || g_logo_w <= 0 || g_logo_h <= 0) return;
|
||||
int dw = g_logo_w;
|
||||
int dh = g_logo_h;
|
||||
if (dw > max_w) {
|
||||
dh = dh * max_w / dw;
|
||||
dw = max_w;
|
||||
}
|
||||
for (int y = 0; y < dh; y++) {
|
||||
int sy = y * g_logo_h / dh;
|
||||
for (int x = 0; x < dw; x++) {
|
||||
int sx = x * g_logo_w / dw;
|
||||
u32 px = g_logo[sy * g_logo_w + sx];
|
||||
u8 a = (px >> 24) & 0xff;
|
||||
if (a < 16) continue;
|
||||
int dx = dst_x + x;
|
||||
int dy = dst_y + y;
|
||||
if (dx < 0 || dy < 0 || dx >= FB_W || dy >= FB_H) continue;
|
||||
fb[dy * stride_px + dx] = px | 0xff000000u;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void load_logo(void) {
|
||||
if (g_logo) return;
|
||||
FILE *fp = fopen(OTA_LOGO_ROMFS, "rb");
|
||||
if (!fp) return;
|
||||
|
||||
unsigned char header[8];
|
||||
if (fread(header, 1, sizeof(header), fp) != sizeof(header)) {
|
||||
fclose(fp);
|
||||
return;
|
||||
}
|
||||
|
||||
int w = (int)(header[0] | (header[1] << 8) | (header[2] << 16) | (header[3] << 24));
|
||||
int h = (int)(header[4] | (header[5] << 8) | (header[6] << 16) | (header[7] << 24));
|
||||
if (w <= 0 || h <= 0 || w > 2048 || h > 2048) {
|
||||
fclose(fp);
|
||||
return;
|
||||
}
|
||||
|
||||
size_t nbytes = (size_t)w * (size_t)h * 4u;
|
||||
unsigned char *data = (unsigned char *)malloc(nbytes);
|
||||
if (!data) {
|
||||
fclose(fp);
|
||||
return;
|
||||
}
|
||||
if (fread(data, 1, nbytes, fp) != nbytes) {
|
||||
free(data);
|
||||
fclose(fp);
|
||||
return;
|
||||
}
|
||||
fclose(fp);
|
||||
|
||||
g_logo = (u32 *)malloc((size_t)w * (size_t)h * sizeof(u32));
|
||||
if (!g_logo) {
|
||||
free(data);
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < w * h; i++) {
|
||||
unsigned char *p = data + (size_t)i * 4u;
|
||||
g_logo[i] = RGBA8(p[0], p[1], p[2], p[3]);
|
||||
}
|
||||
free(data);
|
||||
g_logo_w = w;
|
||||
g_logo_h = h;
|
||||
}
|
||||
|
||||
static int ui_ensure(void) {
|
||||
if (g_ready) return 1;
|
||||
NWindow *win = nwindowGetDefault();
|
||||
if (R_FAILED(framebufferCreate(&g_fb, win, FB_W, FB_H, PIXEL_FORMAT_RGBA_8888, 2))) return 0;
|
||||
framebufferMakeLinear(&g_fb);
|
||||
padInitializeDefault(&g_pad);
|
||||
load_logo();
|
||||
g_ready = 1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
void ota_ui_shutdown(void) {
|
||||
if (!g_ready) return;
|
||||
framebufferClose(&g_fb);
|
||||
free(g_logo);
|
||||
g_logo = NULL;
|
||||
g_logo_w = g_logo_h = 0;
|
||||
g_ready = 0;
|
||||
}
|
||||
|
||||
static void draw_button(u32 *fb, u32 stride_px, int x, int y, int w, int h, u32 fill, u32 ink,
|
||||
const char *label, int focused) {
|
||||
fill_rect(fb, stride_px, x, y, w, h, fill);
|
||||
if (focused) {
|
||||
fill_rect(fb, stride_px, x - 3, y - 3, w + 6, 2, COL_INK);
|
||||
fill_rect(fb, stride_px, x - 3, y + h + 1, w + 6, 2, COL_INK);
|
||||
fill_rect(fb, stride_px, x - 3, y - 3, 2, h + 6, COL_INK);
|
||||
fill_rect(fb, stride_px, x + w + 1, y - 3, 2, h + 6, COL_INK);
|
||||
} else {
|
||||
fill_rect(fb, stride_px, x, y, w, 1, COL_LINE);
|
||||
fill_rect(fb, stride_px, x, y + h - 1, w, 1, COL_LINE);
|
||||
fill_rect(fb, stride_px, x, y, 1, h, COL_LINE);
|
||||
fill_rect(fb, stride_px, x + w - 1, y, 1, h, COL_LINE);
|
||||
}
|
||||
int scale = 3;
|
||||
char lbl[128];
|
||||
ota_ui_sanitize_ascii(label, lbl, sizeof(lbl));
|
||||
int max_tw = w - 24;
|
||||
int tw = text_width(lbl, scale);
|
||||
while (scale > 2 && tw > max_tw) {
|
||||
scale--;
|
||||
tw = text_width(lbl, scale);
|
||||
}
|
||||
int tx = x + (w - tw) / 2;
|
||||
int ty = y + (h - 8 * scale) / 2;
|
||||
draw_text(fb, stride_px, tx, ty, lbl, ink, scale);
|
||||
}
|
||||
|
||||
static void draw_chrome(u32 *fb, u32 stride_px) {
|
||||
fill_rect(fb, stride_px, 0, 0, FB_W, FB_H, COL_BG);
|
||||
draw_rail(fb, stride_px);
|
||||
int logo_max = 320;
|
||||
int logo_h = g_logo ? (g_logo_h * logo_max / g_logo_w) : 40;
|
||||
if (logo_h > 90) logo_h = 90;
|
||||
int logo_y = 48;
|
||||
if (g_logo) {
|
||||
int logo_w = logo_max;
|
||||
if (g_logo_w * logo_h / g_logo_h < logo_max) logo_w = g_logo_w * logo_h / g_logo_h;
|
||||
blit_logo(fb, stride_px, (FB_W - logo_w) / 2, logo_y, logo_max);
|
||||
} else {
|
||||
draw_text_centered(fb, stride_px, logo_y + 16, "gen1recomp", COL_INK, 4);
|
||||
}
|
||||
}
|
||||
|
||||
static void present(void (*paint)(u32 *fb, u32 stride_px, void *ctx), void *ctx) {
|
||||
if (!ui_ensure()) return;
|
||||
u32 stride = 0;
|
||||
u32 *fb = (u32 *)framebufferBegin(&g_fb, &stride);
|
||||
u32 stride_px = stride / sizeof(u32);
|
||||
paint(fb, stride_px, ctx);
|
||||
framebufferEnd(&g_fb);
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
const char *installed;
|
||||
const char *latest;
|
||||
int focus; /* 0 = Update, 1 = Play */
|
||||
} prompt_ctx_t;
|
||||
|
||||
static void paint_prompt(u32 *fb, u32 stride_px, void *ctx) {
|
||||
prompt_ctx_t *p = (prompt_ctx_t *)ctx;
|
||||
draw_chrome(fb, stride_px);
|
||||
|
||||
int y = TITLE_Y - 20;
|
||||
draw_text_centered(fb, stride_px, y, "Update available", COL_INK, 3);
|
||||
y += LINE_TITLE;
|
||||
|
||||
char line[96];
|
||||
snprintf(line, sizeof(line), "v%s to v%s", p->installed ? p->installed : "?",
|
||||
p->latest ? p->latest : "?");
|
||||
draw_text_centered(fb, stride_px, y, line, COL_YELLOW, 3);
|
||||
y += LINE_TITLE + 8;
|
||||
|
||||
draw_text_centered(fb, stride_px, y, "Your saves stay on this console.", COL_MUTED, 2);
|
||||
y += LINE_BODY + 28;
|
||||
|
||||
int bw = 600;
|
||||
int bh = 64;
|
||||
int bx = (FB_W - bw) / 2;
|
||||
draw_button(fb, stride_px, bx, y, bw, bh, COL_YELLOW, COL_INVERSE, "(A) Update", p->focus == 0);
|
||||
y += bh + 24;
|
||||
draw_button(fb, stride_px, bx, y, bw, bh, COL_INK, COL_INVERSE, "(B) Play without update",
|
||||
p->focus == 1);
|
||||
}
|
||||
|
||||
int ota_ui_prompt_update(const char *installed, const char *latest) {
|
||||
if (!ui_ensure()) return 0;
|
||||
prompt_ctx_t ctx = {installed, latest, 0};
|
||||
while (appletMainLoop()) {
|
||||
present(paint_prompt, &ctx);
|
||||
padUpdate(&g_pad);
|
||||
u64 k = padGetButtonsDown(&g_pad);
|
||||
if (k & HidNpadButton_A) {
|
||||
int do_update = (ctx.focus == 0);
|
||||
if (!do_update) ota_ui_shutdown();
|
||||
return do_update;
|
||||
}
|
||||
if (k & HidNpadButton_B) {
|
||||
ota_ui_shutdown();
|
||||
return 0;
|
||||
}
|
||||
if (k & (HidNpadButton_Up | HidNpadButton_Down | HidNpadButton_Left | HidNpadButton_Right)) {
|
||||
ctx.focus = 1 - ctx.focus;
|
||||
}
|
||||
svcSleepThread(16000000ULL);
|
||||
}
|
||||
ota_ui_shutdown();
|
||||
return 0;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
const char *title;
|
||||
const char *detail;
|
||||
float progress;
|
||||
int show_bar;
|
||||
} status_ctx_t;
|
||||
|
||||
static void paint_progress_bar(u32 *fb, u32 stride_px, int y, float t) {
|
||||
int bw = 520;
|
||||
int bh = 18;
|
||||
int bx = (FB_W - bw) / 2;
|
||||
if (t < 0.f) t = 0.f;
|
||||
if (t > 1.f) t = 1.f;
|
||||
fill_rect(fb, stride_px, bx, y, bw, bh, COL_RAISED);
|
||||
fill_rect(fb, stride_px, bx, y, bw, 1, COL_LINE);
|
||||
fill_rect(fb, stride_px, bx, y + bh - 1, bw, 1, COL_LINE);
|
||||
fill_rect(fb, stride_px, bx, y, 1, bh, COL_LINE);
|
||||
fill_rect(fb, stride_px, bx + bw - 1, y, 1, bh, COL_LINE);
|
||||
int fw = (int)(bw * t);
|
||||
if (fw > 0) fill_rect(fb, stride_px, bx, y, fw, bh, COL_GREEN);
|
||||
}
|
||||
|
||||
static void paint_status(u32 *fb, u32 stride_px, void *ctx) {
|
||||
status_ctx_t *s = (status_ctx_t *)ctx;
|
||||
draw_chrome(fb, stride_px);
|
||||
int y = TITLE_Y;
|
||||
draw_text_centered(fb, stride_px, y, s->title ? s->title : "", COL_INK, 3);
|
||||
y += LINE_TITLE;
|
||||
if (s->detail && s->detail[0]) {
|
||||
y = draw_text_wrapped_centered(fb, stride_px, y, s->detail, COL_DETAIL, 2, WRAP_MAX_PX);
|
||||
y += LINE_SMALL;
|
||||
}
|
||||
if (s->show_bar) paint_progress_bar(fb, stride_px, y, s->progress);
|
||||
}
|
||||
|
||||
void ota_ui_show_status(const char *title, const char *detail) {
|
||||
status_ctx_t s = {title, detail, 0.f, 0};
|
||||
present(paint_status, &s);
|
||||
}
|
||||
|
||||
void ota_ui_show_progress(const char *title, const char *detail, float progress01) {
|
||||
status_ctx_t s = {title, detail, progress01, 1};
|
||||
present(paint_status, &s);
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
const char *title;
|
||||
const char *line1;
|
||||
const char *line2;
|
||||
const char *line3;
|
||||
} alert_ctx_t;
|
||||
|
||||
static void paint_alert(u32 *fb, u32 stride_px, void *ctx) {
|
||||
alert_ctx_t *a = (alert_ctx_t *)ctx;
|
||||
draw_chrome(fb, stride_px);
|
||||
int y = TITLE_Y;
|
||||
draw_text_centered(fb, stride_px, y, a->title ? a->title : "", COL_YELLOW, 3);
|
||||
y += LINE_TITLE;
|
||||
if (a->line1 && a->line1[0])
|
||||
y = draw_text_wrapped_centered(fb, stride_px, y, a->line1, COL_DETAIL, 2, WRAP_MAX_PX);
|
||||
y += LINE_SMALL;
|
||||
if (a->line2 && a->line2[0])
|
||||
y = draw_text_wrapped_centered(fb, stride_px, y, a->line2, COL_MUTED, 2, WRAP_MAX_PX);
|
||||
y += LINE_SMALL;
|
||||
if (a->line3 && a->line3[0])
|
||||
y = draw_text_wrapped_centered(fb, stride_px, y, a->line3, COL_MUTED, 2, WRAP_MAX_PX);
|
||||
y += LINE_BODY + 16;
|
||||
int bw = 360;
|
||||
int bh = 56;
|
||||
draw_button(fb, stride_px, (FB_W - bw) / 2, y, bw, bh, COL_INK, COL_INVERSE, "(B) Continue", 1);
|
||||
}
|
||||
|
||||
void ota_ui_alert(const char *title, const char *line1, const char *line2) {
|
||||
if (!ui_ensure()) return;
|
||||
alert_ctx_t a = {title, line1, line2, NULL};
|
||||
while (appletMainLoop()) {
|
||||
present(paint_alert, &a);
|
||||
padUpdate(&g_pad);
|
||||
if (padGetButtonsDown(&g_pad) & HidNpadButton_B) break;
|
||||
svcSleepThread(16000000ULL);
|
||||
}
|
||||
ota_ui_shutdown();
|
||||
}
|
||||
|
||||
void ota_ui_alert_error(const char *title, const char *friendly, const char *technical,
|
||||
const char *installed_version) {
|
||||
if (!ui_ensure()) return;
|
||||
char footer[96];
|
||||
snprintf(footer, sizeof(footer), "Playing installed version (v%s).",
|
||||
installed_version && installed_version[0] ? installed_version : "?");
|
||||
char tech[256];
|
||||
if (technical && technical[0]) {
|
||||
ota_ui_sanitize_ascii(technical, tech, sizeof(tech));
|
||||
if (strlen(tech) > 80) tech[80] = '\0';
|
||||
} else {
|
||||
tech[0] = '\0';
|
||||
}
|
||||
alert_ctx_t a = {title, friendly, tech[0] ? tech : NULL, footer};
|
||||
while (appletMainLoop()) {
|
||||
present(paint_alert, &a);
|
||||
padUpdate(&g_pad);
|
||||
if (padGetButtonsDown(&g_pad) & HidNpadButton_B) break;
|
||||
svcSleepThread(16000000ULL);
|
||||
}
|
||||
ota_ui_shutdown();
|
||||
}
|
||||
|
||||
static void paint_missing(u32 *fb, u32 stride_px, void *ctx) {
|
||||
(void)ctx;
|
||||
draw_chrome(fb, stride_px);
|
||||
int y = TITLE_Y - 10;
|
||||
draw_text_centered(fb, stride_px, y, "Game files missing", COL_YELLOW, 3);
|
||||
y += LINE_TITLE;
|
||||
y = draw_text_wrapped_centered(fb, stride_px, y,
|
||||
"Copy the Switch zip onto your microSD, then open gen1recomp again.",
|
||||
COL_DETAIL, 2, WRAP_MAX_PX);
|
||||
y += LINE_BODY + 16;
|
||||
int bw = 320;
|
||||
int bh = 56;
|
||||
draw_button(fb, stride_px, (FB_W - bw) / 2, y, bw, bh, COL_INK, COL_INVERSE, "(+) Exit", 1);
|
||||
}
|
||||
|
||||
void ota_ui_missing_game(void) {
|
||||
if (!ui_ensure()) return;
|
||||
while (appletMainLoop()) {
|
||||
present(paint_missing, NULL);
|
||||
padUpdate(&g_pad);
|
||||
if (padGetButtonsDown(&g_pad) & HidNpadButton_Plus) break;
|
||||
svcSleepThread(16000000ULL);
|
||||
}
|
||||
ota_ui_shutdown();
|
||||
}
|
||||
|
||||
#else /* host stub */
|
||||
|
||||
int ota_ui_prompt_update(const char *installed, const char *latest) {
|
||||
fprintf(stderr, "[ota_ui] update %s -> %s (host stub: skip)\n", installed ? installed : "?",
|
||||
latest ? latest : "?");
|
||||
return 0;
|
||||
}
|
||||
void ota_ui_show_status(const char *title, const char *detail) {
|
||||
fprintf(stderr, "[ota_ui] %s %s\n", title ? title : "", detail ? detail : "");
|
||||
}
|
||||
void ota_ui_show_progress(const char *title, const char *detail, float progress01) {
|
||||
fprintf(stderr, "[ota_ui] %s %s (%.0f%%)\n", title ? title : "", detail ? detail : "",
|
||||
progress01 * 100.f);
|
||||
}
|
||||
void ota_ui_alert(const char *title, const char *line1, const char *line2) {
|
||||
fprintf(stderr, "[ota_ui] alert: %s / %s / %s\n", title ? title : "", line1 ? line1 : "",
|
||||
line2 ? line2 : "");
|
||||
}
|
||||
void ota_ui_alert_error(const char *title, const char *friendly, const char *technical,
|
||||
const char *installed_version) {
|
||||
fprintf(stderr, "[ota_ui] error: %s / %s / %s / v%s\n", title ? title : "",
|
||||
friendly ? friendly : "", technical ? technical : "",
|
||||
installed_version ? installed_version : "?");
|
||||
}
|
||||
void ota_ui_missing_game(void) { fprintf(stderr, "[ota_ui] missing game\n"); }
|
||||
void ota_ui_shutdown(void) {}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,89 @@
|
||||
#include "ota_unzip.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
#include <fcntl.h>
|
||||
#include <zzip/zzip.h>
|
||||
#endif
|
||||
|
||||
#if defined(__SWITCH__)
|
||||
static int extract_member(ZZIP_DIR *dir, const char *member, const char *dest_path, char *err,
|
||||
size_t err_len) {
|
||||
ZZIP_FILE *zf = zzip_file_open(dir, member, O_RDONLY);
|
||||
if (!zf) return 1; /* not found / cannot open */
|
||||
|
||||
FILE *out = fopen(dest_path, "wb");
|
||||
if (!out) {
|
||||
zzip_file_close(zf);
|
||||
if (err && err_len) snprintf(err, err_len, "fopen dest failed: %s", dest_path);
|
||||
return -1;
|
||||
}
|
||||
|
||||
char buf[8192];
|
||||
zzip_ssize_t n;
|
||||
while ((n = zzip_file_read(zf, buf, sizeof(buf))) > 0) {
|
||||
if ((zzip_ssize_t)fwrite(buf, 1, (size_t)n, out) != n) {
|
||||
fclose(out);
|
||||
zzip_file_close(zf);
|
||||
if (err && err_len) snprintf(err, err_len, "fwrite failed");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
fclose(out);
|
||||
zzip_file_close(zf);
|
||||
if (n < 0) {
|
||||
if (err && err_len) snprintf(err, err_len, "zzip_file_read failed");
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
int ota_unzip_extract_file(const char *zip_path, const char *member_name, const char *dest_path,
|
||||
char *err, size_t err_len) {
|
||||
if (!zip_path || !member_name || !dest_path) {
|
||||
if (err && err_len) snprintf(err, err_len, "bad args");
|
||||
return -1;
|
||||
}
|
||||
#if defined(__SWITCH__)
|
||||
zzip_error_t zerr = ZZIP_NO_ERROR;
|
||||
ZZIP_DIR *dir = zzip_dir_open(zip_path, &zerr);
|
||||
if (!dir) {
|
||||
if (err && err_len) snprintf(err, err_len, "zzip_dir_open failed (%d): %s", (int)zerr, zip_path);
|
||||
return -1;
|
||||
}
|
||||
|
||||
char alt1[192];
|
||||
char alt2[192];
|
||||
snprintf(alt1, sizeof(alt1), "switch/gen1recomp/%s", member_name);
|
||||
snprintf(alt2, sizeof(alt2), "./%s", member_name);
|
||||
|
||||
const char *candidates[] = {member_name, alt1, alt2, NULL};
|
||||
int rc = -1;
|
||||
if (err && err_len) err[0] = '\0';
|
||||
for (int i = 0; candidates[i]; i++) {
|
||||
int t = extract_member(dir, candidates[i], dest_path, err, err_len);
|
||||
if (t == 0) {
|
||||
rc = 0;
|
||||
break;
|
||||
}
|
||||
if (t < 0) {
|
||||
rc = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (rc != 0 && err && err_len && !err[0]) {
|
||||
snprintf(err, err_len, "member not found in zip: %s", member_name);
|
||||
}
|
||||
zzip_dir_close(dir);
|
||||
return rc;
|
||||
#else
|
||||
(void)zip_path;
|
||||
(void)member_name;
|
||||
(void)dest_path;
|
||||
if (err && err_len) snprintf(err, err_len, "ota_unzip only available on __SWITCH__");
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 8x8 monochrome bitmap fonts for rendering
|
||||
* Author: Daniel Hepper <daniel@hepper.net>
|
||||
*
|
||||
* License: Public Domain
|
||||
*
|
||||
* Based on:
|
||||
* // Summary: font8x8.h
|
||||
* // 8x8 monochrome bitmap fonts for rendering
|
||||
* //
|
||||
* // Author:
|
||||
* // Marcel Sondaar
|
||||
* // International Business Machines (public domain VGA fonts)
|
||||
* //
|
||||
* // License:
|
||||
* // Public Domain
|
||||
*
|
||||
* Fetched from: http://dimensionalrift.homelinux.net/combuster/mos3/?p=viewsource&file=/modules/gfx/font8_8.asm
|
||||
**/
|
||||
|
||||
// Constant: font8x8_basic
|
||||
// Contains an 8x8 font map for unicode points U+0000 - U+007F (basic latin)
|
||||
char font8x8_basic[128][8] = {
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0000 (nul)
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0001
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0002
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0003
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0004
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0005
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0006
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0007
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0008
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0009
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000A
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000B
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000C
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000D
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000E
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000F
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0010
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0011
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0012
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0013
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0014
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0015
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0016
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0017
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0018
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0019
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001A
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001B
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001C
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001D
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001E
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001F
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0020 (space)
|
||||
{ 0x18, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x18, 0x00}, // U+0021 (!)
|
||||
{ 0x36, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0022 (")
|
||||
{ 0x36, 0x36, 0x7F, 0x36, 0x7F, 0x36, 0x36, 0x00}, // U+0023 (#)
|
||||
{ 0x0C, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x0C, 0x00}, // U+0024 ($)
|
||||
{ 0x00, 0x63, 0x33, 0x18, 0x0C, 0x66, 0x63, 0x00}, // U+0025 (%)
|
||||
{ 0x1C, 0x36, 0x1C, 0x6E, 0x3B, 0x33, 0x6E, 0x00}, // U+0026 (&)
|
||||
{ 0x06, 0x06, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0027 (')
|
||||
{ 0x18, 0x0C, 0x06, 0x06, 0x06, 0x0C, 0x18, 0x00}, // U+0028 (()
|
||||
{ 0x06, 0x0C, 0x18, 0x18, 0x18, 0x0C, 0x06, 0x00}, // U+0029 ())
|
||||
{ 0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00}, // U+002A (*)
|
||||
{ 0x00, 0x0C, 0x0C, 0x3F, 0x0C, 0x0C, 0x00, 0x00}, // U+002B (+)
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // U+002C (,)
|
||||
{ 0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x00}, // U+002D (-)
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // U+002E (.)
|
||||
{ 0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x00}, // U+002F (/)
|
||||
{ 0x3E, 0x63, 0x73, 0x7B, 0x6F, 0x67, 0x3E, 0x00}, // U+0030 (0)
|
||||
{ 0x0C, 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x3F, 0x00}, // U+0031 (1)
|
||||
{ 0x1E, 0x33, 0x30, 0x1C, 0x06, 0x33, 0x3F, 0x00}, // U+0032 (2)
|
||||
{ 0x1E, 0x33, 0x30, 0x1C, 0x30, 0x33, 0x1E, 0x00}, // U+0033 (3)
|
||||
{ 0x38, 0x3C, 0x36, 0x33, 0x7F, 0x30, 0x78, 0x00}, // U+0034 (4)
|
||||
{ 0x3F, 0x03, 0x1F, 0x30, 0x30, 0x33, 0x1E, 0x00}, // U+0035 (5)
|
||||
{ 0x1C, 0x06, 0x03, 0x1F, 0x33, 0x33, 0x1E, 0x00}, // U+0036 (6)
|
||||
{ 0x3F, 0x33, 0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x00}, // U+0037 (7)
|
||||
{ 0x1E, 0x33, 0x33, 0x1E, 0x33, 0x33, 0x1E, 0x00}, // U+0038 (8)
|
||||
{ 0x1E, 0x33, 0x33, 0x3E, 0x30, 0x18, 0x0E, 0x00}, // U+0039 (9)
|
||||
{ 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // U+003A (:)
|
||||
{ 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // U+003B (;)
|
||||
{ 0x18, 0x0C, 0x06, 0x03, 0x06, 0x0C, 0x18, 0x00}, // U+003C (<)
|
||||
{ 0x00, 0x00, 0x3F, 0x00, 0x00, 0x3F, 0x00, 0x00}, // U+003D (=)
|
||||
{ 0x06, 0x0C, 0x18, 0x30, 0x18, 0x0C, 0x06, 0x00}, // U+003E (>)
|
||||
{ 0x1E, 0x33, 0x30, 0x18, 0x0C, 0x00, 0x0C, 0x00}, // U+003F (?)
|
||||
{ 0x3E, 0x63, 0x7B, 0x7B, 0x7B, 0x03, 0x1E, 0x00}, // U+0040 (@)
|
||||
{ 0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00}, // U+0041 (A)
|
||||
{ 0x3F, 0x66, 0x66, 0x3E, 0x66, 0x66, 0x3F, 0x00}, // U+0042 (B)
|
||||
{ 0x3C, 0x66, 0x03, 0x03, 0x03, 0x66, 0x3C, 0x00}, // U+0043 (C)
|
||||
{ 0x1F, 0x36, 0x66, 0x66, 0x66, 0x36, 0x1F, 0x00}, // U+0044 (D)
|
||||
{ 0x7F, 0x46, 0x16, 0x1E, 0x16, 0x46, 0x7F, 0x00}, // U+0045 (E)
|
||||
{ 0x7F, 0x46, 0x16, 0x1E, 0x16, 0x06, 0x0F, 0x00}, // U+0046 (F)
|
||||
{ 0x3C, 0x66, 0x03, 0x03, 0x73, 0x66, 0x7C, 0x00}, // U+0047 (G)
|
||||
{ 0x33, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x33, 0x00}, // U+0048 (H)
|
||||
{ 0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0049 (I)
|
||||
{ 0x78, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E, 0x00}, // U+004A (J)
|
||||
{ 0x67, 0x66, 0x36, 0x1E, 0x36, 0x66, 0x67, 0x00}, // U+004B (K)
|
||||
{ 0x0F, 0x06, 0x06, 0x06, 0x46, 0x66, 0x7F, 0x00}, // U+004C (L)
|
||||
{ 0x63, 0x77, 0x7F, 0x7F, 0x6B, 0x63, 0x63, 0x00}, // U+004D (M)
|
||||
{ 0x63, 0x67, 0x6F, 0x7B, 0x73, 0x63, 0x63, 0x00}, // U+004E (N)
|
||||
{ 0x1C, 0x36, 0x63, 0x63, 0x63, 0x36, 0x1C, 0x00}, // U+004F (O)
|
||||
{ 0x3F, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x0F, 0x00}, // U+0050 (P)
|
||||
{ 0x1E, 0x33, 0x33, 0x33, 0x3B, 0x1E, 0x38, 0x00}, // U+0051 (Q)
|
||||
{ 0x3F, 0x66, 0x66, 0x3E, 0x36, 0x66, 0x67, 0x00}, // U+0052 (R)
|
||||
{ 0x1E, 0x33, 0x07, 0x0E, 0x38, 0x33, 0x1E, 0x00}, // U+0053 (S)
|
||||
{ 0x3F, 0x2D, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0054 (T)
|
||||
{ 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0x00}, // U+0055 (U)
|
||||
{ 0x33, 0x33, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // U+0056 (V)
|
||||
{ 0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00}, // U+0057 (W)
|
||||
{ 0x63, 0x63, 0x36, 0x1C, 0x1C, 0x36, 0x63, 0x00}, // U+0058 (X)
|
||||
{ 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x0C, 0x1E, 0x00}, // U+0059 (Y)
|
||||
{ 0x7F, 0x63, 0x31, 0x18, 0x4C, 0x66, 0x7F, 0x00}, // U+005A (Z)
|
||||
{ 0x1E, 0x06, 0x06, 0x06, 0x06, 0x06, 0x1E, 0x00}, // U+005B ([)
|
||||
{ 0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x40, 0x00}, // U+005C (\)
|
||||
{ 0x1E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x1E, 0x00}, // U+005D (])
|
||||
{ 0x08, 0x1C, 0x36, 0x63, 0x00, 0x00, 0x00, 0x00}, // U+005E (^)
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF}, // U+005F (_)
|
||||
{ 0x0C, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0060 (`)
|
||||
{ 0x00, 0x00, 0x1E, 0x30, 0x3E, 0x33, 0x6E, 0x00}, // U+0061 (a)
|
||||
{ 0x07, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x3B, 0x00}, // U+0062 (b)
|
||||
{ 0x00, 0x00, 0x1E, 0x33, 0x03, 0x33, 0x1E, 0x00}, // U+0063 (c)
|
||||
{ 0x38, 0x30, 0x30, 0x3e, 0x33, 0x33, 0x6E, 0x00}, // U+0064 (d)
|
||||
{ 0x00, 0x00, 0x1E, 0x33, 0x3f, 0x03, 0x1E, 0x00}, // U+0065 (e)
|
||||
{ 0x1C, 0x36, 0x06, 0x0f, 0x06, 0x06, 0x0F, 0x00}, // U+0066 (f)
|
||||
{ 0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // U+0067 (g)
|
||||
{ 0x07, 0x06, 0x36, 0x6E, 0x66, 0x66, 0x67, 0x00}, // U+0068 (h)
|
||||
{ 0x0C, 0x00, 0x0E, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0069 (i)
|
||||
{ 0x30, 0x00, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E}, // U+006A (j)
|
||||
{ 0x07, 0x06, 0x66, 0x36, 0x1E, 0x36, 0x67, 0x00}, // U+006B (k)
|
||||
{ 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+006C (l)
|
||||
{ 0x00, 0x00, 0x33, 0x7F, 0x7F, 0x6B, 0x63, 0x00}, // U+006D (m)
|
||||
{ 0x00, 0x00, 0x1F, 0x33, 0x33, 0x33, 0x33, 0x00}, // U+006E (n)
|
||||
{ 0x00, 0x00, 0x1E, 0x33, 0x33, 0x33, 0x1E, 0x00}, // U+006F (o)
|
||||
{ 0x00, 0x00, 0x3B, 0x66, 0x66, 0x3E, 0x06, 0x0F}, // U+0070 (p)
|
||||
{ 0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x78}, // U+0071 (q)
|
||||
{ 0x00, 0x00, 0x3B, 0x6E, 0x66, 0x06, 0x0F, 0x00}, // U+0072 (r)
|
||||
{ 0x00, 0x00, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x00}, // U+0073 (s)
|
||||
{ 0x08, 0x0C, 0x3E, 0x0C, 0x0C, 0x2C, 0x18, 0x00}, // U+0074 (t)
|
||||
{ 0x00, 0x00, 0x33, 0x33, 0x33, 0x33, 0x6E, 0x00}, // U+0075 (u)
|
||||
{ 0x00, 0x00, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // U+0076 (v)
|
||||
{ 0x00, 0x00, 0x63, 0x6B, 0x7F, 0x7F, 0x36, 0x00}, // U+0077 (w)
|
||||
{ 0x00, 0x00, 0x63, 0x36, 0x1C, 0x36, 0x63, 0x00}, // U+0078 (x)
|
||||
{ 0x00, 0x00, 0x33, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // U+0079 (y)
|
||||
{ 0x00, 0x00, 0x3F, 0x19, 0x0C, 0x26, 0x3F, 0x00}, // U+007A (z)
|
||||
{ 0x38, 0x0C, 0x0C, 0x07, 0x0C, 0x0C, 0x38, 0x00}, // U+007B ({)
|
||||
{ 0x18, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x00}, // U+007C (|)
|
||||
{ 0x07, 0x0C, 0x0C, 0x38, 0x0C, 0x0C, 0x07, 0x00}, // U+007D (})
|
||||
{ 0x6E, 0x3B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+007E (~)
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} // U+007F
|
||||
};
|
||||
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -113,6 +113,11 @@ picker copies user-selected files into LocalState and the launcher imports them
|
||||
from there. Saves, ROM cache and installed mods remain under the LÖVE save
|
||||
directory in LocalState.
|
||||
|
||||
The launcher also checks `LocalState\pokemon-love2d\baseroms` once at startup
|
||||
for clean Red, Blue, and Yellow ROMs. Compatible files are offered on their
|
||||
launcher tabs and remain in `baseroms` after import. The file picker remains
|
||||
available when no compatible ROM is found.
|
||||
|
||||
LuaJIT requires the `codeGeneration` capability. `removableStorage` exposes
|
||||
external media to the Xbox picker. The network capabilities support relay play
|
||||
and direct hosting. The package does not request full trust or broad filesystem
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# LÖVE UWP binaries
|
||||
|
||||
These x64 UWP Release binaries were built from [`caorthann-celt/love-xbox-uwp`](https://github.com/caorthann-celt/love-xbox-uwp) at commit `62275ed10190afbec01f65754b3bcc04f244dd35`.
|
||||
These x64 UWP Release binaries were built from [`caorthann-celt/love-xbox-uwp`](https://github.com/caorthann-celt/love-xbox-uwp) at commit `cdf85f28ed794c95d6f5f7e3a8f23ca9ee1fbdcb`.
|
||||
|
||||
The build uses LÖVE 11.5, LuaJIT, SDL2, and ANGLE. Keep the DLLs and import libraries together; they are one binary interface.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"love": {
|
||||
"version": "11.5",
|
||||
"repository": "https://github.com/caorthann-celt/love-xbox-uwp.git",
|
||||
"commit": "62275ed10190afbec01f65754b3bcc04f244dd35"
|
||||
"commit": "cdf85f28ed794c95d6f5f7e3a8f23ca9ee1fbdcb"
|
||||
},
|
||||
"luajit": {
|
||||
"repository": "https://github.com/LuaJIT/LuaJIT.git",
|
||||
@@ -99,7 +99,7 @@
|
||||
},
|
||||
{
|
||||
"path": "love/bin/love.dll",
|
||||
"sha256": "7B1BE4699AC950173474BB0C03B5DD52E1202ABA6F60C29E294957057F587692"
|
||||
"sha256": "A3622828014F03DDB502D2A7807DC8F94432172595BE03508099BD66937FD45F"
|
||||
},
|
||||
{
|
||||
"path": "love/bin/lua51.dll",
|
||||
|
||||
@@ -357,13 +357,44 @@ build_linux() {
|
||||
|
||||
cp "$LOVE_FILE" "$appdir/game.love"
|
||||
|
||||
# The .desktop's Icon=love resolves against the AppDir root by basename,
|
||||
# so drop the stock love.svg and provide our PNG under the same name;
|
||||
# .DirIcon is what appimaged/thumbnailers show for the file itself.
|
||||
# Replace LÖVE's own desktop entry rather than keeping it: it says
|
||||
# Name=LÖVE / Icon=love, which is what appimaged, app menus and file
|
||||
# managers displayed this image as. Same file as the arm64 build writes,
|
||||
# so both architectures integrate under the game's name.
|
||||
local stock_desktop
|
||||
stock_desktop="$(find "$appdir" -maxdepth 1 -name '*.desktop' | wc -l | tr -d ' ')"
|
||||
[ "$stock_desktop" = 1 ] \
|
||||
|| fail "expected exactly one .desktop at the AppDir root, found $stock_desktop"
|
||||
rm -f "$appdir"/*.desktop
|
||||
|
||||
# share/ carries a second, NoDisplay copy of the same entry plus the .love
|
||||
# file-type icons and mime rule, all left over from LÖVE's `make install`
|
||||
# (its Exec even points at the CI runner that built it). Nothing at runtime
|
||||
# reads them -- only share/lua and share/luajit-* are on LUA_PATH -- but
|
||||
# AppRun puts $APPDIR/share on XDG_DATA_DIRS, so anyone extracting the image
|
||||
# gets a "LÖVE" entry back. The arm64 AppDir never had them.
|
||||
rm -rf "$appdir/share/applications" "$appdir/share/pixmaps" \
|
||||
"$appdir/share/mime" "$appdir/share/icons"
|
||||
|
||||
cat > "$appdir/$APP_NAME.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=gen1recomp
|
||||
Comment=Pokémon Gen 1 recompilation
|
||||
Exec=$APP_NAME
|
||||
Icon=$APP_NAME
|
||||
StartupWMClass=love
|
||||
Categories=Game;
|
||||
Terminal=false
|
||||
EOF
|
||||
|
||||
# Icon= resolves against the AppDir root by basename, so the PNG has to be
|
||||
# named after the desktop entry; .DirIcon is what appimaged and
|
||||
# file-manager thumbnailers show for the file itself.
|
||||
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
|
||||
rm -f "$appdir/love.svg" "$appdir/.DirIcon"
|
||||
sips -z 512 512 "$ICON_SRC" --out "$appdir/love.png" >/dev/null
|
||||
cp "$appdir/love.png" "$appdir/.DirIcon"
|
||||
rm -f "$appdir/love.svg" "$appdir/love.png" "$appdir/.DirIcon"
|
||||
sips -z 512 512 "$ICON_SRC" --out "$appdir/$APP_NAME.png" >/dev/null
|
||||
cp "$appdir/$APP_NAME.png" "$appdir/.DirIcon"
|
||||
|
||||
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
|
||||
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|
||||
|
||||
@@ -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")"
|
||||
@@ -19,19 +19,22 @@
|
||||
# (gen1recomp.nro + game.love side by side). Requires the pin
|
||||
# (run --fetch first, or combine --fetch --loose).
|
||||
#
|
||||
# --fused Build a single gen1recomp-<ver>-switch.nro via nacptool+elf2nro,
|
||||
# then pack dist/switch/gen1recomp-<ver>-switch.zip (SD-ready
|
||||
# tree under switch/gen1recomp/). Uses native tools (PATH or
|
||||
# $DEVKITPRO/tools/bin) first; else Docker from
|
||||
# scripts/switch/dkp-docker.image (override GEN1_DKP_IMAGE).
|
||||
# Requires the pin (run --fetch or combine). GitHub Releases
|
||||
# publish the zip only; the versioned .nro stays local / PR CI.
|
||||
# --fused Build fused gen1recomp-<ver>-switch.nro (game in romfs), native
|
||||
# OTA launcher NRO, and dual-NRO SD zip (gen1recomp.nro launcher +
|
||||
# gen1recomp-game.nro under switch/gen1recomp/). The same
|
||||
# gen1recomp-<ver>-switch.zip is the OTA download asset.
|
||||
# Requires DEVKITPRO + switch-dev; OTA launcher via native packages
|
||||
# (install_devkitpro_deps.sh) or Docker.
|
||||
# Uses native tools (PATH or $DEVKITPRO/tools/bin) first; else Docker
|
||||
# from scripts/switch/dkp-docker.image (override GEN1_DKP_IMAGE).
|
||||
# Requires the pin (run --fetch or combine). GitHub Releases publish
|
||||
# the zip only; versioned .nro files stay local / PR CI.
|
||||
#
|
||||
# Combinable: --fetch alone, or --fetch with --loose / --fused.
|
||||
# XOR: --loose and --fused cannot be used together.
|
||||
# XOR: --loose and --fused cannot be combined with each other.
|
||||
#
|
||||
# Non-goals (never done by this script):
|
||||
# MTP push, ROM install, dkp-pacman auto-install.
|
||||
# MTP push, ROM install, dkp-pacman auto-install (use install_devkitpro_deps.sh).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -49,7 +52,7 @@ say() { printf '\033[1;32m==>\033[0m %s\n' "$*" >&2; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
usage() {
|
||||
sed -n '2,34p' "$0" | sed 's/^# \{0,1\}//'
|
||||
sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//'
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
@@ -129,11 +132,22 @@ if [ "$LOOSE" -eq 1 ]; then
|
||||
fi
|
||||
|
||||
if [ "$FUSED" -eq 1 ]; then
|
||||
# shellcheck source=scripts/switch/common.sh
|
||||
. "$ROOT/scripts/switch/common.sh"
|
||||
preflight_fused_build
|
||||
|
||||
GAME_LOVE="$(pack_game_love)"
|
||||
OUT_NRO="$DIST/gen1recomp-${VERSION}-switch.nro"
|
||||
OUT_ZIP="$DIST/gen1recomp-${VERSION}-switch.zip"
|
||||
"$ROOT/scripts/switch/build_fused.sh" "$GAME_LOVE" "$VERSION" "$OUT_NRO"
|
||||
"$ROOT/scripts/switch/pack_sd_zip.sh" "$OUT_NRO" "$VERSION" "$OUT_ZIP"
|
||||
|
||||
LAUNCHER_NRO="$DIST/gen1recomp-${VERSION}-launcher.nro"
|
||||
say "building native OTA launcher"
|
||||
"$ROOT/scripts/switch/build_ota_launcher.sh" "$LAUNCHER_NRO" "$VERSION"
|
||||
say "packing dual-NRO SD zip (also the OTA download asset)"
|
||||
"$ROOT/scripts/switch/pack_sd_zip.sh" "$OUT_NRO" "$VERSION" "$OUT_ZIP" "$LAUNCHER_NRO"
|
||||
cp "$OUT_NRO" "$DIST/gen1recomp-${VERSION}-game.nro"
|
||||
|
||||
cp "$WORK/build-info.json" "$DIST/gen1recomp-${VERSION}-build-info.json"
|
||||
say "done. See $DIST/"
|
||||
exit 0
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,428 @@
|
||||
#!/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
|
||||
StartupWMClass=love
|
||||
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))"
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/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")"
|
||||
# --retry-all-errors because plain --retry skips TLS handshake failures,
|
||||
# which is how xiph.org drops these tarballs; the pin below still gates it.
|
||||
curl -fL --retry 5 --retry-delay 2 --retry-all-errors --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
|
||||
)"
|
||||
}
|
||||
@@ -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"
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the LÖVE2D Pokémon Red port (macOS-friendly).
|
||||
#
|
||||
# Assumes scripts/setup.sh has been run once (generated data present and
|
||||
# LÖVE installed). Extra arguments are passed through to LÖVE.
|
||||
# Assumes scripts/setup.sh has been run once for at least one game (generated
|
||||
# data present and LÖVE installed). Extra arguments are passed through to LÖVE.
|
||||
#
|
||||
# Link play is peer-to-peer (lua-enet, bundled with LÖVE): one player
|
||||
# picks HOST A GAME in START > LINK and reads out the address shown;
|
||||
@@ -15,8 +15,11 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[ -f "$ROOT/data/generated/maps.lua" ] \
|
||||
|| fail "generated data missing, run scripts/setup.sh first"
|
||||
if [ ! -f "$ROOT/data/generated/maps.lua" ] \
|
||||
&& [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \
|
||||
&& [ ! -f "$ROOT/yellow/data/generated/maps.lua" ]; then
|
||||
fail "generated data missing, run scripts/setup.sh first"
|
||||
fi
|
||||
|
||||
find_love() {
|
||||
command -v love >/dev/null 2>&1 && { echo "love"; return; }
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bake assets/logo/logo.png into a pre-scaled RGBA blob for the Switch OTA UI.
|
||||
|
||||
Output format (little-endian):
|
||||
uint32 width, uint32 height, then width*height RGBA8888 pixels.
|
||||
|
||||
Regenerate after editing the source PNG:
|
||||
python3 scripts/switch/bake_ota_logo.py
|
||||
|
||||
Uses Pillow when available (see scripts/setup.sh). On macOS without Pillow,
|
||||
falls back to sips + BMP export.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import platform
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_SRC = ROOT / "assets/logo/logo.png"
|
||||
DEFAULT_OUT = ROOT / "ports/switch/assets/logo.rgba"
|
||||
DEFAULT_MAX_W = 320
|
||||
DEFAULT_MAX_H = 90
|
||||
|
||||
|
||||
def fit_size(src_w: int, src_h: int, max_w: int, max_h: int) -> tuple[int, int]:
|
||||
scale = min(max_w / src_w, max_h / src_h, 1.0)
|
||||
w = max(1, int(math.floor(src_w * scale + 0.5)))
|
||||
h = max(1, int(math.floor(src_h * scale + 0.5)))
|
||||
return w, h
|
||||
|
||||
|
||||
def read_png_size(path: Path) -> tuple[int, int]:
|
||||
with path.open("rb") as fp:
|
||||
sig = fp.read(8)
|
||||
if sig != b"\x89PNG\r\n\x1a\n":
|
||||
raise SystemExit(f"not a PNG: {path}")
|
||||
while True:
|
||||
raw = fp.read(8)
|
||||
if len(raw) < 8:
|
||||
raise SystemExit(f"truncated PNG: {path}")
|
||||
length, ctype = struct.unpack(">I4s", raw)
|
||||
data = fp.read(length)
|
||||
fp.read(4)
|
||||
if ctype == b"IHDR":
|
||||
return struct.unpack(">II", data[:8])
|
||||
if ctype == b"IEND":
|
||||
break
|
||||
raise SystemExit(f"missing IHDR in PNG: {path}")
|
||||
|
||||
|
||||
def read_bmp_rgba(path: Path) -> tuple[int, int, bytes]:
|
||||
data = path.read_bytes()
|
||||
if len(data) < 54 or data[:2] != b"BM":
|
||||
raise SystemExit(f"not a BMP: {path}")
|
||||
pixel_offset = struct.unpack_from("<I", data, 10)[0]
|
||||
width = struct.unpack_from("<i", data, 18)[0]
|
||||
height_raw = struct.unpack_from("<i", data, 22)[0]
|
||||
bpp = struct.unpack_from("<H", data, 28)[0]
|
||||
height = abs(height_raw)
|
||||
top_down = height_raw < 0
|
||||
if bpp != 32 or width <= 0 or height <= 0:
|
||||
raise SystemExit(f"unsupported BMP {width}x{height_raw} @{bpp}bpp: {path}")
|
||||
|
||||
row_bytes = ((width * 4 + 3) // 4) * 4
|
||||
pixels = bytearray(width * height * 4)
|
||||
for row in range(height):
|
||||
src_row = row if top_down else height - 1 - row
|
||||
row_off = pixel_offset + src_row * row_bytes
|
||||
for col in range(width):
|
||||
b, g, r, a = data[row_off + col * 4 : row_off + col * 4 + 4]
|
||||
dst = (row * width + col) * 4
|
||||
pixels[dst : dst + 4] = bytes((r, g, b, a))
|
||||
return width, height, bytes(pixels)
|
||||
|
||||
|
||||
def bake_with_sips(src: Path, out_w: int, out_h: int) -> tuple[int, int, bytes]:
|
||||
if platform.system() != "Darwin":
|
||||
raise SystemExit(
|
||||
"Pillow is required to bake the OTA logo on this platform. "
|
||||
"Run scripts/setup.sh or: pip install pillow"
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
scaled_png = tmp_dir / "scaled.png"
|
||||
scaled_bmp = tmp_dir / "scaled.bmp"
|
||||
subprocess.run(
|
||||
["sips", "-z", str(out_h), str(out_w), str(src), "--out", str(scaled_png)],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
subprocess.run(
|
||||
["sips", "-s", "format", "bmp", str(scaled_png), "--out", str(scaled_bmp)],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
return read_bmp_rgba(scaled_bmp)
|
||||
|
||||
|
||||
def bake_with_pillow(src: Path, out_w: int, out_h: int) -> tuple[int, int, bytes]:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(src) as image:
|
||||
image = image.convert("RGBA")
|
||||
if image.size != (out_w, out_h):
|
||||
image = image.resize((out_w, out_h), Image.LANCZOS)
|
||||
w, h = image.size
|
||||
return w, h, image.tobytes()
|
||||
|
||||
|
||||
def bake(src: Path, out: Path, max_w: int, max_h: int) -> tuple[int, int]:
|
||||
if not src.is_file():
|
||||
raise SystemExit(f"missing source PNG: {src}")
|
||||
|
||||
src_w, src_h = read_png_size(src)
|
||||
out_w, out_h = fit_size(src_w, src_h, max_w, max_h)
|
||||
|
||||
try:
|
||||
from PIL import Image # noqa: F401
|
||||
|
||||
w, h, pixels = bake_with_pillow(src, out_w, out_h)
|
||||
except ImportError:
|
||||
w, h, pixels = bake_with_sips(src, out_w, out_h)
|
||||
|
||||
if len(pixels) != w * h * 4:
|
||||
raise SystemExit(f"unexpected pixel buffer size for {w}x{h}")
|
||||
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out.open("wb") as fp:
|
||||
fp.write(struct.pack("<II", w, h))
|
||||
fp.write(pixels)
|
||||
|
||||
return w, h
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
src = Path(argv[1]) if len(argv) > 1 else DEFAULT_SRC
|
||||
out = Path(argv[2]) if len(argv) > 2 else DEFAULT_OUT
|
||||
max_w = int(argv[3]) if len(argv) > 3 else DEFAULT_MAX_W
|
||||
max_h = int(argv[4]) if len(argv) > 4 else DEFAULT_MAX_H
|
||||
|
||||
w, h = bake(src, out, max_w, max_h)
|
||||
print(f"wrote {out} ({w}x{h}, {8 + w * h * 4} bytes)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
@@ -15,7 +15,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
LOVE_NX_TAG="11.5-nx1"
|
||||
LOVE_NX_DIR="$ROOT/.bazinga/love-nx/$LOVE_NX_TAG"
|
||||
LOVE_ELF="$LOVE_NX_DIR/love.elf"
|
||||
ICON="$ROOT/assets/switch/icon.jpg"
|
||||
ICON="$ROOT/ports/switch/assets/icon.jpg"
|
||||
APP_NAME="gen1recomp"
|
||||
APP_AUTHOR="bryanthaboi, port by andrewqsantos"
|
||||
DKP_IMAGE_FILE="$ROOT/scripts/switch/dkp-docker.image"
|
||||
@@ -93,7 +93,7 @@ run_fused_docker() {
|
||||
set -euo pipefail
|
||||
nacptool --create '$APP_NAME' '$APP_AUTHOR' '$VERSION' /work/control.nacp
|
||||
elf2nro /src/.bazinga/love-nx/$LOVE_NX_TAG/love.elf /out/$out_base \
|
||||
--icon=/src/assets/switch/icon.jpg \
|
||||
--icon=/src/ports/switch/assets/icon.jpg \
|
||||
--nacp=/work/control.nacp \
|
||||
--romfsdir=/work/romfs
|
||||
"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the native Switch OTA launcher NRO (DEVKITPRO or Docker).
|
||||
#
|
||||
# Usage: scripts/switch/build_ota_launcher.sh [OUT_NRO] [VERSION]
|
||||
#
|
||||
# Default OUT_NRO: dist/switch/gen1recomp-launcher.nro
|
||||
# VERSION (optional X.Y.Z) is written into the NACP so hbmenu shows the
|
||||
# same release as the fused game; defaults to 0.0.0 when omitted.
|
||||
# Host-only protocol check: always runs `make host-test` first.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=common.sh
|
||||
. "$SCRIPT_DIR/common.sh"
|
||||
|
||||
LAUNCHER_DIR="$ROOT/ports/switch/ota-launcher"
|
||||
OUT_NRO="${1:-$ROOT/dist/switch/gen1recomp-launcher.nro}"
|
||||
APP_VERSION="${2:-${GEN1_LAUNCHER_VERSION:-0.0.0}}"
|
||||
DKP_IMAGE_FILE="$ROOT/scripts/switch/dkp-docker.image"
|
||||
|
||||
[ -d "$LAUNCHER_DIR" ] || fail "missing $LAUNCHER_DIR"
|
||||
[ -f "$LAUNCHER_DIR/Makefile" ] || fail "missing Makefile"
|
||||
|
||||
if ! devkitpro_ready; then
|
||||
fail_missing_devkitpro
|
||||
fi
|
||||
|
||||
say "host-test ota_protocol (no DEVKITPRO required)"
|
||||
make -C "$LAUNCHER_DIR" host-test
|
||||
|
||||
mkdir -p "$(dirname "$OUT_NRO")"
|
||||
|
||||
if ota_launcher_deps_ready; then
|
||||
say "building launcher NRO with native DEVKITPRO (NACP $APP_VERSION)"
|
||||
make -C "$LAUNCHER_DIR" clean || true
|
||||
if ! make -C "$LAUNCHER_DIR" all APP_VERSION="$APP_VERSION"; then
|
||||
fail_ota_launcher_toolchain
|
||||
fi
|
||||
cp "$LAUNCHER_DIR/gen1recomp.nro" "$OUT_NRO"
|
||||
say "wrote $OUT_NRO"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
resolve_dkp_image() {
|
||||
if [ -n "${GEN1_DKP_IMAGE:-}" ]; then
|
||||
printf '%s' "$GEN1_DKP_IMAGE"
|
||||
return 0
|
||||
fi
|
||||
[ -f "$DKP_IMAGE_FILE" ] || fail "missing Docker image pin: $DKP_IMAGE_FILE"
|
||||
local line
|
||||
line="$(grep -v '^[[:space:]]*#' "$DKP_IMAGE_FILE" | grep -v '^[[:space:]]*$' | head -1 || true)"
|
||||
[ -n "$line" ] || fail "empty Docker image pin: $DKP_IMAGE_FILE"
|
||||
printf '%s' "$line"
|
||||
}
|
||||
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
image="$(resolve_dkp_image)"
|
||||
say "building launcher NRO via Docker ($image, NACP $APP_VERSION)"
|
||||
if ! docker run --rm \
|
||||
-v "$ROOT:/work" \
|
||||
-w /work/ports/switch/ota-launcher \
|
||||
-e "APP_VERSION=$APP_VERSION" \
|
||||
"$image" \
|
||||
bash -lc 'pacman -Sy --noconfirm switch-curl switch-mbedtls switch-zlib switch-zziplib >/dev/null 2>&1 || true; make clean; make all APP_VERSION="$APP_VERSION"'; then
|
||||
fail_ota_launcher_toolchain
|
||||
fi
|
||||
cp "$LAUNCHER_DIR/gen1recomp.nro" "$OUT_NRO"
|
||||
say "wrote $OUT_NRO"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
fail_missing_ota_deps
|
||||
@@ -8,7 +8,8 @@ if [ -z "${ROOT:-}" ]; then
|
||||
fi
|
||||
export ROOT
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
# Progress on stderr so command-substitution (e.g. pack_game_love) stays a bare path.
|
||||
say() { printf '\033[1;32m==>\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 shasum, falls back to sha256sum.
|
||||
@@ -38,16 +39,137 @@ fail_need_fetch() {
|
||||
fail "${1:-missing pinned love-nx} — run: scripts/build_switch.sh --fetch"
|
||||
}
|
||||
|
||||
# OTA launcher native build packages (shared with install_devkitpro_deps.sh).
|
||||
OTA_LAUNCHER_PKGS=(switch-curl switch-mbedtls switch-zlib switch-zziplib)
|
||||
|
||||
ota_launcher_deps_ready() {
|
||||
command -v dkp-pacman >/dev/null 2>&1 || return 1
|
||||
local pkg
|
||||
for pkg in "${OTA_LAUNCHER_PKGS[@]}"; do
|
||||
dkp-pacman -Q "$pkg" >/dev/null 2>&1 || return 1
|
||||
done
|
||||
}
|
||||
|
||||
# Space-separated list of missing OTA launcher packages (empty when all installed).
|
||||
ota_launcher_missing_pkgs() {
|
||||
command -v dkp-pacman >/dev/null 2>&1 || return 0
|
||||
local pkg missing=""
|
||||
for pkg in "${OTA_LAUNCHER_PKGS[@]}"; do
|
||||
if ! dkp-pacman -Q "$pkg" >/dev/null 2>&1; then
|
||||
missing="${missing} ${pkg}"
|
||||
fi
|
||||
done
|
||||
printf '%s' "$missing"
|
||||
}
|
||||
|
||||
devkitpro_ready() {
|
||||
[ -n "${DEVKITPRO:-}" ] && [ -f "$DEVKITPRO/libnx/switch_rules" ]
|
||||
}
|
||||
|
||||
fail_missing_devkitpro() {
|
||||
fail "$(cat <<'EOF'
|
||||
--fused requires DEVKITPRO (release builds ship the OTA launcher for in-console updates).
|
||||
|
||||
One-time setup on the Mac self-hosted runner (or your dev machine):
|
||||
|
||||
1. Install devkitPro pacman: https://devkitpro.org/wiki/devkitPro_pacman
|
||||
2. Install Switch tools: sudo dkp-pacman -S switch-dev
|
||||
3. Export DEVKITPRO (typical macOS):
|
||||
export DEVKITPRO=/opt/devkitpro
|
||||
export PATH="$DEVKITPRO/tools/bin:$PATH"
|
||||
4. OTA launcher toolchain (native or Docker — pick one):
|
||||
bash scripts/switch/install_devkitpro_deps.sh
|
||||
or install Docker (same pin as fused builds)
|
||||
|
||||
Then re-run: scripts/build_switch.sh --fetch --fused
|
||||
|
||||
See docs/switch-build.md (Runner provisioning).
|
||||
EOF
|
||||
)"
|
||||
}
|
||||
|
||||
fail_missing_ota_deps() {
|
||||
local missing
|
||||
missing="$(ota_launcher_missing_pkgs)"
|
||||
fail "$(cat <<EOF
|
||||
--fused needs a way to build the OTA launcher: native dkp packages or Docker.
|
||||
|
||||
Missing dkp packages:${missing:- (dkp-pacman not on PATH)}
|
||||
|
||||
Pick one:
|
||||
bash scripts/switch/install_devkitpro_deps.sh
|
||||
or install Docker (build_ota_launcher.sh uses the pinned devkitPro image)
|
||||
|
||||
Then re-run: scripts/build_switch.sh --fetch --fused
|
||||
|
||||
See docs/switch-build.md and ports/switch/ota-launcher/README.md.
|
||||
EOF
|
||||
)"
|
||||
}
|
||||
|
||||
fail_ota_launcher_toolchain() {
|
||||
if ! devkitpro_ready; then
|
||||
fail_missing_devkitpro
|
||||
fi
|
||||
if ota_launcher_deps_ready; then
|
||||
fail "$(cat <<'EOF'
|
||||
OTA launcher native build failed after host-test passed.
|
||||
|
||||
Re-run with a clean tree:
|
||||
scripts/switch/build_ota_launcher.sh
|
||||
|
||||
If packages look wrong, reinstall:
|
||||
bash scripts/switch/install_devkitpro_deps.sh
|
||||
|
||||
See ports/switch/ota-launcher/README.md.
|
||||
EOF
|
||||
)"
|
||||
fi
|
||||
fail_missing_ota_deps
|
||||
}
|
||||
|
||||
# Fail fast before packing when --fused is requested.
|
||||
preflight_fused_build() {
|
||||
if ! devkitpro_ready; then
|
||||
fail_missing_devkitpro
|
||||
fi
|
||||
ensure_dkp_tools_path
|
||||
|
||||
local game_ok=0
|
||||
if command -v nacptool >/dev/null 2>&1 && command -v elf2nro >/dev/null 2>&1; then
|
||||
game_ok=1
|
||||
elif command -v docker >/dev/null 2>&1; then
|
||||
game_ok=1
|
||||
fi
|
||||
if [ "$game_ok" -eq 0 ]; then
|
||||
fail_fused_toolchain
|
||||
fi
|
||||
|
||||
if ota_launcher_deps_ready; then
|
||||
return 0
|
||||
fi
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
fail_missing_ota_deps
|
||||
}
|
||||
|
||||
# Fused mode needs nacptool/elf2nro (native or Docker). Rich multi-OS hint.
|
||||
fail_fused_toolchain() {
|
||||
fail "$(cat <<'EOF'
|
||||
fused packaging needs nacptool and elf2nro (devkitPro switch-dev).
|
||||
fused packaging needs DEVKITPRO, nacptool, and elf2nro (devkitPro switch-dev).
|
||||
|
||||
Install options:
|
||||
macOS: https://devkitpro.org/wiki/devkitPro_pacman (installer / pacman)
|
||||
macOS: https://devkitpro.org/wiki/devkitPro_pacman
|
||||
sudo dkp-pacman -S switch-dev
|
||||
OTA launcher (native or Docker):
|
||||
bash scripts/switch/install_devkitpro_deps.sh
|
||||
or install Docker
|
||||
Linux: https://devkitpro.org/wiki/devkitPro_pacman
|
||||
Windows: Git Bash / MSYS2 / WSL with devkitPro tools on PATH
|
||||
Docker: install Docker; build_fused.sh falls back to the pinned image
|
||||
Docker: install Docker; fused game and OTA launcher can build in-container
|
||||
|
||||
Export DEVKITPRO (typical): export DEVKITPRO=/opt/devkitpro
|
||||
|
||||
See docs/switch-build.md for details.
|
||||
EOF
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install DEVKITPRO Switch packages needed for the native OTA launcher.
|
||||
# Run this in Terminal.app / iTerm (needs your macOS admin password):
|
||||
#
|
||||
# bash scripts/switch/install_devkitpro_deps.sh
|
||||
#
|
||||
# Optional: speeds up local native OTA launcher builds. CI/release can fall back
|
||||
# to Docker when these packages are not installed.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=common.sh
|
||||
. "$SCRIPT_DIR/common.sh"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "==> re-running with sudo…"
|
||||
exec sudo -E bash "$0" "$@"
|
||||
fi
|
||||
|
||||
export DEVKITPRO="${DEVKITPRO:-/opt/devkitpro}"
|
||||
export PATH="/usr/local/bin:$DEVKITPRO/pacman/bin:$DEVKITPRO/tools/bin:$PATH"
|
||||
|
||||
if [ -f /etc/profile.d/devkit-env.sh ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/profile.d/devkit-env.sh
|
||||
fi
|
||||
|
||||
if ! command -v dkp-pacman >/dev/null 2>&1; then
|
||||
echo "error: dkp-pacman not found. Install the pkg from:" >&2
|
||||
echo " https://github.com/devkitPro/pacman/releases/latest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! dkp-pacman -Q switch-dev >/dev/null 2>&1; then
|
||||
echo "error: switch-dev is not installed (needed for nacptool/elf2nro)." >&2
|
||||
echo " sudo dkp-pacman -S switch-dev" >&2
|
||||
echo "Then re-run: bash scripts/switch/install_devkitpro_deps.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> DEVKITPRO=$DEVKITPRO"
|
||||
# Named packages only (avoid interactive switch-dev group member prompt).
|
||||
# ZIP reading uses switch-zziplib (dkp has no minizip port for Switch).
|
||||
dkp-pacman -Sy --noconfirm --needed "${OTA_LAUNCHER_PKGS[@]}"
|
||||
|
||||
echo "==> installed packages:"
|
||||
dkp-pacman -Q | grep -E 'switch-(curl|mbedtls|zlib|zziplib|tools)|libnx|devkitA64' || true
|
||||
|
||||
# Ensure zsh/bash sessions see DEVKITPRO
|
||||
MARKER="# gen1recomp-devkitpro"
|
||||
for rc in /Users/*/.zshrc /Users/*/.bash_profile; do
|
||||
[ -f "$rc" ] || continue
|
||||
if ! grep -q "$MARKER" "$rc" 2>/dev/null; then
|
||||
{
|
||||
echo ""
|
||||
echo "$MARKER"
|
||||
echo "export DEVKITPRO=/opt/devkitpro"
|
||||
echo 'export PATH="$DEVKITPRO/tools/bin:$DEVKITPRO/devkitA64/bin:$PATH"'
|
||||
} >> "$rc"
|
||||
echo "==> appended DEVKITPRO exports to $rc"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "==> done. Open a new shell, then:"
|
||||
echo " export DEVKITPRO=/opt/devkitpro"
|
||||
echo " scripts/switch/build_ota_launcher.sh"
|
||||
@@ -0,0 +1,16 @@
|
||||
# Switch OTA launcher packaging manifest
|
||||
#
|
||||
# Release / SD tree MUST include the native OTA launcher NRO as the hbmenu
|
||||
# entrypoint. The LÖVE fused game NRO is loaded via envSetNextLoad after the
|
||||
# update check. Self-update of the launcher itself is out of scope for v1.
|
||||
#
|
||||
# OTA downloads the install zip.
|
||||
#
|
||||
# Paths relative to microSD root:
|
||||
|
||||
ENTRY_NRO=switch/gen1recomp/gen1recomp.nro
|
||||
GAME_NRO=switch/gen1recomp/gen1recomp-game.nro
|
||||
SAVE_DIR=switch/gen1recomp/pokemon-love2d
|
||||
OTA_ASSET_GLOB=gen1recomp-*-switch.zip
|
||||
REQUIRE_SHA256SUMS=1
|
||||
OTA_ENABLED=1
|
||||
@@ -2,16 +2,18 @@
|
||||
# Pack a Switch SD-ready zip: extract at microSD root (merge-safe update).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/switch/pack_sd_zip.sh NRO_PATH VERSION OUT_ZIP
|
||||
# scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP [LAUNCHER_NRO]
|
||||
#
|
||||
# Layout inside the zip (SD root):
|
||||
# switch/gen1recomp/gen1recomp.nro
|
||||
# switch/gen1recomp/gen1recomp.nro (launcher if LAUNCHER_NRO set, else game)
|
||||
# switch/gen1recomp/gen1recomp-game.nro (only when LAUNCHER_NRO set)
|
||||
# switch/gen1recomp/version.txt
|
||||
# switch/gen1recomp/INSTALL.txt
|
||||
# switch/gen1recomp/pokemon-love2d/imports/.../README.txt
|
||||
# switch/gen1recomp/pokemon-love2d/exports/.../README.txt
|
||||
#
|
||||
# Does not ship ROMs, saves, or mods. Re-extracting merges over an existing
|
||||
# install and only overwrites the NRO + these text placeholders — keep
|
||||
# install and only overwrites the NRO(s) + these text placeholders — keep
|
||||
# pokemon-love2d/ to preserve progress.
|
||||
|
||||
set -euo pipefail
|
||||
@@ -23,11 +25,15 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
NRO_PATH="${1:-}"
|
||||
VERSION="${2:-}"
|
||||
OUT_ZIP="${3:-}"
|
||||
LAUNCHER_NRO="${4:-}"
|
||||
|
||||
[ -n "$NRO_PATH" ] && [ -n "$VERSION" ] && [ -n "$OUT_ZIP" ] \
|
||||
|| fail "usage: scripts/switch/pack_sd_zip.sh NRO_PATH VERSION OUT_ZIP"
|
||||
|| fail "usage: scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP [LAUNCHER_NRO]"
|
||||
|
||||
[ -f "$NRO_PATH" ] || fail "missing NRO at $NRO_PATH"
|
||||
[ -f "$NRO_PATH" ] || fail "missing game NRO at $NRO_PATH"
|
||||
if [ -n "$LAUNCHER_NRO" ]; then
|
||||
[ -f "$LAUNCHER_NRO" ] || fail "missing launcher NRO at $LAUNCHER_NRO"
|
||||
fi
|
||||
|
||||
command -v zip >/dev/null 2>&1 || fail "need zip on PATH"
|
||||
|
||||
@@ -45,7 +51,15 @@ trap cleanup EXIT
|
||||
APP_DIR="$STAGE/switch/gen1recomp"
|
||||
SAVE_ROOT="$APP_DIR/pokemon-love2d"
|
||||
mkdir -p "$APP_DIR"
|
||||
cp "$NRO_PATH" "$APP_DIR/gen1recomp.nro"
|
||||
|
||||
if [ -n "$LAUNCHER_NRO" ]; then
|
||||
LAUNCHER_NRO="$(cd "$(dirname "$LAUNCHER_NRO")" && pwd)/$(basename "$LAUNCHER_NRO")"
|
||||
cp "$LAUNCHER_NRO" "$APP_DIR/gen1recomp.nro"
|
||||
cp "$NRO_PATH" "$APP_DIR/gen1recomp-game.nro"
|
||||
else
|
||||
cp "$NRO_PATH" "$APP_DIR/gen1recomp.nro"
|
||||
fi
|
||||
printf '%s\n' "$VERSION" > "$APP_DIR/version.txt"
|
||||
|
||||
cat > "$APP_DIR/INSTALL.txt" <<EOF
|
||||
gen1recomp Switch (v${VERSION})
|
||||
@@ -55,7 +69,7 @@ First install or update (same steps):
|
||||
1. Extract this zip at the root of your microSD (merge folders if asked).
|
||||
2. Do NOT delete switch/gen1recomp/pokemon-love2d/ — that folder holds
|
||||
your saves, imported ROMs, mods, and options. Re-extracting only
|
||||
replaces gen1recomp.nro and these help files.
|
||||
replaces the NRO(s) and these help files.
|
||||
3. Launch with title override (hold R on HOME, open any title → hbmenu).
|
||||
4. Copy a legal Pokemon Red/Blue .gb into:
|
||||
switch/gen1recomp/pokemon-love2d/imports/
|
||||
@@ -106,9 +120,16 @@ rm -f "$OUT_ZIP"
|
||||
LISTING="$(unzip -Z1 "$OUT_ZIP" 2>/dev/null || unzip -l "$OUT_ZIP")"
|
||||
printf '%s\n' "$LISTING" | grep -q 'switch/gen1recomp/gen1recomp.nro' \
|
||||
|| fail "zip missing switch/gen1recomp/gen1recomp.nro"
|
||||
printf '%s\n' "$LISTING" | grep -Fq 'switch/gen1recomp/version.txt' \
|
||||
|| fail "zip missing switch/gen1recomp/version.txt"
|
||||
if [ -n "$LAUNCHER_NRO" ]; then
|
||||
printf '%s\n' "$LISTING" | grep -Fq 'switch/gen1recomp/gen1recomp-game.nro' \
|
||||
|| fail "zip missing switch/gen1recomp/gen1recomp-game.nro (OTA dual-NRO layout)"
|
||||
fi
|
||||
|
||||
REQUIRED=(
|
||||
"switch/gen1recomp/INSTALL.txt"
|
||||
"switch/gen1recomp/version.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/imports/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/imports/mods/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt"
|
||||
|
||||
@@ -51,10 +51,11 @@ MISSING=""
|
||||
printf '%s' "$HELP_LC" | grep -q 'fetch' || MISSING="${MISSING} fetch"
|
||||
printf '%s' "$HELP_LC" | grep -q 'loose' || MISSING="${MISSING} loose"
|
||||
printf '%s' "$HELP_LC" | grep -q 'fused' || MISSING="${MISSING} fused"
|
||||
printf '%s' "$HELP_LC" | grep -Eq 'devkitpro' || MISSING="${MISSING} DEVKITPRO"
|
||||
printf '%s' "$HELP_LC" | grep -Eq 'auto-download|downloads' || MISSING="${MISSING} auto-download"
|
||||
printf '%s' "$HELP_LC" | grep -Eq 'non-goal|does not|never' || MISSING="${MISSING} non-goals"
|
||||
if [ -z "$MISSING" ]; then
|
||||
ok "build_switch.sh --help mentions fetch, loose, fused (+ auto-download/non-goals)"
|
||||
ok "build_switch.sh --help mentions fetch, loose, fused, DEVKITPRO (+ auto-download/non-goals)"
|
||||
else
|
||||
bad "build_switch.sh --help missing:$MISSING"
|
||||
fi
|
||||
@@ -180,7 +181,41 @@ else
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5c. fail_fused_toolchain mentions docs/switch-build.md
|
||||
# 5c2. fail_missing_devkitpro cites install_devkitpro_deps.sh
|
||||
# ---------------------------------------------------------------------------
|
||||
MDK_ERR="$STAGING/missing-dkp.err"
|
||||
MDK_RC=0
|
||||
(
|
||||
. "$SCRIPT_DIR/common.sh"
|
||||
fail_missing_devkitpro
|
||||
) >"$STAGING/missing-dkp.out" 2>"$MDK_ERR" || MDK_RC=$?
|
||||
|
||||
if [ "$MDK_RC" -ne 0 ] \
|
||||
&& grep -q 'install_devkitpro_deps.sh' "$MDK_ERR" \
|
||||
&& grep -q 'DEVKITPRO' "$MDK_ERR" \
|
||||
&& grep -q 'switch-dev' "$MDK_ERR"; then
|
||||
ok "fail_missing_devkitpro cites DEVKITPRO + switch-dev + install_devkitpro_deps.sh"
|
||||
else
|
||||
bad "fail_missing_devkitpro should cite setup steps (rc=$MDK_RC err=$(cat "$MDK_ERR"))"
|
||||
fi
|
||||
|
||||
OTA_ERR="$STAGING/missing-ota.err"
|
||||
OTA_RC=0
|
||||
(
|
||||
. "$SCRIPT_DIR/common.sh"
|
||||
fail_missing_ota_deps
|
||||
) >"$STAGING/missing-ota.out" 2>"$OTA_ERR" || OTA_RC=$?
|
||||
|
||||
if [ "$OTA_RC" -ne 0 ] \
|
||||
&& grep -q 'install_devkitpro_deps.sh' "$OTA_ERR" \
|
||||
&& grep -qi 'docker' "$OTA_ERR"; then
|
||||
ok "fail_missing_ota_deps cites native or Docker options"
|
||||
else
|
||||
bad "fail_missing_ota_deps should cite native + Docker (rc=$OTA_RC err=$(cat "$OTA_ERR"))"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5d. fail_fused_toolchain mentions docs/switch-build.md
|
||||
# ---------------------------------------------------------------------------
|
||||
FT_ERR="$STAGING/fused-toolchain.err"
|
||||
FT_RC=0
|
||||
@@ -228,6 +263,7 @@ ZIP_LIST="$(unzip -Z1 "$FAKE_ZIP" 2>/dev/null || unzip -l "$FAKE_ZIP")"
|
||||
PACK_MISSING=""
|
||||
for rel in \
|
||||
"switch/gen1recomp/gen1recomp.nro" \
|
||||
"switch/gen1recomp/version.txt" \
|
||||
"switch/gen1recomp/INSTALL.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/imports/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/imports/mods/README.txt" \
|
||||
@@ -311,6 +347,94 @@ else
|
||||
bad "pack_sd_zip.sh merge update lost user data or failed to replace NRO"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Dual-NRO OTA layout (launcher + game) when 4th arg set
|
||||
# ---------------------------------------------------------------------------
|
||||
FAKE_LAUNCHER="$STAGING/fake-launcher.nro"
|
||||
FAKE_GAME="$STAGING/fake-game.nro"
|
||||
printf 'fake-launcher\n' > "$FAKE_LAUNCHER"
|
||||
printf 'fake-game\n' > "$FAKE_GAME"
|
||||
OTA_ZIP="$STAGING/gen1recomp-0.0.2-ota-layout-switch.zip"
|
||||
OTA_RC=0
|
||||
"$ROOT/scripts/switch/pack_sd_zip.sh" "$FAKE_GAME" "0.0.2" "$OTA_ZIP" "$FAKE_LAUNCHER" \
|
||||
>"$STAGING/ota-pack.out" 2>"$STAGING/ota-pack.err" || OTA_RC=$?
|
||||
OTA_LIST="$(unzip -Z1 "$OTA_ZIP" 2>/dev/null || true)"
|
||||
if [ "$OTA_RC" -eq 0 ] \
|
||||
&& printf '%s\n' "$OTA_LIST" | grep -Fq 'switch/gen1recomp/gen1recomp.nro' \
|
||||
&& printf '%s\n' "$OTA_LIST" | grep -Fq 'switch/gen1recomp/gen1recomp-game.nro' \
|
||||
&& printf '%s\n' "$OTA_LIST" | grep -Fq 'switch/gen1recomp/version.txt'
|
||||
then
|
||||
EXTRACT_OTA="$STAGING/extract-ota"
|
||||
rm -rf "$EXTRACT_OTA"
|
||||
mkdir -p "$EXTRACT_OTA"
|
||||
unzip -q "$OTA_ZIP" -d "$EXTRACT_OTA"
|
||||
DUAL_OK=1
|
||||
cmp -s "$FAKE_LAUNCHER" "$EXTRACT_OTA/switch/gen1recomp/gen1recomp.nro" || DUAL_OK=0
|
||||
cmp -s "$FAKE_GAME" "$EXTRACT_OTA/switch/gen1recomp/gen1recomp-game.nro" || DUAL_OK=0
|
||||
[ "$(cat "$EXTRACT_OTA/switch/gen1recomp/version.txt")" = "0.0.2" ] || DUAL_OK=0
|
||||
if [ "$DUAL_OK" -eq 1 ]; then
|
||||
ok "pack_sd_zip.sh dual-NRO OTA layout (launcher + game + version.txt)"
|
||||
else
|
||||
bad "pack_sd_zip.sh dual-NRO bytes/version mismatch"
|
||||
fi
|
||||
else
|
||||
bad "pack_sd_zip.sh dual-NRO layout failed (rc=$OTA_RC err=$(cat "$STAGING/ota-pack.err"))"
|
||||
fi
|
||||
|
||||
MANIFEST="$ROOT/scripts/switch/ota_launcher.manifest"
|
||||
if [ -f "$MANIFEST" ] && grep -q '^OTA_ENABLED=1$' "$MANIFEST" \
|
||||
&& grep -q 'ENTRY_NRO=switch/gen1recomp/gen1recomp.nro' "$MANIFEST" \
|
||||
&& grep -q 'GAME_NRO=switch/gen1recomp/gen1recomp-game.nro' "$MANIFEST"
|
||||
then
|
||||
ok "ota_launcher.manifest requires dual-NRO when OTA_ENABLED=1"
|
||||
else
|
||||
bad "ota_launcher.manifest missing OTA dual-NRO requirements"
|
||||
fi
|
||||
|
||||
if [ -f "$ROOT/scripts/switch/build_ota_launcher.sh" ] \
|
||||
&& grep -q 'ota_launcher_deps_ready' "$ROOT/scripts/switch/build_ota_launcher.sh" \
|
||||
&& grep -q 'fail_missing_devkitpro' "$ROOT/scripts/switch/build_ota_launcher.sh" \
|
||||
&& grep -q 'preflight_fused_build' "$ROOT/scripts/build_switch.sh" \
|
||||
&& [ -f "$ROOT/ports/switch/ota-launcher/Makefile" ]
|
||||
then
|
||||
ok "native OTA launcher sources + build_ota_launcher.sh present"
|
||||
else
|
||||
bad "missing native OTA launcher tree or build script"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Unified OTA asset = dual-NRO SD zip (no separate OTA-only archive)
|
||||
# ---------------------------------------------------------------------------
|
||||
LEGACY_OTA_PACKER="$ROOT/scripts/switch/pack_ota_zip.sh"
|
||||
if [ ! -f "$LEGACY_OTA_PACKER" ] \
|
||||
&& ! grep -Eq 'pack_ota_zip\.sh|switch-ota\.zip' "$ROOT/scripts/build_switch.sh" \
|
||||
&& grep -q 'OTA_ASSET_GLOB=gen1recomp-\*-switch.zip' "$MANIFEST"
|
||||
then
|
||||
ok "OTA uses the same SD zip as install (legacy OTA-only packer gone)"
|
||||
else
|
||||
bad "legacy separate OTA-only packer / asset still present"
|
||||
fi
|
||||
|
||||
if grep -q 'ota_ui_prompt_update' "$ROOT/ports/switch/ota-launcher/src/main.c" \
|
||||
&& grep -q 'ota_net_init' "$ROOT/ports/switch/ota-launcher/src/main.c" \
|
||||
&& grep -q 'framebufferCreate\|COL_RAIL' "$ROOT/ports/switch/ota-launcher/src/ota_ui.c" \
|
||||
&& grep -q '^ROMFS' "$ROOT/ports/switch/ota-launcher/Makefile" \
|
||||
&& grep -q 'cacert.pem' "$ROOT/ports/switch/ota-launcher/Makefile"
|
||||
then
|
||||
ok "launcher UI uses framebuffer (no prompt when up to date)"
|
||||
else
|
||||
bad "launcher missing branded ota_ui / ROMFS"
|
||||
fi
|
||||
|
||||
if [ -f "$ROOT/ports/switch/ota-launcher/src/ota_unzip.c" ] \
|
||||
&& grep -q 'zzip/zzip.h' "$ROOT/ports/switch/ota-launcher/src/ota_unzip.c" \
|
||||
&& grep -q 'ota_unzip_extract_file' "$ROOT/ports/switch/ota-launcher/src/main.c"
|
||||
then
|
||||
ok "launcher wires zziplib ota_unzip_extract_file"
|
||||
else
|
||||
bad "launcher missing zziplib unzip wiring"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -66,6 +66,7 @@ run_tier() {
|
||||
|
||||
# ------- ROM-free tiers: these are what CI runs
|
||||
|
||||
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
|
||||
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
|
||||
run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
|
||||
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
|
||||
|
||||
@@ -37,6 +37,12 @@ local BattleState = {}
|
||||
BattleState.__index = BattleState
|
||||
BattleState.isOpaque = true
|
||||
|
||||
-- Category identity for per-category GAME SPEED (RFC 0007), the same
|
||||
-- style OverworldController.isOverworld already uses. Every battle --
|
||||
-- wild, trainer, link, safari, the old-man demo -- is this metatable, so
|
||||
-- Game.speedCategoryInStack needs no special-casing beyond this one flag.
|
||||
BattleState.isBattle = true
|
||||
|
||||
function BattleState:romText(label, fallback, ...)
|
||||
return romText(self.data, label, fallback, ...)
|
||||
end
|
||||
@@ -98,6 +104,15 @@ function BattleState:bgMode()
|
||||
return "white"
|
||||
end
|
||||
|
||||
-- Resume a semantic checkpoint directly at the command menu. Unlike enter(),
|
||||
-- this deliberately does not replay the battle transition, intro queues,
|
||||
-- cries, happiness changes, or battle-start events.
|
||||
function BattleState:resumeCheckpoint()
|
||||
self.isOpaque = self:bgMode() ~= "world"
|
||||
require("src.core.Music").playBattle(self.data,
|
||||
self.musicKind or self:computeMusicKind())
|
||||
end
|
||||
|
||||
-- How far to dim the overworld behind a "world" background, 0..1. Enough
|
||||
-- that the battle reads as the foreground rather than competing with a fully
|
||||
-- lit map behind it.
|
||||
@@ -118,6 +133,18 @@ function BattleState:sgbPalettes()
|
||||
return nil
|
||||
end
|
||||
|
||||
function BattleState:bottomUIVisible()
|
||||
if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end
|
||||
return Runtime.call("battle.bottom_ui_visible", function() return true end,
|
||||
self) ~= false
|
||||
end
|
||||
|
||||
function BattleState:statusHUDVisible()
|
||||
if not Runtime.wantsHook("battle.status_hud_visible") then return true end
|
||||
return Runtime.call("battle.status_hud_visible", function() return true end,
|
||||
self) ~= false
|
||||
end
|
||||
|
||||
local Rulesets = {
|
||||
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
|
||||
modern_clean = require("src.battle.rulesets.modern_clean"),
|
||||
@@ -424,7 +451,7 @@ function StatBox:draw()
|
||||
{ Strings("SPEED"), s.speed },
|
||||
{ Strings("SPECIAL"), s.special } }
|
||||
for i, r in ipairs(rows) do
|
||||
Font.draw(r[1], 88, 24 + (i - 1) * 16)
|
||||
Font.draw(Strings(r[1]), 88, 24 + (i - 1) * 16)
|
||||
Font.draw(("%3d"):format(r[2]), 128, 32 + (i - 1) * 16)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -1411,6 +1438,26 @@ function BattleState:computeMusicKind()
|
||||
return "wild"
|
||||
end
|
||||
|
||||
-- a mod-set per-trainer battle theme (trainers.battleTheme, an audio.songs
|
||||
-- id); nil for vanilla trainers, so the kind default is untouched (#782)
|
||||
function BattleState:battleTheme()
|
||||
local trainer = self.trainer
|
||||
if trainer and trainer.battleTheme then return trainer.battleTheme end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- the battle-theme cue for this battle: the mod-set trainer battleTheme
|
||||
-- when the class has one, else the kind default. The single choke point
|
||||
-- both the transition-wipe start (OverworldController:pushBattle) and
|
||||
-- enter() route through, so a per-trainer override can't drift between
|
||||
-- them. self.musicKind is set by enter(); pushBattle runs before that,
|
||||
-- so compute it here when absent.
|
||||
function BattleState:playBattleTheme()
|
||||
require("src.core.Music").playBattle(self.data,
|
||||
self.musicKind or self:computeMusicKind(),
|
||||
self.trainer and self.trainer.id, self:battleTheme())
|
||||
end
|
||||
|
||||
-- side tables mirror the singles battlers; called before every
|
||||
-- battler-switch notification so sides[i].battlers[1] stays honest
|
||||
function BattleState:syncSides()
|
||||
@@ -1462,7 +1509,6 @@ function BattleState:enter()
|
||||
.. Strings("%s blacked\nout!", name), blackedOut))
|
||||
return
|
||||
end
|
||||
local Music = require("src.core.Music")
|
||||
self.musicKind = self:computeMusicKind()
|
||||
if self.isGymLeader then
|
||||
require("src.world.PikachuFollower")
|
||||
@@ -1472,7 +1518,7 @@ function BattleState:enter()
|
||||
-- (audio/play_battle_music.asm runs before the transition, and
|
||||
-- Music.play no-ops on the same song); this covers battles pushed
|
||||
-- without a transition (link battles, scripted pushes)
|
||||
Music.playBattle(self.data, self.musicKind)
|
||||
self:playBattleTheme()
|
||||
-- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both
|
||||
-- sides slide in; the trainer pics stay up until the send-outs
|
||||
-- BATTLE BG "world" drops this battle's opacity so StateStack keeps drawing
|
||||
@@ -1865,6 +1911,7 @@ function BattleState:update(dt)
|
||||
end
|
||||
self.menuIndex = row * 2 + col + 1
|
||||
if input:wasPressed("a") then
|
||||
require("src.core.Sound").play(self.data, "Press_AB")
|
||||
self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex])
|
||||
end
|
||||
return
|
||||
@@ -1901,6 +1948,7 @@ function BattleState:update(dt)
|
||||
end
|
||||
self.menuIndex = row * 2 + col + 1
|
||||
if input:wasPressed("a") then
|
||||
require("src.core.Sound").play(self.data, "Press_AB")
|
||||
local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex]
|
||||
if choice == "fight" and self.ghost then
|
||||
self:say(Strings("%s is too\nscared to move!", self.player.name))
|
||||
@@ -1962,9 +2010,11 @@ function BattleState:update(dt)
|
||||
self.moveSwapIndex = self.moveIndex
|
||||
end
|
||||
elseif input:wasPressed("b") then
|
||||
require("src.core.Sound").play(self.data, "Press_AB")
|
||||
self.moveSwapIndex = nil
|
||||
self.phase = "menu"
|
||||
elseif input:wasPressed("a") then
|
||||
require("src.core.Sound").play(self.data, "Press_AB")
|
||||
if self.moveSwapIndex then
|
||||
self:swapMoves(self.moveSwapIndex, self.moveIndex)
|
||||
self.moveSwapIndex = nil
|
||||
@@ -2003,6 +2053,7 @@ function BattleState:update(dt)
|
||||
elseif input:wasPressed("down") then
|
||||
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
|
||||
elseif input:wasPressed("a") then
|
||||
require("src.core.Sound").play(self.data, "Press_AB")
|
||||
local pick = moves[self.mimicIndex]
|
||||
local ctx = self.mimicCtx
|
||||
self.mimicMoves, self.mimicCtx = nil, nil
|
||||
@@ -5319,7 +5370,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
|
||||
-- .mimicmenu) wipes rows 7+. The port draws pics above the menu
|
||||
-- layer in the colorized pipeline, so clip them to the visible rows.
|
||||
local g = love.graphics
|
||||
local clipY = not skipMenuClip
|
||||
local clipY = not skipMenuClip and self:bottomUIVisible()
|
||||
and (self.phase == "mimicSelect" and 56
|
||||
or self.phase == "moveSelect" and 64)
|
||||
or nil
|
||||
@@ -5431,6 +5482,7 @@ function BattleState:drawHUDs(slide)
|
||||
-- per-pixel tint (grayFill) -- otherwise GREENBAR's red-channel-0 fill
|
||||
-- double-applies and the zone shade shader maps the whole bar to black (#229).
|
||||
local grayFill = self:colorMode()
|
||||
local showStatus = self:statusHUDVisible()
|
||||
local barData = self.data
|
||||
local fx = self.fx
|
||||
local hudShake = (fx and fx.hudShakeX) or 0
|
||||
@@ -5440,7 +5492,8 @@ function BattleState:drawHUDs(slide)
|
||||
-- DrawEnemyHUDAndHPBar is called from _InitBattleCommon (core.asm:6763)
|
||||
-- AFTER PrintBeginningBattleText returns, so "Wild X appeared!" shows the
|
||||
-- player's ball row with no enemy HUD beside it (#317)
|
||||
if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut
|
||||
if showStatus and self.enemy and not self.showEnemyTrainer
|
||||
and not self.enemySendingOut
|
||||
and not self:growInScale(self.enemy) and slide == 0
|
||||
and not self.introBalls and not self.enemy.fainted then
|
||||
-- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, <LV>+level (4,1),
|
||||
@@ -5527,7 +5580,7 @@ function BattleState:drawHUDs(slide)
|
||||
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
|
||||
end
|
||||
local hidePlayer = self.safari or self.demo
|
||||
if self.player and not hidePlayer and not self.showPlayerBack
|
||||
if showStatus and self.player and not hidePlayer and not self.showPlayerBack
|
||||
and slide == 0 then
|
||||
-- player HUD (DrawPlayerHUDAndHPBar): name (10,7), <LV>+level
|
||||
-- (14,8), HP bar (10,9), HP numbers row 10, underline row 11 with
|
||||
@@ -5552,6 +5605,7 @@ function BattleState:drawHUDs(slide)
|
||||
end
|
||||
|
||||
function BattleState:drawTextArea()
|
||||
if not self:bottomUIVisible() then return end
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if self.phase == "messages"
|
||||
@@ -5587,9 +5641,9 @@ function BattleState:drawTextArea()
|
||||
-- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16)
|
||||
Font.drawBox(8, 12, 12, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("FIGHT"), 80, 112)
|
||||
Font.draw(Strings("FIGHT", "battle"), 80, 112)
|
||||
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
|
||||
Font.draw(Strings("ITEM"), 80, 128); Font.draw(Strings("RUN"), 128, 128)
|
||||
Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128)
|
||||
Font.drawCode(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128)
|
||||
elseif self.phase == "menu" then
|
||||
local col = (self.menuIndex - 1) % 2
|
||||
@@ -5599,7 +5653,7 @@ function BattleState:drawTextArea()
|
||||
-- THROW ROCK RUN" from (2,14)
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
Font.draw(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112)
|
||||
Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN"), 112, 128)
|
||||
Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN", "battle"), 112, 128)
|
||||
-- DisplayBattleMenu .safariLeftColumn / .safariRightColumn print
|
||||
-- wNumSafariBalls at hlcoord 7,14 with `lb bc, 1, 2` -- one byte, two
|
||||
-- digits, space padded -- right after the "BALLx" label at columns
|
||||
@@ -5610,9 +5664,9 @@ function BattleState:drawTextArea()
|
||||
-- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT <PK><MN> /
|
||||
-- ITEM RUN" from (10,14); cursor columns 9 / 15
|
||||
Font.drawBox(8, 12, 12, 6)
|
||||
Font.draw(Strings("FIGHT"), 80, 112)
|
||||
Font.draw(Strings("FIGHT", "battle"), 80, 112)
|
||||
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
|
||||
Font.draw(Strings("ITEM"), 80, 128); Font.draw(Strings("RUN"), 128, 128)
|
||||
Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128)
|
||||
Font.drawCode(0xED, (col == 0 and 72 or 120), 112 + row * 16)
|
||||
end
|
||||
elseif self.phase == "moveSelect" then
|
||||
|
||||
@@ -281,11 +281,11 @@ MoveEffects.primary = {
|
||||
failed = true }
|
||||
end
|
||||
local cost = math.floor(user.mon.stats.hp / 4)
|
||||
-- substitute.asm only fails on subtraction underflow (current HP
|
||||
-- strictly below maxHP/4); at equality the substitute is built and
|
||||
-- the user is left standing on exactly 0 HP (it faints only when
|
||||
-- the engine next checks HP, not here)
|
||||
if user.mon.hp < cost then
|
||||
-- A Substitute costs one quarter of max HP, rounded down. Do not let
|
||||
-- the cost consume the user's last HP: the move must fail at the exact
|
||||
-- boundary as well as below it, or the next turn's HP guard can leave a
|
||||
-- trainer battle unable to progress.
|
||||
if user.mon.hp <= cost then
|
||||
return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!"),
|
||||
failed = true }
|
||||
end
|
||||
|
||||
@@ -128,7 +128,8 @@ local function drawIntroBalls(battle)
|
||||
end
|
||||
|
||||
local function drawHUDs(battle, slide)
|
||||
if battle.enemy and not battle.showEnemyTrainer
|
||||
local showStatus = battle:statusHUDVisible()
|
||||
if showStatus and battle.enemy and not battle.showEnemyTrainer
|
||||
and not battle.enemySendingOut and not battle:growInScale(battle.enemy)
|
||||
and slide == 0 and not battle.introBalls and not battle.enemy.fainted then
|
||||
drawStatusPanel(battle, battle.enemy, 0, 0, false)
|
||||
@@ -139,7 +140,7 @@ local function drawHUDs(battle, slide)
|
||||
-- item, not a HUD element (DisplayBattleMenu prints wNumSafariBalls inside
|
||||
-- the battle menu box, engine/battle/core.asm:2074-2079), so it rides in
|
||||
-- drawCommandMenu below like the classic layout's (#540).
|
||||
if not battle.safari and battle.player and not battle.demo
|
||||
if showStatus and not battle.safari and battle.player and not battle.demo
|
||||
and not battle.showPlayerBack and slide == 0 then
|
||||
drawStatusPanel(battle, battle.player, 184, 56, true)
|
||||
end
|
||||
@@ -255,6 +256,7 @@ local function drawMoveMenu(battle)
|
||||
end
|
||||
|
||||
local function drawTextArea(battle)
|
||||
if not battle:bottomUIVisible() then return end
|
||||
if battle.phase == "messages" and (battle.current or battle.animPlaying) then
|
||||
drawMessageBox(battle)
|
||||
elseif battle.phase == "menu" then
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
-- Semantic, data-only capture for settled single-player battle checkpoints.
|
||||
-- Reconstruction lives here too; public mods only see the opaque checkpoint
|
||||
-- facade in Loader.
|
||||
|
||||
local BattleCheckpoint = {}
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local BUILTIN_RULESETS = {
|
||||
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
|
||||
modern_clean = require("src.battle.rulesets.modern_clean"),
|
||||
}
|
||||
|
||||
local function rulesets(game)
|
||||
return game.data.rulesets or BUILTIN_RULESETS
|
||||
end
|
||||
|
||||
local function rulesetId(game, record)
|
||||
for id, candidate in pairs(rulesets(game)) do
|
||||
if candidate == record then return id end
|
||||
end
|
||||
end
|
||||
|
||||
local BATTLER_FIELDS = {
|
||||
"shownHP", "shownStatus", "stages", "curStats", "curTypes", "curMoves",
|
||||
"sleepTurns", "confusedTurns", "disabledSlot", "disabledTurns",
|
||||
"toxicCounter", "substituteHP", "bideDamage", "bideTurns", "boundTurns",
|
||||
"chargeReady", "invulnerable", "mustRecharge",
|
||||
"thrashTurns", "thrashAnnounced", "focusEnergy", "leechSeeded",
|
||||
"lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched",
|
||||
"skipMove", "hazeStatReset", "drainFloor", "drainHold", "trappingTurns",
|
||||
"trapMove", "trapDamage", "fainted",
|
||||
"aiLayer2",
|
||||
}
|
||||
|
||||
local MOVE_REFERENCE_FIELDS = {
|
||||
charging = "chargingSlot",
|
||||
thrashMove = "thrashMoveSlot",
|
||||
rageMove = "rageMoveSlot",
|
||||
}
|
||||
|
||||
local BATTLE_FIELDS = {
|
||||
"oppClass", "partyIndex", "enemyIndex", "turnCount", "menuIndex",
|
||||
"moveIndex", "moveSwapIndex", "aiUses", "runAttempts", "payDay",
|
||||
"sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall",
|
||||
"lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed",
|
||||
"endBattleText",
|
||||
}
|
||||
|
||||
local function partyIndex(party, mon)
|
||||
for index, candidate in ipairs(party or {}) do
|
||||
if candidate == mon then return index end
|
||||
end
|
||||
end
|
||||
|
||||
local function indexSet(set, party)
|
||||
local out = {}
|
||||
for mon, present in pairs(set or {}) do
|
||||
if present then
|
||||
local index = partyIndex(party, mon)
|
||||
if index then out[#out + 1] = index end
|
||||
end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
|
||||
local function captureBattler(battler, index, copy)
|
||||
local out = {
|
||||
index = index,
|
||||
curStatsFromMon = battler.curStats == battler.mon.stats,
|
||||
curTypesFromDefinition = battler.curTypes == battler.def.types,
|
||||
curMovesFromMon = battler.curMoves == battler.mon.moves,
|
||||
}
|
||||
for _, field in ipairs(BATTLER_FIELDS) do
|
||||
if battler[field] ~= nil then out[field] = battler[field] end
|
||||
end
|
||||
for field, slotField in pairs(MOVE_REFERENCE_FIELDS) do
|
||||
local reference = battler[field]
|
||||
if reference ~= nil then
|
||||
for slot, move in ipairs(battler.curMoves or {}) do
|
||||
if move == reference then out[slotField] = slot break end
|
||||
end
|
||||
if out[slotField] == nil then return nil end
|
||||
end
|
||||
end
|
||||
return copy(out)
|
||||
end
|
||||
|
||||
local function integer(value, min, max)
|
||||
return type(value) == "number" and value % 1 == 0
|
||||
and value >= (min or -math.huge) and value <= (max or math.huge)
|
||||
end
|
||||
|
||||
local function validateMoveList(data, moves)
|
||||
if type(moves) ~= "table" then return false end
|
||||
for _, move in ipairs(moves) do
|
||||
if type(move) ~= "table" or type(move.id) ~= "string"
|
||||
or type(data.moves[move.id]) ~= "table" or type(move.pp) ~= "number" then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function validateMon(data, mon)
|
||||
return type(mon) == "table" and type(mon.species) == "string"
|
||||
and type(data.pokemon[mon.species]) == "table" and integer(mon.level, 1, 100)
|
||||
and type(mon.hp) == "number" and type(mon.stats) == "table"
|
||||
and validateMoveList(data, mon.moves)
|
||||
end
|
||||
|
||||
local function validateBattler(data, battler, maxIndex)
|
||||
if type(battler) ~= "table" or not integer(battler.index, 1, maxIndex) then
|
||||
return false
|
||||
end
|
||||
if type(battler.curMoves) ~= "table" then return false end
|
||||
if battler.stages ~= nil then
|
||||
if type(battler.stages) ~= "table" then return false end
|
||||
for _, stage in pairs(battler.stages) do
|
||||
if not integer(stage, -6, 6) then return false end
|
||||
end
|
||||
end
|
||||
for _, slotField in pairs(MOVE_REFERENCE_FIELDS) do
|
||||
if battler[slotField] ~= nil
|
||||
and not integer(battler[slotField], 1, #battler.curMoves) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return validateMoveList(data, battler.curMoves)
|
||||
and type(battler.curStats) == "table" and type(battler.curTypes) == "table"
|
||||
end
|
||||
|
||||
local function clone(value, copy)
|
||||
if type(value) ~= "table" then return value end
|
||||
return assert(copy(value))
|
||||
end
|
||||
|
||||
local function captureMimicRestores(battle)
|
||||
local out = {}
|
||||
for _, restore in ipairs(battle.mimicRestores or {}) do
|
||||
local side = restore.battler == battle.player and "player"
|
||||
or restore.battler == battle.enemy and "enemy" or nil
|
||||
local slot
|
||||
for index, move in ipairs(restore.battler and restore.battler.curMoves or {}) do
|
||||
if move == restore.entry then slot = index break end
|
||||
end
|
||||
if not side or not slot or type(restore.id) ~= "string" then return nil end
|
||||
out[#out + 1] = { side = side, slot = slot, id = restore.id }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function BattleCheckpoint.validate(game, checkpoint)
|
||||
local model = checkpoint.runtime and checkpoint.runtime.battle
|
||||
local rngState = checkpoint.rng and checkpoint.rng.love
|
||||
if type(model) ~= "table" or type(model.origin) ~= "table"
|
||||
or type(rngState) ~= "string" or rngState == "" then
|
||||
return nil, "invalid_checkpoint", "Battle checkpoint data or RNG is missing."
|
||||
end
|
||||
local expectedOrigin = model.kind == "wild" and "wild_encounter"
|
||||
or model.kind == "trainer" and "trainer_encounter" or nil
|
||||
if not expectedOrigin or model.origin.kind ~= expectedOrigin
|
||||
or model.origin.map ~= checkpoint.runtime.overworld.map then
|
||||
return nil, "battle_origin_unsupported",
|
||||
"Battle continuation data is unsupported or inconsistent."
|
||||
end
|
||||
if type(model.rulesetId) ~= "string"
|
||||
or type(rulesets(game)[model.rulesetId]) ~= "table" then
|
||||
return nil, "invalid_content", "Battle ruleset is unavailable."
|
||||
end
|
||||
if model.kind == "trainer" and (type(model.origin.npcId) ~= "string"
|
||||
or model.origin.trainerClass ~= model.oppClass
|
||||
or model.origin.partyIndex ~= (model.partyIndex or 1)) then
|
||||
return nil, "battle_origin_unsupported",
|
||||
"Trainer continuation data is incomplete or inconsistent."
|
||||
end
|
||||
local party = checkpoint.save.party
|
||||
if type(party) ~= "table" or not validateBattler(game.data, model.player, #party) then
|
||||
return nil, "invalid_content", "Player battle state is invalid."
|
||||
end
|
||||
if model.kind == "wild" then
|
||||
if not validateMon(game.data, model.enemyMon)
|
||||
or not validateBattler(game.data, model.enemy, 1) then
|
||||
return nil, "invalid_content", "Wild opponent state is invalid."
|
||||
end
|
||||
else
|
||||
local trainer = game.data.trainers and game.data.trainers[model.oppClass]
|
||||
if type(trainer) ~= "table" or not integer(model.partyIndex, 1)
|
||||
or type(model.enemyParty) ~= "table" or #model.enemyParty == 0
|
||||
or not integer(model.enemyIndex, 1, #model.enemyParty)
|
||||
or not validateBattler(game.data, model.enemy, #model.enemyParty) then
|
||||
return nil, "invalid_content", "Trainer battle identity or roster is invalid."
|
||||
end
|
||||
for _, mon in ipairs(model.enemyParty) do
|
||||
if not validateMon(game.data, mon) then
|
||||
return nil, "invalid_content", "Trainer opponent state is invalid."
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, indices in ipairs({ model.participants, model.leveledUp }) do
|
||||
if type(indices) ~= "table" then
|
||||
return nil, "invalid_checkpoint", "Battle party reference set is missing."
|
||||
end
|
||||
for _, index in ipairs(indices) do
|
||||
if not integer(index, 1, #party) then
|
||||
return nil, "invalid_checkpoint", "Battle party reference is invalid."
|
||||
end
|
||||
end
|
||||
end
|
||||
if type(model.mimicRestores) ~= "table" then
|
||||
return nil, "invalid_checkpoint", "Mimic restore state is missing."
|
||||
end
|
||||
for _, restore in ipairs(model.mimicRestores) do
|
||||
local battler = restore.side == "player" and model.player
|
||||
or restore.side == "enemy" and model.enemy or nil
|
||||
if not battler or not integer(restore.slot, 1, #battler.curMoves)
|
||||
or type(restore.id) ~= "string"
|
||||
or type(game.data.moves[restore.id]) ~= "table" then
|
||||
return nil, "invalid_content", "Mimic restore state is invalid."
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function applyBattler(target, captured, copy)
|
||||
for _, field in ipairs(BATTLER_FIELDS) do
|
||||
if field ~= "curStats" and field ~= "curTypes" and field ~= "curMoves" then
|
||||
if captured[field] ~= nil then
|
||||
target[field] = clone(captured[field], copy)
|
||||
else
|
||||
target[field] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
target.curStats = captured.curStatsFromMon and target.mon.stats
|
||||
or assert(copy(captured.curStats))
|
||||
target.curTypes = captured.curTypesFromDefinition and target.def.types
|
||||
or assert(copy(captured.curTypes))
|
||||
target.curMoves = captured.curMovesFromMon and target.mon.moves
|
||||
or assert(copy(captured.curMoves))
|
||||
for field, slotField in pairs(MOVE_REFERENCE_FIELDS) do
|
||||
target[field] = captured[slotField] and target.curMoves[captured[slotField]] or nil
|
||||
end
|
||||
return target
|
||||
end
|
||||
|
||||
local function restoreIndexSet(indices, party)
|
||||
local out = {}
|
||||
for _, index in ipairs(indices or {}) do out[party[index]] = true end
|
||||
return next(out) and out or nil
|
||||
end
|
||||
|
||||
function BattleCheckpoint.restore(game, checkpoint, copy)
|
||||
local model = checkpoint.runtime.battle
|
||||
local battle
|
||||
if model.kind == "trainer" then
|
||||
battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex)
|
||||
battle.enemyParty = assert(copy(model.enemyParty))
|
||||
battle.enemyIndex = model.enemyIndex
|
||||
else
|
||||
battle = BattleState.newWild(game, model.enemyMon.species, model.enemyMon.level)
|
||||
end
|
||||
|
||||
battle.player = BattleState.makeBattler(game.data,
|
||||
game.save.party[model.player.index], true, game.save)
|
||||
applyBattler(battle.player, model.player, copy)
|
||||
local enemyMon
|
||||
if model.kind == "trainer" then
|
||||
enemyMon = battle.enemyParty[model.enemy.index]
|
||||
else
|
||||
enemyMon = assert(copy(model.enemyMon))
|
||||
end
|
||||
battle.enemy = BattleState.makeBattler(game.data, enemyMon, false)
|
||||
applyBattler(battle.enemy, model.enemy, copy)
|
||||
|
||||
battle.mimicRestores = {}
|
||||
for _, restore in ipairs(model.mimicRestores or {}) do
|
||||
local battler = restore.side == "player" and battle.player or battle.enemy
|
||||
battle.mimicRestores[#battle.mimicRestores + 1] = {
|
||||
battler = battler,
|
||||
entry = battler.curMoves[restore.slot],
|
||||
id = restore.id,
|
||||
}
|
||||
end
|
||||
if #battle.mimicRestores == 0 then battle.mimicRestores = nil end
|
||||
|
||||
for _, field in ipairs(BATTLE_FIELDS) do
|
||||
if model[field] ~= nil then
|
||||
battle[field] = clone(model[field], copy)
|
||||
else
|
||||
battle[field] = nil
|
||||
end
|
||||
end
|
||||
battle.kind = model.kind
|
||||
battle.ruleset = rulesets(game)[model.rulesetId]
|
||||
battle.checkpointOrigin = assert(copy(model.origin))
|
||||
battle.participants = restoreIndexSet(model.participants, game.save.party)
|
||||
battle.leveledUp = restoreIndexSet(model.leveledUp, game.save.party)
|
||||
battle.sides = assert(copy(model.sides))
|
||||
battle.sides[1].battlers = { battle.player }
|
||||
battle.sides[2].battlers = { battle.enemy }
|
||||
battle.field = assert(copy(model.field))
|
||||
battle.field.sides = battle.sides
|
||||
battle.phase, battle.queue = "menu", {}
|
||||
battle.frame = 0
|
||||
battle.current, battle.afterQueue, battle.nextInsert = nil, nil, nil
|
||||
battle.pendingHit, battle.waitingUI, battle.waitingSound = nil, nil, nil
|
||||
battle.waitFrames, battle.draining, battle.animPlaying = nil, nil, nil
|
||||
battle.introText, battle.introBalls, battle.introSlide = nil, nil, nil
|
||||
battle.showPlayerBack, battle.showEnemyTrainer, battle.showEnemyBalls = nil, nil, nil
|
||||
battle.player.shownHP, battle.player.shownStatus =
|
||||
battle.player.mon.hp, battle.player.mon.status
|
||||
battle.enemy.shownHP, battle.enemy.shownStatus =
|
||||
battle.enemy.mon.hp, battle.enemy.mon.status
|
||||
|
||||
local ow = game.overworld
|
||||
if not ow or type(ow.restoreBattleContinuation) ~= "function"
|
||||
or ow:restoreBattleContinuation(battle, battle.checkpointOrigin) ~= true then
|
||||
error("battle continuation reconstruction is unavailable", 0)
|
||||
end
|
||||
if type(game.restoreCheckpointBattle) ~= "function" then
|
||||
error("game has no battle checkpoint reconstruction path", 0)
|
||||
end
|
||||
game:restoreCheckpointBattle(battle)
|
||||
local setState = love and love.math and love.math.setRandomState
|
||||
if type(setState) ~= "function" then error("battle RNG restore is unavailable", 0) end
|
||||
setState(checkpoint.rng.love)
|
||||
return battle
|
||||
end
|
||||
|
||||
local function captureExtensions(battle, copy)
|
||||
local sides = {}
|
||||
for i = 1, 2 do
|
||||
local side = battle.sides and battle.sides[i] or {}
|
||||
local encoded, err = copy({
|
||||
index = i,
|
||||
screens = side.screens or {},
|
||||
hazards = side.hazards or {},
|
||||
tokens = side.tokens or {},
|
||||
})
|
||||
if not encoded then return nil, err end
|
||||
sides[i] = encoded
|
||||
end
|
||||
local field, err = copy({
|
||||
weather = battle.field and battle.field.weather or nil,
|
||||
tokens = battle.field and battle.field.tokens or {},
|
||||
})
|
||||
if not field then return nil, err end
|
||||
return sides, field
|
||||
end
|
||||
|
||||
function BattleCheckpoint.capture(game, battle, progress, copy)
|
||||
local getState = love and love.math and love.math.getRandomState
|
||||
local setState = love and love.math and love.math.setRandomState
|
||||
if type(getState) ~= "function" or type(setState) ~= "function" then
|
||||
return nil, "rng_state_unavailable",
|
||||
"This runtime cannot preserve deterministic battle randomness."
|
||||
end
|
||||
local ok, rngState = pcall(getState)
|
||||
if not ok or type(rngState) ~= "string" or rngState == "" then
|
||||
return nil, "rng_state_unavailable",
|
||||
"The gameplay random-number state could not be captured."
|
||||
end
|
||||
|
||||
local origin, originErr = copy(battle.checkpointOrigin)
|
||||
if not origin then
|
||||
return nil, "battle_origin_unsupported",
|
||||
"The battle completion path is not data-only: " .. tostring(originErr)
|
||||
end
|
||||
local sides, fieldOrErr = captureExtensions(battle, copy)
|
||||
if not sides then
|
||||
return nil, "battle_extension_unsafe",
|
||||
"Battle extension state is not data-only: " .. tostring(fieldOrErr)
|
||||
end
|
||||
local field = fieldOrErr
|
||||
|
||||
local liveParty = game.save.party
|
||||
local playerIndex = partyIndex(liveParty, battle.player.mon)
|
||||
if not playerIndex then
|
||||
return nil, "battle_state_invalid",
|
||||
"The active player battler is not in the current party."
|
||||
end
|
||||
|
||||
local model = {
|
||||
kind = battle.kind,
|
||||
rulesetId = rulesetId(game, battle.ruleset),
|
||||
origin = origin,
|
||||
player = captureBattler(battle.player, playerIndex, copy),
|
||||
participants = indexSet(battle.participants, liveParty),
|
||||
leveledUp = indexSet(battle.leveledUp, liveParty),
|
||||
sides = sides,
|
||||
field = field,
|
||||
mimicRestores = captureMimicRestores(battle),
|
||||
}
|
||||
if not model.rulesetId then
|
||||
return nil, "battle_state_invalid", "Battle ruleset identity is unavailable."
|
||||
end
|
||||
if not model.player then
|
||||
return nil, "battle_state_invalid", "Player move references are inconsistent."
|
||||
end
|
||||
if not model.mimicRestores then
|
||||
return nil, "battle_state_invalid", "Mimic restore state is inconsistent."
|
||||
end
|
||||
if battle.kind == "trainer" then
|
||||
model.enemyParty = copy(battle.enemyParty)
|
||||
model.enemy = captureBattler(battle.enemy, battle.enemyIndex, copy)
|
||||
else
|
||||
model.enemyMon = copy(battle.enemy.mon)
|
||||
model.enemy = captureBattler(battle.enemy, 1, copy)
|
||||
end
|
||||
if not model.enemy then
|
||||
return nil, "battle_state_invalid", "Enemy move references are inconsistent."
|
||||
end
|
||||
for _, fieldName in ipairs(BATTLE_FIELDS) do
|
||||
if battle[fieldName] ~= nil then model[fieldName] = battle[fieldName] end
|
||||
end
|
||||
|
||||
model = copy(model)
|
||||
if not model then
|
||||
return nil, "battle_state_invalid",
|
||||
"Battle state contains non-serializable runtime data."
|
||||
end
|
||||
local player = progress.player
|
||||
return {
|
||||
overworld = {
|
||||
map = player.map, x = player.x, y = player.y,
|
||||
facing = player.facing, surfing = player.surfing and true or false,
|
||||
},
|
||||
battle = model,
|
||||
}, { love = rngState }
|
||||
end
|
||||
|
||||
return BattleCheckpoint
|
||||
@@ -0,0 +1,424 @@
|
||||
-- Public runtime checkpoint implementation. Loader exposes bound forwarding
|
||||
-- methods; mods never receive controller or state-stack internals from here.
|
||||
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Version = require("src.core.Version")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local BattleCheckpoint = require("src.core.BattleCheckpoint")
|
||||
local ModRuntime = require("src.mods.Runtime")
|
||||
|
||||
local Checkpoint = {}
|
||||
|
||||
Checkpoint.FORMAT = 1
|
||||
|
||||
local function refusal(kind, reason, message)
|
||||
return {
|
||||
canCapture = false,
|
||||
canRestore = false,
|
||||
kind = kind or "unknown",
|
||||
reason = reason,
|
||||
message = message,
|
||||
}
|
||||
end
|
||||
|
||||
local function running(runner)
|
||||
return runner and runner.isRunning and runner:isRunning()
|
||||
end
|
||||
|
||||
local function nonempty(value)
|
||||
return type(value) == "table" and next(value) ~= nil
|
||||
end
|
||||
|
||||
local function scriptsBusy(ow)
|
||||
return running(ow.runner) or nonempty(ow.parallelRunners)
|
||||
or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue)
|
||||
or nonempty(ow.scriptMoves)
|
||||
end
|
||||
|
||||
local BATTLE_BUSY_FIELDS = {
|
||||
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
||||
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
||||
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
||||
}
|
||||
|
||||
local function inspectBattle(ow, battle)
|
||||
if battle.kind == "link" then
|
||||
return refusal("battle", "link_battle_unsupported",
|
||||
"Network battles cannot be checkpointed.")
|
||||
end
|
||||
if battle.safari or battle.ghost or battle.scopeReveal or battle.demo
|
||||
or battle.noCatch then
|
||||
return refusal("battle", "battle_variant_unsupported",
|
||||
"This battle variant does not have a checkpoint contract.")
|
||||
end
|
||||
if battle.kind ~= "wild" and battle.kind ~= "trainer" then
|
||||
return refusal("battle", "battle_variant_unsupported",
|
||||
"This battle kind does not have a checkpoint contract.")
|
||||
end
|
||||
local origin = battle.checkpointOrigin
|
||||
local expectedOrigin = battle.kind == "wild" and "wild_encounter"
|
||||
or "trainer_encounter"
|
||||
if type(origin) ~= "table" or origin.kind ~= expectedOrigin then
|
||||
return refusal("battle", "battle_origin_unsupported",
|
||||
"The battle completion path cannot be reconstructed safely.")
|
||||
end
|
||||
if scriptsBusy(ow) then
|
||||
return refusal("battle", "script_busy",
|
||||
"A suspended or queued script cannot be checkpointed.")
|
||||
end
|
||||
if battle.phase ~= "menu" or nonempty(battle.queue) then
|
||||
return refusal("battle", "battle_phase_busy",
|
||||
"Wait for the player command menu before creating a checkpoint.")
|
||||
end
|
||||
for _, field in ipairs(BATTLE_BUSY_FIELDS) do
|
||||
if battle[field] ~= nil and battle[field] ~= false then
|
||||
return refusal("battle", "battle_phase_busy",
|
||||
"Wait for the current battle action to finish.")
|
||||
end
|
||||
end
|
||||
if not battle.player or not battle.enemy or battle.player.mon.hp <= 0
|
||||
or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then
|
||||
return refusal("battle", "battle_phase_busy",
|
||||
"Wait for an ordinary player decision before creating a checkpoint.")
|
||||
end
|
||||
for _, battler in ipairs({ battle.player, battle.enemy }) do
|
||||
if battler.shownHP ~= battler.mon.hp
|
||||
or battler.shownStatus ~= battler.mon.status
|
||||
or battler.drainFloor ~= nil or battler.drainHold ~= nil
|
||||
or battler.faintQueued then
|
||||
return refusal("battle", "battle_phase_busy",
|
||||
"Wait for battle status and HP presentation to settle.")
|
||||
end
|
||||
end
|
||||
return { canCapture = true, canRestore = true, kind = "battle" }
|
||||
end
|
||||
|
||||
function Checkpoint.inspect(game)
|
||||
local save = game and game.save
|
||||
if type(save) ~= "table" or type(save.version) ~= "string" then
|
||||
return refusal("unknown", "not_in_playthrough",
|
||||
"A checkpoint requires an identified active playthrough.")
|
||||
end
|
||||
|
||||
local ow = game.overworld
|
||||
if type(ow) ~= "table" or type(ow.map) ~= "table"
|
||||
or type(ow.map.id) ~= "string" or type(ow.player) ~= "table" then
|
||||
return refusal("unknown", "not_overworld",
|
||||
"Only a settled overworld can be checkpointed.")
|
||||
end
|
||||
local top = game.stack and game.stack.top and game.stack:top()
|
||||
if getmetatable(top) == BattleState then
|
||||
return inspectBattle(ow, top)
|
||||
end
|
||||
if top ~= ow then
|
||||
return refusal("overworld", "screen_busy",
|
||||
"Close the active menu or screen before creating a checkpoint.")
|
||||
end
|
||||
local identity = save.meta and save.meta.playthroughId
|
||||
if type(identity) ~= "string" or identity == "" then
|
||||
identity = SaveData.ensurePlaythroughId(save)
|
||||
end
|
||||
if type(identity) ~= "string" or identity == "" then
|
||||
return refusal("overworld", "not_in_playthrough",
|
||||
"The active playthrough could not be identified.")
|
||||
end
|
||||
if ow.transitioning then
|
||||
return refusal("overworld", "transition_busy",
|
||||
"Wait for the map transition to finish.")
|
||||
end
|
||||
if scriptsBusy(ow) then
|
||||
return refusal("overworld", "script_busy",
|
||||
"Wait for the active or queued script to finish.")
|
||||
end
|
||||
|
||||
local animationFields = {
|
||||
"engaging", "emote", "teleportOut", "dustAnim", "cutAnim", "fishPose",
|
||||
"pikaHop", "healAnim", "flyAnim", "flyArrive",
|
||||
}
|
||||
for _, field in ipairs(animationFields) do
|
||||
if ow[field] then
|
||||
return refusal("overworld", "animation_busy",
|
||||
"Wait for the overworld animation to finish.")
|
||||
end
|
||||
end
|
||||
if ow.player.moving or ow.player.targetX ~= nil or ow.player.targetY ~= nil then
|
||||
return refusal("overworld", "movement_busy",
|
||||
"Wait for movement to settle on a tile.")
|
||||
end
|
||||
return { canCapture = true, canRestore = true, kind = "overworld" }
|
||||
end
|
||||
|
||||
local function dataCopy(value)
|
||||
local ok, encoded = pcall(SaveSerializer.encode, value)
|
||||
if not ok then return nil, tostring(encoded) end
|
||||
local decoded, err = SaveSerializer.decode(encoded)
|
||||
if not decoded then return nil, err end
|
||||
return decoded
|
||||
end
|
||||
|
||||
local function captureRng()
|
||||
local getState = love and love.math and love.math.getRandomState
|
||||
local setState = love and love.math and love.math.setRandomState
|
||||
if type(getState) ~= "function" or type(setState) ~= "function" then return nil end
|
||||
local ok, state = pcall(getState)
|
||||
if ok and type(state) == "string" and state ~= "" then
|
||||
return { love = state }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function restoreRng(rng)
|
||||
if rng == nil then return end -- legacy format-1 overworld checkpoint
|
||||
local setState = love and love.math and love.math.setRandomState
|
||||
if type(rng) ~= "table" or type(rng.love) ~= "string"
|
||||
or type(setState) ~= "function" then
|
||||
error("checkpoint RNG restore is unavailable", 0)
|
||||
end
|
||||
setState(rng.love)
|
||||
end
|
||||
|
||||
function Checkpoint.capture(game)
|
||||
local capability = Checkpoint.inspect(game)
|
||||
if not capability.canCapture then
|
||||
return nil, capability.reason, capability.message
|
||||
end
|
||||
|
||||
local progress = {}
|
||||
for key, value in pairs(game.save) do
|
||||
if key ~= "options" then progress[key] = value end
|
||||
end
|
||||
progress = dataCopy(progress)
|
||||
if not progress then
|
||||
return nil, "capture_failed", "Progress contains non-serializable runtime data."
|
||||
end
|
||||
|
||||
local ok, err = pcall(game.overworld.captureSave, game.overworld, progress)
|
||||
if not ok then
|
||||
return nil, "capture_failed", "Could not synchronize overworld progress: "
|
||||
.. tostring(err)
|
||||
end
|
||||
progress, err = dataCopy(progress)
|
||||
if not progress then
|
||||
return nil, "capture_failed", "Synchronized progress is not data-only: "
|
||||
.. tostring(err)
|
||||
end
|
||||
|
||||
if capability.kind == "battle" then
|
||||
local battle = game.stack:top()
|
||||
local runtime, rngOrCode, battleMessage =
|
||||
BattleCheckpoint.capture(game, battle, progress, dataCopy)
|
||||
if not runtime then return nil, rngOrCode, battleMessage end
|
||||
return {
|
||||
format = Checkpoint.FORMAT,
|
||||
kind = "battle",
|
||||
identity = {
|
||||
engineVersion = Version.engine,
|
||||
gameVersion = game.save.version,
|
||||
playthroughId = game.save.meta.playthroughId,
|
||||
},
|
||||
save = progress,
|
||||
runtime = runtime,
|
||||
rng = rngOrCode,
|
||||
}
|
||||
end
|
||||
|
||||
local player = game.overworld.player
|
||||
return {
|
||||
format = Checkpoint.FORMAT,
|
||||
kind = "overworld",
|
||||
identity = {
|
||||
engineVersion = Version.engine,
|
||||
gameVersion = game.save.version,
|
||||
playthroughId = game.save.meta.playthroughId,
|
||||
},
|
||||
save = progress,
|
||||
runtime = { overworld = {
|
||||
map = game.overworld.map.id,
|
||||
x = player.cellX,
|
||||
y = player.cellY,
|
||||
facing = player.facing,
|
||||
surfing = player.surfing and true or false,
|
||||
} },
|
||||
rng = captureRng(),
|
||||
}
|
||||
end
|
||||
|
||||
local FACINGS = { up = true, down = true, left = true, right = true }
|
||||
|
||||
local function validate(game, checkpoint)
|
||||
if type(checkpoint) ~= "table" then
|
||||
return nil, "invalid_checkpoint", "Checkpoint root must be a table."
|
||||
end
|
||||
if checkpoint.format ~= Checkpoint.FORMAT then
|
||||
return nil, "unsupported_format", "This checkpoint format is not supported."
|
||||
end
|
||||
if checkpoint.kind ~= "overworld" and checkpoint.kind ~= "battle" then
|
||||
return nil, "unsupported_runtime_kind", "This checkpoint runtime kind is not supported."
|
||||
end
|
||||
|
||||
local copy, copyErr = dataCopy(checkpoint)
|
||||
if not copy then
|
||||
return nil, "invalid_checkpoint", "Checkpoint is not data-only: "
|
||||
.. tostring(copyErr)
|
||||
end
|
||||
local identity = copy.identity
|
||||
local current = game and game.save
|
||||
local currentId = current and current.meta and current.meta.playthroughId
|
||||
if type(identity) ~= "table" or type(identity.engineVersion) ~= "string"
|
||||
or type(identity.gameVersion) ~= "string"
|
||||
or type(identity.playthroughId) ~= "string" then
|
||||
return nil, "invalid_checkpoint", "Checkpoint identity is missing or corrupt."
|
||||
end
|
||||
if identity.gameVersion ~= current.version then
|
||||
return nil, "wrong_game", "Checkpoint belongs to another game version."
|
||||
end
|
||||
if identity.playthroughId ~= currentId then
|
||||
return nil, "wrong_playthrough", "Checkpoint belongs to another playthrough."
|
||||
end
|
||||
|
||||
local save = copy.save
|
||||
local runtime = copy.runtime and copy.runtime.overworld
|
||||
if type(save) ~= "table" or type(save.player) ~= "table"
|
||||
or type(runtime) ~= "table" then
|
||||
return nil, "invalid_checkpoint", "Checkpoint progress or runtime data is missing."
|
||||
end
|
||||
if save.version ~= identity.gameVersion
|
||||
or not save.meta or save.meta.playthroughId ~= identity.playthroughId then
|
||||
return nil, "invalid_checkpoint", "Checkpoint progress identity is inconsistent."
|
||||
end
|
||||
if copy.rng ~= nil and (type(copy.rng) ~= "table"
|
||||
or type(copy.rng.love) ~= "string" or copy.rng.love == "") then
|
||||
return nil, "invalid_checkpoint", "Checkpoint RNG state is corrupt."
|
||||
end
|
||||
if type(runtime.map) ~= "string" or type(runtime.x) ~= "number"
|
||||
or type(runtime.y) ~= "number" or runtime.x % 1 ~= 0 or runtime.y % 1 ~= 0
|
||||
or not FACINGS[runtime.facing] or type(runtime.surfing) ~= "boolean" then
|
||||
return nil, "invalid_checkpoint", "Overworld position is missing or corrupt."
|
||||
end
|
||||
if save.player.map ~= runtime.map or save.player.x ~= runtime.x
|
||||
or save.player.y ~= runtime.y or save.player.facing ~= runtime.facing
|
||||
or (save.player.surfing and true or false) ~= runtime.surfing then
|
||||
return nil, "invalid_checkpoint", "Progress and runtime position disagree."
|
||||
end
|
||||
|
||||
local map = game.data and game.data.maps and game.data.maps[runtime.map]
|
||||
if type(map) ~= "table" then
|
||||
return nil, "invalid_map", "Checkpoint references a map that is unavailable."
|
||||
end
|
||||
local width, height = tonumber(map.width), tonumber(map.height)
|
||||
if not width or not height or runtime.x < 0 or runtime.y < 0
|
||||
or runtime.x >= width * 2 or runtime.y >= height * 2 then
|
||||
return nil, "invalid_position", "Checkpoint position is outside the map."
|
||||
end
|
||||
|
||||
-- A checkpoint is a strict restoration record, not an ordinary CONTINUE
|
||||
-- migration. Reuse the canonical save validator on the detached copy, but
|
||||
-- reject any quarantine, remap, reclaim, clamp, or content repair it would
|
||||
-- perform instead of silently changing the state the caller selected.
|
||||
local beforeContent = SaveSerializer.encode(copy.save)
|
||||
local validOk, report = pcall(SaveData.validate, copy.save, game.data)
|
||||
local afterOk, afterContent = pcall(SaveSerializer.encode, copy.save)
|
||||
if not validOk or not afterOk or not SaveData.emptyReport(report)
|
||||
or afterContent ~= beforeContent then
|
||||
return nil, "invalid_content",
|
||||
"Checkpoint references unavailable or invalid game content."
|
||||
end
|
||||
if copy.kind == "battle" then
|
||||
local battleOk, battleCode, battleMessage = BattleCheckpoint.validate(game, copy)
|
||||
if not battleOk then return nil, battleCode, battleMessage end
|
||||
elseif copy.runtime.battle ~= nil then
|
||||
return nil, "invalid_checkpoint",
|
||||
"Overworld checkpoint contains unexpected battle state."
|
||||
end
|
||||
return copy
|
||||
end
|
||||
|
||||
local function apply(game, checkpoint, options)
|
||||
local save, err = dataCopy(checkpoint.save)
|
||||
if not save then error("checkpoint progress decode failed: " .. tostring(err), 0) end
|
||||
local runtime = checkpoint.runtime.overworld
|
||||
save.options = options
|
||||
save.player.map = runtime.map
|
||||
save.player.x = runtime.x
|
||||
save.player.y = runtime.y
|
||||
save.player.facing = runtime.facing
|
||||
save.player.surfing = runtime.surfing
|
||||
if type(game.restoreCheckpointSave) ~= "function" then
|
||||
error("game has no checkpoint reconstruction path", 0)
|
||||
end
|
||||
game:restoreCheckpointSave(save)
|
||||
if checkpoint.kind == "battle" then
|
||||
BattleCheckpoint.restore(game, checkpoint, dataCopy)
|
||||
else
|
||||
restoreRng(checkpoint.rng)
|
||||
end
|
||||
end
|
||||
|
||||
local function equalData(a, b)
|
||||
local okA, encodedA = pcall(SaveSerializer.encode, a)
|
||||
local okB, encodedB = pcall(SaveSerializer.encode, b)
|
||||
return okA and okB and encodedA == encodedB
|
||||
end
|
||||
|
||||
local function firstDifference(a, b, path)
|
||||
path = path or "$"
|
||||
if type(a) ~= type(b) then return path .. " (type)" end
|
||||
if type(a) ~= "table" then
|
||||
if a ~= b then return path end
|
||||
return nil
|
||||
end
|
||||
for key, value in pairs(a) do
|
||||
if b[key] == nil and value ~= nil then
|
||||
return path .. "." .. tostring(key) .. " (missing)"
|
||||
end
|
||||
local found = firstDifference(value, b[key], path .. "." .. tostring(key))
|
||||
if found then return found end
|
||||
end
|
||||
for key, value in pairs(b) do
|
||||
if a[key] == nil and value ~= nil then
|
||||
return path .. "." .. tostring(key) .. " (unexpected)"
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Checkpoint.restore(game, checkpoint)
|
||||
local capability = Checkpoint.inspect(game)
|
||||
if not capability.canRestore then
|
||||
return false, capability.reason, capability.message
|
||||
end
|
||||
local validated, code, message = validate(game, checkpoint)
|
||||
if not validated then return false, code, message end
|
||||
|
||||
local rollback, captureCode, captureMessage = Checkpoint.capture(game)
|
||||
if not rollback then return false, captureCode, captureMessage end
|
||||
local options = game.save.options
|
||||
|
||||
local ok, err = pcall(apply, game, validated, options)
|
||||
if ok then
|
||||
local restored, verifyCode = Checkpoint.capture(game)
|
||||
if restored and validated.rng == nil then restored.rng = nil end
|
||||
if restored and equalData(restored, validated) then
|
||||
if ModRuntime.wants("checkpoint.restored") then
|
||||
ModRuntime.emit("checkpoint.restored", {
|
||||
game = game,
|
||||
kind = validated.kind,
|
||||
})
|
||||
end
|
||||
return true
|
||||
end
|
||||
err = restored and ("restored state differed at "
|
||||
.. tostring(firstDifference(validated, restored) or "canonical encoding"))
|
||||
or ("restored state could not be captured: " .. tostring(verifyCode))
|
||||
end
|
||||
|
||||
local rolledBack, rollbackErr = pcall(apply, game, rollback, options)
|
||||
if not rolledBack then
|
||||
return false, "rollback_failed",
|
||||
"Checkpoint restore and rollback both failed: " .. tostring(rollbackErr)
|
||||
end
|
||||
return false, "restore_failed", "Checkpoint restoration failed: " .. tostring(err)
|
||||
end
|
||||
|
||||
return Checkpoint
|
||||
@@ -111,6 +111,11 @@ local NOISE_DIVISORS = {
|
||||
[4] = 64, [5] = 80, [6] = 96, [7] = 112,
|
||||
}
|
||||
|
||||
|
||||
local HPF_CHARGE = 0.999958 ^ (GB_CLOCK / SAMPLE_RATE)
|
||||
local LPF_ALPHA = 0.8
|
||||
local MIX_SCALE = 0.5
|
||||
|
||||
local function snapTicks(ticks)
|
||||
return math.floor((ticks * 1470 + 256) / 512)
|
||||
end
|
||||
@@ -269,6 +274,7 @@ function Channel.new(engine, spec, options)
|
||||
phase = 0,
|
||||
noiseLfsr = 0x7FFF,
|
||||
noiseClock = 0,
|
||||
drumTail = nil,
|
||||
timeTicks = 0,
|
||||
}, Channel)
|
||||
end
|
||||
@@ -527,6 +533,25 @@ local function envelopeVolume(volume, fade, elapsed)
|
||||
return math.min(15, volume + steps)
|
||||
end
|
||||
|
||||
|
||||
local function envelopeRingSamples(volume, fade)
|
||||
if not fade or fade <= 0 or not volume or volume <= 0 then return 0 end
|
||||
return math.floor(volume * (fade / 64) * SAMPLE_RATE + 0.5)
|
||||
end
|
||||
|
||||
local function extendDrumEnvelope(segments)
|
||||
local last = segments and segments[#segments]
|
||||
if not last then return segments end
|
||||
local ringEnd = last.startSample + envelopeRingSamples(last.volume, last.fade)
|
||||
if ringEnd > last.endSample then last.endSample = ringEnd end
|
||||
return segments
|
||||
end
|
||||
|
||||
local function drumAudioEnd(drum)
|
||||
local last = drum and drum[#drum]
|
||||
return last and last.endSample or 0
|
||||
end
|
||||
|
||||
function Channel:resetNoise()
|
||||
self.noiseLfsr = 0x7FFF
|
||||
self.noiseClock = 0
|
||||
@@ -566,8 +591,7 @@ function Channel:sampleNoise(parameter)
|
||||
end
|
||||
end
|
||||
end
|
||||
-- LuaGB: instantaneous inverted LFSR LSB (high when bit0 == 0)
|
||||
return bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
|
||||
return bit.band(self.noiseLfsr, 1) == 0 and 1 or 0
|
||||
end
|
||||
|
||||
local function sweepCalculation(register, sweep)
|
||||
@@ -611,21 +635,51 @@ end
|
||||
function Channel:sample()
|
||||
while not self.ended
|
||||
and (not self.event or self.event.sample >= self.event.samples) do
|
||||
local prev = self.event
|
||||
self.event = self:nextEvent()
|
||||
self.phase = 0
|
||||
self:resetNoise()
|
||||
if self.event and self.event.drum then
|
||||
self.drumTail = nil
|
||||
self:resetNoise()
|
||||
elseif prev and prev.drum and prev.sample < drumAudioEnd(prev.drum) then
|
||||
-- ..(audio/engine_1.asm ln 197)
|
||||
self.drumTail = prev
|
||||
elseif not (self.event and self.event.silence and self.drumTail) then
|
||||
self.drumTail = nil
|
||||
self:resetNoise()
|
||||
end
|
||||
end
|
||||
local event = self.event
|
||||
if not event then return 0 end
|
||||
local gain = channelVolume[self.hardware] or 1
|
||||
if not event then
|
||||
local tail = self.drumTail
|
||||
if not tail then return 0 end
|
||||
local sampleIndex = tail.sample
|
||||
tail.sample = sampleIndex + 1
|
||||
if sampleIndex >= drumAudioEnd(tail.drum) then
|
||||
self.drumTail = nil
|
||||
return 0
|
||||
end
|
||||
return self:sampleDrum(tail, sampleIndex) * gain
|
||||
end
|
||||
local sampleIndex = event.sample
|
||||
event.elapsed = sampleIndex / SAMPLE_RATE
|
||||
event.sample = sampleIndex + 1
|
||||
if event.silence then return 0 end
|
||||
|
||||
local gain = channelVolume[self.hardware] or 1
|
||||
if event.silence then
|
||||
local tail = self.drumTail
|
||||
if not tail then return 0 end
|
||||
local tailIndex = tail.sample
|
||||
tail.sample = tailIndex + 1
|
||||
if tailIndex >= drumAudioEnd(tail.drum) then
|
||||
self.drumTail = nil
|
||||
return 0
|
||||
end
|
||||
return self:sampleDrum(tail, tailIndex) * gain
|
||||
end
|
||||
if event.drum then
|
||||
return self:sampleDrum(event, sampleIndex) * gain
|
||||
end
|
||||
self.drumTail = nil
|
||||
local volume = envelopeVolume(
|
||||
event.volume or 0, event.fade or 0, event.elapsed)
|
||||
if event.noise then
|
||||
@@ -665,7 +719,8 @@ function Channel:sample()
|
||||
-- a def-local program may omit its wave table entirely
|
||||
if not wave then return 0 end
|
||||
local index = math.min(32, math.floor(phase * 32) + 1)
|
||||
return wave[index] * event.waveLevel * gain
|
||||
local nibble = math.max(0, math.min(15, wave[index] * 8 + 8))
|
||||
return (nibble / 15) * event.waveLevel * gain
|
||||
end
|
||||
local duty = event.duty
|
||||
if type(duty) == "table" then
|
||||
@@ -674,7 +729,7 @@ function Channel:sample()
|
||||
local pattern = WAVE_PATTERN_TABLES[duty or 2] or WAVE_PATTERN_TABLES[2]
|
||||
local step = math.floor(phase * 8) % 8
|
||||
if pattern[step + 1] == 0 then
|
||||
return -volume / 15 * gain
|
||||
return 0
|
||||
end
|
||||
return volume / 15 * gain
|
||||
end
|
||||
@@ -685,7 +740,7 @@ Engine.__index = Engine
|
||||
function Engine:noiseInstrument(number)
|
||||
-- a def-local drum wins over the ROM engine's table for that id
|
||||
local custom = self.customDrums and self.customDrums[number]
|
||||
if custom then return custom end
|
||||
if custom then return extendDrumEnvelope(custom) end
|
||||
local cached = self.noiseInstruments[number]
|
||||
if cached then return cached end
|
||||
|
||||
@@ -718,6 +773,7 @@ function Engine:noiseInstrument(number)
|
||||
end
|
||||
end
|
||||
|
||||
extendDrumEnvelope(segments)
|
||||
self.noiseInstruments[number] = segments
|
||||
return segments
|
||||
end
|
||||
@@ -792,6 +848,8 @@ function Engine.new(data, header, options)
|
||||
customDrums = chip and chip.drums or nil,
|
||||
noiseInstruments = {},
|
||||
channels = {},
|
||||
hpfCap = 0, hpfCapLeft = 0, hpfCapRight = 0,
|
||||
lpf = 0, lpfLeft = 0, lpfRight = 0,
|
||||
}, Engine)
|
||||
-- header.tempo: the Music_*AlternateTempo override Music.play stamps onto
|
||||
-- a copy of the song def (audio/alternate_tempo.asm) (#847)
|
||||
@@ -826,10 +884,20 @@ function Engine:finished()
|
||||
return true
|
||||
end
|
||||
|
||||
local function analogOut(engine, input, hpfField, lpfField)
|
||||
local cap = engine[hpfField]
|
||||
local hp = input - cap
|
||||
engine[hpfField] = input - hp * HPF_CHARGE
|
||||
local prev = engine[lpfField]
|
||||
local lp = prev + LPF_ALPHA * (hp - prev)
|
||||
engine[lpfField] = lp
|
||||
return math.max(-1, math.min(1, lp * MIX_SCALE))
|
||||
end
|
||||
|
||||
function Engine:sample()
|
||||
local value = 0
|
||||
for _, channel in ipairs(self.channels) do value = value + channel:sample() end
|
||||
return math.max(-1, math.min(1, value / 4))
|
||||
return analogOut(self, value, "hpfCap", "lpf")
|
||||
end
|
||||
|
||||
function Engine:sampleStereo()
|
||||
@@ -840,8 +908,8 @@ function Engine:sampleStereo()
|
||||
if not event or event.panLeft ~= false then left = left + value end
|
||||
if not event or event.panRight ~= false then right = right + value end
|
||||
end
|
||||
return math.max(-1, math.min(1, left / 4)),
|
||||
math.max(-1, math.min(1, right / 4))
|
||||
return analogOut(self, left, "hpfCapLeft", "lpfLeft"),
|
||||
analogOut(self, right, "hpfCapRight", "lpfRight")
|
||||
end
|
||||
|
||||
function Engine:sampleChannel(number)
|
||||
@@ -850,7 +918,7 @@ function Engine:sampleChannel(number)
|
||||
local value = channel:sample()
|
||||
if channel.number == number then selected = value end
|
||||
end
|
||||
return math.max(-1, math.min(1, selected / 4))
|
||||
return analogOut(self, selected, "hpfCap", "lpf")
|
||||
end
|
||||
|
||||
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
|
||||
@@ -870,22 +938,7 @@ local function soundData(engine, samples, channels)
|
||||
return result
|
||||
end
|
||||
|
||||
-- Render a one-shot effect (SFX/cry) to a two-channel SoundData, or nil when
|
||||
-- it is too short to be audible. The caller wraps it in a static
|
||||
-- love.audio.Source (a playback concern, hence not done here).
|
||||
--
|
||||
-- The synthesis is mono (one summed value per frame, unlike the music path's
|
||||
-- sampleStereo), but the buffer is written stereo on purpose: OpenAL only
|
||||
-- spatializes 1-channel Sources, and a Source left at the default (0,0,0)
|
||||
-- position, exactly where the listener sits, is rendered as an ambient sound
|
||||
-- spread over EVERY output channel the device exposes at gains that differ
|
||||
-- from the front pair. On an interface with more than two outputs that put
|
||||
-- the SFX on outputs 5+6 as well, while the 2-channel music source
|
||||
-- (ChipAudio.playMusic) stayed on 1+2 (#626). Multi-channel buffers skip
|
||||
-- spatialization entirely and map onto the front pair, so duplicating the
|
||||
-- sample costs one buffer's memory and makes effects route exactly like
|
||||
-- music. Deliberately not sampleStereo: that honors the NR51 panning byte
|
||||
-- and would newly hard-pan any effect whose header issues command 0xEE.
|
||||
|
||||
local function renderEffectData(data, header, options)
|
||||
if not header then return nil end
|
||||
options = options or {}
|
||||
|
||||
@@ -84,6 +84,15 @@ function Data:applyVersionedFieldData()
|
||||
-- Yellow caches carry the wrong demo species too. The fixed import
|
||||
-- manifest below stamps RATTATA for fresh imports.
|
||||
self.field.oldManBattle = { species = "RATTATA", level = 5 }
|
||||
-- The Oak-speech show-off mon is the player's Pikachu in Yellow
|
||||
-- (engine/battle/core.asm BATTLE_TYPE_PIKACHU / the ProfOak demo)
|
||||
-- but caches imported before the manifest carried demoSpecies fell
|
||||
-- back to Red's NIDORINO (#915). The fixed import manifest below
|
||||
-- stamps PIKACHU for fresh imports; fill it here for stale caches.
|
||||
local oakSpeech = self.field.oakSpeech
|
||||
if type(oakSpeech) == "table" and not oakSpeech.demoSpecies then
|
||||
oakSpeech.demoSpecies = "PIKACHU"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -217,24 +221,44 @@ function Game:step(dt)
|
||||
-- it on its own real-time 60Hz accumulator instead.
|
||||
end
|
||||
|
||||
-- The per-category (RFC 0007) save.options multiplier for whichever of
|
||||
-- "battle"/"overworld"/"menu" Game.speedCategoryInStack says is active
|
||||
-- right now. This is the "vanilla" the core.logic_speed hook wraps below
|
||||
-- -- Game:logicSpeed calls it AFTER the link and speedOverride checks, so
|
||||
-- neither a mod nor the category resolution ever has a seam to defeat them.
|
||||
function Game:_resolveLogicSpeed()
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local category = Game.speedCategoryInStack(self.stack)
|
||||
local key = GameSpeed.optionKey(category)
|
||||
local opts = self.save and self.save.options
|
||||
return GameSpeed.clamp(opts and opts[key] or GameSpeed.DEFAULT)
|
||||
end
|
||||
|
||||
-- The logic multiplier for this frame. Read live rather than cached so the
|
||||
-- Options row takes effect immediately; speedOverride is the --speed /
|
||||
-- Options rows take effect immediately; speedOverride is the --speed /
|
||||
-- POKEPORT_SPEED run argument, which wins over the saved option so a bot
|
||||
-- or screenshot run does not depend on whatever the player last chose.
|
||||
function Game:logicSpeed()
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
-- Link play is always 1X on both machines, and this wins over every other
|
||||
-- source including POKEPORT_SPEED. Fast-forward multiplies the logic
|
||||
-- clock, so a peer at 10X burned a tournament shot clock ten times faster
|
||||
-- than the opponent it is racing, and drove its own animation/message
|
||||
-- queue at a different rate than the peer it is locked to. Nothing about
|
||||
-- a match should depend on what either player set this to.
|
||||
-- source including POKEPORT_SPEED and every per-category option.
|
||||
-- Fast-forward multiplies the logic clock, so a peer at 10X burned a
|
||||
-- tournament shot clock ten times faster than the opponent it is racing,
|
||||
-- and drove its own animation/message queue at a different rate than the
|
||||
-- peer it is locked to. Nothing about a match should depend on what
|
||||
-- either player set this to -- checked here, before the core.logic_speed
|
||||
-- hook ever runs, so a mod cannot defeat it either.
|
||||
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
|
||||
return 1
|
||||
end
|
||||
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
||||
local opts = self.save and self.save.options
|
||||
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
|
||||
-- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's
|
||||
-- core.logic_speed hook can return anything (0, negative, nil, NaN) and
|
||||
-- Hooks:call only guards against a hook that throws, not one that
|
||||
-- returns a bad value, so an unclamped result would flow straight into
|
||||
-- the FixedStep accumulator math below and freeze or destabilize logic.
|
||||
return GameSpeed.clamp(ModRuntime.call("core.logic_speed",
|
||||
function(g) return g:_resolveLogicSpeed() end, self))
|
||||
end
|
||||
|
||||
function Game:update(dt)
|
||||
@@ -333,6 +357,28 @@ function Game.wideBattleInStack(stack)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Which of "battle"/"overworld"/"menu" per-category GAME SPEED (RFC 0007)
|
||||
-- applies right now. Whole-stack, the same idiom as fillScaleInStack/
|
||||
-- wideBattleInStack above: an overlay with neither marker (PartyMenu,
|
||||
-- ChoiceBox, a NamingScreen, a text box) is transparent to the walk and
|
||||
-- inherits whatever is under it, making the category a property of the
|
||||
-- STACK POSITION the overlay sits over, not of the overlay itself. A
|
||||
-- scripted sequence (script.started/ended) never pushes a state of its
|
||||
-- own either -- it runs through the owning overworld/battle state's own
|
||||
-- script runner or message queue -- so it inherits the same way. Nothing
|
||||
-- identifying as either (the title screen, credits, an intro cutscene
|
||||
-- with nothing under it) falls to "menu", the bucket every non-gameplay
|
||||
-- screen gets; see the RFC's Decisions section for the full reasoning.
|
||||
function Game.speedCategoryInStack(stack)
|
||||
local states = stack and stack.states
|
||||
for i = #(states or {}), 1, -1 do
|
||||
local state = states[i]
|
||||
if state and state.isBattle then return "battle" end
|
||||
if state and state.isOverworld then return "overworld" end
|
||||
end
|
||||
return "menu"
|
||||
end
|
||||
|
||||
-- Whether a state on the stack composes its own screen and so wants the
|
||||
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
|
||||
-- everything else here: the text box and YES/NO a battle puts up are states
|
||||
@@ -460,7 +506,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 +530,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
|
||||
@@ -552,8 +598,15 @@ function Game:_cycleSpeed(dir)
|
||||
or ow.engaging or ow.emote))
|
||||
end
|
||||
if busy then return end
|
||||
-- Cycles whichever category Game.speedCategoryInStack says is active
|
||||
-- right now (RFC 0007) -- pressing the hotkey during a battle speeds up
|
||||
-- just the battle, on the overworld just the walk, in a menu just the
|
||||
-- menu. A single physical control that means "speed up whatever I'm
|
||||
-- looking at right now" needs no new UI and matches what a player
|
||||
-- pressing it mid-battle almost certainly wants.
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
self.save.options.speed = GameSpeed.cycle(self.save.options.speed, dir)
|
||||
local key = GameSpeed.optionKey(Game.speedCategoryInStack(self.stack))
|
||||
self.save.options[key] = GameSpeed.cycle(self.save.options[key], dir)
|
||||
self:writeOptions()
|
||||
end
|
||||
|
||||
@@ -1126,4 +1179,28 @@ function Game:restoreSave(loaded, recovered)
|
||||
end
|
||||
end
|
||||
|
||||
-- Reconstruct a previously validated runtime checkpoint without replaying the
|
||||
-- ordinary CONTINUE lifecycle. In particular, map onEnter scripts and
|
||||
-- save.loading/save.loaded events must not run a second time. Validation,
|
||||
-- identity checks and transactional rollback live in Checkpoint.lua.
|
||||
function Game:restoreCheckpointSave(loaded)
|
||||
self.save = loaded
|
||||
self:adoptSave(loaded)
|
||||
while self.stack:top() do self.stack:pop() end
|
||||
self.stack:push(self.overworld, loaded.player.map,
|
||||
loaded.player.x, loaded.player.y, loaded.player.facing,
|
||||
{ via = "checkpoint", checkpoint = true })
|
||||
end
|
||||
|
||||
-- Install a reconstructed battle without calling BattleState:enter(), whose
|
||||
-- transition, intro queues and battle-start side effects already happened in
|
||||
-- the checkpointed timeline.
|
||||
function Game:restoreCheckpointBattle(battle)
|
||||
if self.stack:top() ~= self.overworld then
|
||||
error("battle checkpoint requires a reconstructed overworld base", 0)
|
||||
end
|
||||
self.stack.states[#self.stack.states + 1] = battle
|
||||
if battle.resumeCheckpoint then battle:resumeCheckpoint() end
|
||||
end
|
||||
|
||||
return Game
|
||||
|
||||
@@ -51,4 +51,19 @@ function GameSpeed.cycle(v, dir)
|
||||
return levels[nextIdx]
|
||||
end
|
||||
|
||||
-- Per-category speed (RFC 0007): overworld walking, battle turns and menu
|
||||
-- navigation each cycle their own multiplier instead of one global "speed"
|
||||
-- value. This list is the single source of truth for which categories
|
||||
-- exist and the order the Options rows/save.options keys follow;
|
||||
-- Game.lua's stack-walk (Game.speedCategoryInStack) decides WHICH category
|
||||
-- is active on a given frame, this module only knows the category names.
|
||||
GameSpeed.CATEGORIES = { "overworld", "battle", "menu" }
|
||||
|
||||
-- the save.options field name a category's multiplier lives under, e.g.
|
||||
-- "overworld" -> "speedOverworld". Centralized so Game.lua, OptionsMenu.lua,
|
||||
-- LauncherSettings.lua and the SaveData migration never hand-spell the key.
|
||||
function GameSpeed.optionKey(category)
|
||||
return "speed" .. category:sub(1, 1):upper() .. category:sub(2)
|
||||
end
|
||||
|
||||
return GameSpeed
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
-- extracted cache lives, and the save-file suffix -- so the importer,
|
||||
-- cache mount, SaveData, title screen and palette all agree.
|
||||
--
|
||||
-- Red keeps every un-suffixed path it always used (save.lua, the root cache),
|
||||
-- so existing installs are untouched; Blue is namespaced under blue/ and
|
||||
-- _blue, Yellow under yellow/ and _yellow, so all three can be imported and
|
||||
-- Red keeps the un-suffixed save paths it always used (save.lua) so existing
|
||||
-- saves are untouched, but its extracted cache lives under red/ like Blue and
|
||||
-- Yellow (issue #899); a legacy root cache is moved into red/ once by
|
||||
-- CacheFs.migrateLegacyRedCache. All three versions can be imported and
|
||||
-- played side by side.
|
||||
--
|
||||
-- Zero requires, so it loads during love.conf and under plain Lua for tools
|
||||
@@ -23,7 +24,7 @@ GameVersion.VERSIONS = {
|
||||
launcherName = "Red", -- game-panel header in the launcher
|
||||
sha1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
|
||||
manifest = "tools/rom_manifest.json",
|
||||
cachePrefix = "", -- Red owns the cache root (backwards compatible)
|
||||
cachePrefix = "red/", -- red/data/generated, red/assets/generated (#899)
|
||||
saveSuffix = "", -- save.lua / save.lua.bak / save.lua.tmp
|
||||
},
|
||||
blue = {
|
||||
@@ -40,7 +41,7 @@ GameVersion.VERSIONS = {
|
||||
id = "yellow",
|
||||
label = "Yellow",
|
||||
displayName = "Pokemon Yellow",
|
||||
launcherName = "Yellow (alpha)",
|
||||
launcherName = "Yellow",
|
||||
sha1 = "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1",
|
||||
manifest = "tools/rom_manifest_yellow.json",
|
||||
cachePrefix = "yellow/", -- yellow/data/generated, yellow/assets/generated
|
||||
|
||||