mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-27 08:51:27 +02:00
Compare commits
193 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a231657093 | |||
| 6f587520d4 | |||
| e41f2c528a | |||
| 7797962f66 | |||
| 017eafc51b | |||
| c1b7980b4a | |||
| 3e4772abf4 | |||
| 522d8cecf3 | |||
| 40075ae469 | |||
| dce0dd32db | |||
| 88e24f2c0c | |||
| b7b96cd9ef | |||
| 9886920c7a | |||
| f5f9e60b3c | |||
| 247ec919ea | |||
| fe338f47f2 | |||
| 7e4c4eb46e | |||
| 1d735c3291 | |||
| dce0f92c2d | |||
| f1bc392664 | |||
| ee427cd9eb | |||
| 592daafa42 | |||
| b78ab6fb50 | |||
| 6a02a13f22 | |||
| 0e8c93ec75 | |||
| 7b77dbe3ea | |||
| 0c6c813e87 | |||
| a6c914ecad | |||
| 1f12e4838f | |||
| 7fa762871f | |||
| 4d805abb9c | |||
| 51908faabf | |||
| 54c7a3aeae | |||
| 5cc33af354 | |||
| 93c7dff4d6 | |||
| b796c30278 | |||
| ec6b03f84b | |||
| 69c7ee89b5 | |||
| 28dc9b9bb0 | |||
| 2ca07cfd09 | |||
| b3159c19fd | |||
| 08121faa3d | |||
| bbe0f0d9ae | |||
| b35fab845a | |||
| d622e83a8e | |||
| 9dd38e06a5 | |||
| 6b4796b08f | |||
| ab61d08fe9 | |||
| c94ff016d9 | |||
| 85e47bc422 | |||
| 1850b50ee6 | |||
| ef62962010 | |||
| ba201aa4ad | |||
| 7d6566fa51 | |||
| 1ad6c810fa | |||
| 233082967c | |||
| d76d173b60 | |||
| bc7b96c963 | |||
| aea38240b2 | |||
| 56fc4653d7 | |||
| 0142468125 | |||
| e7d898625d | |||
| 5ee4a3cd35 | |||
| 4794ddcfdf | |||
| 6503e982db | |||
| 72ce1fe33e | |||
| 493fca7297 | |||
| 7d5856f93b | |||
| ef43814efc | |||
| bed1062a5f | |||
| 190c03cfca | |||
| cec264ad81 | |||
| 041051e154 | |||
| 92fd4221a0 | |||
| 07c828cab0 | |||
| b89771508e | |||
| 2c1c2fe080 | |||
| 6f4c59df8b | |||
| 0d14980205 | |||
| 1bd6e35931 | |||
| da73a4fe25 | |||
| cd4ef14f13 | |||
| d575a64287 | |||
| ff826ce01e | |||
| 0ab4ef2755 | |||
| 6f343d9b29 | |||
| 41790637f0 | |||
| 4ab0ac433b | |||
| 44c304574b | |||
| c8b05e7aa1 | |||
| 8c6c360ce0 | |||
| 6b6b8f46a1 | |||
| 2d62805128 | |||
| d27c59b18d | |||
| 1905261c5b | |||
| 333949ce0d | |||
| fb95dc5b6a | |||
| 96ed862b50 | |||
| 58681fe62e | |||
| 68fc01dd1c | |||
| cec19db5e0 | |||
| 38d515f547 | |||
| 7e069df740 | |||
| ca700c44d7 | |||
| 2ad2e028d1 | |||
| b286e6584f | |||
| 654650e3ff | |||
| fad7443f94 | |||
| f895293217 | |||
| ebf44f20d3 | |||
| ca4d3d283c | |||
| 5807b8d836 | |||
| b550db3abb | |||
| 01c5eb2189 | |||
| dd0f0e0892 | |||
| d17f0725d8 | |||
| 3b7fc42858 | |||
| c9221daa90 | |||
| 0147e3d87b | |||
| fb2cebd54f | |||
| cb504e5328 | |||
| bdbdbf2ed6 | |||
| 7ae469603c | |||
| 77e189bc2e | |||
| e88f2ef060 | |||
| 16eefcde68 | |||
| 6d55da0ed2 | |||
| 745374d8ae | |||
| 3470e3c4ae | |||
| f76a8b2f96 | |||
| 24a7ff86d4 | |||
| c2e1d810f9 | |||
| 6d6ccb5778 | |||
| d54f9a02e0 | |||
| e094b73536 | |||
| 6e7b9c0848 | |||
| 116a6ba450 | |||
| 478e3bf8eb | |||
| f009fc856c | |||
| 3abc6ceb15 | |||
| b93d070abc | |||
| 921f565f1d | |||
| 6e624724ca | |||
| 51bc4c7437 | |||
| e13271fc85 | |||
| c23f82efdd | |||
| 15fc04e067 | |||
| 61f42cea2b | |||
| 2c1e411e3c | |||
| c0f4315cd8 | |||
| 8c65e76145 | |||
| ea2cd63395 | |||
| 70d7b6383e | |||
| 5ec9ce5f88 | |||
| 087a275189 | |||
| 63b31d234c | |||
| 44f4680b24 | |||
| 06e06e305b | |||
| 464fb47756 | |||
| fe3746fd1a | |||
| 0e407dca7a | |||
| b0ff1552f2 | |||
| d191aaa34d | |||
| a1a70540b8 | |||
| 5c1837b1eb | |||
| bb0f156497 | |||
| 4b0496bad1 | |||
| a56add6d17 | |||
| 48b39c8519 | |||
| decaa006b2 | |||
| c0bd4a35be | |||
| 8c0d0ace4d | |||
| c11c762f15 | |||
| c465f58006 | |||
| 780246c4f6 | |||
| 5714555847 | |||
| 738f7317d7 | |||
| 5e514335c1 | |||
| f5b8b6c85f | |||
| db25c14dfb | |||
| 90163a3ff2 | |||
| f63707c45b | |||
| 7756fdc3cb | |||
| 4bdb9435a4 | |||
| 5cbde96177 | |||
| 03c5a1eddf | |||
| ada0d8abe1 | |||
| dbecc345e3 | |||
| 0f8f6d0e4f | |||
| 911e11a372 | |||
| 2d28d18bf6 | |||
| 0b70d6c535 | |||
| 1e6613e2de |
@@ -33,6 +33,8 @@ body:
|
||||
- Blue
|
||||
- Yellow
|
||||
- Gold
|
||||
- Silver
|
||||
- Crystal
|
||||
- N/A
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -333,6 +333,56 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
linux-flatpak-changes:
|
||||
name: detect Linux Flatpak 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_flatpak\.sh$|flatpak/|scripts/pack_love\.sh$|docs/linux-flatpak\.md$|\.github/workflows/(ci|release)\.yml$)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
linux-flatpak-build:
|
||||
name: Linux Flatpak build
|
||||
needs: linux-flatpak-changes
|
||||
if: needs.linux-flatpak-changes.outputs.changed == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Install flatpak-builder deps
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder elfutils imagemagick
|
||||
- name: Build Flatpak bundle
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_flatpak.sh --version 0.0.0
|
||||
- name: Upload the Flatpak
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-linux-flatpak
|
||||
path: |
|
||||
dist/flatpak/gen1recomp-0.0.0-linux.flatpak
|
||||
dist/flatpak/gen1recomp-0.0.0-linux.flatpak.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
headless:
|
||||
name: headless suites (no ROM)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -468,3 +518,24 @@ jobs:
|
||||
if [ "$found" = "0" ]; then
|
||||
echo "no committed mods to lint"
|
||||
fi
|
||||
|
||||
luacheck:
|
||||
name: engine lint (luacheck)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: install luacheck
|
||||
run: |
|
||||
set -e
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y lua5.4 liblua5.4-dev luarocks
|
||||
sudo luarocks install luacheck || sudo apt-get install -y lua-check
|
||||
luacheck --version
|
||||
|
||||
- name: luacheck gate (undefined globals, unreachable code)
|
||||
run: ./scripts/lint.sh --gate
|
||||
|
||||
- name: luacheck full report (advisory)
|
||||
continue-on-error: true
|
||||
run: ./scripts/lint.sh
|
||||
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
|
||||
linux-arm64:
|
||||
name: build Linux arm64 AppImage
|
||||
needs: [version, love-payload]
|
||||
needs: [version, love-payload, shaderfx-bridge]
|
||||
# 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.
|
||||
@@ -155,13 +155,22 @@ jobs:
|
||||
with:
|
||||
name: gen1recomp-release-love
|
||||
path: .bazinga/work
|
||||
- name: Download the ShaderFX bridge
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: shaderfx-bridge-linux-arm64
|
||||
path: dist/native/linux-arm64
|
||||
- name: Build Linux arm64 AppImage
|
||||
env:
|
||||
SHADERFX_BRIDGE_REQUIRED: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_linux_arm64.sh \
|
||||
--version "${{ needs.version.outputs.version }}" \
|
||||
--game-love .bazinga/work/game.love
|
||||
- name: Verify the AppImage is self-contained and bullseye-compatible
|
||||
env:
|
||||
SHADERFX_BRIDGE_REQUIRED: "1"
|
||||
run: bash scripts/linux-arm64/verify_appimage.sh "dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage"
|
||||
- name: Upload Linux arm64 release
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -173,6 +182,45 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
linux-flatpak:
|
||||
name: build Linux Flatpak
|
||||
needs: [version, love-payload, shaderfx-bridge]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Install flatpak-builder deps
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder elfutils imagemagick
|
||||
- name: Download shared payload
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-release-love
|
||||
path: .bazinga/work
|
||||
- name: Download the ShaderFX bridge
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: shaderfx-bridge-linux-x64
|
||||
path: dist/native/linux-x64
|
||||
- name: Build Flatpak bundle
|
||||
env:
|
||||
SHADERFX_BRIDGE_REQUIRED: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_flatpak.sh \
|
||||
--version "${{ needs.version.outputs.version }}" \
|
||||
--game-love .bazinga/work/game.love
|
||||
- name: Upload Flatpak release
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-linux-flatpak-release
|
||||
path: |
|
||||
dist/flatpak/gen1recomp-${{ needs.version.outputs.version }}-linux.flatpak
|
||||
dist/flatpak/gen1recomp-${{ needs.version.outputs.version }}-linux.flatpak.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
xbox-uwp:
|
||||
name: build Xbox UWP release
|
||||
needs: [version, love-payload]
|
||||
@@ -286,8 +334,85 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
shaderfx-bridge:
|
||||
name: build ShaderFX bridge (${{ matrix.plat }})
|
||||
needs: version
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- plat: win-x64
|
||||
runs-on: windows-2022
|
||||
lib: librashader_bridge.dll
|
||||
- plat: mac
|
||||
runs-on: macos-latest
|
||||
lib: liblibrashader_bridge.dylib
|
||||
- plat: linux-x64
|
||||
runs-on: ubuntu-24.04
|
||||
lib: liblibrashader_bridge.so
|
||||
glibc_triple: x86_64-unknown-linux-gnu.2.17
|
||||
- plat: linux-arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
lib: liblibrashader_bridge.so
|
||||
glibc_triple: aarch64-unknown-linux-gnu.2.17
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: Cache cargo registry and build dir
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: tools/shaderfx-bridge
|
||||
key: ${{ matrix.plat }}-${{ matrix.glibc_triple }}
|
||||
- name: Build the bridge
|
||||
shell: bash
|
||||
env:
|
||||
PLAT: ${{ matrix.plat }}
|
||||
LIB: ${{ matrix.lib }}
|
||||
GLIBC_TRIPLE: ${{ matrix.glibc_triple }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
out="dist/native/$PLAT"
|
||||
mkdir -p "$out"
|
||||
cd tools/shaderfx-bridge
|
||||
case "$PLAT" in
|
||||
win-x64)
|
||||
rustup target add x86_64-pc-windows-msvc
|
||||
cargo build --release --target x86_64-pc-windows-msvc
|
||||
cp "target/x86_64-pc-windows-msvc/release/$LIB" "$GITHUB_WORKSPACE/$out/$LIB"
|
||||
;;
|
||||
mac)
|
||||
rustup target add x86_64-apple-darwin aarch64-apple-darwin
|
||||
cargo build --release --target x86_64-apple-darwin
|
||||
cargo build --release --target aarch64-apple-darwin
|
||||
lipo -create -output "$GITHUB_WORKSPACE/$out/$LIB" \
|
||||
"target/x86_64-apple-darwin/release/$LIB" \
|
||||
"target/aarch64-apple-darwin/release/$LIB"
|
||||
lipo -info "$GITHUB_WORKSPACE/$out/$LIB"
|
||||
;;
|
||||
linux-x64|linux-arm64)
|
||||
pipx install cargo-zigbuild
|
||||
pipx inject cargo-zigbuild ziglang
|
||||
zigpy="$(pipx environment --value PIPX_LOCAL_VENVS)/cargo-zigbuild/bin/python"
|
||||
"$zigpy" -m ziglang version
|
||||
export CARGO_ZIGBUILD_PYTHON_PATH="$zigpy"
|
||||
triple="$GLIBC_TRIPLE"
|
||||
rustup target add "${triple%%.*}"
|
||||
cargo zigbuild --release --target "$triple"
|
||||
cp "target/${triple%%.*}/release/$LIB" "$GITHUB_WORKSPACE/$out/$LIB"
|
||||
;;
|
||||
esac
|
||||
- name: Upload the bridge
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: shaderfx-bridge-${{ matrix.plat }}
|
||||
path: dist/native/${{ matrix.plat }}/${{ matrix.lib }}
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
needs: [version, love-payload, xbox-uwp, linux-arm64, native-tls-win]
|
||||
needs: [version, love-payload, xbox-uwp, linux-arm64, linux-flatpak, native-tls-win, shaderfx-bridge]
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
|
||||
steps:
|
||||
@@ -309,6 +434,28 @@ jobs:
|
||||
name: gen1tls-win-x64
|
||||
path: dist/native/win-x64
|
||||
|
||||
- name: Download ShaderFX bridge libraries
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: shaderfx-bridge-*
|
||||
path: dist/native
|
||||
- name: Flatten the ShaderFX bridge artifact layout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for d in dist/native/shaderfx-bridge-*/; do
|
||||
d="${d%/}"
|
||||
plat="${d#dist/native/shaderfx-bridge-}"
|
||||
mkdir -p "dist/native/$plat"
|
||||
for f in "$d"/*; do mv "$f" "dist/native/$plat/"; done
|
||||
rmdir "$d"
|
||||
done
|
||||
for plat in mac win-x64 linux-x64 linux-arm64; do
|
||||
ls "dist/native/$plat" >/dev/null \
|
||||
|| { echo "::error::no ShaderFX bridge staged for $plat"; exit 1; }
|
||||
done
|
||||
ls -lR dist/native
|
||||
|
||||
# The same game.love the arm64 AppImage and Xbox UWP builds fused, so
|
||||
# every release asset ships one identical payload (build.sh's own pack
|
||||
# would omit PATCH_NOTES.md and mobile/ios/app-repo.json).
|
||||
@@ -358,6 +505,7 @@ jobs:
|
||||
- name: Build macOS + Windows + Linux
|
||||
env:
|
||||
GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll
|
||||
SHADERFX_BRIDGE_REQUIRED: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Sign in-build (identity auto-detected from the temp keychain);
|
||||
@@ -372,11 +520,34 @@ jobs:
|
||||
--game-love dist/payload/game.love
|
||||
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|
||||
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
|
||||
unzip -l dist/win/gen1recomp-win64.zip | grep -F librashader_bridge.dll \
|
||||
|| { echo "::error::Windows zip is missing librashader_bridge.dll"; exit 1; }
|
||||
|
||||
- name: Build Android
|
||||
- name: Materialize Android release signing key
|
||||
env:
|
||||
KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_android.sh --version "${{ needs.version.outputs.version }}"
|
||||
[ -n "$KEYSTORE_B64" ] || {
|
||||
echo "::error::ANDROID_RELEASE_KEYSTORE_B64 is required for a publishable Android update"
|
||||
exit 1
|
||||
}
|
||||
python3 - <<'PY'
|
||||
import base64, os, pathlib
|
||||
encoded = os.environ["KEYSTORE_B64"]
|
||||
path = pathlib.Path(os.environ["RUNNER_TEMP"]) / "gen1recomp-android-release.keystore"
|
||||
path.write_bytes(base64.b64decode(encoded, validate=True))
|
||||
PY
|
||||
|
||||
- name: Build Android
|
||||
env:
|
||||
GEN1RECOMP_ANDROID_KEYSTORE: ${{ runner.temp }}/gen1recomp-android-release.keystore
|
||||
GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }}
|
||||
GEN1RECOMP_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
|
||||
GEN1RECOMP_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_android.sh --release --version "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Install xcbeautify
|
||||
run: |
|
||||
@@ -408,6 +579,8 @@ jobs:
|
||||
--version "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Build Anbernic RG34XXSP port
|
||||
env:
|
||||
SHADERFX_BRIDGE_REQUIRED: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5
|
||||
@@ -421,6 +594,7 @@ jobs:
|
||||
# builds, while this explicit local override keeps CI source-aligned.
|
||||
GEN1RECOMP_SOURCE_DIR: ${{ github.workspace }}
|
||||
GEN1RECOMP_RELEASE_TAG: v${{ needs.version.outputs.version }}
|
||||
SHADERFX_BRIDGE_REQUIRED: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Same aarch64 PortMaster-style pack for Linux ARM SBC PortMaster. The build
|
||||
@@ -477,6 +651,13 @@ jobs:
|
||||
name: gen1recomp-linux-arm64-release
|
||||
path: dist/linux-arm64
|
||||
|
||||
- name: Download Linux Flatpak release
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-linux-flatpak-release
|
||||
path: dist/flatpak
|
||||
|
||||
- name: Stage release assets
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
id: assets
|
||||
@@ -488,18 +669,26 @@ jobs:
|
||||
mkdir -p "$outdir"
|
||||
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"
|
||||
# x86_64 desktop Linux: raw AppImage (no zip wrapper).
|
||||
x64_appimage="dist/linux/gen1recomp-linux-x86_64.AppImage"
|
||||
[ -f "$x64_appimage" ] || { echo "::error::$x64_appimage not found (expected from scripts/build.sh linux)"; exit 1; }
|
||||
cp "$x64_appimage" "$outdir/gen1recomp-${v}-linux-x86_64.AppImage"
|
||||
chmod +x "$outdir/gen1recomp-${v}-linux-x86_64.AppImage"
|
||||
|
||||
# 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.
|
||||
# AppImage 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; }
|
||||
|
||||
flatpak_bundle="dist/flatpak/gen1recomp-${v}-linux.flatpak"
|
||||
[ -f "$flatpak_bundle" ] || { echo "::error::$flatpak_bundle not found (expected from the linux-flatpak job)"; exit 1; }
|
||||
cp "$flatpak_bundle" "$outdir/gen1recomp-${v}-linux.flatpak"
|
||||
apk="$(find dist/android/release -name '*.apk' | head -1)"
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/release"; exit 1; }
|
||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||
|
||||
ipa="dist/ios/gen1recomp++.ipa"
|
||||
@@ -635,8 +824,9 @@ jobs:
|
||||
release_files=(
|
||||
"dist/release/gen1recomp-${v}-macos.zip"
|
||||
"dist/release/gen1recomp-${v}-windows.zip"
|
||||
"dist/release/gen1recomp-${v}-linux.zip"
|
||||
"dist/release/gen1recomp-${v}-linux-x86_64.AppImage"
|
||||
"dist/release/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
"dist/release/gen1recomp-${v}-linux.flatpak"
|
||||
"dist/release/gen1recomp-${v}-android.apk"
|
||||
"dist/release/gen1recomp++-${v}-ios.ipa"
|
||||
"dist/release/gen1recomp-${v}-switch.zip"
|
||||
|
||||
+9
-2
@@ -1,5 +1,9 @@
|
||||
# Generated from a user-provided Pokemon Red ROM.
|
||||
# Regenerate with: python3 tools/build_data.py --rom /path/to/pokemon-red.gb --clean
|
||||
# Generated from a user-provided Pokemon ROM (Red/Blue/Yellow/Gold/Silver/Crystal).
|
||||
# Regenerate with: python3 tools/build_data.py --rom /path/to/rom.gb --clean
|
||||
# Yellow Surfing Pikachu minigame art also lands here on import:
|
||||
# assets/generated/minigame/surf_1{a,b,c}.png
|
||||
# assets/generated/minigame/title_bg.png
|
||||
# assets/generated/minigame/intro_pika_{0,1,2,3}.png
|
||||
data/generated/
|
||||
assets/generated/
|
||||
|
||||
@@ -62,6 +66,9 @@ mobile/dist/
|
||||
# ROM cache exactly like data/generated/, so it is never committable.
|
||||
/build/tiled/
|
||||
|
||||
# Local visual scratch from minigame development (not wired to CI).
|
||||
/tests/fixtures/
|
||||
|
||||
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
|
||||
mobile/ios/bundle_id.local
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ read_globals = {
|
||||
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
|
||||
-- it; without this, every `table.unpack` reads as an undefined field.
|
||||
table = { fields = { "unpack" } },
|
||||
"POKEPORT_DISPLAY_COMPANION",
|
||||
"POKEPORT_EDITOR_MODE",
|
||||
"rawlen",
|
||||
package = { fields = { "searchers" } },
|
||||
}
|
||||
|
||||
-- Vendored/native trees and the test suites have their own conventions.
|
||||
@@ -30,6 +34,7 @@ exclude_files = {
|
||||
"mobile/",
|
||||
"tests/",
|
||||
"tools/save-editor/",
|
||||
"tools/save_convert/vendor/",
|
||||
}
|
||||
|
||||
ignore = {
|
||||
|
||||
+16
-14
@@ -140,8 +140,8 @@ ship text.
|
||||
|
||||
### 4. `games` (and the legacy `gen2compat`)
|
||||
|
||||
Pokemon Gold and Silver are Gen 2, and they run their own battle engine,
|
||||
overworld, script VM and save format. The mod API is shared across both
|
||||
Pokemon Gold, Silver and Crystal are Gen 2, and they run their own battle
|
||||
engine, overworld, script VM and save format. The mod API is shared across both
|
||||
generations (same hook names, same event names, same registry names) but Gen 2
|
||||
cannot serve all of it yet, so it is opt-in. Say which games the mod is for:
|
||||
|
||||
@@ -150,19 +150,20 @@ cannot serve all of it yet, so it is opt-in. Say which games the mod is for:
|
||||
```
|
||||
|
||||
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`,
|
||||
`"silver"`), a generation (`"gen1"`, `"gen2"`) or `"all"`;
|
||||
`"silver"`, `"crystal"`), a generation (`"gen1"`, `"gen2"`) or `"all"`;
|
||||
`src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing
|
||||
restates the game list. `python3 tools/modkit.py scaffold my_mod --games
|
||||
gen1,gen2` writes the key for you. The mod still installs to one directory,
|
||||
`mods/<id>/`, shared by every game -- targeting is declared, never filed.
|
||||
restates the game list, which is why `"gen2"` covers Crystal as well as Gold
|
||||
and Silver. `python3 tools/modkit.py scaffold my_mod --games gen1,gen2` writes
|
||||
the key for you. The mod still installs to one directory, `mods/<id>/`, shared
|
||||
by every game -- targeting is declared, never filed.
|
||||
|
||||
Absent means Gen 1 only, which is what every mod written before the key existed
|
||||
was tested as. `"gen2compat": true` is the legacy spelling, still accepted and
|
||||
purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it
|
||||
already ran on. On a Gold or Silver boot a mod claiming no Gen 2 game is not
|
||||
loaded at all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why,
|
||||
because a mod that half-applies reads as a broken mod. Claim Gen 2 once you
|
||||
have actually run your mod on Gold or Silver.
|
||||
already ran on. On a Gen 2 boot a mod claiming no Gen 2 game is not loaded at
|
||||
all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a
|
||||
mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually
|
||||
run your mod on Gold, Silver or Crystal.
|
||||
|
||||
Every token is enforced, per game: the loader gates on the same
|
||||
`ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]`
|
||||
@@ -172,10 +173,11 @@ manifest with neither key still covers every Gen 1 game, so nothing written
|
||||
before the key existed changes behavior; list both generations or say `"all"`
|
||||
when you mean everywhere.
|
||||
|
||||
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold
|
||||
and Silver today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1,
|
||||
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
|
||||
writes with a report, and which hooks and events are still to come.
|
||||
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold,
|
||||
Silver and Crystal today (40 of the 46 registries, 40 event and 44 hook names
|
||||
shared with Gen 1, and 24 Gen 2-only ones), which registries have no Gen 2 home
|
||||
and drop their writes with a report, and which hooks and events are still to
|
||||
come.
|
||||
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
|
||||
Gen 1 mod, and it is the one to start from.
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
# Gen1Recomp
|
||||
|
||||
A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map
|
||||
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
||||
supplied by the player.
|
||||
A native LÖVE2D recreation of Poke Red, Blue, Gold, Silver, and Crystal. The
|
||||
engine and map behavior are hand-written Lua; game data and graphics are
|
||||
decoded from a ROM supplied by the player.
|
||||
|
||||
And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. ***Reverse Engineering Causes Obsessive Mental Problems***
|
||||
|
||||
[Click Here for the AI Use Disclosure!](AIDisclosure.md)
|
||||
|
||||
> [!CAUTION]
|
||||
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project. Also, as I assumed would eventually happen, the idiot that made that website now pumped it full of adware. Please stay away from that website.
|
||||
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository, the Discord, and https://gen1re.com are the only official sources for this project. Also, as I assumed would eventually happen, the idiot that made that website now pumped it full of adware. Please stay away from that website.
|
||||
|
||||
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
|
||||
|
||||
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
|
||||
# [SUPPORT / ANNOUNCEMENTS / MODS ALL FOUND ON THE DISCORD](https://bois.icu)
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -53,17 +53,19 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
|
||||
|
||||
### Watch the latest update video
|
||||
|
||||
[](https://youtu.be/yi7LkWQPKKM)
|
||||
[](https://youtu.be/0hgtLDNGpdo)
|
||||
|
||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold, or
|
||||
Silver ROM is the only game content input.
|
||||
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold,
|
||||
Silver, or Crystal ROM is the only game content input.
|
||||
|
||||
The ROM is verified, used during import, and then released from memory. It is
|
||||
not copied into the cache. Later launches load the private generated cache and
|
||||
do not ask for the ROM again. Red, Blue, Yellow, Gold, and Silver can all be
|
||||
imported side by side. Gold and Silver are Gen 2 Phase 1 (import + launcher;
|
||||
see `docs/gold-phase1.md`): the Gen 2 engine is still under construction.
|
||||
do not ask for the ROM again. Red, Blue, Yellow, Gold, Silver, and Crystal can
|
||||
all be imported side by side. Gold, Silver, and Crystal are Gen 2 Phase 1
|
||||
(import + launcher; see `docs/gold-phase1.md`): the Gen 2 engine is still under
|
||||
construction, and Crystal is the newest of the three, so the launcher lists it
|
||||
as Crystal (Beta).
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -71,14 +73,17 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` /
|
||||
`.gbc` file or drop it onto the window. Import takes a few seconds and the
|
||||
game starts automatically.
|
||||
|
||||
Only the canonical US Red, Blue, Yellow (1 MiB), Gold, and Silver (2 MiB)
|
||||
ROMs are accepted. The importer verifies SHA-1 before creating any game data:
|
||||
Only the canonical US Red, Blue, Yellow (1 MiB), Gold, Silver, and Crystal
|
||||
(2 MiB) ROMs are accepted. The importer verifies SHA-1 before creating any
|
||||
game data:
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
||||
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
|
||||
- Silver: `49b163f7e57702bc939d642a18f591de55d92dae`
|
||||
- Crystal (1.0): `f4cd194bdee0d04ca4eac29e09b8e4e9d818c133`
|
||||
- Crystal (1.1): `f2f52230b536214ef7c9924f483392993e226cfb`
|
||||
|
||||
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
||||
sound effects, and cries are synthesized while the game runs from compact
|
||||
@@ -122,24 +127,23 @@ supported out of the box.
|
||||
| `2` | Cycle COLORS |
|
||||
| `3` | Cycle TILT (free-roam overworld) |
|
||||
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||
| `5` | Cycle GBC FX |
|
||||
| `F1` | Save |
|
||||
| `F2` | Load |
|
||||
| `F10` | Open / close the mod manager |
|
||||
|
||||
|
||||
COLORS, TILT, ZOOM, GBC FX, GAME SPEED, and VOID FILL are also in the
|
||||
COLORS, TILT, ZOOM, SHADER FX, GAME SPEED, and VOID FILL are also in the
|
||||
Options menu and persist in `options.lua`.
|
||||
|
||||
### Low-end devices
|
||||
|
||||
**OPTIONS → PERFORMANCE** scales the port's optional extras for weaker
|
||||
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt or GBC FX),
|
||||
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt),
|
||||
**LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which
|
||||
picks a tier from your device (ARM handhelds → LOW, phones → BALANCED,
|
||||
normal desktops → HIGH, unchanged). It only scales presentation; the
|
||||
fixed-step game logic is identical on every tier, and a lower tier hides
|
||||
your tilt/zoom/GBC-FX preferences without forgetting them. Details in
|
||||
your tilt/zoom preferences without forgetting them. Details in
|
||||
[docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices).
|
||||
|
||||
### Rulesets
|
||||
@@ -219,23 +223,32 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
|
||||
|
||||
| Option | Effect |
|
||||
| --- | --- |
|
||||
| `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold` and `silver` too, or just `r` / `b` / `y` / `g` / `s`) |
|
||||
| `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold`, `silver` and `crystal` too, or just `r` / `b` / `y` / `g` / `s` / `c`) |
|
||||
| `--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)
|
||||
## Linux desktop (AppImage / Flatpak)
|
||||
|
||||
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:
|
||||
Releases ship raw AppImages (no zip wrapper) plus an optional Flatpak bundle:
|
||||
|
||||
- `gen1recomp-*-linux-x86_64.AppImage`
|
||||
- `gen1recomp-*-linux-arm64.AppImage` (Raspberry Pi 4/5, Armbian, arm64 VMs)
|
||||
- `gen1recomp-*-linux.flatpak` (see [docs/linux-flatpak.md](docs/linux-flatpak.md))
|
||||
|
||||
```sh
|
||||
chmod +x gen1recomp-*-linux-x86_64.AppImage
|
||||
./gen1recomp-*-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
```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
|
||||
Shared troubleshooting (FUSE, curl, portable mode): [docs/linux-appimage.md](docs/linux-appimage.md).
|
||||
|
||||
LÖVE publishes no aarch64 binary of any kind, so the arm64 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
|
||||
|
||||
+187
-51
@@ -134,7 +134,7 @@ mkdir -p "$GAME_SRC"
|
||||
main.lua conf.lua src libs data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
tools/rom_manifest_silver.json \
|
||||
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
@@ -192,6 +192,20 @@ chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/"
|
||||
|
||||
BRIDGE_LIB="liblibrashader_bridge.so"
|
||||
BRIDGE_SRC="${SHADERFX_BRIDGE_LINUX_ARM64:-}"
|
||||
if [ -z "$BRIDGE_SRC" ] && [ -f "$ROOT/dist/native/linux-arm64/$BRIDGE_LIB" ]; then
|
||||
BRIDGE_SRC="$ROOT/dist/native/linux-arm64/$BRIDGE_LIB"
|
||||
fi
|
||||
if [ -n "$BRIDGE_SRC" ] && [ -f "$BRIDGE_SRC" ]; then
|
||||
cp "$BRIDGE_SRC" "$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/$BRIDGE_LIB"
|
||||
say "bundled $BRIDGE_LIB for SHADER FX preset conversion"
|
||||
elif [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then
|
||||
fail "$BRIDGE_LIB not found: set SHADERFX_BRIDGE_LINUX_ARM64 or stage it at dist/native/linux-arm64/$BRIDGE_LIB"
|
||||
else
|
||||
warn "$BRIDGE_LIB not found: this port can run converted presets but not CONVERT new ones"
|
||||
fi
|
||||
|
||||
# Drop a short license pointer for the bundled LÖVE bits.
|
||||
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
|
||||
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
|
||||
@@ -203,81 +217,203 @@ EOF
|
||||
# Resolve the game directory from the launcher so this works with both
|
||||
# PortMaster-managed ports directories.
|
||||
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
|
||||
#!/bin/bash
|
||||
# gen1recomp-sbc — Linux ARM SBC / PortMaster launcher
|
||||
# Uses SHDIR-relative paths so firmware-specific mount points do not matter.
|
||||
#!/usr/bin/env bash
|
||||
# gen1recomp-sbc — High-Performance Native ARM64 Handheld Port Launcher
|
||||
# Compatible with TrimUI Brick, TrimUI Smart Pro, Anbernic (H700/RK3566), muOS, Knulli, ArkOS, Stock OS
|
||||
|
||||
export HOME="${HOME:-/root}"
|
||||
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||
SHDIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
if [ -d "/mnt/SDCARD/Apps/PortMaster/PortMaster/" ]; then
|
||||
controlfolder="/mnt/SDCARD/Apps/PortMaster/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Roms/ports/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/Roms/ports/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Data/PortMaster/" ]; then
|
||||
controlfolder="/mnt/SDCARD/Data/PortMaster"
|
||||
elif [ -d "$SHDIR/PortMaster" ]; then
|
||||
controlfolder="$SHDIR/PortMaster"
|
||||
elif [ -d "/opt/system/Tools/PortMaster/" ]; then
|
||||
controlfolder="/opt/system/Tools/PortMaster"
|
||||
elif [ -d "/opt/tools/PortMaster/" ]; then
|
||||
controlfolder="/opt/tools/PortMaster"
|
||||
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
|
||||
controlfolder="$XDG_DATA_HOME/PortMaster"
|
||||
elif [ -d "/roms/ports/PortMaster" ]; then
|
||||
controlfolder="/roms/ports/PortMaster"
|
||||
else
|
||||
controlfolder="/mnt/SDCARD/Roms/PORTS/PortMaster"
|
||||
fi
|
||||
|
||||
if [ ! -f "$controlfolder/control.txt" ]; then
|
||||
echo "PortMaster control.txt not found under $controlfolder" >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
source "$controlfolder/control.txt"
|
||||
get_controls
|
||||
if [ -n "${CFW_NAME:-}" ] && [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
source "${controlfolder}/mod_${CFW_NAME}.txt"
|
||||
fi
|
||||
|
||||
GAMEDIR="$SHDIR/gen1recomp-sbc"
|
||||
CONFDIR="$GAMEDIR/conf"
|
||||
|
||||
mkdir -p "$CONFDIR"
|
||||
|
||||
cd "$GAMEDIR" || exit 1
|
||||
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
|
||||
|
||||
# Setup POSIX logging (compatible with busybox ash/dash/bash)
|
||||
exec 1>"$GAMEDIR/log.txt" 2>&1
|
||||
|
||||
echo "========================================="
|
||||
echo "gen1recomp Handheld Native ARM64 Launcher"
|
||||
echo "SHDIR: $SHDIR"
|
||||
echo "GAMEDIR: $GAMEDIR"
|
||||
echo "Date: $(date 2>/dev/null || echo 'N/A')"
|
||||
echo "========================================="
|
||||
|
||||
# PortMaster's control.txt wants a home tree under /mnt/SDCARD/Data; on a
|
||||
# fresh card that tree may not exist yet and its mkdir fails noisily (log.txt:
|
||||
# "mkdir: can't create directory '/mnt/SDCARD/Data/home'"). Pre-create it
|
||||
# before any PortMaster script runs so the failure cannot occur.
|
||||
if [ -d /mnt/SDCARD ]; then
|
||||
mkdir -p /mnt/SDCARD/Data/home 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 1. Locate PortMaster control directory if present
|
||||
controlfolder=""
|
||||
if [ -d "/mnt/SDCARD/Apps/PortMaster/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/Apps/PortMaster/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/PortMaster/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/PortMaster/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Apps/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/Apps/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Emus/PORTS/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/Emus/PORTS/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Emus/PORTMASTER" ]; then
|
||||
controlfolder="/mnt/SDCARD/Emus/PORTMASTER"
|
||||
elif [ -d "/mnt/SDCARD/Roms/ports/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/Roms/ports/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Roms/PORTS/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/Roms/PORTS/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Ports/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/Ports/PortMaster"
|
||||
elif [ -d "$SHDIR/PortMaster" ]; then
|
||||
controlfolder="$SHDIR/PortMaster"
|
||||
elif [ -d "/opt/system/Tools/PortMaster" ]; then
|
||||
controlfolder="/opt/system/Tools/PortMaster"
|
||||
elif [ -d "/opt/tools/PortMaster" ]; then
|
||||
controlfolder="/opt/tools/PortMaster"
|
||||
elif [ -d "$XDG_DATA_HOME/PortMaster" ]; then
|
||||
controlfolder="$XDG_DATA_HOME/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Emus/tg5040/PORTS.pak/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/Emus/tg5040/PORTS.pak/PortMaster"
|
||||
elif [ -d "/roms/PORTS/PortMaster" ]; then
|
||||
controlfolder="/roms/PORTS/PortMaster"
|
||||
elif [ -d "/roms/ports/PortMaster" ]; then
|
||||
controlfolder="/roms/ports/PortMaster"
|
||||
fi
|
||||
|
||||
if [ -n "$controlfolder" ] && [ -f "$controlfolder/control.txt" ]; then
|
||||
echo "Found PortMaster control folder: $controlfolder"
|
||||
# shellcheck disable=SC1090
|
||||
. "$controlfolder/control.txt"
|
||||
if type get_controls >/dev/null 2>&1; then
|
||||
get_controls
|
||||
fi
|
||||
if [ -n "${CFW_NAME:-}" ] && [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
. "${controlfolder}/mod_${CFW_NAME}.txt"
|
||||
fi
|
||||
else
|
||||
echo "PortMaster control.txt not found, using standalone handheld configuration."
|
||||
fi
|
||||
|
||||
# 2. CPU governor
|
||||
# Pinning every core to performance keeps handhelds at peak clock even when gameplay
|
||||
# uses only a fraction of CPU. Schedutil is the default; set POKEPORT_CPU_GOVERNOR=performance
|
||||
# only for profiling or devices needing an extra boost.
|
||||
CPU_GOVERNOR_OVERRIDE="${POKEPORT_CPU_GOVERNOR:-schedutil}"
|
||||
CPU_GOVERNOR_STATE=""
|
||||
|
||||
write_cpu_governor() {
|
||||
local governor_path="$1"
|
||||
local governor="$2"
|
||||
if [ -n "${ESUDO:-}" ]; then
|
||||
printf '%s\n' "$governor" | $ESUDO tee "$governor_path" >/dev/null 2>&1 || true
|
||||
else
|
||||
printf '%s\n' "$governor" > "$governor_path" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
restore_cpu_governors() {
|
||||
[ -n "$CPU_GOVERNOR_STATE" ] || return 0
|
||||
while IFS='|' read -r governor_path governor; do
|
||||
[ -n "$governor_path" ] || continue
|
||||
write_cpu_governor "$governor_path" "$governor"
|
||||
done <<< "$CPU_GOVERNOR_STATE"
|
||||
CPU_GOVERNOR_STATE=""
|
||||
}
|
||||
|
||||
apply_cpu_governors() {
|
||||
[ -n "$CPU_GOVERNOR_OVERRIDE" ] || return 0
|
||||
for governor_path in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
|
||||
[ -f "$governor_path" ] || continue
|
||||
old_governor=$(cat "$governor_path" 2>/dev/null || true)
|
||||
[ -n "$old_governor" ] || continue
|
||||
CPU_GOVERNOR_STATE="${CPU_GOVERNOR_STATE}${governor_path}|${old_governor}"$'\n'
|
||||
write_cpu_governor "$governor_path" "$CPU_GOVERNOR_OVERRIDE"
|
||||
done
|
||||
echo "CPU governor requested: $CPU_GOVERNOR_OVERRIDE"
|
||||
}
|
||||
|
||||
# 3. Environment & Low-Level OS Tuning
|
||||
export POKEPORT_HANDHELD=1
|
||||
export HANDHELD=1
|
||||
export PORTMASTER=1
|
||||
export NINTENDO_LAYOUT=1
|
||||
export XDG_DATA_HOME="$CONFDIR"
|
||||
export XDG_CONFIG_HOME="$CONFDIR"
|
||||
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}"
|
||||
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:$GAMEDIR/libs:/usr/trimui/lib:/mnt/SDCARD/System/lib:/usr/lib64:/usr/lib:${LD_LIBRARY_PATH:-}"
|
||||
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
|
||||
# GLES is the common path on ARM SBC handhelds; firmware may override it.
|
||||
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
|
||||
|
||||
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64
|
||||
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
|
||||
# Audio buffer size
|
||||
export SDL_AUDIO_SAMPLES=1024
|
||||
|
||||
if [ -n "${GPTOKEYB:-}" ]; then
|
||||
$GPTOKEYB "love.aarch64" &
|
||||
# Chip-synth sample rate. The audio worker is the dominant CPU cost while
|
||||
# idle (music keeps playing when the screen is static), and synthesis cost
|
||||
# scales linearly with the rate. 22050 Hz roughly halves audio CPU vs the
|
||||
# 44100 Hz default; the Game Boy's own DAC content is well below 11 kHz, so
|
||||
# the handheld speaker sounds effectively identical.
|
||||
export POKEPORT_AUDIO_RATE=22050
|
||||
|
||||
# Idle-power render governor: after 10s with no input on static screens,
|
||||
# presentation drops to IDLE_FPS, cutting idle CPU/GPU compositing ~6x.
|
||||
# Any button press restores full framerate next frame.
|
||||
export POKEPORT_IDLE_FPS=6
|
||||
|
||||
# Memory allocator tuning (limit arena fragmentation on 1GB RAM SBCs)
|
||||
export MALLOC_ARENA_MAX=2
|
||||
export MALLOC_TRIM_THRESHOLD_=131072
|
||||
|
||||
# Process scheduling & CPU affinity
|
||||
renice -n -5 -p $$ >/dev/null 2>&1 || true
|
||||
taskset -cp 0-3 $$ >/dev/null 2>&1 || true
|
||||
ionice -c 3 -p $$ >/dev/null 2>&1 || true
|
||||
|
||||
# Ensure executable permissions
|
||||
if [ -n "${ESUDO:-}" ]; then
|
||||
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || true
|
||||
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
|
||||
else
|
||||
chmod a+x ./bin/love.aarch64 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 4. Platform helper
|
||||
if type pm_platform_helper >/dev/null 2>&1; then
|
||||
echo "Running pm_platform_helper..."
|
||||
pm_platform_helper "$GAMEDIR/bin/love.aarch64"
|
||||
fi
|
||||
|
||||
# Apply after PortMaster's helper: some firmware helpers reset cpufreq policy
|
||||
# while preparing the runtime. The saved values are restored on exit.
|
||||
apply_cpu_governors
|
||||
trap restore_cpu_governors EXIT
|
||||
|
||||
# PulseAudio runtime: PortMaster's helper points it at conf/pulse on the vfat
|
||||
# SD card, where the runtime symlink fails ("Operation not permitted" -- vfat
|
||||
# has no symlinks). Force the runtime onto tmpfs (real dirs, no symlinks).
|
||||
export PULSE_RUNTIME_PATH="/tmp/pulse-$PPID"
|
||||
mkdir -p "$PULSE_RUNTIME_PATH" 2>/dev/null || true
|
||||
|
||||
echo "Launching ./bin/love.aarch64 $GAMEDIR/lovegame..."
|
||||
echo "LD_LIBRARY_PATH: $LD_LIBRARY_PATH"
|
||||
|
||||
# 5. Launch
|
||||
./bin/love.aarch64 "$GAMEDIR/lovegame"
|
||||
EXIT_CODE=$?
|
||||
|
||||
echo "Game exited with code: $EXIT_CODE"
|
||||
|
||||
# 6. Cleanup
|
||||
restore_cpu_governors
|
||||
|
||||
if type pm_finish >/dev/null 2>&1; then
|
||||
pm_finish
|
||||
else
|
||||
if [ -n "${ESUDO:-}" ]; then
|
||||
$ESUDO kill -9 $(pidof gptokeyb) 2>/dev/null || true
|
||||
else
|
||||
kill -9 $(pidof gptokeyb) 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
exit $EXIT_CODE
|
||||
EOF
|
||||
chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
|
||||
|
||||
|
||||
+17
-13
@@ -92,7 +92,7 @@ mkdir -p "$GAME_SRC"
|
||||
main.lua conf.lua src libs data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
tools/rom_manifest_silver.json \
|
||||
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
|
||||
printf '%s\n' "$payload_list" \
|
||||
@@ -102,6 +102,8 @@ printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|
||||
|| fail "payload is missing tools/rom_manifest_gold.json"
|
||||
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_silver.json" \
|
||||
|| fail "payload is missing tools/rom_manifest_silver.json"
|
||||
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_crystal.json" \
|
||||
|| fail "payload is missing tools/rom_manifest_crystal.json"
|
||||
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
||||
rm -f "$WORK/game-payload.zip"
|
||||
|
||||
@@ -154,6 +156,20 @@ chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/"
|
||||
|
||||
BRIDGE_LIB="liblibrashader_bridge.so"
|
||||
BRIDGE_SRC="${SHADERFX_BRIDGE_LINUX_ARM64:-}"
|
||||
if [ -z "$BRIDGE_SRC" ] && [ -f "$ROOT/dist/native/linux-arm64/$BRIDGE_LIB" ]; then
|
||||
BRIDGE_SRC="$ROOT/dist/native/linux-arm64/$BRIDGE_LIB"
|
||||
fi
|
||||
if [ -n "$BRIDGE_SRC" ] && [ -f "$BRIDGE_SRC" ]; then
|
||||
cp "$BRIDGE_SRC" "$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/$BRIDGE_LIB"
|
||||
say "bundled $BRIDGE_LIB for SHADER FX preset conversion"
|
||||
elif [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then
|
||||
fail "$BRIDGE_LIB not found: set SHADERFX_BRIDGE_LINUX_ARM64 or stage it at dist/native/linux-arm64/$BRIDGE_LIB"
|
||||
else
|
||||
warn "$BRIDGE_LIB not found: this port can run converted presets but not CONVERT new ones"
|
||||
fi
|
||||
|
||||
# Drop a short license pointer for the bundled LÖVE bits.
|
||||
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
|
||||
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
|
||||
@@ -228,11 +244,6 @@ export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}"
|
||||
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
|
||||
# Mali / H700: prefer GLES where available
|
||||
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
|
||||
# Same GPU class as a phone, but getOS() here says "Linux", so the Android
|
||||
# gate (issue #136) would not fire on its own: refuse GBC FX explicitly.
|
||||
# Hides the OPTIONS row, pins the level to OFF, and heals a level already
|
||||
# persisted in options.lua.
|
||||
export POKEPORT_GBCFX="${POKEPORT_GBCFX:-0}"
|
||||
|
||||
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64
|
||||
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
|
||||
@@ -323,13 +334,6 @@ Native LÖVE 11.5 port of gen1recomp for Anbernic RG34XXSP on
|
||||
|
||||
In-game controls use the normal PortMaster / SDL pad map (rebind under OPTIONS → CONTROLS).
|
||||
|
||||
### Display options
|
||||
|
||||
GBC FX is disabled on this device (the H700's Mali GPU compiles that present
|
||||
pass and then shows a black frame), so the OPTIONS row is hidden. COLORS,
|
||||
TILT, ZOOM, VOID FILL and MAX FPS all work. To try it anyway, launch with
|
||||
`POKEPORT_GBCFX=1`.
|
||||
|
||||
### First run
|
||||
|
||||
Stock OS has no zenity file picker. Put the `.gb` in `lovegame/`, then press
|
||||
|
||||
@@ -62,10 +62,25 @@ function love.conf(t)
|
||||
t.window.minheight = 360
|
||||
end
|
||||
t.version = love._os == "iOS" and "12.0" or "11.5"
|
||||
t.window.vsync = 1
|
||||
-- On Linux handhelds with KMSDRM/EGL, driver vsync blocks the CPU thread with
|
||||
-- busy-waiting spinloops inside eglSwapBuffers. Disabling driver vsync allows
|
||||
-- love.run to perform kernel nanosleep(), dropping idle CPU from 25% to 4%.
|
||||
if os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
|
||||
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
|
||||
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1" then
|
||||
t.window.vsync = 0
|
||||
else
|
||||
t.window.vsync = 1
|
||||
end
|
||||
t.modules.audio = not companion
|
||||
t.modules.joystick = not companion
|
||||
t.modules.physics = false
|
||||
-- love.sensor exposes raw accelerometer/gyroscope data (love.sensor.getData),
|
||||
-- independent of t.accelerometerjoystick below (which instead maps the
|
||||
-- accelerometer onto joystick axes and stays off -- see that flag's
|
||||
-- comment for why). Explicit here so a future effect (e.g. a tilt-driven
|
||||
-- reflective-screen look) has sensor data available without reviving #468.
|
||||
t.modules.sensor = true
|
||||
|
||||
-- love.system is not loaded during love.conf; love._os is set by the
|
||||
-- engine before conf runs (LÖVE 11.x / 11.5).
|
||||
|
||||
@@ -0,0 +1,765 @@
|
||||
return {
|
||||
{ name = "BGB", palettes = {
|
||||
{ "BGB_v0.3", "142c3c548c74acd494ecfccc" },
|
||||
{ "Blue", "0000600000c05f5fffc0c0ff" },
|
||||
{ "GB_Kiosk", "1434046c6c04bcbc1cececb4" },
|
||||
{ "GB_Light", "004f3b00694a009a7100b581" },
|
||||
{ "Grey", "1414145c5c5ca4a4a4ececec" },
|
||||
{ "Greys_High_Contrast", "000000686868b0b0b0ffffff" },
|
||||
{ "KIG_Green", "20402030703040a04050d050" },
|
||||
{ "LCD_Green", "0c1c24346c548cc474e4fcd4" },
|
||||
{ "Red", "600000c00000ff6060ffc0c0" },
|
||||
{ "Super_GB", "311852ad2921de944affefce" },
|
||||
{ "Yellow", "202010686830b0a848f8f078" },
|
||||
{ "Yellow_BGB", "556f3593a73bd2cf4df4ebbf" },
|
||||
{ "NGM_Brown", "483818987838d8b058f8e088" },
|
||||
} },
|
||||
{ name = "BGB/Inverted", palettes = {
|
||||
{ "BGB_v0.3 - Inverted", "ecfcccacd494548c74142c3c" },
|
||||
{ "Blue - Inverted", "c0c0ff5f5fff0000c0000060" },
|
||||
{ "GB_Kiosk - Inverted", "ececb4bcbc1c6c6c04143404" },
|
||||
{ "GB_Light - Inverted", "00b581009a7100694a004f3b" },
|
||||
{ "Grey - Inverted", "ecececa4a4a45c5c5c141414" },
|
||||
{ "Greys_High_Contrast - Inverted", "ffffffb0b0b0686868000000" },
|
||||
{ "KIG_Green - Inverted", "50d05040a040307030204020" },
|
||||
{ "LCD_Green - Inverted", "e4fcd48cc474346c540c1c24" },
|
||||
{ "Red - Inverted", "ffc0c0ff6060c00000600000" },
|
||||
{ "Super_GB - Inverted", "ffefcede944aad2921311852" },
|
||||
{ "Yellow - Inverted", "f8f078b0a848686830202010" },
|
||||
{ "Yellow_BGB- Inverted", "f4ebbfd2cf4d93a73b556f35" },
|
||||
{ "NGM_Brown - Inverted", "f8e088d8b058987838483818" },
|
||||
} },
|
||||
{ name = "GalleryWebApp Palettes", palettes = {
|
||||
{ "Artistic Caffeinated Lactose", "2416069e754fdea963fdfef5" },
|
||||
{ "AQP Pikes Peak", "2a201efa2b25868779ebeee7" },
|
||||
{ "Azure Clouds", "00000012412732b66d47ff99" },
|
||||
{ "BGB Emulator", "08182034685688c070e0f8d0" },
|
||||
{ "Black & White", "000000555555aaaaaaffffff" },
|
||||
{ "Black Zero", "2e463d385d49577b467e8416" },
|
||||
{ "CGA Palette Crush 1", "000000ff55ff55ffffffffff" },
|
||||
{ "CGA Palette Crush 2", "000000ff555555ffffffffff" },
|
||||
{ "CMYKeystone", "373737fb00fa0be8fdffff00" },
|
||||
{ "Candy Cotton Tower Raid", "3800168f0039e65790e6aec4" },
|
||||
{ "Caramel Fudge Paranoia", "3f1711b01553cf7163cf9255" },
|
||||
{ "Childhood in Greenland", "405010708028a0a840d0d058" },
|
||||
{ "Cyanide Blues", "0e1e3d1e479321aff59efbe3" },
|
||||
{ "Deep Haze Green", "00000027421f467818a1d909" },
|
||||
{ "Dies ist meine Wassermelone", "222903558429f27d7affdbcb" },
|
||||
{ "Drowning at Night", "030c2220293f586164a9b0b3" },
|
||||
{ "DUN 2000 Remastered", "000000725441c09e7dfbf1cd" },
|
||||
{ "Flowerfeldstrasse", "171f6275868fc5c5cee9d9cc" },
|
||||
{ "Floyd Steinberg in Love", "4c1c2dd23c4e5fb1f5eaf5fa" },
|
||||
{ "GB Light", "0b7a6d16a59619c7b31ddece" },
|
||||
{ "GB Pocket", "1f1f1f4d533c8b956dc4cfa1" },
|
||||
{ "Glowing Mountains", "2f1c35514f6ca1a8b8ffbf98" },
|
||||
{ "Golden Elephant Curry", "0000004f3000c27600ff9c00" },
|
||||
{ "Grafixkid Gray", "2b2b26706b66a89f94e0dbcd" },
|
||||
{ "Grafixkid Green", "4c625a7b9278abc396dbf4b4" },
|
||||
{ "Knee-Deep in the Wood", "2c24103c3ca9d5690ffffe6e" },
|
||||
{ "LNK late Awakening", "5a39216b8c427bc67bffffb5" },
|
||||
{ "MTR Aran Remixed", "2c1700b62558047e60aedf1e" },
|
||||
{ "My Friend from Bavaria", "382977b60077df7925feda1b" },
|
||||
{ "Nortorious Comadante", "0402ac04aaac54fefcfcfe54" },
|
||||
{ "Original GB", "0f380f30623077a1129bbc0f" },
|
||||
{ "Purple Rain", "68006aff00848570b2adfffc" },
|
||||
{ "Romeros Garden", "323727574431649a57ebc4ab" },
|
||||
{ "Rusted City Rain", "2c2137764462a96868edb4a1" },
|
||||
{ "Space Haze Overload", "0b06306b1fb1cc3495f8e3c4" },
|
||||
{ "Starlit Memories", "4f133f6f20966d53bd869ad9" },
|
||||
{ "Sunflower Holidays", "000000881400ff5555ffff55" },
|
||||
{ "Super Hyper Mega GB", "331e50a63725d68e49f7e7c6" },
|
||||
{ "The death of Yung Columbus", "1d1414462917ff2261b5ff32" },
|
||||
{ "The starry knight", "123f770f86b637cae5f5db37" },
|
||||
{ "There is Always Money", "221a09977b25fed638fdfe0a" },
|
||||
{ "Tramonto al Parco degli Acquedotti", "0c0a3e912978e64a4ef3c677" },
|
||||
{ "VTB 1985", "000000520000db0000ff0000" },
|
||||
{ "Waterfront Plaza", "10253342678e6f9edfcecece" },
|
||||
{ "Youth Ikarus reloaded", "1e00009e0000f78e50cef7f7" },
|
||||
} },
|
||||
{ name = "GB Color (Combo Palettes)", palettes = {
|
||||
{ "GBC - Down + A", "000000ff0000ffff00ffffff" },
|
||||
{ "GBC - Down + B", "0000007b4a00ffff00ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC - Down", "0000009494ffff9494ffffa5" },
|
||||
{ "GBC - Left + A", "00000052528c8c8cdeffffff", "000000943a3aff8484ffffff", "000000843100ffad63ffffff" },
|
||||
{ "GBC - Left + B", "000000525252a5a5a5ffffff" },
|
||||
{ "GBC - Left", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC - Right + A", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC - Right + B", "ffffffffde00008484000000" },
|
||||
{ "GBC - Right", "000000ff420052ff00ffffff" },
|
||||
{ "GBC - Up + A", "000000943a3aff8484ffffff", "0000000084007bff31ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC - Up + B", "5a3108846b29ce9c84ffe6c5", "000000843100ffad63ffffff", "000000843100ffad63ffffff" },
|
||||
{ "GBC - Up", "000000843100ffad63ffffff" },
|
||||
} },
|
||||
{ name = "GB Color (Unique Palettes)", palettes = {
|
||||
{ "GBC Palette 001 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 002 (World)", "000000006300ffff00a59cff" },
|
||||
{ "GBC Palette 003 (USA, Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 004 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 005 (USA)", "ffffffffde00008484000000" },
|
||||
{ "GBC Palette 006 (USA, Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 007 (USA, Europe)", "000000ff0000ff9c00ffffff" },
|
||||
{ "GBC Palette 008 (World)", "ffffffffff00ff840052de00", "0000ff63a5ffffffffffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 009 (USA)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 010 (Europe)", "000000843100ffad63ffffff", "0000000084007bff31ffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 011 (Europe)", "000000843100ffad63ffffff", "0000000084007bff31ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 012 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 013 (World) (Rev A)", "000000ff0000ff9c00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 014 (World)", "000000ff0000ff9c00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 015 (Japan)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 016 (USA, Europe)", "003a3a63947394b5ffffff9c", "4a0000943a00ffd600ffc542", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 017 (USA, Europe)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 018 (USA, Europe) (Rev A)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 019 (USA, Europe)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 020 (World) (Rev A)", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 021 (World)", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 022 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 023 (World) (Rev A)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 024 (World)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 025 (USA)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 026 (Europe)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 027 (Japan)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 028 (Australia)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 029 (Japan)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 030 (Japan)", "00000042737badad84ffffff", "000000944200ff7300ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 031 (Europe)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 032 (USA) (Rev A)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 033 (USA)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 034 (World)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 035 (Japan) (Rev A)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
|
||||
{ "GBC Palette 036 (Japan)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
|
||||
{ "GBC Palette 037 (Japan)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
|
||||
{ "GBC Palette 038 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 039 (Japan)", "00000052528c8c8cdeffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 040 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 041 (USA, Europe)", "00000052528c8c8cdeffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 042 (USA, Europe)", "00000052528c8c8cdeffffff", "000000943a3aff8484ffffff", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 043 (USA)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 044 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 045 (Japan)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
|
||||
{ "GBC Palette 046 (Japan)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 047 (Japan)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "000000630000d60000ff6352" },
|
||||
{ "GBC Palette 048 (USA, Europe)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
|
||||
{ "GBC Palette 049 (USA, Europe)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
|
||||
{ "GBC Palette 050 (USA, Europe)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
|
||||
{ "GBC Palette 051 (USA, Europe)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "000000630000d60000ff6352" },
|
||||
{ "GBC Palette 052 (USA, Europe)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 053 (France)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 054 (Germany)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 055 (USA, Europe) (Rev A)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 056 (USA, Europe) (Rev B)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 057 (USA, Europe)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 058 (Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 059 (Europe)", "000000ff420052ff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 060 (Japan)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 061 (USA, Europe)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 062 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 063 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 064 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 065 (World)", "0000000000ff63a5ffffffff", "000000630000ff0000ffff00", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 066 (Japan)", "00000042737badad84ffffff", "000000944200ff7300ffffff", "000000944200ff7300ffffff" },
|
||||
{ "GBC Palette 067 (USA, Europe)", "00000042737badad84ffffff", "000000944200ff7300ffffff", "000000944200ff7300ffffff" },
|
||||
{ "GBC Palette 068 (Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 069 (France)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 070 (Germany)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 071 (Europe)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 072 (USA, Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 073 (Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 074 (USA)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 075 (Japan)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 076 (Europe)", "00000052528c8c8cdeffffff", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542" },
|
||||
{ "GBC Palette 077 (USA)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 078 (Europe)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "4a0000943a00ffd600ffc542" },
|
||||
{ "GBC Palette 079 (Japan) (Rev A)", "0000009c6300ffce00ffffff" },
|
||||
{ "GBC Palette 080 (Japan) (Rev 0A)", "000000ff0000ffff00ffffff" },
|
||||
{ "GBC Palette 081 (Japan) (Rev B)", "000000ff0000ffff00ffffff" },
|
||||
{ "GBC Palette 082 (Japan) (Rev C)", "000000ff0000ffff00ffffff" },
|
||||
{ "GBC Palette 083 (Japan) (Rev D)", "000000ff0000ffff00ffffff" },
|
||||
{ "GBC Palette 084 (Japan) (Rev A)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 085 (Japan)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 086 (Japan)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 087 (Japan) (Rev A)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 088 (Japan)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 089 (Germany)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 090 (USA, Europe)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 091 (Spain)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 092 (Spain)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 093 (USA, Europe)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 094 (Germany)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 095 (France)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 096 (France)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 097 (Italy)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 098 (Italy)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 099 (World)", "000000ff0000ffff00ffffff", "000000ff0000ffff00ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 100 (Japan)", "003a3a63947394b5ffffff9c" },
|
||||
{ "GBC Palette 101 (USA, Europe)", "003a3a63947394b5ffffff9c" },
|
||||
{ "GBC Palette 102 (Europe) (En,Fr,De)", "000000ff524affffff6bff00", "0000ff63a5ffffffffffffff", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 103 (World)", "ffffffffde00008484000000" },
|
||||
{ "GBC Palette 104 (Europe)", "ffffffffde00008484000000" },
|
||||
{ "GBC Palette 105 (USA)", "ffffffffde00008484000000" },
|
||||
{ "GBC Palette 106 (USA, Europe) (Rev A)", "000000843100ffad63ffffff", "0000000084007bff31ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 107 (USA, Europe) (Rev A)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 108 (Japan)", "003a3a63947394b5ffffff9c", "4a0000943a00ffd600ffc542", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 109 (World) (Rev A)", "000000ad5a42ffff94b5b5ff", "943a3aff8484ffffff000000", "943a3aff8484ffffff000000" },
|
||||
{ "GBC Palette 110 (World)", "000000ad5a42ffff94b5b5ff", "943a3aff8484ffffff000000", "943a3aff8484ffffff000000" },
|
||||
{ "GBC Palette 111 (USA, Europe) (Rev A)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 112 (USA, Europe) (Rev B)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 113 (USA, Europe)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 114 (Japan) (Rev B)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 115 (Japan)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 116 (USA, Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 117 (World)", "000000ff524affffff6bff00", "0000ff63a5ffffffffffffff", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 118 (World) (Rev A)", "000000ff0000ffff00ffffff" },
|
||||
{ "GBC Palette 119 (World)", "000000ff0000ffff00ffffff" },
|
||||
{ "GBC Palette 120 (Europe)", "000000ff0000ffff00ffffff", "000000ff0000ffff00ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 121 (USA)", "000000ff0000ffff00ffffff", "000000ff0000ffff00ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 122 (USA)", "000000ff420052ff00ffffff", "000000ff420052ff00ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 123 (USA, Europe) (Rev A)", "000000ff420052ff00ffffff", "000000ff420052ff00ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 124 (USA, Europe)", "000000ff0000ff9c00ffffff" },
|
||||
{ "GBC Palette 125 (Japan)", "000000ff0000ffff00ffffff", "000000ff0000ffff00ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 126 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 127 (USA)", "000000ff524affffff6bff00", "0000ff63a5ffffffffffffff", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 128 (Europe)", "000000ff524affffff6bff00", "0000ff63a5ffffffffffffff", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 129 (Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 130 (USA, Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 131 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 132 (World)", "00000042737badad84ffffff", "000000944200ff7300ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 133 (USA, Europe)", "00000042737badad84ffffff", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 134 (USA, Europe)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff", "ff00000084ffffff7bffffff" },
|
||||
{ "GBC Palette 135 (Japan)", "000000525252a5a5a5ffffff" },
|
||||
{ "GBC Palette 136 (Japan) (Rev A)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 137 (Japan)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 138 (USA)", "000000ff420052ff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 139 (Japan)", "000000ff0000ff9c00ffffff", "000000ff0000ff9c00ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 140 (Japan)", "000000ff420052ff00ffffff", "000000ff420052ff00ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 141 (Japan)", "000000ff420052ff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 142 (USA, Europe)", "000000ff0000ff9c00ffffff", "000000ff0000ff9c00ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 143 (Japan) (Rev A)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 144 (Japan)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
|
||||
} },
|
||||
{ name = "GB Color (Unused Palettes)", palettes = {
|
||||
{ "Unused0", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "Unused1", "000000ff0000ffff00ffffff", "0000000000ff63a5ffffffff", "0000ffff00005abdffffffff" },
|
||||
{ "Unused2", "000000ad5a42ffff94b5b5ff", "943a3aff8484ffffff000000", "943a3aff8484ffffff000000" },
|
||||
{ "Unused3", "ffffffffde00008484000000", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
|
||||
{ "Unused4", "000000525252a5a5a5ffffff" },
|
||||
{ "Unused5", "0000009c6300ffce00ffffff" },
|
||||
} },
|
||||
{ name = "R.A.Helllord/Limited Edition Pockets", palettes = {
|
||||
{ "Classic Blue LE", "0000000232741b559e5775af" },
|
||||
{ "Classic Green LE", "00000001633f0b8a68549d80" },
|
||||
{ "Classic Indigo LE", "0000002631725457a47b80b4" },
|
||||
{ "Classic Pink LE", "000000d8648beb8cb3efa1bf" },
|
||||
{ "Classic Red LE", "00000095030dbc2b2bcb5d5b" },
|
||||
{ "Classic Silver LE", "000000908a85cececee0dfde" },
|
||||
{ "Classic Spice Orange LE", "000000c96722e38f48e8a36c" },
|
||||
{ "Classic Yellow LE", "000000cf8b08d2921de8be63" },
|
||||
{ "Glow in the Dark LE", "00000005a56d4dcc808cf68a" },
|
||||
{ "Transparent Blue LE", "0000001e124f361aa6646dcb", "1e124f361aa6646dcb7f8cff", "1e124f361aa6646dcb7f8cff" },
|
||||
{ "Transparent Clear LE", "0000003e3b395959599c9a99", "3e3b395959599c9a99d7d7d7", "3e3b395959599c9a99d7d7d7" },
|
||||
{ "Transparent Green LE", "000000003c0600b10003ce09", "003c0600b10003ce09afe4ab", "003c0600b10003ce09afe4ab" },
|
||||
{ "Transparent Orange LE", "0000003c1405ad2d01e56400", "3c1405ad2d01e56400ebbf8f", "3c1405ad2d01e56400ebbf8f" },
|
||||
{ "Transparent Purple LE", "0000003c2e498d6ea7a986bb", "3c2e498d6ea7a986bbe0d6e5", "3c2e498d6ea7a986bbe0d6e5" },
|
||||
{ "Transparent Red LE", "0000006d1a1a991019c51d21", "6d1a1a991019c51d21eab5b1", "6d1a1a991019c51d21eab5b1" },
|
||||
{ "Transparent Smoke LE", "000000282421363333686464", "282421363333686464bfbfbe", "282421363333686464bfbfbe" },
|
||||
} },
|
||||
{ name = "R.A.Helllord/Limited Edition Pockets Inverted", palettes = {
|
||||
{ "Classic Blue LE - inverted", "5775af1b559e023274000000" },
|
||||
{ "Classic Green LE - inverted", "549d800b8a6801633f000000" },
|
||||
{ "Classic Indigo LE - inverted", "7b80b45457a4263172000000" },
|
||||
{ "Classic Pink LE - inverted", "efa1bfeb8cb3d8648b000000" },
|
||||
{ "Classic Red LE - inverted", "cb5d5bbc2b2b95030d000000" },
|
||||
{ "Classic Silver LE - inverted", "e0dfdececece908a85000000" },
|
||||
{ "Classic Spice Orange LE - inverted", "e8a36ce38f48c96722000000" },
|
||||
{ "Classic Yellow LE - inverted", "e8be63d2921dcf8b08000000" },
|
||||
{ "Glow in the Dark LE - inverted", "8cf68a4dcc8005a56d000000" },
|
||||
{ "Transparent Blue LE - inverted", "646dcb361aa61e124f000000", "7f8cff646dcb361aa61e124f", "7f8cff646dcb361aa61e124f" },
|
||||
{ "Transparent Clear LE - inverted", "9c9a995959593e3b39000000", "d7d7d79c9a995959593e3b39", "d7d7d79c9a995959593e3b39" },
|
||||
{ "Transparent Green LE - inverted", "03ce0900b100003c06000000", "afe4ab03ce0900b100003c06", "afe4ab03ce0900b100003c06" },
|
||||
{ "Transparent Orange LE - inverted", "e56400ad2d013c1405000000", "ebbf8fe56400ad2d013c1405", "ebbf8fe56400ad2d013c1405" },
|
||||
{ "Transparent Purple LE - inverted", "a986bb8d6ea73c2e49000000", "e0d6e5a986bb8d6ea73c2e49", "e0d6e5a986bb8d6ea73c2e49" },
|
||||
{ "Transparent Red LE - inverted", "c51d219910196d1a1a000000", "eab5b1c51d219910196d1a1a", "eab5b1c51d219910196d1a1a" },
|
||||
{ "Transparent Smoke LE - inverted", "686464363333282421000000", "bfbfbe686464363333282421", "bfbfbe686464363333282421" },
|
||||
} },
|
||||
{ name = "R.A.Helllord/PPB", palettes = {
|
||||
{ "PPB - Amber", "ffb13b8c61216043172d1f0b" },
|
||||
{ "PPB - Blue", "28ccff16708c0f4b5e07242d" },
|
||||
{ "PPB - Green", "00ee00008e00005f00002f00" },
|
||||
{ "PPB - White", "c1ffff6a8c8c496060202b2b" },
|
||||
{ "PPB inverted - Amber", "2d1f0b6043178c6121ffb13b" },
|
||||
{ "PPB inverted - Blue", "07242d0f4b5e16708c28ccff" },
|
||||
{ "PPB inverted - Green", "002f00005f00008e0000ee00" },
|
||||
{ "PPB inverted - White", "202b2b4960606a8c8cc1ffff" },
|
||||
} },
|
||||
{ name = "SMY", palettes = {
|
||||
{ "Desert", "3e2f30746657a49b83d2d0b1" },
|
||||
{ "Evening", "012636185569539889aee4a6" },
|
||||
{ "Fake Crystal", "21293129317b4a73bd9ce7f7" },
|
||||
{ "Fog", "343c3756727386a39dbfd2c3" },
|
||||
{ "Magic Eggplant", "36213c842e949d69c7b0e4f1" },
|
||||
{ "Radioactive Pea", "00522106731f349e16b8ce03" },
|
||||
{ "Seaweed", "15003f32654278a758dfe095" },
|
||||
{ "Twilight", "15003f861246bd5462e9d397" },
|
||||
} },
|
||||
{ name = "Super GB", palettes = {
|
||||
{ "1-A (Default)", "311852ad2921de944affefce" },
|
||||
{ "1-B", "000000b55210ceb573dedec6" },
|
||||
{ "1-C", "39399c9c3963ef9c52ffc6ff" },
|
||||
{ "1-D", "521800ff0000c6844affffad" },
|
||||
{ "1-E", "5a39216b8c427bc67bffdeb5" },
|
||||
{ "1-F", "004210ad0000e78c52deefff" },
|
||||
{ "1-G", "ffff5a7b7b0000a5ef000052" },
|
||||
{ "1-H", "311800844200ffbd8cffefe7" },
|
||||
{ "2-A", "000000297b00c68c4af7cea5" },
|
||||
{ "2-B", "52005aff3100ffef52ffffff" },
|
||||
{ "2-C", "29299c7b31efef8c8cffc6ff" },
|
||||
{ "2-D", "000052ff310000ff00ffffa5" },
|
||||
{ "2-E", "10081029106394b5e7ffce84" },
|
||||
{ "2-F", "180000a50000ff9452d6ffff" },
|
||||
{ "2-G", "001800e7bd84e752426bbd39" },
|
||||
{ "2-H", "000000737373bdbdbdffffff" },
|
||||
{ "3-A", "314a63ff632973c6c6ffd69c" },
|
||||
{ "3-B", "001010005200e78421dedec6" },
|
||||
{ "3-C", "21215a00bdffffff7be7adce" },
|
||||
{ "3-D", "00000008ce00e7ad7bf7ffbd" },
|
||||
{ "3-E", "524a73b57b21e7b56bffffc6" },
|
||||
{ "3-F", "424242ffd600ff6bff7b7bce" },
|
||||
{ "3-G", "390000ce3139ffffff63de52" },
|
||||
{ "3-H", "0818004a8c187bce39e7ffa5" },
|
||||
{ "4-A", "00007bd600d67badfff7ad6b" },
|
||||
{ "4-B", "180808427b39efa563f7eff7" },
|
||||
{ "4-C", "0800009ca5e7dea5d6ffe7e7" },
|
||||
{ "4-D", "08214a4a6b7b94ceceffffbd" },
|
||||
{ "4-E", "0021317b5a8ce7ad7bffdead" },
|
||||
{ "4-F", "3900008400a5de84debdd6d6" },
|
||||
{ "4-G", "008463291000bd215ab5e718" },
|
||||
{ "4-H", "425229848c42bdc65affffce" },
|
||||
} },
|
||||
{ name = "Super GB 2 - Vaporwave Edition", palettes = {
|
||||
{ "SGB2VE 1-A Sunset", "1039adef2994f7a552f7f794" },
|
||||
{ "SGB2VE 1-B Kimochi WRO", "4a3973bd63a5f7a56bf7efbd" },
|
||||
{ "SGB2VE 1-C Pachinko", "42427b9c42eff7947bf7deef" },
|
||||
{ "SGB2VE 1-D Cotton Candy", "1039adffa5ff84e7eff7f7f7" },
|
||||
{ "SGB2VE 1-E Absynthe", "7b4aada5b5ffadff5affff8c" },
|
||||
{ "SGB2VE 1-F Arizona", "52529cff4273ffcecedefff7" },
|
||||
{ "SGB2VE 1-G Hot Vibes", "8c18f7f72973f78c18f7ce18" },
|
||||
{ "SGB2VE 1-H Sorbet Whip", "63184aff6b8cffc684f7ffc6" },
|
||||
{ "SGB2VE 2-A Bufotoxin", "104a63009c84ef8c84f7de9c" },
|
||||
{ "SGB2VE 2-B Dank", "4a184263528463b5bdf7f794" },
|
||||
{ "SGB2VE 2-C Creampuff", "632963a573f7f79cadffdef7" },
|
||||
{ "SGB2VE 2-D Modern Computing", "101018ff849c6bffbdf7f7f7" },
|
||||
{ "SGB2VE 2-E Uranium Glass", "4a08ef9c42ef00deded6ff9c" },
|
||||
{ "SGB2VE 2-F. Tenshi", "2142e7de5aceefadceadf7c6" },
|
||||
{ "SGB2VE 2-G 87 Ninjas", "42427bd63994bdb5f7c6f7de" },
|
||||
{ "SGB2VE 2-H Floral", "08101852b59cff849cefe7e7" },
|
||||
{ "SGB2VE 3-A Mind Game", "4200f700b5f7f784f7f7f7f7" },
|
||||
{ "SGB2VE 3-B Sewer Magic", "5a00848c7be784f7c6f7f794" },
|
||||
{ "SGB2VE 3-C Galactic Rabbit Trip", "4a527352a5cebdceeff7f7ef" },
|
||||
{ "SGB2VE 3-D Frenemy", "1039ad42d6d6ffd6b5bdffe7" },
|
||||
{ "SGB2VE 3-E Agent Orange", "632963e76b84f7a56bffe742" },
|
||||
{ "SGB2VE 3-F Backstreet", "294a8cff8cbdffceb5dedeff" },
|
||||
{ "SGB2VE 3-G Eccojam", "8c42a5ad7be7946cff9cefff" },
|
||||
{ "SGB2VE 3-H Sakura", "4a39b5ef73d6efb5bdf7efb5" },
|
||||
{ "SGB2VE 4-A Vector", "1039ad9400f700b5f7f7d684" },
|
||||
{ "SGB2VE 4-B Slumber", "944ab58c8cf794ceffd6ffff" },
|
||||
{ "SGB2VE 4-C Lingonberry Butter", "21529c7b84e7ffadcefff7de" },
|
||||
{ "SGB2VE 4-D Zangetsu", "104a6331a5c684e7eff7efbd" },
|
||||
{ "SGB2VE 4-E Icy Hot", "8c00ffff39ced6c6ffbdfff7" },
|
||||
{ "SGB2VE 4-F Night Driver", "214aff6b00c6ff31ef10006b" },
|
||||
{ "SGB2VE 4-G Bug Hunt", "0839633952bdf7738cf7e7ad" },
|
||||
{ "SGB2VE 4-H MGB-101", "004a84107ba500bdc621ffde" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/American Pop Culture Pack", palettes = {
|
||||
{ "Absorbent and Yellow", "343b00687600aec600fff752" },
|
||||
{ "BMO Ver.", "394c3d607f6699cca3c0ffcc" },
|
||||
{ "BRB Pink", "480030790050c10080f200a1" },
|
||||
{ "Bedrock Caveman Vision", "002f37005e6e009eb8ff7f00" },
|
||||
{ "BKB Ver.", "6060002098f048f8e0f8f880" },
|
||||
{ "BLS Pink", "492e357a4d59c47c8ef59bb2" },
|
||||
{ "BBS Blue", "1e3a45326274509cba64c4e9" },
|
||||
{ "BTC Green", "39422a5f6e4698b070bedc8d" },
|
||||
{ "DNP Silver", "33383d555d668895a3abbbcc" },
|
||||
{ "Fairly OddPalette", "3d1b2d7b365bce5a997bb850" },
|
||||
{ "GRF Vision", "2d1309964220e59436f5ea8b" },
|
||||
{ "HGW Goldius", "3631215b523891845ab6a571" },
|
||||
{ "Invincible Yellow and Blue", "113c4622788d39c9ebfee566" },
|
||||
{ "NCK Orange", "4c1e007f3300cc5200ff6700" },
|
||||
{ "NJT Green", "28380b435e126b961d86bc25" },
|
||||
{ "OPP Palette", "0015330047abd92121d3d3d3" },
|
||||
{ "PTS Pink", "4c26297f4045cc666fff808b" },
|
||||
{ "Retro Bogeda", "0000006408ffff6cfffbfd1b" },
|
||||
{ "Rocket Portable Power", "09122d213f99bdd539fee998" },
|
||||
{ "RGR Playtime Palette", "662d91ed1c24ffde17ffffff" },
|
||||
{ "Sailor Spinach Green", "2434123d581e628c307bb03c" },
|
||||
{ "SCD Mystery Ver.", "2a1a318f59a5f79321c6de31" },
|
||||
{ "Smurfy Blue", "0d3747165c772394bf2cb9ef" },
|
||||
{ "SPB Yellow", "4a45157b7424c5ba39f7e948" },
|
||||
{ "SQW Sea Foam Green", "37403d5c6b6694aca4b9d7cd" },
|
||||
{ "Swampy Ogre Green", "39400d606b179aab24c1d62e" },
|
||||
{ "TMT Pink", "3815205e2436963957bc486d" },
|
||||
{ "UNS Blue", "0020430136710256b4036ce2" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Anime and Manga Pack", palettes = {
|
||||
{ "ANX BLUE", "0f2d451a4c74297ab93499e8" },
|
||||
{ "ALM Hero Palette", "212f79d12021f3db43eff0f0" },
|
||||
{ "Anime DGV Ver.", "1b241d2d3d3148624f5b7b63" },
|
||||
{ "Anime Expo Red", "47110f771d19be2f28ee3b33" },
|
||||
{ "Anime Expo Ver.", "242a2d656e729ba3a6e5eaeb" },
|
||||
{ "BRS Blood", "3806065d0a0a951010bb1414" },
|
||||
{ "Blue Stripes Ver.", "2d2e2e5b5d5d999b9c8bd3e1" },
|
||||
{ "CDC Pink", "430427e10e82eac3d6f2f4f7" },
|
||||
{ "Colorful Horizons", "373939d1525260bdc7f6cf72" },
|
||||
{ "CRR Orange", "49230a7a3a11c35d1bf47522" },
|
||||
{ "DKU Alpha Emerald", "11332f1c564f2d8a7e39ad9e" },
|
||||
{ "DKU Gamma Palette", "24262b166668b6bec8f0e8d6" },
|
||||
{ "DKU Vigilante Palette", "13131538534e878b92ada89a" },
|
||||
{ "DMS Gold", "3734195d572b948c44baaf56" },
|
||||
{ "DRM Tricolor", "450000e6000000a8f4ffe800" },
|
||||
{ "DGB Orange", "48270878410ec06817f0831d" },
|
||||
{ "DYN Hero Palette", "16161629463dc84a31d1d0cc" },
|
||||
{ "EV-01", "303345765898f99b2254cf54" },
|
||||
{ "EDN Academy Uniform", "483e39cc4c47dcbc76f7f8f5" },
|
||||
{ "FRG Pretense Palette", "0000008d0000697d738ea99b" },
|
||||
{ "GDS VISION", "4723008e4700ee77009fa0a0" },
|
||||
{ "GR5 Fever", "311d3da662cdc5cedff7f7f7" },
|
||||
{ "GKU GT Gi", "1221313d6ea5f0ac18dbe3e6" },
|
||||
{ "GKU Gi", "061222173f72e7612cf9f0e1" },
|
||||
{ "Grand Ivory", "4140396c6b5fadab98d9d6be" },
|
||||
{ "GRZ Coat", "3d0b32ce26a9fce72dfbfbfa" },
|
||||
{ "HKG Orange", "462718754129bb6841ea8352" },
|
||||
{ "KNS Sherbet", "440021890042e5006ef08200" },
|
||||
{ "KWB Blue", "00283b004462006c9d0088c5" },
|
||||
{ "Legendary SSJ", "31411b536d2d84ae48a6da5b" },
|
||||
{ "LSA Orange!", "461c00752f00bc4b00eb5e01" },
|
||||
{ "Perfect MJN Emperor", "2718328550a9a3b453fdc1bf" },
|
||||
{ "Perfected ULI", "393c4060646c99a0acc0c8d8" },
|
||||
{ "PRC Marble Raspberry", "400a1b6b112eab1b49d6225c" },
|
||||
{ "PTG Gold", "3633275a5541908868b4aa82" },
|
||||
{ "SYN Beast Silver", "2d2e344b4d57787c8c969baf" },
|
||||
{ "SHN Green", "1b3a162d6125489c3b5ac34a" },
|
||||
{ "STH Red", "4a18127c291ec64130f8523c" },
|
||||
{ "SSJ 3", "4a3c107c641cc6a02cf8c838" },
|
||||
{ "SSJ Blue Evolved", "082d3e0d4b681578a71b97d1" },
|
||||
{ "SSJ Blue", "01383d025d660496a305bccc" },
|
||||
{ "SSJ God", "40001d6b0131ac024ed70362" },
|
||||
{ "SSJ Rose", "4a34357b5759c58c8ff7afb3" },
|
||||
{ "SSJ", "4c4b397f7e60cbc99afefcc1" },
|
||||
{ "SVC Uniform", "321d1a593d34ac7c59acaba9" },
|
||||
{ "SWA Cyan", "1a3a432c6171479cb459c3e2" },
|
||||
{ "TMR Uniform", "474f4d755e88e94e60eeefeb" },
|
||||
{ "Tri DGV Ver.", "272a2442473c697260848f79" },
|
||||
{ "UAH Uniform", "0c4856a02929a4aaafededed" },
|
||||
{ "UTV VISION", "262c48e24465f1a7b5f4f4f4" },
|
||||
{ "ULI Sign", "1b1f212d34374853585a686f" },
|
||||
{ "URM Green", "1734282657443d8b6c4dae88" },
|
||||
{ "VGT Armor", "0619321554a9e3c56cefefef" },
|
||||
{ "ANM vision", "231815385eaaf9be00ffffff" },
|
||||
{ "HLO blue", "063f5c3368d349c4f2b0edfa" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Bandai Namco Pack", palettes = {
|
||||
{ "1st Vision Pastel", "101c33385ead7faf5aea7cb1" },
|
||||
{ "765P Ver.", "383a445d6272959cb6bbc4e4" },
|
||||
{ "765P Pink", "481720792736c23f57f34f6d" },
|
||||
{ "765P TRICOLOR", "0b143f2743d2e22b30b4e04b" },
|
||||
{ "ARS BLUE", "0e192e182a4e26447c30559c" },
|
||||
{ "AQR Blue", "0030450050740080ba00a0e9" },
|
||||
{ "BDN Tricolor", "001f350069b1df4f61f6b700" },
|
||||
{ "CND Blue", "0b263c1340641e67a02681c8" },
|
||||
{ "IDOL WORLD TRICOLOR!!!", "0b263c2681c8f34f6dffc30b" },
|
||||
{ "LLA Purple!", "31152e52234d84387ca5469b" },
|
||||
{ "MLV GOLD!", "3d351d665930a48e4dcdb261" },
|
||||
{ "MLN Yellow!", "4c3a037f6105cc9c08ffc30b" },
|
||||
{ "Moonlight Vision", "1010103050783890e8f8d868" },
|
||||
{ "MUS Pink", "44002672003fb60065e4007f" },
|
||||
{ "NMC Idol Pink", "4c22377f3a5ccc5c93ff74b8" },
|
||||
{ "NJG Orange", "482d00794c00c27900f39800" },
|
||||
{ "PCM-PALETTE", "420000dc0000ff8c00ffd800" },
|
||||
{ "PCM Vision", "0000003732ffffb897ffff00" },
|
||||
{ "PCM Yellow", "4c44007f7100ccb500ffe300" },
|
||||
{ "RADIANT SMILE RAMP", "1b1c81e6016b01b3c4fff89b" },
|
||||
{ "RX-78-2 GDM Mode", "333f3fa73736dbad40dbdcd5" },
|
||||
{ "RYG Sunset", "2b10399238befd99e1ffe43f" },
|
||||
{ "SHINY Sky Blue", "2a384c465d7f7095cc8dbbff" },
|
||||
{ "SNS Red", "3911105f1c1b982d2bbf3936" },
|
||||
{ "School Idol Blue", "2830663960d787adf5f9f9f8" },
|
||||
{ "School Idol Mix", "31152ea5469b00a0e9f39800" },
|
||||
{ "SDM Green", "04392c075f4a0c98760fbe94" },
|
||||
{ "Sunny Passion Paradise", "0920341e6caee06846f0ba40" },
|
||||
{ "SYL Rose Pink", "452728744243b9696be88486" },
|
||||
{ "Tarnished Gold", "392f18604e299a7d42c19d53" },
|
||||
{ "Vulnerable Blue", "ffb897cc93787f5c4b3732ff" },
|
||||
{ "Wind Ring Emerald", "0c3627145a4220906a28b585" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Company Pack", palettes = {
|
||||
{ "AMZ Vision", "252f3e008296ff9900ffffff" },
|
||||
{ "AND Green", "1242271e6e4230b0693ddc84" },
|
||||
{ "APL Silver", "30333351555681888aa2aaad" },
|
||||
{ "DUO Green", "1a3d002c660146a30158cc02" },
|
||||
{ "DRC Copper", "3c291b64442ea06d4ac8895d" },
|
||||
{ "GGL Blue", "13274921427a346ac34285f4" },
|
||||
{ "GGL Green", "0f32181a542929864234a853" },
|
||||
{ "GGL Red", "46140f75211abb352aea4335" },
|
||||
{ "GGL Yellow", "4b38017d5e02c89604fbbc05" },
|
||||
{ "SPT Green", "09401c0f6b3018ac4c1ed760" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Food and Drink Pack", palettes = {
|
||||
{ "7EL Color Combo", "062218147350eb0f2aff6c00" },
|
||||
{ "BJB Beach", "273d3b4e7a7683ccc5dbe441" },
|
||||
{ "BJB Storm", "1f3a31346152539b8368c2a4" },
|
||||
{ "BKR Sundae", "40202178284bc04078f05097" },
|
||||
{ "BGK Color Combo", "502314d62300ff8732f5ebdc" },
|
||||
{ "CHT Orange", "442300723b00b75e00e57600" },
|
||||
{ "CRK Color Combo", "460d0a8d1b15ec2e24f99b2a" },
|
||||
{ "CCL Vision", "000000f40009d7d7d7ffffff" },
|
||||
{ "Dew Green", "2d40004b6b0078ac0097d700" },
|
||||
{ "DMP Vision", "000000006491e31837ffffff" },
|
||||
{ "DRP Red", "290a0e4511186e1b278a2231" },
|
||||
{ "DNK Vision", "48032b910756f20c90ff6e0c" },
|
||||
{ "KFR Red", "33070e550c17881325ab182f" },
|
||||
{ "KKR Vision", "000000166938cf152dffffff" },
|
||||
{ "Lemon-Lime Green", "18310e30631d51a631f1c545" },
|
||||
{ "MTD Ver.", "29673cd82a34a1d23fffffff" },
|
||||
{ "PCS BLUE", "001b35002e59004a8e015db2" },
|
||||
{ "PPS Vision", "0000001414c8ff1400ffffff" },
|
||||
{ "PZH Red", "441012711c1fb52c31e3383e" },
|
||||
{ "SPR Green", "002e17004d27007c3e009b4e" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Gaming Pack", palettes = {
|
||||
{ "AVG Raging Volcano", "1c0000a81000f83800f8b800" },
|
||||
{ "Blue Bomber Vision", "0000000d4dc4639afce2cda7" },
|
||||
{ "BBN Blue", "093e4b0f687e18a7ca1fd1fd" },
|
||||
{ "BBG Green", "1f420e356e1854b0276adc31" },
|
||||
{ "CHE Green", "20800040980080c800a0e000" },
|
||||
{ "Classic Blurple", "22294138446d5b6dae7289da" },
|
||||
{ "DMG Pea Green", "204631527f39aec440d7e894" },
|
||||
{ "EBT Prolouge", "2c17084a290b854a1dc08226" },
|
||||
{ "GGR Orange", "45230e743b17ba5e25e9762f" },
|
||||
{ "Giga Kiwi DMG", "384828607028a0a830d0e040" },
|
||||
{ "Investigation Yellow", "4c4a077f7c0cccc714fff919" },
|
||||
{ "Phantom Red", "4b0b117e131cca1e2dfd2639" },
|
||||
{ "PYO Green", "15431024771b39b42b48e236" },
|
||||
{ "SES Blue", "073e4c0c687f14a7cc19d1ff" },
|
||||
{ "SGA Tokyo Blue", "00273f00416a0068a90082d4" },
|
||||
{ "Scarlett Green", "2e48034d78057cc0089bf00b" },
|
||||
{ "Slime Blue", "0e2a3d1746662570a32f8ccc" },
|
||||
{ "SNC Mega Blue", "13274120426c3369ad4084d9" },
|
||||
{ "STM Gray", "3b3a396261609d9c99c5c3c0" },
|
||||
{ "TYV Brown", "372c1f5c4a34937654b89469" },
|
||||
{ "Tropical Cyber Space", "1414394344c1ef58f763fdfb" },
|
||||
{ "XBX Green", "2b3c1249641f74a03192c83e" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Holiday Pack", palettes = {
|
||||
{ "Christmas Gold", "39321660542599873cc0a94b" },
|
||||
{ "Christmas Silver", "2c32354a545977868e95a8b2" },
|
||||
{ "Christmas Ver.", "300f0fa0323220a465cbb96a" },
|
||||
{ "Classy Christmas", "534d579e5c5e8bab95e8e7df" },
|
||||
{ "Clover Green", "1136161c5a252d903b39b54a" },
|
||||
{ "Cupid's Love Palette", "5e081eb51a3ae48397e4cdd3" },
|
||||
{ "Festive Christmas", "0a1609224b21cc1902bb9b60" },
|
||||
{ "Glacial Winter Blue", "2839434360716c9ab487c1e2" },
|
||||
{ "Grinchy Green", "3639085b5f0e929816b7be1c" },
|
||||
{ "Halloween Ver.", "2c13319540a5f68c00ffcc00" },
|
||||
{ "Haunted Halloween", "2f094c9e1fffffa52ecaff37" },
|
||||
{ "Independence Boy", "0000000a3161b31942ffffff" },
|
||||
{ "Irish Green", "143923225f3b37985e45be76" },
|
||||
{ "Spooky Purple", "2f253f4f3e697e63a89e7cd2" },
|
||||
{ "Winter Christmas", "341113ae3b4065b08fdddddd" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/J-Pop Pack", palettes = {
|
||||
{ "A48 Pink", "4923317b3b53c45e84f676a6" },
|
||||
{ "ATG PARETTO", "030a5b7a5375c375bcf4a7eb" },
|
||||
{ "BBM KITSUNE RED", "2c00034a000677000995000c" },
|
||||
{ "H46 Blue", "1b39442d5f724898b75bbee5" },
|
||||
{ "J-Pop Idol Sherbet", "260c2b8129905bbee5f19db5" },
|
||||
{ "MKU Blue", "05333f08566a0d8aaa11add5" },
|
||||
{ "NZU PrePlay Palette", "47120fef3f3500b8b0fff200" },
|
||||
{ "N46 Purple", "260c2b401448672073812990" },
|
||||
{ "OOR RED", "370f0c5c1a14942920b93429" },
|
||||
{ "YSB AMARANTH", "480c1b79142dc12048f2285a" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Japanese Company Pack", palettes = {
|
||||
{ "ANA Flight Blue", "00146e223f9a3b8bc000b3f0" },
|
||||
{ "FMM Vision", "00301300602600a040008cd6" },
|
||||
{ "LWS BLUE", "001f3600345b0053920068b7" },
|
||||
{ "NHK Silver Gray", "262626404040666666808080" },
|
||||
{ "SIT SUPER BLUE", "0b2438133d5e1f6196277abc" },
|
||||
{ "SIT SUPER GREEN", "0634270b5742118b6a16ae85" },
|
||||
{ "SKO Timely Vision", "2021210050a54393e6ffbf00" },
|
||||
{ "SB109 PASTEL", "5503a63dacb8fd87b2f2d53f" },
|
||||
{ "SNR Pink", "83534de74b5af485a1f9c2d0" },
|
||||
{ "TKS CLOUDY BLUE", "27363b415a6368909f82b5c7" },
|
||||
{ "NCO sea green", "073a310c6152149c8319c3a4" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/K-Pop Pack", palettes = {
|
||||
{ "BGT ARMY PURPLE", "26154040246c6639ac8048d8" },
|
||||
{ "BKP BLINK PINK", "4932377a535dc38594f4a7ba" },
|
||||
{ "EER COSMIC LATTE", "4c4a457f7c73ccc6b8fff8e7" },
|
||||
{ "LSS FEARLESS BLUE", "26314a40527c6784c781a5f9" },
|
||||
{ "NCZ YELLOW", "43451e717333b4b851e2e766" },
|
||||
{ "SVT COOL CARAT", "2b323e57647d92a8d1f7cac9" },
|
||||
{ "SHW GREEN", "22413e396d685cafa673dbd0" },
|
||||
{ "SWN PINK", "48172579273ec13f63f24f7c" },
|
||||
{ "SKD STAY PINK", "4c142d7f224ccc367aff4499" },
|
||||
{ "TROPICAL TWC APRICOT", "4c1c30993961ff5fa2fcc89b" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Legacy Palette Pack", palettes = {
|
||||
{ "TDS VC Green", "4a4a4a5a8c429cef29bdff21" },
|
||||
{ "TDS VC Ver.", "2929186b6b52a5a58ccecead" },
|
||||
{ "BLL PC Screen", "000000783800b86000f87800" },
|
||||
{ "DMG Ver.", "2a453b365d48577c447f860f" },
|
||||
{ "DGV Ver.", "2a2a2246463970705c8c8c73" },
|
||||
{ "GMT Ver.", "12424c255955437a636ba64a" },
|
||||
{ "GNW LCD", "3b3c336365569ea28ac6cbad" },
|
||||
{ "GMK Ver.", "184421405d3b6b9c638cce94" },
|
||||
{ "GBU Classic Ver.", "26302540503f67806481a17e" },
|
||||
{ "HGM Ver.", "2d2d2b414f53687f84829fa6" },
|
||||
{ "Light Ver.", "004f3b00694a009a7100b581" },
|
||||
{ "LKA DX Ver.", "58382068884078c078f8f8b0" },
|
||||
{ "MBM Ver.", "303030505050808080a0a0a0" },
|
||||
{ "MGM V Ver.", "08203040689070a0e0d0d0d0" },
|
||||
{ "NGP Ver.", "101010707070b0b0b0f0f0f0" },
|
||||
{ "NKA 3310 Ver.", "2231273953425c846973a684" },
|
||||
{ "Pocket Tales Ver.", "00000038500088a000d0d860" },
|
||||
{ "Pocket Ver.", "1f1f1f4d533c8b956dc4cfa1" },
|
||||
{ "PKS Ver.", "2d2d284b4b4378786c969687" },
|
||||
{ "PKM Pinball Ver.", "181820786030a0b050e8f8b8" },
|
||||
{ "PKM Ver.", "181010807098f0b088f8e8f8" },
|
||||
{ "PKM mini Ver.", "1b2d1b525c52849484a5b9a5" },
|
||||
{ "PKT Ver.", "10281838503050805070b070" },
|
||||
{ "Rocky-Valley Holiday", "204008805850d89078c0f0f8" },
|
||||
{ "T83 Ver.", "2e332a4e55467c88709caa8c" },
|
||||
{ "TMG Ver.", "3c383878787cc0c0c7f1f0f9" },
|
||||
{ "TGC Ver.", "0000000f4f2f6f8f4fdfff8f" },
|
||||
{ "Timing Hero Ver.", "202e004c67188c994ccccc99" },
|
||||
{ "Travel Wood", "482810705030a08058f8d8b0" },
|
||||
{ "VMU Ver.", "0814804466546ca38688cca8" },
|
||||
{ "VTB Ver.", "ff0000aa0000550000000000" },
|
||||
{ "WTS Ver.", "0c322c2c626454a68c7cc67c" },
|
||||
{ "WDS Ver.", "2c2c2c686868c0c0c0fefefe" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Misc. Palette Pack", palettes = {
|
||||
{ "Camouflage Ver.", "37353879533dac7e54bcab90" },
|
||||
{ "Cherry Blossom Pink", "482534783f58c0648cf07eb0" },
|
||||
{ "Emerald Green", "183c2428643c40a06050c878" },
|
||||
{ "Fool's Gold and Silver", "2d30345a606997a1b0c5c66d" },
|
||||
{ "Glitchy Blue", "0f254b193f7d2864c8337efb" },
|
||||
{ "Gold, Silver, and Bronze", "2d1f1499684386949abeb049" },
|
||||
{ "Golden Trophy", "453e0774680cb9a613e8d018" },
|
||||
{ "Greenscale Ver.", "0c360c2c62346e870a9cbe0c" },
|
||||
{ "Leprechaun Green", "10281d1b44302c6c4d378861" },
|
||||
{ "Nightvision Green", "66bf2f519825335f171e390e" },
|
||||
{ "Rising Sun Red", "38000d5d0016960024bc002d" },
|
||||
{ "Treasure Gold", "3c360a655a12a2901ccbb524" },
|
||||
{ "Wild West Vision", "3a160e924a36c3976ad9d7c7" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Console Pack", palettes = {
|
||||
{ "Advanced Indigo", "241f373c355d605494796aba" },
|
||||
{ "Aegis Cherry", "42111e6e1d32b02f50dd3b64" },
|
||||
{ "Ancient HSU Brown", "352f2b594f488f7f73b39f90" },
|
||||
{ "ANC Green", "002e25004d3f007c64009b7e" },
|
||||
{ "Brilliant Diamond Blue", "2638433f5d706595b47fbbe1" },
|
||||
{ "COLLECTION of SAG Ver.", "041820345d51769a67b2c0a8" },
|
||||
{ "Champion's Tunic", "281b13875b40009edde2dcb1" },
|
||||
{ "CHZ Blue", "1735432759703e8fb44eb3e1" },
|
||||
{ "DKG Arcade Blue", "15304223516f3881b147a2de" },
|
||||
{ "DKG Barrel Brown", "3a220e613a179c5c25c3742f" },
|
||||
{ "DMG-099", "3132313f642f6bad1984b510" },
|
||||
{ "DMG-GOLD", "30361c505a3080904ca1b560" },
|
||||
{ "DMG-SWITCH", "214231426b296c94218cad28" },
|
||||
{ "DRL GB Ver.", "48530e788b1db9d03af6ff70" },
|
||||
{ "EVE Brown", "3c2a0f644619a07028c88d32" },
|
||||
{ "FMC Disk Yellow", "483a00796100c29b00f3c200" },
|
||||
{ "FMC Frenzy", "231916a32135d9be72efecda" },
|
||||
{ "Frog Coin Green", "00390039840000ef00fff7de" },
|
||||
{ "Fury Blue", "0c1c2d152f4c224c792b5f98" },
|
||||
{ "GMC Glimmer", "3e1318cf415111a396b6bed3" },
|
||||
{ "Golden Wild", "372f1e5c4f32947f50b99f65" },
|
||||
{ "GMB Brown", "331a11552c1d88472faa593b" },
|
||||
{ "Green Awakening", "000b1636705898db75f1ffdd" },
|
||||
{ "Green Banana", "2149003969394a9e0063df08" },
|
||||
{ "INK Tricolor", "1c114c603bffff505eeaff3d" },
|
||||
{ "LCD Clock Green", "183626285a4040906650b580" },
|
||||
{ "LBO Fawn", "4033226b5539ac885cd7aa73" },
|
||||
{ "MRO Red", "430400700700b40c00e10f00" },
|
||||
{ "Metallic PLD Brass", "302d0f514c1a817929a29834" },
|
||||
{ "Neon Blue", "033745055c730894b80ab9e6" },
|
||||
{ "Neon Green", "0942000f6e0018b0001edc00" },
|
||||
{ "Neon Orange", "4b30017d5002c88004faa005" },
|
||||
{ "Neon Pink", "4c0f247f193ccc2860ff3278" },
|
||||
{ "Neon Purple", "3600455a00739000b8b400e6" },
|
||||
{ "Neon Red", "4c120c7f1e14cc3020ff3c28" },
|
||||
{ "Neon Yellow", "454c00737f00b8cc00e6ff00" },
|
||||
{ "Odyssey Boy", "2224215054457e8e67acbe8c" },
|
||||
{ "Odyssey Gold", "3a30006150009b8000c2a000" },
|
||||
{ "POCKET SWT", "3038206372518d9c7bb5c69c" },
|
||||
{ "PKC Yellow", "4c42007f6e00ccb000ffdc00" },
|
||||
{ "PKD Red", "461918752a28bb4340ea5450" },
|
||||
{ "Royal Blue", "151949232a7a3844c44655f5" },
|
||||
{ "Shining Pearl Pink", "3f2a30694750a87180d28ea0" },
|
||||
{ "Sky Pop Ivory", "52502586825cbebb95e5e0b8" },
|
||||
{ "Super FMC Supreme", "0846bad9404044ac71feda5a" },
|
||||
{ "Super Mushroom Vision", "000000923404cc9e22f7cec3" },
|
||||
{ "Super Star Pink", "440d2372163bb7235fe52c77" },
|
||||
{ "Superball Ivory", "646432828250bcbc8aeef0bc" },
|
||||
{ "Timeless Gold and Red", "37090a6f1215b91e23c8aa50" },
|
||||
{ "Ultra Black", "17181d2629313d414f4d5263" },
|
||||
{ "WRW MicroBlue", "05293c0844650d6da11189ca" },
|
||||
{ "Wonder Purple", "451a42732c6fb846b2e658df" },
|
||||
{ "Yellow Banana", "734900ad6939de9e00ffdf08" },
|
||||
{ "YSH Egg Green", "1e3a0e336218519c2666c430" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Sports Pack", palettes = {
|
||||
{ "MLB Vision", "041e42bf0d3e057affffffff" },
|
||||
{ "NASCAR Ver.", "000000007ac2e4002bffd659" },
|
||||
{ "NBA Vision", "000000253b73c8102effffff" },
|
||||
{ "NCAA Blue", "002e42004e6f007cb1009cde" },
|
||||
{ "NFL Vision", "000000013369d50a0affffff" },
|
||||
{ "OLY Bronze", "3d2618664029a46741cd8152" },
|
||||
{ "OLY Gold", "3f360a6a5b12aa911cd5b624" },
|
||||
{ "OLY Silver", "2f312e4f524e7e847c9ea59c" },
|
||||
{ "PGA Tour Vision", "000000003c80f1373dffffff" },
|
||||
{ "WWE White and Red", "40070c810e19d7182affffff" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Traditional JPN Colors Pack", palettes = {
|
||||
{ "Aquatic Iro", "192f603e62ad2ca9e1a0d8ef" },
|
||||
{ "Fruity Orange", "241a089f563af08300f3bf88" },
|
||||
{ "Ghostly Aoi", "0f235019448e5a79ba84a2d4" },
|
||||
{ "Golden Kiiro", "6a5d21a69425dccb18f8e58c" },
|
||||
{ "Lime Midori", "4759507b8d42aacf53e0ebaf" },
|
||||
{ "Oni Aka", "640125a22041d9333fec6d71" },
|
||||
{ "Sakura Pink", "a25768e7609eeebbcbfdeff2" },
|
||||
{ "Silver Shiro", "383c3c727171afafb0dcdddd" },
|
||||
{ "Tea Midori", "33363102876088cb7fd6e9ca" },
|
||||
{ "Wisteria Murasaki", "2e29307058a3a59acadbd0e6" },
|
||||
} },
|
||||
{ name = "Trashuncle/Artistic", palettes = {
|
||||
{ "TU PSL Park", "9200aeb722d4d442f0ec89ff" },
|
||||
{ "TU Quicksilver", "555555717286a4a5c3bbbce1" },
|
||||
} },
|
||||
{ name = "Trashuncle/Crush", palettes = {
|
||||
{ "TU Blueberry Crush", "0000002811ff4dffffffffff" },
|
||||
{ "TU Grape Crush", "0000003d43cb0083ebffffff" },
|
||||
{ "TU Kiwi Crush", "000000059e322eef7affffff" },
|
||||
{ "TU Lavender Crush", "000000812482ab6a9bffffff" },
|
||||
{ "TU Orange Crush", "000000c23615ff632cffffff" },
|
||||
{ "TU Pineapple Crush", "000000ffff00ffff80ffffff" },
|
||||
{ "TU Strawberry Crush", "000000ea1211ff5250ffffff" },
|
||||
} },
|
||||
{ name = "Trashuncle/DMG", palettes = {
|
||||
{ "TU DMG Bright", "0c3a1d2a61414c7b2b919707" },
|
||||
{ "TU DMG Clean", "1749022b5402496503788603" },
|
||||
{ "TU DMG Weak Alt", "2c4d2054733c597730677f31" },
|
||||
{ "TU DMG Weak", "364e1a5772405e7a406d833d" },
|
||||
} },
|
||||
{ name = "Trashuncle/GBP", palettes = {
|
||||
{ "TU GBP Bright", "5b5d5a879481abb8a7ffffff" },
|
||||
{ "TU GBP Clean", "3d493b98a291adbfacedfff4" },
|
||||
{ "TU GBP Weak Alt", "4c4b3170734e757952848459" },
|
||||
{ "TU GBP Weak", "434738535a42535f49667159" },
|
||||
} },
|
||||
{ name = "Trashuncle/Light", palettes = {
|
||||
{ "TU Light Bright", "00637400a1b100daea00e3f3" },
|
||||
{ "TU Light Clean", "007577019fa000aeaf01b4b9" },
|
||||
{ "TU Light Dark", "0076a80092d10098c500a5f2" },
|
||||
{ "TU Light Idealized", "006e9d0093c500a6cc00ccd5" },
|
||||
} },
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
-- Yellow-only Advanced (redpp) deltas. Merged on top of data/palettes_gbc.lua
|
||||
-- world tables when GameVersion.isYellow(); Red/Blue never load this file's
|
||||
-- world path. Named SuperPalettes stay washed on Yellow -- PaletteFX.pal
|
||||
-- prefers CGBBase under Advanced instead (title MEWMON/LOGO, YELLOWMON).
|
||||
-- See #1639.
|
||||
|
||||
return {
|
||||
world = {
|
||||
-- Summer Beach House: Yellow-only tileset. Tile→group map mirrors HOUSE
|
||||
-- (closest Advanced indoor profile); colors lean sand / wood / water.
|
||||
tileGroups = {
|
||||
BEACH_HOUSE = {
|
||||
[0] = 5,
|
||||
[1] = 0,
|
||||
[2] = 0,
|
||||
[3] = 0,
|
||||
[4] = 1,
|
||||
[5] = 0,
|
||||
[6] = 3,
|
||||
[7] = 3,
|
||||
[8] = 2,
|
||||
[9] = 2,
|
||||
[10] = 2,
|
||||
[11] = 2,
|
||||
[12] = 2,
|
||||
[13] = 2,
|
||||
[14] = 5,
|
||||
[15] = 5,
|
||||
[16] = 0,
|
||||
[17] = 0,
|
||||
[18] = 0,
|
||||
[19] = 0,
|
||||
[20] = 1,
|
||||
[21] = 0,
|
||||
[22] = 3,
|
||||
[23] = 3,
|
||||
[24] = 5,
|
||||
[25] = 5,
|
||||
[26] = 5,
|
||||
[27] = 5,
|
||||
[28] = 0,
|
||||
[29] = 0,
|
||||
[30] = 5,
|
||||
[31] = 5,
|
||||
[32] = 0,
|
||||
[33] = 0,
|
||||
[34] = 3,
|
||||
[35] = 5,
|
||||
[36] = 5,
|
||||
[37] = 5,
|
||||
[38] = 5,
|
||||
[39] = 5,
|
||||
[40] = 5,
|
||||
[41] = 5,
|
||||
[42] = 2,
|
||||
[43] = 2,
|
||||
[44] = 5,
|
||||
[45] = 5,
|
||||
[46] = 5,
|
||||
[47] = 5,
|
||||
[48] = 5,
|
||||
[49] = 5,
|
||||
[50] = 5,
|
||||
[51] = 5,
|
||||
[52] = 5,
|
||||
[53] = 5,
|
||||
[54] = 5,
|
||||
[55] = 5,
|
||||
[56] = 5,
|
||||
[57] = 5,
|
||||
[58] = 5,
|
||||
[59] = 5,
|
||||
[60] = 5,
|
||||
[61] = 5,
|
||||
[62] = 5,
|
||||
[63] = 5,
|
||||
[64] = 0,
|
||||
[65] = 0,
|
||||
[66] = 0,
|
||||
[67] = 0,
|
||||
[68] = 0,
|
||||
[69] = 0,
|
||||
[70] = 5,
|
||||
[71] = 5,
|
||||
[72] = 5,
|
||||
[73] = 5,
|
||||
[74] = 5,
|
||||
[75] = 5,
|
||||
[76] = 5,
|
||||
[77] = 5,
|
||||
[78] = 5,
|
||||
[79] = 5,
|
||||
[80] = 5,
|
||||
[81] = 5,
|
||||
[82] = 5,
|
||||
[83] = 5,
|
||||
[84] = 5,
|
||||
[85] = 5,
|
||||
[86] = 5,
|
||||
[87] = 5,
|
||||
[88] = 5,
|
||||
[89] = 5,
|
||||
[90] = 5,
|
||||
[91] = 5,
|
||||
[92] = 5,
|
||||
[93] = 5,
|
||||
[94] = 5,
|
||||
[95] = 5,
|
||||
},
|
||||
},
|
||||
groupColors = {
|
||||
BEACH_HOUSE = {
|
||||
-- sand / wood floor
|
||||
{
|
||||
{ 255, 239, 198 },
|
||||
{ 206, 173, 115 },
|
||||
{ 156, 123, 74 },
|
||||
{ 58, 58, 58 },
|
||||
},
|
||||
-- signs / accents (warm coral)
|
||||
{
|
||||
{ 255, 239, 198 },
|
||||
{ 255, 156, 148 },
|
||||
{ 230, 82, 66 },
|
||||
{ 58, 58, 58 },
|
||||
},
|
||||
-- foliage
|
||||
{
|
||||
{ 255, 239, 198 },
|
||||
{ 123, 189, 82 },
|
||||
{ 66, 123, 41 },
|
||||
{ 58, 58, 58 },
|
||||
},
|
||||
-- water / windows
|
||||
{
|
||||
{ 255, 239, 198 },
|
||||
{ 99, 189, 230 },
|
||||
{ 41, 115, 189 },
|
||||
{ 58, 58, 58 },
|
||||
},
|
||||
-- yellow props
|
||||
{
|
||||
{ 255, 239, 198 },
|
||||
{ 255, 255, 82 },
|
||||
{ 230, 148, 25 },
|
||||
{ 58, 58, 58 },
|
||||
},
|
||||
-- wood walls / furniture
|
||||
{
|
||||
{ 255, 239, 198 },
|
||||
{ 189, 140, 74 },
|
||||
{ 140, 99, 41 },
|
||||
{ 58, 58, 58 },
|
||||
},
|
||||
-- sky / cool trim
|
||||
{
|
||||
{ 255, 239, 198 },
|
||||
{ 148, 189, 255 },
|
||||
{ 99, 148, 230 },
|
||||
{ 58, 58, 58 },
|
||||
},
|
||||
-- text
|
||||
{
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 255 },
|
||||
{ 0, 0, 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
-- Yellow spriteOrder is 82 entries; Red Advanced assignment is 72.
|
||||
-- Indices 60-69 are Yellow inserts (Pikachu, Jenny, mons, Jessie/James);
|
||||
-- Red's ball/fossil/snorlax/... shift to 70-81.
|
||||
spriteAssignment = {
|
||||
[0] = 0,
|
||||
[1] = 1,
|
||||
[2] = 3,
|
||||
[3] = "random",
|
||||
[4] = 0,
|
||||
[5] = "random",
|
||||
[6] = "random",
|
||||
[7] = "random",
|
||||
[8] = 0,
|
||||
[9] = "random",
|
||||
[10] = "random",
|
||||
[11] = "random",
|
||||
[12] = "random",
|
||||
[13] = "random",
|
||||
[14] = "random",
|
||||
[15] = 1,
|
||||
[16] = 1,
|
||||
[17] = "random",
|
||||
[18] = "random",
|
||||
[19] = "random",
|
||||
[20] = "random",
|
||||
[21] = 2,
|
||||
[22] = 1,
|
||||
[23] = 3,
|
||||
[24] = "random",
|
||||
[25] = "random",
|
||||
[26] = "random",
|
||||
[27] = "random",
|
||||
[28] = "random",
|
||||
[29] = 0,
|
||||
[30] = 3,
|
||||
[31] = 3,
|
||||
[32] = "random",
|
||||
[33] = "random",
|
||||
[34] = "random",
|
||||
[35] = "random",
|
||||
[36] = "random",
|
||||
[37] = "random",
|
||||
[38] = "random",
|
||||
[39] = "random",
|
||||
[40] = 0,
|
||||
[41] = 2,
|
||||
[42] = "random",
|
||||
[43] = "random",
|
||||
[44] = "random",
|
||||
[45] = "random",
|
||||
[46] = "random",
|
||||
[47] = "random",
|
||||
[48] = "random",
|
||||
[49] = "random",
|
||||
[50] = "random",
|
||||
[51] = "random",
|
||||
[52] = "random",
|
||||
[53] = "random",
|
||||
[54] = "random",
|
||||
[55] = 0,
|
||||
[56] = 1,
|
||||
[57] = 3,
|
||||
[58] = 0,
|
||||
[59] = 0,
|
||||
[60] = 4, -- SPRITE_PIKACHU
|
||||
[61] = 1, -- SPRITE_OFFICER_JENNY
|
||||
[62] = 3, -- SPRITE_SANDSHREW
|
||||
[63] = 2, -- SPRITE_ODDISH
|
||||
[64] = 2, -- SPRITE_BULBASAUR
|
||||
[65] = "random", -- SPRITE_JIGGLYPUFF
|
||||
[66] = "random", -- SPRITE_CLEFAIRY
|
||||
[67] = "random", -- SPRITE_CHANSEY
|
||||
[68] = "random", -- SPRITE_JESSIE
|
||||
[69] = "random", -- SPRITE_JAMES
|
||||
[70] = 0, -- SPRITE_POKE_BALL
|
||||
[71] = 0, -- SPRITE_FOSSIL
|
||||
[72] = 3, -- SPRITE_BOULDER
|
||||
[73] = 3, -- SPRITE_PAPER
|
||||
[74] = 0, -- SPRITE_POKEDEX
|
||||
[75] = 3, -- SPRITE_CLIPBOARD
|
||||
[76] = 0, -- SPRITE_SNORLAX
|
||||
[77] = 3, -- SPRITE_UNUSED_OLD_AMBER
|
||||
[78] = 3, -- SPRITE_OLD_AMBER
|
||||
[79] = "random",
|
||||
[80] = "random",
|
||||
[81] = 3, -- SPRITE_GAMBLER_ASLEEP
|
||||
},
|
||||
|
||||
-- Group 4 is Pikachu yellow on Yellow Advanced (Red keeps its own [4]).
|
||||
spritePalettes = {
|
||||
[4] = {
|
||||
{ 222, 255, 222 },
|
||||
{ 255, 255, 0 },
|
||||
{ 230, 115, 0 },
|
||||
{ 0, 0, 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -42,27 +42,29 @@ local LINK_HEADINGS = { "HOW TO LINK", "COLOSSEUM", "TRADE CENTER" }
|
||||
|
||||
local function linkCableHelp(game)
|
||||
local text = game.data.text or {}
|
||||
local items, showMenu, askHeading
|
||||
function showMenu()
|
||||
game.stack:push(Menu.new(game, items,
|
||||
{ tx = 0, ty = 0, tw = 15, th = 10, rowStep = 1 }))
|
||||
end
|
||||
function askHeading()
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._LinkCableHelpText2 or "Which heading do\nyou want to read?",
|
||||
showMenu))
|
||||
local items, menu, openMenu
|
||||
local function closeAll()
|
||||
game.stack:pop()
|
||||
end
|
||||
items = {}
|
||||
for i, label in ipairs(LINK_HEADINGS) do
|
||||
items[i] = { label = label, onSelect = function()
|
||||
items[i] = { label = label, keepOpen = true, onSelect = function()
|
||||
game.stack:push(TextBox.new(game,
|
||||
text["_LinkCableInfoText" .. i] or label, askHeading))
|
||||
text["_LinkCableInfoText" .. i] or label))
|
||||
end }
|
||||
end
|
||||
items[#items + 1] = { label = "STOP READING" }
|
||||
items[#items + 1] = { label = "STOP READING", onSelect = closeAll }
|
||||
menu = Menu.new(game, items,
|
||||
{ tx = 0, ty = 0, tw = 15, th = 10, rowStep = 2, itemY = 2,
|
||||
onCancel = closeAll })
|
||||
function openMenu() game.stack:push(menu) end
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._LinkCableHelpText1 or "TRAINER TIPS\fUsing a Game Link\nCable",
|
||||
askHeading))
|
||||
function()
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._LinkCableHelpText2 or "Which heading do\nyou want to read?",
|
||||
nil, { stay = { onShown = openMenu } }))
|
||||
end))
|
||||
end
|
||||
|
||||
return {
|
||||
@@ -84,21 +86,15 @@ return {
|
||||
{ "jump_if_false", 5 }, -- 2
|
||||
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 3 (old saves)
|
||||
{ "jump", 13 }, -- 4
|
||||
{ "give_pokemon", "EEVEE", 25 }, -- 5
|
||||
{ "jump_if_false", 12 }, -- 6 (party+box full)
|
||||
-- flag + HideObject before the jingle and GotMonText: the nickname
|
||||
-- prompt inside give_pokemon and the text row both yield, and a
|
||||
-- script that dies there would leave the EEVEE taken with the ball
|
||||
-- still on the table and the gift claimable again (#426). The row
|
||||
-- count is unchanged, so the numeric jump targets still hold.
|
||||
{ "jump", 11 }, -- 4
|
||||
{ "give_pokemon", "EEVEE", 25, false, true }, -- 5
|
||||
{ "jump_if_false", 10 }, -- 6 (party+box full)
|
||||
-- scripts/CeladonMansionRoofHouse.asm:17-20 (#426)
|
||||
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
|
||||
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
|
||||
{ "text_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
|
||||
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
|
||||
{ "jump", 13 }, -- 11
|
||||
{ "show_text", "_BoxIsFullText" }, -- 12
|
||||
{ "jump", 11 }, -- 9
|
||||
{ "show_text", "_BoxIsFullText" }, -- 10
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
return {
|
||||
CELADON_CITY = {
|
||||
talk = {
|
||||
-- CeladonCityPoliwrathText (scripts/CeladonCity.asm): text_far
|
||||
-- _CeladonCityPoliwrathText, then plays the POLIWRATH cry and ends.
|
||||
-- The cry playback has no port-side equivalent command, so we just
|
||||
-- show the flavor line.
|
||||
TEXT_CELADONCITY_POLIWRATH = {
|
||||
{"face_player"},
|
||||
-- pokered/scripts/CeladonCity.asm:90
|
||||
{"play_cry", "POLIWRATH", true},
|
||||
{"show_text", "_CeladonCityPoliwrathText"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,30 +2,24 @@
|
||||
return {
|
||||
CELADON_MANSION_1F = {
|
||||
talk = {
|
||||
-- CeladonMansion1FClefairyText (scripts/CeladonMansion1F.asm): text_far
|
||||
-- _CeladonMansion1FClefairyText, then plays the CLEFAIRY cry and ends.
|
||||
-- The cry playback has no port-side equivalent command, so we just
|
||||
-- show the flavor line.
|
||||
TEXT_CELADONMANSION1F_CLEFAIRY = {
|
||||
{"face_player"},
|
||||
-- pokered/scripts/CeladonMansion1F.asm:27
|
||||
{"play_cry", "CLEFAIRY", true},
|
||||
{"show_text", "_CeladonMansion1FClefairyText"},
|
||||
},
|
||||
|
||||
-- CeladonMansion1FMeowthText (scripts/CeladonMansion1F.asm): text_far
|
||||
-- _CeladonMansion1FMeowthText, then plays the MEOWTH cry and ends.
|
||||
-- The cry playback has no port-side equivalent command, so we just
|
||||
-- show the flavor line.
|
||||
TEXT_CELADONMANSION1F_MEOWTH = {
|
||||
{"face_player"},
|
||||
-- pokered/scripts/CeladonMansion1F.asm:18
|
||||
{"play_cry", "MEOWTH", true},
|
||||
{"show_text", "_CeladonMansion1FMeowthText"},
|
||||
},
|
||||
|
||||
-- CeladonMansion1FNidoranFText (scripts/CeladonMansion1F.asm): text_far
|
||||
-- _CeladonMansion1FNidoranFText, then plays the NIDORAN_F cry and ends.
|
||||
-- The cry playback has no port-side equivalent command, so we just
|
||||
-- show the flavor line.
|
||||
TEXT_CELADONMANSION1F_NIDORANF = {
|
||||
{"face_player"},
|
||||
-- pokered/scripts/CeladonMansion1F.asm:33
|
||||
{"play_cry", "NIDORAN_F", true},
|
||||
{"show_text", "_CeladonMansion1FNidoranFText"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,11 +4,10 @@
|
||||
return {
|
||||
LAVENDER_CUBONE_HOUSE = {
|
||||
talk = {
|
||||
-- LavenderCuboneHouseCuboneText: text_far _LavenderCuboneHouseCuboneText,
|
||||
-- then text_asm plays the CUBONE cry. Cry playback has no Commands
|
||||
-- equivalent in this port, so just show the line.
|
||||
-- LavenderCuboneHouse.asm:10
|
||||
TEXT_LAVENDERCUBONEHOUSE_CUBONE = {
|
||||
{ "face_player" },
|
||||
{ "play_cry", "CUBONE", true },
|
||||
{ "show_text", "_LavenderCuboneHouseCuboneText" },
|
||||
},
|
||||
|
||||
|
||||
@@ -31,16 +31,15 @@ M.MR_FUJIS_HOUSE = {
|
||||
{ "show_text", "_MrFujisHouseLittleGirlPokemonAreNiceToHugText" }, -- 6
|
||||
},
|
||||
|
||||
-- scripts/MrFujisHouse.asm MrFujisHousePsyduckText: text_far then
|
||||
-- PlayCry(PSYDUCK). Cry playback isn't modeled by Commands, so just
|
||||
-- show the flavor text.
|
||||
-- scripts/MrFujisHouse.asm:56
|
||||
TEXT_MRFUJISHOUSE_PSYDUCK = {
|
||||
{ "play_cry", "PSYDUCK", true },
|
||||
{ "show_text", "_MrFujisHousePsyduckText" },
|
||||
},
|
||||
|
||||
-- scripts/MrFujisHouse.asm MrFujisHouseNidorinoText: text_far then
|
||||
-- PlayCry(NIDORINO).
|
||||
-- scripts/MrFujisHouse.asm:63
|
||||
TEXT_MRFUJISHOUSE_NIDORINO = {
|
||||
{ "play_cry", "NIDORINO", true },
|
||||
{ "show_text", "_MrFujisHouseNidorinoText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -36,19 +36,29 @@ M.POKEMON_FAN_CLUB = {
|
||||
{ "clear_flag", "EVENT_SEEL_FAN_BOAST" }, -- 8
|
||||
},
|
||||
|
||||
-- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm): the
|
||||
-- PIKACHU itself, just a flavor line (its cry isn't playable in the
|
||||
-- port's talk pipeline, so it's dropped like other cry-only lines).
|
||||
-- PokemonFanClubPikachuText (scripts/PokemonFanClub.asm:71): PrintText,
|
||||
-- then ld a, PIKACHU / call PlayCry (:76) / call WaitForSoundToFinish.
|
||||
TEXT_POKEMONFANCLUB_PIKACHU = {
|
||||
{ "show_text", "_PokemonFanClubPikachuText" },
|
||||
{ "play_cry", "PIKACHU", true }, -- 1 PlayCry (#1649)
|
||||
{ "show_text", "_PokemonFanClubPikachuText" }, -- 2 PrintText
|
||||
},
|
||||
|
||||
-- PokemonFanClubSeelText (scripts/PokemonFanClub.asm): the SEEL
|
||||
-- itself, flavor line only.
|
||||
-- PokemonFanClubSeelText (scripts/PokemonFanClub.asm:84): the same
|
||||
-- shape with SEEL, PlayCry at :89.
|
||||
TEXT_POKEMONFANCLUB_SEEL = {
|
||||
{ "show_text", "_PokemonFanClubSeelText" },
|
||||
{ "play_cry", "SEEL", true }, -- 1 PlayCry (#1649)
|
||||
{ "show_text", "_PokemonFanClubSeelText" }, -- 2 PrintText
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- pokeyellow/scripts/PokemonFanClub.asm:149 PokemonFanClubClefairyText: Yellow's
|
||||
-- pet is a CLEFAIRY on its own TEXT_POKEMONFANCLUB_CLEFAIRY, PlayCry at :154.
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
M.POKEMON_FAN_CLUB.talk.TEXT_POKEMONFANCLUB_CLEFAIRY = {
|
||||
{ "play_cry", "CLEFAIRY", true }, -- 1 PlayCry
|
||||
{ "show_text", "_PokemonFanClubClefairyText" }, -- 2 PrintText
|
||||
}
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
return {
|
||||
ROUTE_16_FLY_HOUSE = {
|
||||
talk = {
|
||||
-- Route16FlyHouseFearowText: text_asm just prints the one line and
|
||||
-- plays the FEAROW cry (no cry-playback command exists in this
|
||||
-- port's Commands vocabulary, so only the text is ported).
|
||||
-- scripts/Route16FlyHouse.asm:45-51
|
||||
TEXT_ROUTE16FLYHOUSE_FEAROW = {
|
||||
{ "play_cry", "FEAROW", true },
|
||||
{ "show_text", "_Route16FlyHouseFearowText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
-- pokered/scripts/SSAnne1FRooms.asm: SSAnne1FRoomsWigglytuffText
|
||||
-- text_far _SSAnne1FRoomsWigglytuffText; then ld a, WIGGLYTUFF / call PlayCry (cosmetic cry sound, not ported)
|
||||
-- pokered/scripts/SSAnne1FRooms.asm:66 SSAnne1FRoomsWigglytuffText
|
||||
-- text_far _SSAnne1FRoomsWigglytuffText, then ld a, WIGGLYTUFF / call PlayCry (:70)
|
||||
return {
|
||||
SS_ANNE_1F_ROOMS = {
|
||||
talk = {
|
||||
TEXT_SSANNE1FROOMS_WIGGLYTUFF = {
|
||||
{"face_player"},
|
||||
{"play_cry", "WIGGLYTUFF", true}, -- 1 PlayCry (#1687)
|
||||
{"show_text", "_SSAnne1FRoomsWigglytuffText"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
-- pokered/scripts/SSAnneB1FRooms.asm: SSAnneB1FRoomsMachokeText
|
||||
-- text_far _SSAnneB1FRoomsMachokeText, then `ld a, MACHOKE / call PlayCry`
|
||||
-- (cry playback has no equivalent Commands.lua verb in this port, so only
|
||||
-- the flavor text is ported).
|
||||
-- pokered/scripts/SSAnneB1FRooms.asm:82 SSAnneB1FRoomsMachokeText
|
||||
-- text_far _SSAnneB1FRoomsMachokeText, then ld a, MACHOKE / call PlayCry (:86)
|
||||
return {
|
||||
SS_ANNE_B1F_ROOMS = {
|
||||
talk = {
|
||||
TEXT_SSANNEB1FROOMS_MACHOKE = {
|
||||
{ "face_player" },
|
||||
{ "play_cry", "MACHOKE", true }, -- 1 PlayCry (#1687)
|
||||
{ "show_text", "_SSAnneB1FRoomsMachokeText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -18,10 +18,11 @@ return {
|
||||
|
||||
-- VermilionCityMachopText: cries out, then follows up with a
|
||||
-- second line about stomping the land flat.
|
||||
-- (pokered/scripts/VermilionCity.asm)
|
||||
-- (pokered/scripts/VermilionCity.asm:224, PlayCry at :228)
|
||||
TEXT_VERMILIONCITY_MACHOP = {
|
||||
{ "show_text", "_VermilionCityMachopText" }, -- 1
|
||||
{ "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 2
|
||||
{ "play_cry", "MACHOP", true }, -- 1 PlayCry (#1649)
|
||||
{ "show_text", "_VermilionCityMachopText" }, -- 2
|
||||
{ "show_text", "_VermilionCityMachopStompingTheLandFlatText" }, -- 3
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
-- pokered/scripts/VermilionPidgeyHouse.asm: VermilionPidgeyHousePidgeyText
|
||||
-- text_far _VermilionPidgeyHousePidgeyText, then text_asm plays the PIDGEY
|
||||
-- cry (ld a, PIDGEY / call PlayCry / call WaitForSoundToFinish) before
|
||||
-- TextScriptEnd. This port has no cry-playback command, so only the
|
||||
-- flavor line is ported.
|
||||
-- pokered/scripts/VermilionPidgeyHouse.asm:15 VermilionPidgeyHousePidgeyText
|
||||
-- text_far, then ld a, PIDGEY / call PlayCry (:19) / call WaitForSoundToFinish
|
||||
|
||||
return {
|
||||
VERMILION_PIDGEY_HOUSE = {
|
||||
talk = {
|
||||
TEXT_VERMILIONPIDGEYHOUSE_PIDGEY = {
|
||||
{"face_player"},
|
||||
{"play_cry", "PIDGEY", true}, -- 1 PlayCry (#1649)
|
||||
{"show_text", "_VermilionPidgeyHousePidgeyText"},
|
||||
},
|
||||
},
|
||||
|
||||
+20
-2
@@ -38,6 +38,22 @@ local function retryTmGive(game, ow, victoryKey, done)
|
||||
return true
|
||||
end
|
||||
|
||||
-- The badge line + its jingle, armed for the battle screen the way
|
||||
-- SaveEndBattleTextPointers does (PewterGym.asm:117-119) (#1606)
|
||||
local function badgeEndBattleText(game, victoryKey)
|
||||
local reward = victoryKey and require("data.scripts.victories")[victoryKey]
|
||||
if not (reward and reward.dialogue) then return nil end
|
||||
local text = game.data.text or {}
|
||||
local pages = {}
|
||||
for _, label in ipairs(reward.dialogue) do
|
||||
if text[label] and text[label] ~= "" then
|
||||
pages[#pages + 1] = text[label]
|
||||
end
|
||||
end
|
||||
if #pages == 0 then return nil end
|
||||
return table.concat(pages, "\f"), reward.badgeSound
|
||||
end
|
||||
|
||||
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
|
||||
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
|
||||
-- _PewterGymBrockPreBattleText and engages the leader battle
|
||||
@@ -58,7 +74,8 @@ M.PEWTER_GYM.talk = {
|
||||
game.data.text._PewterGymBrockPostBattleAdviceText
|
||||
or "Go to the GYM in\nCERULEAN and test\nyour abilities!", done))
|
||||
else
|
||||
ow:engageTrainer(npc, done)
|
||||
local text, sound = badgeEndBattleText(game, "OPP_BROCK#1")
|
||||
ow:engageTrainer(npc, done, text, nil, sound)
|
||||
end
|
||||
end,
|
||||
}
|
||||
@@ -91,7 +108,8 @@ local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryK
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text[adviceLabel] or fallback, finish))
|
||||
else
|
||||
ow:engageTrainer(npc, done)
|
||||
local text, sound = badgeEndBattleText(game, victoryKey)
|
||||
ow:engageTrainer(npc, done, text, nil, sound)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -111,7 +111,7 @@ local function joinPrompt(game, ow, done)
|
||||
local t = game.data.text
|
||||
local back = function(text)
|
||||
game.stack:push(TextBox.new(game, text, function()
|
||||
ow:scriptMove(ow.player, "down", 1, done)
|
||||
ow:scriptMove(ow.player, "down", 1, done, { collide = true })
|
||||
end))
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
|
||||
+45
-29
@@ -127,7 +127,7 @@ M.VIRIDIAN_CITY = {
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
||||
or "You can't go\nthrough here!\fThis is private\nproperty!",
|
||||
function() ow:scriptMove(ow.player, "down", 1) end))
|
||||
function() ow:scriptMove(ow.player, "down", 1, nil, { collide = true }) end))
|
||||
return true
|
||||
end,
|
||||
}
|
||||
@@ -356,7 +356,7 @@ M.VERMILION_CITY = {
|
||||
if shipLeft then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||
function() ow:scriptMove(ow.player, "up", 1, nil, { collide = true }) end))
|
||||
return true
|
||||
end
|
||||
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
|
||||
@@ -377,26 +377,39 @@ M.VERMILION_CITY = {
|
||||
ask .. "\f"
|
||||
.. (t._VermilionCitySailor1YouNeedATicketText
|
||||
or "You need a ticket\nto get aboard."),
|
||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||
function() ow:scriptMove(ow.player, "up", 1, nil, { collide = true }) end))
|
||||
return true
|
||||
end,
|
||||
talk = {
|
||||
-- the sailor guarding the dock gangway (VermilionCitySailor1Text):
|
||||
-- flashing the ticket just lets you through -- he never hides, and
|
||||
-- once the ship has sailed he only reports it gone
|
||||
TEXT_VERMILIONCITY_SAILOR1 = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
|
||||
{ "jump_if_true", 11 }, -- 3
|
||||
{ "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4
|
||||
{ "check_item", "S_S_TICKET" }, -- 5
|
||||
{ "jump_if_false", 9 }, -- 6
|
||||
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
|
||||
{ "jump", 12 }, -- 8
|
||||
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 9
|
||||
{ "jump", 12 }, -- 10
|
||||
{ "show_text", "_VermilionCitySailor1ShipSetSailText" }, -- 11
|
||||
},
|
||||
-- scripts/VermilionCity.asm:158 (#1651)
|
||||
TEXT_VERMILIONCITY_SAILOR1 = function(game, ow, npc, done)
|
||||
local Flags = require("src.script.Flags")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
||||
done))
|
||||
return
|
||||
end
|
||||
-- scripts/VermilionCity.asm:195
|
||||
local p = ow and ow.player
|
||||
if not p or p.facing == "right"
|
||||
or (p.cellX == 19 and (p.cellY == 29 or p.cellY == 31)) then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._VermilionCitySailor1WelcomeToSSAnneText
|
||||
or "Welcome to S.S.\nANNE!", done))
|
||||
return
|
||||
end
|
||||
local ask = t._VermilionCitySailor1DoYouHaveATicketText
|
||||
or "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?"
|
||||
local tail = ((game.save.inventory.S_S_TICKET or 0) > 0)
|
||||
and (t._VermilionCitySailor1FlashedTicketText
|
||||
or "{PLAYER} flashed\nthe S.S.TICKET!")
|
||||
or (t._VermilionCitySailor1YouNeedATicketText
|
||||
or "You need a ticket\nto get aboard.")
|
||||
game.stack:push(TextBox.new(game, ask .. "\f" .. tail, done))
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -405,13 +418,14 @@ M.SS_ANNE_2F = {
|
||||
TEXT_SSANNE2F_RIVAL = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 2
|
||||
{ "jump_if_true", 9 }, -- 3
|
||||
{ "jump_if_true", 9 }, -- 3 (beaten: silent)
|
||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
||||
{ "jump_if_false", 10 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
||||
{ "jump", 10 }, -- 9 (already beaten: silent)
|
||||
-- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers
|
||||
-- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688)
|
||||
{ "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 6
|
||||
{ "jump_if_false", 9 }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -837,13 +851,14 @@ M.SILPH_CO_11F = {
|
||||
-- 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).
|
||||
-- queueScript starts it on the first idle overworld frame (#722).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
ow:queueScript(silphAftermathRows())
|
||||
end
|
||||
end, nil, true)
|
||||
end,
|
||||
-- "Arrgh!!" is armed for the battle screen, not the map
|
||||
-- (scripts/SilphCo11F.asm:264-266 SaveEndBattleTextPointers) #1606
|
||||
game.data.text._SilphCo10FGiovanniILostAgainText, true)
|
||||
end)
|
||||
end))
|
||||
return true
|
||||
@@ -1032,6 +1047,7 @@ local championsRoomRivalScript = {
|
||||
-- numeric 26 into a jump ONTO the closing HALL_OF_FAME warp instead of past
|
||||
-- it, so a returning champion warped straight into the induction.
|
||||
{ "jump_if_true", "end" }, -- 3
|
||||
{ "set_option", "animations", true }, -- scripts/ChampionsRoom.asm:57
|
||||
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
|
||||
-- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after
|
||||
-- the intro text, before the battle itself (#706); pushBattle's wipe-time
|
||||
|
||||
+25
-2
@@ -414,7 +414,7 @@ local function saffronGate(guardText, triggers, horizontal)
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
|
||||
function()
|
||||
ow:scriptMove(ow.player, back, 1)
|
||||
ow:scriptMove(ow.player, back, 1, nil, { collide = true })
|
||||
end))
|
||||
return true
|
||||
end,
|
||||
@@ -731,12 +731,35 @@ M.MT_MOON_B2F = {
|
||||
local function museumClerk(game, ow, done, onDecline)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text or {}
|
||||
local p = ow and ow.player
|
||||
-- scripts/Museum1F.asm:45 (#1690)
|
||||
if p and ((p.cellY == 4 and p.cellX == 13)
|
||||
or (p.cellY == 3 and p.cellX == 12)) then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._Museum1FScientist1DoYouKnowWhatAmberIsText
|
||||
or "You can't sneak\nin the back way!\fOh, whatever!\nDo you know what\vAMBER is?",
|
||||
nil, { choice = function(yes)
|
||||
game.stack:push(TextBox.new(game, yes
|
||||
and (t._Museum1FScientist1TheresALabSomewhereText
|
||||
or "There's a lab\nsomewhere trying\vto resurrect\vancient POKéMON\vfrom AMBER.")
|
||||
or (t._Museum1FScientist1AmberIsFossilizedTreeSapText
|
||||
or "AMBER is fossil-\nized tree sap."), done))
|
||||
end }))
|
||||
return
|
||||
end
|
||||
if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._Museum1FScientist1TakePlentyOfTimeText
|
||||
or "Take your time,\nand enjoy it all!", done))
|
||||
return
|
||||
end
|
||||
-- scripts/Museum1F.asm:58
|
||||
if p and p.cellY ~= 4 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._Museum1FScientist1GoToOtherSideText
|
||||
or "Please go to the\nother side!", done))
|
||||
return
|
||||
end
|
||||
-- scripts/Museum1F.asm:72
|
||||
local money = function() return game.save.money end
|
||||
game.stack:push(TextBox.new(game,
|
||||
@@ -769,7 +792,7 @@ M.MUSEUM_1F = {
|
||||
if y == 4 and (x == 9 or x == 10)
|
||||
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||
museumClerk(game, ow, nil, function()
|
||||
ow:scriptMove(ow.player, "down", 1)
|
||||
ow:scriptMove(ow.player, "down", 1, nil, { collide = true })
|
||||
end)
|
||||
return true
|
||||
end
|
||||
|
||||
+75
-80
@@ -2,6 +2,8 @@
|
||||
-- ghost, elevators, the Game Corner coins/prizes, the SS Anne departure
|
||||
-- and the Hall of Fame record. Each cites its pokered source.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -9,20 +11,30 @@ local M = {}
|
||||
-- FuchsiaGoodRodHouse.asm, Route12SuperRodHouse.asm)
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
local function rodGiver(askText, receivedText, afterText, rodItem, flag)
|
||||
return {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", flag }, -- 2
|
||||
{ "jump_if_true", 9 }, -- 3
|
||||
{ "ask", askText }, -- 4
|
||||
{ "jump_if_false", 10 }, -- 5
|
||||
-- refusedText is the .ThatsSoDisappointingText tail on NO; followText is
|
||||
-- the second half of the received chain (scripts/VermilionOldRodHouse.asm:45)
|
||||
local function rodGiver(askText, receivedText, afterText, rodItem, flag,
|
||||
refusedText, followText)
|
||||
local rows = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", flag },
|
||||
{ "jump_if_true", "already_got" },
|
||||
{ "ask", askText },
|
||||
{ "jump_if_false", "refused" },
|
||||
-- give-then-print like the three rod-house scripts (GiveItem fills
|
||||
-- wStringBuffer; the received texts read OLD/GOOD/SUPER ROD from it)
|
||||
{ "give_item", rodItem, 1, false }, -- 6
|
||||
{ "show_text", receivedText }, -- 7
|
||||
{ "set_flag", flag }, -- 8
|
||||
{ "jump", 10 }, -- 9 is below
|
||||
{ "give_item", rodItem, 1, false },
|
||||
{ "set_flag", flag },
|
||||
{ "show_text", receivedText },
|
||||
}
|
||||
if followText then rows[#rows + 1] = { "show_text", followText } end
|
||||
rows[#rows + 1] = { "jump", "end" }
|
||||
rows[#rows + 1] = { "label", "refused" }
|
||||
rows[#rows + 1] = { "show_text", refusedText }
|
||||
rows[#rows + 1] = { "jump", "end" }
|
||||
rows[#rows + 1] = { "label", "already_got" }
|
||||
rows[#rows + 1] = { "show_text", afterText }
|
||||
return rows
|
||||
end
|
||||
|
||||
M.VERMILION_OLD_ROD_HOUSE = {
|
||||
@@ -31,11 +43,11 @@ M.VERMILION_OLD_ROD_HOUSE = {
|
||||
"_VermilionOldRodHouseFishingGuruDoYouLikeToFishText",
|
||||
"_VermilionOldRodHouseFishingGuruTakeThisText",
|
||||
"_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText",
|
||||
"OLD_ROD", "EVENT_GOT_OLD_ROD"),
|
||||
"OLD_ROD", "EVENT_GOT_OLD_ROD",
|
||||
"_VermilionOldRodHouseFishingGuruThatsSoDisappointingText",
|
||||
"_VermilionOldRodHouseFishingGuruFishingIsAWayOfLifeText"),
|
||||
},
|
||||
}
|
||||
M.VERMILION_OLD_ROD_HOUSE.talk.TEXT_VERMILIONOLDRODHOUSE_FISHING_GURU[9] =
|
||||
{ "show_text", "_VermilionOldRodHouseFishingGuruHowAreTheFishBitingText" }
|
||||
|
||||
M.FUCHSIA_GOOD_ROD_HOUSE = {
|
||||
talk = {
|
||||
@@ -43,11 +55,10 @@ M.FUCHSIA_GOOD_ROD_HOUSE = {
|
||||
"_FuchsiaGoodRodHouseFishingGuruText",
|
||||
"_FuchsiaGoodRodHouseFishingGuruReceivedGoodRodText",
|
||||
"_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText",
|
||||
"GOOD_ROD", "EVENT_GOT_GOOD_ROD"),
|
||||
"GOOD_ROD", "EVENT_GOT_GOOD_ROD",
|
||||
"_FuchsiaGoodRodHouseFishingGuruThatsSoDisappointingText"),
|
||||
},
|
||||
}
|
||||
M.FUCHSIA_GOOD_ROD_HOUSE.talk.TEXT_FUCHSIAGOODRODHOUSE_FISHING_GURU[9] =
|
||||
{ "show_text", "_FuchsiaGoodRodHouseFishingGuruHowAreTheFishText" }
|
||||
|
||||
M.ROUTE_12_SUPER_ROD_HOUSE = {
|
||||
talk = {
|
||||
@@ -55,11 +66,11 @@ M.ROUTE_12_SUPER_ROD_HOUSE = {
|
||||
"_Route12SuperRodHouseFishingGuruDoYouLikeToFishText",
|
||||
"_Route12SuperRodHouseFishingGuruReceivedSuperRodText",
|
||||
"_Route12SuperRodHouseFishingGuruTryFishingText",
|
||||
"SUPER_ROD", "EVENT_GOT_SUPER_ROD"),
|
||||
"SUPER_ROD", "EVENT_GOT_SUPER_ROD",
|
||||
"_Route12SuperRodHouseFishingGuruThatsDisappointingText",
|
||||
"_Route12SuperRodHouseFishingGuruFishingWayOfLifeText"),
|
||||
},
|
||||
}
|
||||
M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
|
||||
{ "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" }
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Pokemon Tower 5F purified zone (scripts/PokemonTower5F.asm
|
||||
@@ -176,7 +187,7 @@ M.POKEMON_TOWER_6F = {
|
||||
-- .did_not_defeat: one simulated step right, off the trigger,
|
||||
-- so fleeing does not leave you standing on a cell that
|
||||
-- immediately re-fires.
|
||||
ow:scriptMove(ow.player, "right", 1)
|
||||
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
|
||||
end
|
||||
ow:afterBattle(result, battle)
|
||||
end
|
||||
@@ -893,81 +904,65 @@ local DOCK_SHIP_BLOCKS = {
|
||||
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
|
||||
}
|
||||
|
||||
-- her four hull columns bow-to-stern (upper-half / lower-half block ids)
|
||||
-- and the open-water ids of the rows she sits in
|
||||
local DOCK_SHIP_COLUMNS = {
|
||||
{ bx = 5, top = 4, bottom = 8 },
|
||||
{ bx = 6, top = 5, bottom = 9 },
|
||||
{ bx = 7, top = 6, bottom = 10 },
|
||||
{ bx = 8, top = 7, bottom = 11 },
|
||||
}
|
||||
local DOCK_WATER_TOP, DOCK_WATER_BOTTOM = 1, 13
|
||||
|
||||
M.VERMILION_DOCK = {
|
||||
onEnter = function(game, ow)
|
||||
local Flags = require("src.script.Flags")
|
||||
local f = game.save.flags
|
||||
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
|
||||
-- the ship is long gone: erase her right away, and anyone who
|
||||
-- still lands here is sent back out past the guard
|
||||
-- still lands here is sent back out past the guard unless a mod
|
||||
-- explicitly permits this occupied map state. This hook surrounds
|
||||
-- only the ejection decision; map-script registration and dispatch
|
||||
-- stay unchanged, and the departed ship remains erased.
|
||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||
ow.map:setBlock(b.bx, b.by, b.water)
|
||||
end
|
||||
ow.map.renderer:rebuild()
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._VermilionCitySailor1ShipSetSailText
|
||||
or "The ship set sail.", function()
|
||||
ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
|
||||
end))
|
||||
local occupancyAllowed = false
|
||||
if Runtime.wantsHook("map.occupancy_allowed") then
|
||||
local player = ow.player or {}
|
||||
occupancyAllowed = Runtime.call("map.occupancy_allowed",
|
||||
function() return false end, game, {
|
||||
mapId = "VERMILION_DOCK",
|
||||
reason = "ss_anne_departed",
|
||||
gameVersion = game.save and game.save.version,
|
||||
x = player.cellX,
|
||||
y = player.cellY,
|
||||
}) == true
|
||||
end
|
||||
if not occupancyAllowed then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._VermilionCitySailor1ShipSetSailText
|
||||
or "The ship set sail.", function()
|
||||
ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
|
||||
end))
|
||||
end
|
||||
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
|
||||
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
|
||||
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
|
||||
-- Music_Surfing plays for the sail-away cutscene, smoke puffs
|
||||
-- drift off the funnel, the horn blows, the ship is erased to
|
||||
-- open water, and the player is walked off the dock into the
|
||||
-- city past the guard (VermilionCity's
|
||||
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
|
||||
-- triggers the departure (wDestinationWarpID == 1 in pokered)
|
||||
Flags.set(game.save, "EVENT_SS_ANNE_LEFT")
|
||||
local Music = require("src.core.Music")
|
||||
Music.stop()
|
||||
Music.play(game.data, "Music_Surfing")
|
||||
local function puff(n, cx)
|
||||
if n <= 0 then return end
|
||||
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
||||
end
|
||||
puff(3, 15)
|
||||
-- scripts/VermilionDock.asm:182-203
|
||||
local rows = {}
|
||||
local function setBlock(bx, by, block)
|
||||
if bx < 1 or bx > 8 then return end
|
||||
rows[#rows + 1] = { "replace_block", bx, by, block }
|
||||
end
|
||||
rows[#rows + 1] = { "wait", 120 }
|
||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||
-- .shift_columns_up slides her tile columns west behind a mid-frame
|
||||
-- rSCX split; with no split scroll here she sails one block per beat
|
||||
-- and the water closes in astern (#360)
|
||||
for step = 1, 8 do
|
||||
for _, col in ipairs(DOCK_SHIP_COLUMNS) do
|
||||
setBlock(col.bx - step, 1, col.top)
|
||||
setBlock(col.bx - step, 2, col.bottom)
|
||||
end
|
||||
setBlock(9 - step, 1, DOCK_WATER_TOP)
|
||||
setBlock(9 - step, 2, DOCK_WATER_BOTTOM)
|
||||
rows[#rows + 1] = { "wait", 20 }
|
||||
end
|
||||
-- the second horn as she clears the dock, then EraseSSAnne's 120
|
||||
-- frames before the walk out
|
||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||
rows[#rows + 1] = { "wait", 120 }
|
||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||
-- no keepMusic on this warp: Music_Surfing belongs to the dock's
|
||||
-- cutscene, and VERMILION_CITY's own theme has to take over as the
|
||||
-- player crosses in (EnterMap's PlayDefaultMusic)
|
||||
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||
ow:queueScript(rows)
|
||||
ow:queueScript({
|
||||
-- scripts/VermilionDock.asm:50 zeroes the player image index and
|
||||
-- :77 freezes sprite updates, so he faces DOWN throughout (#1689)
|
||||
{ "face_player_dir", "down" },
|
||||
{ "wait", 120 },
|
||||
{ "play_sound", "SS_Anne_Horn" },
|
||||
-- scripts/VermilionDock.asm:80 .shift_columns_up
|
||||
{ "ss_anne_departs" },
|
||||
-- scripts/VermilionDock.asm:205 VermilionDock_EraseSSAnne
|
||||
{ "play_sound", "SS_Anne_Horn" },
|
||||
{ "wait", 120 },
|
||||
{ "move_player", "up", 2 },
|
||||
-- no keepMusic on this warp: Music_Surfing belongs to the dock's
|
||||
-- cutscene, and VERMILION_CITY's own theme has to take over as the
|
||||
-- player crosses in (EnterMap's PlayDefaultMusic)
|
||||
{ "warp", "VERMILION_CITY", 18, 31, "up" },
|
||||
{ "move_player", "up", 2 },
|
||||
})
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
+14
-7
@@ -216,7 +216,10 @@ local function dojoMasterGate(game, ow, x, y)
|
||||
if not master or ow:trainerDefeated(master) then return false end
|
||||
ow.player.facing = "right"
|
||||
master:facePlayer(ow.player)
|
||||
ow:engageTrainer(master)
|
||||
-- scripts/FightingDojo.asm:117-119 SaveEndBattleTextPointers (#1606)
|
||||
ow:engageTrainer(master, nil,
|
||||
((game.data or {}).text or {})._FightingDojoKarateMasterDefeatedText,
|
||||
nil, nil, false)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -356,18 +359,22 @@ M.ROUTE_16_FLY_HOUSE = {
|
||||
TEXT_ROUTE16FLYHOUSE_BRUNETTE_GIRL = function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
if game.save.flags.EVENT_GOT_HM02 then
|
||||
push(game, t._Route16FlyHouseBrunetteGirlHm02ExplanationText
|
||||
or "HM02 is FLY!\fIt will whisk you\nback to any town!", done)
|
||||
push(game, t._Route16FlyHouseBrunetteGirlHM02ExplanationText
|
||||
or "HM02 is FLY.\nIt will take you\vback to any town.\fPut it to good\nuse!", done)
|
||||
return
|
||||
end
|
||||
push(game, t._Route16FlyHouseBrunetteGirlText
|
||||
or "Shh! It's a\nsecret!\fMy POKéMON's\nHM02, take it!", function()
|
||||
or "Oh, you found my\nsecret retreat!", function()
|
||||
if not require("src.inventory.Bag").add(game.save, "HM_FLY", 1) then
|
||||
push(game, "You don't have\nroom for HM02!", done)
|
||||
push(game, t._Route16FlyHouseBrunetteGirlHM02NoRoomText
|
||||
or "You don't have any\nroom for this.", done)
|
||||
return
|
||||
end
|
||||
game.save.flags.EVENT_GOT_HM02 = true
|
||||
push(game, ("%s got\nHM02!"):format(game.save.player.name), done)
|
||||
-- scripts/Route16FlyHouse.asm:32-35
|
||||
push(game, fill(t._Route16FlyHouseBrunetteGirlReceivedHM02Text
|
||||
or "{PLAYER} received\nHM02!", { player = game.save.player.name }),
|
||||
done, require("src.render.TextBox").soundOpts(game, "Get_Key_Item"))
|
||||
end)
|
||||
end,
|
||||
},
|
||||
@@ -731,7 +738,7 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text[dontRunText] or "Don't run away!", function()
|
||||
ow:scriptMove(ow.player, "up", 1)
|
||||
ow:scriptMove(ow.player, "up", 1, nil, { collide = true })
|
||||
end))
|
||||
return true
|
||||
end,
|
||||
|
||||
+28
-23
@@ -240,7 +240,7 @@ local function stepGate(opts)
|
||||
push(game, text(game)[opts.text] or opts.fallback, function()
|
||||
ow.player.facing = opts.push
|
||||
if not ow:checkLedgeHop(opts.push) then
|
||||
ow:scriptMove(ow.player, opts.push, 1)
|
||||
ow:scriptMove(ow.player, opts.push, 1, nil, { collide = true })
|
||||
end
|
||||
end)
|
||||
return true
|
||||
@@ -647,27 +647,29 @@ end
|
||||
local rocketRows = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
|
||||
{ "jump_if_true", 15 }, -- 3 → CeruleanHideRocket
|
||||
{ "jump_if_true", 16 }, -- 3 → CeruleanHideRocket
|
||||
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
|
||||
{ "jump_if_true", 9 }, -- 5
|
||||
{ "jump_if_true", 10 }, -- 5
|
||||
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
|
||||
{ "jump_if_false", "end" }, -- 8
|
||||
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
|
||||
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
|
||||
{ "set_flag", "EVENT_GOT_TM28" }, -- 12
|
||||
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
|
||||
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
|
||||
{ "fade", "out" }, -- 15 GBFadeOutToBlack
|
||||
-- scripts/CeruleanCity.asm:297 SaveEndBattleTextPointers
|
||||
{ "save_end_battle_text", "_CeruleanCityRocketIGiveUpText" }, -- 7
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 8
|
||||
{ "jump_if_false", "end" }, -- 9
|
||||
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 10
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 11
|
||||
{ "give_item", "TM_DIG", 1, false }, -- 12 (row 14 prints)
|
||||
{ "set_flag", "EVENT_GOT_TM28" }, -- 13
|
||||
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 14
|
||||
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 15
|
||||
{ "fade", "out" }, -- 16 GBFadeOutToBlack
|
||||
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
||||
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
|
||||
-- door neighbour -- the swap reconnects the city (Bill's ticket does
|
||||
-- the same in story.lua; either route is enough).
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18
|
||||
{ "fade", "in" }, -- 19 GBFadeInFromBlack
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 17
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 18
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 19
|
||||
{ "fade", "in" }, -- 20 GBFadeInFromBlack
|
||||
}
|
||||
|
||||
M.CERULEAN_CITY = {
|
||||
@@ -818,7 +820,8 @@ M.PEWTER_POKECENTER = {
|
||||
-- on the west-side cells and walks you back
|
||||
local function bikeGateGuard(coords, stopText, explainText)
|
||||
return function(game, ow, x, y)
|
||||
if game.save.inventory.BICYCLE then return false end
|
||||
local bike = game.save.inventory.BICYCLE
|
||||
if bike and bike ~= 0 then return false end
|
||||
if not inCoords(coords, x, y) then return false end
|
||||
-- walk the player up to the tile beside the counter, no further:
|
||||
-- (matchedY - closestY) tiles, 0 when already next to it
|
||||
@@ -840,10 +843,10 @@ local function bikeGateGuard(coords, stopText, explainText)
|
||||
-- (PlayerMovingRightScript). Without it the player was left
|
||||
-- parked beside the guard's counter with no way past. #518
|
||||
local function shoveRight()
|
||||
ow:scriptMove(ow.player, "right", 1)
|
||||
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
|
||||
end
|
||||
if dist > 0 then
|
||||
ow:scriptMove(ow.player, "up", dist, shoveRight)
|
||||
ow:scriptMove(ow.player, "up", dist, shoveRight, { collide = true })
|
||||
else
|
||||
shoveRight()
|
||||
end
|
||||
@@ -919,10 +922,12 @@ M.SS_ANNE_2F = {
|
||||
{ "move_npc_to", 2, 36, onLeft and 7 or 8 }, -- 2
|
||||
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
||||
{ "jump_if_false", 13 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
||||
-- SSAnne2FRivalText's text_asm arms SaveEndBattleTextPointers
|
||||
-- (scripts/SSAnne2F.asm:199), so the line prints in battle (#1688)
|
||||
{ "save_end_battle_text", "_SSAnne2FRivalDefeatedText" }, -- 5
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 6
|
||||
{ "jump_if_false", 13 }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 8
|
||||
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } }, -- 10
|
||||
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 11
|
||||
|
||||
@@ -55,16 +55,6 @@ In-game controls use the normal PortMaster / SDL pad map, rebindable under
|
||||
|
||||
## Notes
|
||||
|
||||
**GBC FX is off on this device.** The launcher exports `POKEPORT_GBCFX=0`,
|
||||
which hides the GBC FX row from OPTIONS, pins the level to OFF, and clears a
|
||||
level carried over in an `options.lua` from another machine. The H700's Mali
|
||||
GPU is in the same class as the phone GPUs that compile that present pass and
|
||||
then show a black frame (issue #136), and `love.system.getOS()` reports
|
||||
`"Linux"` here, so the Android gate would not have caught it. Every other
|
||||
display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
|
||||
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
|
||||
put the row back.
|
||||
|
||||
**PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults
|
||||
to AUTO, which reads this device as an ARM Linux handheld and resolves to
|
||||
**LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Linux AppImage packaging
|
||||
|
||||
Releases ship raw AppImages (no zip wrapper):
|
||||
|
||||
- `gen1recomp-<version>-linux-x86_64.AppImage`
|
||||
- `gen1recomp-<version>-linux-arm64.AppImage`
|
||||
|
||||
```sh
|
||||
chmod +x gen1recomp-*-linux-x86_64.AppImage
|
||||
./gen1recomp-*-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
Flatpak users should prefer the `.flatpak` bundle (see
|
||||
[linux-flatpak.md](linux-flatpak.md)); it avoids host glibc / FUSE / curl
|
||||
mismatches on immutable desktops.
|
||||
|
||||
## Common failure modes
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| Won't start / `libfuse.so.2` | No FUSE | `sudo apt install libfuse2` (or `libfuse2t64`) **or** `./app.AppImage --appimage-extract-and-run` |
|
||||
| Update check / mods / save sync fail | Host `curl` missing or broken by AppImage `LD_LIBRARY_PATH` | Install curl; current builds scrub `LD_LIBRARY_PATH` for **host** curl and keep it for a bundled AppDir curl |
|
||||
| Settings / sync reset after quit | Portable mode next to a read-only AppImage parent (`/opt`, system dir) | Remove `portable.txt` or move the AppImage to a writable folder; the game falls back to the XDG save dir when the probe write fails |
|
||||
| "Download AppImage update" | Shell/`minShell` gate needs a full native package | Download the new `.AppImage`, `chmod +x`, replace the old file |
|
||||
| Steam / Game Mode weirdness after update | Overlay `LD_PRELOAD` / PID change | HostShell unsets `LD_PRELOAD` for children and `execv`s `$APPIMAGE` on restart |
|
||||
|
||||
## Network transport (HostShell)
|
||||
|
||||
Desktop Linux fetches go through host or bundled `curl`:
|
||||
|
||||
1. Flatpak `/app/bin/curl` (bundled, keep sandbox libs)
|
||||
2. `$APPDIR/usr/bin/curl` or `$APPDIR/bin/curl` (keep `$APPDIR` on `LD_LIBRARY_PATH`)
|
||||
3. Host `curl` (`env -u LD_LIBRARY_PATH`, and always `-u LD_PRELOAD`)
|
||||
|
||||
## Portable mode
|
||||
|
||||
Drop `portable.txt` beside the `.AppImage` to keep saves next to the binary.
|
||||
The launcher probes writability with a unique `.write_probe_<time>_<rand>.tmp`
|
||||
file. Read-only parents fail soft and use `love.filesystem` XDG saves instead.
|
||||
Flatpak ignores `portable.txt`.
|
||||
|
||||
## Auto-update
|
||||
|
||||
In-place updates download `gen1recomp-X.Y.Z.love` into the save directory.
|
||||
Full shell bumps open the matching AppImage (or Flatpak) download URL — there
|
||||
is no silent in-place AppImage replace yet.
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
# x86_64 (macOS or Linux host with squashfs-tools)
|
||||
scripts/build.sh linux --version X.Y.Z
|
||||
|
||||
# arm64 (aarch64 host + docker/podman)
|
||||
scripts/build_linux_arm64.sh --version X.Y.Z
|
||||
```
|
||||
|
||||
See [linux-arm64-build.md](linux-arm64-build.md) for the arm64 builder.
|
||||
@@ -34,6 +34,25 @@ The package bundles PortMaster's LÖVE 11.5 aarch64 runtime. The launcher source
|
||||
|
||||
Suspend/resume uses the existing LÖVE focus/visibility lifecycle: input is reset on focus loss and the game resumes when the window becomes visible again. Exact power-button behavior remains firmware-dependent; hardware validation has been performed on the TrimUI Brick, not every SBC or H700 device.
|
||||
|
||||
## Power and Performance Tuning
|
||||
|
||||
The handheld build applies several optimizations to reduce power draw and ensure smooth 60 FPS frame pacing on low-power ARM SoCs:
|
||||
|
||||
- **KMSDRM / EGL Vsync Fix**: Handheld builds disable driver vsync (`vsync = 0`) to prevent GPU driver busy-wait spinloops in `eglSwapBuffers`, allowing `nanosleep()` and dropping idle CPU usage from ~25% to ~4%.
|
||||
- **Idle Render Governor**: Drops presentation rate when a static screen (menus, text dialogs, stationary scenes) receives no input, cutting compositing work ~6x while preserving full 60 Hz game and audio clocks. Any button press restores full framerate immediately.
|
||||
- **Sample Rate Scaling**: Audio synthesis is tuned to 22.05 kHz by default (`POKEPORT_AUDIO_RATE=22050`), halving synthesis CPU overhead on Cortex-A53 cores with no audible quality loss on handheld speakers.
|
||||
- **Dynamic CPU Governor**: Defaults to `schedutil` instead of pinning `performance` on all cores, reducing thermals and extending battery life.
|
||||
|
||||
Environment variables for fine-tuning (configured in `gen1recomp-sbc.sh`):
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `POKEPORT_IDLE_AFTER` | `10` | Seconds without input before an idle screen drops presentation rate |
|
||||
| `POKEPORT_IDLE_FPS` | `6` | Framerate while idle |
|
||||
| `POKEPORT_AUDIO_RATE` | `22050` | Chip-synth sample rate (set to `44100` for full rate) |
|
||||
| `POKEPORT_CPU_GOVERNOR` | `schedutil` | CPU scaling governor (`schedutil`, `performance`, `ondemand`) |
|
||||
|
||||
|
||||
## Building
|
||||
|
||||
Release workflows build this automatically. Standalone builds resolve the latest published Gen1Recomp release by default:
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# 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
|
||||
existing x86_64 `gen1recomp-<version>-linux-x86_64.AppImage`. 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.
|
||||
aarch64 handhelds that run a full distro. See also
|
||||
[linux-appimage.md](linux-appimage.md) for shared AppImage failure modes.
|
||||
|
||||
> The Anbernic RG34XXSP has its own PortMaster-style pack
|
||||
> (`gen1recomp-*-rg34xxsp-stockos64-mod.zip`, see
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Linux Flatpak
|
||||
|
||||
Releases may ship `gen1recomp-<version>-linux.flatpak` (x86_64 bundle).
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
flatpak install --user ./gen1recomp-<version>-linux.flatpak
|
||||
flatpak run com.theboisclub.gen1recomp
|
||||
```
|
||||
|
||||
Or open the `.flatpak` in Discover / GNOME Software. AppStream metainfo
|
||||
includes a `<releases>` entry so those UIs can show the version.
|
||||
|
||||
## Permissions
|
||||
|
||||
The manifest requests:
|
||||
|
||||
- network (updater, mods, save sync)
|
||||
- X11 / Wayland / DRI / PulseAudio
|
||||
- `--device=all` (SDL2 gamepads, rumble, gyro via evdev/hidraw)
|
||||
- `--filesystem=home` (ROM import)
|
||||
|
||||
Curl is bundled at `/app/bin/curl` so the game does not need host curl.
|
||||
|
||||
## Updates
|
||||
|
||||
Lua/engine payloads still use the in-app `.love` updater. A native shell bump
|
||||
points at the new `.flatpak` asset (`Download Flatpak update`).
|
||||
|
||||
After a `.love` payload download, restart uses `love.event.quit("restart")`
|
||||
(`execv` of `/proc/self/exe` inside bwrap). If a restart hangs after an
|
||||
update, fully quit and relaunch — PhysFS can retain a lock on the previous
|
||||
payload across a bad handoff.
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
scripts/build_flatpak.sh --version X.Y.Z
|
||||
# → dist/flatpak/gen1recomp-X.Y.Z-linux.flatpak
|
||||
```
|
||||
|
||||
Requires `flatpak` + `flatpak-builder` and the Freedesktop 24.08 runtime from
|
||||
Flathub (installed automatically on first build).
|
||||
|
||||
Release CI builds the bundle on `ubuntu-24.04` (`linux-flatpak` job) from the
|
||||
shared `game.love` payload and publishes `gen1recomp-<ver>-linux.flatpak`
|
||||
alongside the AppImages. PR CI path-gates the same script when `flatpak/` or
|
||||
`scripts/build_flatpak.sh` change.
|
||||
|
||||
Flathub store submission is out of scope for the GitHub bundle channel.
|
||||
@@ -24,7 +24,7 @@ The short version, for an author deciding what to write:
|
||||
merged.** The write is taken, dropped, and named once per mod in the same
|
||||
error feed the mod manager shows -- in both directions, so a Red boot writing
|
||||
to `decorations` is told exactly as a Gold boot writing to `map_scripts` is.
|
||||
- **40 event names and 43 hook names have a call site in both generations**, so
|
||||
- **40 event names and 44 hook names have a call site in both generations**, so
|
||||
one subscription serves both games. `tests/engine/gate_gen2_mod_api.lua`
|
||||
reads those names back out of the source and fails if a site is renamed or
|
||||
deleted on either side, and fails again if a new shared site appears without
|
||||
@@ -55,10 +55,10 @@ The short version, for an author deciding what to write:
|
||||
```
|
||||
|
||||
`games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`,
|
||||
`"gold"`, `"silver"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or
|
||||
`"all"`. `src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER`
|
||||
and `GameVersion.generation`, so nothing anywhere restates the game list.
|
||||
`"gen2"` now expands to both Gold and Silver.
|
||||
`"gold"`, `"silver"`, `"crystal"`), generations (`"gen1"`, `"gen2"`,
|
||||
case-insensitive) or `"all"`. `src/mods/ModTargets.lua` resolves the tokens off
|
||||
`GameVersion.ORDER` and `GameVersion.generation`, so nothing anywhere restates
|
||||
the game list. `"gen2"` now expands to Gold, Silver and Crystal.
|
||||
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
|
||||
and **derives** `manifest.gen2compat` from them, which is the one field the
|
||||
loader's gate reads.
|
||||
@@ -469,8 +469,11 @@ is warned once per name and the rest of the list still runs. The engine's own
|
||||
Gen 1 verbs are **not** seeded on Gold: a row-list verb handed Gold's ctx would
|
||||
find no runner on it, so `data.commands` under Gen 2 is the mod verbs alone.
|
||||
|
||||
**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`, exports.**
|
||||
Generation-agnostic; nothing to adapt.
|
||||
**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`,
|
||||
`mod.developer`, exports.** Generation-agnostic; nothing to adapt.
|
||||
`mod.developer` is the same fixed boot-time boolean on both generations and is
|
||||
available while the entry chunk runs. Gold does not gain Gen 1's developer
|
||||
console or F5 hot-reload hotkey; the field reports the loader's mode only.
|
||||
|
||||
**`mod.world`.** Same method set, resolved against Gold's world
|
||||
(`src/world/gen2/WorldAPI.lua`). Two differences show through and are
|
||||
@@ -539,7 +542,8 @@ gains a field instead of the name gaining a prefix.
|
||||
`battle.damage_dealt`, `battle.fainted`, `battle.status_inflicted`,
|
||||
`battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`,
|
||||
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
|
||||
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
||||
`battle.crit`, `battle.accuracy`, `battle.charge_required`,
|
||||
`battle.turn_order`,
|
||||
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
||||
`battle.catch_exp`, `battle.bottom_ui_visible`,
|
||||
@@ -549,6 +553,17 @@ gains a field instead of the name gaining a prefix.
|
||||
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
||||
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
||||
`ctx.battle.data` instead gets nil on Gold.
|
||||
`battle.exp_award`'s `ctx.applyShare(mon, split, announce)` reads its third
|
||||
argument on both generations: truthy prints the mon's GainedText, falsy pays
|
||||
it silently, so one mod source can print a single summary line for a
|
||||
party-wide award instead of a box per recipient. Gold honours it **only when
|
||||
it is passed**, by argument count -- `applyShare(mon, split)` was written
|
||||
against a seam that always announced on Gold and keeps announcing there,
|
||||
while `applyShare(mon, split, nil)` is silent on both. Pass the argument
|
||||
explicitly and the two generations agree; omit it and Gen 1 stays silent
|
||||
where Gold speaks. Only the line is affected: the exp, the stat exp,
|
||||
`battle.exp_gained`, the level-up line, learned moves and the forget prompt
|
||||
happen either way.
|
||||
- *The catch and the evolution:* `pokemon.caught`, `pokemon.evolved`; hook
|
||||
`evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits
|
||||
`pokemon.caught` once the mon is in the party or the box, and
|
||||
@@ -561,7 +576,7 @@ gains a field instead of the name gaining a prefix.
|
||||
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
|
||||
-- the logic tick before the pad is read, a pointer the touch overlay gets
|
||||
first refusal on, the palette zone list handed to the present pass, the
|
||||
composed frame before GBCFX, the letterbox, and the finished playfield rect
|
||||
composed frame before ShaderFX, the letterbox, and the finished playfield rect
|
||||
-- and carries the same payload.
|
||||
`render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and
|
||||
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
|
||||
@@ -771,6 +786,9 @@ name and the existing payload, plus fields where Gen 2 genuinely carries more
|
||||
|
||||
The list is much shorter than it was. What is outstanding, in descending value:
|
||||
|
||||
- `battle.field_residual`: the first guarded call site is in Gen 1 end-of-round
|
||||
processing. Gold already has a native weather/between-turn pipeline but does
|
||||
not yet expose the shared data-only descriptor hook.
|
||||
- `trainer.before_battle`: Gold constructs and pushes its trainer battle in
|
||||
`src/world/gen2/World.lua:startBattle`, which does not yet expose a deferred
|
||||
preparation boundary or a battle-local player-party view. Gen 1 mods can use
|
||||
|
||||
+373
-10
@@ -122,21 +122,40 @@ Each object requires a stable `id`, a display `name`, a destination `file`
|
||||
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
|
||||
`description` gives players dump or region guidance in the import panel.
|
||||
`size` declares the exact canonical byte length; `max_size` declares a smaller
|
||||
per-import ceiling when an exact size is not appropriate. Every import also
|
||||
has an engine-enforced 128 MiB ceiling and is rejected before hashing when its
|
||||
filesystem reports an invalid size.
|
||||
per-import ceiling when an exact size is not appropriate. The engine hard limit
|
||||
is 2 GiB. Imports above 128 MiB receive an explicit free-space confirmation and
|
||||
use the launcher's streaming large-file path rather than being materialized as
|
||||
one Lua string.
|
||||
|
||||
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
|
||||
strips a recognized 512-byte copier header, converts the bytes to canonical
|
||||
big-endian `.z64` order, and then checks MD5. The canonical bytes are written
|
||||
to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
|
||||
mod: the launcher never scans or copies another mod's imported files merely
|
||||
because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for
|
||||
example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem
|
||||
because its manifest names the same digest. Small sources can still be read
|
||||
with the existing scoped `mod:read` API, for example
|
||||
`mod:read("baseroms/stadium2.z64")`. For large sources, prefer the bounded
|
||||
`mod.imports` facade described below; no host path or new general filesystem
|
||||
permission is exposed. Missing `required_imports` block the mod before its
|
||||
entry chunk runs; missing `optional_imports` remain visible in the same
|
||||
launcher panel but do not block loading.
|
||||
|
||||
#### Bounded access to validated imports
|
||||
|
||||
A loaded mod can address only ids declared by its own `required_imports` or
|
||||
`optional_imports` arrays:
|
||||
|
||||
```lua
|
||||
local info, err = mod.imports:info("stadium2")
|
||||
local header, err = mod.imports:read("stadium2", 0, 4096)
|
||||
```
|
||||
|
||||
`read` uses zero-based offsets and is capped at 8 MiB per call. The engine
|
||||
rechecks the stored import before exposing it, seeks into the engine-owned
|
||||
copy, and never gives the mod a host path or file handle. This is intended for
|
||||
large source formats whose table/index can be parsed with small reads before
|
||||
selectively reading the payloads a transform actually needs.
|
||||
|
||||
MD5 here identifies a known dump because ROM databases commonly publish it;
|
||||
it is not a security or authenticity guarantee. Do not paste the SHA-1 used by
|
||||
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
|
||||
@@ -181,6 +200,60 @@ the adapter's own coverage table:
|
||||
python3 tools/modkit.py gen2check mods/my_mod
|
||||
```
|
||||
|
||||
## Imported version datasets
|
||||
|
||||
A mod can inspect semantic content from another game the player has already
|
||||
imported without switching the active game or reaching into engine cache
|
||||
internals:
|
||||
|
||||
```lua
|
||||
local gold, reason = mod.datasets:open("gold")
|
||||
if not gold then
|
||||
-- reason is "unknown_version", "not_imported", or "invalid_cache"
|
||||
return
|
||||
end
|
||||
|
||||
local chikorita = gold.content.pokemon:get("CHIKORITA")
|
||||
local normalVsGhost = gold.content.type_chart:get("NORMAL>GHOST")
|
||||
local spritePath = gold.assets:path(chikorita.spriteFront)
|
||||
|
||||
for id, record in gold.content.pokemon:each() do
|
||||
-- ids are returned in deterministic lexical order
|
||||
end
|
||||
```
|
||||
|
||||
`view.version` and `view.generation` identify the selected dataset.
|
||||
`view.content` exposes the same registry names, aliases, generation routing,
|
||||
and data-only record shapes as `mod.content`, but only `get`, `has`, and
|
||||
`each`. Returned records are detached copies and cannot mutate either dataset.
|
||||
Every generated base record passes the selected generation's existing public
|
||||
schema before it is returned; extractor metadata beside record maps stays out
|
||||
of the registry id space and is reserved against `register`, `override`,
|
||||
`patch`, and `remove` writes through the active registry. A malformed record
|
||||
makes `get` return nil, `has`
|
||||
return false, and `each` return no rows, and invalidates that dataset view.
|
||||
Records containing functions, userdata, threads, metatables, or cycles are not
|
||||
exposed. Each open call receives an independent facade, so one mod cannot
|
||||
replace another mod view method. Canonical boot shaping is included, such as
|
||||
Gen 1 defaults and Yellow corrections, and Gold's Foresight matchup rows and
|
||||
derived `held_items`.
|
||||
|
||||
`open` checks the completion marker, exact version-specific file inventory,
|
||||
source/cache boundary, and file-size bounds without reading or decoding the
|
||||
semantic modules. A root is read, bounded-decoded, normalized, and cached only
|
||||
when a content operation first needs it. Each later view operation rechecks
|
||||
readiness and the source bytes behind already cached roots; unchanged roots are
|
||||
not decoded again. Missing, partial, and stale imports return
|
||||
`nil, "not_imported"`. Malformed syntax, a resource-limit violation, or a
|
||||
record that fails the public schema is discovered on first root access and
|
||||
fails that operation closed; a later `open` against the same source returns
|
||||
`nil, "invalid_cache"`. Generated modules use a bounded literal-only grammar
|
||||
and are never executed. No raw ROM bytes or generated source are exposed.
|
||||
`view.assets:path(relative)` and
|
||||
`view.assets:info(relative)` accept only `assets/generated/...` paths and
|
||||
keep them under the selected version cache prefix. The API never changes
|
||||
`mod.game`, the active `Data` table, `GameVersion`, or cache mount state.
|
||||
|
||||
## Editing maps in Tiled
|
||||
|
||||
Maps are data, not assets, so they can be authored in a real map editor and
|
||||
@@ -209,6 +282,66 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
|
||||
read-only snapshots; mods choose which layers to render. Red and Gold expose
|
||||
the same contract while applying their own object and event visibility rules.
|
||||
|
||||
### Active Gen 1 block checks
|
||||
|
||||
Red, Blue, and Yellow expose
|
||||
`mod.world:activeBlockAt(mapId, blockX, blockY)`. It returns the numeric block
|
||||
ID at one zero-based block coordinate only when `mapId` is the active map.
|
||||
The value is a scalar snapshot: changing it cannot change the map. This lets a
|
||||
mod compare a small runtime map signature before it applies a lawful authored
|
||||
replacement, without reading the mutable map or ROM cache through engine
|
||||
internals.
|
||||
|
||||
The method fails closed. Before an overworld exists it returns
|
||||
`nil, "no overworld"`; for a different active map it returns
|
||||
`nil, "map is not active"`; non-numeric, non-finite, or fractional coordinates
|
||||
return `nil, "invalid block coordinates"`; and negative or out-of-range
|
||||
coordinates return `nil, "block coordinates out of bounds"`. An unavailable
|
||||
or malformed active block returns `nil, "block unavailable"`. The caller must
|
||||
require every expected cell to match before changing presentation. This method
|
||||
is Gen 1-only; Gold callers receive no parity promise for it.
|
||||
|
||||
The same unavailable result covers missing or sparse active block storage and
|
||||
an accessor result that does not match its validated active block slot.
|
||||
|
||||
### Conditional map occupancy
|
||||
|
||||
`map.occupancy_allowed` is a narrow Gen 1 hook around a map script's vanilla
|
||||
decision to eject the player from an otherwise valid loaded map. Its first
|
||||
call site is the post-departure `VERMILION_DOCK` branch. The ship has already
|
||||
been erased when the hook runs, and the hook does not replace or suppress any
|
||||
base or peer map handler.
|
||||
|
||||
The wrapper receives `(next, game, context)`. The dock context is a copied
|
||||
`{ mapId = "VERMILION_DOCK", reason = "ss_anne_departed", gameVersion, x, y }`
|
||||
record. Vanilla returns `false`. Return exactly `true` to allow the player to
|
||||
remain; every other value denies occupancy and preserves the normal message
|
||||
and warp. A composable wrapper calls downstream first and only adds its own
|
||||
permission:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("map.occupancy_allowed", function(next, game, ctx)
|
||||
local allowed = next(game, ctx)
|
||||
local mine = ctx.mapId == "VERMILION_DOCK"
|
||||
and ctx.reason == "ss_anne_departed"
|
||||
and myPublicEligibilityCheck(game)
|
||||
return allowed == true or mine == true
|
||||
end)
|
||||
```
|
||||
|
||||
With no wrapper, the hook allocates no context and vanilla behavior is
|
||||
unchanged. A throwing wrapper is isolated by the normal hook bus. A nil,
|
||||
string, number, table, or other malformed final answer fails closed. Disabling
|
||||
or uninstalling the permitting mod therefore restores vanilla ejection without
|
||||
changing the S.S. Anne story flag or restoring the ship.
|
||||
|
||||
Normal hook-chain ownership applies: a wrapper that does not call `next`
|
||||
intentionally owns the final answer and does not run lower-priority wrappers.
|
||||
Permission wrappers must call `next` as shown above to compose. A noncompliant
|
||||
wrapper that returns false without calling `next` safely denies occupancy and
|
||||
can suppress downstream permission by this standard rule. A malformed answer
|
||||
also fails closed and cannot force occupancy.
|
||||
|
||||
## Party ordering
|
||||
|
||||
Companion UIs and alternate party screens can call
|
||||
@@ -246,6 +379,80 @@ Red exposes FLY separately because it requires a destination picker:
|
||||
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
|
||||
Fly town list. Gold does not expose these two methods yet.
|
||||
|
||||
## Self-driven world actors
|
||||
|
||||
`mod.world:spawnNpc()` returns a handle whose `scriptMove` queues onto the
|
||||
overworld's scripted-movement list. A non-empty list is how the overworld
|
||||
knows a cutscene is running, so it gates player input for as long as the
|
||||
actor walks -- right for Oak marching to his lab, wrong for an actor that
|
||||
moves on its own schedule (a networked player's ghost, an ambient walker).
|
||||
Five handle methods drive one without that lockout:
|
||||
|
||||
```lua
|
||||
local ghost = mod.world:spawnNpc({ map = "ROUTE_1", x = 5, y = 7,
|
||||
sprite = "SPRITE_RED" })
|
||||
if ghost:canStep("up") then ghost:stepNow("up") end -- one tile, now
|
||||
if not ghost:isMoving() then ghost:placeAt(9, 3, "down") end -- snap, no walk
|
||||
ghost:setPassable(true) -- walk-through
|
||||
```
|
||||
|
||||
`stepNow(dir)` sets the same per-tile state `scriptMove` does, minus the
|
||||
queue. It deliberately does **not** check collision: a caller replaying a
|
||||
move that was already decided elsewhere (validated on a peer's machine, or
|
||||
authored) would let the two copies disagree about where the actor is if this
|
||||
re-judged it. Ask `canStep(dir)` first when you do want the map's opinion.
|
||||
`placeAt(x, y, facing)` snaps with no animation and clears any step in
|
||||
flight, for a warp arrival or a resync too far gone to walk off.
|
||||
`isMoving()` lets a driver pace itself instead of stomping a move already
|
||||
running. `setPassable(flag)` is the flag `Collision.occupied` skips (the
|
||||
engine's own user is Yellow's companion Pikachu); a passable object still
|
||||
draws and can still be talked to.
|
||||
|
||||
An object spawned this way carries no `TEXT_*` id, so the vanilla talk path
|
||||
has nothing to say for it. The **`world.talk`** hook is the A press on an
|
||||
object, raised before the map's text tables get it:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("world.talk", function(next, ow, target)
|
||||
if mine(target) then
|
||||
say(target) -- the mod answers for an object it owns
|
||||
return -- ...by not calling next()
|
||||
end
|
||||
return next(ow, target) -- everything else falls through unchanged
|
||||
end)
|
||||
```
|
||||
|
||||
With no subscriber the A press reaches `talkTo` exactly as before. An object
|
||||
mid-step raises no hook, matching the vanilla gate.
|
||||
|
||||
## Adopting an already-paired link session
|
||||
|
||||
`LinkState.newFromSession(game, transport, mode, isHost, opts)` starts a link
|
||||
session on a transport that is *already* paired, skipping the address/code
|
||||
entry UI while keeping the hello and fingerprint compatibility exchange
|
||||
intact. `transport` is anything `Session` accepts, which is what lets a mode
|
||||
tunnel a battle through its own connection rather than opening a second one.
|
||||
|
||||
When the battle finishes, **`link.battle_ended`** reports the outcome:
|
||||
|
||||
```lua
|
||||
mod.events:on("link.battle_ended", function(ev)
|
||||
-- ev = { result, myParty, theirParty, peerName, role }
|
||||
end)
|
||||
```
|
||||
|
||||
The party copies are the point. Cable rules leave the real party untouched,
|
||||
so a mode built on link battles -- a tournament ladder, a battle royale --
|
||||
has no other way to learn what the fight cost, and by the time the state
|
||||
unwinds the battle object is gone. `role` is `"host"` or `"guest"`.
|
||||
|
||||
Two smaller pieces support the same shape of mode. `Game:startNewGame(opts)`
|
||||
is the title screen's NEW GAME closure made callable, with `opts.intro =
|
||||
false` to land straight in the world -- a mode that hands out its own starting
|
||||
state has no use for Oak's speech. `CodeEntry.new` takes an optional
|
||||
`{ length = , charset = }`, so the slot-scrub widget that enters a link code
|
||||
can also carry a room code or an address.
|
||||
|
||||
## Read-only battle snapshots
|
||||
|
||||
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
||||
@@ -298,6 +505,65 @@ Menu choices and moves use the same engine methods as the native controls;
|
||||
their mutable logic. Tutorial, link, forced, stale, and covered battle states
|
||||
refuse core intents. Use `mod.input` for ordinary text advance.
|
||||
|
||||
## Battle rule hooks
|
||||
|
||||
Two decisions the OPTION screen and the cart make for the player, which a game
|
||||
mode can make instead (RFC 0015). Neither writes the player's saved
|
||||
preference, so a mode can hold a rule for as long as it is active and hand the
|
||||
player's own setting back untouched.
|
||||
|
||||
`battle.style` wraps the SHIFT/SET read at the moment the foe's Pokémon faints
|
||||
and the engine would offer a free switch:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("battle.style", function(next, battle)
|
||||
if myMode.active then return "set" end -- no "will you change POKéMON?"
|
||||
return next(battle) -- the OPTION row, as today
|
||||
end)
|
||||
```
|
||||
|
||||
Return `"set"` or `"shift"`; anything else reads as the vanilla answer.
|
||||
|
||||
`catch.nickname` wraps the `AskName` prompt after a capture (party or box),
|
||||
the same question `pokemon.before_give`'s `gift.nickname` already answers for
|
||||
script gifts:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("catch.nickname", function(next, mon, ctx)
|
||||
-- ctx = { battle = <BattleState>, name = <display name>, game = <Game> }
|
||||
if myMode.active then return false end -- keep the species name
|
||||
if myNames then return myNames[mon.species] end -- a string names it, no prompt
|
||||
return next(mon, ctx) -- true: ask, as today
|
||||
end)
|
||||
```
|
||||
|
||||
`false` keeps the species name with no prompt. A string is the nickname with
|
||||
no prompt, clipped to the naming grid's ten characters (an empty string names
|
||||
nothing). Anything else queues the prompt.
|
||||
|
||||
## Party-full custody at a catch
|
||||
|
||||
When a capture lands on a full party, the cart deposits the mon in storage
|
||||
without a question. `catch.party_full` (RFC 0018) lets a mode stand in front
|
||||
of `SendNewMonToBox` and take custody instead:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("catch.party_full", function(next, ctx)
|
||||
-- ctx = { battle = <BattleState>, mon = <Pokemon>, name = <display name>,
|
||||
-- game = <Game> }
|
||||
if myMode.active then
|
||||
takeCustody(ctx.mon) -- the mon is the mod's problem now
|
||||
return true -- nothing is deposited
|
||||
end
|
||||
return next(ctx) -- false: deposit, as today
|
||||
end)
|
||||
```
|
||||
|
||||
A truthy return skips the box entirely -- the mon is neither in the party nor
|
||||
in any box, and `pokemon.caught` reports `destination = "mod"` so the mode can
|
||||
find its own custody again. Anything falsy deposits as always, "But every BOX
|
||||
is full!" included.
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||
@@ -441,6 +707,28 @@ 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.
|
||||
|
||||
## Installation-scoped generated cache
|
||||
|
||||
Generated data derived from a validated user source often belongs to the mod
|
||||
installation rather than to one Pokémon save. `mod.cache` is that namespace:
|
||||
|
||||
```lua
|
||||
local ok, err = mod.cache:write("extract/v1/arena.bin", encodedArena)
|
||||
local bytes, err = mod.cache:read("extract/v1/arena.bin")
|
||||
local info = mod.cache:info("extract/v1/arena.bin")
|
||||
mod.cache:delete("extract/v1/arena.bin")
|
||||
```
|
||||
|
||||
The physical root is engine-owned (`mod_cache/<mod-id>/`) and never exposed to
|
||||
the mod. Keys are safe relative paths and a single write is capped at 64 MiB.
|
||||
The cache does not rewind with checkpoints and is not scoped to game version,
|
||||
slot, or playthrough. The mod owns its generated format, fingerprints, rebuild
|
||||
policy, and completion marker; the engine treats the bytes as opaque data.
|
||||
|
||||
Use `mod.storage` instead when the data belongs to one playthrough. Use
|
||||
`mod.cache` when it is a reproducible installation artifact that can be rebuilt
|
||||
from a declared user source.
|
||||
|
||||
## Durable tool storage and runtime checkpoints
|
||||
|
||||
`mod.save` remains the right place for state that should travel with the next
|
||||
@@ -633,17 +921,51 @@ the selected indices. Mods remain responsible for selection policy and should
|
||||
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
|
||||
contract and compatibility guarantees.
|
||||
|
||||
Both battle engines expose the guarded `battle.charge_required` hook when a
|
||||
charge-capable move is selected for its initial turn and the active ruleset
|
||||
would otherwise charge it. The wrapper receives `(next, ctx)`, where `ctx` is
|
||||
`{ battle, user, target, move, charge = true, isCalled }`. Return `false` to
|
||||
skip only that initial charge and continue through the ordinary move pipeline;
|
||||
call `next(ctx)` to keep it. The hook does not run for the release turn or when
|
||||
the active ruleset already skips charging (for example, Gold Solarbeam in
|
||||
sun). PP use, accuracy, damage, animation, and secondary effects remain owned
|
||||
by the engine. With no subscriber, the vanilla decision runs without building
|
||||
the hook context.
|
||||
|
||||
## Developer console
|
||||
|
||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass
|
||||
`--developer` on the command line:
|
||||
On a Gen 1 boot, developer mode unlocks the in-game console and hot-reload
|
||||
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass `--developer`
|
||||
on the command line:
|
||||
|
||||
```sh
|
||||
love . --developer
|
||||
```
|
||||
|
||||
While developer mode is active:
|
||||
The mod loader independently derives a matching boolean for every sandboxed
|
||||
entry chunk as `mod.developer`. It is available while the entry file is
|
||||
loading, so a mod can keep diagnostic commands, screens, and verbose tracing
|
||||
out of player builds:
|
||||
|
||||
```lua
|
||||
if mod.developer then
|
||||
mod.commands:register("my_mod:diagnostics", function(ctx)
|
||||
-- open or print this mod's diagnostic view
|
||||
end)
|
||||
end
|
||||
```
|
||||
|
||||
`mod.developer` is a plain boolean snapshot for this boot. It grants no
|
||||
permission and exposes neither the process environment nor the loader. In a
|
||||
normal player boot it is `false`; `POKEPORT_DEV=1` and `--developer` make it
|
||||
`true`. On Gen 1 those inputs separately enable the console and hot-reload
|
||||
hotkeys. The headless loader's `opts.dev` test seam changes only the loader
|
||||
signal and diagnostics; it does not enable the game's console or hot reload.
|
||||
Gold exposes the same `mod.developer` boolean but does not implement the Gen 1
|
||||
console or hotkeys. Use a mod option for player-facing feature toggles rather
|
||||
than treating developer mode as configuration.
|
||||
|
||||
While developer mode is active on Gen 1:
|
||||
|
||||
- `` ` `` (backtick) opens the console overlay — a Lua REPL with `game`,
|
||||
`data` and `mods` in scope. Press `` ` `` again to close it.
|
||||
@@ -754,7 +1076,7 @@ contract, so a mod does not need a desktop-specific rendering path.
|
||||
|
||||
`render.output_enabled` and `render.output` are the later, whole-window seam
|
||||
for mods that need the engine's normal composite rather than its separate
|
||||
layers. It runs after registered present pipelines and before GBCFX,
|
||||
layers. It runs after registered present pipelines and before ShaderFX,
|
||||
`render.hud`, and touch controls. A mod wraps both hooks: the first returns
|
||||
`true` only while output ownership is needed, and the second receives
|
||||
`(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`,
|
||||
@@ -805,6 +1127,47 @@ 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.
|
||||
|
||||
## Battle field residual hook
|
||||
|
||||
`battle.field_residual` lets a Gen 1 battle-rule mod request end-of-round
|
||||
damage without mutating live battlers. It is guarded and runs after vanilla
|
||||
status residuals, before field-token expiry and `battle.turn_ended`. The
|
||||
wrapper receives `(next, context)`, calls `next(context)` for the existing
|
||||
descriptor list, and appends data-only rows:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("battle.field_residual", function(next, context)
|
||||
local rows = next(context)
|
||||
rows[#rows + 1] = {
|
||||
side = "enemy", amount = 7,
|
||||
message = context.battlers.enemy.name .. " is buffeted!",
|
||||
}
|
||||
return rows
|
||||
end)
|
||||
```
|
||||
|
||||
`context.field` is a detached, data-only view with the same
|
||||
`{ weather, tokens }` shape that battle checkpoints capture; it does not expose
|
||||
`field.sides` or any live battler aliases. The projection recursively retains
|
||||
raw tables and finite numbers, strings, and booleans under scalar keys. It
|
||||
strips metatables and omits functions, userdata, threads, unsupported keys, and
|
||||
cyclic edges. Consequently a wrapper cannot obtain or invoke an engine callback
|
||||
even if a live field token uses one internally, and changing any nested view
|
||||
value cannot change live field state. `context.battlers.player` and `.enemy` are
|
||||
detached `{ side, name, hp, maxHp, types, vanished }` views, and `context.turn`
|
||||
is the current turn number. A descriptor accepts `side`
|
||||
(`player` or `enemy`), a positive, finite integer number `amount`, and an
|
||||
optional string `message`.
|
||||
Numeric strings and invalid rows are ignored; damage is clamped to current HP.
|
||||
The engine retains HP-bar, faint, experience, and replacement authority. If
|
||||
both active battlers take terminal residual damage together and the player has
|
||||
no healthy reserve, this hook batch queues only the player faint authority and
|
||||
resolves as a blackout loss without an enemy-faint EXP award or replacement.
|
||||
That precedence is local to accepted rows from this hook; native faint paths
|
||||
are unchanged when no hook is active. Hook callbacks remain process-local;
|
||||
checkpoints serialize only field data. Gold does not yet raise this hook; its
|
||||
native weather pipeline is documented in `docs/mod-api-gen2-compat.md`.
|
||||
|
||||
## Process-lifecycle hooks
|
||||
|
||||
These exist so a platform-specific launcher integration (a native shell
|
||||
|
||||
@@ -342,6 +342,7 @@ accepted and merged as-is.
|
||||
| `source` | string |
|
||||
| `swarmGrass` | map of string -> {map?, rates, slots} |
|
||||
| `swarmWater` | map of string -> {map?, rate, slots} |
|
||||
| `timeFishGroups` | map of string | integer 0..255 -> {day, nite} |
|
||||
| `treeSets` | map of string -> {common, rare} |
|
||||
| `trees` | map of string -> string |
|
||||
| `water` | map of string -> {map?, rate, slots} |
|
||||
@@ -509,6 +510,26 @@ mod.content.item_effects:register("MOON_FLUTE", { use = fn, field = true })
|
||||
mod.content.items:patch("POTION", { price = 100 })
|
||||
```
|
||||
|
||||
### On Gold (Gen 2)
|
||||
|
||||
- semantics: `record`
|
||||
- target: `Data.items`
|
||||
|
||||
The record differs; the registry name, the verbs and the id space
|
||||
do not.
|
||||
|
||||
| field | type | required |
|
||||
|---|---|---|
|
||||
| `ball` | balls id | no |
|
||||
| `effect` | item_effects id | no |
|
||||
| `id` | string | yes |
|
||||
| `index` | integer 0..255 | no |
|
||||
| `machine` | {kind, move, number} | no |
|
||||
| `name` | string | yes |
|
||||
| `needsTarget` | boolean | no |
|
||||
| `price` | integer >= 0 | yes |
|
||||
| `tossable` | boolean | no |
|
||||
|
||||
## landmarks
|
||||
|
||||
- semantics: `record`
|
||||
@@ -598,7 +619,7 @@ mod.content.map_songs:override("PALLET_TOWN", "Music_Routes1")
|
||||
| `id` | string | yes |
|
||||
| `index` | integer >= 0 | no |
|
||||
| `label` | string | no |
|
||||
| `objects` | list of any value | no |
|
||||
| `objects` | list of {pokemon?, ...} | no |
|
||||
| `palette` | string | no |
|
||||
| `signs` | list of any value | no |
|
||||
| `tileset` | tilesets id | yes |
|
||||
@@ -673,6 +694,34 @@ mod.content.move_effects:register("DRAIN_PP_EFFECT", { kind = "primary", run = f
|
||||
mod.content.moves:patch("BLIZZARD", { accuracy = 70 })
|
||||
```
|
||||
|
||||
### On Gold (Gen 2)
|
||||
|
||||
- semantics: `record`
|
||||
- target: `Data.moves`
|
||||
|
||||
The record differs; the registry name, the verbs and the id space
|
||||
do not.
|
||||
|
||||
| field | type | required |
|
||||
|---|---|---|
|
||||
| `accuracy` | integer 0..100 | yes |
|
||||
| `anim` | any value | no |
|
||||
| `category` | one of "physical" | "special" | "status" | no |
|
||||
| `chargeText` | string | no |
|
||||
| `counterable` | boolean | no |
|
||||
| `effect` | move_effects id | yes |
|
||||
| `fixedDamage` | integer >= 1 | function | no |
|
||||
| `highCrit` | boolean | no |
|
||||
| `id` | string | yes |
|
||||
| `index` | integer 0..255 | no |
|
||||
| `multiHit` | integer >= 1 | list of integer >= 1 | no |
|
||||
| `name` | string | yes |
|
||||
| `power` | integer 0..255 | yes |
|
||||
| `pp` | integer 0..64 | yes |
|
||||
| `priority` | integer -7..7 | no |
|
||||
| `semiInvulnerable` | boolean | no |
|
||||
| `type` | type_chart id | yes |
|
||||
|
||||
## music
|
||||
|
||||
- semantics: `record`
|
||||
|
||||
@@ -14,9 +14,14 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
||||
* **Screen position setting** (center, upper, top) shared across all games, for clamp-on controllers that cover the lower screen
|
||||
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
|
||||
* **Pokédex diploma and printer image exports**
|
||||
* **Shareable mod lists** over save sync, optionally carrying the options set for those mods, which the receiving device is asked about before anything is changed
|
||||
* **Custom carts**, a named mod set saved from the mods tab and picked from a game's page, with its own shell colour, label art, save slots and export file
|
||||
* **Install required mods**, one press on a cart that will not start, fetching every pinned mod at the pinned version and refusing any archive whose hash is not the one the cart recorded
|
||||
* **Browse carts in Find mods**, a Mods / Carts switch on the same community index, searched and filtered by base game, installing the cart file straight into that game's cart list
|
||||
|
||||
## Gen 2 Specifics
|
||||
|
||||
* **Pokémon Silver** as an importable, launcher-selectable version alongside Gold
|
||||
* **Pokémon Crystal** as an importable, launcher-selectable version alongside Gold and Silver
|
||||
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
|
||||
* **Followers** for mods, plus Gen 2-only registries and hooks
|
||||
|
||||
@@ -176,7 +176,7 @@ something the filesystem encodes.
|
||||
|
||||
| token | means |
|
||||
| --- | --- |
|
||||
| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"` | that one game (a version id from `GameVersion.ORDER`) |
|
||||
| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"`, `"crystal"` | that one game (a version id from `GameVersion.ORDER`) |
|
||||
| `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) |
|
||||
| `"all"` | every game this engine has |
|
||||
|
||||
@@ -768,7 +768,8 @@ the same:
|
||||
So: read the log for coverage problems, and the manager for load problems.
|
||||
|
||||
`POKEPORT_IDENTITY=<name>` sandboxes the save directory if you want a clean
|
||||
profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload.
|
||||
profile to test in. `POKEPORT_DEV=1` makes `mod.developer` true for loader-gated
|
||||
diagnostics; Gold does not add Gen 1's console or `F5` hot reload.
|
||||
|
||||
## What this guide does not promise
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# RFC 0008 — Streamed mod imports and installation-scoped generated cache
|
||||
|
||||
## Motivation
|
||||
|
||||
`required_imports`/`optional_imports` can now describe files up to 2 GiB, but
|
||||
the existing launcher and public mod API still assume imported bytes are small:
|
||||
|
||||
* the Windows desktop picker stages a selected required import through a fixed
|
||||
`%TEMP%/pokeport_required_import.bin` path before validation;
|
||||
* the fallback import path materializes the selected file as one Lua string;
|
||||
* after validation a mod can only use `mod:read("baseroms/...")`, which also
|
||||
materializes the whole file;
|
||||
* `mod.storage` is intentionally scoped to one Pokémon playthrough, so it is
|
||||
not an appropriate home for a one-time generated asset cache shared by every
|
||||
save using the same installed mod.
|
||||
|
||||
This makes optical-disc-sized user sources impractical even though the manifest
|
||||
schema already accepts them. A failed temporary staging copy can also turn a
|
||||
valid large source into a smaller temporary file and produce a misleading
|
||||
"wrong file size" rejection.
|
||||
|
||||
A mod should be able to consume its own already-validated source incrementally
|
||||
and compile derived runtime data once, without receiving a host path or general
|
||||
filesystem access.
|
||||
|
||||
## Decision being extended
|
||||
|
||||
This extends the same legal/sandbox direction as **D11 asset transforms**
|
||||
(`src/mods/AssetTransform.lua`): mods distribute recipes and derive bytes from
|
||||
user-owned sources rather than shipping ROM-derived data. It also follows the
|
||||
**D14 parity-gate** contract referenced by `tests/harness.lua` and
|
||||
`tests/engine/gate_meta_coverage.lua` (the `21-testing-and-ci` plan): additive
|
||||
extension points ship public-API coverage, no-mod parity coverage, and docs in
|
||||
the same change.
|
||||
|
||||
The historical D11 plan document is referenced by source comments but is not
|
||||
present in the current repository tree; this RFC is the checked-in design
|
||||
record for the new surface.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
No manifest field changes. Existing `required_imports` and `optional_imports`
|
||||
remain the declaration/validation authority.
|
||||
|
||||
Two additive facades are added to the `mod` object.
|
||||
|
||||
### `mod.imports`
|
||||
|
||||
```lua
|
||||
local info, err = mod.imports:info("source_id")
|
||||
local bytes, err = mod.imports:read("source_id", offset, length)
|
||||
```
|
||||
|
||||
* `source_id` must name an import declared by the calling mod.
|
||||
* the import is rechecked through `RequiredImports.validateStored` before it is
|
||||
exposed, so missing, replaced, or invalid optional imports are not readable;
|
||||
* `offset` and `length` are zero-based byte coordinates;
|
||||
* one read is capped at 8 MiB;
|
||||
* no host path or file handle is returned;
|
||||
* production reads seek into the engine-owned stored copy instead of reading
|
||||
the whole source.
|
||||
|
||||
`info()` returns declaration metadata plus stored size. It does not expose a
|
||||
host path.
|
||||
|
||||
### `mod.cache`
|
||||
|
||||
```lua
|
||||
mod.cache:write("extract/v1/model.bin", bytes)
|
||||
local bytes = mod.cache:read("extract/v1/model.bin")
|
||||
local info = mod.cache:info("extract/v1/model.bin")
|
||||
mod.cache:delete("extract/v1/model.bin")
|
||||
```
|
||||
|
||||
The cache is rooted at `mod_cache/<mod-id>/`, follows the engine persistence
|
||||
backend, and is independent of game version, launcher slot, and playthrough.
|
||||
Paths are checked with `SafePath`; `..`, absolute paths, drive paths, and other
|
||||
escapes remain unavailable. A single cache write is capped at 64 MiB so large
|
||||
generated datasets are naturally split into independently replaceable files.
|
||||
|
||||
The engine does not interpret cache bytes. Mods own generated-format versioning,
|
||||
fingerprints, transactional completion markers, and rebuild policy.
|
||||
|
||||
## Launcher/import transport delta
|
||||
|
||||
For large raw required imports:
|
||||
|
||||
1. desktop pickers return the original selected path instead of staging it
|
||||
through a fixed temporary file;
|
||||
2. the engine opens that source itself;
|
||||
3. bytes are copied directly to the existing engine-owned
|
||||
`mods/<id>/baseroms/<file>` destination in 4 MiB chunks;
|
||||
4. MD5 is updated incrementally during the copy;
|
||||
5. the normal size/MD5 validation receipt is written only after the complete
|
||||
destination passes validation;
|
||||
6. partial destinations are removed on short reads, write failure, size
|
||||
mismatch, or digest mismatch.
|
||||
|
||||
N64 imports stay on the existing canonicalization path because byte-order and
|
||||
copier-header normalization require transformation rather than a raw copy.
|
||||
|
||||
If a validation receipt for an already-stored large raw import is missing, the
|
||||
engine rebuilds it with streaming MD5 rather than a whole-file read.
|
||||
|
||||
## Backward compatibility / migration
|
||||
|
||||
**Existing mods do nothing.** This is additive.
|
||||
|
||||
* manifest v1/v2 fields are unchanged;
|
||||
* `mod:read`, `mod.storage`, registries, events, hooks, and legacy compatibility
|
||||
retain their existing behavior;
|
||||
* small required imports retain the existing in-memory validation path;
|
||||
* N64 imports retain canonicalization and existing accepted byte orders;
|
||||
* a mod that never touches `mod.imports` or `mod.cache` creates no new cache
|
||||
files and observes no new behavior.
|
||||
|
||||
The mod API integer is not bumped because no existing member changes meaning or
|
||||
shape.
|
||||
|
||||
## Security and legal posture
|
||||
|
||||
The launcher remains the authority that validates user-supplied bytes. The new
|
||||
facade narrows access rather than widening it: a mod can read only ids declared
|
||||
in its own manifest, only after validation, and only in bounded ranges. It does
|
||||
not receive host paths, `io`, or a raw filesystem handle.
|
||||
|
||||
`mod.cache` is writable only beneath the calling mod's generated-cache root.
|
||||
Nothing in this RFC permits packaged ROM-derived bytes; `modkit lint/pack`
|
||||
continue to enforce the existing legal posture.
|
||||
|
||||
## Parity guarantee
|
||||
|
||||
The change ships with:
|
||||
|
||||
* a no-mod/API-v1 parity test proving an empty load and an existing v1-style
|
||||
`mod:read` load do not create cache data or change the old surface;
|
||||
* a public mod-API test that reaches `mod.imports` and `mod.cache` through a
|
||||
real `Loader` load, including bounded reads, undeclared/missing imports,
|
||||
cache isolation, and traversal rejection;
|
||||
* incremental MD5 vectors and a large-import streaming regression test;
|
||||
* the existing engine suite, required-import suite, and mod lint gates.
|
||||
@@ -0,0 +1,92 @@
|
||||
# RFC 0011: Charge-required battle hook
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
A battle-mechanics mod can change damage through `battle.damage` and register
|
||||
move effects, but it cannot conditionally skip the first turn of an existing
|
||||
charge move. In Gen 1, the engine decides and stores the charge continuation
|
||||
before any public effect callback can run. Reaching into `user.charging`,
|
||||
`user.chargeReady`, or generation-specific volatile state is private,
|
||||
checkpoint-fragile, and would require a mod to duplicate move-pipeline policy.
|
||||
|
||||
Weather is the immediate example: a portable sun rule needs Solarbeam to
|
||||
resolve on selection while leaving Fly, Dig, PP use, hit resolution, animation,
|
||||
and secondary effects to the engine. The capability is generic and useful to
|
||||
other ruleset and move-mechanics mods.
|
||||
|
||||
## Decision and plan extended
|
||||
|
||||
This implements **D-AT-002: charge-stage policy remains mod authority through a
|
||||
generic guarded engine decision seam**. The consuming design is tracked in the
|
||||
Adaptive Trainers implementation plan,
|
||||
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
|
||||
Task 8. The delta follows the additive, guarded hook convention documented by
|
||||
Route B in `CONTRIBUTING-mods.md`; it contains no weather, move-id, trainer, or
|
||||
Adaptive Trainers policy.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
Both the Gen 1 and Gen 2 battle engines add this guarded hook:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("battle.charge_required", function(next, ctx)
|
||||
-- ctx = {
|
||||
-- battle = live battle controller,
|
||||
-- user = attacking battler,
|
||||
-- target = defending battler,
|
||||
-- move = merged move record,
|
||||
-- charge = true,
|
||||
-- isCalled = false,
|
||||
-- }
|
||||
if should_resolve_now(ctx) then return false end
|
||||
return next(ctx)
|
||||
end)
|
||||
```
|
||||
|
||||
The call site is the initial-use charge decision, after announcement and PP
|
||||
handling but before charge state, invulnerability, charge animation, or charge
|
||||
text is created. It runs only when the active engine rules would otherwise
|
||||
require a charge. It does not run on the release turn. Returning exactly
|
||||
`false` skips that initial charge and continues through the engine-owned move
|
||||
pipeline. Any other downstream return preserves the charge. `isCalled` is true
|
||||
when Metronome or Mirror Move selected the move.
|
||||
|
||||
Gold keeps its native sun decision first, so Solarbeam in native sun already
|
||||
requires no charge and does not invoke the hook. Gen 1 link battles use the
|
||||
shared Gen 1 move pipeline and therefore receive the same seam; normal link
|
||||
mod-compatibility rules continue to govern deterministic peers.
|
||||
|
||||
The hot path first calls `Runtime.wantsHook("battle.charge_required")`. With no
|
||||
subscriber, no hook payload table is allocated and the existing branch runs
|
||||
unchanged.
|
||||
|
||||
## Migration and compatibility
|
||||
|
||||
Existing mods change nothing. The hook name and payload are additive. With no
|
||||
wrapper installed, Red, Blue, Yellow, Gold, and Silver retain their previous
|
||||
charge state, PP use, text, animation, accuracy, damage, and native weather
|
||||
behavior. Existing charge-move data and effect records require no migration.
|
||||
|
||||
A mod adopting the seam should call `next(ctx)` unless it deliberately wants to
|
||||
skip this charge. It should not mutate private charge fields or re-run the move.
|
||||
|
||||
## Verification
|
||||
|
||||
- `tests/engine/battle_charge_required.lua` exercises the real Gen 1 and Gen 2
|
||||
engines through a sandboxed public mod, including false-to-skip, next-to-keep,
|
||||
release-turn behavior, called-move PP semantics, shared payload shape, and
|
||||
native Gold sun behavior.
|
||||
- The same test proves no-mod charge/release parity and replaces
|
||||
`Runtime.call` with a sentinel behind a false `Runtime.wantsHook` guard.
|
||||
- `tests/engine/gate_hooks.lua` discovers the new catalog name and proves empty
|
||||
chains preserve vanilla values and allocation behavior.
|
||||
- `tests/engine/gate_gen2_mod_api.lua` requires a guarded site in both
|
||||
generations and keeps the compatibility reference list complete.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is removed, renamed, superseded, or deprecated.
|
||||
@@ -0,0 +1,140 @@
|
||||
# RFC 0012: `applyShare`'s announce argument on Gen 2
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
`battle.exp_award` hands a mod `ctx.applyShare(mon, split, announce)` on both
|
||||
generations. On Gen 1 the third argument decides whether the mon's GainedText
|
||||
box is printed (`src/battle/BattleState.lua`, `if announce then`), which is how
|
||||
a mod that pays the whole party prints **one** summary line instead of a box
|
||||
per recipient.
|
||||
|
||||
Gold accepts the argument and ignores it, as its own comment above the hook
|
||||
call says. So the same mod source, running the same code, prints one line on
|
||||
Red and six on Gold — one for the participant plus one for every bench mon it
|
||||
paid.
|
||||
|
||||
This is not hypothetical. The [Exp Share](https://github.com/ShaneMcGovernIE/exp_share)
|
||||
mod declares `"games": ["gen1", "gen2"]` and its description promises "a single
|
||||
shared-exp line instead of one message per Pokemon". It passes `true` for the
|
||||
fighters and `nil` for the bench, exactly as the Gen 1 seam asks. On Gold every
|
||||
one of those `nil` calls announces anyway, so a five-mon party turns every KO
|
||||
into six boxes to click through.
|
||||
|
||||
There is no mod-side fix. The announcement is emitted inside
|
||||
`Battle:giveExperiencePass`, behind no hook, and a mod cannot ask for silence
|
||||
because the argument that means "quietly" is discarded. The only workaround is
|
||||
to intercept the battle's event queue afterwards and delete the boxes, which is
|
||||
what a mod written for this had to do — a mod reaching into engine internals to
|
||||
undo something the public seam should never have done.
|
||||
|
||||
## Decision and plan extended
|
||||
|
||||
This does not add a seam. It finishes one: `battle.exp_award` is documented as
|
||||
"the same hook `BattleState:awardExp` calls on Gen 1 and with the same ctx",
|
||||
and `docs/mod-api-gen2-compat.md` lists it among the hooks shared with Gen 1.
|
||||
The third `applyShare` argument is the one part of that ctx whose meaning did
|
||||
not survive the crossing, so the promise the catalog already makes is what this
|
||||
change delivers.
|
||||
|
||||
The delta follows Route B's additive, guarded convention in
|
||||
`CONTRIBUTING-mods.md`: nothing is renamed, nothing is removed, and no mod that
|
||||
exists today changes behaviour.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
`ctx.applyShare(mon, split, announce)` on Gen 2 now reads `announce`:
|
||||
|
||||
| Call | Gen 1 | Gen 2 before | Gen 2 after |
|
||||
|---|---|---|---|
|
||||
| `applyShare(mon, split)` | silent | announces | **announces** (unchanged) |
|
||||
| `applyShare(mon, split, nil)` | silent | announces | **silent** |
|
||||
| `applyShare(mon, split, false)` | silent | announces | **silent** |
|
||||
| `applyShare(mon, split, true)` | announces | announces | announces |
|
||||
| `applyShare(mon, split, "expAll")` | announces | announces | announces |
|
||||
|
||||
The argument is honoured **only when it is actually passed**, decided by
|
||||
argument count rather than by value:
|
||||
|
||||
```lua
|
||||
local function applyShare(mon, split, ...)
|
||||
local announce = ...
|
||||
local silent = select("#", ...) > 0 and not announce
|
||||
...
|
||||
end
|
||||
```
|
||||
|
||||
`select("#", ...)` counts an explicit `nil`, so `applyShare(mon, split)` and
|
||||
`applyShare(mon, split, nil)` are distinguishable — and they have to be, because
|
||||
the first is a Gen 2-era call written against a seam that always announced, and
|
||||
the second is a deliberate "pay this one quietly".
|
||||
|
||||
Only the `{ kind = "experience" }` event is affected. A silent award is still a
|
||||
whole award: the exp, the stat exp, the `battle.exp_gained` event, the
|
||||
`grew to level` line, learned moves and the interactive forget-a-move prompt all
|
||||
happen exactly as before, in the same order.
|
||||
|
||||
Internally `Battle:giveExperiencePass` takes a sixth parameter, `silent`. It
|
||||
defaults to announcing, so both of the cart's own passes are untouched.
|
||||
|
||||
## Migration and compatibility
|
||||
|
||||
**Existing mods change nothing.** A Gen 2 mod calling `applyShare(mon, split)`
|
||||
gets the behaviour it was written against. A Gen 1 mod is untouched: no Gen 1
|
||||
file is modified. The v1 surface — `content.X:register/override/get`,
|
||||
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields and
|
||||
`pokemon.before_give` — is not involved; `mods/example_mew_starter` neither
|
||||
calls this seam nor loads differently.
|
||||
|
||||
A mod that wants parity passes the argument explicitly, which is what the Gen 1
|
||||
seam has always documented. Exp Share already does, and needs no edit to get
|
||||
its own README's behaviour on Gold.
|
||||
|
||||
One residual difference is deliberate and now documented rather than silent: an
|
||||
**omitted** third argument still means "silent" on Gen 1 and "announce" on
|
||||
Gold. Closing that would change what an existing Gen 2 mod prints, which Route
|
||||
B rejects. Passing the argument makes the two generations agree, so the rule an
|
||||
author needs is one sentence: *say what you mean and both games do the same
|
||||
thing.*
|
||||
|
||||
## Verification
|
||||
|
||||
- `tests/gen2_exp_share_test.lua` grows two sections and 14 checks, and the 23
|
||||
checks it already had are unchanged — which is itself the vanilla-parity
|
||||
evidence for this file.
|
||||
- **The no-mod test.** With nothing subscribed to `battle.exp_award`, a solo
|
||||
participant still prints one line and the EXP.SHARE double pass still
|
||||
prints both. The hot path is unchanged: `Runtime.wantsHook` still guards
|
||||
the ctx allocation, and `vanillaAward` never passes `silent`.
|
||||
- **The mod-API test.** The seam is driven through `hooks:wrap` on a real
|
||||
`Runtime.install`ed bus, not by calling internals: the omitted argument
|
||||
announces, an explicit `nil` and an explicit `false` are silent, a truthy
|
||||
value (including Gen 1's `"expAll"`) announces, the exp and stat exp paid
|
||||
are identical either way, and no `experience` event leaks into the queue on
|
||||
a silent pass.
|
||||
- `tests/engine/gate_gen2_mod_api.lua` (943 checks),
|
||||
`tests/engine/gate_hooks.lua` (493) and `tests/engine/gate_events.lua` (529)
|
||||
pass unchanged; `battle.exp_award` was already in the shared catalog, so no
|
||||
gate list moves.
|
||||
- `tests/gen2_battle_test.lua` (690), `gen2_battle_end_test.lua` (32),
|
||||
`gen2_battle_items_test.lua` (99), `gen2_badge_boosts_test.lua` (34) and
|
||||
`gen2_battle_loss_test.lua` (15) pass unchanged.
|
||||
|
||||
## Docs with the change
|
||||
|
||||
`docs/mod-api-gen2-compat.md` gains the `applyShare` reading beside the
|
||||
existing `battle.low_health_alarm` payload note, in the same section that lists
|
||||
`battle.exp_award` as shared — including the argument-count rule and the
|
||||
residual difference above.
|
||||
|
||||
No registry or schema field changes, so `src/mods/Schemas.lua` is untouched and
|
||||
`tools/gen_registry_docs.lua` has nothing new to emit.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is removed, renamed, superseded or deprecated. The two-argument call is
|
||||
not deprecated either — it keeps its current Gen 2 meaning permanently, and the
|
||||
docs name the explicit form as the one that behaves the same on both games.
|
||||
@@ -0,0 +1,142 @@
|
||||
# RFC 0013: Conditional map occupancy and active-block reads
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
The Gen 1 Vermilion Dock script correctly ejects a player who enters after the
|
||||
S.S. Anne has departed. A content mod can add a city-side route back to that
|
||||
empty harbor, but it cannot preserve the dock visit: replacing the complete
|
||||
dock handler would discard vanilla behavior and peer handlers, while an added
|
||||
handler cannot cancel the base handler's ejection.
|
||||
|
||||
A mod that changes one active map block also needs to prove that it is looking
|
||||
at the expected Red, Blue, or Yellow layout before it acts. `mapOverview()` is
|
||||
intentionally presentation-oriented and does not expose block identity.
|
||||
Requiring internal `Map` state or generated ROM data would cross the public mod
|
||||
boundary and make a wrong-version edit difficult to fail closed.
|
||||
|
||||
## Decision and plan extended
|
||||
|
||||
This extends Route B in `CONTRIBUTING-mods.md`: new behavior is additive,
|
||||
ordinary hook composition remains the authority, an empty hook chain is a
|
||||
provable no-op, and mods receive copied or scalar data rather than mutable
|
||||
engine state. It supports the approved Mew-under-the-truck implementation plan
|
||||
without adding any Mew-specific rule, asset, flag, or content to the engine.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
### `map.occupancy_allowed`
|
||||
|
||||
The post-departure `VERMILION_DOCK` script calls this hook after it replaces the
|
||||
ship blocks with water and immediately before it would display the departure
|
||||
message and warp the player to Vermilion City.
|
||||
|
||||
A wrapper has this shape:
|
||||
|
||||
```lua
|
||||
function(next, game, context) -> boolean
|
||||
```
|
||||
|
||||
The context is a new table with these fields:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `mapId` | `"VERMILION_DOCK"` at this call site |
|
||||
| `reason` | Stable reason key `"ss_anne_departed"` |
|
||||
| `gameVersion` | Active save version (`red`, `blue`, or `yellow`) when present |
|
||||
| `x`, `y` | Current player cell coordinates when present |
|
||||
|
||||
Vanilla returns `false`. The player remains only when the final chain result is
|
||||
exactly `true`. Absent, throwing, or malformed wrappers therefore preserve
|
||||
ejection. A wrapper composes by calling `next(game, context)` and returning
|
||||
true when either downstream or its own narrow rule permits occupancy. The hook
|
||||
does not replace `MapScripts` registration, merging, or dispatch, and does not
|
||||
change the departure flag or reconstruct the ship.
|
||||
|
||||
As with every wrapper hook, a callback that does not call `next` intentionally
|
||||
owns the final answer and does not run lower-priority callbacks. Permission
|
||||
wrappers must call downstream to compose. A false, non-forwarding wrapper
|
||||
safely denies occupancy and can suppress downstream permission by this normal
|
||||
rule. A malformed final result also fails closed and cannot permit occupancy.
|
||||
|
||||
The call is guarded by `Runtime.wantsHook`, so an empty chain allocates no
|
||||
context and follows the prior branch exactly.
|
||||
|
||||
### `WorldAPI:activeBlockAt`
|
||||
|
||||
Gen 1's public `mod.world` facade adds:
|
||||
|
||||
```lua
|
||||
activeBlockAt(mapId, blockX, blockY) -> blockId
|
||||
| nil, reason
|
||||
```
|
||||
|
||||
`mapId` must equal the active map ID. Coordinates are finite, integral,
|
||||
zero-based block coordinates. A successful result is a numeric scalar copied
|
||||
from the active runtime map. The method never returns the map's mutable block
|
||||
array and never writes game or save state.
|
||||
|
||||
Failure reasons are stable:
|
||||
|
||||
| Condition | Reason |
|
||||
|---|---|
|
||||
| No active overworld map | `no overworld` |
|
||||
| `mapId` differs from the active map | `map is not active` |
|
||||
| Coordinate has the wrong type, is non-finite, or is fractional | `invalid block coordinates` |
|
||||
| Coordinate is negative or outside the active map | `block coordinates out of bounds` |
|
||||
| Active block data is absent or malformed | `block unavailable` |
|
||||
|
||||
The block slot and the active map accessor must both contain the same valid
|
||||
nonnegative integer. Missing or sparse storage and inconsistent accessor data
|
||||
return `block unavailable`.
|
||||
|
||||
Requiring the expected map ID and rejecting all ambiguous input lets a mod
|
||||
compare every cell in its version-specific signature before it calls an
|
||||
existing mutation API. Red, Blue, and Yellow each use their own loaded map
|
||||
data. Gold does not gain this method in this RFC.
|
||||
|
||||
## Migration and compatibility
|
||||
|
||||
Existing mods change nothing. No hook, event, registry, manifest field, save
|
||||
field, map handler, or WorldAPI method is removed or renamed. With no hook
|
||||
subscriber the departed-dock behavior is unchanged. Existing callers cannot
|
||||
invoke the new WorldAPI method accidentally.
|
||||
|
||||
The occupancy answer is not persisted by the engine. Disabling or uninstalling
|
||||
a mod removes its wrapper through normal owner cleanup, so a later dock entry
|
||||
uses vanilla ejection. The engine writes no new save state and neither API
|
||||
returns or serializes ROM or save data.
|
||||
|
||||
## Verification requirements
|
||||
|
||||
The parity gate must prove:
|
||||
|
||||
- vanilla departed-dock message and warp remain with no subscriber;
|
||||
- one permission wrapper can allow occupancy without removing the ship-erasure
|
||||
work or any map handler;
|
||||
- multiple cooperative wrappers preserve downstream permission;
|
||||
- absent, throwing, nil, false, malformed, and non-forwarding hook answers fail
|
||||
closed according to the normal wrapper-chain rule;
|
||||
- Red, Blue, and Yellow contexts keep their version identity separate;
|
||||
- disabling or removing the owner restores vanilla behavior without a save
|
||||
migration;
|
||||
- `activeBlockAt` accepts only the active map and valid in-range integral
|
||||
coordinates, returns a scalar, and never mutates map or save state; and
|
||||
- the test fixtures and resulting changes contain no ROM or save payload.
|
||||
|
||||
The hook must be driven through a real public `hooks:wrap` chain. The block API
|
||||
must be exercised through a public `WorldAPI` instance. Tests must not replace
|
||||
the production map handler with a test-only implementation.
|
||||
|
||||
## Docs with the change
|
||||
|
||||
`docs/modding.md` documents both contracts, their failure behavior, the
|
||||
cooperative wrapper pattern, and the Gen 1-only block method. No registry or
|
||||
schema changes occur, so generated registry documentation is unchanged.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is removed, superseded, or deprecated.
|
||||
@@ -0,0 +1,158 @@
|
||||
# RFC 0014: Mod-driven world actors and adopted link sessions
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
A mod can already spawn a runtime object with `mod.world:spawnNpc` and can
|
||||
already start a link battle. It cannot make either of them behave like
|
||||
something the mod itself owns.
|
||||
|
||||
Three walls, each of which stops a mode rather than inconveniencing it:
|
||||
|
||||
**An actor cannot move on its own schedule.** The only public way to animate a
|
||||
spawned object is `Handle:scriptMove`, which queues onto
|
||||
`OverworldState.scriptMoves`. A non-empty `scriptMoves` is how the overworld
|
||||
knows a cutscene is running -- `handleInput` gates on it -- so anything
|
||||
animated that way freezes the player's controls for as long as it walks. That
|
||||
is correct for Oak marching into his lab and wrong for an ambient walker or a
|
||||
networked player's ghost, which move continuously and must not lock anyone out.
|
||||
|
||||
**A spawned object cannot answer the A press.** Talking is resolved from the
|
||||
map's text tables by `TEXT_*` id. A runtime object has no id, so the vanilla
|
||||
path has nothing to say for it, and the mod that created it has no way to say
|
||||
anything either.
|
||||
|
||||
**A link battle cannot ride a connection the mod already has.** `LinkState`
|
||||
owns pairing, so a mode that already has a socket to its peer must either open
|
||||
a second connection for the battle or reimplement lockstep. And when the
|
||||
battle ends, cable rules leave the real party untouched, so the damage exists
|
||||
only in the battle's own copies -- which are gone by the time the state
|
||||
unwinds. A mode built on link battles cannot learn what the fight cost.
|
||||
|
||||
The immediate consumer is an overworld multiplayer mode, but nothing here is
|
||||
specific to it: the first two are wanted by any mod with an actor that moves
|
||||
itself, and the third by any mode that runs battles over its own transport --
|
||||
a tournament ladder, a draft, a gauntlet.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
This adds nothing to the compatibility surface's shape; it extends the
|
||||
**additive, guarded seam convention** that Route B in `CONTRIBUTING-mods.md`
|
||||
documents, and is gated by the parity guarantee `tests/engine/gate_meta_coverage.lua`
|
||||
enforces ("21-testing-and-ci: a parity gate for every extension point"; M14).
|
||||
|
||||
There is no in-repo D-number registry to amend; the consuming design lives
|
||||
outside this repository, as it did for RFC 0011.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
### New hook: `world.talk`
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("world.talk", function(next, ow, target)
|
||||
-- ow = the OverworldState raising it
|
||||
-- target = the object on the faced cell
|
||||
if mine(target) then
|
||||
say(target)
|
||||
return -- the mod answered; the text path is skipped
|
||||
end
|
||||
return next(ow, target) -- anything else falls through unchanged
|
||||
end)
|
||||
```
|
||||
|
||||
Call site: `OverworldState:interact`, on the branch that has already resolved
|
||||
an object on the faced cell (including across a counter) and confirmed it is
|
||||
not mid-step and not the Pikachu follower. It runs before the map's text
|
||||
tables are consulted. With no subscriber, `Runtime.call` invokes the vanilla
|
||||
fallthrough, which is a file-local function rather than a per-press closure,
|
||||
so an unhooked A press allocates nothing it did not allocate before.
|
||||
|
||||
### New event: `link.battle_ended`
|
||||
|
||||
```lua
|
||||
mod.events:on("link.battle_ended", function(ev)
|
||||
-- ev = {
|
||||
-- result = "win" | "lose" | "draw" | "ended",
|
||||
-- myParty = lockstep copy of our party,
|
||||
-- theirParty = lockstep copy of theirs,
|
||||
-- peerName = string,
|
||||
-- role = "host" | "guest",
|
||||
-- }
|
||||
end)
|
||||
```
|
||||
|
||||
Call site: `LinkState:update`, in the `battleRunning` stage, once the battle
|
||||
state has been popped and before `exitWith` unwinds the link stack. Guarded by
|
||||
`Runtime.wants("link.battle_ended")`, so with no subscriber no payload table
|
||||
is allocated and the branch runs exactly as before.
|
||||
|
||||
The party copies are the point of the event. They are the lockstep records the
|
||||
battle actually fought with, which is where the damage lives under cable rules.
|
||||
|
||||
### New `WorldAPI` handle methods
|
||||
|
||||
```lua
|
||||
handle:stepNow(dir) --> true when the step started
|
||||
handle:canStep(dir) --> would stepNow land somewhere legal?
|
||||
handle:placeAt(x, y, dir) --> snap, no animation, clears a step in flight
|
||||
handle:isMoving() --> true while a step is animating
|
||||
handle:setPassable(flag) --> may the player walk through this object?
|
||||
```
|
||||
|
||||
`stepNow` sets the same per-tile state `scriptMove` does, without the queue and
|
||||
therefore without the input lockout. It does **not** consult collision: the
|
||||
intended caller is replaying a move already decided elsewhere (validated on a
|
||||
peer, or authored), and re-judging it locally would let two copies of the same
|
||||
actor disagree about where it is. `canStep` is the separate opinion for callers
|
||||
that want it. `setPassable` sets the flag `Collision.occupied` already honours
|
||||
for Yellow's companion Pikachu; a passable object still draws and still talks.
|
||||
|
||||
### Two supporting changes
|
||||
|
||||
`Game:startNewGame(opts)` -- the title screen's NEW GAME closure, made callable,
|
||||
with `opts.intro = false` to land directly in the world. A mode that issues its
|
||||
own starting state has no use for the intro cutscene.
|
||||
|
||||
`CodeEntry.new(shape)` -- accepts an optional `{ length =, charset = }`, so the
|
||||
slot-scrub widget that enters a link code can also carry a room code or a
|
||||
host:port address. Called with no argument it is byte-for-byte the previous
|
||||
widget.
|
||||
|
||||
## Migration and compatibility
|
||||
|
||||
**Existing mods change nothing.** Every item above is a new name or a new
|
||||
optional argument; no existing name, payload, signature, or default changes.
|
||||
|
||||
The v1 surface is unaffected: `content.X:register/override/get`, `events:on`,
|
||||
`hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields and
|
||||
`pokemon.before_give` all behave as before. `mods/example_mew_starter` -- api 1,
|
||||
`category = "GAMEPLAY"`, whole-species copy -- loads unchanged, which
|
||||
`tests/run_modkit.lua` proves on every run.
|
||||
|
||||
With no subscriber to either seam, Red, Blue, Yellow, Gold and Silver behave
|
||||
exactly as they did: the A press reaches `talkTo`, the link battle unwinds
|
||||
without building an event payload, and no handle method is reachable unless a
|
||||
mod calls it.
|
||||
|
||||
## Verification
|
||||
|
||||
- `tests/modkit/cases/world_talk.lua` drives the real `OverworldState:interact`
|
||||
path. It asserts the unhooked build first (the A press reaches `talkTo`),
|
||||
then that a mod owning the object suppresses the text path by not calling
|
||||
`next`, that an object the mod ignores still falls through, and that an
|
||||
object mid-step raises no hook at all.
|
||||
- `tests/modkit/cases/link_battle_ended.lua` drives the real `LinkState:update`
|
||||
path. It asserts that with nothing subscribed the event is not wanted (so no
|
||||
payload is built) and the battle still unwinds, then that a subscriber
|
||||
receives the result, the role, the peer name and both party copies including
|
||||
the damage the real party never took, from both sides of the cable.
|
||||
- `tests/engine/gate_hooks.lua` and `tests/engine/gate_events.lua` walk the live
|
||||
catalog, so both seams are parity-gated structurally; `gate_meta_coverage`
|
||||
passes 208/208 with both names covered and no DEBT entry added.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is removed, renamed, superseded or deprecated.
|
||||
@@ -0,0 +1,110 @@
|
||||
# RFC 0015: Battle rule hooks — `battle.style` and `catch.nickname`
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
Two decisions in a Red/Blue/Yellow battle are made for the player by the
|
||||
OPTION screen and by the cart, and a game mode has no way to make them
|
||||
instead:
|
||||
|
||||
**Whether a faint offers a free switch.** `EnemySendOutFirstMon` reads the
|
||||
battle-style bit: SHIFT asks "will you change POKéMON?" when the foe's Pokémon
|
||||
faints, SET does not. The engine reads `save.options.battleStyle` inline at
|
||||
that moment. A mode that wants SET — a tournament, a Nuzlocke, a battle royale
|
||||
where party-as-health is the whole design — can only get it by writing the
|
||||
player's saved preference, which leaks into their real playthrough the first
|
||||
time anything calls `Game:writeOptions` (the speed hotkey does, on every
|
||||
press).
|
||||
|
||||
**Whether a catch asks for a nickname.** `AddPartyMon` and `SendNewMonToBox`
|
||||
both run `AskName` after a capture. A mode with a disposable team, a
|
||||
randomizer that names what it hands out, a speedrun practice mod — all want to
|
||||
answer that prompt themselves, and the only way today is to drive the yes/no
|
||||
box from outside. The script-gift path already has this seam: a mod that sets
|
||||
`gift.nickname` on `pokemon.before_give` skips `AskName`. The catch path does
|
||||
not.
|
||||
|
||||
The immediate consumer is a battle-royale mode; neither hook is specific to it.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
This extends the **additive, guarded seam convention** Route B in
|
||||
`CONTRIBUTING-mods.md` documents, and is gated by the parity guarantee
|
||||
`tests/engine/gate_meta_coverage.lua` enforces. `catch.nickname` mirrors a
|
||||
contract that already exists for gifts (`pokemon.before_give`'s
|
||||
`gift.nickname`), so the two ways a Pokémon joins the party answer the same
|
||||
question the same way.
|
||||
|
||||
There is no in-repo D-number registry to amend.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
### New hook: `battle.style`
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("battle.style", function(next, battle)
|
||||
if myMode.active then return "set" end
|
||||
return next(battle) -- the OPTION row, as today
|
||||
end)
|
||||
```
|
||||
|
||||
Call site: `BattleState:battleStyle()`, called from the enemy send-out path at
|
||||
the moment the SHIFT prompt would be offered. The vanilla link reads
|
||||
`save.options.battleStyle` (lower-cased, default `"shift"`) exactly as the
|
||||
inline read did. A return of `"set"` or `"shift"` is used; anything else reads
|
||||
as the vanilla answer, so a hook that returns nothing by mistake cannot change
|
||||
the rule. The player's saved preference is never written.
|
||||
|
||||
### New hook: `catch.nickname`
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("catch.nickname", function(next, mon, ctx)
|
||||
-- ctx = { battle = <BattleState>, name = <display name>, game = <Game> }
|
||||
if myMode.active then return false end -- keep the species name
|
||||
if randomizer then return pickName(mon) end -- a string names it
|
||||
return next(mon, ctx) -- true: ask, as today
|
||||
end)
|
||||
```
|
||||
|
||||
Call site: `BattleState:offerNickname(mon, displayName)`, called from the
|
||||
capture path where `AskName` ran, before the prompt is queued. The vanilla
|
||||
link returns `true`. `false` skips the prompt and keeps the species name; a
|
||||
string skips the prompt and is the nickname, clipped to the naming grid's ten
|
||||
characters (an empty string names nothing, like the grid's own empty entry);
|
||||
anything else queues the prompt as today. The method returns whether a prompt
|
||||
was queued.
|
||||
|
||||
### No other surface changes
|
||||
|
||||
Both call sites are guarded by `Runtime.wantsHook`, and both vanilla links are
|
||||
file-local functions, so a build with nothing wrapped runs the branch exactly
|
||||
as before and allocates nothing it did not allocate before. `askNicknameUI` is
|
||||
unchanged and still public.
|
||||
|
||||
## Migration
|
||||
|
||||
Nothing changes for existing mods. A mod that was writing
|
||||
`save.options.battleStyle` to force a style should wrap `battle.style` instead
|
||||
and stop writing the option.
|
||||
|
||||
## Verification
|
||||
|
||||
- `tests/modkit/cases/battle_rule_hooks.lua` — through the public mod API: the
|
||||
no-mod answers match the OPTION row and the cart's AskName; a wrapped mod
|
||||
forces either style without the row being written; `false`, a string, a
|
||||
too-long string, an empty string, and a fall-through each do what this RFC
|
||||
says at the catch prompt.
|
||||
- `tests/engine/gate_hooks.lua` — both names are in the live catalog and pass
|
||||
the no-mod parity gate (vanilla called exactly once, result unchanged,
|
||||
nothing allocated).
|
||||
- `tests/engine/gate_meta_coverage.lua` — both names are covered by the unit
|
||||
corpus.
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
Additive. No existing hook, event, registry or manifest field changes shape.
|
||||
The two new methods on `BattleState` are called only from the paths that
|
||||
previously inlined their logic; the results with no subscriber are identical.
|
||||
@@ -0,0 +1,154 @@
|
||||
# RFC 0015: Read-only imported dataset views
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
A cross-version content mod can read only the active merged dataset through
|
||||
`mod.content`. Even when the player has already imported another supported
|
||||
game, a mod cannot inspect that version's semantic species, moves, items, type
|
||||
chart, or generated asset namespace. The available alternatives are private:
|
||||
mutating `CacheFs.prefix`, mounting another cache over the active one, loading
|
||||
generated Lua directly, or asking for a second raw-ROM import. They compose
|
||||
poorly with the active game, expose unstable import layout, and make safe
|
||||
read-only use impossible from the sandbox.
|
||||
|
||||
The concrete consumer is **Adaptive Trainers**, whose approved Phase G Kanto+
|
||||
sidecar must derive nine Kanto-line continuations, Steel/type and move
|
||||
definitions, and generated sprites from the player's existing verified Gold
|
||||
cache while Red, Blue, or Yellow remains active. `mod.content` exposes only the
|
||||
active R/B/Y dataset; `mod.imports` can read only separately declared raw mod
|
||||
imports; and `mod.cache` is mod-private generated output. None can inspect the
|
||||
launcher-owned Gold semantic dataset without a second ROM import and mod-side
|
||||
ROM interpretation. The engine API remains generic and contains no Adaptive
|
||||
Trainers policy.
|
||||
|
||||
## Decision and plan extended
|
||||
|
||||
This implements **D-AT-004: optional Kanto+ content consumes an
|
||||
active-independent semantic dataset view**. The consuming design is tracked in
|
||||
the Adaptive Trainers implementation plan,
|
||||
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
|
||||
Task 8. The delta is a generic, additive public API and contains no trainer
|
||||
pools, scaling, boss identities, version-mixing rules, or Adaptive Trainers
|
||||
policy.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
Every sandboxed mod receives:
|
||||
|
||||
```lua
|
||||
local view, reason = mod.datasets:open("gold")
|
||||
```
|
||||
|
||||
A known version with the current completed import marker returns a read-only view:
|
||||
|
||||
```lua
|
||||
view = {
|
||||
version = "gold",
|
||||
generation = 2,
|
||||
content = {
|
||||
pokemon = {
|
||||
get = function(self, id) end,
|
||||
has = function(self, id) end,
|
||||
each = function(self) end,
|
||||
},
|
||||
-- every public registry name and alias
|
||||
},
|
||||
assets = {
|
||||
path = function(self, generatedPath) end,
|
||||
info = function(self, generatedPath) end,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`get` returns a bounded detached data-only copy or nil. `has` reports data-only
|
||||
semantic presence.
|
||||
`each` returns ids in lexical order and detached values. The registries use
|
||||
the selected version's generation routing and the engine's existing
|
||||
`Schemas`, `Registry`, and `Builtins` normalization, so structured sources
|
||||
such as type matchups retain the same public ids used by the active
|
||||
`mod.content` facade and extractor metadata beside record maps is not exposed
|
||||
as a record id or writable through the active registry's mutation verbs. Every
|
||||
generated base record is checked with that selected generation's existing
|
||||
public schema before it can cross `get`, `has`, or
|
||||
`each`; there is no dataset-specific duplicate schema. No register, patch,
|
||||
override, or remove verb is exposed. Each call returns an independent facade
|
||||
over the cached internal dataset, so facade mutation cannot cross mod
|
||||
boundaries.
|
||||
|
||||
`assets:path` returns the selected cache-prefixed virtual path.
|
||||
`assets:info` returns sanitized `type` and optional `size` metadata. Both
|
||||
accept only relative paths below `assets/generated/`, reject control
|
||||
characters, absolute paths, backslashes, and traversal, and expose no byte
|
||||
reader.
|
||||
|
||||
An unknown version returns `nil, "unknown_version"`. `open` checks the current
|
||||
completion marker, exact version-specific file inventory, source/cache
|
||||
boundary, and available file sizes; it does not read or decode semantic
|
||||
modules. A missing, partial, or stale cache returns `nil, "not_imported"`.
|
||||
|
||||
The first content operation that needs a root reads it once, applies the
|
||||
limits (8 MiB per module, 48 MiB aggregate, depth 64, 500,000 values, 2 MiB
|
||||
per string, and 250,000 entries per table), decodes the restricted literal
|
||||
grammar, applies canonical selected-version normalization, and caches the
|
||||
detached root. Each later content or asset operation rechecks marker/file and
|
||||
source-bound readiness. It also rereads the source bytes for already cached
|
||||
roots; unchanged roots are not decoded again, while a changed root clears the
|
||||
derived registry cache and is decoded on its next use.
|
||||
|
||||
Malformed syntax, non-table roots, binary/trailing content, resource-limit
|
||||
violations, and records that fail the public schema are therefore discovered
|
||||
on first access rather than during `open`. The triggering `get` returns nil,
|
||||
`has` returns false, or `each` returns no rows; the whole internal view is
|
||||
invalidated, and a subsequent `open` against the unchanged source returns
|
||||
`nil, "invalid_cache"`. Actionable detail is engine-logged but not exposed to
|
||||
the mod. Generated Lua is never executed. Functions, userdata, threads,
|
||||
metatables, and cycles cannot cross the facade. The API never exposes raw ROM
|
||||
bytes, generated source, host paths, or a mount.
|
||||
|
||||
## Migration and compatibility
|
||||
|
||||
Existing mods change nothing. `mod.datasets` is additive and requires no
|
||||
permission. The service is allocated lazily on the first explicit
|
||||
`mod.datasets:open` call. A boot with no mods, or with mods that do not call
|
||||
it, performs no cross-version cache reads.
|
||||
|
||||
Opening a view does not change `GameVersion`, `CacheFs.prefix`, the active
|
||||
`Data` table, PhysFS mounts, save state, or the selected game's behavior.
|
||||
Red, Blue, Yellow, Gold, Silver, and Crystal keep their existing active data paths.
|
||||
|
||||
The completion marker and per-version required-file rules live in the pure,
|
||||
injected `CacheContract` shared by the importer and dataset service. It also
|
||||
defines source-tree behavior. Neither consumer mutates `CacheFs.prefix` while
|
||||
checking readiness. Every `open` revalidates the contract and required module
|
||||
inventory without semantic decoding. Every view operation rechecks that
|
||||
readiness before serving cached state; a stale/remove/reimport transition
|
||||
evicts the previous semantic view.
|
||||
|
||||
## Verification
|
||||
|
||||
- `tests/modkit/cases/dataset_views.lua` loads sandboxed fixture mods through
|
||||
the public API and covers Red, Blue, Yellow, Gold, Silver, and Crystal independently.
|
||||
- The test proves semantic registry normalization, deterministic iteration,
|
||||
detached records, read-only facades, cross-mod facade isolation,
|
||||
version-prefixed generated assets,
|
||||
traversal rejection, stable failure reasons, and stale-marker rejection.
|
||||
- It also proves missing/empty/partial/stale/remove/reimport behavior, hostile
|
||||
generated-source and malformed-record rejection, canonical Gen
|
||||
1/Yellow/Gold hydration, active Red/Blue/Yellow isolation while reading Gold,
|
||||
and the approved Kanto+ Gold records and assets.
|
||||
- `tests/engine/dataset_views_lazy_validation.lua` counts semantic reads and
|
||||
decoder calls to prove `open` decodes nothing, unused roots stay unread, and
|
||||
repeated access does not re-decode an unchanged cached root.
|
||||
- `tests/modkit/cases/dataset_views_nontermination.lua` proves generated code
|
||||
is rejected rather than executed; `tests/engine/generated_data_decoder_test.lua`
|
||||
proves every decoder resource bound.
|
||||
- `tests/engine/dataset_views_no_mod_parity.lua` is the separate guarded no-mod
|
||||
parity suite and proves the service stays unallocated with zero cache reads.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is removed, renamed, superseded, or deprecated.
|
||||
@@ -0,0 +1,123 @@
|
||||
# RFC 0016: Engine-owned field residual descriptors
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
Battle-rule mods can keep deterministic data-only state in the public battle
|
||||
field and observe `battle.turn_ended`, but that event fires after the engine's
|
||||
residual and faint pipeline. A listener cannot safely deal end-of-round field
|
||||
damage: directly changing live HP bypasses bar drains, faint messages,
|
||||
experience, replacements, double-faint resolution, and checkpoint continuation.
|
||||
Putting callbacks into `battle.field` is also rejected by the checkpoint
|
||||
serializer, correctly, because executable state is not save-safe.
|
||||
|
||||
## Decision and plan extended
|
||||
|
||||
This implements **D-AT-004: public engine-owned field residual execution**, the
|
||||
consuming decision required by Adaptive Trainers capability `ENGINE-FIELD-
|
||||
RESIDUALS`. The plan is
|
||||
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
|
||||
Task 8. The engine delta defines only a generic end-of-round extension point;
|
||||
it contains no weather names, immunities, damage formula, trainer identity, or
|
||||
Adaptive Trainers policy.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
Add the guarded Gen 1 hook:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("battle.field_residual", function(next, context)
|
||||
local rows = next(context)
|
||||
rows[#rows + 1] = {
|
||||
side = "enemy",
|
||||
amount = 7,
|
||||
message = context.battlers.enemy.name .. " is buffeted!",
|
||||
}
|
||||
return rows
|
||||
end)
|
||||
```
|
||||
|
||||
The hook runs once during an undecided battle's end-of-round processing, after
|
||||
vanilla status residuals and before field/side token expiry and
|
||||
`battle.turn_ended`. With no subscriber, the guarded site builds no context and
|
||||
changes nothing. Vanilla contributes an empty list.
|
||||
|
||||
`context` is `{ field, battlers, turn }`. `field` is a strictly data-only view
|
||||
with the same `{ weather, tokens }` shape captured by battle checkpoints. Its
|
||||
recursive projection retains raw tables and finite numbers, strings, and
|
||||
booleans under scalar keys. It strips metatables and omits functions, userdata,
|
||||
threads, unsupported keys, and cyclic edges. Thus it exposes neither
|
||||
`field.sides` nor a graph or executable callback back into live engine state.
|
||||
`battlers.player` and `battlers.enemy` are detached snapshots with `{ side,
|
||||
name, hp, maxHp, types, vanished }`. Changing either detached view cannot
|
||||
change the live battle. `turn` is the current Gen 1 turn counter.
|
||||
|
||||
A result row is `{ side = "player"|"enemy", amount =
|
||||
positive_finite_integer_number, message = optional_string }`. Numeric strings,
|
||||
zero, negatives, fractions, NaN, infinities, malformed sides, and non-string
|
||||
messages fail closed. Damage is clamped to current HP. The engine owns
|
||||
mutation, HP-bar drain rows, and its existing faint pipeline. It applies every
|
||||
accepted row before scheduling newly fainted battlers. If this hook batch
|
||||
terminally faints the player with no healthy reserve, it queues only the player
|
||||
faint authority, so descriptor order cannot race a blackout against an enemy
|
||||
EXP/replacement path. Otherwise it schedules newly fainted battlers in fixed
|
||||
player/enemy order. This precedence is scoped to this hook response; native
|
||||
faint paths, including `enemyMonFainted`, keep their existing no-hook behavior.
|
||||
Wrappers compose by calling `next(context)` and appending their own rows.
|
||||
Callbacks are not part of the descriptor contract and hook functions are never
|
||||
stored in the battle.
|
||||
|
||||
Gold already owns native weather and a generation-specific between-turn order;
|
||||
this first additive call site is Gen 1-only. A future Gold site must keep the
|
||||
same context and descriptor contract and choose its native ordering explicitly.
|
||||
|
||||
## Migration and compatibility
|
||||
|
||||
Existing mods change nothing. No hook name or payload changes. With no wrapper,
|
||||
Gen 1 performs the same residual, token, event, and faint work as before,
|
||||
including native simultaneous-faint resolution, and allocates no context. Gen
|
||||
2 is unchanged. Existing and new checkpoints keep
|
||||
serializing only the data stored in `battle.field`; hook callbacks remain
|
||||
process-local loader state and are never serialized.
|
||||
|
||||
The v1 surface remains unchanged: `content.X:register/override/get`,
|
||||
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, manifest v1 fields, and
|
||||
`pokemon.before_give` keep their existing behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
- The catalog hook parity gate proves null and live-empty buses return the
|
||||
vanilla list unchanged.
|
||||
- A sandboxed fixture mod exercises the seam through `mod.hooks`, verifies the
|
||||
detached checkpoint-shaped context, applies damage, and reaches the engine
|
||||
faint pipeline.
|
||||
- Engine validation tests cover strict number validation (including numeric
|
||||
strings, NaN, infinities, zero, and negatives), nested mutation isolation,
|
||||
omission of functions/userdata/threads/cycles and metatables from the public
|
||||
field projection, optional messages, non-table results, clamping, and
|
||||
settled-battle suppression.
|
||||
- Both descriptor orders are driven through queue completion for a simultaneous
|
||||
terminal residual. Each proves player blackout loss with no EXP event,
|
||||
enemy replacement, or replacement UI.
|
||||
- A disabled-bus sentinel proves the guard performs no `Runtime.call` or field
|
||||
context construction. An ordering probe proves the enabled hook runs after
|
||||
vanilla status residuals and before token expiry and `battle.turn_ended`.
|
||||
- A no-hook native regression proves a simultaneous zero-HP state still enters
|
||||
the pre-existing enemy-faint EXP and win authority outside this hook batch.
|
||||
- Capture/restore/capture evidence proves checkpointed field state round-trips
|
||||
while the enabled process-local hook remains installed and callable.
|
||||
|
||||
## Docs with the change
|
||||
|
||||
`docs/modding.md` documents the Gen 1 timing, detached payload, descriptor
|
||||
validation, simultaneous-terminal result, and checkpoint boundary.
|
||||
`docs/mod-api-gen2-compat.md` records that Gold does not yet expose the hook.
|
||||
No registry or schema changes are involved, so generated registry docs do not
|
||||
change.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is deprecated. The hook is additive.
|
||||
@@ -0,0 +1,102 @@
|
||||
# RFC 0017: Public mod developer-mode signal
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
The loader already derives a boot-time developer-mode flag for its permission
|
||||
diagnostics and headless test seam. Gen 1's `Game` independently derives a
|
||||
similarly sourced flag for its console and hot reload. A sandboxed mod cannot
|
||||
read either one. `mod.commands` can register a diagnostic command but cannot
|
||||
say whether the current boot is a developer boot. `mod.exports` only publishes
|
||||
values to other mods. `game.ready` fires after entry registration and carries
|
||||
only the game. `mod.options` is player configuration, not engine mode, and
|
||||
`mod.log` logs unconditionally. The pre-sandbox compatibility
|
||||
`os.getenv("POKEPORT_DEV")` deliberately returns `nil`, because the process
|
||||
environment is hidden from mods.
|
||||
|
||||
The concrete consumer is **Adaptive Trainers**. Its approved Chapter 30 and
|
||||
Phase H require trainer, boss, Rival, and League diagnostic views plus
|
||||
seed-label tracing to exist only when `POKEPORT_DEV` is active. Without a
|
||||
public signal, the mod must either ship those registrations in production,
|
||||
misuse a player option, or import loader/Logger internals. All three violate
|
||||
the approved observability boundary or the sandbox/public-API policy.
|
||||
|
||||
## Decision and plan extended
|
||||
|
||||
This implements **D-AT-005: diagnostics and seed tracing are admitted only by
|
||||
the engine's developer-mode decision**. The consuming design is tracked in the
|
||||
Adaptive Trainers implementation plan,
|
||||
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
|
||||
Task 9. The engine delta is generic and contains no trainer, balancing,
|
||||
diagnostic-layout, seed-label, or Adaptive Trainers policy.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
Every sandboxed mod object adds one field:
|
||||
|
||||
```lua
|
||||
mod.developer -- boolean
|
||||
```
|
||||
|
||||
The loader copies its existing `dev` decision into this field before invoking
|
||||
the mod's entry chunk. It is therefore available for load-time registration:
|
||||
|
||||
```lua
|
||||
if mod.developer then
|
||||
mod.commands:register("my_mod:diagnostics", diagnostics_command)
|
||||
end
|
||||
```
|
||||
|
||||
The value is a plain boolean snapshot, not a loader reference or environment
|
||||
facade. `false` is the normal player-build answer. `POKEPORT_DEV=1` and the
|
||||
`--developer` command-line path make the loader flag true; on Gen 1 those inputs
|
||||
separately make `Game`'s own developer flag true for its console and hot
|
||||
reload. The loader's existing injected `opts.dev` test seam changes only the
|
||||
loader flag and diagnostics, not `Game` or its hotkeys. It grants no permission
|
||||
and does not expose environment variables. The answer is fixed for the life of
|
||||
that loader; changing a field on a mod's own table cannot change engine mode.
|
||||
|
||||
The field is generation-independent and has identical semantics on Red, Blue,
|
||||
Yellow, Gold, and Silver. Gold and Silver do not gain Gen 1's developer console
|
||||
or hot-reload hotkeys from this field.
|
||||
|
||||
## Migration and compatibility
|
||||
|
||||
Existing mods change nothing. `mod.developer` is additive, requires no
|
||||
permission, and does not bump the integer mod API. Existing API-v1 and API-v2
|
||||
entry chunks receive one extra scalar field and retain all prior fields and
|
||||
methods unchanged. No name is removed or shadowed.
|
||||
|
||||
With no mods installed, `Loader:_api` is never called, so the delta allocates no
|
||||
mod object and changes no data, save, options, event, hook, command, or file.
|
||||
With mods installed in a normal boot, the new field is `false` unless an author
|
||||
explicitly reads it. Existing registration and logging behavior is unchanged.
|
||||
|
||||
An adopting mod should gate developer-only registrations and verbose logging
|
||||
directly on `mod.developer`. Player-facing behavior belongs behind
|
||||
`mod.options`, not this signal.
|
||||
|
||||
## Verification
|
||||
|
||||
- `tests/engine/mod_developer_mode_test.lua` loads a real sandboxed mod through
|
||||
the public SDK with developer mode both on and off. It proves the boolean is
|
||||
available during entry execution and that the same source registers its
|
||||
diagnostic command only for the developer load. It also covers the
|
||||
command-line global path and a Gen 2 load.
|
||||
- `tests/engine/mod_developer_mode_parity_test.lua` is the separate no-mod
|
||||
parity suite. It proves both developer answers discover no mods, create no
|
||||
files, and leave injected vanilla data unchanged. It also loads an unchanged
|
||||
API-v1 probe and verifies identity, `mod:read`, exports, and options behavior.
|
||||
- The full ROM-free engine and modkit tiers remain the compatibility proof for
|
||||
`content.X:register/override/get`, `events:on`, `hooks:wrap`, `mod.log`,
|
||||
`mod:read`, manifest v1 fields, and `pokemon.before_give`.
|
||||
|
||||
No registry or schema changes are involved, so generated registry documentation
|
||||
is unaffected.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is removed, renamed, superseded, or deprecated.
|
||||
@@ -0,0 +1,113 @@
|
||||
# RFC 0018: `catch.party_full` — custody of a catch the party cannot hold
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
When a capture lands and the party already holds six Pokémon, the cart does
|
||||
not ask the player anything: `AddPartyMon` fails and the mon goes to
|
||||
`SendNewMonToBox` without a stop. The engine mirrors that —
|
||||
`BattleState:storeCaughtMon` falls through to `Boxes.deposit` — and a game
|
||||
mode has no way to stand in front of it.
|
||||
|
||||
That silence is wrong for any mode where the box is not a thing. A battle
|
||||
royale locks the Pokémon Center PC for the whole match (the box is a second
|
||||
health bar: deposit the healthy ones, fight with one, withdraw fresh ones),
|
||||
so a seventh catch is deposited into storage the player cannot reach — the
|
||||
mon is gone and everyone saw the fanfare. A Nuzlocke that counts a boxed mon
|
||||
as lost, a randomizer that wants to hand out a replacement instead, a
|
||||
challenge run that makes the player release someone for the catch — all want
|
||||
the decision, and today there is no seam to catch it at.
|
||||
|
||||
The immediate consumer is a battle-royale mode, where the fix is the game's
|
||||
own rule: at 6/6 you choose who makes room, and whoever leaves hits the
|
||||
ground as a ball. Neither the decision nor the spill is specific to it.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
This extends the **additive, guarded seam convention** Route B in
|
||||
`CONTRIBUTING-mods.md` documents, and is gated by the parity guarantee
|
||||
`tests/engine/gate_meta_coverage.lua` enforces. It sits next to
|
||||
`catch.nickname` (RFC 0015) on the same capture path: that hook answers
|
||||
*what the mon is called* once its home is decided; this one decides the home
|
||||
when the party cannot hold it.
|
||||
|
||||
There is no in-repo D-number registry to amend.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
### New hook: `catch.party_full`
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("catch.party_full", function(next, ctx)
|
||||
-- ctx = { battle = <BattleState>, mon = <Pokemon>, name = <display name>,
|
||||
-- game = <Game> }
|
||||
if myMode.active then
|
||||
takeCustody(ctx.mon) -- the mon is the mod's problem now
|
||||
return true -- nothing is deposited
|
||||
end
|
||||
return next(ctx) -- false: deposit, as today
|
||||
end)
|
||||
```
|
||||
|
||||
Call site: `BattleState:partyFullDestination(mon)`, called from the capture
|
||||
path at the moment `AddPartyMon` has failed and `SendNewMonToBox` would run.
|
||||
The vanilla link returns `false`. A truthy return skips the box entirely —
|
||||
the mon is neither in the party nor in any box, and `pokemon.caught` reports
|
||||
`destination = "mod"` so the mode can find its own custody again. Anything
|
||||
falsy deposits as always, "But every BOX is full!" included.
|
||||
|
||||
The method returns `"box"` or `"mod"`, and is a *method* on purpose: the
|
||||
compatibility seam for engines that predate this RFC needs a name to ask for
|
||||
(see below), and `battleStyle`/`offerNickname` set the precedent.
|
||||
|
||||
### One event value, already emitted
|
||||
|
||||
`pokemon.caught`'s `destination` gains a third value, `"mod"`, next to
|
||||
`"party"` and `"box"`. Nothing that reads the event today matches on it.
|
||||
|
||||
### No other surface changes
|
||||
|
||||
The call site is guarded by `Runtime.wantsHook`, and the vanilla link is a
|
||||
file-local, so a build with nothing wrapped runs the branch exactly as
|
||||
before and allocates nothing it did not allocate before.
|
||||
|
||||
## Migration
|
||||
|
||||
Nothing changes for existing mods. A mode that was fighting the box after
|
||||
the fact — withdrawing the deposit it could not prevent, or eating the loss —
|
||||
should wrap `catch.party_full` and take the mon at the moment of the catch.
|
||||
|
||||
## Verification
|
||||
|
||||
- `tests/modkit/cases/catch_party_full.lua` — through the public mod API:
|
||||
with no mod a 6/6 catch lands in the box with the transfer text; a wrapped
|
||||
mod that claims custody leaves the mon out of both party and boxes; a
|
||||
fall-through deposits; the hook's ctx carries the battle, the mon, the
|
||||
display name and the game.
|
||||
- `tests/engine/gate_hooks.lua` — the name is in the live catalog and passes
|
||||
the no-mod parity gate (vanilla called exactly once, result unchanged,
|
||||
nothing allocated).
|
||||
- `tests/engine/gate_meta_coverage.lua` — the name is covered by the unit
|
||||
corpus.
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
Additive. No existing hook, event, registry or manifest field changes shape.
|
||||
`storeCaughtMon`'s box branch keeps its text, its nickname offer and its
|
||||
`pokemon.caught` emit; the only reader-visible difference with no subscriber
|
||||
is one extra method on `BattleState`.
|
||||
|
||||
## Compatibility seam for older engines
|
||||
|
||||
The call site is mid-function, so a mod on a stock engine cannot see it —
|
||||
the same problem `world.talk` has. The battle-royale mod's shim covers the
|
||||
gap by wrapping `Boxes.deposit` (the one call the old branch makes that a
|
||||
patch can reach): during a match it raises `catch.party_full` first, and a
|
||||
claim refuses the deposit, so nothing reaches a box even there. The mod then
|
||||
takes custody from the `pokemon.caught` emit, which carries the mon. What
|
||||
the shim cannot repair is the text: the old branch answers a refused deposit
|
||||
with "But every BOX is full!" before the mode's own prompt opens — the wrong
|
||||
reason for the right decision, and the argument for the seam.
|
||||
@@ -0,0 +1,761 @@
|
||||
# ShaderFX: runtime slang shader presets
|
||||
|
||||
ShaderFX plays real libretro `.slangp` shader presets over the finished frame.
|
||||
It replaced `src/render/GBCFX.lua`, a hand-ported fixed four-level effect, with
|
||||
a picker: any preset the player drops in a folder, or any preset pulled from
|
||||
the RetroArch buildbot, can be selected and run. Engine:
|
||||
`src/render/ShaderFX.lua` (discovery, download, translation call, pass-graph
|
||||
runtime, render entry point), `src/render/ShaderFixup.lua` (GLSL rewrites),
|
||||
`src/render/ShaderSourcePatches.lua` (pre-translation source patches),
|
||||
`src/core/Sensors.lua` (accelerometer and gyroscope), `src/ui/ShaderFXScreen.lua`
|
||||
(the picker), `src/ui/ShaderFXParamsScreen.lua` (per-preset parameter editor),
|
||||
`tools/shaderfx-bridge/` (the Rust translator). Call sites:
|
||||
`src/render/Renderer.lua` (Gen 1) and `src/core/Game2.lua` (Gen 2), both at the
|
||||
end of the frame. Drivers: `tests/drivers/gold_shaderfx_zoom_sizing_test.lua`,
|
||||
`tests/drivers/gold_shaderfx_menu_black_crop_test.lua`.
|
||||
|
||||
This is first-party engine code, not a mod. It calls the native translator
|
||||
directly through `ffi.load`, with no `Sandbox.lua`, no `native` permission
|
||||
declaration, and no mod boundary. Mods do not get that, and the distinction is
|
||||
deliberate: the upstream maintainer will not accept `native` in mods.
|
||||
|
||||
## What a player sees
|
||||
|
||||
**OPTIONS** carries two rows, `SHADER FX` and `SHADER FX 2`. Each opens the
|
||||
same pushed list screen (`ShaderFXScreen`) on a different slot. The list is
|
||||
`OFF`, then every `.slangp` found on disk, then a permanent `DOWNLOAD SHADERS`
|
||||
action row at the bottom.
|
||||
|
||||
A preset that has never been translated draws muted with a `CONVERT` hint on
|
||||
the right. `A` on that row translates it in place and stays open: converting is
|
||||
a preparation step, not a selection. `A` on a converted row activates it,
|
||||
persists the choice, and closes. `SELECT` on a converted row opens
|
||||
`ShaderFXParamsScreen`, which lists every `#pragma parameter` the preset
|
||||
declares and lets the player step each one (`A` wraps, Left/Right clamp,
|
||||
`SELECT` resets one row, `START` resets all behind a confirm).
|
||||
|
||||
Presets live in a plain OS folder, `shaders/` under the portable base directory
|
||||
when running portable (`SaveData.portableBaseDir()`, the SD-card convention the
|
||||
Anbernic pack uses, see `docs/anbernic-rg34xxsp.md`) and otherwise under LOVE's
|
||||
save directory. `ShaderFX.list()` scans it recursively, so a shader pack keeps
|
||||
whatever nested layout it shipped with. These are real filesystem paths rather
|
||||
than `love.filesystem` virtual paths on purpose: the native translator does
|
||||
plain `std::fs` reads and knows nothing about LOVE's mounts, and neither do the
|
||||
LUT loads.
|
||||
|
||||
Persisted state, all in `save.options`:
|
||||
|
||||
| Key | Meaning |
|
||||
| --- | --- |
|
||||
| `shaderfx` | main slot's preset name, or absent for OFF |
|
||||
| `shaderfxSecondary` | secondary slot's preset name |
|
||||
| `shaderfxParams[name][paramId]` | one preset's edited pragma values |
|
||||
|
||||
`POKEPORT_SHADERFX=<name>` activates a preset in the main slot for scratch
|
||||
harnesses that never call `ShaderFX.applyOptions`. It is a stand-in that
|
||||
predates the real OPTIONS row; `applyOptions` marks itself as having run so the
|
||||
env var can never later override a real player choice, including a real choice
|
||||
of OFF.
|
||||
|
||||
One quiet behavior worth knowing about: if a slot wants a real preset while
|
||||
PERFORMANCE is still on AUTO, and AUTO would resolve to a tier that caps
|
||||
ShaderFX off, `ShaderFX.applyOptions` pins PERFORMANCE to `HIGH`. Without it
|
||||
the saved choice was force-deactivated a few lines later on every boot, which
|
||||
looks identical to "the setting does not save" from the player's side. On
|
||||
Android and iOS that is the common case, because AUTO always resolves to
|
||||
`balanced` there. It never touches an already-explicit performance choice and
|
||||
never un-escalates.
|
||||
|
||||
## The five stages
|
||||
|
||||
| Stage | Owner |
|
||||
| --- | --- |
|
||||
| Fetch | `ShaderFX.list`, `ShaderFX.downloadPresets`, `ShaderFX.installDownloaded` |
|
||||
| Translate | `tools/shaderfx-bridge/` via `ShaderFX.translate` |
|
||||
| Fixup | `src/render/ShaderFixup.lua` |
|
||||
| Cache | `ShaderFX.convert` writes, `ShaderFX.load` reads |
|
||||
| Run | `ShaderFX.runChain` / `runPass` / `ShaderFX.render` |
|
||||
|
||||
### Fetch
|
||||
|
||||
Two acquisition paths, and they land in the same place. A player can copy a
|
||||
shader pack into `shaders/` by hand, or press `DOWNLOAD SHADERS`, which fetches
|
||||
`https://buildbot.libretro.com/assets/frontend/shaders_slang.zip` (the same
|
||||
~54 MB archive RetroArch's own "Update Shaders" entry pulls) through
|
||||
`src/net/Fetch.lua`, the curl-on-a-`love.thread` transport the self-updater and
|
||||
mod index already use.
|
||||
|
||||
Downloaded presets are *not* pre-converted. The buildbot is RetroArch's asset
|
||||
mirror and has no notion of this project's cache format, so a downloaded preset
|
||||
goes through the same `CONVERT` row a hand-copied one does. Every platform
|
||||
ships both convert and use; there is no asymmetry to work around.
|
||||
|
||||
Repeat downloads are conditional. The zip itself is deleted right after
|
||||
extraction, so what is cached instead is the buildbot's own ETag
|
||||
(`shaderfx_buildbot.etag`), replayed as `If-None-Match`. The server answers a
|
||||
match with 304 and no body, and curl writes no file at all in that case, so
|
||||
`installDownloaded(notModified)` short-circuits before touching the filesystem
|
||||
and reports "already up to date" rather than "FAILED".
|
||||
|
||||
The interesting part is what gets extracted. `handheld/` is not self-contained:
|
||||
its 78 presets carry 101 references that escape the folder (color-mod LUTs
|
||||
into `../shaders/color/`, console-border helpers into `../../reshade/`, shared
|
||||
motion-blur and misc helpers, a shared `stock.slang`) across roughly 40 of
|
||||
them. Extracting `handheld/` alone silently breaks about a third of its own
|
||||
list; extracting the whole zip drags in ~5600 files of CRT, arcade and console
|
||||
content nobody asked for. So `extractClosure` walks the real file-level
|
||||
dependency closure in Lua before a single file is copied, the same
|
||||
`#reference`-closure idea librashader applies internally, done up front because
|
||||
deciding what to copy has to happen before the translator ever sees these
|
||||
files. Against this zip that is 207 files and about 9.5 MB with zero broken
|
||||
references. The closure seeds from `KEPT_PRESETS`, a curated shortlist rather
|
||||
than all 78, after most of `color-mod/` and `console-border/` turned out either
|
||||
irrelevant (color-only, no LCD effect) or broken for this project. That list
|
||||
is a temporary trim pending wider testing and is expected to change.
|
||||
|
||||
Two mechanical details in that walk that are easy to get wrong a second time.
|
||||
`extractRefs` tries a quoted `key = "path"` match per line first, with an
|
||||
unrestricted `[^"]+` capture, because the closing quote is an unambiguous
|
||||
delimiter and real packs ship paths with spaces and parentheses in them
|
||||
(`"shaders/handheld/color-mod/Game Boy (Color).slang"`); only a line with no
|
||||
quoted match falls back to a conservative character class, since an unquoted
|
||||
path has no delimiter to trust past. And `love.filesystem.write` does not
|
||||
create intermediate directories, so each destination directory is created once
|
||||
before anything is written into it; without that, every file under a subfolder
|
||||
`handheld/` never had before was silently dropped while flat writes succeeded.
|
||||
Cleanup is equally literal: `love.filesystem.unmount()` takes the archive path
|
||||
originally passed to `mount()`, not the mountpoint. Called with the mountpoint
|
||||
it returns false, leaves the zip's handle open, and the following `remove()`
|
||||
silently fails too, so every download used to leave 54 MB on disk forever.
|
||||
|
||||
### Translate: the native bridge
|
||||
|
||||
`tools/shaderfx-bridge/` is a small Rust crate (`spike`) that builds a cdylib
|
||||
named `librashader_bridge`. It wraps `librashader-presets`,
|
||||
`librashader-preprocess` and `librashader-reflect` behind a two-function C ABI:
|
||||
|
||||
```
|
||||
char* librashader_translate_preset(const char* preset_path, int es);
|
||||
void librashader_free_string(char* s);
|
||||
```
|
||||
|
||||
It returns a JSON `TranslateResult`: `pass_count`, a `passes` array (each with
|
||||
its emitted `vertex`/`fragment` GLSL, `filter`, `wrap_mode`, `scale_x`/
|
||||
`scale_y`, its own `#pragma parameter` declarations, a classified `samplers`
|
||||
list and a classified `size_uniforms` list), the preset's `textures` (LUTs,
|
||||
with resolved absolute paths and filter/wrap settings), its
|
||||
`parameter_overrides`, and an `error` field.
|
||||
|
||||
**What the bridge is not.** No librashader runtime backend is linked in, for
|
||||
any API: no GL, Vulkan, D3D or Metal crate is a dependency, and no live
|
||||
graphics context is ever touched. It is the translation step only. LOVE still
|
||||
owns every draw call, every canvas and every shader object. The bridge hands
|
||||
back text and metadata and nothing else.
|
||||
|
||||
**When it is called.** Only from `ShaderFX.convert()`. Translation is ahead of
|
||||
time, not just in time. `ShaderFX.load()`, `ShaderFX.activate()`, boot-time
|
||||
reactivation of a saved choice and every frame of `ShaderFX.render()` read the
|
||||
cached artifact and never call the library. An entry with no cached artifact
|
||||
fails `activate()` loudly instead of silently live-translating.
|
||||
|
||||
The classification is done with librashader's real semantics resolution rather
|
||||
than name matching on the Lua side, and that matters for correctness, not just
|
||||
tidiness. Sampler classification uses `ShaderSemantics::create_pass_semantics`
|
||||
plus the `TextureSemanticMap` lookup (explicit alias and LUT-name entries
|
||||
first, then the built-in `Source`/`Original`/`OriginalHistoryN`/`PassOutputN`/
|
||||
`PassFeedbackN` conventions), so a pass reachable only by its real `.slangp`
|
||||
alias resolves. The per-pass convenience API only registers the alias of the
|
||||
pass being compiled, so the bridge mirrors upstream's `insert_pass_semantics`
|
||||
loop and builds a preset-wide alias map first; without that,
|
||||
`ds-hybrid-scalefx.slangp`'s pass 2 sampling `scalefx_pass0` by alias, with no
|
||||
`PassOutput1`-shaped name anywhere, can never resolve. Size-uniform
|
||||
classification runs the same resolution over the pass's `uniform_semantics`
|
||||
map, which also covers shapes (`PassFeedbackSizeN`, `UserSizeN`) that no
|
||||
present preset uses but that the convention allows.
|
||||
|
||||
The `es` flag picks the emitted dialect: 1 for GLSL ES 1.00 (mobile, LOVE's ES
|
||||
dialect), 0 for GLSL 1.20 (LOVE's desktop dialect). `ShaderFX` picks it from
|
||||
`love.system.getOS()`, and the same function decides which dialect
|
||||
`validateShader` is asked about at run time, so the two always agree. Convert
|
||||
and render always happen on the same device; **artifacts are not portable
|
||||
across platforms**.
|
||||
|
||||
One class of fixup has to happen in the bridge rather than in `ShaderFixup.lua`,
|
||||
on the raw `.slang` text before SPIR-V compilation: `textureSize`,
|
||||
`texelFetchOffset` and `textureOffset` are all ES 3.00+ only, and spirv-cross
|
||||
refuses to *emit* them for an ES 1.00 target, failing the whole pass with
|
||||
`UnsupportedSpirv("textureSize is not supported in ESSL 100.")`. No GLSL text
|
||||
is ever produced for a later pass to patch, so `rewrite_essl100_gaps` rewrites
|
||||
the source first, and only for the ES target:
|
||||
|
||||
- `textureSize(Tex, lod)` becomes a literal `ivec2(w, h)` when `Tex` is one of
|
||||
the preset's declared static textures, with dimensions read straight out of
|
||||
each PNG's IHDR chunk. A texture that is not one of those, or a non-literal
|
||||
`lod`, is left alone so it fails as loudly as before instead of guessing.
|
||||
- `texelFetchOffset` and `textureOffset` become ordinary `texture()` calls at
|
||||
the equivalent texel-centre UV, using the texture's own `<Tex>Size.zw`
|
||||
reciprocal-size uniform. The containing block instance (`params`, `global`,
|
||||
whatever) is discovered by scanning the source's own uniform block bodies,
|
||||
never assumed. When a pass samples another pass purely through these calls it
|
||||
may never declare that `<Tex>Size` uniform at all, so one is injected first,
|
||||
before any byte offsets are computed.
|
||||
|
||||
That last rewrite has an honest limit: `texture()` honours the sampler's wrap
|
||||
mode at out-of-range coordinates, which is not necessarily identical to
|
||||
`texelFetch`'s implementation-defined out-of-bounds behavior. Any edge-of-image
|
||||
discrepancy for passes whose offsets can leave the image is unmeasured.
|
||||
|
||||
**Building it.** `cargo build --release` inside `tools/shaderfx-bridge/`.
|
||||
`ShaderFX` looks for the library, most specific first: the
|
||||
`LIBRASHADER_BRIDGE_DLL` environment variable, the source directory,
|
||||
`<source>/tools/shaderfx-bridge/target/release/`, the save directory, and
|
||||
finally the bare name handed to the system loader. Per-OS names are
|
||||
`librashader_bridge.dll` (Windows), `liblibrashader_bridge.dylib` or
|
||||
`librashader_bridge.dylib` (macOS), and `liblibrashader_bridge.so` or
|
||||
`librashader_bridge.so` (Linux and Android). Android resolves the bare name
|
||||
because the `.so` ships as an ordinary `jniLibs` entry, so `dlopen` finds it
|
||||
without a path. `ShaderFX.canConvert()` reports whether the library resolved on this machine,
|
||||
and `ShaderFX.bridgeError()` says why not. Activating an already-converted
|
||||
preset never needs any of this.
|
||||
|
||||
**Shipped builds carry it.** `cargo` only ever emits the host's library, and
|
||||
the macOS release runner packs macOS, Windows and Linux in a single
|
||||
`scripts/build.sh all`, so release CI cross-builds one cdylib per platform on
|
||||
its own native runner (the `shaderfx-bridge` matrix in
|
||||
`.github/workflows/release.yml`) and stages it at `dist/native/<plat>/`.
|
||||
`bundle_shader_bridge` in `scripts/build.sh` reads that directory, and
|
||||
`SHADERFX_BRIDGE_<PLAT>` overrides it. Where each artifact puts the library:
|
||||
|
||||
| Artifact | Location | Found by |
|
||||
| --- | --- | --- |
|
||||
| macOS .app | `Contents/MacOS/` | source base directory |
|
||||
| Windows zip | next to `love.exe` | source base directory |
|
||||
| Linux AppImage (both arches) | AppDir root, next to `game.love` | source base directory |
|
||||
| Flatpak | `/app/share/gen1recomp/` | source base directory |
|
||||
| RG34XXSP / ARM SBC ports | `libs.aarch64/` | bare name via `LD_LIBRARY_PATH` |
|
||||
| Android APK | `jniLibs/<abi>/` | bare name |
|
||||
| iOS | linked into the binary | `ffi.C` |
|
||||
|
||||
The macOS library is a universal binary, because LÖVE.app is: an arm64-only
|
||||
bridge would fail `ffi.load` on Intel Macs the moment they hit CONVERT. The
|
||||
two Linux libraries are built with `cargo-zigbuild` against
|
||||
`{x86_64,aarch64}-unknown-linux-gnu.2.17`, below every consumer: the upstream
|
||||
x86_64 LÖVE AppImage floors at GLIBC_2.29, the arm64 AppImage's own gate is
|
||||
GLIBC_2.31, PortMaster's aarch64 LÖVE runtime floors at GLIBC_2.17, and ArkOS
|
||||
is glibc 2.30. 2.17 is the oldest glibc with aarch64 support, so one library
|
||||
per arch clears the whole fleet. `scripts/linux-arm64/verify_appimage.sh`
|
||||
discovers every ELF object in the AppDir rather than globbing `bin/love` and
|
||||
`lib/*.so*`, so the bridge is held to the same floor, resolution and
|
||||
dlopen-not-linked rules as everything else, then dlopened once to prove
|
||||
relocations and constructors run.
|
||||
|
||||
Setting `SHADERFX_BRIDGE_REQUIRED=1` turns the packagers' "not found" warning
|
||||
into a hard error. Release CI sets it on every job that ships a bridge, so a
|
||||
release cannot silently go out without one again.
|
||||
|
||||
**Where CONVERT is unavailable.** Switch and Xbox UWP ship without a bridge.
|
||||
The Switch build uses devkitPro's toolchain, which is not a rustc target; the
|
||||
UWP appcontainer is unproven for loading a desktop MSVC DLL. On both, presets
|
||||
converted elsewhere still activate and run normally, and `ShaderFX.canConvert()`
|
||||
returns false with `ShaderFX.bridgeError()` explaining why.
|
||||
|
||||
**Statically linked bridges.** On iOS there is no loadable library at all:
|
||||
the bridge is linked straight into the app binary, so its symbols live in the
|
||||
main image and are reachable only through `ffi.C`. The candidate list is empty
|
||||
on iOS and, on every platform, a failed candidate walk falls back to probing
|
||||
`ffi.C` for `librashader_translate_preset` after the `ffi.cdef`. If the symbol
|
||||
is there, `ffi.C` becomes the library handle and `ShaderFX.translate` uses it
|
||||
unchanged. If it is not, `ShaderFX.bridgeError()` reports that `ffi.C` was
|
||||
probed as well, followed by the file paths that were tried (omitted on iOS,
|
||||
where none are).
|
||||
|
||||
**Android ships the bridge inside the APK.** `scripts/build_android.sh`
|
||||
stages `liblibrashader_bridge.so` into
|
||||
`mobile/android/app/src/main/jniLibs/<abi>/` for the two ABIs the APK ships,
|
||||
arm64-v8a and armeabi-v7a, so `ffi.load("liblibrashader_bridge.so")` finds it
|
||||
by bare name in the app's native library directory. The build cross-compiles
|
||||
the crate with `cargo ndk` against the same NDK the gradle project uses
|
||||
(25.2.9519653), which needs `cargo install cargo-ndk` and
|
||||
`rustup target add aarch64-linux-android armv7-linux-androideabi`; the script
|
||||
names the exact command for any target that is missing. Set
|
||||
`SHADERFX_BRIDGE_ANDROID_DIR` to a directory holding
|
||||
`<abi>/liblibrashader_bridge.so` to bundle prebuilt libraries instead. As on
|
||||
desktop, the bridge is only needed to CONVERT a preset: a build without it
|
||||
still runs presets converted elsewhere, and the packager warns and continues
|
||||
rather than failing.
|
||||
|
||||
**iOS links the bridge instead of loading it.** An iOS app cannot `dlopen` a
|
||||
dylib shipped beside its binary, so `tools/shaderfx-bridge` also builds as a
|
||||
`staticlib` and `scripts/build_ios.sh` links it into the app executable
|
||||
(`bundle_shader_bridge_ios`, mirroring `bundle_shader_bridge` in
|
||||
`scripts/build.sh`). `SHADERFX_BRIDGE_IOS` points at a prebuilt archive;
|
||||
otherwise cargo builds `aarch64-apple-ios` for device builds and every
|
||||
installed simulator target (`aarch64-apple-ios-sim`, `x86_64-apple-ios`) for
|
||||
the Simulator, with `IPHONEOS_DEPLOYMENT_TARGET=15.0`, and `ARCHS` is pinned to
|
||||
what got built. The archive is never linked directly: LÖVE 12 carries its own
|
||||
glslang for shader validation and the crate drags in a second, incompatible
|
||||
one, and linking both crashed inside `Shader::validateInternal` at startup.
|
||||
Each slice is therefore prelinked with `ld -r -all_load
|
||||
-exported_symbols_list` so only `_librashader_translate_preset` and
|
||||
`_librashader_free_string` stay external and every other symbol (glslang,
|
||||
spirv-cross, Rust std) becomes private to the object, then `rust-objcopy`
|
||||
drops the `__LLVM` bitcode sections rustup's prebuilt std carries, since
|
||||
Apple's `nm` cannot read them. `OTHER_LDFLAGS` adds `-Wl,-u` on both entry
|
||||
points to keep them past dead-stripping and `-lc++` for the C++ the crate
|
||||
needs; no Objective-C framework is involved. A build that linked the object
|
||||
fails if `nm` cannot find `_librashader_translate_preset` in the finished
|
||||
binary; a build that could not produce one only warns and lands where the
|
||||
desktop packages without cargo land: converted presets run, CONVERT does not.
|
||||
|
||||
### Fixup
|
||||
|
||||
`ShaderFixup.lua` mechanically rewrites the emitted GLSL into something LOVE
|
||||
will accept. librashader emits a standalone `void main()` /`gl_FragData[0]` /
|
||||
`gl_Position` shape (translated Vulkan GLSL); LOVE requires the `effect()` and
|
||||
`position()` convention and refuses a raw `main()`-shaped source outright. This
|
||||
is a targeted rewriter, not a GLSL parser, and every rule below exists because a
|
||||
real preset in the corpus failed without it. This list is the least guessable
|
||||
part of the whole feature.
|
||||
|
||||
**`#version` line.** Stripped; LOVE prepends its own.
|
||||
|
||||
**Array constructors.** SPIR-V Cross emits ES 3.0 array-constructor syntax
|
||||
(`const float _17[5] = float[](0.0, 1.0, ...)`) for compile-time array
|
||||
literals, which validation rejects with "arrayed constructor: not supported for
|
||||
this version". GLSL ES 1.00 has no array-constructor syntax at all. There are
|
||||
three real shapes in the corpus and each is handled: a `const` global (declared
|
||||
without an initializer at global scope, with per-element assignments relocated
|
||||
to the top of `main()`), a non-const local declaration with initializer
|
||||
(rewritten in place, since a function body can hold assignment statements where
|
||||
the literal was), and a bare reassignment of an array declared elsewhere (also
|
||||
in place). Splitting the element list needs `splitTopLevelCommas`, because a
|
||||
naive comma split breaks on any element containing its own parentheses
|
||||
(`vec2(-1.0, 0.0)`), and the outer capture needs `%b()` rather than a
|
||||
`[^%)]-` class for the same reason: the class stops at the first inner `)`, the
|
||||
whole match fails, and the literal passes through completely untouched.
|
||||
|
||||
**Whole-array copies.** `float param_1[7] = coeffs;` is how SPIR-V Cross clones
|
||||
a function-parameter array before passing it on, since GLSL array arguments are
|
||||
by value. ES 1.00 has no whole-array assignment either, so it becomes a bare
|
||||
declaration plus an element-by-element copy. The size is known from the
|
||||
declaration, so no comma splitting is involved. This one only became reachable
|
||||
once the other array shapes stopped masking it in the same file.
|
||||
|
||||
**Integer modulo.** ES 1.00 has no `%` operator, and SPIR-V Cross emits it
|
||||
anyway for an upstream integer-modulo op. `%` in GLSL is only defined for
|
||||
integer operands, so routing through float `mod()` and back is exact for the
|
||||
non-negative operands this shader family uses (rotation and orientation enum
|
||||
indices). Four patterns are tried in order (paren/paren, paren/bare,
|
||||
bare/paren, bare/bare) because SPIR-V Cross fully parenthesizes a compound
|
||||
operand and leaves a simple one bare, and the balanced form must be tried
|
||||
before a plain identifier can partially match. Seen live on
|
||||
`authentic_gbc`'s subpixel-rotation math.
|
||||
|
||||
**Precision.** SPIR-V Cross hardcodes an unguarded `precision highp float;` /
|
||||
`precision highp int;` pair with no toggle. That is removed and replaced with
|
||||
the same guard the old hand-written `DotMatrix` port used: claim `highp` only
|
||||
where `GL_FRAGMENT_PRECISION_HIGH` says the driver actually offers fragment
|
||||
highp, and fall through to the stage default otherwise.
|
||||
|
||||
**Struct flattening.** This is the big one. LOVE's `Shader:send` cannot address
|
||||
a member of a custom struct-typed uniform: neither an `INSTANCE.member` dot path
|
||||
nor sending the whole struct as a table works, both raise "Shader uniform '...'
|
||||
does not exist." librashader emits every pass's `#pragma parameter`s and size
|
||||
uniforms as exactly that kind of struct, so as shipped the output is unusable
|
||||
from LOVE, not merely inefficient. `flattenStruct` deletes the struct and its
|
||||
instance uniform and re-declares the members at top level. Scalar members are
|
||||
*packed* four at a time into synthetic `uniform vec4 LIBRA_PACKED_N;` slots,
|
||||
because GLSL ES 1.00 guarantees only 16 fragment uniform *vectors* and every
|
||||
scalar costs a whole one; `gb-pass4`'s pass 0 alone has 14 scalars, already over
|
||||
budget unpacked. Non-scalar members keep their own uniform, since packing an
|
||||
existing `vec4` saves nothing. Every `instance.member` reference is rewritten to
|
||||
`LIBRA_PACKED_N.x` (or `.y`/`.z`/`.w`), and a member whose original declared
|
||||
type was `int` or `bool` gets an explicit cast back on every read, since a
|
||||
packed slot only stores floats. Vertex and fragment declare identically ordered
|
||||
structs for the same parameters, so packing both with the same prefix assigns
|
||||
the same slot and component to the same parameter in both stages, and one
|
||||
`shader:send` reaches whichever stage uses it.
|
||||
|
||||
Two ordering constraints inside that function are load bearing and look
|
||||
arbitrary from the outside. Packing must walk the members in the struct's own
|
||||
declaration order, because that order is what keeps the scalars in one
|
||||
contiguous run; an earlier version sorted them longest-name-first before
|
||||
packing and produced six packed vec4s instead of four on `gb-pass4`, blowing the
|
||||
budget. Substitution, separately, must go longest-name-first, so a replacement
|
||||
can never land as a substring inside a still-pending member name that shares a
|
||||
prefix. The two orders are separate copies of the list for exactly that reason.
|
||||
|
||||
`Fixup.packValues` turns a flat `{name = value}` table back into the
|
||||
`{uniform = value_or_vec4}` shape the packed shader expects, per the manifest
|
||||
`flattenStruct` returned. `Fixup.countUniformSlots` counts declared uniform
|
||||
slots against that same 16-vector budget, samplers excluded. Its pattern uses
|
||||
`[%w_]+` rather than `%w+` because Lua's `%w` does not include underscore
|
||||
unlike regex `\w`, and every generated name here is full of underscores.
|
||||
|
||||
**UBO blocks.** `LIBRA_UBO_FRAGMENT` and `LIBRA_UBO_VERTEX` have the identical
|
||||
problem and are flattened the same way, under a distinct `LIBRA_UBO_PACKED_`
|
||||
prefix so their groups cannot collide with the push block's numbering. `MVP` is
|
||||
the one special member: it is substituted directly to `transform_projection`
|
||||
instead of becoming a uniform, because "multiply the incoming vertex by it" is
|
||||
exactly what LOVE's `transform_projection` already is on a full-screen draw, and
|
||||
nothing would ever supply a value for it. An earlier version assumed the UBO
|
||||
block only ever carried `MVP` and deleted the whole declaration after
|
||||
substituting it. That is true only for presets that declare their parameters in
|
||||
a push-constant block; presets that use a UBO instead (many real handheld and
|
||||
console-border presets do) had every other member reference left dangling, and
|
||||
the driver then read `INSTANCE.PAR` as a swizzle, which is where the "undeclared
|
||||
identifier" and "unknown swizzle selection" errors on real Android hardware came
|
||||
from.
|
||||
|
||||
**Fragment entry point.** `void main()` becomes LOVE's `effect()` signature.
|
||||
The parameter list is qualified by an `EFFECT_PREC` define rather than a literal
|
||||
precision, because LOVE forward-declares `effect()`'s prototype under its own
|
||||
header's precision default before this source runs, which can mismatch whatever
|
||||
the precision guard above raises the default to. `Fixup.PREC_HEADS` holds the
|
||||
two variants (`mediump`, then unqualified) and the caller tries them in order
|
||||
against `validateShader`, taking the first that passes.
|
||||
|
||||
**Fragment output.** `gl_FragData` does not exist in LOVE's `effect()`
|
||||
convention, so every `gl_FragData[0]` occurrence is rewritten to a local
|
||||
`gbFragColor`, declared at the top of the function, with a single `return`
|
||||
appended before the closing brace. Rewriting *every* occurrence regardless of
|
||||
the operator that follows is necessary, not just symmetric with the vertex side:
|
||||
an earlier assign-then-return pair assumed one write at the very end of
|
||||
`main()`, which holds for most presets but not for ones like `ds-hybrid-sabr`
|
||||
that write once with `=` and later accumulate with `+=`. The `+=` statement
|
||||
passed through unconverted and collided with LOVE's own `gl_FragColor` write
|
||||
("Cannot use both gl_FragColor and gl_FragData"). A bare early `return;` is
|
||||
rewritten to `return gbFragColor;`, which holds the value assigned just before
|
||||
it on every real shape seen.
|
||||
|
||||
**Vertex entry point.** `void main()` becomes `position(mat4
|
||||
transform_projection, vec4 vertex_position)`, the source's own `attribute`
|
||||
redeclarations of `Position`/`TexCoord` are dropped since LOVE supplies them,
|
||||
`gl_Position = X;` becomes `gbClipPos = X;` (named to share no substring with
|
||||
`Position`, or the next step would mangle it), and a `return gbClipPos;` is
|
||||
appended. The `Position` and `TexCoord` substitutions are frontier-matched
|
||||
whole identifiers (`%f[%w]...%f[%W]`), not blind substring replacements: real
|
||||
presets declare their own unrelated locals such as `vec2 vTexCoord;`, and a
|
||||
blind `gsub` turned that declaration into the invalid `vec2 vVertexTexCoord.xy;`
|
||||
(`dot.slangp` pass 0, a real driver "unexpected DOT" error).
|
||||
|
||||
### Cache
|
||||
|
||||
`ShaderFX.convert(entry)` is the only path that calls the bridge. It runs
|
||||
`ShaderSourcePatches.apply` first, translates, then serializes the decoded
|
||||
result to `ShaderFX.artifactPath(entry)`: the source `.slangp`'s own absolute
|
||||
path with the extension swapped to `.lua`, so the artifact sits next to the
|
||||
preset it came from. The file is a plain `return { ... }` chunk written by
|
||||
`serializeLua`, which handles the string/number/boolean/nested-table shape
|
||||
`Json.decode` produces. Array detection walks every key rather than trusting
|
||||
`#t`, since `#t` counts a trailing nil as absent and a sparse table can pass a
|
||||
naive length check by accident.
|
||||
|
||||
`ShaderFX.load(entry)` `loadfile`s that chunk and builds a chain state. On
|
||||
failure it says "convert this preset first" rather than falling back to a live
|
||||
translation.
|
||||
|
||||
**AOT rather than JIT** is the whole point of this stage. Translation is a rare,
|
||||
explicit, user-initiated action whose result is stable for a given preset and
|
||||
dialect, so paying for it once and writing the answer to disk keeps `ffi.load`
|
||||
and the native call off every activation, every boot and every frame. The cost
|
||||
is a staleness gap: `ShaderFX.isConverted()` is a plain "does the artifact file
|
||||
exist" check with no version or content stamp, and there is no explicit
|
||||
"reconvert" action in the UI. A preset converted by an older build never picks
|
||||
up a later translator fix on its own. This was seen on a real device, where
|
||||
`sunlight_shimmer.slangp`'s `Accelerometer` uniform never reached the shader
|
||||
because that device's cache predated the fix while `pixel_transparency`'s
|
||||
happened to be fresher. Two places compensate by reconverting unconditionally:
|
||||
`ShaderFXScreen`'s explicit selection of an already-converted row, and
|
||||
`ShaderFX.applyOptions` on every boot and options save. Both are human-paced,
|
||||
CPU-only work with no GPU compile, and neither is on the per-frame path.
|
||||
|
||||
`ShaderSourcePatches.lua` sits just before translation and patches the raw
|
||||
`.slang`/`.inc` files *on disk*, because only librashader's own preset parser,
|
||||
reading the real files, discovers `#pragma parameter` lines and struct members;
|
||||
nothing downstream can add one. Patches are small, explicit, per-preset literal
|
||||
find/replace pairs (plain `find`, not `gsub`, since GLSL source is full of Lua
|
||||
pattern magic), re-applied idempotently on every convert so a buildbot
|
||||
re-download that replaces the upstream file wholesale does not quietly undo
|
||||
them. **The patch table ships empty on purpose and nothing registers one.** Its
|
||||
original use case, wiring gyroscope yaw into `sunlight_shimmer.slangp` as new
|
||||
`PT_YAW_*` pragma parameters, was reverted precisely because of the staleness
|
||||
gap above: a new pragma can only reach an artifact that gets reconverted, and at
|
||||
the time nothing forced one. The mechanism is kept for a future preset that
|
||||
genuinely needs a new declaration, but an already-wired engine-side channel is
|
||||
preferred whenever one exists.
|
||||
|
||||
### Run: the pass graph
|
||||
|
||||
`ShaderFX.activate(slot, entry, paramOverrides)` loads the artifact, layers the
|
||||
player's edited parameters over the artifact's own defaults, loads the preset's
|
||||
LUTs once, and snapshots the accelerometer rest pose. `ShaderFX.render` then
|
||||
runs the chain each frame.
|
||||
|
||||
`newChainState` builds one instance of chain-local state per loaded preset,
|
||||
never module-global, so switching presets cannot leak a previous preset's
|
||||
canvases or dimensions. `ALL_DEFAULTS` is built in layers: each pass's declared
|
||||
`initial`, then the preset's own `parameter_overrides`, then (in `activate`) the
|
||||
player's `shaderfxParams` edits.
|
||||
|
||||
Sizes resolve through `resolveScale`, which handles all four slang scale types
|
||||
(`absolute`, `viewport`, `source`, `original`) against the viewport, the pass's
|
||||
input dimensions and the original frame. Size uniforms are packed as
|
||||
`{w, h, 1/w, 1/h}`, the slang convention.
|
||||
|
||||
`runPass` caches two things per `(state, pass index)`. The **shader** is
|
||||
compiled once for the state's lifetime, along with the fragment manifest it was
|
||||
compiled against, since a pass's GLSL depends only on the preset and never on
|
||||
per-frame input. The **canvas** is reallocated only when the pass's resolved
|
||||
size actually changes, a window resize or a different preset. Every harness this
|
||||
runtime was ported from ran the chain once and quit, so allocating a fresh
|
||||
canvas and compiling a fresh shader on every call was invisible there. On a real
|
||||
per-frame render path it is one GPU allocation per pass per frame, and a shader
|
||||
recompile on top. The same discipline applies to the crop canvas in
|
||||
`cropToGbSource`, which is called once per frame and whose size grows with the
|
||||
world canvas as the player zooms out; leaving it uncached was a real cost that
|
||||
scaled with zoom level even with a single preset active.
|
||||
|
||||
Sampler binding is by semantic, from the bridge's classification, never by a
|
||||
hardcoded per-preset name check: `Source` is the previous pass's output (or the
|
||||
input frame for pass 0), `Original` and `OriginalHistory` are the input frame,
|
||||
`PassOutput` indexes an earlier pass's canvas, and `User` resolves a LUT by the
|
||||
real name the translation reported. A sampler that resolves to nothing asserts
|
||||
rather than drawing garbage.
|
||||
|
||||
**LUTs** are loaded once per `activate`. `ShaderFX.loadImageFromPath` reads the
|
||||
bytes with plain `io.open` and goes through `love.data.newByteData` and
|
||||
`love.image.newImageData`, because a preset's texture paths are arbitrary
|
||||
absolute OS paths outside any LOVE mount and `love.graphics.newImage` refuses
|
||||
those outright ("Could not open file ... Does not exist") even when the file is
|
||||
real. Wrap modes are mapped from librashader's names to LOVE's
|
||||
(`clamp_to_border` to `clampzero`, `clamp_to_edge` to `clamp`, `repeat`,
|
||||
`mirrored_repeat` to `mirroredrepeat`). A LUT that fails to load is logged and
|
||||
left nil; the fail-loud point is the sampler assertion in `runPass` that
|
||||
actually needed it, not the loader.
|
||||
|
||||
**History ring.** `OriginalHistoryN` currently resolves to a steady state: every
|
||||
history slot reads the current frame, both for the sampler binding and for the
|
||||
size uniform. Real per-frame history rotation has been proven out in a desktop
|
||||
harness but is not wired into this path.
|
||||
|
||||
**Feedback.** `PassFeedback` is not implemented. A `PassFeedback` or `User` size
|
||||
uniform raises an explicit "not yet supported" error, and a `PassFeedback`
|
||||
sampler resolves to nothing and trips the binding assertion. No preset in the
|
||||
shipped shortlist uses it.
|
||||
|
||||
**Blending.** Every pass draws with `replace`, and the chain's final pass uses
|
||||
`replace, premultiplied`. Intermediate canvases are `nearest` filtered.
|
||||
|
||||
`ShaderFX.render(canvas, rect, source, dpiX, dpiY)` is the entry point
|
||||
`Renderer:endFrame` and `Game2` call. `canvas` is the finished window-sized
|
||||
composite (world, UI, and any post-process pipeline that already ran); `rect` is
|
||||
this frame's real playfield rectangle in physical framebuffer pixels and
|
||||
`source` is the real pixel size of the content it frames. The sequence is: crop
|
||||
`rect` out of the composite, run whichever slots are active over that crop, draw
|
||||
the untouched composite, then stretch the chain output back over `rect`. UI and
|
||||
letterbox bars outside the playfield pass through untouched. If the chain throws,
|
||||
the frame still shows the unprocessed composite; a broken preset degrades to
|
||||
"shader off", never to a crash or a blank frame.
|
||||
|
||||
Three details in that path are non-obvious:
|
||||
|
||||
- **DPI.** `love.graphics.newCanvas` and `draw` work in LOVE's DPI-aware
|
||||
logical units, not raw pixels, so the viewport handed to the pass graph and
|
||||
the final draw-back position are both converted from `rect`'s physical pixels
|
||||
first. On a `dpiscale = 1` desktop the two are numerically identical and the
|
||||
bug is invisible; at dpiscale 3 on real Android hardware the chain output
|
||||
rendered about three times too large and at a pixel-valued offset in unit
|
||||
space.
|
||||
- **Draw color.** `cropToGbSource` sets `setColor(1, 1, 1, 1)` explicitly. The
|
||||
caller can leave the draw color dirty (a menu's black text leaves it at
|
||||
`(0,0,0,x)`), the crop draw multiplies the canvas texels by the active color,
|
||||
and `push("all")` saves state for `pop()` without resetting it. That was the
|
||||
root cause of the Gen 2 blank-menu bug, confirmed on a desktop repro where
|
||||
`getColor()` read `0,0,0,1` here exactly when a menu was on the stack.
|
||||
`flushBatch()` on the line above is cheap insurance against a read-after-write
|
||||
ordering hazard between this draw and whatever last rendered into the canvas;
|
||||
it was never confirmed to fix anything on its own.
|
||||
- **The final blit stretches.** A slang chain's last pass is not required to
|
||||
land on the viewport size, and most presets (21 of the 78 in the corpus)
|
||||
declare their last pass `scale_type = "source"` and stay at native Game Boy
|
||||
resolution, relying on the frontend's blit exactly as RetroArch does.
|
||||
Requiring an exact size match here used to skip the draw outright for every
|
||||
such preset on every frame, which is a silent total no-op rather than a sizing
|
||||
quirk. The stretch uses the last-run chain's own final-pass `filter` to pick
|
||||
nearest or linear.
|
||||
|
||||
### What this engine feeds shaders that a libretro core does not
|
||||
|
||||
A stock libretro core hands its frontend a raw framebuffer and a frame count.
|
||||
This engine has more context available and passes some of it through.
|
||||
|
||||
| Context | How it reaches the shader |
|
||||
| --- | --- |
|
||||
| Playfield rect and true source size | `rect`/`source` per frame from `Renderer:endFrame` or `Game2`, so the chain sees real on-screen geometry at any survey zoom or Faithful Ratio state rather than a fixed 160x144 assumption that then gets stretched |
|
||||
| Blit scale | `rect.scale`, the crisp integer scale the composite was built at, used to derive the crop's own draw scale |
|
||||
| SGB zone coloring and palette | Baked into the input frame. `PaletteFX` zone passes run before the composite reaches ShaderFX, so a preset shades an already-zone-tinted image |
|
||||
| Performance tier | `chainRenderScale()` reads `Performance.CAPS[tier].shaderfx`, a chain-resolution multiplier; the viewport and the cropped source both shrink by it and the final blit upscales |
|
||||
| Accelerometer | `Sensors.read("accelerometer")`, bound to the `Accelerometer` unique semantic |
|
||||
| Gyroscope | `Sensors.read("gyroscope")`, bound to `Gyroscope`, plus the integrated yaw twist below |
|
||||
|
||||
Two motion semantics are deliberately pinned rather than guessed. `Rotation` is
|
||||
bound to 0 because librashader's own documentation is explicit that it is
|
||||
`retroarch_get_rotation()`, the *content's* requested rotation (a vertically
|
||||
oriented arcade core, say), not device orientation. Nothing here ever rotates
|
||||
Game Boy content, so 0 is the correct answer, not a placeholder.
|
||||
`AccelerometerRest` is bound to `{0, 0, 0}`: it is librashader's "reading at
|
||||
rest" calibration reference, no preset in the corpus reads it, and a fixed
|
||||
placeholder beats an invented value.
|
||||
|
||||
`src/core/Sensors.lua` is what makes the two real motion semantics work.
|
||||
`love.sensor` does not exist in LOVE 11.5, the version this project ships, on
|
||||
any platform including Android; it is a LOVE 12 addition. The working path is
|
||||
raw FFI into the SDL2 that LOVE already links, the same technique
|
||||
`src/core/Orientation.lua` uses, opening the first `SDL_SENSOR_ACCEL` or
|
||||
`SDL_SENSOR_GYRO` device via `SDL_NumSensors`/`SDL_SensorGetDeviceType`/
|
||||
`SDL_SensorOpen`. The `love.sensor` path is kept above it and simply stops being
|
||||
dead code after a future LOVE 12 upgrade. Loading order matters:
|
||||
`ffi.load("SDL2")` first, needed on desktop where SDL2 is a separate DLL, then
|
||||
bare `ffi.C`, needed on Android where love-android links SDL2 statically into
|
||||
`libmain.so` and there is no `libSDL2.so` for `ffi.load` to find by name. A
|
||||
device with no sensor is probed once and then permanently reports zeros, so a
|
||||
desktop run does not pay for it every frame.
|
||||
|
||||
SDL keeps sensor readings in the device's fixed chassis frame regardless of
|
||||
screen orientation, so `rotateForScreen` remaps x and y into
|
||||
"as currently displayed" terms using `SDL_GetDisplayOrientation`. That
|
||||
compensation is mobile-only: a desktop monitor is legitimately and permanently
|
||||
"landscape" to that query, which says something about the monitor's shape and
|
||||
nothing about how a player is holding anything.
|
||||
|
||||
The accelerometer path in `sizeTable` does three things to the raw reading
|
||||
before it becomes a uniform, all of them driven by real on-device data:
|
||||
|
||||
1. **Rest-pose subtraction.** `activate()` snapshots whatever pose the player is
|
||||
actually holding the device in and every later reading is measured relative
|
||||
to that, rather than to an assumed idealized vertical. The shipped tilt maths
|
||||
(`pt_base.inc`'s `getOrientedTilt`) was authored assuming gravity sits almost
|
||||
entirely on one axis at rest; a natural, comfortable hold already puts 56 to
|
||||
66 percent of gravity's magnitude on the axis the shader reads as tilt, so
|
||||
the effect sat near-saturated all the time instead of starting near neutral.
|
||||
2. **Axis swap.** The tilt maths assumes a device resting flat, with gravity
|
||||
dominant on Z, the one axis it never reads. This engine's rest pose is
|
||||
upright portrait, where Y is gravity-dominant, so y and z are swapped to put
|
||||
gravity back on the ignored axis.
|
||||
3. **Denominator stabilization.** `getOrientedTilt` normalizes by the full
|
||||
vector's magnitude. Before calibration that magnitude was a stable ~9.8 that
|
||||
quietly damped tilt and noise alike by the same factor; calibration correctly
|
||||
zeroes x and y at neutral but also shrinks the magnitude near rest, and real
|
||||
logs showed it swinging between 0.65 and 11.4 second to second on ordinary
|
||||
hand jitter, which reads as wildly bouncing. A fixed constant is re-injected
|
||||
on the ignored axis to keep the denominator stable, but only when there is a
|
||||
genuine live reading to calibrate against. An all-zero raw read is
|
||||
`Sensors.lua`'s explicit "no hardware at all" sentinel, never a real value on
|
||||
Earth, and injecting into that case would make the shader believe it had
|
||||
sensor data and silently replace its own static fallback with fake motion.
|
||||
|
||||
Yaw is a separate mechanism. A raw gyroscope reading is angular velocity, not an
|
||||
angle, so it only becomes a usable on-screen offset by integrating over time,
|
||||
and only the per-slot state persists frame to frame to do that. `updateYawTwist`
|
||||
is deliberately a decaying spring rather than a true integrated heading:
|
||||
gyro-only integration drifts without a magnetometer to correct it, so this
|
||||
settles back toward neutral and stays bounded by construction. It is folded onto
|
||||
the accelerometer's x component before the shader's own normalize and clamp,
|
||||
because that is the only already-compiled channel the stock upstream maths
|
||||
reads, and reaching an already-converted artifact with no reconvert was worth
|
||||
the tradeoff that the twist reads as an added simulated tilt rather than a
|
||||
cleanly separate motion. It applies to `sunlight_shimmer.slangp` only, the one
|
||||
preset in the shortlist with a twist-reactive channel. `YAW_GAIN = 0.6` and a
|
||||
clamp of +/-2 were tuned against real device data (a moderate real yaw turn
|
||||
peaks around 1.5 to 1.7 rad/s); a much larger gain was tried on-device and
|
||||
looked worse, because overshooting a comfortable range reads worse than being
|
||||
subtle. Retune in small steps with real device checks, not big jumps.
|
||||
|
||||
## Two slots
|
||||
|
||||
`ShaderFX.SLOTS` is `{"main", "secondary"}` and `ShaderFX.OPTION_KEY` maps each
|
||||
to its save key. The slots are activated and persisted independently, and the
|
||||
same `ShaderFXScreen` serves both, opened with the slot as its argument. Pragma
|
||||
parameter edits are keyed by *preset name*, not by slot, because a preset's
|
||||
values are a property of the preset the same way its cached artifact is;
|
||||
editing them re-activates every slot currently showing that preset and persists
|
||||
for the next load in either.
|
||||
|
||||
When both slots are active, `render` runs main's chain first and hands its
|
||||
finished output to secondary as secondary's own input frame, along with its
|
||||
dimensions, so a secondary preset that scales off its input sees main's real
|
||||
output size rather than the original crop. Either slot alone behaves exactly as
|
||||
a single-preset path; neither active is a plain passthrough.
|
||||
|
||||
**This is not how RetroArch composes multiple presets.** RetroArch merges
|
||||
presets into a *single* pass list through `#reference` and `Append`, producing
|
||||
one pass graph with one shared semantics map, where a later pass can reference
|
||||
an earlier one's output by alias and the whole thing resolves as one unit. Two
|
||||
slots here are two independent librashader chains run back to back, which is
|
||||
what stacking two separate preset chains would give you, not what merging them
|
||||
gives you. Presets that assume merged semantics will not behave the same way.
|
||||
|
||||
## Test seams
|
||||
|
||||
`ShaderFX` exposes a few fields purely so a headless harness can assert on real
|
||||
per-frame values without taking a screenshot: `_lastRect` and `_lastSource`
|
||||
(the rect and source dimensions a caller handed in), `_lastCrop` (the exact crop
|
||||
canvas, which the later unconditional draw-back would otherwise mask),
|
||||
`_lastYawTwist` and `_lastAccelPacked` (the integrated twist and the values that
|
||||
actually reached the packed uniform, per slot). `Sensors.setOverride`,
|
||||
`Sensors.clearOverride` and `Sensors.setOrientationOverride` inject synthetic
|
||||
readings on a machine with no hardware.
|
||||
|
||||
## Limitations
|
||||
|
||||
None of these are theoretical.
|
||||
|
||||
- **Tested on very little real hardware.** Essentially one Android phone, one
|
||||
desktop, and the automated harnesses. Anything about how a preset actually
|
||||
looks or performs elsewhere is unverified.
|
||||
- **No performance tier is actually tuned.** The chain-resolution multiplier in
|
||||
`Performance.CAPS` is a working mechanism, but every tier that permits
|
||||
ShaderFX at all sets it to 1.0. Nothing runs at reduced chain resolution
|
||||
today. Picking a real value for weak hardware needs a device this project does
|
||||
not have.
|
||||
- **The dual-slot design does not match RetroArch.** See above. Two chains in
|
||||
sequence is not one merged pass list.
|
||||
- **`OriginalHistoryN` is a steady state.** Real per-frame history rotation
|
||||
falls back to "every slot is the current frame" in the live render path, and
|
||||
is unverified there.
|
||||
- **`PassFeedback` is unimplemented.** Its size uniform raises an explicit
|
||||
error and its sampler trips an assertion.
|
||||
- **`ShaderFXScreen` has a known text-overlap bug on long preset names.**
|
||||
`ListMenu`'s `fitLabel` truncation covers the ordinary case, but a long enough
|
||||
player-supplied filename still collides with the row's right-hand hint.
|
||||
- **`ShaderSourcePatches` ships with an empty patch table and nothing uses it.**
|
||||
Intentional, for the reason given above, but it means the mechanism has no
|
||||
live coverage.
|
||||
- **Cached artifacts have no staleness detection.** Existence is the only check.
|
||||
The two unconditional reconvert points paper over it; anything that does not
|
||||
go through them can be running a stale translation.
|
||||
- **Artifacts are per-device.** The GLSL dialect is baked in at convert time.
|
||||
Copying a converted preset folder between a phone and a desktop copies a
|
||||
wrong artifact along with it.
|
||||
- **The bundled bridge is only as good as the build machine.**
|
||||
`scripts/build.sh` bundles the cdylib for mac, win and linux via
|
||||
`bundle_shader_bridge`, building it with cargo when a prebuilt one is not
|
||||
supplied through `SHADERFX_BRIDGE`. A build host without cargo produces a
|
||||
package that can run converted presets but cannot CONVERT new ones, and says
|
||||
so rather than failing. `scripts/build_android.sh` and `scripts/build_ios.sh`
|
||||
follow the same rule with `cargo ndk` and the iOS static archive.
|
||||
- **The buildbot shortlist is a temporary trim.** `KEPT_PRESETS` reflects one
|
||||
manual pass over `handheld/` and is expected to change, most likely to shrink.
|
||||
- **Tilt direction is unverified.** Which way forward and back rocking moves the
|
||||
effect was never confirmed on a device; if it feels backwards the fix is a
|
||||
sign flip on the swapped axis, not a deeper bug. Likewise, whether the
|
||||
landscape rotation compensation matches real RetroArch is genuinely unknown:
|
||||
RetroArch's Android input driver computes a screen rotation but does not
|
||||
visibly apply it to the accelerometer values that reach shader uniforms, so
|
||||
this project's compensation may be an improvement over upstream rather than a
|
||||
match to it.
|
||||
- **`texelFetch` wrap behavior at image edges may differ.** The ES 1.00 rewrite
|
||||
in the bridge turns those calls into `texture()`, which honours the sampler's
|
||||
wrap mode out of range where `texelFetch`'s out-of-bounds behavior is
|
||||
implementation defined. Unmeasured.
|
||||
+64
-25
@@ -1,11 +1,11 @@
|
||||
# Touch skins and the Skin Studio
|
||||
|
||||
A **skin** replaces the on-screen controls wholesale: a bezel image, a
|
||||
control layout, and the rectangle the Game Boy screen is drawn into. Engine:
|
||||
control layout, and a screen-placement anchor. Engine:
|
||||
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
|
||||
(draw and input), `src/render/Renderer.lua` (the screen viewport),
|
||||
(draw and input), `src/render/Renderer.lua` (screen placement),
|
||||
`src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export),
|
||||
`src/ui/SkinStudio.lua` (the desktop editor). Tests:
|
||||
`src/ui/SkinStudio.lua` (the responsive skin editor). Tests:
|
||||
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
|
||||
`tests/engine/skin_studio_ux.lua`,
|
||||
`tests/engine/skin_studio_image_import.lua`,
|
||||
@@ -13,8 +13,10 @@ control layout, and the rectangle the Game Boy screen is drawn into. Engine:
|
||||
`tests/engine/launcher_skins_tab.lua`,
|
||||
`tests/engine/launcher_skins_ux.lua`.
|
||||
|
||||
Skins are picked in the launcher's **Skins** tab, which also imports them and
|
||||
opens the studio. `options.touchControls.skin` holds the folder name.
|
||||
The launcher's **Skins** tab imports skins, shows the enabled skin, exports it,
|
||||
and is the one place that turns skin use off. **My Skins** holds the visual
|
||||
grid, pagination, edit, delete and per-skin export actions.
|
||||
`options.touchControls.skin` holds the folder name.
|
||||
|
||||
## Formats
|
||||
|
||||
@@ -30,7 +32,7 @@ as-is. Supported keys:
|
||||
| `overlays` | page count |
|
||||
| `overlayN_name` | page name, the target of `next_target` |
|
||||
| `overlayN_overlay` | bezel image |
|
||||
| `overlayN_full_screen` | stretch the page to the window |
|
||||
| `overlayN_full_screen` | cover the window with the page without deforming its artwork |
|
||||
| `overlayN_rect` | page placement, default `0,0,1,1` |
|
||||
| `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen |
|
||||
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
|
||||
@@ -98,8 +100,8 @@ corners fire two directions. `screens[1].outputFrame` (or the legacy
|
||||
`gameScreenFrame`) becomes the screen cutout. A portrait page with neither
|
||||
keeps `mappingSize` as the overlay aspect, sits at the bottom of the
|
||||
window, and puts the Game Boy picture in the leftover space above -- the
|
||||
usual GBA4iOS controller-deck layout. Pages that name a screen rect still
|
||||
stretch to the window the way Delta does. Host functions map to
|
||||
usual GBA4iOS controller-deck layout. Pages that name a screen rect fit the
|
||||
game into it. Host functions map to
|
||||
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
|
||||
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
|
||||
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
|
||||
@@ -135,7 +137,7 @@ to decoration and never captures a touch.
|
||||
As an extension to the format, `key:<name>` presses any keyboard key, which is
|
||||
how a skin button reaches a mod hotkey.
|
||||
|
||||
## The screen viewport
|
||||
## Screen placement
|
||||
|
||||
`overlayN_viewport` is the cutout the picture is fitted into. The Game Boy
|
||||
screen keeps its whole-pixel scale and letterboxes inside that rect rather than
|
||||
@@ -146,6 +148,17 @@ that lets a widescreen bezel take the filling survey-zoom world view instead.
|
||||
A viewport also implies the faithful-ratio lock. Without it the world pass
|
||||
expands to fill the cutout and you get more map instead of a Game Boy screen.
|
||||
|
||||
Zoom still steps around that hole: OUT shows more map inside it, IN enlarges
|
||||
the world, and the start menu stays at the hole's fit scale instead of
|
||||
shrinking with the map.
|
||||
|
||||
An image-backed portrait overlay that has no explicit vertical anchor is treated
|
||||
as a controller deck: it is contained without deformation and pinned to the
|
||||
bottom on taller screens. The spare space belongs to the game above it.
|
||||
|
||||
When a skin is active, **SCREEN POS** reads **SKIN**: placement comes from the
|
||||
skin rather than the normal Center / Upper / Top setting.
|
||||
|
||||
Border art often ships with a transparent hole and no `viewport` key. **Detect
|
||||
screen from bezel** in the studio measures the hole out of the art's alpha
|
||||
channel and writes the rect.
|
||||
@@ -153,10 +166,8 @@ channel and writes the rect.
|
||||
## Bezels versus pads
|
||||
|
||||
A skin whose active page binds nothing is a frame rather than a pad: a TV
|
||||
surround, a handheld shell, a Super Game Boy border. Those draw on **desktop**
|
||||
as well, where the touch overlay itself does not, and a gamepad does not hide
|
||||
them. Anything that binds a button still follows the usual mobile /
|
||||
`POKEPORT_TOUCH` rule.
|
||||
surround, a handheld shell, a Super Game Boy border. Selected skins draw on
|
||||
**desktop** as well as mobile; a gamepad does not hide them.
|
||||
|
||||
## Installing
|
||||
|
||||
@@ -190,9 +201,32 @@ shipping branding.
|
||||
|
||||
## The studio
|
||||
|
||||
Launcher, Skins tab, **Open Skin Studio**, or the gear on any skin row to open
|
||||
that skin. Desktop only: the launcher does not offer it on Android or iOS,
|
||||
because it wants a mouse, typed coordinates and room for an inspector.
|
||||
Launcher, Skins tab, **My Skins** opens the Studio library on desktop and
|
||||
mobile. **My Skins** is the only visual grid: real bezel previews plus create
|
||||
and import actions, with each card owning Edit, Export and (for installed
|
||||
skins) Delete. Choosing Edit opens a separate, canvas-first editor; the old
|
||||
New/Load workspace controls are deliberately not duplicated inside that editor.
|
||||
|
||||
The editor keeps its canvas unobstructed and puts the contextual actions in a
|
||||
compact lower tray: add/control binding, button and bezel artwork, pages,
|
||||
screen placement, freeform/10:9 screen shape and deletion. **Screen** opens
|
||||
cutout, bezel-hole detect, **Detect this screen**, and the canvas presets.
|
||||
**Detect this screen** (also on the tray) sets the mock device to the live
|
||||
window size so a phone skin is authored at that phone's form factor rather
|
||||
than a generic 1080x1920 16:9. **Zoom −** shrinks the mock device inside the
|
||||
workspace so the screen hole can be dragged larger than the bezel while the
|
||||
handles stay grabable; **Fit** restores contain. The mouse wheel over the
|
||||
canvas, and `-` / `=` / `0` on a keyboard, do the same. Touches select, drag and resize the
|
||||
same controls that a mouse edits on desktop.
|
||||
|
||||
My Skins and the editor chrome sit inside the platform safe area (notch,
|
||||
status bar, home indicator), the same inset the launcher uses. The mock
|
||||
device still represents the full window, because a skin covers the whole
|
||||
screen at play time.
|
||||
|
||||
The launcher’s **Turn skins off** button clears the selected skin and disables
|
||||
skin use. With no skin enabled, mobile falls back to the built-in pad; that pad
|
||||
is not itself a skin card.
|
||||
|
||||
**Canvas.** A mock device at a chosen preset, so a phone skin is authored at
|
||||
phone proportions on a desktop monitor.
|
||||
@@ -200,6 +234,7 @@ phone proportions on a desktop monitor.
|
||||
| Preset | Size |
|
||||
| --- | --- |
|
||||
| Phone portrait / landscape | 1080x1920, 1920x1080 |
|
||||
| This screen | the live window, so a phone is authored at its own height |
|
||||
| Tablet portrait / landscape | 1536x2048, 2048x1536 |
|
||||
| Steam Deck | 1280x800 |
|
||||
| Desktop 1080p | 1920x1080 |
|
||||
@@ -216,13 +251,16 @@ and of the page itself when it comes within a few pixels, and the guide it
|
||||
snapped to is drawn. X / Y / W / H are in canvas pixels, so a control can be
|
||||
typed to the coordinate its art was drawn at. **Back** and **Front** move the
|
||||
selection through the draw order. Bind, hitbox shape, hit reach and idle and
|
||||
pressed images are per control; the bezel, the pages and the screen cutout are
|
||||
per page. The cutout is itself a draggable element with a 10:9 lock.
|
||||
pressed images are per control; the bezel, the pages and the screen anchor are
|
||||
per page. The SCREEN anchor is itself draggable and resizable; its default
|
||||
shape is freeform, with an optional 10:9 lock.
|
||||
|
||||
**Bind** opens a grid of every bind the engine understands: the eight Game Boy
|
||||
buttons, the diagonal pairs, every hotkey, a few `key:` entries, and
|
||||
decoration. The COMBINE chips at the top toggle one part at a time, which is
|
||||
how a pipe bind like `left|down` is built without typing it.
|
||||
buttons, the diagonal pairs, every hotkey, desktop hotkeys and decoration.
|
||||
The desktop section exposes `-` / `=`, `1` through `5`, `F1`, `F2` and `F10`
|
||||
as `key:` controls, so a mobile button invokes the exact same game path as
|
||||
its desktop shortcut. The COMBINE chips at the top toggle one part at a time,
|
||||
which is how a pipe bind like `left|down` is built without typing it.
|
||||
|
||||
**Undo** and **Redo** in the top bar cover every edit (ctrl+Z / ctrl+Y, or
|
||||
`u` / shift+`u` without a keyboard modifier). The stack holds the last 50
|
||||
@@ -247,12 +285,13 @@ the **Import** button there and beside each row opens the host file picker (`src
|
||||
zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in
|
||||
the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the
|
||||
window does the same for whichever slot was last touched. A new bezel does not
|
||||
move the screen cutout: press **Detect screen from bezel** to measure it out of
|
||||
move the screen anchor: press **Detect screen from bezel** to measure it out of
|
||||
the art's alpha.
|
||||
|
||||
**Testing.** **Test** makes the canvas live: clicking presses real Game Boy
|
||||
buttons and the footer reports what is held. **Play** saves the skin, selects
|
||||
it, and boots the game with it.
|
||||
**Testing.** **Test** renders a game-composition preview behind the live overlay:
|
||||
the 160x144 picture letterboxes inside the screen cutout, matching gameplay.
|
||||
Clicking presses real Game Boy buttons and the footer reports what is held.
|
||||
**Play** saves the skin, selects it, and boots the game with it.
|
||||
|
||||
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
|
||||
skin names, so the folder stands alone. **Export** offers three formats, and
|
||||
|
||||
@@ -183,7 +183,6 @@ hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
|
||||
| ----- | -------------- | ------------------- |
|
||||
| Select + **A** | `2` | COLORS |
|
||||
| Select + **B** | `3` | TILT |
|
||||
| Select + **Y** | `5` | GBC FX |
|
||||
| Select + **X** | `6` | Mod pipeline hotkey (if a mod registers `6`) |
|
||||
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ 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\|gold\|silver/` then that game's SAVE FILES → **Import save** (Gold / Silver cart `.sav` not supported yet) |
|
||||
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver/` (pull after **Export save**; Gold / Silver cart `.sav` not supported yet) |
|
||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver\|crystal/` then that game's SAVE FILES → **Import save** (Gen 2 cart `.sav` not supported yet, on Gold, Silver or Crystal) |
|
||||
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver\|crystal/` (pull after **Export save**; Gen 2 cart `.sav` not supported yet, on Gold, Silver or Crystal) |
|
||||
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
||||
| Lua error log | `lua-error.log` in the save dir |
|
||||
|
||||
@@ -54,8 +54,8 @@ macOS, not a Mac-only requirement.
|
||||
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
|
||||
(or copy NRO / `game.love` for loose).
|
||||
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
|
||||
`imports/saves/<red|blue|yellow|gold|silver>/`, or
|
||||
`exports/<red|blue|yellow|gold|silver>/`
|
||||
`imports/saves/<red|blue|yellow|gold|silver|crystal>/`, or
|
||||
`exports/<red|blue|yellow|gold|silver|crystal>/`
|
||||
path the launcher prints.
|
||||
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
||||
|
||||
|
||||
+28
-5
@@ -63,8 +63,10 @@ mounted or deleted as stale; the launcher directs the player to a full package.
|
||||
## Release assets
|
||||
|
||||
Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
||||
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
|
||||
`-android.apk`) plus two assets the updater itself consumes:
|
||||
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`,
|
||||
`-linux-x86_64.AppImage`, `-linux-arm64.AppImage`, `-linux.flatpak`,
|
||||
`-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and
|
||||
PortMaster archives) plus two assets the updater itself consumes:
|
||||
|
||||
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
|
||||
`gen1recomp-<version>.love` (see `isPayloadName` in `Boot.lua` and
|
||||
@@ -75,8 +77,9 @@ Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
||||
filename otherwise to match the asset name exactly.
|
||||
|
||||
A release missing either asset is treated as "no in-place update available":
|
||||
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
|
||||
(`https://github.com/bryanthaboi/gen1recomp/releases/latest`).
|
||||
`Check` reports `needs_full`. It also selects the exact current platform asset
|
||||
from the same release and persists the requirement, so it is visible again on
|
||||
every launch, including offline launches.
|
||||
|
||||
## Save-directory layout
|
||||
|
||||
@@ -85,6 +88,7 @@ Under the save directory (identity `pokemon-love2d`):
|
||||
```
|
||||
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
|
||||
updates/pending.txt crash-guard marker
|
||||
updates/full-update.json persistent native-package requirement
|
||||
```
|
||||
|
||||
`pending.txt` holds the filename of the payload currently being chainloaded.
|
||||
@@ -106,7 +110,8 @@ bundled game, in that case.
|
||||
against the GitHub releases API; safe to call every frame, it is a no-op
|
||||
once a check is in flight or has reached a terminal state. `Check.state()`
|
||||
reports `idle | checking | uptodate | available | downloading | ready |
|
||||
needs_full | error` plus the latest version and download progress.
|
||||
needs_full | full_downloading | full_ready | error` plus the latest version,
|
||||
download progress, and (when applicable) the selected full-package asset.
|
||||
3. **Download + verify**: on `available`, `Check.download()` tells the
|
||||
worker to fetch the payload, polling the growing `.part` file for
|
||||
progress. On completion the worker re-fetches `sha256sums.txt`, verifies
|
||||
@@ -117,6 +122,17 @@ bundled game, in that case.
|
||||
4. **Restart to apply**: a `ready` payload just sits in `updates/` until the
|
||||
player relaunches; the next launch's Boot step (1) is what actually
|
||||
mounts and runs it. There is no in-session hot-swap.
|
||||
5. **Native-package requirement**: when `minShell` or `payloadHost` is
|
||||
incompatible, the worker writes `full-update.json` and surfaces a
|
||||
persistent launcher control. Android downloads the release APK, verifies
|
||||
its SHA-256 entry from `sha256sums.txt`, then invokes Android's Package
|
||||
Installer. The installer asks the user for consent and enforces package,
|
||||
version-code, and signing-certificate compatibility. A legacy APK without
|
||||
the installer bridge links its full package for one manual bootstrap
|
||||
update, including when its downloaded payload already reports the latest
|
||||
engine version. iOS links the sideload repository for a re-sideload; Xbox,
|
||||
desktop, and PortMaster builds link their correctly named full package.
|
||||
Switch keeps its native OTA flow.
|
||||
|
||||
## Known limitations
|
||||
|
||||
@@ -140,6 +156,13 @@ bundled game, in that case.
|
||||
still need a full reinstall (`minShell` / `payloadHost` gate →
|
||||
`needs_full`). Applying a downloaded payload on Android relaunches via
|
||||
`love.system.restartApp`; iOS still uses in-process `quit("restart")`.
|
||||
- **Android full updates are user-confirmed and certificate-bound.** The app
|
||||
uses a private `FileProvider` cache path plus
|
||||
`Intent.ACTION_INSTALL_PACKAGE`, checks Android 8+'s per-app
|
||||
"install unknown apps" setting, and never requests a silent install. The
|
||||
release job must use the original long-lived Android signing key; a new key
|
||||
causes Android to reject an in-place update and requires a one-time manual
|
||||
reinstall. See [mobile/ANDROID.md](../mobile/ANDROID.md).
|
||||
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
|
||||
`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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=gen1recomp
|
||||
Comment=Pokémon Gen 1/2 recompilation
|
||||
Exec=gen1recomp
|
||||
Icon=com.theboisclub.gen1recomp
|
||||
Terminal=false
|
||||
Categories=Game;
|
||||
StartupWMClass=love
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>com.theboisclub.gen1recomp</id>
|
||||
<name>gen1recomp</name>
|
||||
<summary>Pokémon Gen 1/2 recompilation</summary>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>LicenseRef-proprietary</project_license>
|
||||
<developer_name>The Bois Club</developer_name>
|
||||
<description>
|
||||
<p>
|
||||
A LÖVE-based recompilation of Pokémon Gen 1 and Gen 2 with a launcher,
|
||||
ROM importer, mods, and optional cloud save sync. Requires your own
|
||||
legal cartridge dumps.
|
||||
</p>
|
||||
</description>
|
||||
<launchable type="desktop-id">com.theboisclub.gen1recomp.desktop</launchable>
|
||||
<url type="homepage">https://github.com/bryanthaboi/gen1recomp</url>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Launcher</caption>
|
||||
<image>https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<content_rating type="oars-1.1"/>
|
||||
<releases>
|
||||
<release version="0.0.0" date="1970-01-01"/>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -0,0 +1,68 @@
|
||||
# Template consumed by scripts/build_flatpak.sh (placeholders substituted).
|
||||
# Do not pass this file directly to flatpak-builder.
|
||||
app-id: com.theboisclub.gen1recomp
|
||||
runtime: org.freedesktop.Platform
|
||||
runtime-version: "24.08"
|
||||
sdk: org.freedesktop.Sdk
|
||||
command: gen1recomp
|
||||
finish-args:
|
||||
- --share=network
|
||||
- --share=ipc
|
||||
- --socket=x11
|
||||
- --socket=wayland
|
||||
- --device=dri
|
||||
- --socket=pulseaudio
|
||||
# Full input device access so SDL2 sees udev/evdev gamepads + rumble/gyro
|
||||
- --device=all
|
||||
- --filesystem=home
|
||||
modules:
|
||||
- name: curl
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- tar -xJf curl-static.tar.xz
|
||||
- install -Dm755 curl /app/bin/curl
|
||||
sources:
|
||||
- type: file
|
||||
url: https://github.com/stunnel/static-curl/releases/download/8.21.0/curl-linux-x86_64-musl-8.21.0.tar.xz
|
||||
sha256: e955f211202ded2536164588331acfc987dc4b7857efa3577717b1ffeab22029
|
||||
dest-filename: curl-static.tar.xz
|
||||
|
||||
- name: love
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- chmod +x love-x86_64.AppImage
|
||||
- ./love-x86_64.AppImage --appimage-extract
|
||||
- mkdir -p /app/lib/love /app/bin /app/share/gen1recomp
|
||||
- cp -a squashfs-root/bin/love /app/bin/love
|
||||
- cp -a squashfs-root/lib/. /app/lib/love/
|
||||
- |
|
||||
cat > /app/bin/gen1recomp <<'EOF'
|
||||
#!/bin/sh
|
||||
export LD_LIBRARY_PATH="/app/lib/love:${LD_LIBRARY_PATH:-}"
|
||||
exec /app/bin/love /app/share/gen1recomp/game.love "$@"
|
||||
EOF
|
||||
- chmod +x /app/bin/gen1recomp
|
||||
- install -Dm644 game.love /app/share/gen1recomp/game.love
|
||||
# BRIDGE-BEGIN
|
||||
- install -Dm755 liblibrashader_bridge.so /app/share/gen1recomp/liblibrashader_bridge.so
|
||||
# BRIDGE-END
|
||||
- install -Dm644 com.theboisclub.gen1recomp.desktop /app/share/applications/com.theboisclub.gen1recomp.desktop
|
||||
- install -Dm644 com.theboisclub.gen1recomp.metainfo.xml /app/share/metainfo/com.theboisclub.gen1recomp.metainfo.xml
|
||||
- install -Dm644 icon.png /app/share/icons/hicolor/512x512/apps/com.theboisclub.gen1recomp.png
|
||||
sources:
|
||||
- type: file
|
||||
url: https://github.com/love2d/love/releases/download/11.5/love-11.5-x86_64.AppImage
|
||||
sha256: 65a673406431eff7167a15a032bf7a2e4ba50108e091eb7b176465831f9b5e00
|
||||
dest-filename: love-x86_64.AppImage
|
||||
- type: file
|
||||
path: game.love
|
||||
# BRIDGE-BEGIN
|
||||
- type: file
|
||||
path: liblibrashader_bridge.so
|
||||
# BRIDGE-END
|
||||
- type: file
|
||||
path: com.theboisclub.gen1recomp.metainfo.xml
|
||||
- type: file
|
||||
path: com.theboisclub.gen1recomp.desktop
|
||||
- type: file
|
||||
path: icon.png
|
||||
@@ -22,18 +22,91 @@ local PlatformHooks = require("src.core.PlatformHooks")
|
||||
local HostDisplay = require("src.core.HostDisplay")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
|
||||
-- Global emergency quit: holding Start + Select for 5 seconds forcefully terminates LOVE.
|
||||
local emergencyQuitTimer = 0
|
||||
|
||||
local function checkEmergencyQuit(dt)
|
||||
local held = false
|
||||
if love.joystick and love.joystick.getJoysticks then
|
||||
local joysticks = love.joystick.getJoysticks()
|
||||
for _, j in ipairs(joysticks) do
|
||||
if j:isGamepad() then
|
||||
local start = j:isGamepadDown("start")
|
||||
local selectBtn = j:isGamepadDown("back") or j:isGamepadDown("guide")
|
||||
if start and selectBtn then
|
||||
held = true
|
||||
break
|
||||
end
|
||||
else
|
||||
local bCount = j:getButtonCount()
|
||||
local s1 = (bCount >= 7 and j:isDown(7)) or (bCount >= 9 and j:isDown(9))
|
||||
local s2 = (bCount >= 8 and j:isDown(8)) or (bCount >= 10 and j:isDown(10))
|
||||
if s1 and s2 then
|
||||
held = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if love.keyboard and love.keyboard.isDown then
|
||||
if (love.keyboard.isDown("escape") and love.keyboard.isDown("return"))
|
||||
or (love.keyboard.isDown("lalt") and love.keyboard.isDown("f4")) then
|
||||
held = true
|
||||
end
|
||||
end
|
||||
|
||||
if held then
|
||||
emergencyQuitTimer = emergencyQuitTimer + (dt or 0.016)
|
||||
if emergencyQuitTimer >= 5.0 then
|
||||
print("[FORCE QUIT] Start + Select held for 5 seconds. Exiting forcefully.")
|
||||
pcall(function()
|
||||
if love.audio and love.audio.stop then love.audio.stop() end
|
||||
if love.window and love.window.close then love.window.close() end
|
||||
end)
|
||||
local exitFn = os["exit"]
|
||||
exitFn(0)
|
||||
end
|
||||
else
|
||||
emergencyQuitTimer = 0
|
||||
end
|
||||
end
|
||||
|
||||
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
||||
do
|
||||
local defaultErrorHandler = love.errorhandler
|
||||
local defaultErrorHandler = love.errorhandler or love.errhand
|
||||
function love.errorhandler(msg)
|
||||
local hint = SwitchDiagnostics.logLuaError(msg)
|
||||
if hint and type(msg) == "string" then
|
||||
local ok, hint = pcall(SwitchDiagnostics.logLuaError, msg)
|
||||
if ok and hint and type(msg) == "string" then
|
||||
msg = msg .. "\n\n" .. hint
|
||||
end
|
||||
|
||||
if love.window and love.window.isOpen and love.window.isOpen() and love.graphics and love.graphics.isActive() then
|
||||
local fullMsg = tostring(msg) .. "\n\n" .. tostring(debug.traceback()) .. "\n\n[Hold START + SELECT for 5s to Force Quit]"
|
||||
return function()
|
||||
love.event.pump()
|
||||
for e, a in love.event.poll() do
|
||||
if e == "quit" or (e == "keypressed" and a == "escape") then
|
||||
return 1
|
||||
elseif e == "gamepadpressed" and (a == "start" or a == "back") then
|
||||
return 1
|
||||
end
|
||||
end
|
||||
checkEmergencyQuit(0.016)
|
||||
love.graphics.origin()
|
||||
love.graphics.clear(0.10, 0.10, 0.12)
|
||||
love.graphics.setColor(1, 0.4, 0.4, 1)
|
||||
love.graphics.printf(fullMsg, 20, 20, love.graphics.getWidth() - 40)
|
||||
love.graphics.present()
|
||||
love.timer.sleep(0.016)
|
||||
end
|
||||
end
|
||||
|
||||
if defaultErrorHandler then
|
||||
return defaultErrorHandler(msg)
|
||||
end
|
||||
end
|
||||
love.errhand = love.errorhandler
|
||||
end
|
||||
|
||||
local Game, EditorApp, Importer, TouchEditor, Studio
|
||||
@@ -82,6 +155,11 @@ end
|
||||
local editorHost, editorVersion, editorWindow
|
||||
local closeEditor -- forward declaration: openEditor hands it to the editor
|
||||
|
||||
-- Drop CacheFs / Data / mod Runtime / Assets / LegacyCompat for one mounted
|
||||
-- version session (save editor or game). closeEditor and returnToLauncher
|
||||
-- both go through SessionLifecycle so neither path forgets a singleton.
|
||||
local SessionLifecycle = require("src.core.SessionLifecycle")
|
||||
|
||||
-- The editor's modules use flat names (require("Kit"), require("Party")), so
|
||||
-- their directories have to be on the require path. It must be
|
||||
-- love.filesystem's path, not package.path: in a packaged build these files
|
||||
@@ -157,9 +235,7 @@ local function openEditor(version, slotId)
|
||||
local okReq, appOrErr = pcall(require, "App")
|
||||
if not okReq then
|
||||
editorMode = false
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
end
|
||||
SessionLifecycle.endEditorSession({ version = version, app = nil })
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
editorHost = nil
|
||||
@@ -179,10 +255,7 @@ local function openEditor(version, slotId)
|
||||
editorMode = false
|
||||
if EditorApp.unload then pcall(EditorApp.unload) end
|
||||
EditorApp = nil
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
end
|
||||
SessionLifecycle.endEditorSession({ version = version, app = nil })
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
editorHost = nil
|
||||
@@ -197,21 +270,14 @@ end
|
||||
-- Back to the launcher. Everything the editor mounted or cached has to come
|
||||
-- back out: the version overlay (CacheFs) and the generated modules require
|
||||
-- cached behind it (Data), or pressing Play on the OTHER game would boot it
|
||||
-- with this one's data.
|
||||
-- with this one's data. Also reset Runtime / Assets / LegacyCompat so the
|
||||
-- next Edit or Play does not inherit the editor's dead mod loader.
|
||||
function closeEditor()
|
||||
local version = editorVersion
|
||||
local app = EditorApp
|
||||
editorMode = false
|
||||
if EditorApp and EditorApp.unload then EditorApp.unload() end
|
||||
EditorApp = nil
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
end
|
||||
for k in pairs(package.loaded) do
|
||||
if type(k) == "string" and (k:find("save%-editor") or k == "App" or k == "Kit" or k == "State" or k == "Catalog" or k == "SaveIO" or k == "Ops" or k == "MonOps" or k == "ItemOps" or k == "PadInput" or k == "Gen" or k == "Theme") then
|
||||
package.loaded[k] = nil
|
||||
end
|
||||
end
|
||||
SessionLifecycle.endEditorSession({ version = version, app = app })
|
||||
editorVersion = nil
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
@@ -294,14 +360,16 @@ end
|
||||
local function makeLauncher()
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
|
||||
return RomImporter.new(function(version)
|
||||
return RomImporter.new(function(version, cartId)
|
||||
Importer = nil
|
||||
bootGame(version)
|
||||
bootGame(version, cartId)
|
||||
end, {
|
||||
launcher = true,
|
||||
forceImport = forceImport,
|
||||
onEditSave = openEditor,
|
||||
onEditTouchControls = openTouchControlsEditor,
|
||||
-- Skin Studio owns a touch-first layout as well as the desktop workspace.
|
||||
-- Keep the compatibility predicate so external hosts using it still work.
|
||||
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
|
||||
and openSkinStudio or nil,
|
||||
})
|
||||
@@ -310,45 +378,19 @@ end
|
||||
local function returnToLauncher()
|
||||
if not Game then return end
|
||||
|
||||
pcall(function() require("src.core.Music").stop() end)
|
||||
pcall(function() require("src.core.Sound").stop() end)
|
||||
if package.loaded["src.core.ChipAudio"] then
|
||||
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
||||
end
|
||||
if package.loaded["src.core.DiscordPresence"] then
|
||||
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
|
||||
end
|
||||
if package.loaded["src.core.gen2.Clock"] then
|
||||
pcall(package.loaded["src.core.gen2.Clock"].shutdown)
|
||||
end
|
||||
if package.loaded["src.net.Gen1Tls"] then
|
||||
pcall(package.loaded["src.net.Gen1Tls"].shutdown)
|
||||
end
|
||||
if love.audio and love.audio.stop then
|
||||
pcall(love.audio.stop)
|
||||
end
|
||||
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local currentVersion = GameVersion.get()
|
||||
if currentVersion then
|
||||
require("src.import.CacheFs").unmountVersion(currentVersion)
|
||||
end
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
if Runtime.reset then
|
||||
Runtime.reset()
|
||||
end
|
||||
|
||||
SessionLifecycle.endGameSession(Game)
|
||||
Game = nil
|
||||
autopilot = nil
|
||||
driverCo = nil
|
||||
-- Leave the cart's scope behind: the launcher's own settings and slots are
|
||||
-- the base game's, not the cart's. The speed ladder is cart state too, so
|
||||
-- a 1x/2x cart must not pin the launcher or the next game.
|
||||
require("src.core.SaveData").setCart(nil)
|
||||
require("src.core.GameSpeed").setAllowed(nil)
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
Input:reset()
|
||||
TouchControls:reset()
|
||||
SessionLifecycle.endMountedSession(currentVersion)
|
||||
|
||||
require("src.core.Orientation").applyOptions(
|
||||
require("src.core.SaveData").loadOptions())
|
||||
@@ -364,7 +406,7 @@ local function returnToLauncher()
|
||||
Importer = makeLauncher()
|
||||
end
|
||||
|
||||
function bootGame(version)
|
||||
function bootGame(version, cartId)
|
||||
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
|
||||
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
|
||||
-- Set the active version and overlay its extracted cache BEFORE anything
|
||||
@@ -377,6 +419,25 @@ function bootGame(version)
|
||||
-- (Blue/Yellow/Gold caches live under blue/ / yellow/ / gold/).
|
||||
CacheFs.prefix = GameVersion.cachePrefix()
|
||||
CacheFs.mountVersion(GameVersion.get())
|
||||
local cartHash, cartSpeeds, cartOptions
|
||||
if cartId then
|
||||
local ok, cart, hash = pcall(function()
|
||||
return require("src.carts.CartStore").get(cartId)
|
||||
end)
|
||||
if ok and cart then
|
||||
cartHash, cartSpeeds, cartOptions = hash, cart.speeds, cart.options
|
||||
else
|
||||
cartId = nil
|
||||
end
|
||||
end
|
||||
local SaveData = require("src.core.SaveData")
|
||||
SaveData.setCart(cartId, cartHash)
|
||||
-- The author's settings land in the cart's own scope the first time only;
|
||||
-- after that the player owns them.
|
||||
if cartOptions then SaveData.seedCartOptions(cartOptions) end
|
||||
-- A cart may narrow or pin the speed ladder; nil restores the full one.
|
||||
require("src.core.GameSpeed").setAllowed(cartSpeeds)
|
||||
if cartId then SaveData.adoptCartSeal(cartId) end
|
||||
-- NX: always write nx-asset-probe.log so Yellow/Blue art failures are
|
||||
-- diagnosable from the SD without enabling switch-debug.txt.
|
||||
pcall(function()
|
||||
@@ -396,7 +457,16 @@ function bootGame(version)
|
||||
Game = require("src.core.Game2").new()
|
||||
Game:load()
|
||||
else
|
||||
Game = require("src.core.Game")
|
||||
-- Gen1 Game is a module singleton. Always re-require after in-process
|
||||
-- EXIT GAME so a prior session cannot leave a table whose rawget(load)
|
||||
-- is nil (release Android: bootGame then dies with load-a-nil-value).
|
||||
-- rawget: type(mod.load) can lie via __index and skip a rebuild.
|
||||
package.loaded["src.core.Game"] = nil
|
||||
local gameMod = require("src.core.Game")
|
||||
if type(rawget(gameMod, "load")) ~= "function" then
|
||||
error("src.core.Game missing load after reload")
|
||||
end
|
||||
Game = gameMod
|
||||
Game:load()
|
||||
if os.getenv("POKEPORT_AUTOPILOT") then
|
||||
autopilot = require("tests.autopilot")
|
||||
@@ -562,6 +632,7 @@ function love.load(args)
|
||||
end
|
||||
|
||||
function love.update(dt)
|
||||
checkEmergencyQuit(dt)
|
||||
HostDisplay.update(dt)
|
||||
SwitchDiagnostics.maybeFlush(false)
|
||||
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
|
||||
@@ -692,7 +763,7 @@ function love.gamepadpressed(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.gamepadpressed(joystick, button) end
|
||||
if Importer then return Importer:gamepadpressed(joystick, button) end
|
||||
if not Game then return end
|
||||
Game:gamepadpressed(joystick, button)
|
||||
@@ -712,7 +783,7 @@ function love.gamepadreleased(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.gamepadreleased(joystick, button) end
|
||||
if Importer then return Importer:gamepadreleased(joystick, button) end
|
||||
if not Game then return end
|
||||
Game:gamepadreleased(joystick, button)
|
||||
@@ -732,7 +803,7 @@ function love.gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.gamepadaxis(joystick, axis, value) end
|
||||
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
||||
if not Game then return end
|
||||
Game:gamepadaxis(joystick, axis, value)
|
||||
@@ -752,7 +823,7 @@ function love.joystickpressed(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.joystickpressed(joystick, button) end
|
||||
if Importer then return Importer:joystickpressed(joystick, button) end
|
||||
if not Game then return end
|
||||
Game:joystickpressed(joystick, button)
|
||||
@@ -772,7 +843,7 @@ function love.joystickreleased(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.joystickreleased(joystick, button) end
|
||||
if Importer then return Importer:joystickreleased(joystick, button) end
|
||||
if not Game then return end
|
||||
Game:joystickreleased(joystick, button)
|
||||
@@ -792,7 +863,7 @@ function love.joystickaxis(joystick, axis, value)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.joystickaxis(joystick, axis, value) end
|
||||
if Importer then return Importer:joystickaxis(joystick, axis, value) end
|
||||
if not Game then return end
|
||||
Game:joystickaxis(joystick, axis, value)
|
||||
@@ -812,7 +883,7 @@ function love.joystickhat(joystick, hat, direction)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.joystickhat(joystick, hat, direction) end
|
||||
if Importer then return Importer:joystickhat(joystick, hat, direction) end
|
||||
if not Game then return end
|
||||
Game:joystickhat(joystick, hat, direction)
|
||||
@@ -877,6 +948,8 @@ love.handlers = love.handlers or {}
|
||||
function love.handlers.audiosuspend()
|
||||
local ChipAudio = package.loaded["src.core.ChipAudio"]
|
||||
if ChipAudio then pcall(ChipAudio.setSuspended, true) end
|
||||
local Sound = package.loaded["src.core.Sound"]
|
||||
if Sound then pcall(Sound.onDeviceReset) end
|
||||
end
|
||||
|
||||
function love.handlers.audioreset()
|
||||
@@ -929,7 +1002,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return TouchEditor.touchpressed(id, x, y)
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.touchpressed(id, x, y) end
|
||||
if Importer then
|
||||
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
|
||||
-- polled inside the view; the istouch filter on mousepressed still drops
|
||||
@@ -946,7 +1019,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return TouchEditor.touchmoved(id, x, y)
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.touchmoved(id, x, y) end
|
||||
if Importer then
|
||||
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
@@ -960,7 +1033,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return TouchEditor.touchreleased(id, x, y)
|
||||
end
|
||||
if Studio then return end
|
||||
if Studio then return Studio.touchreleased(id, x, y) end
|
||||
if Importer then
|
||||
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
@@ -1013,7 +1086,12 @@ function love.mousepressed(x, y, button, istouch)
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousepressed(x, y, button)
|
||||
end
|
||||
if Studio then return Studio.mousepressed(x, y, button) end
|
||||
if Studio then
|
||||
-- Mobile LÖVE sends both a touch event and an `istouch` mouse twin.
|
||||
-- Studio consumes the real finger stream above, so discard the twin.
|
||||
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
||||
return Studio.mousepressed(x, y, button)
|
||||
end
|
||||
if Importer then
|
||||
-- love.touchpressed already forwards the primary touch into FlexLove for
|
||||
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
|
||||
@@ -1048,7 +1126,10 @@ function love.mousereleased(x, y, button, istouch)
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousereleased(x, y, button)
|
||||
end
|
||||
if Studio then return Studio.mousereleased(x, y, button) end
|
||||
if Studio then
|
||||
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
||||
return Studio.mousereleased(x, y, button)
|
||||
end
|
||||
if Importer then return end
|
||||
if editorMode and EditorApp.mousereleased then
|
||||
return EditorApp.mousereleased(x, y, button)
|
||||
@@ -1066,7 +1147,10 @@ function love.mousemoved(x, y, dx, dy, istouch)
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousemoved(x, y)
|
||||
end
|
||||
if Studio then return Studio.mousemoved(x, y) end
|
||||
if Studio then
|
||||
if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end
|
||||
return Studio.mousemoved(x, y)
|
||||
end
|
||||
if editorMode or Importer then return end
|
||||
if mouseTouch then
|
||||
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
|
||||
@@ -1117,15 +1201,23 @@ function love.quit()
|
||||
-- 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 isAndroid = (love.system and love.system.getOS and love.system.getOS() == "Android")
|
||||
--
|
||||
-- Android and iOS both tear down LOVE in-process rather than
|
||||
-- love.event.quit("restart"): Android's vendored love.cpp PHYSFS-crashes
|
||||
-- on a second init (#575), and iOS's love.cpp forces DONE_RESTART for
|
||||
-- every quit while warning that leftover threads make that unreliable.
|
||||
-- SessionLifecycle workers (ChipAudio / Fetch / Check) make that warning
|
||||
-- real -- endProcess joins them, then the native restart still blows up.
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
local inProcessReturn = (osName == "Android" or osName == "iOS")
|
||||
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
|
||||
return Game and not Importer and not quitToLauncher and not scripted
|
||||
and (isAndroid or not launchedIntoGame)
|
||||
and (inProcessReturn or not launchedIntoGame)
|
||||
end)
|
||||
if wouldReturnToLauncher then
|
||||
if isAndroid then
|
||||
if inProcessReturn then
|
||||
returnToLauncher()
|
||||
return true -- abort this quit; the restart lands back in the launcher
|
||||
return true -- abort this quit; stay in the same LOVE run
|
||||
end
|
||||
quitToLauncher = true
|
||||
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
||||
@@ -1138,22 +1230,7 @@ function love.quit()
|
||||
pcall(function()
|
||||
require("src.core.DiscordPresence").shutdown()
|
||||
end)
|
||||
-- LOVE waits for every live love.thread before the process exits, and both
|
||||
-- background workers idle in a loop that only a "quit" command breaks, so
|
||||
-- without this the process outlived the window and the next launch re-entered
|
||||
-- the dead one instead of starting fresh (#339)
|
||||
if package.loaded["src.core.ChipAudio"] then
|
||||
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
||||
end
|
||||
if package.loaded["src.update.Check"] then
|
||||
pcall(package.loaded["src.update.Check"].shutdown)
|
||||
end
|
||||
-- The launcher's fetch pool is the same story: its workers idle in
|
||||
-- Channel:demand(), which never returns on its own, so a launcher that ever
|
||||
-- touched the network would hang the process on exit (#339's shape again).
|
||||
if package.loaded["src.net.Fetch"] then
|
||||
pcall(package.loaded["src.net.Fetch"].shutdown)
|
||||
end
|
||||
SessionLifecycle.endProcess()
|
||||
end
|
||||
|
||||
function love.filedropped(file)
|
||||
@@ -1184,6 +1261,17 @@ function love.run()
|
||||
-- per-frame sleep-granularity jitter.
|
||||
local nextFrame = love.timer and love.timer.getTime() or 0
|
||||
local dt = 0
|
||||
local idleFor = 0
|
||||
local SLEEP_FLOOR = 0.001
|
||||
local WAKE = {
|
||||
keypressed = true, keyreleased = true, textinput = true,
|
||||
mousepressed = true, mousereleased = true, mousemoved = true,
|
||||
wheelmoved = true, touchpressed = true, touchreleased = true,
|
||||
touchmoved = true, joystickpressed = true, joystickreleased = true,
|
||||
joystickhat = true, gamepadpressed = true, gamepadreleased = true,
|
||||
joystickadded = true, joystickremoved = true, filedropped = true,
|
||||
directorydropped = true, focus = true, visible = true, resize = true,
|
||||
}
|
||||
|
||||
return function()
|
||||
-- process events
|
||||
@@ -1202,17 +1290,36 @@ function love.run()
|
||||
return a or 0
|
||||
end
|
||||
end
|
||||
if WAKE[name] then
|
||||
idleFor = 0
|
||||
elseif name == "joystickaxis" and type(c) == "number" and math.abs(c) > 0.5 then
|
||||
idleFor = 0
|
||||
end
|
||||
love.handlers[name](a, b, c, d, e, f)
|
||||
end
|
||||
end
|
||||
|
||||
-- update dt
|
||||
if love.timer then dt = love.timer.step() end
|
||||
idleFor = idleFor + dt
|
||||
|
||||
checkEmergencyQuit(dt)
|
||||
|
||||
-- call update and draw
|
||||
if love.update then love.update(dt) end
|
||||
|
||||
if love.graphics and love.graphics.isActive() then
|
||||
local visible = not (love.window and love.window.isVisible)
|
||||
or love.window.isVisible()
|
||||
local focused = not (love.window and love.window.hasFocus)
|
||||
or love.window.hasFocus()
|
||||
local cap = FrameCap.current
|
||||
if not visible then
|
||||
cap = 10
|
||||
elseif Importer and (not focused or idleFor > 30) then
|
||||
cap = 15
|
||||
end
|
||||
|
||||
if visible and love.graphics and love.graphics.isActive() then
|
||||
love.graphics.origin()
|
||||
love.graphics.clear(love.graphics.getBackgroundColor())
|
||||
if love.draw then love.draw() end
|
||||
@@ -1223,9 +1330,9 @@ function love.run()
|
||||
if paced then
|
||||
-- Sleep out the remainder of the frame budget, measured from the
|
||||
-- carried deadline, in small chunks so the OS timer stays
|
||||
-- responsive. vsync is untouched: when it already paces slower
|
||||
-- than the cap the remainder is <= 0 and this rounds to a no-op.
|
||||
local budget = 1 / FrameCap.current
|
||||
-- responsive. The pacer yields to vsync inside a 1ms dead band, so
|
||||
-- when the panel already paces at or below the cap it is a no-op.
|
||||
local budget = 1 / cap
|
||||
nextFrame = nextFrame + budget
|
||||
local now = love.timer.getTime()
|
||||
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
|
||||
@@ -1236,8 +1343,8 @@ function love.run()
|
||||
end
|
||||
while true do
|
||||
local remaining = nextFrame - love.timer.getTime()
|
||||
if remaining <= 0 then break end
|
||||
love.timer.sleep(remaining < 0.001 and remaining or 0.001)
|
||||
if remaining <= SLEEP_FLOOR then break end
|
||||
love.timer.sleep(0.001)
|
||||
end
|
||||
else
|
||||
love.timer.sleep(0.001)
|
||||
|
||||
+20
-5
@@ -93,12 +93,18 @@ transport, exactly as a missing curl does.
|
||||
love-android 11.5a expects:
|
||||
|
||||
- **JDK 17**
|
||||
- Android SDK with **API 34**
|
||||
- Android SDK with **API 36** (Android 16; latest 36.x Build-Tools)
|
||||
- NDK **25.2.9519653** (Apple Silicon host supported)
|
||||
- **minSdk 19** (Android 4.4), **targetSdk 36** (Android 16)
|
||||
|
||||
Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
|
||||
`local.properties` when it finds `~/Library/Android/sdk`.
|
||||
|
||||
**ShaderFX bridge**: `scripts/build_android.sh` bundles
|
||||
`liblibrashader_bridge.so` for arm64-v8a and armeabi-v7a via `cargo ndk` (or
|
||||
from `SHADERFX_BRIDGE_ANDROID_DIR`), and warns and continues when neither is
|
||||
available; see `docs/shaderfx.md`.
|
||||
|
||||
Gradle flavor used: **`embedNoRecord`** (game fused into the APK, no microphone).
|
||||
Build task: `assembleEmbedNoRecordDebug`.
|
||||
|
||||
@@ -122,15 +128,24 @@ scripts, tests, and mobile build sources are excluded.
|
||||
| `app.application_id` | `com.theboisclub.pokemonred` |
|
||||
| `app.name` | Pokemon Red |
|
||||
| `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. |
|
||||
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
|
||||
| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept |
|
||||
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*1,000,000 + minor*1,000 + patch); left as-is if `--version` is omitted |
|
||||
| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept; REQUEST_INSTALL_PACKAGES is limited to the user-confirmed full-update installer |
|
||||
|
||||
## Releases
|
||||
|
||||
`.github/workflows/release.yml` builds the APK with `--version` set to the
|
||||
release version and publishes it alongside the macOS/Windows/Linux builds as
|
||||
`PokemonRed-<version>-android.apk`.
|
||||
`gen1recomp-<version>-android.apk`.
|
||||
|
||||
## Signing
|
||||
|
||||
Signed with the default Android keystore (no setup required).
|
||||
Production APKs are built with `scripts/build_android.sh --release`. They must
|
||||
be signed with the same long-lived certificate as the currently installed app:
|
||||
Android's Package Installer rejects an update with a different signing
|
||||
certificate. Store that keystore and its passwords only in CI secrets, expose
|
||||
them as `GEN1RECOMP_ANDROID_KEYSTORE`,
|
||||
`GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD`, `GEN1RECOMP_ANDROID_KEY_ALIAS`, and
|
||||
`GEN1RECOMP_ANDROID_KEY_PASSWORD`, and never commit the keystore. A newly
|
||||
created certificate cannot update users who have an APK signed by a different
|
||||
legacy key; those users need one final manual reinstall before in-app updates
|
||||
can take over.
|
||||
|
||||
@@ -41,7 +41,7 @@ Quick Start:
|
||||
Before you start, install JDK 17 (not later not earlier). If you intend to build from Android Studio, skip this step as
|
||||
Android Studio bundles its own JDK 17.
|
||||
|
||||
Install Android SDK with SDK API 34 (34.x.y) and Android NDK 25.2.9519653, set the environment variable
|
||||
Install Android SDK with SDK API 36 (latest 36.x Build-Tools) and Android NDK 25.2.9519653, set the environment variable
|
||||
`ANDROID_SDK_ROOT` to your Android SDK location and run:
|
||||
|
||||
```
|
||||
|
||||
@@ -10,9 +10,12 @@ android {
|
||||
applicationId project.properties["app.application_id"]
|
||||
versionCode project.properties["app.version_code"].toInteger()
|
||||
versionName project.properties["app.version_name"]
|
||||
minSdk 16
|
||||
compileSdk 34
|
||||
targetSdk 34
|
||||
// NDK r25 no longer supports API 16; API 19 is Android 4.4 and keeps
|
||||
// the native toolchain and package-installer bridge on a supported ABI.
|
||||
minSdk 19
|
||||
// Android 16 / API 36: current Android distribution target.
|
||||
compileSdk 36
|
||||
targetSdk 36
|
||||
|
||||
def getAppName = {
|
||||
def nameArray = project.properties["app.name_byte_array"]
|
||||
@@ -38,10 +41,31 @@ android {
|
||||
ORIENTATION:project.properties["app.orientation"],
|
||||
]
|
||||
}
|
||||
// Release signing lives outside the repository. The release build script
|
||||
// requires all five values below, while debug builds intentionally remain
|
||||
// usable without them.
|
||||
def releaseStore = System.getenv("GEN1RECOMP_ANDROID_KEYSTORE")
|
||||
def releaseStorePassword = System.getenv("GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD")
|
||||
def releaseKeyAlias = System.getenv("GEN1RECOMP_ANDROID_KEY_ALIAS")
|
||||
def releaseKeyPassword = System.getenv("GEN1RECOMP_ANDROID_KEY_PASSWORD")
|
||||
def hasReleaseSigning = releaseStore && releaseStorePassword && releaseKeyAlias && releaseKeyPassword
|
||||
|
||||
if (hasReleaseSigning) {
|
||||
signingConfigs {
|
||||
release {
|
||||
storeFile file(releaseStore)
|
||||
storePassword releaseStorePassword
|
||||
keyAlias releaseKeyAlias
|
||||
keyPassword releaseKeyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
if (hasReleaseSigning) signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
flavorDimensions = ['mode', 'recording']
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
the link screen shows as "(Operation not permitted)" (issue #287).
|
||||
scripts/build_android.sh must not strip this again. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Required only to hand a checksum-verified, user-selected GitHub release
|
||||
APK to Android's own Package Installer. Android still shows the install
|
||||
confirmation and enforces package/signing-key/version compatibility. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step
|
||||
counter, which Android 10+ gates behind this runtime permission.
|
||||
Requested only on the first sync call (the Pokéwalker mod's SYNC
|
||||
@@ -26,6 +30,10 @@
|
||||
<!-- Low latency audio -->
|
||||
<uses-feature android:name="android.hardware.audio.low_latency" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.audio.pro" android:required="false" />
|
||||
<!-- love.sensor (accelerometer). No runtime permission needed on Android;
|
||||
this is a Play Store filtering hint only, so required=false like the
|
||||
other optional hardware features above. -->
|
||||
<uses-feature android:name="android.hardware.sensor.accelerometer" android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -35,10 +43,22 @@
|
||||
<meta-data
|
||||
android:name="android.allow_multiple_resumed_activities"
|
||||
android:value="true" />
|
||||
<!-- The full-update APK is copied into this small cache subdirectory
|
||||
before it is handed to Package Installer. Keep the provider private
|
||||
and expose only that directory, never a storage root. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.full_update_provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/full_update_paths" />
|
||||
</provider>
|
||||
<activity
|
||||
android:name="org.love2d.android.GameActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode|density|fontScale|locale|layoutDirection|colorMode"
|
||||
android:label="${NAME}"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="${ORIENTATION}"
|
||||
@@ -55,7 +75,7 @@
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.love2d.android.GameActivity$SecondaryActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode|density|fontScale|locale|layoutDirection|colorMode"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTask"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Deliberately narrow: FileProvider may grant only the staged APK, never
|
||||
arbitrary app, external, or shared storage. -->
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path name="full_update" path="full-update/" />
|
||||
</paths>
|
||||
@@ -18,7 +18,8 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.1.1'
|
||||
// Android 16 / API 36 requires Android Gradle Plugin 8.9+.
|
||||
classpath 'com.android.tools.build:gradle:8.9.2'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
|
||||
@@ -15,7 +15,6 @@ app.version_name=11.5a
|
||||
# No need to modify anything past this line!
|
||||
android.enableJetifier=false
|
||||
android.useAndroidX=true
|
||||
android.defaults.buildfeatures.buildconfig=true
|
||||
android.nonTransitiveRClass=true
|
||||
android.nonFinalResIds=true
|
||||
app.name=gen1recomp
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||
networkTimeout=10000
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -10,9 +10,9 @@ android {
|
||||
ndkVersion '25.2.9519653'
|
||||
|
||||
defaultConfig {
|
||||
minSdk 16
|
||||
compileSdk 34
|
||||
targetSdk 34
|
||||
minSdk 19
|
||||
compileSdk 36
|
||||
targetSdk 36
|
||||
externalNativeBuild {
|
||||
ndkBuild {
|
||||
arguments "-j" + Runtime.runtime.availableProcessors()
|
||||
@@ -63,8 +63,7 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
minifyEnabled false
|
||||
}
|
||||
debug {
|
||||
ndk {
|
||||
|
||||
@@ -283,6 +283,40 @@ bool restartApp()
|
||||
return result;
|
||||
}
|
||||
|
||||
bool installApk(const char *path)
|
||||
{
|
||||
if (path == nullptr || path[0] == '\0')
|
||||
return false;
|
||||
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
// This may be called from Lua's main thread, but use the activity object
|
||||
// class just like httpDownload so a future worker caller does not depend on
|
||||
// the system JNI class loader finding the app class.
|
||||
void *rawActivity = SDL_AndroidGetActivity();
|
||||
if (rawActivity == nullptr)
|
||||
return false;
|
||||
jobject activityObj = (jobject) rawActivity;
|
||||
jclass activity = env->GetObjectClass(activityObj);
|
||||
env->DeleteLocalRef(activityObj);
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "installApk",
|
||||
"(Ljava/lang/String;Ljava/lang/String;)Z");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
|
||||
jstring jpath = env->NewStringUTF(path);
|
||||
jstring jroot = env->NewStringUTF(bridgeSaveDirectory());
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jpath, jroot);
|
||||
env->DeleteLocalRef(jroot);
|
||||
env->DeleteLocalRef(jpath);
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool updateAppShortcuts(const std::vector<std::string> &versions)
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
|
||||
@@ -90,6 +90,12 @@ bool syncHealthSteps();
|
||||
**/
|
||||
bool restartApp();
|
||||
|
||||
/**
|
||||
* Stages a checksum-verified APK from the current save directory and starts
|
||||
* Android's user-confirmed Package Installer flow. Android-only.
|
||||
**/
|
||||
bool installApk(const char *path);
|
||||
|
||||
/**
|
||||
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
|
||||
**/
|
||||
|
||||
@@ -182,7 +182,7 @@ void System::vibrate(double seconds) const
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::pickFile(const char *kind) const
|
||||
bool System::pickFile(const char *kind, const char *destination) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
const char *dest = "picked_rom.gb";
|
||||
@@ -193,7 +193,8 @@ bool System::pickFile(const char *kind) const
|
||||
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
|
||||
dest = "picked_save.sav";
|
||||
else if (strcmp(kind, "required_import") == 0)
|
||||
dest = "picked_required_import.bin";
|
||||
dest = (destination != nullptr && destination[0] != '\0')
|
||||
? destination : "picked_required_import.bin";
|
||||
else if (strcmp(kind, "rom") == 0)
|
||||
dest = "picked_rom.gb";
|
||||
// Unknown kinds used to fall through to the ROM destination. Refuse them
|
||||
@@ -245,6 +246,16 @@ bool System::restartApp() const
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::installApk(const char *path) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::installApk(path);
|
||||
#else
|
||||
LOVE_UNUSED(path);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::updateShortcuts(const std::vector<std::string> &versions) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
|
||||
@@ -116,7 +116,7 @@ public:
|
||||
* "required_import" -> picked_required_import.bin.
|
||||
* @return Whether the picker was shown.
|
||||
**/
|
||||
virtual bool pickFile(const char *kind = nullptr) const;
|
||||
virtual bool pickFile(const char *kind = nullptr, const char *destination = nullptr) const;
|
||||
virtual const char *pickFileKinds() const;
|
||||
|
||||
/**
|
||||
@@ -143,6 +143,9 @@ public:
|
||||
**/
|
||||
virtual bool restartApp() const;
|
||||
|
||||
/** Starts Android's user-confirmed install flow for a verified APK. */
|
||||
virtual bool installApk(const char *path) const;
|
||||
|
||||
virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
|
||||
virtual std::string getLaunchGame() const;
|
||||
|
||||
|
||||
@@ -101,7 +101,8 @@ int w_vibrate(lua_State *L)
|
||||
int w_pickFile(lua_State *L)
|
||||
{
|
||||
const char *kind = luaL_optstring(L, 1, nullptr);
|
||||
luax_pushboolean(L, instance()->pickFile(kind));
|
||||
const char *destination = luaL_optstring(L, 2, nullptr);
|
||||
luax_pushboolean(L, instance()->pickFile(kind, destination));
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -132,6 +133,13 @@ int w_restartApp(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_installApk(lua_State *L)
|
||||
{
|
||||
const char *path = luaL_checkstring(L, 1);
|
||||
luax_pushboolean(L, instance()->installApk(path));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_httpDownload(lua_State *L)
|
||||
{
|
||||
const char *url = luaL_checkstring(L, 1);
|
||||
@@ -325,6 +333,7 @@ static const luaL_Reg functions[] =
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "restartApp", w_restartApp },
|
||||
{ "installApk", w_installApk },
|
||||
{ "updateShortcuts", w_updateShortcuts },
|
||||
{ "getLaunchGame", w_getLaunchGame },
|
||||
{ "httpDownload", w_httpDownload },
|
||||
|
||||
@@ -39,12 +39,14 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.AlarmManager;
|
||||
import android.app.AlertDialog;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.ClipData;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -77,6 +79,7 @@ import android.view.*;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.FileProvider;
|
||||
|
||||
public class GameActivity extends SDLActivity {
|
||||
private static DisplayMetrics metrics = null;
|
||||
@@ -109,6 +112,8 @@ public class GameActivity extends SDLActivity {
|
||||
// basename as its body, so RomImporter:focus can say so in the launcher
|
||||
// instead of leaving the player on "No ROM imported" (issue #442).
|
||||
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
|
||||
// Written after a direct required-import copy has been fully published.
|
||||
private static final String PICK_COMPLETE_FILENAME = "pick_complete.flag";
|
||||
// Step bridge (love.system.syncHealthSteps): pending-steps delivery
|
||||
// consumed by the Pokéwalker mod, same contract as the iOS
|
||||
// GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER
|
||||
@@ -574,6 +579,21 @@ public class GameActivity extends SDLActivity {
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav, or
|
||||
* picked_required_import.bin)
|
||||
*/
|
||||
private static boolean isDirectRequiredDestination(String relative) {
|
||||
if (relative == null || relative.length() == 0 || relative.startsWith("/")) return false;
|
||||
String normalized = relative.replace('\\', '/');
|
||||
if (!normalized.startsWith("mods/")) return false;
|
||||
int marker = normalized.indexOf("/baseroms/");
|
||||
if (marker <= "mods/".length() || marker + "/baseroms/".length() >= normalized.length()) {
|
||||
return false;
|
||||
}
|
||||
return !normalized.contains("//")
|
||||
&& !normalized.equals("..")
|
||||
&& !normalized.startsWith("../")
|
||||
&& !normalized.contains("/../")
|
||||
&& !normalized.endsWith("/..");
|
||||
}
|
||||
|
||||
/** Legacy single-argument entry; resolves the save dir itself. */
|
||||
@Keep
|
||||
public static boolean showFilePicker(String destFilename) {
|
||||
@@ -591,14 +611,30 @@ public class GameActivity extends SDLActivity {
|
||||
// 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) {
|
||||
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
|
||||
// Basename destinations keep the historical ROM/mod/save staging path.
|
||||
// A nested destination is accepted only for an engine-generated mod
|
||||
// baseroms path, then canonicalized beneath LOVE's mounted save root.
|
||||
String normalizedDest = destFilename.replace('\\', '/');
|
||||
boolean nested = normalizedDest.indexOf('/') >= 0;
|
||||
if (nested && !isDirectRequiredDestination(normalizedDest)) {
|
||||
Log.d("GameActivity", "refusing non-baseroms picker dest: " + destFilename);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
File rootCanonical = self.saveIdentityDir().getCanonicalFile();
|
||||
File destCanonical = new File(rootCanonical, normalizedDest).getCanonicalFile();
|
||||
String rootPrefix = rootCanonical.getPath() + File.separator;
|
||||
if (destCanonical.equals(rootCanonical)
|
||||
|| !destCanonical.getPath().startsWith(rootPrefix)) {
|
||||
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
|
||||
return false;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "could not validate picker dest: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
self.pendingPickFilename = destFilename;
|
||||
self.pendingPickFilename = normalizedDest;
|
||||
if (android.os.Build.VERSION.SDK_INT >= 21) {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
@@ -696,6 +732,103 @@ public class GameActivity extends SDLActivity {
|
||||
return true; // unreachable, but keeps the JNI signature honest
|
||||
}
|
||||
|
||||
/**
|
||||
* Stages a verified release APK in cache and asks Android's Package
|
||||
* Installer to update this package. This never silently installs an APK:
|
||||
* the platform owns both the unknown-sources consent and final install
|
||||
* confirmation. `updateRoot` comes from the native save directory and is
|
||||
* checked before any file is read, so a Lua caller cannot turn this into a
|
||||
* general-purpose local-file sharing bridge.
|
||||
*/
|
||||
@Keep
|
||||
public static boolean installApk(final String sourcePath, final String updateRoot) {
|
||||
final GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null || sourcePath == null || updateRoot == null) return false;
|
||||
final File source;
|
||||
try {
|
||||
source = new File(sourcePath).getCanonicalFile();
|
||||
File root = new File(updateRoot, "updates").getCanonicalFile();
|
||||
String rootPath = root.getPath() + File.separator;
|
||||
if (!source.getPath().startsWith(rootPath)
|
||||
|| !source.isFile() || source.length() == 0
|
||||
|| !source.getName().matches("gen1recomp-[0-9]+\\.[0-9]+\\.[0-9]+-android\\.apk")) {
|
||||
return false;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "invalid update APK path: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Android 8+ lets the user decide whether this app is trusted to
|
||||
// request package installs. Send them to the per-app setting first;
|
||||
// they deliberately tap Install again after granting it.
|
||||
if (android.os.Build.VERSION.SDK_INT >= 26
|
||||
&& !self.getPackageManager().canRequestPackageInstalls()) {
|
||||
try {
|
||||
Intent settings = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:" + self.getPackageName()));
|
||||
self.startActivity(settings);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not open install-source settings: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Copying an APK can be large; keep both I/O and checksum-verified
|
||||
// source access off the UI thread. The FileProvider exposes this cache
|
||||
// child only after it has been fully written and renamed.
|
||||
new Thread(new Runnable() {
|
||||
@Override public void run() {
|
||||
File stagedDir = new File(self.getCacheDir(), "full-update");
|
||||
File partial = new File(stagedDir, "update.apk.part");
|
||||
File staged = new File(stagedDir, "update.apk");
|
||||
try {
|
||||
if (!stagedDir.exists() && !stagedDir.mkdirs()) return;
|
||||
copyFile(source, partial);
|
||||
if (staged.exists() && !staged.delete()) return;
|
||||
if (!partial.renameTo(staged)) return;
|
||||
self.runOnUiThread(new Runnable() {
|
||||
@Override public void run() { launchPackageInstaller(self, staged); }
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not stage update APK: " + e.getMessage());
|
||||
} finally {
|
||||
if (partial.exists()) partial.delete();
|
||||
}
|
||||
}
|
||||
}, "gen1recomp-apk-stage").start();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void copyFile(File source, File destination) throws IOException {
|
||||
InputStream in = new BufferedInputStream(new FileInputStream(source));
|
||||
OutputStream out = new BufferedOutputStream(new FileOutputStream(destination));
|
||||
try {
|
||||
byte[] buffer = new byte[32768];
|
||||
int count;
|
||||
while ((count = in.read(buffer)) != -1) out.write(buffer, 0, count);
|
||||
} finally {
|
||||
try { out.close(); } catch (IOException ignored) {}
|
||||
try { in.close(); } catch (IOException ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
private static void launchPackageInstaller(GameActivity activity, File apk) {
|
||||
try {
|
||||
Context context = activity.getApplicationContext();
|
||||
Uri uri = FileProvider.getUriForFile(context,
|
||||
context.getPackageName() + ".full_update_provider", apk);
|
||||
Intent install = new Intent(Intent.ACTION_INSTALL_PACKAGE);
|
||||
install.setData(uri);
|
||||
install.setClipData(ClipData.newRawUri("apk", uri));
|
||||
install.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
activity.startActivity(install);
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not open package installer: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static String getLaunchGame() {
|
||||
return initialGame != null ? initialGame : "";
|
||||
@@ -1178,13 +1311,7 @@ public class GameActivity extends SDLActivity {
|
||||
|
||||
/** Drops a small flag file in the save identity for Lua to consume on focus. */
|
||||
private void writeSaveDirFlag(String name, String body) {
|
||||
try {
|
||||
FileOutputStream fos = new FileOutputStream(new File(saveIdentityDir(), name), false);
|
||||
fos.write(body.getBytes());
|
||||
fos.close();
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "could not write " + name + ": " + e.getMessage());
|
||||
}
|
||||
writeFlagFile(saveIdentityDir(), name, body);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1407,9 +1534,26 @@ public class GameActivity extends SDLActivity {
|
||||
Log.d("GameActivity", "could not create " + destDir);
|
||||
return;
|
||||
}
|
||||
String destName = pendingPickFilename != null
|
||||
final String destName = pendingPickFilename != null
|
||||
? pendingPickFilename : PICKED_ROM_FILENAME;
|
||||
File destFile = new File(destDir, destName);
|
||||
final boolean directRequired = isDirectRequiredDestination(destName);
|
||||
final File destFile;
|
||||
try {
|
||||
File rootCanonical = destDir.getCanonicalFile();
|
||||
destFile = new File(rootCanonical, destName).getCanonicalFile();
|
||||
String rootPrefix = rootCanonical.getPath() + File.separator;
|
||||
if (destFile.equals(rootCanonical)
|
||||
|| !destFile.getPath().startsWith(rootPrefix)
|
||||
|| (destName.indexOf('/') >= 0 && !directRequired)) {
|
||||
Log.d("GameActivity", "refusing unsafe result dest: " + destName);
|
||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||
return;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "could not validate result dest: " + e.getMessage());
|
||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||
return;
|
||||
}
|
||||
|
||||
// ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but
|
||||
// some OEM shells (ColorOS) offer third-party file managers in a
|
||||
@@ -1435,15 +1579,110 @@ public class GameActivity extends SDLActivity {
|
||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||
return;
|
||||
}
|
||||
if (!copyAssetFile(source, destFile.getPath())) {
|
||||
final InputStream pickedSource = source;
|
||||
final File pickedRoot = destDir;
|
||||
if (directRequired) {
|
||||
// Optical-disc-sized imports must not block Android's UI thread and
|
||||
// must not create a second picked_required_import.bin copy.
|
||||
new Thread(new Runnable() {
|
||||
@Override public void run() {
|
||||
PickCopyResult result = copyRequiredImport(pickedSource, destFile);
|
||||
if (!result.ok) {
|
||||
writeFlagFile(pickedRoot, PICK_ERROR_FILENAME, destName);
|
||||
return;
|
||||
}
|
||||
String marker = "v1\n" + destName + "\n" + result.md5 + "\n"
|
||||
+ Long.toString(result.bytes) + "\n";
|
||||
writeFlagFile(pickedRoot, PICK_COMPLETE_FILENAME, marker);
|
||||
}
|
||||
}, "gen1recomp-required-import").start();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!copyAssetFile(pickedSource, destFile.getPath())) {
|
||||
Log.d("GameActivity", "could not copy picked file to " + destFile);
|
||||
// A truncated pick would only fail verification later, so drop it
|
||||
// and report instead.
|
||||
destFile.delete();
|
||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PickCopyResult {
|
||||
boolean ok = false;
|
||||
long bytes = 0;
|
||||
String md5 = "";
|
||||
}
|
||||
|
||||
private static String hex(byte[] bytes) {
|
||||
StringBuilder out = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) out.append(String.format(Locale.US, "%02x", b & 0xff));
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
private static void writeFlagFile(File dir, String name, String body) {
|
||||
try {
|
||||
if (!dir.isDirectory() && !dir.mkdirs()) return;
|
||||
File tmp = new File(dir, name + ".tmp");
|
||||
File dest = new File(dir, name);
|
||||
FileOutputStream out = new FileOutputStream(tmp, false);
|
||||
out.write(body.getBytes("UTF-8"));
|
||||
out.getFD().sync();
|
||||
out.close();
|
||||
if (dest.exists() && !dest.delete()) { tmp.delete(); return; }
|
||||
if (!tmp.renameTo(dest)) tmp.delete();
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not write " + name + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static PickCopyResult copyRequiredImport(InputStream source, File destination) {
|
||||
PickCopyResult result = new PickCopyResult();
|
||||
File parent = destination.getParentFile();
|
||||
File partial = new File(destination.getPath() + ".part");
|
||||
BufferedInputStream in = null;
|
||||
BufferedOutputStream out = null;
|
||||
FileOutputStream rawOut = null;
|
||||
try {
|
||||
if (parent != null && !parent.isDirectory() && !parent.mkdirs()) return result;
|
||||
if (partial.exists() && !partial.delete()) return result;
|
||||
MessageDigest md5 = MessageDigest.getInstance("MD5");
|
||||
in = new BufferedInputStream(source, 1024 * 1024);
|
||||
rawOut = new FileOutputStream(partial, false);
|
||||
out = new BufferedOutputStream(rawOut, 1024 * 1024);
|
||||
byte[] buf = new byte[1024 * 1024];
|
||||
int n;
|
||||
long total = 0;
|
||||
while ((n = in.read(buf)) != -1) {
|
||||
if (n == 0) continue;
|
||||
out.write(buf, 0, n);
|
||||
md5.update(buf, 0, n);
|
||||
total += n;
|
||||
}
|
||||
out.flush();
|
||||
rawOut.getFD().sync();
|
||||
out.close(); out = null; rawOut = null;
|
||||
in.close(); in = null;
|
||||
|
||||
// Publish only a complete same-directory file. Validation still
|
||||
// happens in Lua against the manifest before a receipt is written.
|
||||
if (destination.exists() && !destination.delete()) return result;
|
||||
if (!partial.renameTo(destination)) return result;
|
||||
result.ok = true;
|
||||
result.bytes = total;
|
||||
result.md5 = hex(md5.digest());
|
||||
Log.d("GameActivity", "direct required import copied " + total
|
||||
+ " bytes to " + destination);
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "direct required import failed: " + e.getMessage());
|
||||
return result;
|
||||
} finally {
|
||||
try { if (in != null) in.close(); } catch (IOException ignored) {}
|
||||
try { if (out != null) out.close(); } catch (IOException ignored) {}
|
||||
try { if (rawOut != null) rawOut.close(); } catch (IOException ignored) {}
|
||||
if (!result.ok && partial.exists()) partial.delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a given file from the assets folder to the destination.
|
||||
*
|
||||
@@ -1466,13 +1705,12 @@ public class GameActivity extends SDLActivity {
|
||||
assert (source != null && destination != null);
|
||||
|
||||
try {
|
||||
byte[] buf = new byte[1024];
|
||||
chunk_read = source.read(buf);
|
||||
do {
|
||||
byte[] buf = new byte[1024 * 1024];
|
||||
while ((chunk_read = source.read(buf)) != -1) {
|
||||
if (chunk_read == 0) continue;
|
||||
destination.write(buf, 0, chunk_read);
|
||||
bytes_written += chunk_read;
|
||||
chunk_read = source.read(buf);
|
||||
} while (chunk_read != -1);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "Copying failed:" + e.getMessage());
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ The script verifies the final app before packaging it:
|
||||
|
||||
- the public Documents plist settings are present
|
||||
- the native picker bridge is present
|
||||
- the SHADER FX librashader bridge is linked into the app binary, when this build produced one
|
||||
- `game.love` exists and is non-empty
|
||||
|
||||
If the payload is missing, the build fails instead of producing a blank app.
|
||||
|
||||
@@ -12,6 +12,118 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.2.27",
|
||||
"date": "2026-08-26",
|
||||
"size": 21365966,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.27/gen1recomp++-0.2.27-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.2.26",
|
||||
"date": "2026-08-25",
|
||||
"size": 21326715,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.26/gen1recomp++-0.2.26-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #917 Save editor still unable to save\n- #1074 Yellow ROM Importer may be broken\n- #1635 Missing FX jingle sounds for items\n- #1639 [Yellow] Summer Beach House colors in Advanced mode\n- #1644 Minor bug: Fly's screen fade is black instead of white\n- #1743 Super Nerd in Mt. Moon Wrong Dialogue\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @campavao\n- @HighDrexler"
|
||||
},
|
||||
{
|
||||
"version": "0.2.25",
|
||||
"date": "2026-08-25",
|
||||
"size": 21307139,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.25/gen1recomp++-0.2.25-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #941 Screen Size in Portrait\n- #1081 OS Slideshow\n- #1349 Modded Transparent Sprites getting cropped\n- #1602 Default on screen controls are different between RBY and Gold\n- #1618 [Silver] Potions [the item] are able to use rock smash as if they are Pokemon\n- #1624 gen 2 fullscreen\n- #1630 Gold missing attack feedback\n- #1633 #1488 Issue fixed, but a minor blemish remains.\n- #1661 bug: built in controls are missing.\n- #1669 gold item pc cannot hold two stacks of the same item id\n- #1676 Battle size: world\n- #1731 (Gold) 2 turn moves are broken\n- #1739 Town Map Sprite Icon dont have Color in Advanced Color Setting\n- #1740 Misaligned ! Box\n- #1742 Healing Screen\n- #1744 foresight does not allow normal type moves to hit ghosts\n- #1753 Pokémon show up in wide battle layout and use the incorrect colour palette\n- #1757 Potential deviations in game mechanics\n- #1758 Smog animation colors\n- #1760 \"Sent some money to mom\" cut off\n- #1761 Return / Frustration have incorrect base power\n- #1762 No incoming phone calls\n- #1763 [Gen2] Title Screen should restart after song ends\n- #1765 Battle animations all black when Flash isn't used in Rock Tunnel\n- #1770 \"Go!\" text visible when opening item menu\n- #1777 bug: Frame pacing micro stutter above 60Hz on steam deck oled\n- #1781 Missing Pokemon cries in Lavender Town\n- #1783 Order of items displayed is different from inventory when depositing\n- #1784 Pokemon cries not played in Celadon City\n- #1785 Wrong text order when picking up Eevee\n- #1787 Blackboard in Celadon Mansion is a little buggy\n- #1788 Cry of Pokemon that's about to evolve isn't played\n- #1789 Champion Blue doesn't force battle animations\n- #1790 Kris is red in battle screen\n- #1792 Violet City Bellsprout for Onix Trade Offers Bellsprout for Bellsprout\n- #1797 Pokedex diploma has minor visual errors\n- #1802 Weird persistant CPU usage spike after viewing mod list\n- #1803 A couple of issues in the secret house\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @campavao\n- MaxTomahawk"
|
||||
},
|
||||
{
|
||||
"version": "0.2.24",
|
||||
"date": "2026-08-24",
|
||||
"size": 14060528,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.24/gen1recomp++-0.2.24-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.23",
|
||||
"date": "2026-08-24",
|
||||
"size": 14060437,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.23/gen1recomp++-0.2.23-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.22",
|
||||
"date": "2026-08-24",
|
||||
"size": 14060365,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.22/gen1recomp++-0.2.22-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @ShaneMcGovernIE\n- @thibautbus"
|
||||
},
|
||||
{
|
||||
"version": "0.2.21",
|
||||
"date": "2026-08-24",
|
||||
"size": 14058979,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.21/gen1recomp++-0.2.21-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1483 (Pokémon Gold) Missing HM/TM fanfare when receiving them\n- #1610 Wrong text when choosing Pokemon to trade\n- #1615 Fishing Guru dialogue issue\n- #1640 Sync doesnt work on XBOX build\n- #1646 Cashier dialogue + sound effects\n- #1649 Pokemon cries missing in Vermilion City\n- #1651 Ticket taker asks for ticket too much\n- #1653 Wrap animation is too fast and has no screen shake\n- #1656 Map visible in between end of fight & evolution\n- #1683 Music not changing when talking to trainer\n- #1685 Cancel option missing in item menu\n- #1686 Issues with teaching TMs & HMs\n- #1687 Pokemon cries missing on S.S. Anne\n- #1688 S.S. Anne rival dialogue\n- #1689 S.S. Anne departing cutscene broken\n- #1690 Wrong dialogue when taking back entrance to museum\n- #1693 (Gold) Issues not properly fixed\n- #1694 (Gold) Arrow when seeing items in the bag should be red, not black\n- #1695 (Gold) Wrong layout in TMs/HMs pocket\n- #1696 (Gold) Caught pokémon don't get healed when automatically sent to the PC\n- #1697 Standardize identification of mod-created options/settings screens\n- #1702 Lt. Surge's door opening isn't synced with the sound effect\n- #1704 Ledge jumping on the bike doesn't slow you down\n- #1705 The bike shouldn't have Use/Toss options\n- #1706 (Gold) Cursor wraps around the edge in the battle interface\n- #1707 (Gold) Pokémon sometimes wake up in the same turn they were put to sleep\n- #1708 (Gold) Missing rod when fishing\n- #1710 (Gold) You shouldn't be able to change boxes by pressing LEFT or RIGHT\n- #1711 (Gold) Missing animation for FLY in the overworld\n- #1712 (Gold) Wrong cursor in the map when using FLY\n- #1713 (Gold) Low framerate when jumping off ledges\n- #1716 (Gold) Player can go through a whirlpool even without having the HM\n- #1717 (Gold) Missing animation and SFX when using whirlpool in the overworld\n- #1718 (Gold) Can't pause in the Cycling Road while moving down\n- #1719 (Gold) Pokémon given by NPCs not being registered in the pokédex\n- #1720 Critical hit on multi-hit moves repeats the message\n- #1721 Rival not visible while talking if you lose first fight\n- #1724 Centralize ROM cache readiness and publication in an engine-owned contract\n- #1725 (Gold) Text not spaced nor centered correctly for secondary actions in the bag\n- #1732 Cursor doesn't stay on last used item\n- #1745 Viewing items makes ingame menu disappear\n- #1748 (Gold) Overworld pokémon sprite are stactic\n- #1749 (Gold) You shouldn't be able to surf on Cycling Road\n- #1751 (Gold) Synthesis and Morning Sun always recover 50% of max HP\n- #1754 Leaving building with bike speed\n- #1756 No cursor or gamepad controls in skin menu, locked out on Xbox\n\n## Contributors\n\n- @1Jamie\n- @BoLayerDev\n- @bryanthaboi\n- @campavao\n- @caorthann-celt\n- @dlloa\n- @syybott\n- @thibautbus\n- MaxTomahawk"
|
||||
},
|
||||
{
|
||||
"version": "0.2.20",
|
||||
"date": "2026-08-22",
|
||||
"size": 13812226,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.20/gen1recomp++-0.2.20-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1536 Magnet Train Cutscene visual bugs\n- #1597 Link cable looks broken\n- #1643 Pikachu + Title uses an incorrect Colour Palette\n- #1662 The game glitches after exiting, returning to the menu, and choosing another one.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @colsonrice"
|
||||
},
|
||||
{
|
||||
"version": "0.2.19",
|
||||
"date": "2026-08-22",
|
||||
"size": 13778435,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.19/gen1recomp++-0.2.19-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1274 Choose ROM imports a different version than the one selected when several dumps are pending (Linux, no file picker)\n- #1484 (Pokémon Gold) Bill's PC doesn't show equipped items + missing orange filter on pokémon sprite\n- #1486 (Pokémon Gold) Can't deposit key items\n- #1513 Experience share issue different case from #1464\n- #1517 Game Crash Opening Stats\n- #1548 Colisión\n- #1556 (Gold) Missing SFX for a lot of actions\n- #1564 Bide's storing turn is missing the screen shake\n- #1567 TM/HM case not in order\n- #1663 transition pops the top state, destroying anything pushed from a midpoint callback\n- #1664 gold bide spends pp every turn instead of only the turn it is selected\n- #1665 gold has no bide lock-in, so a mid-bide switch of move strands the counter\n- #1666 sleep talk is inert in gold: EFFECT_SLEEP_TALK has no move effect entry\n- #1667 gold: a fainted party mon keeps its exp participant credit and halves the survivor's exp\n- #1668 summary page 2 formats a move's pp with no numeric guard\n- #1670 gold tm/hm pocket capacity is an invented 64 where the cart has 57 slots\n- #1672 bill's pc move screen is missing the box name arrows\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @colsonrice\n- @jramiresbrito"
|
||||
},
|
||||
{
|
||||
"version": "0.2.18",
|
||||
"date": "2026-08-21",
|
||||
"size": 13772120,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.18/gen1recomp++-0.2.18-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.17",
|
||||
"date": "2026-08-21",
|
||||
"size": 13771903,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.17/gen1recomp++-0.2.17-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.16",
|
||||
"date": "2026-08-21",
|
||||
"size": 13769300,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.16/gen1recomp++-0.2.16-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1367 Android auto scrolling down bug\n- #1570 Pokemon Gold isn't noticing my controller inputs on...ANY controller I own, despite all of them working in the other games, and in the main launcher\n- #1585 Power-saving mode or dark mode closes the game on Android.\n- #1611 bug: keyboard gets stuck IOS\n- #1612 Fold 7 infinite scrolling mouse.\n- #1636 Game freezes when trainer battle starts\n- #1638 Game screen orientation on smartphone\n- #1641 [Gold] [Android] PKMN Evolutions sequence freezes the Game \n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @HighDrexler\n- MaxTomahawk"
|
||||
},
|
||||
{
|
||||
"version": "0.2.15",
|
||||
"date": "2026-08-21",
|
||||
"size": 13753007,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.15/gen1recomp++-0.2.15-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1589 Player sprite still blinking on the Town Map\n- #1595 Pokedex completion certificate is not displaying correctly\n- #1597 Link cable looks broken\n- #1613 Visual differences in Pokemon trades\n- #1619 Save Menu cutting off and Put into another place\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.14",
|
||||
"date": "2026-08-20",
|
||||
"size": 13749372,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.14/gen1recomp++-0.2.14-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.13",
|
||||
"date": "2026-08-20",
|
||||
"size": 13749377,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.13/gen1recomp++-0.2.13-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1474 (Pokémon Gold) Pokédex mode not being saved\n- #1475 (Pokémon Gold) Vital Throw attacking before the opponent\n- #1477 (Pokémon Gold) Map when using fly keeps the arrow from the pokégear\n- #1478 (Pokémon Gold) Wrong move layout in battles\n- #1479 [Gold] tiles broken at Route 28 and Mt Silver\n- #1482 (Pokémon Gold) Nothing happens when trying to use Coin Case\n- #1488 Pokemon EXP calculation after trading went back to issue #984\n- #1510 Title Screen Transition is Missing\n- #1511 Broken Trainer Rival Name Layout\n- #1512 Gen 2 Post battle interactions don't exist\n- #1514 Health damage timing\n- #1516 Move learning timing\n- #1521 Different Item Menu\n- #1522 Wrong Save Game Layout\n- #1545 Pikachu not sliding out when switching Pokemon.\n- #1557 (Gold) Impossible to get TM 03 in Celadon Mansion at night\n- #1558 (Gold) Missing colors and symbol in stats screen\n- #1563 Player mon pic cuts away instead of shrinking when recalled\n- #1565 Thrash doesn't lock in when the first use misses\n- #1566 Center PC missing the PKMN LEAGUE entry after the Hall of Fame\n- #1569 Goldenrod Gift Spearow Bugged\n- #1577 Thrash has no animation past the first turn\n- #1578 Hitting yourself in confusion animation missing\n- #1579 Missing dialogue for Cerulean City Rocket\n- #1594 Pokemon menu closing too soon when using rare candy\n- #1596 Evolution dialogue & missing jingle\n- #1606 Misty dialogue issue\n- #1608 \"There's no will to fight!\" message issues\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.12",
|
||||
"date": "2026-08-20",
|
||||
"size": 13737259,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.12/gen1recomp++-0.2.12-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1582 Sync not working between steamdeck and windows\n- #1583 Can’t sync between iOS and windows\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @thibautbus"
|
||||
},
|
||||
{
|
||||
"version": "0.2.11",
|
||||
"date": "2026-08-20",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
import CryptoKit
|
||||
|
||||
@objc(GRPickerBridge)
|
||||
public final class GRPickerBridge: NSObject {
|
||||
@@ -190,9 +191,17 @@ public final class GRPickerBridge: NSObject {
|
||||
@objc(presentPickerWithKind:saveDir:)
|
||||
public static func presentPicker(kind: UnsafePointer<CChar>?,
|
||||
saveDir: UnsafePointer<CChar>?) -> Bool {
|
||||
return presentPicker(kind: kind, saveDir: saveDir, destination: nil)
|
||||
}
|
||||
|
||||
@objc(presentPickerWithKind:saveDir:destination:)
|
||||
public static func presentPicker(kind: UnsafePointer<CChar>?,
|
||||
saveDir: UnsafePointer<CChar>?,
|
||||
destination: UnsafePointer<CChar>?) -> Bool {
|
||||
let kindStr = kind.map { String(cString: $0) } ?? "rom"
|
||||
guard let dir = resolvedSaveDir(saveDir) else { return false }
|
||||
|
||||
let requestedDestination = destination.map { String(cString: $0) }
|
||||
let destName: String
|
||||
var types: [UTType] = []
|
||||
switch kindStr {
|
||||
@@ -202,7 +211,13 @@ public final class GRPickerBridge: NSObject {
|
||||
case "sav":
|
||||
destName = "picked_save.sav"
|
||||
case "required_import":
|
||||
destName = "picked_required_import.bin"
|
||||
if let requestedDestination,
|
||||
isDirectRequiredDestination(requestedDestination),
|
||||
safeDestination(in: dir, relative: requestedDestination) != nil {
|
||||
destName = requestedDestination
|
||||
} else {
|
||||
destName = "picked_required_import.bin"
|
||||
}
|
||||
// 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
|
||||
@@ -234,12 +249,18 @@ public final class GRPickerBridge: NSObject {
|
||||
types.append(.data)
|
||||
if !types.contains(.item) { types.append(.item) }
|
||||
|
||||
let directRequired = kindStr == "required_import"
|
||||
&& destName != "picked_required_import.bin"
|
||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types,
|
||||
asCopy: true)
|
||||
asCopy: !directRequired)
|
||||
picker.allowsMultipleSelection = false
|
||||
let delegate = PickerDelegate { urls in
|
||||
guard let src = urls.first else { return }
|
||||
copyItem(at: src, into: dir, named: destName)
|
||||
if directRequired {
|
||||
copyRequiredItemAsync(at: src, into: dir, relative: destName)
|
||||
} else {
|
||||
copyItem(at: src, into: dir, named: destName)
|
||||
}
|
||||
}
|
||||
return present(picker, with: delegate)
|
||||
}
|
||||
@@ -410,6 +431,82 @@ public final class GRPickerBridge: NSObject {
|
||||
try? fm.moveItem(at: item, to: target)
|
||||
}
|
||||
|
||||
private static func isDirectRequiredDestination(_ relative: String) -> Bool {
|
||||
let normalized = relative.replacingOccurrences(of: "\\", with: "/")
|
||||
guard normalized.hasPrefix("mods/"),
|
||||
let range = normalized.range(of: "/baseroms/"),
|
||||
range.lowerBound > normalized.index(normalized.startIndex, offsetBy: 5),
|
||||
range.upperBound < normalized.endIndex else { return false }
|
||||
return !normalized.hasPrefix("/")
|
||||
&& !normalized.contains("//")
|
||||
&& !normalized.contains("/../")
|
||||
&& !normalized.hasSuffix("/..")
|
||||
}
|
||||
|
||||
private static func safeDestination(in root: URL, relative: String) -> URL? {
|
||||
guard isDirectRequiredDestination(relative) else { return nil }
|
||||
let rootURL = root.standardizedFileURL
|
||||
let candidate = rootURL.appendingPathComponent(relative).standardizedFileURL
|
||||
let rootPath = rootURL.path.hasSuffix("/") ? rootURL.path : rootURL.path + "/"
|
||||
guard candidate.path.hasPrefix(rootPath) else { return nil }
|
||||
return candidate
|
||||
}
|
||||
|
||||
private static func writeFlag(in dir: URL, name: String, body: String) {
|
||||
try? body.data(using: .utf8)?.write(to: dir.appendingPathComponent(name),
|
||||
options: .atomic)
|
||||
}
|
||||
|
||||
private static func copyRequiredItemAsync(at src: URL, into dir: URL,
|
||||
relative: String) {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let scoped = src.startAccessingSecurityScopedResource()
|
||||
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
|
||||
guard let dest = safeDestination(in: dir, relative: relative) else {
|
||||
writeFlag(in: dir, name: "pick_error.flag", body: relative)
|
||||
return
|
||||
}
|
||||
let fm = FileManager.default
|
||||
ensureDirectory(dest.deletingLastPathComponent())
|
||||
let partial = URL(fileURLWithPath: dest.path + ".part")
|
||||
try? fm.removeItem(at: partial)
|
||||
guard fm.createFile(atPath: partial.path, contents: nil) else {
|
||||
writeFlag(in: dir, name: "pick_error.flag", body: relative)
|
||||
return
|
||||
}
|
||||
|
||||
var hasher = Insecure.MD5()
|
||||
var total: UInt64 = 0
|
||||
do {
|
||||
let input = try FileHandle(forReadingFrom: src)
|
||||
let output = try FileHandle(forWritingTo: partial)
|
||||
defer {
|
||||
try? input.close()
|
||||
try? output.close()
|
||||
}
|
||||
while true {
|
||||
let data = input.readData(ofLength: 1024 * 1024)
|
||||
if data.isEmpty { break }
|
||||
hasher.update(data: data)
|
||||
output.write(data)
|
||||
total += UInt64(data.count)
|
||||
}
|
||||
output.synchronizeFile()
|
||||
try? fm.removeItem(at: dest)
|
||||
try fm.moveItem(at: partial, to: dest)
|
||||
let digest = hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
||||
let marker = "v1\n" + relative + "\n" + digest + "\n"
|
||||
+ String(total) + "\n"
|
||||
writeFlag(in: dir, name: "pick_complete.flag", body: marker)
|
||||
NSLog("GRPickerBridge: direct required import delivered %llu bytes", total)
|
||||
} catch {
|
||||
try? fm.removeItem(at: partial)
|
||||
NSLog("GRPickerBridge: direct required import failed: \(error)")
|
||||
writeFlag(in: dir, name: "pick_error.flag", body: relative)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func copyItem(at src: URL, into dir: URL, named name: String) {
|
||||
let scoped = src.startAccessingSecurityScopedResource()
|
||||
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
|
||||
@@ -426,7 +523,7 @@ public final class GRPickerBridge: NSObject {
|
||||
let report = "Could not copy \(src.lastPathComponent): " +
|
||||
error.localizedDescription
|
||||
try? report.data(using: .utf8)?
|
||||
.write(to: dir.appendingPathComponent("pick_error.txt"))
|
||||
.write(to: dir.appendingPathComponent("pick_error.flag"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,20 @@ static int gr_callBridge(lua_State *L, const char *className,
|
||||
int w_pickFile(lua_State *L)
|
||||
{
|
||||
const char *kind = luaL_optstring(L, 1, "rom");
|
||||
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
|
||||
const char *destination = luaL_optstring(L, 2, nullptr);
|
||||
Class cls = objc_getClass("GRPickerBridge");
|
||||
if (cls == nullptr)
|
||||
{
|
||||
lua_pushboolean(L, 0);
|
||||
return 1;
|
||||
}
|
||||
typedef signed char (*GRPick)(Class, SEL, const char *, const char *,
|
||||
const char *);
|
||||
signed char ok = ((GRPick)objc_msgSend)(
|
||||
cls, sel_registerName("presentPickerWithKind:saveDir:destination:"),
|
||||
kind, gr_saveDirectory(), destination);
|
||||
lua_pushboolean(L, ok != 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// love.system.pickFileKinds() -> the comma-separated kinds supported by the
|
||||
|
||||
Vendored
+1
-1
@@ -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 `cdf85f28ed794c95d6f5f7e3a8f23ca9ee1fbdcb`.
|
||||
These x64 UWP Release binaries were built from [`caorthann-celt/love-xbox-uwp`](https://github.com/caorthann-celt/love-xbox-uwp) at commit `3f51bf0be5f3f86e5934da3893caba2b817f82c1`.
|
||||
|
||||
The build uses LÖVE 11.5, LuaJIT, SDL2, and ANGLE. Keep the DLLs and import libraries together; they are one binary interface.
|
||||
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+3
-3
@@ -7,7 +7,7 @@
|
||||
"love": {
|
||||
"version": "11.5",
|
||||
"repository": "https://github.com/caorthann-celt/love-xbox-uwp.git",
|
||||
"commit": "cdf85f28ed794c95d6f5f7e3a8f23ca9ee1fbdcb"
|
||||
"commit": "3f51bf0be5f3f86e5934da3893caba2b817f82c1"
|
||||
},
|
||||
"luajit": {
|
||||
"repository": "https://github.com/LuaJIT/LuaJIT.git",
|
||||
@@ -99,7 +99,7 @@
|
||||
},
|
||||
{
|
||||
"path": "love/bin/love.dll",
|
||||
"sha256": "A3622828014F03DDB502D2A7807DC8F94432172595BE03508099BD66937FD45F"
|
||||
"sha256": "E2CE334817636AA9EFF97E181FB5C33C506971F2AC7C653F2CA751B5CDE2FCE6"
|
||||
},
|
||||
{
|
||||
"path": "love/bin/lua51.dll",
|
||||
@@ -111,7 +111,7 @@
|
||||
},
|
||||
{
|
||||
"path": "love/lib/lovestatic.lib",
|
||||
"sha256": "15FB3A9CB3C3CDAB8B33DE5B1D1F8D8FF6CE9719E9AA7BC2CF3D7B51CDCE0C98"
|
||||
"sha256": "3794690ED3F44DB6F0C83BBC1673979922627929E852E7668C9911DE1F72B972"
|
||||
},
|
||||
{
|
||||
"path": "love/lib/lua51.lib",
|
||||
|
||||
+91
-15
@@ -11,7 +11,7 @@
|
||||
#
|
||||
# Output: dist/mac/gen1recomp-macos.zip
|
||||
# dist/win/gen1recomp-win64.zip
|
||||
# dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage)
|
||||
# dist/linux/gen1recomp-linux-x86_64.AppImage (fused x86_64 AppImage)
|
||||
# dist/android/debug/*.apk (full gradle output stays under
|
||||
# mobile/android/app/build/outputs/apk/embedNoRecord/)
|
||||
# dist/ios/<Config>-<sdk>/gen1recomp++.app (full xcodebuild output stays
|
||||
@@ -79,7 +79,7 @@ else
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
tools/rom_manifest_silver.json \
|
||||
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
fi
|
||||
# Materialize the listing once and grep the file: piping unzip straight into
|
||||
@@ -101,7 +101,8 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||
src/ui/kit/Kit.lua \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
tools/rom_manifest_silver.json; do
|
||||
tools/rom_manifest_silver.json \
|
||||
tools/rom_manifest_crystal.json; do
|
||||
grep -qxF "$required" "$LOVE_LISTING" \
|
||||
|| fail "game.love is missing $required"
|
||||
done
|
||||
@@ -167,6 +168,57 @@ make_ico() { # $1 = output .ico path
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- macOS
|
||||
# ShaderFX's librashader bridge. Only the CONVERT action needs it, so a build
|
||||
# without it still runs presets that were converted elsewhere.
|
||||
# SHADERFX_BRIDGE_<PLAT> or dist/native/<plat>/ points at a prebuilt library;
|
||||
# otherwise cargo builds it, host platform only.
|
||||
shader_bridge_host_plat() {
|
||||
case "$(uname -s)-$(uname -m)" in
|
||||
Darwin-*) printf 'mac' ;;
|
||||
Linux-x86_64) printf 'linux-x64' ;;
|
||||
Linux-aarch64|Linux-arm64) printf 'linux-arm64' ;;
|
||||
*) printf '' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
bundle_shader_bridge() {
|
||||
local dest="$1" name="$2" plat="$3"
|
||||
local crate="$ROOT/tools/shaderfx-bridge"
|
||||
local src="" var
|
||||
[ -n "$plat" ] || fail "bundle_shader_bridge: no platform key for $name"
|
||||
|
||||
var="SHADERFX_BRIDGE_$(printf '%s' "$plat" | tr 'a-z-' 'A-Z_')"
|
||||
eval "src=\${$var:-}"
|
||||
|
||||
if [ -z "$src" ] && [ -f "$DIST/native/$plat/$name" ]; then
|
||||
src="$DIST/native/$plat/$name"
|
||||
fi
|
||||
if [ -z "$src" ] && [ -n "${SHADERFX_BRIDGE:-}" ]; then
|
||||
src="$SHADERFX_BRIDGE"
|
||||
fi
|
||||
if [ -z "$src" ] && [ "$plat" = "$(shader_bridge_host_plat)" ]; then
|
||||
if [ -f "$crate/target/release/$name" ]; then
|
||||
src="$crate/target/release/$name"
|
||||
elif command -v cargo >/dev/null 2>&1; then
|
||||
say "building the ShaderFX bridge with cargo"
|
||||
if (cd "$crate" && cargo build --release >/dev/null 2>&1); then
|
||||
src="$crate/target/release/$name"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$src" ] && [ -f "$src" ]; then
|
||||
mkdir -p "$dest"
|
||||
cp "$src" "$dest/$name"
|
||||
say "bundled $name for SHADER FX preset conversion ($plat)"
|
||||
return 0
|
||||
fi
|
||||
if [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then
|
||||
fail "$name ($plat) not found: set $var or stage it at dist/native/$plat/$name"
|
||||
fi
|
||||
warn "$name not found: this build can run converted presets but not CONVERT new ones (set $var or install cargo)"
|
||||
}
|
||||
|
||||
build_mac() {
|
||||
say "building macOS app"
|
||||
local love_app="${LOVE_APP:-/Applications/love.app}"
|
||||
@@ -179,6 +231,7 @@ build_mac() {
|
||||
# drop any bundled placeholder .love and fuse ours in
|
||||
find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete
|
||||
cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love"
|
||||
bundle_shader_bridge "$out_app/Contents/MacOS" "liblibrashader_bridge.dylib" mac
|
||||
|
||||
local plist="$out_app/Contents/Info.plist"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$plist" 2>/dev/null \
|
||||
@@ -292,9 +345,11 @@ build_win() {
|
||||
cp "$tls_dll" "$out_dir/gen1tls.dll"
|
||||
say "bundled gen1tls.dll for Windows TLS (wss://)"
|
||||
else
|
||||
warn "gen1tls.dll not found — Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)"
|
||||
warn "gen1tls.dll not found: Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)"
|
||||
fi
|
||||
|
||||
bundle_shader_bridge "$out_dir" "librashader_bridge.dll" win-x64
|
||||
|
||||
# The exe's icon lives in love.exe's PE resources, so it must be patched
|
||||
# BEFORE the .love is appended: peresed rewrites the whole file and would
|
||||
# drop the fused bytes. peresed (pipx install pe_tools) has no .ico input,
|
||||
@@ -390,6 +445,7 @@ build_linux() {
|
||||
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
|
||||
|
||||
cp "$LOVE_FILE" "$appdir/game.love"
|
||||
bundle_shader_bridge "$appdir" "liblibrashader_bridge.so" linux-x64
|
||||
|
||||
# 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
|
||||
@@ -427,15 +483,31 @@ EOF
|
||||
# file-manager thumbnailers show for the file itself.
|
||||
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
|
||||
rm -f "$appdir/love.svg" "$appdir/love.png" "$appdir/.DirIcon"
|
||||
sips -z 512 512 "$ICON_SRC" --out "$appdir/$APP_NAME.png" >/dev/null
|
||||
if command -v sips >/dev/null 2>&1; then
|
||||
sips -z 512 512 "$ICON_SRC" --out "$appdir/$APP_NAME.png" >/dev/null
|
||||
elif command -v convert >/dev/null 2>&1; then
|
||||
convert "$ICON_SRC" -resize 512x512 "$appdir/$APP_NAME.png"
|
||||
else
|
||||
fail "need sips (macOS) or ImageMagick convert to resize $ICON_SRC"
|
||||
fi
|
||||
cp "$appdir/$APP_NAME.png" "$appdir/.DirIcon"
|
||||
|
||||
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
|
||||
# sed -i '' is BSD; GNU sed wants sed -i (no empty backup suffix).
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
|
||||
else
|
||||
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
|
||||
fi
|
||||
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|
||||
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
|
||||
|
||||
sed -i '' 's|^exec "\$APPDIR/bin/love"|if [ -n "$WAYLAND_DISPLAY" ] \&\& [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\
|
||||
exec "$APPDIR/bin/love"|' "$appdir/AppRun"
|
||||
local wayland_hook='if [ -n "$WAYLAND_DISPLAY" ] && [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\
|
||||
exec "$APPDIR/bin/love"'
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i "s|^exec \"\$APPDIR/bin/love\"|$wayland_hook|" "$appdir/AppRun"
|
||||
else
|
||||
sed -i '' "s|^exec \"\$APPDIR/bin/love\"|$wayland_hook|" "$appdir/AppRun"
|
||||
fi
|
||||
|
||||
# Match the upstream image's compression (gzip, 128K blocks) so the
|
||||
# bundled runtime can read it.
|
||||
@@ -444,16 +516,20 @@ exec "$APPDIR/bin/love"|' "$appdir/AppRun"
|
||||
mksquashfs "$appdir" "$sfs_out" \
|
||||
-comp gzip -b 131072 -noappend -all-root -no-xattrs -quiet >/dev/null
|
||||
|
||||
local out_bin="$WORK/$APP_NAME-x86_64.AppImage"
|
||||
rm -f "$out_bin"
|
||||
mkdir -p "$DIST/linux"
|
||||
local out_bin="$DIST/linux/$APP_NAME-linux-x86_64.AppImage"
|
||||
rm -f "$out_bin" "$out_bin.sha256"
|
||||
head -c "$sfs_offset" "$love_appimage" > "$out_bin"
|
||||
cat "$sfs_out" >> "$out_bin"
|
||||
chmod +x "$out_bin"
|
||||
|
||||
local zip_out="$DIST/linux/$APP_NAME-linux.zip"
|
||||
rm -f "$zip_out"
|
||||
(cd "$WORK" && zip -q -9 -j "$zip_out" "$(basename "$out_bin")")
|
||||
say "Linux build: $zip_out"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
printf '%s %s\n' "$(sha256sum "$out_bin" | awk '{print $1}')" "$(basename "$out_bin")" \
|
||||
> "$out_bin.sha256"
|
||||
else
|
||||
printf '%s %s\n' "$(shasum -a 256 "$out_bin" | awk '{print $1}')" "$(basename "$out_bin")" \
|
||||
> "$out_bin.sha256"
|
||||
fi
|
||||
say "Linux build: $out_bin"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- Android
|
||||
|
||||
+234
-14
@@ -1,19 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
|
||||
#
|
||||
# Usage: scripts/build_android.sh [--version X.Y.Z] [--package-only]
|
||||
# Usage: scripts/build_android.sh [--version X.Y.Z] [--release] [--package-only]
|
||||
# [--test-application-id ID]
|
||||
#
|
||||
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
|
||||
# --package-only zip game.love + apply branding; skip gradle
|
||||
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
|
||||
# --release build the production-signed release APK (requires the
|
||||
# GEN1RECOMP_ANDROID_* signing environment variables)
|
||||
# --package-only zip game.love + apply branding; skip gradle
|
||||
# --test-application-id ID install under a distinct application id (e.g.
|
||||
# com.theboisclub.pokemonred.shaderfxtest) so a
|
||||
# test build installs side by side with a real
|
||||
# played copy instead of overwriting it. App name
|
||||
# gets a " (test)" suffix so it's distinguishable
|
||||
# in the launcher too. Without this flag,
|
||||
# apply_android_branding always writes back the
|
||||
# real shipping identity, which previously had to
|
||||
# be restored by hand in gradle.properties after
|
||||
# every test build.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md)
|
||||
# - Android SDK + NDK (SDK API 34, NDK 25.2.9519653)
|
||||
# - Android SDK + NDK (SDK API 36, NDK 25.2.9519653)
|
||||
# - JDK 17
|
||||
#
|
||||
# Output (after gradle):
|
||||
# dist/android/debug/*.apk (convenience copy)
|
||||
# mobile/android/app/build/outputs/apk/embedNoRecord/debug/*.apk
|
||||
# dist/android/debug/*.apk (normal local build) or dist/android/release/*.apk
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -26,15 +38,20 @@ APP_NAME="gen1recomp"
|
||||
APPLICATION_ID="com.theboisclub.pokemonred"
|
||||
LOVE_ANDROID_VERSION="11.5a"
|
||||
NDK_VERSION="25.2.9519653"
|
||||
ANDROID_API="36"
|
||||
YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
|
||||
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
|
||||
GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
|
||||
GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_gold.json}"
|
||||
SILVER_MANIFEST_RELATIVE="tools/rom_manifest_silver.json"
|
||||
SILVER_MANIFEST_URL="${SILVER_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_silver.json}"
|
||||
CRYSTAL_MANIFEST_RELATIVE="tools/rom_manifest_crystal.json"
|
||||
CRYSTAL_MANIFEST_URL="${CRYSTAL_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_crystal.json}"
|
||||
|
||||
VERSION=""
|
||||
PACKAGE_ONLY=false
|
||||
RELEASE=false
|
||||
TEST_APPLICATION_ID=""
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
@@ -44,11 +61,13 @@ while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version) VERSION="$2"; shift ;;
|
||||
--package-only) PACKAGE_ONLY=true ;;
|
||||
--release) RELEASE=true ;;
|
||||
--test-application-id) TEST_APPLICATION_ID="$2"; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,20p' "$0"
|
||||
sed -n '2,28p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown argument: $1 (try --version X.Y.Z or --package-only)" ;;
|
||||
*) fail "unknown argument: $1 (try --version X.Y.Z, --release, --package-only, or --test-application-id ID)" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
@@ -62,7 +81,37 @@ if [ -n "$VERSION" ]; then
|
||||
rest="${VERSION#*.}"
|
||||
minor="${rest%%.*}"
|
||||
patch="${rest##*.}"
|
||||
VERSION_CODE=$((major * 10000 + minor * 100 + patch))
|
||||
# Reserve three digits for each lower component. This stays monotonic across
|
||||
# 1.0.100 -> 1.1.0, unlike the old two-digit encoding, and remains inside
|
||||
# Android's signed 32-bit versionCode range for normal release versions.
|
||||
if [ "$minor" -gt 999 ] || [ "$patch" -gt 999 ] || [ "$major" -gt 2099 ]; then
|
||||
fail "--version components exceed Android versionCode limits"
|
||||
fi
|
||||
VERSION_CODE=$((major * 1000000 + minor * 1000 + patch))
|
||||
fi
|
||||
|
||||
if $RELEASE; then
|
||||
for var in GEN1RECOMP_ANDROID_KEYSTORE GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD \
|
||||
GEN1RECOMP_ANDROID_KEY_ALIAS GEN1RECOMP_ANDROID_KEY_PASSWORD; do
|
||||
[ -n "${!var:-}" ] || fail "--release requires $var"
|
||||
done
|
||||
[ -f "$GEN1RECOMP_ANDROID_KEYSTORE" ] \
|
||||
|| fail "Android signing keystore does not exist: $GEN1RECOMP_ANDROID_KEYSTORE"
|
||||
fi
|
||||
|
||||
if [ -n "$TEST_APPLICATION_ID" ]; then
|
||||
if ! printf '%s' "$TEST_APPLICATION_ID" | grep -Eq '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$'; then
|
||||
fail "invalid --test-application-id '$TEST_APPLICATION_ID' (expected a dotted package id, e.g. com.theboisclub.pokemonred.shaderfxtest)"
|
||||
fi
|
||||
APPLICATION_ID="$TEST_APPLICATION_ID"
|
||||
APP_NAME="$APP_NAME (test)"
|
||||
fi
|
||||
# Optional overrides for side-by-side test APKs (never used by CI shipping builds).
|
||||
if [ -n "${GEN1RECOMP_ANDROID_APPLICATION_ID:-}" ]; then
|
||||
APPLICATION_ID="$GEN1RECOMP_ANDROID_APPLICATION_ID"
|
||||
fi
|
||||
if [ -n "${GEN1RECOMP_ANDROID_APP_NAME:-}" ]; then
|
||||
APP_NAME="$GEN1RECOMP_ANDROID_APP_NAME"
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------- preconditions
|
||||
@@ -227,6 +276,54 @@ ensure_silver_manifest() {
|
||||
fail "Silver import manifest is unavailable. Git recovery failed and could not download $SILVER_MANIFEST_URL"
|
||||
}
|
||||
|
||||
crystal_manifest_is_valid() {
|
||||
local path="$1"
|
||||
python3 - "$path" <<'PY'
|
||||
import json, pathlib, sys
|
||||
|
||||
try:
|
||||
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||
except (OSError, ValueError):
|
||||
raise SystemExit(1)
|
||||
|
||||
raise SystemExit(0 if manifest.get("romSha1") ==
|
||||
"f4cd194bdee0d04ca4eac29e09b8e4e9d818c133" else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
ensure_crystal_manifest() {
|
||||
local manifest="$ROOT/$CRYSTAL_MANIFEST_RELATIVE"
|
||||
local staged
|
||||
staged="$(mktemp)"
|
||||
|
||||
if crystal_manifest_is_valid "$manifest"; then
|
||||
rm -f "$staged"
|
||||
return
|
||||
fi
|
||||
|
||||
warn "Crystal import manifest is missing or invalid; recovering it before packaging"
|
||||
if git -C "$ROOT" show "HEAD:$CRYSTAL_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \
|
||||
&& crystal_manifest_is_valid "$staged"; then
|
||||
mkdir -p "$(dirname "$manifest")"
|
||||
mv "$staged" "$manifest"
|
||||
say "restored Crystal import manifest from this checkout's Git data"
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v curl >/dev/null 2>&1 \
|
||||
&& curl --fail --location --retry 2 --connect-timeout 15 \
|
||||
--output "$staged" "$CRYSTAL_MANIFEST_URL" \
|
||||
&& crystal_manifest_is_valid "$staged"; then
|
||||
mkdir -p "$(dirname "$manifest")"
|
||||
mv "$staged" "$manifest"
|
||||
say "downloaded Crystal import manifest from the project repository"
|
||||
return
|
||||
fi
|
||||
|
||||
rm -f "$staged"
|
||||
fail "Crystal import manifest is unavailable. Git recovery failed and could not download $CRYSTAL_MANIFEST_URL"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- branding
|
||||
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
|
||||
# Manifest still gets permission trims. Re-applied every build so refreshing
|
||||
@@ -293,6 +390,7 @@ pack_game_love() {
|
||||
ensure_yellow_manifest
|
||||
ensure_gold_manifest
|
||||
ensure_silver_manifest
|
||||
ensure_crystal_manifest
|
||||
mkdir -p "$EMBED_ASSETS"
|
||||
rm -f "$LOVE_FILE"
|
||||
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
||||
@@ -307,7 +405,7 @@ pack_game_love() {
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
tools/rom_manifest_silver.json \
|
||||
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
# List once and match against the captured text: piping unzip straight into
|
||||
@@ -331,6 +429,8 @@ pack_game_love() {
|
||||
|| fail "game.love is missing the Gold ROM import manifest"
|
||||
grep -qx 'tools/rom_manifest_silver.json' <<< "$archive_entries" \
|
||||
|| fail "game.love is missing the Silver ROM import manifest"
|
||||
grep -qx 'tools/rom_manifest_crystal.json' <<< "$archive_entries" \
|
||||
|| fail "game.love is missing the Crystal ROM import manifest"
|
||||
# This gate exists because the launcher's UI toolkit once lived outside
|
||||
# src/ (libs/flexlove) and was added to scripts/build.sh's payload and to
|
||||
# no other packager, so Android and iOS built an APK/IPA whose launcher
|
||||
@@ -368,6 +468,115 @@ pack_game_love() {
|
||||
fi
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- ShaderFX bridge
|
||||
SHADER_BRIDGE_LIB="liblibrashader_bridge.so"
|
||||
SHADER_BRIDGE_ABIS="arm64-v8a armeabi-v7a"
|
||||
|
||||
shader_bridge_rust_target() {
|
||||
case "$1" in
|
||||
arm64-v8a) printf 'aarch64-linux-android' ;;
|
||||
armeabi-v7a) printf 'armv7-linux-androideabi' ;;
|
||||
x86_64) printf 'x86_64-linux-android' ;;
|
||||
x86) printf 'i686-linux-android' ;;
|
||||
*) printf '' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
shader_bridge_staged_count() {
|
||||
local jni="$1" abi count=0
|
||||
for abi in $SHADER_BRIDGE_ABIS; do
|
||||
[ -f "$jni/$abi/$SHADER_BRIDGE_LIB" ] && count=$((count + 1))
|
||||
done
|
||||
printf '%s' "$count"
|
||||
}
|
||||
|
||||
bundle_shader_bridge_android() {
|
||||
local jni="$ANDROID_DIR/app/src/main/jniLibs"
|
||||
local crate="$ROOT/tools/shaderfx-bridge"
|
||||
local abi target
|
||||
|
||||
for abi in $SHADER_BRIDGE_ABIS; do
|
||||
rm -f "$jni/$abi/$SHADER_BRIDGE_LIB"
|
||||
done
|
||||
|
||||
local prebuilt="${SHADERFX_BRIDGE_ANDROID_DIR:-}"
|
||||
if [ -n "$prebuilt" ]; then
|
||||
for abi in $SHADER_BRIDGE_ABIS; do
|
||||
if [ -f "$prebuilt/$abi/$SHADER_BRIDGE_LIB" ]; then
|
||||
mkdir -p "$jni/$abi"
|
||||
cp "$prebuilt/$abi/$SHADER_BRIDGE_LIB" "$jni/$abi/$SHADER_BRIDGE_LIB"
|
||||
else
|
||||
warn "SHADERFX_BRIDGE_ANDROID_DIR has no $abi/$SHADER_BRIDGE_LIB"
|
||||
fi
|
||||
done
|
||||
if [ "$(shader_bridge_staged_count "$jni")" -gt 0 ]; then
|
||||
say "bundled $SHADER_BRIDGE_LIB for SHADER FX preset conversion (prebuilt)"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -f "$crate/Cargo.toml" ]; then
|
||||
warn "$SHADER_BRIDGE_LIB not found: this build can run converted presets but not CONVERT new ones (tools/shaderfx-bridge is missing)"
|
||||
return
|
||||
fi
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1 || ! cargo ndk --version >/dev/null 2>&1; then
|
||||
warn "$SHADER_BRIDGE_LIB not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE_ANDROID_DIR or run 'cargo install cargo-ndk')"
|
||||
return
|
||||
fi
|
||||
|
||||
local ndk="${ANDROID_NDK_HOME:-}"
|
||||
if [ -z "$ndk" ] || [ ! -d "$ndk" ]; then
|
||||
ndk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Library/Android/sdk}}/ndk/$NDK_VERSION"
|
||||
fi
|
||||
if [ ! -d "$ndk" ]; then
|
||||
warn "$SHADER_BRIDGE_LIB not built: NDK $NDK_VERSION not found (set ANDROID_NDK_HOME)"
|
||||
return
|
||||
fi
|
||||
|
||||
local installed missing="" buildable=""
|
||||
installed="$(rustup target list --installed 2>/dev/null || true)"
|
||||
for abi in $SHADER_BRIDGE_ABIS; do
|
||||
target="$(shader_bridge_rust_target "$abi")"
|
||||
if grep -qx "$target" <<< "$installed"; then
|
||||
buildable="$buildable $abi"
|
||||
else
|
||||
missing="$missing $target"
|
||||
fi
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
warn "$SHADER_BRIDGE_LIB: skipping$missing. Run: rustup target add$missing"
|
||||
fi
|
||||
if [ -z "$buildable" ]; then
|
||||
warn "$SHADER_BRIDGE_LIB not built: this build can run converted presets but not CONVERT new ones (no Android Rust targets installed)"
|
||||
return
|
||||
fi
|
||||
|
||||
local args=()
|
||||
for abi in $buildable; do
|
||||
args+=(-t "$abi")
|
||||
done
|
||||
|
||||
say "building the ShaderFX bridge with cargo-ndk (${buildable# })"
|
||||
mkdir -p "$jni"
|
||||
if ! (
|
||||
cd "$crate"
|
||||
export ANDROID_NDK_HOME="$ndk"
|
||||
export ANDROID_NDK_ROOT="$ndk"
|
||||
export CARGO_PROFILE_RELEASE_STRIP="symbols"
|
||||
cargo ndk "${args[@]}" -o "$jni" build --release
|
||||
); then
|
||||
warn "$SHADER_BRIDGE_LIB failed to cross-compile: this build can run converted presets but not CONVERT new ones"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$(shader_bridge_staged_count "$jni")" -gt 0 ]; then
|
||||
say "bundled $SHADER_BRIDGE_LIB for SHADER FX preset conversion"
|
||||
else
|
||||
warn "$SHADER_BRIDGE_LIB not found after cargo-ndk: this build can run converted presets but not CONVERT new ones"
|
||||
fi
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- SDK check
|
||||
require_android_sdk() {
|
||||
local sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
|
||||
@@ -389,13 +598,18 @@ require_android_sdk() {
|
||||
export ANDROID_SDK_ROOT=\$HOME/Library/Android/sdk
|
||||
or create mobile/android/local.properties with:
|
||||
sdk.dir=/path/to/Android/sdk
|
||||
love-android $LOVE_ANDROID_VERSION expects SDK API 34 and NDK $NDK_VERSION
|
||||
love-android $LOVE_ANDROID_VERSION expects SDK API $ANDROID_API and NDK $NDK_VERSION
|
||||
(see mobile/ANDROID.md)."
|
||||
fi
|
||||
|
||||
export ANDROID_SDK_ROOT="$sdk"
|
||||
export ANDROID_HOME="$sdk"
|
||||
|
||||
if [ ! -d "$sdk/platforms/android-$ANDROID_API" ]; then
|
||||
fail "Android SDK platform android-$ANDROID_API is not installed.
|
||||
Install Android $ANDROID_API (and the latest 36.x Build-Tools) in SDK Manager."
|
||||
fi
|
||||
|
||||
local props="$ANDROID_DIR/local.properties"
|
||||
# Always rewrite so a leftover Docker sdk.dir=/opt/android-sdk cannot stick.
|
||||
printf 'sdk.dir=%s\n' "$sdk" > "$props"
|
||||
@@ -412,7 +626,12 @@ require_android_sdk() {
|
||||
|
||||
# --------------------------------------------------------------- gradle
|
||||
run_gradle() {
|
||||
local task="assembleEmbedNoRecordDebug"
|
||||
local variant="debug"
|
||||
$RELEASE && variant="release"
|
||||
# Keep this compatible with macOS's bundled Bash 3.2 (no ${var^}).
|
||||
local variant_title="Debug"
|
||||
$RELEASE && variant_title="Release"
|
||||
local task="assembleEmbedNoRecord$variant_title"
|
||||
local build_dir="$ANDROID_DIR"
|
||||
|
||||
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in
|
||||
@@ -447,12 +666,12 @@ run_gradle() {
|
||||
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
|
||||
fi
|
||||
|
||||
local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/debug"
|
||||
local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/$variant"
|
||||
if [ -d "$out_dir" ]; then
|
||||
say "APK output:"
|
||||
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
|
||||
|
||||
local dist_dir="$DIST/debug"
|
||||
local dist_dir="$DIST/$variant"
|
||||
rm -rf "$dist_dir"
|
||||
mkdir -p "$dist_dir"
|
||||
find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \;
|
||||
@@ -472,5 +691,6 @@ if $PACKAGE_ONLY; then
|
||||
fi
|
||||
|
||||
require_android_sdk
|
||||
bundle_shader_bridge_android
|
||||
run_gradle
|
||||
say "done"
|
||||
|
||||
Executable
+143
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a single-file Flatpak bundle for x86_64 desktop Linux.
|
||||
#
|
||||
# Usage: scripts/build_flatpak.sh [--version X.Y.Z] [--game-love PATH]
|
||||
#
|
||||
# Output:
|
||||
# dist/flatpak/gen1recomp-<version>-linux.flatpak
|
||||
#
|
||||
# Requires: flatpak, flatpak-builder, and network on first run (Freedesktop
|
||||
# runtime + LÖVE AppImage download).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
APP_ID="com.theboisclub.gen1recomp"
|
||||
VERSION="0.0.0"
|
||||
GAME_LOVE_IN=""
|
||||
DIST="$ROOT/dist/flatpak"
|
||||
BUILD_DIR="$ROOT/.bazinga/flatpak-build"
|
||||
REPO_DIR="$ROOT/.bazinga/flatpak-repo"
|
||||
STATE_DIR="$ROOT/.bazinga/flatpak-state"
|
||||
CACHE="$ROOT/.bazinga/flatpak-cache"
|
||||
|
||||
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; }
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version) VERSION="$2"; shift ;;
|
||||
--game-love) GAME_LOVE_IN="$2"; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,12p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown argument: $1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
fail "invalid --version '$VERSION' (expected X.Y.Z)"
|
||||
fi
|
||||
|
||||
command -v flatpak >/dev/null || fail "flatpak not found"
|
||||
command -v flatpak-builder >/dev/null || fail "flatpak-builder not found"
|
||||
|
||||
mkdir -p "$DIST" "$BUILD_DIR" "$REPO_DIR" "$STATE_DIR" "$CACHE"
|
||||
|
||||
LOVE_FILE="$CACHE/game.love"
|
||||
if [ -n "$GAME_LOVE_IN" ]; then
|
||||
[ -f "$GAME_LOVE_IN" ] || fail "game.love not found: $GAME_LOVE_IN"
|
||||
if [ "$(readlink -f "$GAME_LOVE_IN")" != "$(readlink -f "$LOVE_FILE")" ]; then
|
||||
cp "$GAME_LOVE_IN" "$LOVE_FILE"
|
||||
fi
|
||||
else
|
||||
say "packing game.love"
|
||||
"$ROOT/scripts/pack_love.sh" --output "$LOVE_FILE" --version "$VERSION"
|
||||
fi
|
||||
|
||||
# Stage local sources next to the generated manifest (flatpak path: is relative).
|
||||
cp "$ROOT/flatpak/$APP_ID.desktop" "$CACHE/$APP_ID.desktop"
|
||||
# AppStream rejects icons larger than the declared hicolor size.
|
||||
if command -v magick >/dev/null 2>&1; then
|
||||
magick "$ROOT/assets/logo/gen1recomp_cover.png" -resize 512x512 "$CACHE/icon.png"
|
||||
elif command -v convert >/dev/null 2>&1; then
|
||||
convert "$ROOT/assets/logo/gen1recomp_cover.png" -resize 512x512 "$CACHE/icon.png"
|
||||
else
|
||||
fail "need ImageMagick (magick/convert) to resize the Flatpak icon to 512x512"
|
||||
fi
|
||||
|
||||
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
python3 - "$ROOT/flatpak/$APP_ID.metainfo.xml" "$CACHE/$APP_ID.metainfo.xml" "$VERSION" "$DATE" <<'PY'
|
||||
import sys, pathlib, re
|
||||
src, dst, ver, date = sys.argv[1:5]
|
||||
text = pathlib.Path(src).read_text(encoding="utf-8")
|
||||
release = f' <release version="{ver}" date="{date}"/>'
|
||||
text = re.sub(
|
||||
r"<releases>.*?</releases>",
|
||||
"<releases>\n" + release + "\n </releases>",
|
||||
text,
|
||||
count=1,
|
||||
flags=re.S,
|
||||
)
|
||||
pathlib.Path(dst).write_text(text, encoding="utf-8")
|
||||
PY
|
||||
|
||||
BRIDGE_LIB="liblibrashader_bridge.so"
|
||||
BRIDGE_SRC="${SHADERFX_BRIDGE_LINUX_X64:-}"
|
||||
if [ -z "$BRIDGE_SRC" ] && [ -f "$ROOT/dist/native/linux-x64/$BRIDGE_LIB" ]; then
|
||||
BRIDGE_SRC="$ROOT/dist/native/linux-x64/$BRIDGE_LIB"
|
||||
fi
|
||||
if [ -z "$BRIDGE_SRC" ] && [ -f "$ROOT/tools/shaderfx-bridge/target/release/$BRIDGE_LIB" ]; then
|
||||
BRIDGE_SRC="$ROOT/tools/shaderfx-bridge/target/release/$BRIDGE_LIB"
|
||||
fi
|
||||
rm -f "$CACHE/$BRIDGE_LIB"
|
||||
if [ -n "$BRIDGE_SRC" ] && [ -f "$BRIDGE_SRC" ]; then
|
||||
cp "$BRIDGE_SRC" "$CACHE/$BRIDGE_LIB"
|
||||
cp "$ROOT/flatpak/$APP_ID.yml" "$CACHE/$APP_ID.yml"
|
||||
say "staged $BRIDGE_LIB for SHADER FX preset conversion"
|
||||
elif [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then
|
||||
fail "$BRIDGE_LIB not found: set SHADERFX_BRIDGE_LINUX_X64 or stage it at dist/native/linux-x64/$BRIDGE_LIB"
|
||||
else
|
||||
warn "$BRIDGE_LIB not found: this bundle can run converted presets but not CONVERT new ones"
|
||||
sed '/# BRIDGE-BEGIN/,/# BRIDGE-END/d' "$ROOT/flatpak/$APP_ID.yml" > "$CACHE/$APP_ID.yml"
|
||||
fi
|
||||
|
||||
say "installing Freedesktop runtime (no-op if present)"
|
||||
flatpak --user remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo || true
|
||||
flatpak --user install -y flathub \
|
||||
org.freedesktop.Platform//24.08 \
|
||||
org.freedesktop.Sdk//24.08 \
|
||||
|| warn "runtime install reported an error; flatpak-builder may still reuse cache"
|
||||
|
||||
say "building Flatpak ($APP_ID $VERSION)"
|
||||
# Run from CACHE so path: sources resolve.
|
||||
(
|
||||
cd "$CACHE"
|
||||
flatpak-builder --user --force-clean \
|
||||
--state-dir="$STATE_DIR" \
|
||||
--repo="$REPO_DIR" \
|
||||
"$BUILD_DIR" "$APP_ID.yml"
|
||||
)
|
||||
|
||||
OUT="$DIST/gen1recomp-${VERSION}-linux.flatpak"
|
||||
rm -f "$OUT"
|
||||
say "exporting bundle $OUT"
|
||||
flatpak build-bundle "$REPO_DIR" "$OUT" "$APP_ID" \
|
||||
--runtime-repo=https://flathub.org/repo/flathub.flatpakrepo
|
||||
chmod 644 "$OUT"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
printf '%s %s\n' "$(sha256sum "$OUT" | awk '{print $1}')" "$(basename "$OUT")" > "$OUT.sha256"
|
||||
else
|
||||
printf '%s %s\n' "$(shasum -a 256 "$OUT" | awk '{print $1}')" "$(basename "$OUT")" > "$OUT.sha256"
|
||||
fi
|
||||
if [ -f "$CACHE/$BRIDGE_LIB" ]; then
|
||||
[ -f "$BUILD_DIR/files/share/gen1recomp/$BRIDGE_LIB" ] \
|
||||
|| fail "$BRIDGE_LIB was staged but is missing from the built /app/share/gen1recomp"
|
||||
say "verified $BRIDGE_LIB in the Flatpak"
|
||||
fi
|
||||
say "Flatpak build: $OUT ($(du -h "$OUT" | cut -f1))"
|
||||
say "sha256: $(cut -d' ' -f1 "$OUT.sha256")"
|
||||
+149
-3
@@ -615,6 +615,138 @@ verify_game_payload() {
|
||||
say "game.love present ($(du -h "$app/game.love" | cut -f1))"
|
||||
}
|
||||
|
||||
SHADER_BRIDGE_LIB=""
|
||||
SHADER_BRIDGE_NAME="liblibrashader_bridge.a"
|
||||
SHADER_BRIDGE_OBJ="librashader_bridge.o"
|
||||
|
||||
SHADER_BRIDGE_ARCHS=""
|
||||
|
||||
rust_targets_for_sdk() {
|
||||
if [ "$1" = "iphoneos" ]; then
|
||||
printf 'aarch64-apple-ios'
|
||||
else
|
||||
printf 'aarch64-apple-ios-sim x86_64-apple-ios'
|
||||
fi
|
||||
}
|
||||
|
||||
build_shader_bridge_slice() {
|
||||
local rust_target="$1"
|
||||
local crate="$ROOT/tools/shaderfx-bridge"
|
||||
local built="$crate/target/$rust_target/release/$SHADER_BRIDGE_NAME"
|
||||
if [ -f "$built" ]; then
|
||||
printf '%s' "$built"
|
||||
return 0
|
||||
fi
|
||||
if command -v cargo >/dev/null 2>&1 \
|
||||
&& rustup target list --installed 2>/dev/null \
|
||||
| grep -x "$rust_target" >/dev/null; then
|
||||
say "building the ShaderFX bridge with cargo ($rust_target)" >&2
|
||||
if (cd "$crate" && IPHONEOS_DEPLOYMENT_TARGET=15.0 \
|
||||
cargo build --release --target "$rust_target" >/dev/null 2>&1); then
|
||||
printf '%s' "$built"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
prelink_shader_bridge_slice() {
|
||||
local archive="$1" sdk="$2" out="$3"
|
||||
local arch platform sdk_version syms objcopy tmp
|
||||
arch="$(lipo -archs "$archive" 2>/dev/null | awk '{print $1}')"
|
||||
[ -n "$arch" ] || return 1
|
||||
if [ "$sdk" = "iphoneos" ]; then platform="ios"; else platform="ios-simulator"; fi
|
||||
sdk_version="$(xcrun --sdk "$sdk" --show-sdk-version 2>/dev/null)"
|
||||
[ -n "$sdk_version" ] || return 1
|
||||
syms="$LIBS_DIR/librashader_bridge.exports"
|
||||
printf '_librashader_translate_preset\n_librashader_free_string\n' > "$syms"
|
||||
tmp="$out.tmp"
|
||||
xcrun ld -r -arch "$arch" -platform_version "$platform" 15.0 "$sdk_version" \
|
||||
-all_load -exported_symbols_list "$syms" -o "$tmp" "$archive" || return 1
|
||||
objcopy="$(ls "$(rustc --print sysroot 2>/dev/null)"/lib/rustlib/*/bin/rust-objcopy 2>/dev/null | head -1)"
|
||||
if [ -n "$objcopy" ] && "$objcopy" --remove-section __LLVM,__bitcode \
|
||||
--remove-section __LLVM,__cmdline "$tmp" "$out" 2>/dev/null; then
|
||||
rm -f "$tmp"
|
||||
else
|
||||
mv "$tmp" "$out"
|
||||
fi
|
||||
}
|
||||
|
||||
bundle_shader_bridge_ios() {
|
||||
local rust_targets="$1" sdk="$2"
|
||||
local src="${SHADERFX_BRIDGE_IOS:-}"
|
||||
local slices=() objs=() target slice obj i
|
||||
SHADER_BRIDGE_LIB=""
|
||||
SHADER_BRIDGE_ARCHS=""
|
||||
rm -f "$LIBS_DIR/$SHADER_BRIDGE_NAME" "$LIBS_DIR/$SHADER_BRIDGE_OBJ" "$LIBS_DIR"/librashader_bridge.*.o
|
||||
if [ -n "$src" ]; then
|
||||
if [ -f "$src" ]; then
|
||||
slices+=("$src")
|
||||
else
|
||||
warn "SHADERFX_BRIDGE_IOS=$src does not exist"
|
||||
fi
|
||||
else
|
||||
for target in $rust_targets; do
|
||||
if slice="$(build_shader_bridge_slice "$target")"; then
|
||||
slices+=("$slice")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ "${#slices[@]}" -gt 0 ]; then
|
||||
mkdir -p "$LIBS_DIR"
|
||||
i=0
|
||||
for slice in "${slices[@]}"; do
|
||||
i=$((i + 1))
|
||||
obj="$LIBS_DIR/librashader_bridge.$i.o"
|
||||
if prelink_shader_bridge_slice "$slice" "$sdk" "$obj"; then
|
||||
objs+=("$obj")
|
||||
else
|
||||
warn "could not prelink $(basename "$slice") for $sdk"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ "${#objs[@]}" -eq 1 ]; then
|
||||
mv "${objs[0]}" "$LIBS_DIR/$SHADER_BRIDGE_OBJ"
|
||||
elif [ "${#objs[@]}" -gt 1 ]; then
|
||||
lipo -create "${objs[@]}" -output "$LIBS_DIR/$SHADER_BRIDGE_OBJ"
|
||||
rm -f "${objs[@]}"
|
||||
fi
|
||||
if [ -f "$LIBS_DIR/$SHADER_BRIDGE_OBJ" ]; then
|
||||
SHADER_BRIDGE_LIB="$LIBS_DIR/$SHADER_BRIDGE_OBJ"
|
||||
SHADER_BRIDGE_ARCHS="$(lipo -archs "$SHADER_BRIDGE_LIB" 2>/dev/null || true)"
|
||||
say "linking $SHADER_BRIDGE_OBJ for SHADER FX preset conversion (${SHADER_BRIDGE_ARCHS:-unknown arch})"
|
||||
else
|
||||
warn "$SHADER_BRIDGE_NAME not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE_IOS, or install cargo plus one of: rustup target add $rust_targets)"
|
||||
fi
|
||||
}
|
||||
|
||||
verify_shader_bridge() {
|
||||
local app="$1"
|
||||
local exe bin
|
||||
if [ -z "$SHADER_BRIDGE_LIB" ]; then
|
||||
warn "no SHADER FX bridge in this build: CONVERT stays unavailable, converted presets still run"
|
||||
return 0
|
||||
fi
|
||||
exe="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' \
|
||||
"$app/Info.plist" 2>/dev/null || true)"
|
||||
bin="$app/${exe:-love}"
|
||||
[ -f "$bin" ] || bin="$app/love"
|
||||
if [ ! -f "$bin" ]; then
|
||||
warn "no executable inside $(basename "$app"); skipping SHADER FX bridge check"
|
||||
return 0
|
||||
fi
|
||||
if nm "$bin" 2>/dev/null | grep -E ' _librashader_translate_preset$' >/dev/null \
|
||||
|| xcrun dyld_info -exports "$bin" 2>/dev/null \
|
||||
| grep -E ' _librashader_translate_preset$' >/dev/null; then
|
||||
say "SHADER FX bridge present (librashader_translate_preset)"
|
||||
return 0
|
||||
fi
|
||||
fail "built app does not carry the SHADER FX bridge symbols.
|
||||
$SHADER_BRIDGE_OBJ was linked but librashader_translate_preset is absent,
|
||||
so SHADER FX CONVERT would fail at runtime.
|
||||
Rebuild after: rm -rf tools/shaderfx-bridge/target"
|
||||
}
|
||||
|
||||
run_xcodebuild() {
|
||||
local config sdk destination
|
||||
if $RELEASE; then
|
||||
@@ -633,6 +765,8 @@ run_xcodebuild() {
|
||||
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
bundle_shader_bridge_ios "$(rust_targets_for_sdk "$sdk")" "$sdk"
|
||||
|
||||
# Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires
|
||||
# -scheme whenever -derivedDataPath is set, and love-ios ships no shared schemes.
|
||||
# Always stamp both: the overlay plist expands $(MARKETING_VERSION) /
|
||||
@@ -661,6 +795,13 @@ run_xcodebuild() {
|
||||
ONLY_ACTIVE_ARCH=NO
|
||||
DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES
|
||||
)
|
||||
if [ -n "$SHADER_BRIDGE_LIB" ]; then
|
||||
args+=(OTHER_LDFLAGS="-Wl,-u,_librashader_translate_preset -Wl,-u,_librashader_free_string \"$SHADER_BRIDGE_LIB\" -lc++")
|
||||
if [ -n "$SHADER_BRIDGE_ARCHS" ]; then
|
||||
args+=(ARCHS="$SHADER_BRIDGE_ARCHS")
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! $DEVICE; then
|
||||
# Simulator: ad-hoc signing (no certificate needed). A plain unsigned
|
||||
# build would drop the entitlements file, and HealthKit refuses to run
|
||||
@@ -723,11 +864,14 @@ run_xcodebuild() {
|
||||
|
||||
local products="$BUILD_DIR/Build/Products/${config}-${sdk}"
|
||||
local app=""
|
||||
local candidate
|
||||
local candidate newest=0 mtime
|
||||
for candidate in "$products/$PRODUCT_NAME.app" "$products/$APP_NAME.app" "$products/love.app"; do
|
||||
if [ -d "$candidate" ]; then
|
||||
app="$candidate"
|
||||
break
|
||||
mtime="$(stat -f %m "$candidate" 2>/dev/null || echo 0)"
|
||||
if [ "$mtime" -gt "$newest" ]; then
|
||||
newest="$mtime"
|
||||
app="$candidate"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ -z "$app" ]; then
|
||||
@@ -736,6 +880,7 @@ run_xcodebuild() {
|
||||
return 0
|
||||
fi
|
||||
if [ "$app" != "$products/$APP_NAME.app" ]; then
|
||||
rm -rf "$products/$APP_NAME.app"
|
||||
mv "$app" "$products/$APP_NAME.app"
|
||||
app="$products/$APP_NAME.app"
|
||||
fi
|
||||
@@ -754,6 +899,7 @@ run_xcodebuild() {
|
||||
|
||||
verify_game_payload "$app"
|
||||
verify_native_bridge "$app"
|
||||
verify_shader_bridge "$app"
|
||||
|
||||
local dist_dir="$DIST/${config}-${sdk}"
|
||||
rm -rf "$dist_dir"
|
||||
|
||||
@@ -100,6 +100,21 @@ then
|
||||
fi
|
||||
cp "$GAME_LOVE" "$IN_DIR/game.love"
|
||||
|
||||
BRIDGE_LIB="liblibrashader_bridge.so"
|
||||
BRIDGE_SRC="${SHADERFX_BRIDGE_LINUX_ARM64:-}"
|
||||
if [ -z "$BRIDGE_SRC" ] && [ -f "$ROOT/dist/native/linux-arm64/$BRIDGE_LIB" ]; then
|
||||
BRIDGE_SRC="$ROOT/dist/native/linux-arm64/$BRIDGE_LIB"
|
||||
fi
|
||||
rm -f "$IN_DIR/$BRIDGE_LIB"
|
||||
if [ -n "$BRIDGE_SRC" ] && [ -f "$BRIDGE_SRC" ]; then
|
||||
cp "$BRIDGE_SRC" "$IN_DIR/$BRIDGE_LIB"
|
||||
say "staged $BRIDGE_LIB for SHADER FX preset conversion"
|
||||
elif [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then
|
||||
fail "$BRIDGE_LIB not found: set SHADERFX_BRIDGE_LINUX_ARM64 or stage it at dist/native/linux-arm64/$BRIDGE_LIB"
|
||||
else
|
||||
warn "$BRIDGE_LIB not found: this build can run converted presets but not CONVERT new ones"
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------- 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.
|
||||
|
||||
+21
-2
@@ -7,7 +7,8 @@
|
||||
# the nil global), unused values, unreachable code. The .luacheckrc mutes the
|
||||
# cosmetic categories the codebase lives with, so what prints is worth a look.
|
||||
#
|
||||
# scripts/lint.sh lint src/
|
||||
# scripts/lint.sh full advisory report over every shipped tree
|
||||
# scripts/lint.sh --gate only the codes CI blocks on (0xx, 1xx, 511)
|
||||
# scripts/lint.sh src tools lint specific paths
|
||||
#
|
||||
# Install once with: luarocks install luacheck
|
||||
@@ -15,9 +16,27 @@
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
DEFAULT_PATHS=(main.lua conf.lua src data/scripts mods tools)
|
||||
|
||||
GATE=0
|
||||
if [ "${1:-}" = "--gate" ]; then
|
||||
GATE=1
|
||||
shift
|
||||
fi
|
||||
|
||||
if ! command -v luacheck >/dev/null 2>&1; then
|
||||
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
luacheck "${@:-src}"
|
||||
if [ "$#" -gt 0 ]; then
|
||||
PATHS=("$@")
|
||||
else
|
||||
PATHS=("${DEFAULT_PATHS[@]}")
|
||||
fi
|
||||
|
||||
if [ "$GATE" = "1" ]; then
|
||||
exec luacheck "${PATHS[@]}" -q --codes --only 0 1 511
|
||||
fi
|
||||
|
||||
luacheck "${PATHS[@]}"
|
||||
|
||||
@@ -327,6 +327,14 @@ bundle_needed "$APPDIR/lib/liblove-$LOVE_VERSION.so"
|
||||
say "bundled $(ls "$APPDIR/lib" | wc -l) libraries: $(ls "$APPDIR/lib" | tr '\n' ' ')"
|
||||
|
||||
# ------------------------------------------------- host dependency contract
|
||||
SHADER_BRIDGE=()
|
||||
if [ -f "$IN/liblibrashader_bridge.so" ]; then
|
||||
cp "$IN/liblibrashader_bridge.so" "$APPDIR/liblibrashader_bridge.so"
|
||||
chmod 0755 "$APPDIR/liblibrashader_bridge.so"
|
||||
SHADER_BRIDGE=("$APPDIR/liblibrashader_bridge.so")
|
||||
say "bundled liblibrashader_bridge.so for SHADER FX preset conversion"
|
||||
fi
|
||||
|
||||
# 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
|
||||
@@ -339,7 +347,7 @@ say "bundled $(ls "$APPDIR/lib" | wc -l) libraries: $(ls "$APPDIR/lib" | tr '\n'
|
||||
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
|
||||
for object in "$APPDIR/bin/love" "$APPDIR"/lib/*.so* ${SHADER_BRIDGE[@]+"${SHADER_BRIDGE[@]}"}; do
|
||||
while read -r soname; do
|
||||
[ -n "$soname" ] || continue
|
||||
# Satisfied from inside the AppDir, so not a host requirement at all.
|
||||
@@ -368,6 +376,8 @@ grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \
|
||||
|| fail "game.love is missing tools/rom_manifest_gold.json"
|
||||
grep -qxF "tools/rom_manifest_silver.json" "$WORK/love-listing.txt" \
|
||||
|| fail "game.love is missing tools/rom_manifest_silver.json"
|
||||
grep -qxF "tools/rom_manifest_crystal.json" "$WORK/love-listing.txt" \
|
||||
|| fail "game.love is missing tools/rom_manifest_crystal.json"
|
||||
# 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"
|
||||
|
||||
@@ -171,5 +171,7 @@ grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \
|
||||
|| fail "shared payload is missing tools/rom_manifest_gold.json"
|
||||
grep -qxF "tools/rom_manifest_silver.json" "$temp_dir/love-listing.txt" \
|
||||
|| fail "shared payload is missing tools/rom_manifest_silver.json"
|
||||
grep -qxF "tools/rom_manifest_crystal.json" "$temp_dir/love-listing.txt" \
|
||||
|| fail "shared payload is missing tools/rom_manifest_crystal.json"
|
||||
|
||||
say "Linux arm64 self-test passed"
|
||||
|
||||
@@ -20,6 +20,23 @@ for required in AppRun bin/love game.love lib/liblove-11.5.so; do
|
||||
|| { echo "::error::AppImage is missing $required"; exit 1; }
|
||||
done
|
||||
|
||||
bridge="squashfs-root/liblibrashader_bridge.so"
|
||||
if [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ] && [ ! -e "$bridge" ]; then
|
||||
echo "::error::AppImage is missing liblibrashader_bridge.so"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Discovered, not enumerated: bin/love and lib/*.so* miss anything native at
|
||||
# the AppDir root, which is where ShaderFX's bridge sits and where the next
|
||||
# root-level library would land too.
|
||||
scan=()
|
||||
while IFS= read -r f; do
|
||||
scan+=("$f")
|
||||
done < <(find squashfs-root -type f -exec sh -c \
|
||||
'head -c4 "$1" | od -An -tx1 | tr -d " \n" | grep -q "^7f454c46$"' _ {} \; -print | sort)
|
||||
[ "${#scan[@]}" -gt 0 ] || { echo "::error::found no ELF objects in the AppDir"; exit 1; }
|
||||
echo "scanning ${#scan[@]} ELF objects: ${scan[*]#squashfs-root/}"
|
||||
|
||||
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
|
||||
# applied; an unresolved soname here is a user-visible launch crash.
|
||||
#
|
||||
@@ -28,14 +45,14 @@ done
|
||||
# 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 \
|
||||
ldd "${scan[@]}" 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
|
||||
linked="$(for f in "${scan[@]}"; 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" ] \
|
||||
@@ -45,7 +62,7 @@ done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo
|
||||
# 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 \
|
||||
floor="$(objdump -T "${scan[@]}" 2>/dev/null \
|
||||
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
|
||||
echo "highest required glibc symbol version: $floor"
|
||||
[ -n "$floor" ] \
|
||||
@@ -54,4 +71,12 @@ 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; }
|
||||
|
||||
# The floor grep proves symbol versions; an actual dlopen additionally proves
|
||||
# relocations and constructors. Native arch only, so it is skipped elsewhere.
|
||||
if [ -e "$bridge" ] && [ "$(uname -m)" = "aarch64" ] && command -v python3 >/dev/null 2>&1; then
|
||||
python3 -c "import ctypes,sys; ctypes.CDLL(sys.argv[1])" "$PWD/$bridge" \
|
||||
|| { echo "::error::liblibrashader_bridge.so failed to dlopen"; exit 1; }
|
||||
echo "librashader bridge dlopens cleanly"
|
||||
fi
|
||||
|
||||
echo "AppImage verified: $image"
|
||||
|
||||
@@ -50,7 +50,7 @@ rm -f "$OUTPUT"
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
tools/rom_manifest_silver.json \
|
||||
tools/rom_manifest_silver.json tools/rom_manifest_crystal.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
|
||||
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
|
||||
@@ -96,6 +96,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
tools/rom_manifest_silver.json \
|
||||
tools/rom_manifest_crystal.json \
|
||||
src/ui/kit/Kit.lua \
|
||||
src/import/LauncherView.lua; do
|
||||
grep -qxF "$required" "$LISTING" \
|
||||
|
||||
+2
-1
@@ -19,7 +19,8 @@ if [ ! -f "$ROOT/data/generated/maps.lua" ] \
|
||||
&& [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \
|
||||
&& [ ! -f "$ROOT/yellow/data/generated/maps.lua" ] \
|
||||
&& [ ! -f "$ROOT/gold/data/generated/maps.lua" ] \
|
||||
&& [ ! -f "$ROOT/silver/data/generated/maps.lua" ]; then
|
||||
&& [ ! -f "$ROOT/silver/data/generated/maps.lua" ] \
|
||||
&& [ ! -f "$ROOT/crystal/data/generated/maps.lua" ]; then
|
||||
fail "generated data missing, run scripts/setup.sh first"
|
||||
fi
|
||||
|
||||
|
||||
@@ -71,15 +71,15 @@ First install or update (same steps):
|
||||
your saves, imported ROMs, mods, and options. Re-extracting only
|
||||
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 or Yellow/Gold/Silver .gbc into:
|
||||
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver/Crystal .gbc into:
|
||||
switch/gen1recomp/pokemon-love2d/imports/
|
||||
then use Scan again in the launcher if needed.
|
||||
|
||||
Inboxes (drop files here via MTP / SD / FTP):
|
||||
imports/ — ROM .gb / .gbc
|
||||
imports/mods/ — community mod .zip
|
||||
imports/saves/red|blue|yellow|gold|silver/ — raw .sav import (Gold/Silver cart .sav not yet)
|
||||
exports/red|blue|yellow|gold|silver/ — pull after Export save (Gold/Silver not yet)
|
||||
imports/saves/red|blue|yellow|gold|silver|crystal/ - raw .sav import (Gen 2 cart .sav not yet)
|
||||
exports/red|blue|yellow|gold|silver|crystal/ - pull after Export save (Gen 2 not yet)
|
||||
|
||||
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
|
||||
EOF
|
||||
@@ -92,7 +92,7 @@ write_readme() {
|
||||
}
|
||||
|
||||
write_readme "$SAVE_ROOT/imports/README.txt" \
|
||||
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc here, then Scan again in the launcher."
|
||||
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver/Crystal .gbc here, then Scan again in the launcher."
|
||||
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
|
||||
"Put community mod .zip files here, then MODS → Scan again."
|
||||
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
|
||||
@@ -105,6 +105,8 @@ write_readme "$SAVE_ROOT/imports/saves/gold/README.txt" \
|
||||
"Gold cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
||||
write_readme "$SAVE_ROOT/imports/saves/silver/README.txt" \
|
||||
"Silver cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
||||
write_readme "$SAVE_ROOT/imports/saves/crystal/README.txt" \
|
||||
"Crystal cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
||||
write_readme "$SAVE_ROOT/exports/red/README.txt" \
|
||||
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
|
||||
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
|
||||
@@ -115,6 +117,8 @@ write_readme "$SAVE_ROOT/exports/gold/README.txt" \
|
||||
"Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
||||
write_readme "$SAVE_ROOT/exports/silver/README.txt" \
|
||||
"Silver cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
||||
write_readme "$SAVE_ROOT/exports/crystal/README.txt" \
|
||||
"Crystal cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
||||
|
||||
rm -f "$OUT_ZIP"
|
||||
(
|
||||
@@ -145,11 +149,13 @@ REQUIRED=(
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/crystal/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/crystal/README.txt"
|
||||
)
|
||||
for rel in "${REQUIRED[@]}"; do
|
||||
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
|
||||
|
||||
@@ -272,11 +272,13 @@ for rel in \
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/imports/saves/crystal/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
|
||||
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt" \
|
||||
"switch/gen1recomp/pokemon-love2d/exports/crystal/README.txt"
|
||||
do
|
||||
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
|
||||
done
|
||||
|
||||
+70
-5
@@ -5,8 +5,8 @@
|
||||
# them fails. The tier split is what makes that possible: T1/T2/T4 need
|
||||
# nothing but the committed fixture dataset, so they run anywhere --
|
||||
# including CI, which has no ROM. T3 asserts Pokemon Red facts and needs
|
||||
# data/generated/, so it is skipped automatically when the ROM has never
|
||||
# been imported rather than failing the run.
|
||||
# data/generated/ or an imported Red cache, so it is skipped automatically
|
||||
# when neither exists rather than failing the run.
|
||||
#
|
||||
# scripts/test.sh every tier this checkout can run
|
||||
# scripts/test.sh --quick skip the slow content tier
|
||||
@@ -16,6 +16,13 @@
|
||||
#
|
||||
# LUA overrides the interpreter (luajit here; CI installs lua5.4 too, but
|
||||
# the engine targets LuaJIT/5.1 semantics so luajit is the default).
|
||||
#
|
||||
# POKEPORT_TEST_CACHES points at one LOVE identity holding an imported cache
|
||||
# per version (red/ blue/ yellow/ gold/ silver/ crystal/); each is exported as
|
||||
# RED_CACHE .. CRYSTAL_CACHE for the suites that read one. Build it with:
|
||||
# POKEPORT_IDENTITY=pokeport-test-caches POKEPORT_VERSION=<version> \
|
||||
# POKEPORT_IMPORT_ONLY=1 POKEPORT_IMPORT_ROM="<rom>" love .
|
||||
# An explicit RED_CACHE/GOLD_CACHE/... in the environment always wins.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
@@ -32,7 +39,7 @@ for arg in "$@"; do
|
||||
--bless) BLESS=1 ;;
|
||||
--bless-shots) SHOTS=1; BLESS=1 ;;
|
||||
--quick) QUICK=1 ;;
|
||||
--help|-h) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
--help|-h) sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
@@ -49,6 +56,36 @@ fi
|
||||
# save-editor suite pins that to the default identity.
|
||||
SANDBOX_IDENTITY="ci-$$"
|
||||
|
||||
# Per-version caches. POKEPORT_TEST_CACHES names one LOVE identity holding an
|
||||
# imported cache per version (red/ blue/ yellow/ gold/ silver/ crystal/).
|
||||
CACHE_IDENTITY=${POKEPORT_TEST_CACHE_IDENTITY:-pokeport-test-caches}
|
||||
if [ -n "${POKEPORT_TEST_CACHES:-}" ]; then
|
||||
CACHE_ROOT="$POKEPORT_TEST_CACHES"
|
||||
elif [ -d "$HOME/Library/Application Support/LOVE/$CACHE_IDENTITY" ]; then
|
||||
CACHE_ROOT="$HOME/Library/Application Support/LOVE/$CACHE_IDENTITY"
|
||||
else
|
||||
CACHE_ROOT="$HOME/.local/share/love/$CACHE_IDENTITY"
|
||||
fi
|
||||
|
||||
# Only a cache with the importer's completion marker is offered: a half-written
|
||||
# one would fail suites that are meant to self-skip.
|
||||
adopt_cache() {
|
||||
local var="$1" dir="$CACHE_ROOT/$2"
|
||||
[ -z "${!var:-}" ] || return 0
|
||||
[ -f "$dir/rom-cache.complete" ] || return 0
|
||||
export "$var=$dir"
|
||||
echo " $var=$dir"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "-- per-version caches under $CACHE_ROOT"
|
||||
adopt_cache RED_CACHE red
|
||||
adopt_cache BLUE_CACHE blue
|
||||
adopt_cache YELLOW_CACHE yellow
|
||||
adopt_cache GOLD_CACHE gold
|
||||
adopt_cache SILVER_CACHE silver
|
||||
adopt_cache CRYSTAL_CACHE crystal
|
||||
|
||||
FAILED=()
|
||||
run_tier() {
|
||||
local label="$1"; shift
|
||||
@@ -66,10 +103,22 @@ run_tier() {
|
||||
|
||||
# ------- ROM-free tiers: these are what CI runs
|
||||
|
||||
if command -v luacheck >/dev/null 2>&1; then
|
||||
run_tier "T0 luacheck gate (undefined globals, unreachable code)" \
|
||||
./scripts/lint.sh --gate
|
||||
else
|
||||
echo ""
|
||||
echo "-- T0 luacheck gate: skipped (no luacheck on PATH --"
|
||||
echo " luarocks install luacheck; CI installs and gates on it regardless)"
|
||||
fi
|
||||
|
||||
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
|
||||
run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py
|
||||
run_tier "T0 Yellow title OBP eye remap" python3 tests/title_pikachu_obp_test.py
|
||||
run_tier "T0 Crystal manifest + specials coverage" "$LUA" tests/crystal_import_test.lua
|
||||
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
|
||||
run_tier "T0 ShaderFX bridge packaging gate" "$LUA" tests/shaderfx_bridge_packaging_test.lua
|
||||
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
|
||||
# Sound.lua / overlay regression is not gated only on switch-changes paths.
|
||||
run_tier "T0 NX asset overlay fallback" "$LUA" tests/engine/assets_version_fallback_test.lua
|
||||
@@ -78,6 +127,9 @@ run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_
|
||||
run_tier "T0 NX Gold cache load (maps.lua prefix)" "$LUA" tests/engine/cache_fs_gold_nx_load_test.lua
|
||||
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
|
||||
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
|
||||
# Gen 2 / Crystal: ROM-free (own fixtures, or a self-skip on a missing cache),
|
||||
# so it runs here rather than behind the Red content gate below.
|
||||
run_tier "T2 Gen 2 / Crystal suites" "$LUA" tests/run_gen2.lua
|
||||
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
|
||||
run_tier "T4 title checkpoint cold restart" \
|
||||
bash tests/integration/title_checkpoint_cold_start.sh
|
||||
@@ -124,7 +176,19 @@ run_content_behavior() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# The Red dataset is the source tree's when tools/build_data.py wrote one, and
|
||||
# otherwise the imported Red cache, through Data:load's POKEPORT_DATA_DIR hook.
|
||||
HAVE_RED_DATA=0
|
||||
if [ -f data/generated/maps.lua ]; then
|
||||
HAVE_RED_DATA=1
|
||||
elif [ -n "${RED_CACHE:-}" ] && [ -f "$RED_CACHE/data/generated/maps.lua" ]; then
|
||||
HAVE_RED_DATA=1
|
||||
export POKEPORT_DATA_DIR="$RED_CACHE/data/generated"
|
||||
echo ""
|
||||
echo "-- T3 content: reading Red from $POKEPORT_DATA_DIR"
|
||||
fi
|
||||
|
||||
if [ "$HAVE_RED_DATA" = "1" ]; then
|
||||
if [ "$QUICK" = "1" ]; then
|
||||
echo ""
|
||||
echo "-- T3 content: skipped (--quick)"
|
||||
@@ -154,8 +218,9 @@ if [ -f data/generated/maps.lua ]; then
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "-- T3 content + run_link_tests: skipped (no data/generated/ --"
|
||||
echo " import a ROM to run them; the modded-link cases ran in T4)"
|
||||
echo "-- T3 content + run_link_tests: skipped (no data/generated/ and no"
|
||||
echo " RED_CACHE -- import a ROM to run them; the modded-link cases ran"
|
||||
echo " in T4 and the Gen 2 suites in T2)"
|
||||
fi
|
||||
|
||||
# ------- golden screenshots: needs love + a display
|
||||
|
||||
@@ -348,6 +348,12 @@ local EMITTERS = {
|
||||
SE_PETALS_FALLING = function() return fallingObjectSteps(20, PETAL_TILE, "e4") end,
|
||||
}
|
||||
|
||||
-- home/copy2.asm:62 CopyVideoData -- 8 tiles a frame plus the tail frame
|
||||
local function tilesetLoadFrames(data, ts)
|
||||
local sheet = data and data.tilesheets and data.tilesheets[ts or 0]
|
||||
return math.floor((sheet and sheet.tiles or 79) / 8) + 1
|
||||
end
|
||||
|
||||
-- One OAM entry for tile `t` of a frame block anchored at base coord `bc`,
|
||||
-- with the subanimation transform applied (DrawFrameBlock).
|
||||
local function placeTile(transform, bc, t, tileset)
|
||||
@@ -456,6 +462,11 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
|
||||
local obp0Flip = false
|
||||
|
||||
for _, row in ipairs(anim.seq) do
|
||||
-- engine/battle/animations.asm:252 LoadMoveAnimationTiles, before the
|
||||
-- row's sound and its first frame block (#1653)
|
||||
if not row.effect then
|
||||
emit(tilesetLoadFrames(self.data, row.tileset))
|
||||
end
|
||||
-- PlayAnimation/PlaySubanimation: each row's sound byte is a move id
|
||||
-- whose MoveSoundTable entry (sfx + pitch/tempo modifiers) plays as
|
||||
-- the row starts (GetMoveSound)
|
||||
|
||||
@@ -8,6 +8,7 @@ local BattleSafety = {}
|
||||
local BATTLE_BUSY_FIELDS = {
|
||||
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
||||
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
||||
"shrinkOut",
|
||||
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
||||
}
|
||||
|
||||
|
||||
+495
-103
@@ -34,6 +34,12 @@ local RomText = require("src.core.RomText")
|
||||
local Strings = require("src.core.Strings")
|
||||
local WideBattle = require("src.battle.WideBattle")
|
||||
|
||||
local Chrome2
|
||||
do
|
||||
local ok, v = pcall(require, "src.ui.gen2.Chrome")
|
||||
Chrome2 = ok and v or nil
|
||||
end
|
||||
|
||||
local romText = RomText
|
||||
|
||||
local BattleState = {}
|
||||
@@ -97,7 +103,7 @@ end
|
||||
-- space.
|
||||
function BattleState:extendedHUD()
|
||||
local options = self.game and self.game.save and self.game.save.options
|
||||
local bg = options and options.battleBg
|
||||
local bg = options and self:bgMode()
|
||||
return self:wideLayout()
|
||||
and options and options.battleHud == "extended"
|
||||
and ((options.battleFit == "fixed"
|
||||
@@ -108,12 +114,12 @@ end
|
||||
function BattleState:extendedWorldHUD()
|
||||
local options = self.game and self.game.save and self.game.save.options
|
||||
return self:extendedHUD() and options
|
||||
and options.battleFit == "fixed" and options.battleBg == "world"
|
||||
and options.battleFit == "fixed" and self:bgMode() == "world"
|
||||
end
|
||||
|
||||
function BattleState:extendedBlackHUD()
|
||||
local options = self.game and self.game.save and self.game.save.options
|
||||
return self:extendedHUD() and options and options.battleBg == "black"
|
||||
return self:extendedHUD() and options and self:bgMode() == "black"
|
||||
end
|
||||
|
||||
-- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids
|
||||
@@ -130,6 +136,10 @@ end
|
||||
-- opaque 160x144 field over the top, so only the surround changes.
|
||||
function BattleState:bgMode()
|
||||
local options = self.game and self.game.save and self.game.save.options
|
||||
if options and self:wideLayout() and options.battleFit == "fill"
|
||||
and options.battleHud == "extended" then
|
||||
return "white"
|
||||
end
|
||||
local mode = options and options.battleBg
|
||||
if mode == "black" or mode == "world" then return mode end
|
||||
return "white"
|
||||
@@ -212,6 +222,13 @@ local BALL_ANIMS = {
|
||||
SHAKE_ANIM = true, SHOWPIC_ANIM = true,
|
||||
}
|
||||
|
||||
-- engine/battle/effects.asm:552
|
||||
local ENEMY_STAT_DOWN_MISS = {
|
||||
ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true,
|
||||
DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true,
|
||||
ACCURACY_DOWN1_EFFECT = true,
|
||||
}
|
||||
|
||||
local imageCache = {}
|
||||
-- The three tables below are keyed by the Image OBJECT, not by a path, and a
|
||||
-- running battle holds the pics it built at enter() (battler.sprite,
|
||||
@@ -229,6 +246,11 @@ local imagePadLeft = setmetatable({}, WEAK_KEYS)
|
||||
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
|
||||
-- rebuilt for any battle pic, whatever code loaded it
|
||||
local imageMeta = setmetatable({}, WEAK_KEYS)
|
||||
local function mattedPic(path)
|
||||
return path:sub(1, 17) == "assets/generated/"
|
||||
or path:sub(1, 17) == "save/mod-derived/"
|
||||
end
|
||||
|
||||
-- pal = { name, colors } recolors the 4 GB shades like the Super Game Boy.
|
||||
-- trueColor art (14 §the 4-shade contract) opts out of the quantize
|
||||
-- entirely, so its palette variant collapses back onto the plain path.
|
||||
@@ -254,29 +276,31 @@ local function getImage(path, pal, trueColor)
|
||||
end)
|
||||
end
|
||||
local w, h = id:getDimensions()
|
||||
local bottom = h - 1
|
||||
while bottom >= 0 do
|
||||
local opaque = false
|
||||
for x = 0, w - 1 do
|
||||
local _, _, _, a = id:getPixel(x, bottom)
|
||||
if a > 0 then opaque = true break end
|
||||
if mattedPic(Assets.resolve(path)) then
|
||||
local bottom = h - 1
|
||||
while bottom >= 0 do
|
||||
local opaque = false
|
||||
for x = 0, w - 1 do
|
||||
local _, _, _, a = id:getPixel(x, bottom)
|
||||
if a > 0 then opaque = true break end
|
||||
end
|
||||
if opaque then break end
|
||||
bottom = bottom - 1
|
||||
end
|
||||
if opaque then break end
|
||||
bottom = bottom - 1
|
||||
end
|
||||
local left = 0
|
||||
while left < w do
|
||||
local opaque = false
|
||||
for y = 0, h - 1 do
|
||||
local _, _, _, a = id:getPixel(left, y)
|
||||
if a > 0 then opaque = true break end
|
||||
local left = 0
|
||||
while left < w do
|
||||
local opaque = false
|
||||
for y = 0, h - 1 do
|
||||
local _, _, _, a = id:getPixel(left, y)
|
||||
if a > 0 then opaque = true break end
|
||||
end
|
||||
if opaque then break end
|
||||
left = left + 1
|
||||
end
|
||||
if opaque then break end
|
||||
left = left + 1
|
||||
pad = h - 1 - bottom
|
||||
padL = left
|
||||
end
|
||||
img = love.graphics.newImage(id)
|
||||
pad = h - 1 - bottom
|
||||
padL = left
|
||||
else
|
||||
img = Assets.image(path) -- headless stub: no pixel access
|
||||
end
|
||||
@@ -443,7 +467,7 @@ function BattleState:picImage(img)
|
||||
-- palettes (CYANMON reds 0.678/0.451) already rendered correctly. This mode
|
||||
-- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync.
|
||||
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
|
||||
or PaletteFX.mode == "classic"
|
||||
or PaletteFX.mode == "classic" or PaletteFX.forcesRawGrays()
|
||||
if self.grayPics or mono then return grayImage(img) end
|
||||
-- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go
|
||||
-- dark with the HP bars while the blackout text is up (#292). The intro
|
||||
@@ -538,6 +562,7 @@ end
|
||||
|
||||
local function makeBattler(data, mon, isPlayer, save)
|
||||
local def = data.pokemon[mon.species]
|
||||
require("src.pokemon.Stats").ensure(def, mon)
|
||||
local badgeBoosts = data.constants and data.constants.badgeBoosts
|
||||
local badges = nil
|
||||
if isPlayer and save then
|
||||
@@ -637,6 +662,9 @@ local function newBattle(game)
|
||||
-- wPartyAndBillsPCSavedMenuItem, so entering a battle drops the party
|
||||
-- cursor the field menu has been carrying (src/ui/PartyMenu.lua). #768
|
||||
game.partyMenuSavedIndex = nil
|
||||
-- the same run covers wBagSavedMenuItem, and wListScrollOffset follows it
|
||||
-- (init_battle_variables.asm:7-12) #1732
|
||||
game.bagSavedMenuItem, game.bagListScrollOffset = nil, nil
|
||||
self.data = game.data
|
||||
-- ruleset from the merged registry (the requires above are the same
|
||||
-- records on a mod-free boot); an unknown save value falls back to the
|
||||
@@ -1364,7 +1392,7 @@ function BattleState:updateQueue()
|
||||
-- subanimation (or just the coarse fx when animations are off).
|
||||
-- item.hit carries the target's blink + damage sound, applied when
|
||||
-- the animation ends (hitRow rows carry a hit with no animation --
|
||||
-- thrash/rage continuation turns that skip the announcement).
|
||||
-- Mimic, whose animation waits on a successful copy).
|
||||
if item.anim or item.hitRow then
|
||||
-- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then
|
||||
-- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass
|
||||
@@ -1917,6 +1945,8 @@ function BattleState:exit()
|
||||
-- end_of_battle.asm clears wPartyAndBillsPCSavedMenuItem as well, so the
|
||||
-- field party menu comes back on slot 1 after a battle. #768
|
||||
self.game.partyMenuSavedIndex = nil
|
||||
-- and wBagSavedMenuItem / wListScrollOffset (end_of_battle.asm:57-62) #1732
|
||||
self.game.bagSavedMenuItem, self.game.bagListScrollOffset = nil, nil
|
||||
-- Free this battle's own GPU objects now rather than waiting on a GC
|
||||
-- finalizer: the two full-screen wavy-effect canvases (colorMode) and
|
||||
-- the AnimPlayer's per-instance tilesheet images/quads. The shared
|
||||
@@ -1941,6 +1971,7 @@ local function clearTrapping(battler)
|
||||
battler.trappingTurns = nil
|
||||
battler.trapMove = nil
|
||||
battler.trapDamage = nil
|
||||
battler.trapHitSfx = nil
|
||||
end
|
||||
|
||||
-- SendOutMon (core.asm:1733-1735) clears both battle cursors, though the
|
||||
@@ -2207,6 +2238,9 @@ function BattleState:update(dt)
|
||||
self.waitFrames = nil
|
||||
if destination == "menu" then
|
||||
self.introSlide = nil
|
||||
-- engine/battle/core.asm:2007
|
||||
self.msgHold = nil
|
||||
self.shown = nil
|
||||
self.phase = "menu"
|
||||
elseif destination == "finish" then
|
||||
self:finish()
|
||||
@@ -2495,9 +2529,12 @@ function BattleState:openOldManBag()
|
||||
-- POKé BALLs; pokeyellow's SimulatedInputBattleItemList, shared by
|
||||
-- the Viridian tutorial and Oak's catch, has one.
|
||||
local qty = require("src.core.GameVersion").isYellow() and "x1" or "x50"
|
||||
-- the tutorial bag rides DisplayBagMenu's LIST_MENU_BOX over the battle
|
||||
-- screen (engine/battle/core.asm:2210)
|
||||
list = ListMenu.new(game, "ITEMS", {
|
||||
{ value = "POKE_BALL", label = Strings("POKé BALL"), right = qty },
|
||||
}, {
|
||||
itemBox = true,
|
||||
script = function(l)
|
||||
l.scriptTimer = (l.scriptTimer or 0) + 1
|
||||
if l.scriptTimer == 81 then
|
||||
@@ -2708,9 +2745,11 @@ function BattleState:resolveSwitch(newMon)
|
||||
self.afterQueue = "menu"
|
||||
self:act(function()
|
||||
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the
|
||||
-- outgoing pic and holds 50 frames before the mon is recalled
|
||||
-- outgoing pic and holds 50 frames, then AnimateRetreatingPlayerMon
|
||||
-- runs before the mon is recalled
|
||||
self:sayNextAuto(self:withdrawText(self.player.name),
|
||||
Timing.SWITCH_PLAYER_MON)
|
||||
self:queueRetreatAnim()
|
||||
self:actNext(function()
|
||||
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
||||
local previous = self.player
|
||||
@@ -2786,6 +2825,111 @@ function BattleState:queueResidual(b, opp)
|
||||
end
|
||||
end
|
||||
|
||||
local function fieldBattlerView(battler, side)
|
||||
local types = {}
|
||||
for index, typeId in ipairs(battler.curTypes or {}) do
|
||||
types[index] = typeId
|
||||
end
|
||||
local mon = battler.mon or {}
|
||||
return {
|
||||
side = side,
|
||||
name = battler.name,
|
||||
hp = tonumber(mon.hp) or 0,
|
||||
maxHp = tonumber(mon.stats and mon.stats.hp) or tonumber(mon.hp) or 0,
|
||||
types = types,
|
||||
vanished = battler.invulnerable and true or false,
|
||||
}
|
||||
end
|
||||
|
||||
local function publicScalar(value)
|
||||
local kind = type(value)
|
||||
if kind == "string" or kind == "boolean" then return value, true end
|
||||
if kind == "number" and value == value
|
||||
and value < math.huge and value > -math.huge then
|
||||
return value, true
|
||||
end
|
||||
return nil, false
|
||||
end
|
||||
|
||||
local function publicDataCopy(value, visiting)
|
||||
local scalar, ok = publicScalar(value)
|
||||
if ok then return scalar, true end
|
||||
if type(value) ~= "table" then return nil, false end
|
||||
|
||||
visiting = visiting or {}
|
||||
if visiting[value] then return nil, false end
|
||||
visiting[value] = true
|
||||
|
||||
local copy = {}
|
||||
for key, child in next, value do
|
||||
local copiedKey, keyOk = publicScalar(key)
|
||||
local copiedChild, childOk = publicDataCopy(child, visiting)
|
||||
if keyOk and childOk then copy[copiedKey] = copiedChild end
|
||||
end
|
||||
visiting[value] = nil
|
||||
return copy, true
|
||||
end
|
||||
|
||||
local function checkpointFieldView(field)
|
||||
field = field or {}
|
||||
local view = publicDataCopy({
|
||||
weather = field.weather,
|
||||
tokens = field.tokens or {},
|
||||
})
|
||||
return view
|
||||
end
|
||||
|
||||
-- Public field residuals are data-only requests. Mods can inspect a detached
|
||||
-- checkpoint-shaped field view and detached battler views, but only the engine
|
||||
-- mutates HP, animates the bar, or enters the faint pipeline.
|
||||
function BattleState:applyFieldResiduals()
|
||||
if not Runtime.wantsHook("battle.field_residual") then return end
|
||||
local views = {
|
||||
player = fieldBattlerView(self.player, "player"),
|
||||
enemy = fieldBattlerView(self.enemy, "enemy"),
|
||||
}
|
||||
local rows = Runtime.call("battle.field_residual", function() return {} end, {
|
||||
field = checkpointFieldView(self.field),
|
||||
battlers = views,
|
||||
turn = self.turnCount or 0,
|
||||
})
|
||||
if type(rows) ~= "table" then return end
|
||||
|
||||
local fainted = {}
|
||||
for _, row in ipairs(rows) do
|
||||
local battler = type(row) == "table" and row.side == "player"
|
||||
and self.player or type(row) == "table" and row.side == "enemy"
|
||||
and self.enemy or nil
|
||||
local amount = type(row) == "table" and row.amount or nil
|
||||
if battler and battler.mon.hp > 0 and type(amount) == "number"
|
||||
and amount > 0
|
||||
and amount < math.huge and amount == math.floor(amount)
|
||||
and (row.message == nil or type(row.message) == "string") then
|
||||
amount = math.min(amount, battler.mon.hp)
|
||||
if type(row.message) == "string" and row.message ~= "" then
|
||||
self:sayNext(row.message)
|
||||
end
|
||||
battler.mon.hp = battler.mon.hp - amount
|
||||
self:drainNext(battler, battler.mon.hp)
|
||||
if battler.mon.hp <= 0 then fainted[battler] = true end
|
||||
end
|
||||
end
|
||||
-- A terminal player faint owns a simultaneous field-residual batch. Queue
|
||||
-- only that authority so its blackout cannot race an enemy EXP/replacement
|
||||
-- path from the same hook response. Native faint paths remain untouched.
|
||||
if fainted[self.player]
|
||||
and not Party.firstHealthy(self:playerPartyView()) then
|
||||
self:onFaint(self.player)
|
||||
return
|
||||
end
|
||||
|
||||
-- Otherwise resolve the two sides in engine order after every accepted
|
||||
-- descriptor has landed. Descriptor order must not decide resolution.
|
||||
for _, battler in ipairs({ self.player, self.enemy }) do
|
||||
if fainted[battler] then self:onFaint(battler) end
|
||||
end
|
||||
end
|
||||
|
||||
function BattleState:endOfTurn()
|
||||
-- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed
|
||||
-- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual
|
||||
@@ -2839,6 +2983,7 @@ function BattleState:endOfTurn()
|
||||
b.trappingTurns = nil
|
||||
end
|
||||
end
|
||||
self:applyFieldResiduals()
|
||||
self:tickTokens()
|
||||
Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 })
|
||||
end
|
||||
@@ -2923,18 +3068,33 @@ local BGP_INVERT = { [0] = 3, 2, 1, 0 } -- $1b (flash phase 1)
|
||||
local BGP_WHITE = { [0] = 0, 0, 0, 0 } -- $00 (flash phase 2)
|
||||
local BGP_DARK = { [0] = 3, 3, 2, 1 } -- $6f DarkScreenPalette
|
||||
local BGP_LIGHT = { [0] = 0, 0, 1, 2 } -- $90 LightScreenPalette
|
||||
local BGP_DARKEN = { [0] = 0, 1, 3, 3 } -- $f4 DarkenMonPalette (SGB)
|
||||
local BGP_DARKEN_SGB = { [0] = 0, 1, 3, 3 } -- $f4
|
||||
local BGP_DARKEN_MONO = { [0] = 1, 2, 3, 3 } -- $f9
|
||||
|
||||
-- engine/battle/animations.asm:1090
|
||||
local function onSgb()
|
||||
local m = require("src.render.PaletteFX").mode
|
||||
return m == "gbc" or m == "gbc_inv"
|
||||
end
|
||||
|
||||
-- FlashScreenLongSGB (animations.asm:1010): 12 BGP values per cycle,
|
||||
-- 3 cycles; the first cycle holds each for 2 frames, the rest for 1
|
||||
-- (FlashScreenLongDelay)
|
||||
local FLASH_LONG_MAPS = {
|
||||
local FLASH_LONG_SGB = {
|
||||
{ [0] = 0, 2, 3, 3 }, { [0] = 0, 3, 3, 3 }, { [0] = 3, 3, 3, 3 },
|
||||
{ [0] = 0, 3, 3, 3 }, { [0] = 0, 2, 3, 3 }, { [0] = 0, 1, 2, 3 },
|
||||
{ [0] = 0, 0, 1, 2 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 0, 0 },
|
||||
{ [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 1, 2, 3 },
|
||||
}
|
||||
|
||||
-- engine/battle/animations.asm:992
|
||||
local FLASH_LONG_MONO = {
|
||||
{ [0] = 1, 2, 3, 3 }, { [0] = 2, 3, 3, 3 }, { [0] = 3, 3, 3, 3 },
|
||||
{ [0] = 2, 3, 3, 3 }, { [0] = 1, 2, 3, 3 }, { [0] = 0, 1, 2, 3 },
|
||||
{ [0] = 0, 0, 1, 2 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 0, 0 },
|
||||
{ [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 1, 2, 3 },
|
||||
}
|
||||
|
||||
-- the shade map in force this frame (a running flash wins over the
|
||||
-- persistent palette)
|
||||
function BattleState:activeBgp()
|
||||
@@ -3068,7 +3228,7 @@ function BattleState:applyAnimEffect(ev)
|
||||
elseif e == "SE_LIGHT_SCREEN_PALETTE" then
|
||||
fx.bgp = BGP_LIGHT
|
||||
elseif e == "SE_DARKEN_MON_PALETTE" then
|
||||
fx.bgp = BGP_DARKEN
|
||||
fx.bgp = onSgb() and BGP_DARKEN_SGB or BGP_DARKEN_MONO
|
||||
elseif e == "SE_RESET_SCREEN_PALETTE" then
|
||||
fx.bgp = nil
|
||||
elseif e == "SE_DARK_SCREEN_FLASH" then
|
||||
@@ -3078,8 +3238,9 @@ function BattleState:applyAnimEffect(ev)
|
||||
idx = 1, left = 2 }
|
||||
elseif e == "SE_FLASH_SCREEN_LONG" then
|
||||
local steps = {}
|
||||
local maps = onSgb() and FLASH_LONG_SGB or FLASH_LONG_MONO
|
||||
for cycle = 1, 3 do
|
||||
for _, m in ipairs(FLASH_LONG_MAPS) do
|
||||
for _, m in ipairs(maps) do
|
||||
steps[#steps + 1] = { map = m, frames = (cycle == 1) and 2 or 1 }
|
||||
end
|
||||
end
|
||||
@@ -3322,6 +3483,26 @@ function BattleState:queueSendOutAnim(append)
|
||||
if append then self:act(fn) else self:actNext(fn) end
|
||||
end
|
||||
|
||||
-- AnimateRetreatingPlayerMon (core.asm:1769-1796); the Yellow starter Pikachu
|
||||
-- slides off instead (pokeyellow core.asm:1862-1866, animations.asm:1259)
|
||||
function BattleState:queueRetreatAnim()
|
||||
if self:starterPikachuSendOut() then
|
||||
self:actNext(function() self:slidePic("playerMon", 0, -64, 8, 3) end)
|
||||
self:waitNext(24)
|
||||
self:actNext(function()
|
||||
-- .clearScreenArea keeps the 7x7 area blank until the swap
|
||||
-- (pokeyellow core.asm:1867-1871) (#1545)
|
||||
self.sendingOut = true
|
||||
self:slidePic("playerMon")
|
||||
end)
|
||||
else
|
||||
self:actNext(function()
|
||||
self.shrinkOut = { battler = self.player, frame = 0 }
|
||||
end)
|
||||
self:waitNext(7)
|
||||
end
|
||||
end
|
||||
|
||||
-- Should the low-health alarm sound this frame? pokered keys it off
|
||||
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
|
||||
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
|
||||
@@ -3538,6 +3719,12 @@ function BattleState:updateFx()
|
||||
self.growIn.frame = self.growIn.frame + 1
|
||||
if self.growIn.frame >= 12 then self.growIn = nil end
|
||||
end
|
||||
-- the retreat shrink (AnimateRetreatingPlayerMon): 4+3 frames, then the
|
||||
-- 7x7 area holds cleared (scale 0) until the swap replaces the battler
|
||||
if self.shrinkOut then
|
||||
self.shrinkOut.frame = self.shrinkOut.frame + 1
|
||||
if self.shrinkOut.battler ~= self.player then self.shrinkOut = nil end
|
||||
end
|
||||
-- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren
|
||||
-- loops while the player's bar is red; see lowHealthAlarmActive
|
||||
local Sound = require("src.core.Sound")
|
||||
@@ -3802,6 +3989,8 @@ function BattleState:statusInterrupt(user, target, selectedId)
|
||||
{ rng = self.rng, forceCrit = false, typeless = true,
|
||||
screens = target })
|
||||
self:sayNext(self:romText("_HurtItselfText", "It hurt itself in\nits confusion!"))
|
||||
-- HandleSelfConfusionDamage (core.asm:3706-3714, enemy side :5807-5811)
|
||||
self:animNext("POUND", not user.isPlayer)
|
||||
self:clearVolatiles(user, true)
|
||||
self:applyDamage(user, dmg)
|
||||
if user.mon.hp <= 0 then self:onFaint(user) end
|
||||
@@ -3894,18 +4083,28 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
end
|
||||
|
||||
self.moveAnimRow = nil
|
||||
if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then
|
||||
self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
|
||||
-- the move's animation plays right after the announcement; the
|
||||
-- damage path attaches the target's hit blink to this row so the
|
||||
-- blink follows the animation (pokered's order). Mimic is the
|
||||
-- exception (announceAnim = false): PlayCurrentMoveAnimation runs
|
||||
-- only after a successful copy, never on a miss -- applyMimic queues it
|
||||
if not (record and record.announceAnim == false) then
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
self.moveAnimRow = { anim = move.id, attackerIsPlayer = user.isPlayer }
|
||||
table.insert(self.queue, self.nextInsert, self.moveAnimRow)
|
||||
local thrashing = user.thrashTurns and moveInst == user.thrashMove
|
||||
and user.thrashAnnounced or false
|
||||
if thrashing then
|
||||
-- .ThrashingAboutCheck (core.asm:3531-3552)
|
||||
self:sayNextAuto(self:romText("_ThrashingAboutText", "%s's\nthrashing about!",
|
||||
displayName(user)))
|
||||
user.thrashTurns = user.thrashTurns - 1
|
||||
if user.thrashTurns <= 0 then
|
||||
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
|
||||
if not user.confusedTurns then user.confusedTurns = self.rng(2, 5) end
|
||||
end
|
||||
else
|
||||
self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
|
||||
end
|
||||
-- PlayCurrentMoveAnimation follows the announcement; Mimic (announceAnim
|
||||
-- = false) queues it from applyMimic after a successful copy
|
||||
if not (record and record.announceAnim == false) then
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
-- ld a, THRASH / ld [wPlayerMoveNum] (core.asm:3534-3535, :5909-5910) #1577
|
||||
self.moveAnimRow = { anim = thrashing and "THRASH" or move.id,
|
||||
attackerIsPlayer = user.isPlayer }
|
||||
table.insert(self.queue, self.nextInsert, self.moveAnimRow)
|
||||
end
|
||||
Runtime.emit("battle.move_used", {
|
||||
battle = self, user = user, target = target, move = move,
|
||||
@@ -3913,6 +4112,9 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
})
|
||||
|
||||
local ctx = EffectRegistry.makeCtx(self, user, target, move, moveInst, isCalled)
|
||||
-- .ThrashingAboutCheck jumps past JumpMoveEffect into PlayerCalcMoveDamage
|
||||
-- (core.asm:3540), so SpecialEffectsCont never re-runs on a locked turn
|
||||
ctx.thrashing = thrashing
|
||||
|
||||
-- Metronome / Mirror Move re-entry; a nil pick means the record
|
||||
-- already said its failure text
|
||||
@@ -3934,7 +4136,17 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
-- record (chargeText) and the invulnerability from semiInvulnerable,
|
||||
-- falling back to the id tables (Fly AND Dig go semi-invulnerable:
|
||||
-- ChargeEffect sets INVULNERABLE for both)
|
||||
if record and record.charge and not releasing then
|
||||
local chargeRequired = record and record.charge ~= nil and not releasing
|
||||
if chargeRequired and Runtime.wantsHook("battle.charge_required") then
|
||||
local required = Runtime.call("battle.charge_required", function(c)
|
||||
return c.charge
|
||||
end, {
|
||||
battle = self, user = user, target = target, move = move,
|
||||
charge = true, isCalled = isCalled or false,
|
||||
})
|
||||
chargeRequired = required ~= false
|
||||
end
|
||||
if chargeRequired then
|
||||
self:cancelMoveAnim()
|
||||
user.charging = moveInst
|
||||
user.chargeReady = true
|
||||
@@ -3970,6 +4182,12 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
|
||||
-- pure status moves
|
||||
if move.power == 0 and record and record.kind == "primary" and record.run then
|
||||
if ENEMY_STAT_DOWN_MISS[move.effect] and not user.isPlayer
|
||||
and self.kind ~= "link" and self.rng(0, 255) < 64 then
|
||||
self:cancelMoveAnim()
|
||||
self:sayNext(self:romText("_AttackMissedText", "%s's\nattack missed!", displayName(user)))
|
||||
return
|
||||
end
|
||||
-- accuracy-checked status effects run MoveHitTest, which has no
|
||||
-- 100%-accuracy early-out (even Thunder Wave misses on the 255
|
||||
-- roll) and misses outright against a mid-Fly/Dig target; the
|
||||
@@ -4013,12 +4231,15 @@ function BattleState:continueTrapping(user, target)
|
||||
-- .MultiturnMoveCheck (core.asm:3554-3566) prints AttackContinuesText
|
||||
-- then jumps to GetPlayerAnimationType, so the trapping move's full
|
||||
-- animation replays each locked turn (same damage, animation shown).
|
||||
-- Mirror performMove's anim row (BattleState.lua ~1307), gated on the
|
||||
-- OPTIONS animation toggle.
|
||||
if user.trapMove and self:animationsOn() then
|
||||
-- Mirror performMove's anim row (BattleState.lua ~1307), with the
|
||||
-- applying-attack shake GetPlayerAnimationType picks (core.asm:3159 /
|
||||
-- :5555 -- a trapping move's effect is nonzero, so type 5 / 2) (#1653).
|
||||
if user.trapMove then
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert,
|
||||
{ anim = user.trapMove, attackerIsPlayer = user.isPlayer })
|
||||
{ anim = user.trapMove, attackerIsPlayer = user.isPlayer,
|
||||
hit = { animType = user.isPlayer and 5 or 2,
|
||||
sfx = user.trapHitSfx } })
|
||||
end
|
||||
-- the counter can sit at 0 until the END of the turn: the trapping
|
||||
-- bit is only cleared by CheckNumAttacksLeft (core.asm:439/467)
|
||||
@@ -4185,12 +4406,15 @@ function BattleState:awardExp()
|
||||
end
|
||||
local function applyShare(mon, split, announce)
|
||||
local playerId = self.game.save.player and self.game.save.player.id
|
||||
local traded = mon.traded == true
|
||||
or (mon.otId ~= nil and playerId ~= nil and mon.otId ~= playerId)
|
||||
-- GainExperience (engine/battle/experience.asm:69-88) compares the
|
||||
-- stored MON_OTID against wPlayerID every award; no persistent flag
|
||||
-- mon.traded covers otId-less mons (repairTradedOtIds, old link peers) #1488
|
||||
local traded = playerId ~= nil and ((mon.otId ~= nil and mon.otId ~= playerId)
|
||||
or (mon.otId == nil and mon.traded == true))
|
||||
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
||||
self.enemy.mon.level, self.kind == "trainer",
|
||||
split, traded)
|
||||
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
|
||||
-- Track level-ups for EvolveAfterBattle (BattleState:finish ->
|
||||
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
|
||||
-- without this gate it re-triggers after every later fight (#213).
|
||||
if #levels > 0 then
|
||||
@@ -4242,7 +4466,11 @@ function BattleState:awardExp()
|
||||
-- new current HP (house convention: potions drain the bar too, see
|
||||
-- itemUsed) so the bar grows instead. Only the active player battler
|
||||
-- shares its table with the HUD; other party mons (EXP.ALL) have no bar.
|
||||
if mon == self.player.mon then self:drainNext() end
|
||||
if mon == self.player.mon then
|
||||
-- engine/battle/experience.asm:236
|
||||
self.player.badgeExtraBoosts = nil
|
||||
self:drainNext()
|
||||
end
|
||||
for _, moveId in ipairs(Experience.movesLearnedAt(
|
||||
self.data.pokemon[mon.species], lv)) do
|
||||
self:learnMove(mon, moveId)
|
||||
@@ -4309,8 +4537,7 @@ function BattleState:enemyMonFainted()
|
||||
-- remaining HP); SET / single-mon / fainted active skip the prompt.
|
||||
local nextMon = self.enemyParty[self.enemyIndex]
|
||||
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
|
||||
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
|
||||
:lower()
|
||||
local style = self:battleStyle()
|
||||
local partyCount = #self:playerPartyView()
|
||||
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
|
||||
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
|
||||
@@ -4338,21 +4565,36 @@ function BattleState:enemyMonFainted()
|
||||
-- the battle queue's own \f handling (not TextBox.lua's) does not
|
||||
-- page a sayChoice string the same way -- left as two calls.
|
||||
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
|
||||
-- EnemySendOutFirstMon .next9/.next8 (core.asm:1390-1409) and
|
||||
-- HasMonFainted's NoWillText (core.asm:1473-1488)
|
||||
self:sayChoice(
|
||||
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
|
||||
function(yes)
|
||||
if not yes then return end
|
||||
local game = self.game
|
||||
Screens.push(game, "PartyMenu", {
|
||||
local shiftOpts, reopenShift
|
||||
reopenShift = function(text)
|
||||
table.insert(self.queue, 1, { ui = function()
|
||||
return self:buildScreen("PartyMenu", shiftOpts)
|
||||
end })
|
||||
table.insert(self.queue, 1, { text = text })
|
||||
end
|
||||
shiftOpts = {
|
||||
battle = self,
|
||||
party = self:playerPartyView(),
|
||||
forceSwitch = true,
|
||||
onSwitch = function(mon)
|
||||
if mon ~= self.player.mon and mon.hp > 0 then
|
||||
if mon == self.player.mon then
|
||||
reopenShift(self:romText("_AlreadyOutText",
|
||||
"%s is\nalready out!", self.player.name))
|
||||
elseif mon.hp <= 0 then
|
||||
reopenShift(self:romText("_NoWillText", "There's no will\nto fight!"))
|
||||
else
|
||||
shiftSwitchMon = mon
|
||||
end
|
||||
end,
|
||||
})
|
||||
}
|
||||
Screens.push(game, "PartyMenu", shiftOpts)
|
||||
end, { box = Theme.trainerSwitchBox })
|
||||
end
|
||||
self:act(function()
|
||||
@@ -4395,10 +4637,11 @@ function BattleState:enemyMonFainted()
|
||||
local mon = shiftSwitchMon
|
||||
if not mon then return end
|
||||
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame
|
||||
-- hold, then the recall and the send-out
|
||||
-- hold, AnimateRetreatingPlayerMon, then the recall and the send-out
|
||||
self.nextInsert = 0
|
||||
self:sayNextAuto(self:withdrawText(self.player.name),
|
||||
Timing.SWITCH_PLAYER_MON)
|
||||
self:queueRetreatAnim()
|
||||
self:actNext(function()
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, mon, true, self.game.save)
|
||||
@@ -4470,9 +4713,22 @@ function BattleState:enemyMonFainted()
|
||||
-- TrainerNamePointers aims those entries at wTrainerName). The tag
|
||||
-- prints once, so a `para` page carries no second copy (#566).
|
||||
local tag = self.trainer and self.trainer.name
|
||||
-- the badge jingle (sound_get_item_1 and friends) rides the armed
|
||||
-- line's first page, as the script's text command would (#1606)
|
||||
local sfx = self.endBattleSound
|
||||
local data = self.data
|
||||
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
|
||||
if page ~= "" then
|
||||
self:sayNext(tag and (tag .. ": " .. page) or page)
|
||||
local line = tag and (tag .. ": " .. page) or page
|
||||
if sfx then
|
||||
local id = sfx
|
||||
self:sayNextWaitSfx(line, function()
|
||||
return require("src.core.Sound").play(data, id)
|
||||
end)
|
||||
sfx = nil
|
||||
else
|
||||
self:sayNext(line)
|
||||
end
|
||||
tag = nil
|
||||
end
|
||||
end
|
||||
@@ -4538,6 +4794,14 @@ function BattleState:playerMonFainted()
|
||||
-- Exception: the Oak's Lab starter rival (HandlePlayerBlackOut).
|
||||
if not nextMon and self.result ~= "lose" then
|
||||
if self.oppClass == "OPP_RIVAL1" then
|
||||
-- HandlePlayerBlackOut (core.asm:1139-1146): ClearScreenArea, the pic
|
||||
-- scroll-in and DelayFrames 40 all run before Rival1WinText (#1721)
|
||||
self:actNext(function()
|
||||
self.showEnemyTrainer = self.trainerPic ~= nil
|
||||
if self.showEnemyTrainer then self:slidePic("foe", 64, 16, 2) end
|
||||
end)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { wait = 64 })
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local raw = (self.data.text and self.data.text._Rival1WinText)
|
||||
or Strings("{RIVAL}: Yeah! Am\nI great or what?")
|
||||
@@ -4841,6 +5105,57 @@ function BattleState:ballMissMessage(shakes)
|
||||
return t._ItemUseBallText04 or self:romText("_ItemUseBallText04", "Shoot! It was so\nclose too!")
|
||||
end
|
||||
|
||||
-- ------- battle rules a mode may own
|
||||
--
|
||||
-- Two decisions the OPTION screen and the cart make for the player that a
|
||||
-- game mode may want to make instead: whether a faint offers a free switch,
|
||||
-- and whether a catch asks for a nickname. Each is a hook around the vanilla
|
||||
-- answer, so a mode can force it without touching the player's saved
|
||||
-- preference and without the player being able to change it mid-match.
|
||||
--
|
||||
-- The vanilla links are file-locals so an empty chain allocates no closure.
|
||||
|
||||
local function styleFromOptions(battle)
|
||||
return tostring(((battle.game.save or {}).options or {}).battleStyle or "shift")
|
||||
:lower()
|
||||
end
|
||||
|
||||
local function alwaysAsk() return true end
|
||||
|
||||
-- "shift" or "set" for this battle. battle.style wraps the OPTION row: a
|
||||
-- mod returns "set" or "shift"; anything else reads as the vanilla answer.
|
||||
function BattleState:battleStyle()
|
||||
if not Runtime.wantsHook("battle.style") then return styleFromOptions(self) end
|
||||
local style = Runtime.call("battle.style", styleFromOptions, self)
|
||||
if style == "set" then return "set" end
|
||||
if style == "shift" then return "shift" end
|
||||
return styleFromOptions(self)
|
||||
end
|
||||
|
||||
-- AskName for a catch (AddPartyMon / SendNewMonToBox). Vanilla queues the
|
||||
-- yes/no prompt. catch.nickname may answer for the player: false keeps the
|
||||
-- species name and shows nothing; a string is the nickname, shown nothing;
|
||||
-- anything else asks as usual. Returns whether a prompt was queued.
|
||||
--
|
||||
-- The same verdict a script gift already takes from pokemon.before_give's
|
||||
-- gift.nickname, for the other way a Pokemon joins the party.
|
||||
function BattleState:offerNickname(mon, displayName)
|
||||
if Runtime.wantsHook("catch.nickname") then
|
||||
local verdict = Runtime.call("catch.nickname", alwaysAsk, mon,
|
||||
{ battle = self, name = displayName, game = self.game })
|
||||
if verdict == false then return false end
|
||||
if type(verdict) == "string" then
|
||||
-- the naming grid's own limit, so a mod cannot hand the party a name
|
||||
-- the summary screen has no room to draw
|
||||
verdict = verdict:sub(1, 10)
|
||||
if #verdict > 0 then mon.nickname = verdict end
|
||||
return false
|
||||
end
|
||||
end
|
||||
self:uiNext(function() return self:askNicknameUI(mon, displayName) end)
|
||||
return true
|
||||
end
|
||||
|
||||
-- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank,
|
||||
-- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party
|
||||
-- AddPartyMon and SendNewMonToBox (#172).
|
||||
@@ -4874,6 +5189,22 @@ end
|
||||
-- "New POKéDEX data will be added" + the dex entry page, then
|
||||
-- AddPartyMon or SendNewMonToBox (both call AskName), then the PC
|
||||
-- transfer text when the party was full.
|
||||
-- Where a caught mon goes when the party has no room for it (RFC 0018):
|
||||
-- "box", as AddPartyMon falling through to SendNewMonToBox always did, or
|
||||
-- "mod" when the catch.party_full hook claims it -- a game mode that has
|
||||
-- done away with storage hands the decision to the player instead of
|
||||
-- laundering the catch through a PC it has locked. A method rather than
|
||||
-- an inline read, so a mod or a compatibility shim can tell a seam engine
|
||||
-- from a stock one by name.
|
||||
function BattleState:partyFullDestination(mon)
|
||||
if not Runtime.wantsHook("catch.party_full") then return "box" end
|
||||
local claimed = Runtime.call("catch.party_full", function() return false end,
|
||||
{ battle = self, mon = mon, name = self.enemy and self.enemy.name,
|
||||
game = self.game })
|
||||
if claimed then return "mod" end
|
||||
return "box"
|
||||
end
|
||||
|
||||
function BattleState:storeCaughtMon()
|
||||
-- ItemUseBall reloads the caught mon via LoadEnemyMonData
|
||||
-- (item_effects.asm:472-501), regenerating its move list from the
|
||||
@@ -4905,28 +5236,26 @@ function BattleState:storeCaughtMon()
|
||||
end)
|
||||
end
|
||||
local function askCaughtNickname()
|
||||
local caught = self.enemy.mon
|
||||
local enemyName = self.enemy.name
|
||||
self:uiNext(function()
|
||||
return self:askNicknameUI(caught, enemyName)
|
||||
end)
|
||||
self:offerNickname(self.enemy.mon, self.enemy.name)
|
||||
end
|
||||
if Party.add(game.save.party, self.enemy.mon) then
|
||||
askCaughtNickname()
|
||||
else
|
||||
destination = "box"
|
||||
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
|
||||
if boxNum then
|
||||
askCaughtNickname()
|
||||
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
||||
local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL
|
||||
self:sayNext(self:romText(
|
||||
metBill and "_ItemUseBallText07" or "_ItemUseBallText08",
|
||||
metBill and "%s was\ntransferred to\nBILL's PC!"
|
||||
or "%s was\ntransferred to\nsomeone's PC!",
|
||||
self.enemy.name))
|
||||
else
|
||||
self:sayNext(Strings("But every BOX\nis full!"))
|
||||
destination = self:partyFullDestination(self.enemy.mon)
|
||||
if destination == "box" then
|
||||
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
|
||||
if boxNum then
|
||||
askCaughtNickname()
|
||||
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
||||
local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL
|
||||
self:sayNext(self:romText(
|
||||
metBill and "_ItemUseBallText07" or "_ItemUseBallText08",
|
||||
metBill and "%s was\ntransferred to\nBILL's PC!"
|
||||
or "%s was\ntransferred to\nsomeone's PC!",
|
||||
self.enemy.name))
|
||||
else
|
||||
self:sayNext(Strings("But every BOX\nis full!"))
|
||||
end
|
||||
end
|
||||
end
|
||||
Runtime.emit("pokemon.caught", {
|
||||
@@ -5088,10 +5417,14 @@ function BattleState:openParty()
|
||||
battle = self,
|
||||
party = self:playerPartyView(),
|
||||
onSwitch = function(mon)
|
||||
-- PartyMenuOrRockOrRun's SWITCH .partyMonDeselected (core.asm:2396-2408)
|
||||
if mon == self.player.mon then
|
||||
self:say(Strings("%s is\nalready out!", self.player.name))
|
||||
self:say(self:romText("_AlreadyOutText",
|
||||
"%s is\nalready out!", self.player.name))
|
||||
self:act(function() self:openParty() end)
|
||||
elseif mon.hp <= 0 then
|
||||
self:say(self:romText("_NoWillText", "There's no will\nto fight!"))
|
||||
self:act(function() self:openParty() end)
|
||||
else
|
||||
self:resolveSwitch(mon)
|
||||
end
|
||||
@@ -5123,6 +5456,14 @@ function BattleState:finish()
|
||||
self.phase = "messages"
|
||||
return
|
||||
end
|
||||
-- EndOfBattle runs EvolutionAfterBattle on the battle screen, before the
|
||||
-- GBPalWhiteOut back to the map (end_of_battle.asm:42-45) (#1656, #213)
|
||||
if not self.evolutionsChecked then
|
||||
self.evolutionsChecked = true
|
||||
require("src.pokemon.Evolution").checkParty(self.game,
|
||||
function() self:finish() end, self.leveledUp)
|
||||
return
|
||||
end
|
||||
-- Invariant: a battle can never hand the overworld a party with nothing
|
||||
-- healthy in it -- except the Oak's Lab starter rival, where pret skips
|
||||
-- the blackout and OaksLabRivalEndBattleScript HealParty's immediately.
|
||||
@@ -5163,6 +5504,13 @@ function BattleState:finish()
|
||||
local result = self.result or "run"
|
||||
local onFinish = self.onFinish
|
||||
if result == "lose" then
|
||||
-- .battleOccurred skips the faint check in OAKS_LAB and re-enters the map
|
||||
-- through MapEntryAfterBattle (home/overworld.asm:343-352) (#1721)
|
||||
if BattleState.isOaksLabStarterRival(self) then
|
||||
self.game.stack:push(require("src.render.Transition").battleReturn(
|
||||
self.game, function() if onFinish then onFinish(result) end end))
|
||||
return
|
||||
end
|
||||
-- the blackout path warps to the heal point with its own transition
|
||||
if onFinish then onFinish(result) end
|
||||
return
|
||||
@@ -5235,6 +5583,16 @@ function BattleState:growInScale(battler)
|
||||
return f < 3 and 0 or f < 7 and 3 / 7 or 5 / 7
|
||||
end
|
||||
|
||||
-- AnimateRetreatingPlayerMon's CopyDownscaledMonTiles stages
|
||||
-- (core.asm:1769-1796)
|
||||
function BattleState:shrinkOutScale(battler)
|
||||
local shrink = self.shrinkOut
|
||||
if not shrink or shrink.battler ~= battler then return nil end
|
||||
-- scale 0 past Delay3: the area stays cleared until the swap
|
||||
-- (core.asm:1790-1796) (#1563)
|
||||
return shrink.frame < 4 and 5 / 7 or shrink.frame < 7 and 3 / 7 or 0
|
||||
end
|
||||
|
||||
-- battler hidden this frame? (damage blink)
|
||||
--
|
||||
-- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits
|
||||
@@ -5637,7 +5995,7 @@ function BattleState:drawZonePass(src, sx, sy)
|
||||
-- only the other two showed it. Keep this mode set in sync with picImage /
|
||||
-- PaletteFX.ensureZones / WideBattle.monoMode.
|
||||
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
|
||||
or PaletteFX.mode == "classic"
|
||||
or PaletteFX.mode == "classic" or PaletteFX.forcesRawGrays()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setShader(shader)
|
||||
local shaking = sx ~= 0 or sy ~= 0
|
||||
@@ -5679,7 +6037,9 @@ function BattleState:animSpriteColors(s, px, py)
|
||||
local P
|
||||
-- engine/battle/animations.asm:551 (.notSGB)
|
||||
if PaletteFX.usesSpriteObp() then
|
||||
P = PaletteFX.ogObj()
|
||||
-- engine/battle/init_battle_variables.asm:18
|
||||
P = require("src.core.GameVersion").isBlue() and PaletteFX.GBC_OBJ_BLUE
|
||||
or PaletteFX.GBC_OBJ
|
||||
if key == "f0" then key = "e4" elseif key == "f0x" then key = "e4x" end
|
||||
else
|
||||
P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4))
|
||||
@@ -5871,15 +6231,18 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
|
||||
local s = BattleState.resolveBattleScale(self.data, "back",
|
||||
imagePathOf(self.player.sprite),
|
||||
self.player.mon and self.player.mon.species)
|
||||
local gs = self:growInScale(self.player)
|
||||
local gs = self:growInScale(self.player) or self:shrinkOutScale(self.player)
|
||||
if gs then
|
||||
-- the player-side AnimateSendingOutMon grow (after the poof,
|
||||
-- core.asm:1757-1762): feet pinned at y=96, horizontal centre
|
||||
-- pinned, mod scale composed with the grow stage
|
||||
-- the player-side AnimateSendingOutMon grow (core.asm:1757-1762) and
|
||||
-- the AnimateRetreatingPlayerMon shrink (core.asm:1769-1796)
|
||||
local eff = s * gs
|
||||
if eff > 0 then
|
||||
-- the retreat stages sit one tile right of the grow-in's
|
||||
-- (hlcoord 3,7 / 4,9 vs 2,7 / 3,9, core.asm:1770-1788) (#1563)
|
||||
local shrinkX = self.shrinkOut
|
||||
and self.shrinkOut.battler == self.player and 8 or 0
|
||||
love.graphics.draw(img,
|
||||
8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
|
||||
8 + shrinkX - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
|
||||
96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff)
|
||||
end
|
||||
else
|
||||
@@ -6014,8 +6377,10 @@ function BattleState:drawHUDs(slide)
|
||||
self:drawBallRow(self:playerPartyView(), 88, 80, 8)
|
||||
end
|
||||
local hidePlayer = self.safari or self.demo
|
||||
-- RemoveFaintedPlayerMon clears the player HUD (core.asm:1024-1026) and
|
||||
-- nothing redraws it until the next SendOutMon (#1721)
|
||||
if showStatus and self.player and not hidePlayer and not self.showPlayerBack
|
||||
and slide == 0 then
|
||||
and slide == 0 and not self.player.fainted then
|
||||
-- player HUD (DrawPlayerHUDAndHPBar): name (10,7), <LV>+level
|
||||
-- (14,8), HP bar (10,9), HP numbers row 10, underline row 11 with
|
||||
-- the tick at (18,10) and the triangle at (9,11)
|
||||
@@ -6040,8 +6405,35 @@ end
|
||||
|
||||
function BattleState:drawTextArea()
|
||||
if not self:bottomUIVisible() then return end
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
-- Gold only: routes the command box and its labels through GbcPalette,
|
||||
-- the same fix TextBox.lua got for dialogue. moveSelect/mimicSelect below
|
||||
-- aren't migrated yet since they need their own interior-patch handling
|
||||
-- (see #240).
|
||||
local gold = self.game and self.game.save
|
||||
and (self.game.save.generation == 2 or self.game.save.version == "gold")
|
||||
local Chrome = gold and Chrome2 or nil
|
||||
local function box(tx, ty, tw, th)
|
||||
if Chrome then
|
||||
Chrome.paletteBox(tx, ty, tw, th)
|
||||
else
|
||||
Font.drawBox(tx, ty, tw, th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
end
|
||||
local drawGlyph, finishGlyph = Font.drawCode, nil
|
||||
if Chrome then
|
||||
local _, dg, fg = Chrome.paletteGlyphs(Chrome.DEFAULT_BOX_PALETTE)
|
||||
drawGlyph, finishGlyph = dg, fg
|
||||
end
|
||||
local function text(str, x, y)
|
||||
local pen = x
|
||||
for _, code in ipairs(Font.encode(str)) do
|
||||
drawGlyph(code, pen, y)
|
||||
pen = pen + Font.advanceOf(code)
|
||||
end
|
||||
end
|
||||
|
||||
box(0, 12, 20, 6)
|
||||
if self.phase == "messages"
|
||||
and (self.current or self.animPlaying or self.msgHold) then
|
||||
-- during the move animation self.current is nil but shown still holds
|
||||
@@ -6060,48 +6452,47 @@ function BattleState:drawTextArea()
|
||||
for li, line in ipairs(self.shown or {}) do
|
||||
local y = (ys[li] or 128) + off
|
||||
for i = 1, #line do
|
||||
Font.drawCode(line[i], 8 + (i - 1) * 8, y)
|
||||
drawGlyph(line[i], 8 + (i - 1) * 8, y)
|
||||
end
|
||||
end
|
||||
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait
|
||||
-- (_ContText) or a typed-out page (PromptText) holds the box; both write
|
||||
-- it at (18,16), bottom-right, like TextBox / home/text.asm (#317)
|
||||
if (self.msgWaiting or self.msgPrompt) and self.frame % 60 < 30 then
|
||||
Font.drawCode(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4)
|
||||
drawGlyph(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4)
|
||||
end
|
||||
elseif self.phase == "menu" and self.demo then
|
||||
-- the old-man script (DisplayBattleMenu, core.asm:2038-2049): the
|
||||
-- standard menu, with the '▶' hand drawn by the scripted keystrokes
|
||||
-- -- 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", "battle"), 80, 112)
|
||||
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
|
||||
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)
|
||||
box(8, 12, 12, 6)
|
||||
text(Strings("FIGHT", "battle"), 80, 112)
|
||||
drawGlyph(0xE1, 128, 112); drawGlyph(0xE2, 136, 112)
|
||||
text(Strings("ITEM", "battle"), 80, 128); text(Strings("RUN", "battle"), 128, 128)
|
||||
drawGlyph(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128)
|
||||
elseif self.phase == "menu" then
|
||||
local col = (self.menuIndex - 1) % 2
|
||||
local row = math.floor((self.menuIndex - 1) / 2)
|
||||
if self.safari then
|
||||
-- SAFARI_BATTLE_MENU_TEMPLATE: full-width box, "BALLx BAIT /
|
||||
-- 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", "battle"), 112, 128)
|
||||
box(0, 12, 20, 6)
|
||||
text(Strings("BALLx"), 16, 112); text(Strings("BAIT"), 112, 112)
|
||||
text(Strings("THROW ROCK"), 16, 128); text(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
|
||||
-- 2..6 (engine/battle/core.asm:2074-2079, 2107-2112) (#540)
|
||||
Font.draw(("%2d"):format(self.safari.balls), 56, 112)
|
||||
Font.drawCode(0xED, (col == 0 and 8 or 104), 112 + row * 16)
|
||||
text(("%2d"):format(self.safari.balls), 56, 112)
|
||||
drawGlyph(0xED, (col == 0 and 8 or 104), 112 + row * 16)
|
||||
else
|
||||
-- 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", "battle"), 80, 112)
|
||||
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
|
||||
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)
|
||||
box(8, 12, 12, 6)
|
||||
text(Strings("FIGHT", "battle"), 80, 112)
|
||||
drawGlyph(0xE1, 128, 112); drawGlyph(0xE2, 136, 112)
|
||||
text(Strings("ITEM", "battle"), 80, 128); text(Strings("RUN", "battle"), 128, 128)
|
||||
drawGlyph(0xED, (col == 0 and 72 or 120), 112 + row * 16)
|
||||
end
|
||||
elseif self.phase == "moveSelect" then
|
||||
-- pokered MoveSelectionMenu: move list in a box at (4,12) 16x6,
|
||||
@@ -6176,6 +6567,7 @@ function BattleState:drawTextArea()
|
||||
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
|
||||
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
|
||||
end
|
||||
if finishGlyph then finishGlyph() end
|
||||
end
|
||||
|
||||
function BattleState:draw()
|
||||
|
||||
+30
-8
@@ -41,6 +41,34 @@ local function badgeBoost(battler, stat)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- engine/battle/core.asm:6454
|
||||
function Damage.applyBadgeBoost(battler, stat, value)
|
||||
if battler.hazeStatReset then return value end
|
||||
local row = badgeBoost(battler, stat)
|
||||
if not row then return value end
|
||||
local extra = battler.badgeExtraBoosts and battler.badgeExtraBoosts[stat] or 0
|
||||
for _ = 1, 1 + extra do
|
||||
value = math.min(999, math.floor(value * (row.num or 9) / (row.den or 8)))
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
-- engine/battle/effects.asm:498,689
|
||||
function Damage.reapplyBadgeBoosts(battler, changedStat)
|
||||
if not battler or not battler.badges then return end
|
||||
local extra = battler.badgeExtraBoosts
|
||||
if not extra then extra = {} battler.badgeExtraBoosts = extra end
|
||||
for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do
|
||||
if battler.badges[row.badge] then
|
||||
if row.stat == changedStat then
|
||||
extra[row.stat] = 0
|
||||
else
|
||||
extra[row.stat] = (extra[row.stat] or 0) + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- the merged status record for a battler's persistent condition, or nil
|
||||
local function statusRecord(battler)
|
||||
return Status.recordFor(battler.statuses, battler.mon.status)
|
||||
@@ -178,14 +206,8 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
|
||||
-- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts:
|
||||
-- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder),
|
||||
-- Volcano -> special
|
||||
local atkBoost = badgeBoost(attacker, atkStat)
|
||||
if atkBoost then
|
||||
atk = math.floor(atk * (atkBoost.num or 9) / (atkBoost.den or 8))
|
||||
end
|
||||
local defBoost = badgeBoost(defender, defStat)
|
||||
if defBoost then
|
||||
dfn = math.floor(dfn * (defBoost.num or 9) / (defBoost.den or 8))
|
||||
end
|
||||
atk = Damage.applyBadgeBoost(attacker, atkStat, atk)
|
||||
dfn = Damage.applyBadgeBoost(defender, defStat, dfn)
|
||||
-- burn halves physical attack (applied as part of the stat in Gen 1;
|
||||
-- the status record's statPenalty names the stat it cuts).
|
||||
-- hazeStatReset suppresses it: Haze (haze.asm ResetStats) copied the
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user