mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-28 18:06:09 +02:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5fc4740c8 | |||
| 4b2e772ab2 | |||
| 8e9206b612 | |||
| dc99b1f8da | |||
| 377f1f8a71 | |||
| 2441996d45 | |||
| 5e7dc556f5 | |||
| 71a5602b58 | |||
| ebd315b01e | |||
| e9b815fcac | |||
| fbaf0441ee | |||
| d2a86cd258 | |||
| 8b56ec2d77 | |||
| f11724f08a | |||
| bd3e02986d | |||
| eed246f645 | |||
| c9d06779d8 | |||
| 48d8a4e922 | |||
| 9a05de4474 | |||
| 5c833a3d73 | |||
| 5051cb902d | |||
| 2720bfb5d8 | |||
| 54ba0f04ff | |||
| a231657093 | |||
| 6f587520d4 | |||
| 3a0a72102f | |||
| fe31293566 | |||
| e41f2c528a | |||
| c8eeeaa1f6 | |||
| 273350d11e | |||
| 7797962f66 | |||
| 017eafc51b | |||
| c1b7980b4a | |||
| 3e4772abf4 | |||
| 49a5408c2d | |||
| 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 |
@@ -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
|
||||
|
||||
@@ -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,6 +520,8 @@ 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: Materialize Android release signing key
|
||||
env:
|
||||
@@ -429,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
|
||||
@@ -442,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
|
||||
@@ -498,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
|
||||
@@ -509,16 +669,24 @@ 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"
|
||||
|
||||
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"
|
||||
@@ -656,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"
|
||||
|
||||
@@ -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,7 +53,7 @@ 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,
|
||||
@@ -228,18 +228,27 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
|
||||
| `--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
|
||||
|
||||
+186
-50
@@ -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"
|
||||
|
||||
|
||||
@@ -156,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
|
||||
|
||||
@@ -62,7 +62,16 @@ 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
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
+19
-12
@@ -1251,19 +1251,26 @@ local function pokemonTower2FRivalScript(playerX)
|
||||
and TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT
|
||||
or TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN
|
||||
return {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
|
||||
{ "jump_if_true", 12 }, -- 3
|
||||
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
|
||||
{ "jump_if_false", "end" }, -- 6 loss: stay
|
||||
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
||||
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" },
|
||||
{ "jump_if_true", "beaten" },
|
||||
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" },
|
||||
-- .DefeatedText rides BIT_PRINT_END_BATTLE_TEXT, so it prints on the
|
||||
-- battle screen -- scripts/PokemonTower2F.asm:145-150
|
||||
{ "save_end_battle_text", "_PokemonTower2FRivalDefeatedText" },
|
||||
{ "rival_battle", "OPP_RIVAL2", 4 },
|
||||
{ "jump_if_false", "end" }, -- loss: stay
|
||||
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" },
|
||||
-- the win re-runs DisplayTextID on the beaten branch
|
||||
-- scripts/PokemonTower2F.asm:72-75, :137-140
|
||||
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" },
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||
{ "walk_npc", 1, exitDirs }, -- 9
|
||||
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
|
||||
{ "jump", "end" }, -- 11
|
||||
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 12
|
||||
{ "walk_npc", 1, exitDirs },
|
||||
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" },
|
||||
{ "play_default_music" }, -- scripts/PokemonTower2F.asm:124
|
||||
{ "jump", "end" },
|
||||
{ "label", "beaten" },
|
||||
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" },
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
+20
-21
@@ -167,13 +167,11 @@ M.POKEMON_TOWER_6F = {
|
||||
-- PlayCry RESTLESS_SOUL (EQU MAROWAK, constants/pokemon_constants
|
||||
-- .asm:209) + WaitForSoundToFinish + DelayFrames 30 before the
|
||||
-- calmed line; the port dropped the first text and the cry
|
||||
-- (#867). play_cry arms the next show_text, so the cry rides
|
||||
-- the calmed box's open with the button prompt kept, and the
|
||||
-- wait row stands in for the asm's 30-frame gap.
|
||||
-- (#867). text/PokemonTower6F.asm:1-5 (#1849)
|
||||
local rows = {
|
||||
{ "play_cry", "MAROWAK" },
|
||||
{ "show_text", t._PokemonTower6FGhostWasCubonesMotherText
|
||||
or "The GHOST was the\nrestless soul of\vCUBONE's mother!" },
|
||||
{ "play_cry", "MAROWAK", true },
|
||||
{ "wait", 30 },
|
||||
{ "show_text", t._PokemonTower6FSoulWasCalmedText
|
||||
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!" },
|
||||
@@ -191,7 +189,8 @@ M.POKEMON_TOWER_6F = {
|
||||
end
|
||||
ow:afterBattle(result, battle)
|
||||
end
|
||||
game.stack:push(battle)
|
||||
-- InitWildBattle runs the wipe before the disguise (core.asm:6695-6702)
|
||||
ow:pushBattle(battle)
|
||||
end))
|
||||
return true
|
||||
end,
|
||||
@@ -443,6 +442,9 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
or "I hope we meet\nagain..."
|
||||
game.stack:push(TextBox.new(game, impressed, function()
|
||||
local battle = BattleState.newTrainer(game, "OPP_GIOVANNI", 1)
|
||||
-- SaveEndBattleTextPointers (scripts/RocketHideoutB4F.asm:99-120):
|
||||
-- PrintEndBattleText prints it on the battle screen (#1817)
|
||||
battle.endBattleText = TextBox.substitute(game, cannotBe)
|
||||
battle.onFinish = function(result)
|
||||
if result ~= "win" then
|
||||
ow:afterBattle(result, battle)
|
||||
@@ -451,22 +453,19 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
end
|
||||
game.save.defeatedTrainers[npc.id] = true
|
||||
game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = true
|
||||
-- End-battle "WHAT!" then BeatGiovanniScript's hope text,
|
||||
-- fade, HideObject Giovanni, ShowObject Silph Scope.
|
||||
game.stack:push(TextBox.new(game, cannotBe, function()
|
||||
game.stack:push(TextBox.new(game, hope, function()
|
||||
local Transition = require("src.render.Transition")
|
||||
game.stack:push(Transition.new(game, function()
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
|
||||
"ROCKETHIDEOUTB4F_GIOVANNI")
|
||||
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
|
||||
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
|
||||
end, function()
|
||||
ow:afterBattle(result, battle)
|
||||
done()
|
||||
end))
|
||||
-- BeatGiovanniScript (scripts/RocketHideoutB4F.asm)
|
||||
game.stack:push(TextBox.new(game, hope, function()
|
||||
local Transition = require("src.render.Transition")
|
||||
game.stack:push(Transition.new(game, function()
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
|
||||
"ROCKETHIDEOUTB4F_GIOVANNI")
|
||||
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
|
||||
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
|
||||
end, function()
|
||||
ow:afterBattle(result, battle)
|
||||
done()
|
||||
end))
|
||||
end))
|
||||
end
|
||||
|
||||
@@ -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.
|
||||
@@ -505,6 +505,42 @@ 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
|
||||
@@ -1075,6 +1111,37 @@ own state, preserving selective control outside a battle; a wrapper that only
|
||||
owns battle presentation should return `false` only for its active battle or
|
||||
text-box state.
|
||||
|
||||
`pokemon.level_visible` (RFC 0019) takes a Pokémon's level off the screens
|
||||
that print it, without moving anything else on them:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("pokemon.level_visible", function(next, mon, ctx)
|
||||
-- ctx = { where = "battle.enemy" | "battle.player" | "party" | "summary",
|
||||
-- game = <Game> }
|
||||
if myMode.active and ctx.where ~= "party" then return false end
|
||||
return next(mon, ctx)
|
||||
end)
|
||||
```
|
||||
|
||||
It receives `(next, mon, ctx)` and defaults to `true`, so vanilla rendering
|
||||
is unchanged. Only an explicit `false` suppresses; `nil` and anything else
|
||||
print, so a wrapper that forgets a branch cannot blank a screen by accident.
|
||||
The `<LV>` glyph goes with the digits, and on status page 2 so does the
|
||||
`<to>` arrow that points at the next level, because an arrow with nothing
|
||||
after it is half a sentence. A status condition still replaces the level on
|
||||
a battle healthbox exactly as it does in the cart, so hiding a level never
|
||||
hides `PSN` or `BRN`.
|
||||
|
||||
`ctx.where` names the surface rather than the widget, because the number
|
||||
means different things on different screens: on a battle HUD an opponent's
|
||||
level is information about them, on the party and status screens your own
|
||||
level is information about you. A mode can hide one and keep the other.
|
||||
|
||||
**Gen 1 only for now.** The Gen 2 screens keep their own level readouts and
|
||||
do not consult this hook, and neither does the Gen 1 PC box list, where the
|
||||
level is part of a row label rather than a drawn field. Both are noted in
|
||||
RFC 0019 as follow-ups.
|
||||
|
||||
`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call
|
||||
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
|
||||
option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever
|
||||
|
||||
@@ -821,7 +821,7 @@ mod.content.phone_contacts:patch("PHONE_YOUNGSTER_JOEY", { map = "ROUTE_31" })
|
||||
| `catchRate` | integer 0..255 | yes |
|
||||
| `cry` | cries id | no |
|
||||
| `dex` | integer >= 1 | yes |
|
||||
| `dexEntry` | {heightFt, heightIn, heightM?, kind, text, weight, weightKg?} | no |
|
||||
| `dexEntry` | {heightFt, heightIn, heightM?, kind, text, text2?, weight, weightKg?} | no |
|
||||
| `evolutions` | list of {item?, level?, method, species} | yes |
|
||||
| `frontSize` | integer 1..7 | yes |
|
||||
| `growthRate` | growth_rates id | yes |
|
||||
|
||||
@@ -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,157 @@
|
||||
# RFC 0019: `pokemon.level_visible` — a level a mode can take off the screen
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
A Pokémon's level is printed on four Gen 1 surfaces — both battle
|
||||
healthboxes, the party rows, and page 1 and page 2 of the status screen —
|
||||
and every one of them prints it unconditionally. There is no seam. A mode
|
||||
that wants the number gone has exactly two options today, and both are bad.
|
||||
|
||||
It can paint over the text from `render.hud`, which means knowing four pixel
|
||||
rectangles, matching the background shade, and surviving the palette flashes
|
||||
and the healthbox slide-in. Or it can monkey-patch the drawing modules from
|
||||
inside its sandbox, which works — `require` hands a mod the engine's own
|
||||
table — and is precisely what `CONTRIBUTING-mods.md` tells mods not to do.
|
||||
A mod that took the second road already deleted its patching layer once, for
|
||||
the reasons that document gives.
|
||||
|
||||
The motivating case is a battle royale where every party is scaled to a
|
||||
shared rung that rises with the fog. The number on the healthbox is
|
||||
therefore never news — it is the same for everyone, it changes on a clock,
|
||||
and a player reading `:L37` on an opponent learns nothing except that they
|
||||
are playing the same match. Worse, it reads as a threat it is not: a Lv37
|
||||
opponent looks dangerous to a player who has not worked out that their own
|
||||
team is Lv37 too. The mode wants the level off the HUD and out of the party
|
||||
list, and the announcement it replaces it with is one line about everyone
|
||||
getting stronger at once.
|
||||
|
||||
Nothing about that is specific to a battle royale. A randomizer that hides
|
||||
levels to keep an encounter unreadable, a challenge run that forbids
|
||||
level-checking, a hard mode that withholds an opponent's level, and a
|
||||
"blind" Nuzlocke all want the same switch, and none of them should have to
|
||||
learn where the `<LV>` glyph lives.
|
||||
|
||||
## 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 with the presentation predicates already on the battle screen —
|
||||
`battle.status_hud_visible`, `battle.bottom_ui_visible` and
|
||||
`battle.caught_marker_visible` — and takes the same shape: a hook consulted
|
||||
behind `Runtime.wantsHook`, defaulting to visible, where only an explicit
|
||||
`false` suppresses. The difference is that a level is not a battle-only
|
||||
readout, so this one is not named `battle.*` and carries the surface that
|
||||
asked.
|
||||
|
||||
There is no in-repo D-number registry to amend.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
### New hook: `pokemon.level_visible`
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("pokemon.level_visible", function(next, mon, ctx)
|
||||
-- ctx = { where = "battle.enemy" | "battle.player" | "party" | "summary",
|
||||
-- game = <Game> }
|
||||
if myMode.active and ctx.where ~= "party" then
|
||||
return false -- the level is not printed on that surface
|
||||
end
|
||||
return next(mon, ctx) -- true: printed, as today
|
||||
end)
|
||||
```
|
||||
|
||||
Default `true`. Only an explicit `false` suppresses; `nil` and every other
|
||||
value print, so a wrapper that forgets a branch cannot blank a screen by
|
||||
accident.
|
||||
|
||||
`ctx.where` names the surface rather than the widget, because the number
|
||||
means different things on different screens: on a battle HUD an opponent's
|
||||
level is information about *them*, on the party and status screens your own
|
||||
level is information about *you*. A mode that wants to hide the first and
|
||||
keep the second can, and the motivating mode does exactly that.
|
||||
|
||||
### New module: `src/ui/LevelDisplay.lua`
|
||||
|
||||
One function, `LevelDisplay.visible(mon, where, game)`, wrapping the
|
||||
`wantsHook`/`call` pair. It exists so the four call sites are a one-line
|
||||
guard each instead of four copies of the same five lines, and so the hook
|
||||
has one definition of "visible" rather than four that can drift.
|
||||
|
||||
### Call sites
|
||||
|
||||
| Surface | File | `where` |
|
||||
| --- | --- | --- |
|
||||
| Enemy healthbox | `src/battle/BattleState.lua` | `battle.enemy` |
|
||||
| Player healthbox | `src/battle/BattleState.lua` | `battle.player` |
|
||||
| Party rows | `src/ui/PartyMenu.lua` | `party` |
|
||||
| Status page 1 and 2 | `src/ui/SummaryMenu.lua` | `summary` |
|
||||
|
||||
No layout moves. Each site keeps its own hand-rolled PrintLevel rule
|
||||
(`home/pokemon.asm:335-345` — the `<LV>` tile, then the digits, with a level
|
||||
of 100 writing its third digit back over the tile); it just asks first.
|
||||
|
||||
Two details are deliberate:
|
||||
|
||||
- **A status condition still replaces the level on a healthbox**, exactly as
|
||||
it does in the cart, so hiding the level never hides `PSN` or `BRN`. The
|
||||
guard is an `elseif` on the existing status branch, not a wrapper around
|
||||
it.
|
||||
- **On status page 2 the `<to>` arrow is hidden with the level it points
|
||||
at.** The arrow introduces the next level; on its own it is half a
|
||||
sentence. The EXP-to-next-level figure beside it is a different field and
|
||||
still prints.
|
||||
|
||||
### No other surface changes
|
||||
|
||||
No event, no registry, no save field, no manifest key.
|
||||
|
||||
## Migration
|
||||
|
||||
None. The hook is additive and defaults to current behaviour.
|
||||
|
||||
## Verification
|
||||
|
||||
- `tests/modkit/cases/pokemon_level_visible.lua` — the contract through the
|
||||
public mod API: default true with no mod, `false` suppresses, the surface
|
||||
and the mon reach the hook, falling through prints, and a `nil` mon
|
||||
answers rather than throwing.
|
||||
- `tests/engine/gate_hooks.lua` — the structural parity gate picks the hook
|
||||
up automatically, because it walks the live catalog rather than a list.
|
||||
- `tests/engine/gate_meta_coverage.lua` — the coverage ratchet; the seam is
|
||||
covered by name from the change that introduces it, so it never enters the
|
||||
DEBT ledger.
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
A build with no mod wrapping the hook never reaches `Runtime.call`:
|
||||
`wantsHook` is checked first, which matters because two of the four sites
|
||||
are on a per-frame draw path. Pixels are unchanged, and the parity gates
|
||||
assert it.
|
||||
|
||||
## Scope, and what is deliberately not in this change
|
||||
|
||||
**Gen 1 only.** The Gen 2 screens keep their own level readouts
|
||||
(`src/ui/gen2/PartyMenu.lua`, `SummaryMenu.lua`, `BoxMenu.lua`,
|
||||
`HallOfFame.lua`) and do not consult the hook. So does the Gen 1 PC box list
|
||||
(`src/ui/BoxMenu.lua`), where the level is baked into a row label string
|
||||
rather than drawn as a field, and the box-to-PNG print path beside it.
|
||||
|
||||
Those are mechanical follow-ups, held back so this change stays reviewable
|
||||
against screens that can actually be exercised here. The limitation is
|
||||
stated in `docs/modding.md` beside the hook, so a mod author reads it before
|
||||
depending on it rather than after.
|
||||
|
||||
## Compatibility seam for older engines
|
||||
|
||||
There is none, and none is possible without patching: the call sites are
|
||||
mid-draw, so a mod on a stock engine cannot reach them. That is the argument
|
||||
for the seam rather than a gap around it — the alternatives available to a
|
||||
mod today are painting over the engine's own pixels or reaching into its
|
||||
render modules, and the second is the thing the mod contract exists to
|
||||
prevent.
|
||||
+42
-3
@@ -214,12 +214,51 @@ finally the bare name handed to the system loader. Per-OS names are
|
||||
`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. The desktop path is still a developer build sitting in cargo's
|
||||
output directory; nothing packages it next to a shipped game yet.
|
||||
`ShaderFX.canConvert()` reports whether the library resolved on this machine,
|
||||
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
|
||||
|
||||
+3
-2
@@ -63,8 +63,9 @@ 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`,
|
||||
`-linux-arm64.AppImage`, `-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and
|
||||
(`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
|
||||
|
||||
@@ -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,29 @@
|
||||
<?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>MIT</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://gen1re.com/</url>
|
||||
<url type="vcs-browser">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,6 +22,56 @@ 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 or love.errhand
|
||||
@@ -30,10 +80,33 @@ do
|
||||
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
|
||||
@@ -384,12 +457,14 @@ function bootGame(version, cartId)
|
||||
Game = require("src.core.Game2").new()
|
||||
Game:load()
|
||||
else
|
||||
-- Gen1 Game is a module singleton. In-process EXIT GAME resets it in
|
||||
-- place; if a prior teardown left load missing, rebuild from source.
|
||||
-- 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(gameMod.load) ~= "function" then
|
||||
package.loaded["src.core.Game"] = nil
|
||||
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()
|
||||
@@ -557,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.
|
||||
@@ -1125,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
|
||||
@@ -1219,6 +1303,8 @@ function love.run()
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ 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;
|
||||
@@ -111,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
|
||||
@@ -576,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) {
|
||||
@@ -593,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);
|
||||
@@ -1277,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1506,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
|
||||
@@ -1534,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.
|
||||
*
|
||||
@@ -1565,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());
|
||||
}
|
||||
|
||||
@@ -12,6 +12,55 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.2.32",
|
||||
"date": "2026-08-27",
|
||||
"size": 21387427,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.32/gen1recomp++-0.2.32-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @campavao\n- @colsonrice"
|
||||
},
|
||||
{
|
||||
"version": "0.2.30",
|
||||
"date": "2026-08-27",
|
||||
"size": 21375253,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.30/gen1recomp++-0.2.30-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1562 Link battles show no withdraw message on switch\n- #1804 closed poke ball is not shown sometimes after catching\n- #1805 Missing jingle + closing menu when using ability enhancers\n- #1810 Wrong amount of hidden coins in the game corner\n- #1811 Slot machine issues\n- #1814 Rocket has no vision\n- #1817 Giovanni dialogue in the hideout\n- #1818 Sound effect missing when teaching a move\n- #1819 Option box in Pokemon menu not connected to bottom of the screen\n- #1821 Cursor position still on SAVE after loading\n- #1826 EXP calculation after opposing trainer switches out its PKMN\n- #1827 Behavior of sprite replacement mods on Crystal\n- #1829 Pokedex could use further clean up\n- #1831 Spinning too fast\n- #1833 Cancel option missing when selecting Pokemon in the overworld\n- #1834 Possible bug with Unown in Gold, Silver, Crystal\n- #1836 Crystal Kanto Flight Bugged\n- #1840 Two small issues with flying\n- #1842 Pokemon Tower rival bugs\n- #1845 Pokemon Trainer Item PC wrong Layout\n- #1846 No jingle when leveling up with rare candy\n- #1847 Someones / Bill PC Wrong Layout\n- #1848 Nightshade animation too slow\n- #1849 Pokemon Tower Marowak bugs\n- #1853 Try Catching Pokemon have wierd White nohing\n- #1858 (GSC) Broken colors on stats screen since 0.2.27\n- #1860 (GSC) Jingles are cut off if you advance the dialogue while they play\n- #1862 (GSC) Whirlpool disappearing before the SFX ends\n\n## Contributors\n\n- @bryanthaboi\n- @colsonrice\n- @thibautbus"
|
||||
},
|
||||
{
|
||||
"version": "0.2.29",
|
||||
"date": "2026-08-26",
|
||||
"size": 21366635,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.29/gen1recomp++-0.2.29-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.2.28",
|
||||
"date": "2026-08-26",
|
||||
"size": 21366087,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.28/gen1recomp++-0.2.28-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+72
-26
@@ -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
|
||||
@@ -170,27 +170,53 @@ 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 points at a prebuilt library; otherwise cargo builds it.
|
||||
# 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"
|
||||
local src="${SHADERFX_BRIDGE:-}"
|
||||
local dest="$1" name="$2" plat="$3"
|
||||
local crate="$ROOT/tools/shaderfx-bridge"
|
||||
if [ -z "$src" ] && [ -f "$crate/target/release/$name" ]; then
|
||||
src="$crate/target/release/$name"
|
||||
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" ] && 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
|
||||
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"
|
||||
else
|
||||
warn "$name not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE or install cargo)"
|
||||
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() {
|
||||
@@ -205,7 +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"
|
||||
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 \
|
||||
@@ -322,7 +348,7 @@ build_win() {
|
||||
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"
|
||||
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
|
||||
@@ -419,7 +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"
|
||||
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
|
||||
@@ -457,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.
|
||||
@@ -474,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
|
||||
|
||||
@@ -106,6 +106,13 @@ if [ -n "$TEST_APPLICATION_ID" ]; then
|
||||
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
|
||||
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
|
||||
|
||||
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")"
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -114,9 +114,11 @@ 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
|
||||
|
||||
@@ -104,7 +104,9 @@ local SE_FRAMES = {
|
||||
SE_FLASH_MON_PIC = 4, SE_FLASH_ENEMY_MON_PIC = 4,
|
||||
SE_TRANSFORM_MON = 4,
|
||||
SE_SUBSTITUTE_MON = 3,
|
||||
SE_WAVY_SCREEN = 255, -- AnimationWavyScreen: ld c, $ff frames
|
||||
-- AnimationWavyScreen: `ld c, $ff` counts outer passes, and the inner
|
||||
-- loop exits twice per displayed frame (animations.asm:1884-1903)
|
||||
SE_WAVY_SCREEN = 128,
|
||||
}
|
||||
|
||||
-- data/battle_anims/special_effects.asm AnimationIdSpecialEffects:
|
||||
@@ -502,15 +504,10 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
|
||||
local transform = resolveTransform(sub.type, attackerIsPlayer)
|
||||
local first, last, dir = 1, #sub.blocks, 1
|
||||
if transform == "REVERSE" then first, last, dir = last, first, -1 end
|
||||
-- DoBallShakeSpecialEffects: each ball shake opens with SFX_TINK
|
||||
-- and a 40-frame pause, then rewinds the same subanimation; the
|
||||
-- mode-4 frame blocks persist, so the resting ball stays visible
|
||||
-- through the pauses between wobbles
|
||||
-- DoBallShakeSpecialEffects: animations.asm:739-747, :623-627
|
||||
local pendingTink = false
|
||||
for _ = 1, (opts and opts.shakes) or 1 do
|
||||
if opts and opts.shakes then
|
||||
events[#events + 1] = { effect = "SFX_TINK", frame = frame }
|
||||
emit(40)
|
||||
end
|
||||
pendingTink = opts and opts.shakes ~= nil
|
||||
local dest = 1 -- PlaySubanimation resets the OAM cursor per row
|
||||
local nblocks = math.abs(last - first) + 1
|
||||
local played = 0
|
||||
@@ -568,6 +565,11 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
|
||||
-- DoSpecialEffectByAnimationId runs after every frame
|
||||
-- block with wSubAnimCounter = blocks remaining
|
||||
played = played + 1
|
||||
if pendingTink then
|
||||
pendingTink = false
|
||||
events[#events + 1] = { effect = "SFX_TINK", frame = frame }
|
||||
emit(40)
|
||||
end
|
||||
if ballFlicker then obp0Flip = not obp0Flip end
|
||||
if idFx then
|
||||
local counter = nblocks - played + 1
|
||||
|
||||
+154
-41
@@ -16,6 +16,7 @@ local Damage = require("src.battle.Damage")
|
||||
local EffectRegistry = require("src.battle.EffectRegistry")
|
||||
local Experience = require("src.battle.Experience")
|
||||
local Font = require("src.render.Font")
|
||||
local LevelDisplay = require("src.ui.LevelDisplay")
|
||||
local Logger = require("src.core.Logger")
|
||||
local MoveEffects = require("src.battle.MoveEffects")
|
||||
local Party = require("src.pokemon.Party")
|
||||
@@ -34,6 +35,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 = {}
|
||||
@@ -334,6 +341,22 @@ local function monPalette(data, species)
|
||||
return { name = name, colors = colors }
|
||||
end
|
||||
|
||||
-- MarowakAnim OBJ pics under OAM_PAL1:
|
||||
-- engine/battle/ghost_marowak_anim.asm:3-5,77
|
||||
local function objPicPalette()
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
if not PaletteFX.usesSpriteObp() then return nil end
|
||||
local colors, group = PaletteFX.ogObj()
|
||||
if not colors then return nil end
|
||||
return { name = "obp1:" .. tostring(group), colors = colors }
|
||||
end
|
||||
|
||||
local function objPic(path, trueColor)
|
||||
local pal = objPicPalette()
|
||||
if not pal then return nil end
|
||||
return getImage(path, pal, trueColor)
|
||||
end
|
||||
|
||||
-- a named palette from the active COLORS pack as a getImage pal
|
||||
local function namedPalette(data, name)
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
@@ -461,7 +484,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
|
||||
@@ -891,7 +914,8 @@ local function disguiseAsGhost(self)
|
||||
self.enemy.name = "GHOST"
|
||||
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
|
||||
monPalette(self.data, self.enemy.mon.species))
|
||||
self.introText = Strings("The GHOST\nappeared!")
|
||||
-- _EnemyAppearedText (data/text/text_2.asm:1251-1255) has no article
|
||||
self.introText = self:romText("_EnemyAppearedText", "%s\nappeared!", self.enemy.name)
|
||||
end
|
||||
|
||||
-- Pokémon Tower ghosts (engine/battle/core.asm): without the Silph Scope
|
||||
@@ -935,7 +959,11 @@ function BattleState:queueScopeReveal()
|
||||
local unveiled = self.data.text and self.data.text._UnveiledGhostText
|
||||
self:say(unveiled
|
||||
or Strings("SILPH SCOPE\nunveiled the\vGHOST's identity!"))
|
||||
self:act(function() self.ghostReveal = { t = 0 } end)
|
||||
self:act(function()
|
||||
self.ghostReveal = { t = 0 }
|
||||
local ghostObj = objPic("assets/generated/battle/front/ghost.png")
|
||||
if ghostObj then self.enemy.sprite = ghostObj end
|
||||
end)
|
||||
table.insert(self.queue, { wait = BattleState.GHOST_REVEAL_FRAMES })
|
||||
self:say(self:romText("_WildMonAppearedText", "Wild %s\nappeared!",
|
||||
self.ghostReal and self.ghostReal.name or self.enemy.name))
|
||||
@@ -3258,9 +3286,9 @@ function BattleState:applyAnimEffect(ev)
|
||||
end
|
||||
fx.hudShakeProg = prog
|
||||
elseif e == "SE_WAVY_SCREEN" then
|
||||
-- AnimationWavyScreen: 255 frames of per-scanline SCX offsets
|
||||
-- walking WavyScreenLineOffsets
|
||||
fx.wavy = { left = 255, phase = 0 }
|
||||
-- AnimationWavyScreen: 255 outer passes, two per displayed frame
|
||||
-- (animations.asm:1884-1903), walking WavyScreenLineOffsets
|
||||
fx.wavy = { left = 128, phase = 0 }
|
||||
|
||||
-- ---------------------------------------------- mon pic effects
|
||||
elseif e == "SE_SLIDE_MON_OFF" then
|
||||
@@ -3644,7 +3672,7 @@ function BattleState:updateFx()
|
||||
end
|
||||
if fx.wavy then
|
||||
fx.wavy.left = fx.wavy.left - 1
|
||||
fx.wavy.phase = fx.wavy.phase + 1
|
||||
fx.wavy.phase = fx.wavy.phase + 2
|
||||
if fx.wavy.left <= 0 then fx.wavy = nil end
|
||||
end
|
||||
end
|
||||
@@ -3698,12 +3726,22 @@ function BattleState:updateFx()
|
||||
local real = self.ghostReal
|
||||
if real then
|
||||
self.enemy.name = real.name or self.enemy.name
|
||||
self.enemy.sprite = real.sprite or self.enemy.sprite
|
||||
gr.bgSprite = real.sprite or self.enemy.sprite
|
||||
local objReal
|
||||
if objPicPalette() then
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local path, tc = Sprites.path(self.data, self.enemy.mon.species,
|
||||
"front", { mon = self.enemy.mon, kind = "battle" })
|
||||
objReal = path and objPic(path, tc)
|
||||
end
|
||||
self.enemy.sprite = objReal or gr.bgSprite
|
||||
end
|
||||
end
|
||||
pf.fade = math.min(1, math.ceil((gr.t - outEnd) / 10) / 4)
|
||||
end
|
||||
if gr.t >= BattleState.GHOST_REVEAL_FRAMES then
|
||||
-- home/clear_sprites.asm:1
|
||||
if gr.bgSprite then self.enemy.sprite = gr.bgSprite end
|
||||
self.ghostReveal, self.scopeReveal, pf.fade = nil, nil, nil
|
||||
end
|
||||
end
|
||||
@@ -3819,6 +3857,9 @@ function BattleState:executeAction(user, target, action)
|
||||
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
||||
clearTrapping(self.player)
|
||||
self:syncSides()
|
||||
-- EnemySendOut (core.asm:1276-1289): only the mon on the field stays flagged
|
||||
self.participants = {}
|
||||
self:markParticipant()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[2], battler = self.enemy,
|
||||
previous = previous,
|
||||
@@ -4531,8 +4572,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
|
||||
@@ -5100,6 +5140,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).
|
||||
@@ -5180,11 +5271,7 @@ 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()
|
||||
@@ -5898,8 +5985,7 @@ local WAVY_OFFSETS = { 0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1, 1,
|
||||
0, 0, 0, 0, 0, -1, -1, -1, -2, -2, -2, -2, -2,
|
||||
-1, -1, -1 }
|
||||
|
||||
-- wave the BG canvas one scanline at a time; the offset table walks
|
||||
-- one entry per frame like the asm's advancing pointer
|
||||
-- WavyScreen_SetSCX: animations.asm:1916-1927
|
||||
function BattleState:applyWavy(src)
|
||||
local wavy = self.fx and self.fx.wavy
|
||||
if not wavy then return src end
|
||||
@@ -5912,7 +5998,7 @@ function BattleState:applyWavy(src)
|
||||
for line = 0, 143 do
|
||||
self.waveQuad:setViewport(0, line, 160, 1)
|
||||
g.draw(src, self.waveQuad,
|
||||
WAVY_OFFSETS[(line + wavy.phase) % 32 + 1], line)
|
||||
WAVY_OFFSETS[(line * 2 + wavy.phase) % 32 + 1], line)
|
||||
end
|
||||
g.setCanvas(prev)
|
||||
return self.waveCanvas
|
||||
@@ -5943,7 +6029,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
|
||||
@@ -6252,7 +6338,7 @@ function BattleState:drawHUDs(slide)
|
||||
end
|
||||
if self.enemy.shownStatus then
|
||||
Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8)
|
||||
else
|
||||
elseif LevelDisplay.visible(self.enemy.mon, "battle.enemy", self.game) then
|
||||
hudTile(0x6E, 32, 8) -- <LV>
|
||||
Font.draw(tostring(self.enemy.mon.level), 40, 8)
|
||||
end
|
||||
@@ -6336,7 +6422,7 @@ function BattleState:drawHUDs(slide)
|
||||
Font.draw(self.player.name, nameX(10, self.player.name), 56)
|
||||
if self.player.shownStatus then
|
||||
Font.draw(self:statusLabel({ status = self.player.shownStatus }), 120, 64)
|
||||
else
|
||||
elseif LevelDisplay.visible(self.player.mon, "battle.player", self.game) then
|
||||
hudTile(0x6E, 112, 64) -- <LV>
|
||||
Font.draw(tostring(self.player.mon.level), 120, 64)
|
||||
end
|
||||
@@ -6353,8 +6439,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
|
||||
@@ -6373,48 +6486,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,
|
||||
@@ -6489,6 +6601,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()
|
||||
|
||||
@@ -26,14 +26,10 @@ local WideBattle = {
|
||||
FIELD_BOTTOM = 104,
|
||||
}
|
||||
|
||||
-- The forced-mono display modes re-threshold the whole finished frame
|
||||
-- through the shade shader, and picImage hands them raw DMG grays for that
|
||||
-- (#207). The wide layout has to know: it exposes a matching whole-surface
|
||||
-- zone and leaves the HP bar fill gray, exactly like the zone pass does in
|
||||
-- the classic layout (#229). Keep in sync with picImage / ensureZones.
|
||||
local function monoMode()
|
||||
local m = PaletteFX.mode
|
||||
return m == "og" or m == "og_inv" or m == "classic"
|
||||
or PaletteFX.forcesRawGrays()
|
||||
end
|
||||
|
||||
local function shownHP(battler)
|
||||
|
||||
@@ -88,6 +88,7 @@ function AnimRunner.new(opts)
|
||||
}
|
||||
self.objects = AnimObjects.new(self.data, self.constants, self.env)
|
||||
self.bg = BgEffects.new(self.constants, self.env)
|
||||
self.animId = opts.animId -- wFXAnimID
|
||||
self.gfxOrder = self.constants.battleAnimGfxOrder or {}
|
||||
self.sfxOrder = opts.sfxOrder or {}
|
||||
|
||||
|
||||
@@ -170,6 +170,10 @@ function Data:seedDefaults(version)
|
||||
-- writes no header for him -- seed one so he engages on sight and has
|
||||
-- his defeat / re-talk lines (same idea as the Cinnabar seed above).
|
||||
Data.seedFightingDojoKarateMaster(self)
|
||||
-- #1743: Mt. Moon B2F Super Nerd is text_asm (no def_trainers), so Yellow's
|
||||
-- extractor never writes his header -- seed one so engageTrainer finds
|
||||
-- battle/won/after text instead of the "I like shorts!" fallback.
|
||||
Data.seedMtMoonB2FSuperNerd(self)
|
||||
-- #189: 1F cabin door order vs rooms map (survey zoom)
|
||||
require("src.world.SsAnneLayout").apply(self.maps)
|
||||
end
|
||||
@@ -201,6 +205,25 @@ function Data:seedFightingDojoKarateMaster()
|
||||
}
|
||||
end
|
||||
|
||||
-- Mt. Moon B2F Super Nerd (object index 1) is text_asm with no def_trainers
|
||||
-- row, so Yellow never gets a trainerHeaders.MtMoonB2F[1] entry (Red/Blue
|
||||
-- pin one in make_rom_manifest.py). Without it, engageTrainer falls through
|
||||
-- to the hard-coded "I like shorts!" fallback (#1743). trainerDefeated still
|
||||
-- tracks him via defeatedTrainers[npc.id]; the fabricated event name matches
|
||||
-- the shipped Red/Blue manifest pin for consistency with fossil drivers.
|
||||
function Data:seedMtMoonB2FSuperNerd()
|
||||
local headers = self.trainer_headers
|
||||
if not headers then return end
|
||||
headers.MtMoonB2F = headers.MtMoonB2F or {}
|
||||
if headers.MtMoonB2F[1] then return end
|
||||
headers.MtMoonB2F[1] = {
|
||||
battle = "_MtMoonB2FSuperNerdTheyreBothMineText",
|
||||
won = "_MtMoonB2FSuperNerdOkIllShareText",
|
||||
after = "_MtMoonB2FSuperNerdTheresAPokemonLabText",
|
||||
event = "EVENT_BEAT_MT_MOON_3_SUPER_NERD",
|
||||
}
|
||||
end
|
||||
|
||||
function Data:seedCinnabarGymTrainerHeaders()
|
||||
local headers = self.trainer_headers
|
||||
if not headers or headers.CinnabarGym then return end
|
||||
|
||||
+9
-2
@@ -186,7 +186,8 @@ function Game:makeTitleState()
|
||||
onContinue = function()
|
||||
local loaded, recovered = SaveData.load()
|
||||
if loaded then
|
||||
self:restoreSave(loaded, recovered, { freshBoot = true })
|
||||
self:restoreSave(loaded, recovered,
|
||||
{ freshBoot = true, continued = true })
|
||||
end
|
||||
end,
|
||||
onExit = self.onExit,
|
||||
@@ -1258,6 +1259,7 @@ function Game:applyOptions(opts)
|
||||
if Sound.applyOptions then Sound.applyOptions(opts) end
|
||||
require("src.render.PaletteFX").applyOptions(opts)
|
||||
require("src.render.Tilt").applyOptions(opts)
|
||||
require("src.render.Letterbox").applyOptions(opts)
|
||||
-- after Tilt, so a persisted world pipeline can switch the tilt level it
|
||||
-- just restored back off (the two are mutually exclusive)
|
||||
require("src.render.Pipelines").applyOptions(opts)
|
||||
@@ -1321,6 +1323,9 @@ function Game:restoreSave(loaded, recovered, opts)
|
||||
report.recovered = recovered
|
||||
report.modsDiff = modsDiff
|
||||
self.save = loaded
|
||||
-- the START cursor lives outside sGameData (ram/sram.asm:17-21)
|
||||
loaded.startMenuIndex = nil
|
||||
self.startMenuIndex = nil
|
||||
self:adoptSave(loaded)
|
||||
-- SaveData.load already attached the standalone options.lua table
|
||||
self:applyOptions(loaded.options)
|
||||
@@ -1335,8 +1340,10 @@ function Game:restoreSave(loaded, recovered, opts)
|
||||
-- freshBoot threads through from the caller (onContinue and F2 both set
|
||||
-- it); a future caller that doesn't ask for it keeps the ordinary
|
||||
-- crossfade by default.
|
||||
-- engine/menus/main_menu.asm:110 (CONTINUE forces PLAYER_DIR_DOWN)
|
||||
local facing = (opts and opts.continued) and "down" or loaded.player.facing
|
||||
self.stack:push(self.overworld, loaded.player.map,
|
||||
loaded.player.x, loaded.player.y, loaded.player.facing,
|
||||
loaded.player.x, loaded.player.y, facing,
|
||||
{ via = "boot", freshBoot = opts and opts.freshBoot })
|
||||
self.saveReport = report
|
||||
if not SaveData.emptyReport(report) then
|
||||
|
||||
@@ -1024,6 +1024,11 @@ function Game2:load()
|
||||
require("src.core.gen2.Phone").useRegistry(self.data)
|
||||
require("src.core.gen2.Decorations").useRegistry(self.data)
|
||||
require("src.core.gen2.Apricorns").useRegistry(self.data)
|
||||
-- data.gen2Pokedex is a separate table from the `pokemon` registry's own
|
||||
-- merge target (data.pokemon): a translation mod's
|
||||
-- mod.content.pokemon:patch(id, { dexEntry = ... }) would otherwise never
|
||||
-- reach the #DEX screen. See src/core/gen2/PokedexText.lua.
|
||||
require("src.core.gen2.PokedexText").apply(self.data)
|
||||
|
||||
-- Rendering pipelines: the engine half of the render_pipelines registry
|
||||
-- (src/render/Pipelines.lua). install() points it at GOLD's merged dataset
|
||||
@@ -1787,6 +1792,7 @@ function Game2:hotkey(key)
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
GbcPalette.setMode(options.color or "gbc")
|
||||
options.color = GbcPalette.cycle(1)
|
||||
options.palette = ""
|
||||
persist()
|
||||
return true
|
||||
elseif key == "3" then
|
||||
@@ -2040,6 +2046,7 @@ function Game2:applyOptions()
|
||||
Zoom.allowSurvey = caps.survey
|
||||
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
|
||||
require("src.render.Tilt").applyOptions(options)
|
||||
require("src.render.Letterbox").applyOptions(options)
|
||||
require("src.render.GbcPalette").applyOptions(options)
|
||||
-- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame.
|
||||
Font.setFrame(options.frame or 1)
|
||||
|
||||
+21
-2
@@ -40,8 +40,10 @@ GamepadMap.NX_RAW_BUTTON_BINDINGS = {
|
||||
|
||||
-- Raw index -> gamepad button *name* for RomImporter (then NX face swap applies).
|
||||
GamepadMap.RAW_TO_GAMEPAD_BUTTON = {
|
||||
[1] = "a", [2] = "b",
|
||||
[1] = "a", [2] = "b", [3] = "x", [4] = "y",
|
||||
[5] = "leftshoulder", [6] = "rightshoulder",
|
||||
[7] = "back", [8] = "start", [9] = "back", [10] = "start",
|
||||
[11] = "triggerleft", [12] = "triggerright",
|
||||
}
|
||||
|
||||
GamepadMap.NX_RAW_TO_GAMEPAD_BUTTON = {
|
||||
@@ -57,7 +59,10 @@ function GamepadMap._setForceNXForTests(v)
|
||||
end
|
||||
|
||||
local function nxActive()
|
||||
if GamepadMap._forceNXForTests then return true end
|
||||
if GamepadMap._forceNXForTests == true then return true end
|
||||
if GamepadMap._forceNXForTests == false and love and (love._os ~= "NX" and (not love.system or love.system.getOS() ~= "NX")) then
|
||||
return false
|
||||
end
|
||||
if love and love._os == "NX" then return true end
|
||||
if love and love.system and love.system.getOS() == "NX" then return true end
|
||||
return false
|
||||
@@ -78,6 +83,20 @@ function GamepadMap.mapGamepadButton(button)
|
||||
return GamepadMap.gamepadBindings()[button]
|
||||
end
|
||||
|
||||
function GamepadMap.mapLauncherButton(button)
|
||||
if nxActive() then
|
||||
if button == "a" then return "b"
|
||||
elseif button == "b" then return "a"
|
||||
elseif button == "x" then return "y"
|
||||
elseif button == "y" then return "x"
|
||||
elseif button == "back" then return "select"
|
||||
end
|
||||
elseif button == "back" then
|
||||
return "select"
|
||||
end
|
||||
return button
|
||||
end
|
||||
|
||||
-- Select+face display chords (docs / Nintendo UX):
|
||||
-- Select+A → "2" (COLORS), Select+B → "3" (TILT),
|
||||
-- Select+Y → "5", Select+X → "6", Select+L (leftshoulder) → "7".
|
||||
|
||||
+144
-25
@@ -4,18 +4,79 @@ local HostShell = {}
|
||||
|
||||
-- Our AppRun exports LD_LIBRARY_PATH="$APPDIR/lib:..." so every subprocess we
|
||||
-- spawn tries to link against the libraries we're shipping instead of the
|
||||
-- system ones. We want to unset the var so that any system tools can find
|
||||
-- their proper libraries. Only needed when running in an AppImage.
|
||||
-- LD_PRELOAD is Steam's overlay (#1470): its 32-bit half cannot load into a
|
||||
-- 64-bit child, so ld.so prints an error into that child's output.
|
||||
-- system ones. Host tools (curl, zenity) need that unset. A *bundled* AppDir
|
||||
-- curl must keep APPDIR on LD_LIBRARY_PATH so it resolves the bundled
|
||||
-- libssl/libcrypto -- scrubbing it forces host OpenSSL and segfaults across
|
||||
-- distros. LD_PRELOAD is always scrubbed: Steam's overlay (#1470) cannot load
|
||||
-- into a 64-bit child.
|
||||
function HostShell.envPrefix()
|
||||
return HostShell.curlEnvPrefix("host")
|
||||
end
|
||||
|
||||
-- kind "bundled" = AppDir / Flatpak curl (keep LD_LIBRARY_PATH, scrub preload).
|
||||
-- kind "host" = system curl (scrub both).
|
||||
function HostShell.curlEnvPrefix(kind)
|
||||
local unset = ""
|
||||
if os.getenv("APPIMAGE") then unset = unset .. "-u LD_LIBRARY_PATH " end
|
||||
if kind ~= "bundled" and os.getenv("APPIMAGE") then
|
||||
unset = unset .. "-u LD_LIBRARY_PATH "
|
||||
end
|
||||
if os.getenv("LD_PRELOAD") then unset = unset .. "-u LD_PRELOAD " end
|
||||
if unset == "" then return "" end
|
||||
return "env " .. unset
|
||||
end
|
||||
|
||||
local curlResolved = nil -- { path=, kind= } once per Lua state
|
||||
|
||||
local function pathIsExecutable(path)
|
||||
if type(path) ~= "string" or path == "" then return false end
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return false end
|
||||
f:close()
|
||||
-- Best-effort: existence is enough; exec bit is checked by the spawn.
|
||||
return true
|
||||
end
|
||||
|
||||
-- Prefer Flatpak /app/bin/curl, then $APPDIR/{usr/,}bin/curl, else host "curl".
|
||||
function HostShell.resolveCurl()
|
||||
if curlResolved ~= nil then return curlResolved.path, curlResolved.kind end
|
||||
local candidates = {}
|
||||
if os.getenv("FLATPAK_ID") then
|
||||
candidates[#candidates + 1] = { "/app/bin/curl", "bundled" }
|
||||
end
|
||||
local appdir = os.getenv("APPDIR")
|
||||
if type(appdir) == "string" and appdir ~= "" then
|
||||
candidates[#candidates + 1] = { appdir .. "/usr/bin/curl", "bundled" }
|
||||
candidates[#candidates + 1] = { appdir .. "/bin/curl", "bundled" }
|
||||
end
|
||||
for _, c in ipairs(candidates) do
|
||||
if pathIsExecutable(c[1]) then
|
||||
curlResolved = { path = c[1], kind = c[2] }
|
||||
return curlResolved.path, curlResolved.kind
|
||||
end
|
||||
end
|
||||
curlResolved = { path = "curl", kind = "host" }
|
||||
return curlResolved.path, curlResolved.kind
|
||||
end
|
||||
|
||||
-- Quoted curl argv0 for a shell command, plus the matching env prefix.
|
||||
function HostShell.curlInvocation()
|
||||
local path, kind = HostShell.resolveCurl()
|
||||
return HostShell.curlEnvPrefix(kind) .. HostShell.quote(path), kind
|
||||
end
|
||||
|
||||
-- Diagnostics for support / About screens.
|
||||
function HostShell.curlDiagnostics()
|
||||
local path, kind = HostShell.resolveCurl()
|
||||
return {
|
||||
path = path,
|
||||
kind = kind,
|
||||
appimage = os.getenv("APPIMAGE") ~= nil,
|
||||
appdir = os.getenv("APPDIR"),
|
||||
flatpakId = os.getenv("FLATPAK_ID"),
|
||||
haveCurl = HostShell.haveCurl(),
|
||||
}
|
||||
end
|
||||
|
||||
-- Windows: every host tool we shell out to (curl for the update and mod-index
|
||||
-- fetches, the PowerShell ROM picker, the update downloader's `start /b`) is
|
||||
-- spawned through io.popen / os.execute, which run it under cmd.exe. A
|
||||
@@ -126,12 +187,19 @@ local function withPopenLock(fn)
|
||||
if not okAtomic then fn() end
|
||||
end
|
||||
|
||||
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed
|
||||
function HostShell.popen(command, mode)
|
||||
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed.
|
||||
-- opts.envPrefix overrides the default HostShell.envPrefix() (pass "" to skip,
|
||||
-- or curlEnvPrefix(kind) when spawning a resolved curl binary).
|
||||
function HostShell.popen(command, mode, opts)
|
||||
HostShell.releasePointerGrab()
|
||||
local prefix = HostShell.envPrefix()
|
||||
if type(opts) == "table" and opts.envPrefix ~= nil then
|
||||
prefix = opts.envPrefix
|
||||
end
|
||||
local pipe
|
||||
local line = HostShell.shellCommand(prefix .. command)
|
||||
withPopenLock(function()
|
||||
local ok, p = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r")
|
||||
local ok, p = pcall(io.popen, line, mode or "r")
|
||||
pipe = (ok and p) or nil
|
||||
end)
|
||||
return pipe
|
||||
@@ -158,7 +226,15 @@ end
|
||||
-- and the app dies. There we relaunch through the GameActivity.restartApp
|
||||
-- JNI bridge (love.system.restartApp), which schedules our launch intent
|
||||
-- and kills the process so no native state can leak into the fresh run.
|
||||
-- On every other platform the in-process restart works, so keep it.
|
||||
-- iOS is the same class of problem with a sharper edge: love.cpp under
|
||||
-- LOVE_IOS forces DONE_RESTART for *every* quit (Apple forbids programmatic
|
||||
-- exit) and comments that leftover threads make that restart unreliable --
|
||||
-- which our ChipAudio / Fetch / Check workers are. There is no
|
||||
-- restartApp bridge on iOS, so callers that want "back to launcher" must
|
||||
-- use main.lua's in-process returnToLauncher (love.quit aborts the quit);
|
||||
-- HostShell.restart itself refuses quit("restart") and falls back to a
|
||||
-- bare quit() so a mod that still calls restart does not pick the worst
|
||||
-- path on purpose.
|
||||
function HostShell.restart()
|
||||
if not (love and love.event and love.event.quit) then return end
|
||||
|
||||
@@ -174,9 +250,23 @@ function HostShell.restart()
|
||||
love.event.quit()
|
||||
return
|
||||
end
|
||||
if osName == "iOS" then
|
||||
-- No process-kill bridge. A bare quit still becomes DONE_RESTART in
|
||||
-- love.cpp, but quit("restart") is the path that also runs our
|
||||
-- endProcess worker joins first and then re-enters runlove -- the
|
||||
-- combination that crashes EXIT GAME. Prefer the softer quit.
|
||||
love.event.quit()
|
||||
return
|
||||
end
|
||||
|
||||
local appimage = os.getenv("APPIMAGE")
|
||||
if not appimage then
|
||||
-- Flatpak and plain desktop: love.event.quit("restart") uses
|
||||
-- execv(/proc/self/exe). Inside bwrap that path is valid, but PhysFS
|
||||
-- locks on a mounted .love payload can survive the exec boundary and
|
||||
-- hang the next Boot.mount. Callers that just wrote updates/*.love
|
||||
-- should already have closed archive handles; Boot.run still owns
|
||||
-- crash-guard rollback if a mid-handoff restart dies.
|
||||
love.event.quit("restart")
|
||||
return
|
||||
end
|
||||
@@ -274,6 +364,18 @@ local function fetchError(url, status, noise)
|
||||
return ("fetch failed for %s: %s"):format(url, why)
|
||||
end
|
||||
|
||||
function HostShell.isWindows()
|
||||
return (love and love.system and love.system.getOS
|
||||
and love.system.getOS() == "Windows") or false
|
||||
end
|
||||
|
||||
function HostShell.shellCommand(command)
|
||||
command = tostring(command)
|
||||
if not HostShell.isWindows() then return command end
|
||||
if command:sub(1, 1) ~= '"' then return command end
|
||||
return '"' .. command .. '"'
|
||||
end
|
||||
|
||||
-- Shell quoting for one curl argument; cmd.exe has no single-quote form.
|
||||
function HostShell.quote(s)
|
||||
s = tostring(s)
|
||||
@@ -325,7 +427,10 @@ local curlAvailable = nil
|
||||
|
||||
function HostShell.haveCurl()
|
||||
if curlAvailable ~= nil then return curlAvailable end
|
||||
local pipe = HostShell.popen("curl --version")
|
||||
local path, kind = HostShell.resolveCurl()
|
||||
local pipe = HostShell.popen(
|
||||
HostShell.quote(path) .. " --version", "r",
|
||||
{ envPrefix = HostShell.curlEnvPrefix(kind) })
|
||||
if not pipe then curlAvailable = false return false end
|
||||
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||
HostShell.pclose(pipe)
|
||||
@@ -333,6 +438,12 @@ function HostShell.haveCurl()
|
||||
return curlAvailable
|
||||
end
|
||||
|
||||
local function curlCmd(flags)
|
||||
local path, kind = HostShell.resolveCurl()
|
||||
return HostShell.quote(path) .. " " .. flags,
|
||||
{ envPrefix = HostShell.curlEnvPrefix(kind) }
|
||||
end
|
||||
|
||||
-- An older mobile build reports nil here and falls back to the "no transport"
|
||||
-- error the callers already show.
|
||||
local function haveBridge()
|
||||
@@ -393,9 +504,11 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime, etagPa
|
||||
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
|
||||
userAgent = userAgent or "gen1recomp"
|
||||
if HostShell.haveCurl() then
|
||||
local cmd = ("curl -fsSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 15 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 300)
|
||||
local head, popts = curlCmd(
|
||||
("-fsSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 15 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 300))
|
||||
local cmd = head
|
||||
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
||||
if accept then
|
||||
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
|
||||
@@ -407,7 +520,7 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime, etagPa
|
||||
cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " "
|
||||
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
||||
.. HostShell.quote(url) .. " 2>&1"
|
||||
local pipe = HostShell.popen(cmd)
|
||||
local pipe = HostShell.popen(cmd, "r", popts)
|
||||
if not pipe then return nil, "could not start download" end
|
||||
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||
HostShell.pclose(pipe)
|
||||
@@ -450,16 +563,18 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
|
||||
-- BODY, and on the two services this talks to that body is the whole
|
||||
-- diagnosis: GitHub's 403 says "API rate limit exceeded for <ip>", which
|
||||
-- tells a user to wait rather than to go hunting for a broken index.
|
||||
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 40)
|
||||
local head, popts = curlCmd(
|
||||
("-sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 40))
|
||||
local cmd = head
|
||||
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
||||
if accept then
|
||||
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
|
||||
end
|
||||
cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
||||
.. HostShell.quote(url) .. " 2>&1"
|
||||
local pipe = HostShell.popen(cmd)
|
||||
local pipe = HostShell.popen(cmd, "r", popts)
|
||||
if not pipe then return nil, "could not run curl" end
|
||||
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||
HostShell.pclose(pipe)
|
||||
@@ -546,9 +661,11 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
||||
-- newlines. The body is staged above because io.popen cannot be opened
|
||||
-- for both writing and reading. No -f, matching httpGet: the response
|
||||
-- body is discarded anyway, and curl's stderr carries the diagnosis.
|
||||
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 40)
|
||||
local head, popts = curlCmd(
|
||||
("-sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 40))
|
||||
local cmd = head
|
||||
.. "-X POST "
|
||||
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
||||
if contentType then
|
||||
@@ -558,7 +675,7 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
||||
.. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " "
|
||||
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
||||
.. HostShell.quote(url) .. " 2>&1"
|
||||
local pipe = HostShell.popen(cmd)
|
||||
local pipe = HostShell.popen(cmd, "r", popts)
|
||||
if not pipe then
|
||||
pcall(os.remove, bodyPath)
|
||||
return nil, "could not run curl"
|
||||
@@ -735,8 +852,10 @@ function HostShell.httpRequest(url, opts)
|
||||
pcall(os.remove, headerPath)
|
||||
end
|
||||
|
||||
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d "):format(maxTime)
|
||||
local head, popts = curlCmd(
|
||||
("-sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d "):format(maxTime))
|
||||
local cmd = head
|
||||
.. "-X " .. HostShell.quote(method) .. " "
|
||||
.. "-H " .. HostShell.quote("@" .. headerPath) .. " "
|
||||
if body then
|
||||
@@ -745,7 +864,7 @@ function HostShell.httpRequest(url, opts)
|
||||
cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
||||
.. HostShell.quote(url) .. " 2>&1"
|
||||
|
||||
local pipe = HostShell.popen(cmd)
|
||||
local pipe = HostShell.popen(cmd, "r", popts)
|
||||
if not pipe then
|
||||
cleanup()
|
||||
return nil, "could not run curl"
|
||||
|
||||
@@ -227,7 +227,11 @@ local function metadata(options, context)
|
||||
end
|
||||
if flags and flags.fullscreen == true then add("Fullscreen", "yes") end
|
||||
local version = appVersion()
|
||||
add("App", version ~= "" and Version.title() or "gen1recomp")
|
||||
-- Bug reports always want the stamped engine when we have one; window
|
||||
-- chrome hides it on release builds (Version.title). Unstamped
|
||||
-- working-tree builds stay as plain "gen1recomp" so the placeholder
|
||||
-- never lands in a filed issue.
|
||||
add("App", version ~= "" and ("gen1recomp v" .. version) or "gen1recomp")
|
||||
add("LÖVE", loveVersion())
|
||||
if safeMode then add("Safe mode", "on") end
|
||||
return {
|
||||
|
||||
+37
-2
@@ -173,13 +173,40 @@ function SaveData.gameFolders()
|
||||
end
|
||||
|
||||
-- The game folder carrying portable.txt, or false. First candidate holding
|
||||
-- the marker wins.
|
||||
-- the marker *and* a writable probe wins. Flatpak installs always use the
|
||||
-- sandbox XDG save dir (portable USB mode does not apply). A read-only
|
||||
-- parent (system /opt, immutable OS, etc.) falls back to LOVE's save
|
||||
-- directory without throwing -- probe I/O is fully guarded.
|
||||
local function dirIsWritable(dir)
|
||||
if type(dir) ~= "string" or dir == "" then return false end
|
||||
local ok, writable = pcall(function()
|
||||
local name = string.format(".write_probe_%d_%d.tmp",
|
||||
os.time() % 100000000, math.random(0, 999999))
|
||||
local path = dir .. SEP .. name
|
||||
local f, err = io.open(path, "wb")
|
||||
if not f then return false end
|
||||
local wrote = f:write("ok")
|
||||
f:close()
|
||||
pcall(os.remove, path)
|
||||
return wrote ~= nil
|
||||
end)
|
||||
return ok and writable == true
|
||||
end
|
||||
|
||||
local function detectPortable()
|
||||
if portableChecked then return portableBase end
|
||||
portableChecked = true
|
||||
portableBase = false
|
||||
-- Flatpak: never honor portable.txt (sandbox home is the persistence root).
|
||||
if type(os.getenv) == "function" and os.getenv("FLATPAK_ID") then
|
||||
return portableBase
|
||||
end
|
||||
for _, base in ipairs(SaveData.gameFolders()) do
|
||||
if pathExists(base .. SEP .. PORTABLE_MARKER) then
|
||||
local markerOk = false
|
||||
pcall(function()
|
||||
markerOk = pathExists(base .. SEP .. PORTABLE_MARKER)
|
||||
end)
|
||||
if markerOk and dirIsWritable(base) then
|
||||
portableBase = base
|
||||
break
|
||||
end
|
||||
@@ -187,6 +214,13 @@ local function detectPortable()
|
||||
return portableBase
|
||||
end
|
||||
|
||||
-- Soft-reset cache so tests can re-run detect after env changes.
|
||||
function SaveData._resetPortableCacheForTests()
|
||||
portableChecked = false
|
||||
portableBase = false
|
||||
portableFsCache = nil
|
||||
end
|
||||
|
||||
function SaveData.isPortable()
|
||||
return detectPortable() ~= false
|
||||
end
|
||||
@@ -279,6 +313,7 @@ function SaveData.defaultOptions()
|
||||
zoom = 0,
|
||||
-- OVERWORLD beyond-edge fill: trees | water | black
|
||||
voidFill = "trees",
|
||||
uiLetterbox = "auto",
|
||||
-- windowed | borderless (desktop fullscreen); ignored on mobile
|
||||
videoMode = "windowed",
|
||||
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
|
||||
|
||||
@@ -30,6 +30,8 @@ function SessionLifecycle.endMountedSession(version)
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
if Runtime.reset then Runtime.reset() end
|
||||
if Assets.installLoader then Assets.installLoader(nil) end
|
||||
local Loader = package.loaded["src.mods.Loader"]
|
||||
if Loader and Loader.endSession then Loader.endSession() end
|
||||
local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat")
|
||||
if okCompat and LegacyCompat.reset then LegacyCompat.reset() end
|
||||
end
|
||||
@@ -64,11 +66,18 @@ end
|
||||
|
||||
-- EXIT GAME / intent_game before dropping Game. Stops audio and resets the
|
||||
-- live game instance so map/GPU holders are gone before endMountedSession.
|
||||
--
|
||||
-- ChipAudio's worker stays alive across game sessions (see tier comment
|
||||
-- above). shutdown() joins the thread and is process-exit only -- calling
|
||||
-- it here on every Android EXIT GAME has been observed to leave the Gen1
|
||||
-- Game singleton unbootable (Game.load nil) on the next Play of a version
|
||||
-- already opened this process, while a debug APK with a different liblove
|
||||
-- did not reproduce.
|
||||
function SessionLifecycle.endGameSession(game)
|
||||
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)
|
||||
pcall(package.loaded["src.core.ChipAudio"].stopMusic)
|
||||
end
|
||||
if package.loaded["src.core.DiscordPresence"] then
|
||||
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
|
||||
@@ -87,11 +96,11 @@ function SessionLifecycle.endGameSession(game)
|
||||
if game and game.reset then
|
||||
pcall(function() game:reset() end)
|
||||
end
|
||||
-- Gen1 Game is the module singleton. If teardown left it without load,
|
||||
-- drop the cached module so the next require rebuilds a clean table
|
||||
-- (bootGame also guards this; doing it here keeps Play-again reliable).
|
||||
if game and type(game.load) ~= "function"
|
||||
and package.loaded["src.core.Game"] == game then
|
||||
-- Gen1 Game is the module singleton. Always drop the cached module after
|
||||
-- a session that owned it so the next bootGame require rebuilds a clean
|
||||
-- table. A type(game.load) check is not enough: a wrong table parked in
|
||||
-- package.loaded (e.g. with __index) can still look like it has load.
|
||||
if game and package.loaded["src.core.Game"] == game then
|
||||
package.loaded["src.core.Game"] = nil
|
||||
end
|
||||
|
||||
|
||||
+15
-3
@@ -22,10 +22,22 @@ local Version = {
|
||||
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
|
||||
}
|
||||
|
||||
-- "gen1recomp v0.0.0-dev" (or the stamped release version in shipped builds)
|
||||
-- True for the working-tree placeholder and any stamped "-dev" pre-release.
|
||||
-- Shipped builds get a bare X.Y.Z from CI and are not "dev" here.
|
||||
function Version.isDev()
|
||||
local engine = tostring(Version.engine or "")
|
||||
return engine == "0.0.0-dev" or engine:find("%-dev", 1) ~= nil
|
||||
end
|
||||
|
||||
-- Window / chrome title. Dev builds keep the version visible
|
||||
-- ("gen1recomp v0.0.0-dev"); release builds are just the base name so Linux
|
||||
-- window chrome and taskbars do not read "gen1recomp 0.1.73".
|
||||
function Version.title(base)
|
||||
return (base or "gen1recomp")
|
||||
.. " v" .. Version.engine
|
||||
base = base or "gen1recomp"
|
||||
if Version.isDev() then
|
||||
return base .. " v" .. Version.engine
|
||||
end
|
||||
return base
|
||||
end
|
||||
|
||||
return Version
|
||||
|
||||
@@ -1101,8 +1101,10 @@ function Breeding.hatch(data, save, index, nickname, where)
|
||||
hatched.experience = egg.experience
|
||||
-- `ld a, [de] / ld [hli], a` twice: HP := MaxHP.
|
||||
hatched.hp = hatched.maxHp
|
||||
hatched.ot = egg.ot or (save.player and save.player.name)
|
||||
hatched.otId = egg.otId or (save.player and save.player.id)
|
||||
-- HatchEggs overwrites OT unconditionally, so the ODD_EGG's "ODD" and its
|
||||
-- table id do not survive -- engine/pokemon/breeding.asm:299-309
|
||||
hatched.ot = (save.player and save.player.name) or egg.ot
|
||||
hatched.otId = (save.player and save.player.id) or egg.otId
|
||||
hatched.caughtLevel = egg.level or Breeding.EGG_LEVEL
|
||||
-- SetEggMonCaughtData swaps wCurPartyLevel for CAUGHT_EGG_LEVEL around the
|
||||
-- shared setter -- engine/pokemon/caught_data.asm:235-246.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Projects a mod's `pokemon` registry dexEntry onto the #DEX screen's own
|
||||
-- data table.
|
||||
--
|
||||
-- data.gen2Pokedex.entries[species] (data/generated/pokedex.lua,
|
||||
-- RomExtractorGen2:extractPokedex) is what src/ui/gen2/PokedexMenu.lua
|
||||
-- actually reads for the KIND label and the two description pages
|
||||
-- (entry.kind, entry.text, entry.text2) -- a table loaded straight from
|
||||
-- disk in Game2:load, BEFORE mods:load runs, and never routed through a
|
||||
-- registry. The `pokemon` registry's own merge target is the separate
|
||||
-- data.pokemon table (src/mods/Schemas.lua, R.pokemon: `target = "pokemon"`),
|
||||
-- which mod.content.pokemon:patch(id, { dexEntry = { kind = ..., text = ...,
|
||||
-- text2 = ... } }) already reaches -- so a translation mod's #DEX text was
|
||||
-- silently invisible in-game despite validating against the registry.
|
||||
--
|
||||
-- This is the missing link: called once after the merge (src/core/Game2.lua),
|
||||
-- the same way ItemEffects.applyHeldItems projects held_items onto
|
||||
-- data.gen2HeldItems. height/weight/dex stay untouched -- game state, not
|
||||
-- text a translation carries.
|
||||
local PokedexText = {}
|
||||
|
||||
function PokedexText.apply(data)
|
||||
local dex = data and data.gen2Pokedex
|
||||
local pokemon = data and data.pokemon
|
||||
if not (dex and dex.entries and pokemon) then return 0 end
|
||||
local count = 0
|
||||
for species, entry in pairs(dex.entries) do
|
||||
local def = pokemon[species]
|
||||
local override = def and def.dexEntry
|
||||
if override and (override.kind or override.text or override.text2) then
|
||||
if override.kind then entry.kind = override.kind end
|
||||
if override.text then entry.text = override.text end
|
||||
if override.text2 then entry.text2 = override.text2 end
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
return PokedexText
|
||||
@@ -309,6 +309,7 @@ Save.DEFAULT_OPTIONS = {
|
||||
-- Game Boy. The Gen 1 save's equivalent key is `colors` (SGB packs), which
|
||||
-- means something different, hence the different name.
|
||||
color = "gbc",
|
||||
palette = "",
|
||||
videoMode = "windowed",
|
||||
fpsCap = 60,
|
||||
-- BATTLE BG (#1709): white | black, the surround around the battle screen.
|
||||
@@ -316,6 +317,7 @@ Save.DEFAULT_OPTIONS = {
|
||||
-- VOID FILL: fade | water | trees | black. fade is each map header's own
|
||||
-- border block with the dissolve across a boundary (#1418).
|
||||
voidFill = "fade",
|
||||
uiLetterbox = "auto",
|
||||
musicVol = 7, -- 0-7, like the GB's NR50 master volume
|
||||
sfxVol = 7, -- 0-7
|
||||
musicFilter = 0, -- low-pass steps, 0 = off
|
||||
|
||||
@@ -7,6 +7,28 @@ local SHADES = {
|
||||
{ 0, 0, 0, 1 },
|
||||
}
|
||||
|
||||
ImageWriter.SHADES = SHADES
|
||||
|
||||
-- Title screen rOBP0 = %11100000 ($E0): OBJ shades 1 and 2 draw as white,
|
||||
-- shade 3 as black (pokeyellow engine/movie/title.asm after PlacePikachu).
|
||||
-- Eye OAM is baked into the MEWMON-colored BG PNG; without this remap the
|
||||
-- shade-1 glints become body yellow under the title palette.
|
||||
function ImageWriter.applyTitleObp0(image)
|
||||
local mid, dark = SHADES[2][1], SHADES[3][1]
|
||||
local w, h = image:getWidth(), image:getHeight()
|
||||
for y = 0, h - 1 do
|
||||
for x = 0, w - 1 do
|
||||
local r, g, b, a = image:getPixel(x, y)
|
||||
if a ~= 0 then
|
||||
if math.abs(r - mid) < 0.02 or math.abs(r - dark) < 0.02 then
|
||||
image:setPixel(x, y, 1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return image
|
||||
end
|
||||
|
||||
local function assertDimensions(raw, width, height, bits)
|
||||
assert(width % 8 == 0 and height % 8 == 0,
|
||||
("%dbpp dimensions must be tile-aligned: %dx%d")
|
||||
|
||||
@@ -605,6 +605,7 @@ local function gen2Rows(opts, hooks, shared)
|
||||
end
|
||||
opts.color =
|
||||
GbcPalette.MODES[wrapIndex(idx - 1 + dir, #GbcPalette.MODES) + 1]
|
||||
opts.palette = ""
|
||||
return true
|
||||
end)
|
||||
end
|
||||
|
||||
+88
-13
@@ -377,12 +377,19 @@ end
|
||||
-- visible while typing, and blinks a caret on the importer's pulse clock.
|
||||
local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, action)
|
||||
Kit._audit("control", x, y, w, h, key)
|
||||
Kit.focusable(key, x, y, w, h)
|
||||
local isFocused = Kit.focusable(key, x, y, w, h)
|
||||
Theme.fill(x, y, w, h, PAL.bg, 1)
|
||||
Theme.stroke(x, y, w, h, PAL.line,
|
||||
focused and Theme.A.focus or
|
||||
(Kit.hover(x, y, w, h) and Theme.A.hover or Theme.A.hairline),
|
||||
focused and 2 or 1)
|
||||
if isFocused then
|
||||
local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
|
||||
Theme.strokeRounded(x - 3, y - 3, w + 6, h + 6, PAL.ink, 0.35 + 0.25 * glowPulse, 2.5, 4)
|
||||
Theme.strokeRounded(x, y, w, h, PAL.ink, 0.95 + 0.05 * glowPulse, 2, 2)
|
||||
Theme.fillRounded(x, y, w, h, PAL.ink, 0.12 + 0.06 * glowPulse, 2)
|
||||
else
|
||||
Theme.stroke(x, y, w, h, PAL.line,
|
||||
focused and Theme.A.focus or
|
||||
(Kit.hover(x, y, w, h) and Theme.A.hover or Theme.A.hairline),
|
||||
focused and 2 or 1)
|
||||
end
|
||||
local pad = math.floor(10 * Kit.scale)
|
||||
local ty = y + (h - Kit.textHeight("button")) / 2
|
||||
local text = rawText or ""
|
||||
@@ -397,8 +404,35 @@ local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, ac
|
||||
Kit.textHeight("button"), PAL.ink, 1)
|
||||
end
|
||||
end
|
||||
if action and (Kit.press(x, y, w, h) or Kit._activateId == key) then
|
||||
queueAction(imp, key, action)
|
||||
if (Kit.press(x, y, w, h) or Kit._activateId == key) then
|
||||
if Kit.VirtualKeyboard then
|
||||
Kit.VirtualKeyboard.open({
|
||||
text = text,
|
||||
targetId = key,
|
||||
title = placeholder or "Enter Text",
|
||||
onDone = function(newText, confirmed)
|
||||
if confirmed then
|
||||
if imp._indexPrompt and key:find("index") then
|
||||
imp._indexPrompt.text = newText
|
||||
elseif imp._rename and key:find("rename") then
|
||||
imp._rename.text = newText
|
||||
elseif imp._profileRenamePrompt and key:find("profren") then
|
||||
imp._profileRenamePrompt.text = newText
|
||||
elseif imp._profileSavePrompt and key:find("profsave") then
|
||||
imp._profileSavePrompt.text = newText
|
||||
elseif imp._settingsText and key:find("settext") then
|
||||
imp._settingsText.text = newText
|
||||
elseif imp.tab == "find" then
|
||||
imp._findQuery = newText
|
||||
if imp._refreshFind then imp:_refreshFind() end
|
||||
end
|
||||
end
|
||||
end
|
||||
})
|
||||
end
|
||||
if action then
|
||||
queueAction(imp, key, action)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1360,7 +1394,9 @@ local function buildHeader(imp, m)
|
||||
-- under the gear and the quit X -- "the settings is covering the logo".
|
||||
-- Reserving the space on both sides costs a little width and cannot
|
||||
-- overlap at any window size.
|
||||
local clusterW = 2 * gear + math.floor(6 * m.s) + m.pad
|
||||
-- iOS has no quit button (the OS owns app exit), so the cluster is the
|
||||
-- gear alone and the wordmark gets that width back
|
||||
local clusterW = (imp.ios and gear or 2 * gear + math.floor(6 * m.s)) + m.pad
|
||||
local boxX = m.x + clusterW
|
||||
local boxW = math.max(0, m.w - 2 * clusterW)
|
||||
if imp.logo and boxW > 0 then
|
||||
@@ -1394,8 +1430,8 @@ local function buildHeader(imp, m)
|
||||
-- inboard of it -- but the two are REGISTERED gear first, because the first
|
||||
-- focusable of the first frame adopts the keyboard ring and that must not be
|
||||
-- the button that exits the app.
|
||||
local quitX = rx - gear
|
||||
rx = quitX - math.floor(6 * m.s)
|
||||
local quitX = not imp.ios and rx - gear or nil
|
||||
if quitX then rx = quitX - math.floor(6 * m.s) end
|
||||
|
||||
-- Settings gear. It now also owns the CONTROL settings (touch overlay
|
||||
-- editor, reset rebinds), which used to be buttons stacked in the game
|
||||
@@ -1407,7 +1443,7 @@ local function buildHeader(imp, m)
|
||||
chrome.gear.image = imp._gearIcon
|
||||
btn(imp, rx, by, gear, gear, "gear", "", chrome.gear)
|
||||
|
||||
btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit)
|
||||
if quitX then btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit) end
|
||||
|
||||
-- The self-update control lives in the FOOTER next to the BCG mark (small,
|
||||
-- out of the wordmark's way -- it used to overlap the logo on a phone). It
|
||||
@@ -3406,13 +3442,19 @@ local function buildFooter(imp, m, y)
|
||||
local bx = m.x + math.floor((m.w - topW) / 2)
|
||||
local my = cy + math.floor((rowH - dh) / 2)
|
||||
local chipY = cy + math.floor((rowH - chipH) / 2)
|
||||
local isFocused = Kit.focusable("bcg", bx, my, dw, dh)
|
||||
local hot = Kit.hover(bx, my, dw, dh)
|
||||
if isFocused then
|
||||
local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
|
||||
Theme.strokeRounded(bx - 3, my - 3, dw + 6, dh + 6, PAL.ink, 0.35 + 0.25 * glowPulse, 2.5, 4)
|
||||
Theme.strokeRounded(bx, my, dw, dh, PAL.ink, 0.95 + 0.05 * glowPulse, 2, 2)
|
||||
end
|
||||
love.graphics.setShader(imp.invertShader)
|
||||
love.graphics.setColor(1, 1, 1, hot and 1 or 0.85)
|
||||
love.graphics.setColor(1, 1, 1, (hot or isFocused) and 1 or 0.85)
|
||||
love.graphics.draw(imp.bcg, Theme.snap(bx), Theme.snap(my), 0, scale, scale)
|
||||
love.graphics.setShader()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if Kit.press(bx, my, dw, dh) then
|
||||
if Kit.press(bx, my, dw, dh) or Kit._activateId == "bcg" then
|
||||
queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end)
|
||||
end
|
||||
local cx = bx + dw
|
||||
@@ -5604,6 +5646,8 @@ end
|
||||
-- a click on the scrim lands on whatever button happens to be behind it.
|
||||
-- Keep this list in sync with buildModals below.
|
||||
local function modalUp(imp)
|
||||
if Kit.FileBrowser and Kit.FileBrowser.active then return true end
|
||||
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then return true end
|
||||
return (imp._settingsText or imp._settings or imp._rename
|
||||
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
|
||||
or imp._appPatchNotes
|
||||
@@ -5616,6 +5660,14 @@ local function modalUp(imp)
|
||||
end
|
||||
|
||||
local function buildModals(imp, m)
|
||||
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then
|
||||
Kit.VirtualKeyboard.draw(m)
|
||||
return true
|
||||
end
|
||||
if Kit.FileBrowser and Kit.FileBrowser.active then
|
||||
Kit.FileBrowser.draw(m)
|
||||
return true
|
||||
end
|
||||
if imp._profileRenamePrompt then
|
||||
buildPrompt(imp, m, {
|
||||
key = "profren", title = Strings("Rename profile"),
|
||||
@@ -5787,6 +5839,10 @@ end
|
||||
|
||||
local function drawPadCursor(imp)
|
||||
if not imp._padCursorActive then return end
|
||||
if (Kit.FileBrowser and Kit.FileBrowser.active)
|
||||
or (Kit.VirtualKeyboard and Kit.VirtualKeyboard.active) then
|
||||
return
|
||||
end
|
||||
-- Pixel-snap on NX: subpixel polygon edges shimmer on the 720p Switch
|
||||
-- framebuffer when the stick advances by fractional pixels each frame.
|
||||
local x, y = imp._padCursor.x, imp._padCursor.y
|
||||
@@ -5970,6 +6026,25 @@ function LauncherView.draw(imp)
|
||||
|
||||
Kit.endFrame()
|
||||
drawPadCursor(imp)
|
||||
|
||||
if imp._cursorModeToast and imp._cursorModeToastTime then
|
||||
local elapsed = love.timer.getTime() - imp._cursorModeToastTime
|
||||
if elapsed < 2.2 then
|
||||
local alpha = 1.0
|
||||
if elapsed > 1.7 then alpha = math.max(0, (2.2 - elapsed) / 0.5) end
|
||||
local msg = imp._cursorModeToast
|
||||
local tw = Kit.textWidth("small", msg) + 36 * m.s
|
||||
local th = 34 * m.s
|
||||
local tx = (m.W - tw) / 2
|
||||
local ty = 16 * m.s
|
||||
Theme.fillRounded(tx, ty, tw, th, PAL.surface, 0.95 * alpha, 6)
|
||||
Theme.strokeRounded(tx - 2, ty - 2, tw + 4, th + 4, PAL.railBlue, 0.35 * alpha, 2, 8)
|
||||
Theme.strokeRounded(tx, ty, tw, th, PAL.lineStrong, 0.85 * alpha, 1.5, 6)
|
||||
Kit.textCenterBold("small", msg, tx, ty + 8 * m.s, tw, PAL.heading)
|
||||
else
|
||||
imp._cursorModeToast = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return LauncherView
|
||||
|
||||
@@ -1535,6 +1535,14 @@ function RomExtractor:extractYellowTitleArt()
|
||||
local bg = sheetTiles("TitlePikachuBGGraphics", 64)
|
||||
local ob = sheetTiles("TitlePikachuOBGraphics", 12)
|
||||
local obClear = sheetTiles("TitlePikachuOBGraphics", 12, true)
|
||||
-- Title rOBP0=$E0: remap eye OAM shades 1/2 → white before baking into the
|
||||
-- MEWMON BG composition (otherwise glints read as body yellow).
|
||||
local eyeOb = {}
|
||||
for i, tile in ipairs(obClear) do
|
||||
local copy = ImageWriter.blank(8, 8, 0, 0, 0, 0)
|
||||
ImageWriter.blit(copy, tile, 0, 0)
|
||||
eyeOb[i] = ImageWriter.applyTitleObp0(copy)
|
||||
end
|
||||
local function tileFor(id)
|
||||
if id < 0x80 then return logo[id + 1] end
|
||||
if id < 0xF0 then return bg[id - 0x80 + 1] end
|
||||
@@ -1623,13 +1631,13 @@ function RomExtractor:extractYellowTitleArt()
|
||||
local overlay = ImageWriter.blank(48, 16, 1, 1, 1, 0)
|
||||
ImageWriter.blit(overlay, pikachu, 0, 0, 24, 16, 48, 16)
|
||||
for _, e in ipairs(EYE_LAYOUT) do
|
||||
blitSprite(overlay, obClear[base + e[1]], e[2] - 24, e[3] - 16, e[4])
|
||||
blitSprite(overlay, eyeOb[base + e[1]], e[2] - 24, e[3] - 16, e[4])
|
||||
end
|
||||
overlays[suffix] = overlay
|
||||
end
|
||||
-- open eyes bake into pikachu.png AFTER the blank-face crops
|
||||
for _, e in ipairs(EYE_LAYOUT) do
|
||||
blitSprite(pikachu, obClear[e[1]], e[2], e[3], e[4])
|
||||
blitSprite(pikachu, eyeOb[e[1]], e[2], e[3], e[4])
|
||||
end
|
||||
self:save(pikachu, "title/pikachu.png")
|
||||
for suffix, overlay in pairs(overlays) do
|
||||
|
||||
@@ -2753,7 +2753,8 @@ function RomExtractorGen2:extractAudio(maps)
|
||||
local speciesOrder = self.manifest.constants.speciesOrder or {}
|
||||
local cries = {}
|
||||
for index, species in ipairs(speciesOrder) do
|
||||
if species ~= "UNOWN" and not tostring(species):match("^UNUSED") then
|
||||
-- data/pokemon/cries.asm:209 gives UNOWN a real cry row
|
||||
if not tostring(species):match("^UNUSED") then
|
||||
local row = self.rom:bytes(
|
||||
pokeCryPtr.bank, pokeCryPtr.address + (index - 1) * 6, 6)
|
||||
local cryIndex = row[1] + row[2] * 256
|
||||
|
||||
+492
-39
@@ -9,6 +9,9 @@ local SafeArea = require("src.core.SafeArea")
|
||||
local RomImporter = {}
|
||||
RomImporter.__index = RomImporter
|
||||
|
||||
local PICK_COMPLETE_FILENAME = "pick_complete.flag"
|
||||
local finishDirectRequiredImport
|
||||
|
||||
-- love.system.pickFile is a NATIVE BRIDGE, not part of LÖVE: it exists only on
|
||||
-- builds that compiled one (Android, and iOS builds patched by
|
||||
-- mobile/ios/patch_love_src.py). A build without it must fall back to the
|
||||
@@ -1260,7 +1263,12 @@ end
|
||||
-- up the background worker or reach out to the network.
|
||||
local function updaterAllowed()
|
||||
if not Platform.networkValidated() then return false end
|
||||
if not (love.filesystem.isFused and love.filesystem.isFused()) then return false end
|
||||
local isHandheld = 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"
|
||||
if not (love.filesystem.isFused and love.filesystem.isFused()) and not isHandheld then
|
||||
return false
|
||||
end
|
||||
if os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER") then return false end
|
||||
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
|
||||
return true
|
||||
@@ -1582,8 +1590,11 @@ function RomImporter:focus(f)
|
||||
.. love.filesystem.getSaveDirectory()
|
||||
local legacyRequiredPick = self.requiredImportLegacyRomPick
|
||||
and self.pickerPendingKind == "required_import"
|
||||
if pickError:find("picked_required_import", 1, true)
|
||||
if self.pickerPendingKind == "required_import"
|
||||
or pickError:find("picked_required_import", 1, true)
|
||||
or pickError:find("picked_stadium", 1, true)
|
||||
or pickError:find("/baseroms/", 1, true)
|
||||
or pickError:find("\\baseroms\\", 1, true)
|
||||
or (legacyRequiredPick and pickError:find("picked_rom", 1, true)) then
|
||||
self.modNotice = { ok = false, text = text }
|
||||
self.pickerPendingKind = nil
|
||||
@@ -1606,6 +1617,22 @@ function RomImporter:focus(f)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- Current mobile bridge: the native picker has already streamed a raw
|
||||
-- dependency into mods/<id>/baseroms and published its digest/size marker.
|
||||
local completedRequired = love.filesystem.getInfo(PICK_COMPLETE_FILENAME, "file")
|
||||
and love.filesystem.read(PICK_COMPLETE_FILENAME)
|
||||
if completedRequired then
|
||||
love.filesystem.remove(PICK_COMPLETE_FILENAME)
|
||||
self.pickPending = nil
|
||||
finishDirectRequiredImport(self, completedRequired)
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingModId = nil
|
||||
self.pickerPendingImportId = nil
|
||||
self.requiredImportLegacyRomPick = nil
|
||||
return
|
||||
end
|
||||
|
||||
local requiredName = findPendingRequiredImport(self)
|
||||
if requiredName then
|
||||
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
|
||||
@@ -2033,8 +2060,42 @@ function RomImporter:chooseMod()
|
||||
end
|
||||
return
|
||||
end
|
||||
local isHandheld = 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"
|
||||
|
||||
if isHandheld then
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit and Kit.FileBrowser then
|
||||
self._padCursorActive = false
|
||||
Kit.FileBrowser.open({
|
||||
title = "Select Mod (.zip)",
|
||||
mode = "mod",
|
||||
onSelect = function(pickedPath)
|
||||
self:_installMod(pickedPath)
|
||||
end,
|
||||
})
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local path = chooseZip()
|
||||
if path then self:_installMod(path) end
|
||||
if path then
|
||||
self:_installMod(path)
|
||||
return
|
||||
end
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit and Kit.FileBrowser then
|
||||
self._padCursorActive = false
|
||||
Kit.FileBrowser.open({
|
||||
title = "Select Mod (.zip)",
|
||||
mode = "mod",
|
||||
onSelect = function(pickedPath)
|
||||
self:_installMod(pickedPath)
|
||||
end,
|
||||
})
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local function requiredManifest(self, modId)
|
||||
@@ -2059,6 +2120,122 @@ local function requiredImportNotice(self, modId, importId, text)
|
||||
}
|
||||
end
|
||||
|
||||
-- Current Android/iOS bridges can stream a raw required import directly from
|
||||
-- the system document provider into its engine-owned baseroms destination.
|
||||
-- The bridge hashes/counts the same bytes and then publishes this tiny marker:
|
||||
-- v1\n<relative destination>\n<md5>\n<byte count>\n
|
||||
-- Older mobile builds still stage picked_required_import.bin, so this is an
|
||||
-- additive completion path rather than a replacement for the legacy one.
|
||||
local function directRequiredImportTarget(self, marker)
|
||||
local fields = {}
|
||||
for line in tostring(marker or ""):gmatch("[^\r\n]+") do
|
||||
fields[#fields + 1] = line
|
||||
if #fields > 4 then break end
|
||||
end
|
||||
local version, path, digest, count = fields[1], fields[2], fields[3], fields[4]
|
||||
if version ~= "v1" or not path or not digest or not count or #digest ~= 32
|
||||
or not digest:match("^%x+$") or not count:match("^%d+$") then
|
||||
return nil, "The completed dependency marker was malformed."
|
||||
end
|
||||
digest = digest:lower()
|
||||
count = tonumber(count)
|
||||
if not count then return nil, "The completed dependency size was invalid." end
|
||||
|
||||
if not self.mods and self._refreshMods then pcall(self._refreshMods, self) end
|
||||
local RequiredImports = require("src.mods.RequiredImports")
|
||||
|
||||
local function matchRow(row, wantedImportId)
|
||||
local manifest = row and row.manifest
|
||||
if not manifest then return nil end
|
||||
for _, spec in ipairs(RequiredImports.specs(manifest)) do
|
||||
if (not wantedImportId or spec.id == wantedImportId)
|
||||
and RequiredImports.path(manifest, spec) == path then
|
||||
return manifest, spec, row.id or manifest.id
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Normal resume: bind the marker to exactly the request that opened picker.
|
||||
if self.pickerPendingModId and self.pickerPendingImportId then
|
||||
for _, row in ipairs(self.mods or {}) do
|
||||
if row.id == self.pickerPendingModId then
|
||||
local manifest, spec, modId = matchRow(row, self.pickerPendingImportId)
|
||||
if manifest then return path, digest, count, manifest, spec, modId end
|
||||
end
|
||||
end
|
||||
return nil, "The completed dependency did not match the pending import request."
|
||||
end
|
||||
|
||||
-- Android may recreate GameActivity while DocumentsUI is open. If Lua was
|
||||
-- restarted too, recover by the exact engine-owned destination. The path
|
||||
-- includes the mod id and only a manifest-declared import can match it.
|
||||
for _, row in ipairs(self.mods or {}) do
|
||||
local manifest, spec, modId = matchRow(row)
|
||||
if manifest then return path, digest, count, manifest, spec, modId end
|
||||
end
|
||||
return nil, "The completed dependency did not match an installed mod request."
|
||||
end
|
||||
|
||||
finishDirectRequiredImport = function(self, marker)
|
||||
local path, digestOrErr, nativeSize, manifest, spec, modId =
|
||||
directRequiredImportTarget(self, marker)
|
||||
if not path then
|
||||
self.modNotice = { ok = false, text = tostring(digestOrErr) }
|
||||
return nil
|
||||
end
|
||||
local digest = digestOrErr
|
||||
local RequiredImports = require("src.mods.RequiredImports")
|
||||
local info = love.filesystem.getInfo(path, "file")
|
||||
if not info or type(info.size) ~= "number" then
|
||||
requiredImportNotice(self, modId, spec.id,
|
||||
"The completed dependency file was not found.")
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
if info.size ~= nativeSize then
|
||||
love.filesystem.remove(path)
|
||||
if RequiredImports.receiptPath then
|
||||
love.filesystem.remove(RequiredImports.receiptPath(manifest, spec))
|
||||
end
|
||||
requiredImportNotice(self, modId, spec.id,
|
||||
("Dependency copy size changed after completion (expected %d bytes, found %d).")
|
||||
:format(nativeSize, info.size))
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
local sizeErr = RequiredImports.sizeError(spec, info.size, true)
|
||||
if sizeErr then
|
||||
love.filesystem.remove(path)
|
||||
if RequiredImports.receiptPath then
|
||||
love.filesystem.remove(RequiredImports.receiptPath(manifest, spec))
|
||||
end
|
||||
requiredImportNotice(self, modId, spec.id, sizeErr)
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
|
||||
-- No second 665 MiB / 1.46 GiB read: native calculated this digest while
|
||||
-- streaming the selected document into the one final copy.
|
||||
local ok, detail = RequiredImports.acceptStoredDigest(
|
||||
manifest, spec.id, digest, love.filesystem)
|
||||
if not ok then
|
||||
love.filesystem.remove(path)
|
||||
if RequiredImports.receiptPath then
|
||||
love.filesystem.remove(RequiredImports.receiptPath(manifest, spec))
|
||||
end
|
||||
requiredImportNotice(self, modId, spec.id, detail)
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
|
||||
self.requiredImportNotice = nil
|
||||
self.modNotice = { ok = true, text = "Imported " .. tostring(spec.id)
|
||||
.. " for " .. tostring(manifest.name or manifest.id) .. "." }
|
||||
self:_refreshMods()
|
||||
return true
|
||||
end
|
||||
|
||||
function RomImporter:_importRequiredData(modId, importId, data)
|
||||
local manifest = requiredManifest(self, modId)
|
||||
if not manifest then
|
||||
@@ -2119,8 +2296,8 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed)
|
||||
}
|
||||
return nil
|
||||
end
|
||||
if type(size) == "number" and size > RequiredImports.LARGE_WARN_BYTES
|
||||
and spec.format ~= "n64" then
|
||||
if spec.format ~= "n64" and (size == nil
|
||||
or size > RequiredImports.LARGE_WARN_BYTES) then
|
||||
local ok, result = streamRequiredImport(manifest, importId, source)
|
||||
if ok then
|
||||
self.requiredImportNotice = nil
|
||||
@@ -2218,7 +2395,15 @@ function RomImporter:chooseRequiredImport(modId, importId)
|
||||
self.pickerPendingModId = modId
|
||||
self.pickerPendingImportId = importId
|
||||
self.requiredImportLegacyRomPick = legacyAndroidPicker or nil
|
||||
if not pickFile(legacyAndroidPicker and "rom" or "required_import") then
|
||||
-- Raw imports can go straight to their final private destination on current
|
||||
-- Android/iOS bridges. N64 stays on staging because the launcher still has
|
||||
-- to canonicalize byte order/copier headers before storing it.
|
||||
local directDestination = (self.mobileFileBridge and not legacyAndroidPicker
|
||||
and spec.format ~= "n64"
|
||||
and type(manifest.path) == "string" and manifest.path ~= "")
|
||||
and require("src.mods.RequiredImports").path(manifest, spec) or nil
|
||||
if not pickFile(legacyAndroidPicker and "rom" or "required_import",
|
||||
directDestination) then
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingModId = nil
|
||||
self.pickerPendingImportId = nil
|
||||
@@ -2333,8 +2518,42 @@ function RomImporter:chooseSaveImport(version)
|
||||
end
|
||||
return
|
||||
end
|
||||
local isHandheld = 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"
|
||||
|
||||
if isHandheld then
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit and Kit.FileBrowser then
|
||||
self._padCursorActive = false
|
||||
Kit.FileBrowser.open({
|
||||
title = "Select Save (.sav)",
|
||||
mode = "save",
|
||||
onSelect = function(pickedPath)
|
||||
self:_importSave(version, pickedPath)
|
||||
end,
|
||||
})
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local path = chooseSav()
|
||||
if path then self:_importSave(version, path) end
|
||||
if path then
|
||||
self:_importSave(version, path)
|
||||
return
|
||||
end
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit and Kit.FileBrowser then
|
||||
self._padCursorActive = false
|
||||
Kit.FileBrowser.open({
|
||||
title = "Select Save (.sav)",
|
||||
mode = "save",
|
||||
onSelect = function(pickedPath)
|
||||
self:_importSave(version, pickedPath)
|
||||
end,
|
||||
})
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- "Export save" button: write the active slot back out to a raw .sav in the save
|
||||
@@ -2475,11 +2694,42 @@ function RomImporter:choose(version)
|
||||
end
|
||||
return
|
||||
end
|
||||
local isHandheld = 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"
|
||||
|
||||
if isHandheld then
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit and Kit.FileBrowser then
|
||||
self._padCursorActive = false
|
||||
Kit.FileBrowser.open({
|
||||
title = "Select " .. (GameVersion.info(self.chooseVersion).displayName or "ROM"),
|
||||
mode = "rom",
|
||||
onSelect = function(pickedPath)
|
||||
self:startPath(pickedPath)
|
||||
end,
|
||||
})
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local path = chooseRom(GameVersion.info(self.chooseVersion).displayName)
|
||||
if path then
|
||||
self:startPath(path)
|
||||
return
|
||||
end
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit and Kit.FileBrowser then
|
||||
self._padCursorActive = false
|
||||
Kit.FileBrowser.open({
|
||||
title = "Select " .. (GameVersion.info(self.chooseVersion).displayName or "ROM"),
|
||||
mode = "rom",
|
||||
onSelect = function(pickedPath)
|
||||
self:startPath(pickedPath)
|
||||
end,
|
||||
})
|
||||
return
|
||||
end
|
||||
-- Handheld Linux (Anbernic stock OS / PortMaster) rarely has zenity or
|
||||
-- kdialog. Fall back to the same "drop a .gb/.gbc next to the game" scan
|
||||
-- used on Android, which works when the game is launched as an unpacked
|
||||
@@ -2549,6 +2799,8 @@ function RomImporter:_pollPickedFiles(dt)
|
||||
return
|
||||
end
|
||||
local found = love.filesystem.getInfo("export_done.flag", "file") ~= nil
|
||||
or love.filesystem.getInfo("pick_error.flag", "file") ~= nil
|
||||
or love.filesystem.getInfo(PICK_COMPLETE_FILENAME, "file") ~= nil
|
||||
if not found then
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||
local n = name:lower()
|
||||
@@ -2850,6 +3102,14 @@ function RomImporter:_cycleTab(delta)
|
||||
end
|
||||
|
||||
function RomImporter:_updatePadCursor(dt)
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit then
|
||||
if (Kit.FileBrowser and Kit.FileBrowser.active)
|
||||
or (Kit.VirtualKeyboard and Kit.VirtualKeyboard.active) then
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
if self.isNX then
|
||||
self:_ensureNxPointerBridge()
|
||||
-- Cap dt so a hitch in the FlexLove immediate-mode frame does not fling
|
||||
@@ -2892,14 +3152,6 @@ function RomImporter:_updatePadCursor(dt)
|
||||
local ny = self._padCursor.y + dy * speed * dt
|
||||
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
|
||||
self._padCursor.y = math.max(oy, math.min(oy + h, ny))
|
||||
-- Pushing INTO the top/bottom edge scrolls the page instead of stalling.
|
||||
-- The cursor is clamped to the safe area above, so on a short window the
|
||||
-- rows below the fold are unreachable on a stickless handheld: no mouse
|
||||
-- wheel, no touchscreen, and no right stick to feed the existing wheel
|
||||
-- path. Only the OVERSHOOT scrolls -- parking the cursor at the edge does
|
||||
-- nothing, it has to be actively pushed -- and this block only runs on pad
|
||||
-- input, so a real mouse is unaffected. /48 matches the pixels-per-notch
|
||||
-- LauncherView.draw multiplies back out.
|
||||
local overY = 0
|
||||
if ny > oy + h then overY = ny - (oy + h)
|
||||
elseif ny < oy then overY = ny - oy end
|
||||
@@ -2907,17 +3159,12 @@ function RomImporter:_updatePadCursor(dt)
|
||||
if overY ~= 0 and self._flex then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
|
||||
end
|
||||
-- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer.
|
||||
-- NX: the getPosition bridge already returns pad coords -- skip setPosition.
|
||||
if not self.isNX and love.mouse.setPosition then
|
||||
pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y)
|
||||
self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y
|
||||
end
|
||||
end
|
||||
|
||||
-- Right stick scrolls whatever the pad pointer sits over, through the
|
||||
-- view's wheel path, so the page and the modal scrollers all behave like a
|
||||
-- mouse wheel would.
|
||||
local ry = self._padAxis.righty or 0
|
||||
if math.abs(ry) > PAD_DEAD and self._flex then
|
||||
self:_activatePadCursor()
|
||||
@@ -2926,30 +3173,184 @@ function RomImporter:_updatePadCursor(dt)
|
||||
end
|
||||
|
||||
function RomImporter:gamepadpressed(_, button)
|
||||
self:_activatePadCursor()
|
||||
-- Map through GamepadMap so NX swaps SDL face labels to Nintendo A/B.
|
||||
local action = GamepadMap.mapGamepadButton(button)
|
||||
if action == "a" then
|
||||
-- Instant click at the virtual pointer: dispatched straight into the
|
||||
-- view, since the launcher no longer hit-tests presses itself.
|
||||
if self._flex then
|
||||
require("src.import.LauncherView").clickAt(self,
|
||||
self._padCursor.x, self._padCursor.y)
|
||||
local action = (GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit then
|
||||
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then
|
||||
if Kit.VirtualKeyboard.gamepadpressed(action) then return end
|
||||
end
|
||||
elseif button == "leftshoulder" then
|
||||
if Kit.FileBrowser and Kit.FileBrowser.active then
|
||||
if Kit.FileBrowser.gamepadpressed(action) then return end
|
||||
end
|
||||
end
|
||||
|
||||
-- Y button toggle between Native Controller Navigation and Virtual Pointer Cursor:
|
||||
if action == "y" or button == "y" then
|
||||
self._padCursorActive = not self._padCursorActive
|
||||
if okKit then Kit._ringShown = not self._padCursorActive end
|
||||
self._cursorModeToast = self._padCursorActive and "Cursor Navigation [Y]" or "Controller Menu Navigation [Y]"
|
||||
self._cursorModeToastTime = love.timer.getTime()
|
||||
return
|
||||
end
|
||||
|
||||
-- Select button: toggle Virtual Keyboard on handhelds
|
||||
if button == "back" or button == "select" or action == "select" then
|
||||
local isHandheld = 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"
|
||||
or os.getenv("POKEPORT_SBC") == "1" or os.getenv("ANBERNIC") == "1"
|
||||
if not isHandheld then return end
|
||||
if okKit and Kit.VirtualKeyboard then
|
||||
if Kit.VirtualKeyboard.active then
|
||||
Kit.VirtualKeyboard.close(false)
|
||||
return
|
||||
end
|
||||
if self._indexPrompt then
|
||||
Kit.VirtualKeyboard.open({
|
||||
text = self._indexPrompt.text or "",
|
||||
title = "Add a mod index",
|
||||
onDone = function(newText, confirmed)
|
||||
if confirmed and self._indexPrompt then self._indexPrompt.text = newText end
|
||||
end
|
||||
})
|
||||
return
|
||||
elseif self._rename then
|
||||
Kit.VirtualKeyboard.open({
|
||||
text = self._rename.text or "",
|
||||
title = "Name save slot",
|
||||
onDone = function(newText, confirmed)
|
||||
if confirmed and self._rename then self._rename.text = newText end
|
||||
end
|
||||
})
|
||||
return
|
||||
elseif self._profileRenamePrompt then
|
||||
Kit.VirtualKeyboard.open({
|
||||
text = self._profileRenamePrompt.text or "",
|
||||
title = "Rename profile",
|
||||
onDone = function(newText, confirmed)
|
||||
if confirmed and self._profileRenamePrompt then self._profileRenamePrompt.text = newText end
|
||||
end
|
||||
})
|
||||
return
|
||||
elseif self._profileSavePrompt then
|
||||
Kit.VirtualKeyboard.open({
|
||||
text = self._profileSavePrompt.text or "",
|
||||
title = "Save mod profile",
|
||||
onDone = function(newText, confirmed)
|
||||
if confirmed and self._profileSavePrompt then self._profileSavePrompt.text = newText end
|
||||
end
|
||||
})
|
||||
return
|
||||
elseif self._settingsText then
|
||||
Kit.VirtualKeyboard.open({
|
||||
text = self._settingsText.text or "",
|
||||
title = (self._settingsText.row and self._settingsText.row.label) or "Edit Text",
|
||||
onDone = function(newText, confirmed)
|
||||
if confirmed and self._settingsText then self._settingsText.text = newText end
|
||||
end
|
||||
})
|
||||
return
|
||||
elseif self.tab == "find" then
|
||||
Kit.VirtualKeyboard.open({
|
||||
text = self._findQuery or "",
|
||||
title = "Search Mods",
|
||||
onDone = function(newText, confirmed)
|
||||
if confirmed then
|
||||
self._findQuery = newText
|
||||
if self._refreshFind then self:_refreshFind() end
|
||||
end
|
||||
end
|
||||
})
|
||||
return
|
||||
else
|
||||
Kit.VirtualKeyboard.open({
|
||||
text = "",
|
||||
title = "Virtual Keyboard",
|
||||
onDone = function() end
|
||||
})
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Shoulder buttons: cycle tabs
|
||||
if button == "leftshoulder" then
|
||||
self:_cycleTab(-1)
|
||||
return
|
||||
elseif button == "rightshoulder" then
|
||||
self:_cycleTab(1)
|
||||
elseif button == "dpup" or button == "dpdown"
|
||||
or button == "dpleft" or button == "dpright" then
|
||||
self._padDir[button] = true
|
||||
elseif button == "start" or button == "back" then
|
||||
-- Start / Select: Play if ready, else Choose ROM on the active game tab.
|
||||
return
|
||||
end
|
||||
|
||||
-- Triggers (L2 / R2): fast scroll content / lists up and down
|
||||
if button == "triggerleft" or button == "lefttrigger" or button == "l2"
|
||||
or action == "triggerleft" or action == "l2" then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, 4)
|
||||
return
|
||||
elseif button == "triggerright" or button == "righttrigger" or button == "r2"
|
||||
or action == "triggerright" or action == "r2" then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, -4)
|
||||
return
|
||||
end
|
||||
|
||||
-- Start button: Play / Choose ROM
|
||||
if button == "start" then
|
||||
if self.workState == "working" then return end
|
||||
local version = self.tab
|
||||
if GameVersion.VERSIONS[version] then
|
||||
if self.ready[version] then self:play(version) else self:_romAction(version) end
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if not self._padCursorActive and not self.isNX then
|
||||
if okKit then Kit._ringShown = true end
|
||||
if action == "a" then
|
||||
if okKit and Kit.focusId then
|
||||
Kit.activateFocused()
|
||||
elseif self._flex then
|
||||
require("src.import.LauncherView").clickAt(self,
|
||||
self._padCursor.x, self._padCursor.y)
|
||||
end
|
||||
return
|
||||
elseif action == "b" then
|
||||
if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return
|
||||
elseif self._rename then self._rename = nil; self:_disarmTextInput(); return
|
||||
elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return
|
||||
elseif self._profileSavePrompt then self._profileSavePrompt = nil; self:_disarmTextInput(); return
|
||||
elseif self._settingsText then self._settingsText = nil; self:_disarmTextInput(); return
|
||||
end
|
||||
elseif button == "dpup" or action == "dpup" then
|
||||
if okKit then Kit.navigate("up") end
|
||||
return
|
||||
elseif button == "dpdown" or action == "dpdown" then
|
||||
if okKit then Kit.navigate("down") end
|
||||
return
|
||||
elseif button == "dpleft" or action == "dpleft" then
|
||||
if okKit then Kit.navigate("left") end
|
||||
return
|
||||
elseif button == "dpright" or action == "dpright" then
|
||||
if okKit then Kit.navigate("right") end
|
||||
return
|
||||
end
|
||||
else
|
||||
self:_activatePadCursor()
|
||||
if action == "a" then
|
||||
if self._flex then
|
||||
require("src.import.LauncherView").clickAt(self,
|
||||
self._padCursor.x, self._padCursor.y)
|
||||
end
|
||||
elseif action == "b" then
|
||||
if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return
|
||||
elseif self._rename then self._rename = nil; self:_disarmTextInput(); return
|
||||
elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return
|
||||
elseif self._profileSavePrompt then self._profileSavePrompt = nil; self:_disarmTextInput(); return
|
||||
elseif self._settingsText then self._settingsText = nil; self:_disarmTextInput(); return
|
||||
end
|
||||
elseif button == "dpup" or button == "dpdown"
|
||||
or button == "dpleft" or button == "dpright" then
|
||||
self._padDir[button] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2963,11 +3364,29 @@ end
|
||||
function RomImporter:gamepadaxis(_, axis, value)
|
||||
if axis == "leftx" or axis == "lefty" or axis == "righty" then
|
||||
self._padAxis[axis] = value
|
||||
if math.abs(value) > PAD_DEAD then
|
||||
if self._padCursorActive and math.abs(value) > PAD_DEAD then
|
||||
self:_activatePadCursor()
|
||||
elseif axis == "lefty" then
|
||||
elseif axis == "lefty" and math.abs(value) <= PAD_DEAD then
|
||||
self._padStickCentered = true
|
||||
end
|
||||
elseif axis == "triggerleft" or axis == "lefttrigger" then
|
||||
if value > 0.4 then
|
||||
if not self._l2TriggerActive then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, 4)
|
||||
self._l2TriggerActive = true
|
||||
end
|
||||
elseif value < 0.2 then
|
||||
self._l2TriggerActive = false
|
||||
end
|
||||
elseif axis == "triggerright" or axis == "righttrigger" then
|
||||
if value > 0.4 then
|
||||
if not self._r2TriggerActive then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, -4)
|
||||
self._r2TriggerActive = true
|
||||
end
|
||||
elseif value < 0.2 then
|
||||
self._r2TriggerActive = false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2995,6 +3414,24 @@ function RomImporter:joystickaxis(joystick, axis, value)
|
||||
self:gamepadaxis(joystick, "leftx", value)
|
||||
elseif axis == 2 then
|
||||
self:gamepadaxis(joystick, "lefty", value)
|
||||
elseif axis == 3 or axis == 5 then
|
||||
if value > 0.4 then
|
||||
if not self._l2RawActive then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, 4)
|
||||
self._l2RawActive = true
|
||||
end
|
||||
elseif value < 0.2 then
|
||||
self._l2RawActive = false
|
||||
end
|
||||
elseif axis == 4 or axis == 6 then
|
||||
if value > 0.4 then
|
||||
if not self._r2RawActive then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, -4)
|
||||
self._r2RawActive = true
|
||||
end
|
||||
elseif value < 0.2 then
|
||||
self._r2RawActive = false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3829,6 +4266,7 @@ function RomImporter:_toggleSafeMode()
|
||||
self.mods = nil
|
||||
self.findInstalled = nil
|
||||
self._modSortCache = nil
|
||||
self._cartCaptureCache = nil
|
||||
self._modInfoFetch = nil
|
||||
self.modNotice = nil
|
||||
end
|
||||
@@ -3914,6 +4352,15 @@ function RomImporter:fileUrl(path)
|
||||
end
|
||||
|
||||
function RomImporter:keypressed(key)
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit then
|
||||
if Kit.FileBrowser and Kit.FileBrowser.active then
|
||||
if Kit.FileBrowser.keypressed(key) then return end
|
||||
end
|
||||
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then
|
||||
if Kit.VirtualKeyboard.keypressed(key) then return end
|
||||
end
|
||||
end
|
||||
if self._profileSavePrompt then
|
||||
if key == "backspace" then
|
||||
self._profileSavePrompt.text = utf8Back(self._profileSavePrompt.text or "")
|
||||
@@ -4146,7 +4593,7 @@ function RomImporter:_selectCart(version, id)
|
||||
self.activeCart[version] = id
|
||||
self._cartPlan = nil
|
||||
-- the MODS panel answers for the cart now, so its cached rows are stale
|
||||
self.mods, self._modSortCache = nil, nil
|
||||
self.mods, self._modSortCache, self._cartCaptureCache = nil, nil, nil
|
||||
local scope = self:slotScope(version)
|
||||
self.slots[scope] = nil
|
||||
self.slotScroll[scope] = nil
|
||||
@@ -4445,7 +4892,11 @@ end
|
||||
-- pins the off ones too, so they are part of the cart it would write.
|
||||
function RomImporter:_cartCaptureCount(version)
|
||||
if not GameVersion.VERSIONS[version] then return 0 end
|
||||
return #cartModRows(self, version)
|
||||
local cache = self._cartCaptureCache
|
||||
if cache and cache.version == version then return cache.n end
|
||||
local n = #cartModRows(self, version)
|
||||
self._cartCaptureCache = { version = version, n = n }
|
||||
return n
|
||||
end
|
||||
|
||||
function RomImporter:_cartAuthor()
|
||||
@@ -4804,6 +5255,7 @@ function RomImporter:_refreshMods()
|
||||
local SaveData = require("src.core.SaveData")
|
||||
self._cartPlan = nil
|
||||
self._profileCache = nil
|
||||
self._cartCaptureCache = nil
|
||||
self.findInstalled = nil
|
||||
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
|
||||
-- Once per session, ahead of the first listing: pull in any mod the player
|
||||
@@ -4838,6 +5290,7 @@ function RomImporter:_refreshMods()
|
||||
if m.targetsHere ~= false then kept[#kept + 1] = m end
|
||||
end
|
||||
self.mods = kept
|
||||
self._cartCaptureCache = { version = self.modScope, n = #kept }
|
||||
end
|
||||
-- a pin is judged against the whole listing: the cart named it, so it is
|
||||
-- listed even where the game filter above would have dropped it
|
||||
|
||||
@@ -20,6 +20,37 @@ local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local SaveFileIO = {}
|
||||
|
||||
-- The cartridge image an imported Gen 2 slot came from, kept BESIDE the slot
|
||||
-- rather than inside it: export needs the regions the codec does not model,
|
||||
-- and 32 KB of binary in the serialized table is 40 KB of Lua source reparsed
|
||||
-- on every save and load.
|
||||
local function cartPath(version, slotId)
|
||||
return ("saves/%s/%s.cart"):format(version, tostring(slotId))
|
||||
end
|
||||
|
||||
local function cartFs()
|
||||
local portable = SaveData.portableFs and SaveData.portableFs()
|
||||
return portable or (love and love.filesystem)
|
||||
end
|
||||
|
||||
local function writeCart(version, slotId, bytes)
|
||||
local fs = cartFs()
|
||||
if not (fs and fs.write and bytes) then return end
|
||||
if fs.createDirectory then
|
||||
fs.createDirectory("saves")
|
||||
fs.createDirectory("saves/" .. version)
|
||||
end
|
||||
fs.write(cartPath(version, slotId), bytes)
|
||||
end
|
||||
|
||||
local function readCart(version, slotId)
|
||||
local fs = cartFs()
|
||||
if not (fs and fs.read) then return nil end
|
||||
local ok, bytes = pcall(fs.read, cartPath(version, slotId))
|
||||
if ok and type(bytes) == "string" then return bytes end
|
||||
return nil
|
||||
end
|
||||
|
||||
local SAVE_SIZE = SaveConvert.SAVE_SIZE
|
||||
|
||||
-- Resolve raw save bytes from whatever the launcher hands us:
|
||||
@@ -78,8 +109,17 @@ function SaveFileIO.importToSlot(source, version, force)
|
||||
version = version or GameVersion.get()
|
||||
local bytes, readErr = readSource(source)
|
||||
if not bytes then return false, readErr end
|
||||
-- The GAME decides before the BYTES do. Everything below this line used to
|
||||
-- judge a save by Gen 1's rules whatever game it was for, and a Gen 2 cart
|
||||
-- is MBC3+TIMER: a real Gold/Silver/Crystal .sav carries an RTC footer, so
|
||||
-- it is 32786 bytes, misses the size test, and was then measured against
|
||||
-- pokered's checksum -- which is why a perfectly good Crystal save reported
|
||||
-- as corrupt (#1832). mainChecksumValid now takes the game and asks that
|
||||
-- generation's rule.
|
||||
local supported, unsupportedWhy = SaveConvert.importSupported(version)
|
||||
if not supported then return false, unsupportedWhy end
|
||||
if #bytes ~= SAVE_SIZE then
|
||||
local check = SaveConvert.mainChecksumValid(bytes)
|
||||
local check = SaveConvert.mainChecksumValid(bytes, version)
|
||||
if check == nil then
|
||||
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
|
||||
:format(SAVE_SIZE, #bytes)
|
||||
@@ -87,7 +127,12 @@ function SaveFileIO.importToSlot(source, version, force)
|
||||
if check == false then
|
||||
return false, "save data checksum invalid (main data checksum mismatch)"
|
||||
end
|
||||
if #bytes > SAVE_SIZE and not force then
|
||||
-- The confirm exists because a Gen 1 save bigger than 32768 is a surprise
|
||||
-- worth asking about. On a Gen 2 cart it is the normal shape -- every
|
||||
-- real one has the footer -- so asking would be a prompt with one sensible
|
||||
-- answer, on every import, forever.
|
||||
if #bytes > SAVE_SIZE and not force
|
||||
and not SaveConvert.isGen2Cart(version) then
|
||||
return false, nil, { needsConfirm = true, size = #bytes }
|
||||
end
|
||||
bytes = #bytes > SAVE_SIZE and bytes:sub(1, SAVE_SIZE)
|
||||
@@ -111,6 +156,7 @@ function SaveFileIO.importToSlot(source, version, force)
|
||||
return false, "could not write the imported save: " .. tostring(writeErr)
|
||||
end
|
||||
SaveData.setActiveSlot(version, slotId)
|
||||
if SaveConvert.isGen2Cart(version) then writeCart(version, slotId, bytes) end
|
||||
return true, slotId
|
||||
end
|
||||
|
||||
@@ -125,9 +171,10 @@ function SaveFileIO.exportActiveSlot(version)
|
||||
version = version or GameVersion.get()
|
||||
local save = SaveData.load(version)
|
||||
if not save then return false, "this game has no save to export yet" end
|
||||
local bytes, exportErr = SaveConvert.exportSav(save, version)
|
||||
if not bytes then return false, exportErr end
|
||||
local slotId = SaveData.activeSlot(version) or "save"
|
||||
local bytes, exportErr = SaveConvert.exportSav(save, version,
|
||||
readCart(version, slotId))
|
||||
if not bytes then return false, exportErr end
|
||||
-- Portable mode is the same seam SaveData's own persistFs uses: when
|
||||
-- portable.txt marks the install every persistent write leaves the OS save
|
||||
-- directory for the game folder, and an export is no exception. Writing
|
||||
|
||||
@@ -94,9 +94,11 @@ function ItemEffects.healsHP(id)
|
||||
end
|
||||
|
||||
-- .useRareCandy prints over the still-drawn party menu
|
||||
-- (engine/items/item_effects.asm:1392-1418)
|
||||
-- (engine/items/item_effects.asm:1392-1418); .useVitamin ends at
|
||||
-- RemoveUsedItem the same way (engine/items/item_effects.asm:1315-1322)
|
||||
function ItemEffects.keepsPartyMenuOpen(id)
|
||||
return ItemEffects.healsHP(id) or id == "RARE_CANDY"
|
||||
or VITAMINS[id] ~= nil
|
||||
end
|
||||
|
||||
function ItemEffects.isBattleMedicine(id)
|
||||
@@ -184,6 +186,14 @@ local function adjacentSleepingSnorlax(save, ow)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function itemUseLine(data, save, name)
|
||||
return romText(data, "_ItemUseText001", "%s used\n%s!", save.player.name, name)
|
||||
end
|
||||
|
||||
-- PrintItemUseTextAndRemoveItem (item_effects.asm): used-line + SFX_HEAL_AILMENT.
|
||||
-- BagMenu plays Heal_Ailment via TextBox.soundOpts when extra.useJingle is set.
|
||||
local USE_JINGLE = { useJingle = true }
|
||||
|
||||
-- Use an item on a target party mon (target may be nil for targetless
|
||||
-- items). data = generated data tables; battle = BattleState when used
|
||||
-- mid-battle; ow = the overworld (OverworldState), needed only to check
|
||||
@@ -280,7 +290,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
"All sleeping\nPOKéMON woke up!") }
|
||||
end
|
||||
|
||||
-- battle-only items
|
||||
-- battle-only items (PrintItemUseTextAndRemoveItem + Heal_Ailment, #1635)
|
||||
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
|
||||
or itemId == "POKE_DOLL" then
|
||||
if not battle then
|
||||
@@ -293,21 +303,23 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(save, "USEDXITEM", b and b.mon)
|
||||
end
|
||||
local used = itemUseLine(data, save, name)
|
||||
if itemId == "X_ACCURACY" then
|
||||
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
|
||||
-- (not an accuracy stage)
|
||||
-- (not an accuracy stage); vanilla prints only the used line
|
||||
b.xAccuracy = true
|
||||
return "consumed", { Strings("%s's\nhits will never\nmiss!", b.name) }
|
||||
return "consumed", { used }, USE_JINGLE
|
||||
end
|
||||
if X_ITEMS[itemId] then
|
||||
local stat = X_ITEMS[itemId]
|
||||
local cur = b.stages[stat] or 0
|
||||
-- ItemUseXStat removes the item BEFORE running the stat-up
|
||||
-- effect, so at +6 it is still consumed and StatModifierUpEffect
|
||||
-- just prints "Nothing happened!"
|
||||
-- ItemUseXStat: PrintItemUseTextAndRemoveItem, then StatModifierUpEffect
|
||||
if cur >= 6 then
|
||||
return "consumed", { romText(data, "_NothingHappenedText",
|
||||
"Nothing happened!") }
|
||||
return "consumed", { used }, {
|
||||
useJingle = true,
|
||||
afterMessages = { romText(data, "_NothingHappenedText",
|
||||
"Nothing happened!") },
|
||||
}
|
||||
end
|
||||
b.stages[stat] = cur + 1
|
||||
b.hazeStatReset = nil
|
||||
@@ -315,26 +327,29 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
and battle.kind ~= "link" then
|
||||
require("src.battle.Damage").reapplyBadgeBoosts(b, stat)
|
||||
end
|
||||
return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) }
|
||||
return "consumed", { used }, {
|
||||
useJingle = true,
|
||||
afterMessages = { Strings("%s's\n%s rose!", b.name,
|
||||
Strings(STAT_LABEL[stat])) },
|
||||
}
|
||||
end
|
||||
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
|
||||
-- the item, even when it is already active
|
||||
-- the item, even when it is already active; vanilla prints only used
|
||||
if itemId == "DIRE_HIT" then
|
||||
b.focusEnergy = true
|
||||
return "consumed", { romText(data, "_GettingPumpedText",
|
||||
"%s's\ngetting pumped!", b.name) }
|
||||
return "consumed", { used }, USE_JINGLE
|
||||
end
|
||||
if itemId == "GUARD_SPEC" then
|
||||
b.mist = true
|
||||
return "consumed", { Strings("%s's\nprotected against\nstat changes!", b.name) }
|
||||
return "consumed", { used }, USE_JINGLE
|
||||
end
|
||||
if itemId == "POKE_DOLL" then
|
||||
if battle.kind ~= "wild" then
|
||||
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
|
||||
return "failed", { notTime(data, save) }
|
||||
end
|
||||
return "consumed_escape", { romText(data, "_WildRanText",
|
||||
"The wild POKéMON\nran away!", battle.enemy and battle.enemy.name) }
|
||||
-- PrintItemUseTextAndRemoveItem then escape
|
||||
return "consumed_escape", { used }, USE_JINGLE
|
||||
end
|
||||
end
|
||||
|
||||
@@ -513,8 +528,9 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- _VitaminStatRoseText's slot order is localization-dependent (the
|
||||
-- Spanish ROM puts the stat before the name), so the extracted line
|
||||
-- cannot be filled positionally; the engine wording stands
|
||||
-- engine/items/item_effects.asm:1313
|
||||
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
|
||||
Strings(STAT_LABEL[vitaminStat])) }
|
||||
Strings(STAT_LABEL[vitaminStat])) }, { useJingle = true }
|
||||
end
|
||||
|
||||
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
|
||||
@@ -613,7 +629,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
if REPELS[itemId] then
|
||||
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
|
||||
save.repelSteps = steps
|
||||
return "consumed", { Strings("%s used\n%s!", save.player.name, name) }
|
||||
return "consumed", { itemUseLine(data, save, name) }, USE_JINGLE
|
||||
end
|
||||
|
||||
return "failed", { notTime(data, save) }
|
||||
|
||||
+26
-4
@@ -23,6 +23,7 @@ local Protocol = require("src.link.Protocol")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Timing = require("src.core.Timing")
|
||||
|
||||
local LinkBattle = {}
|
||||
|
||||
@@ -473,11 +474,22 @@ function LinkBattle.new(game, net, opts)
|
||||
-- switches happen before attacks (both may switch)
|
||||
if myMsg.kind == "switch" then
|
||||
local idx = myMsg.index
|
||||
s:act(function() sendOutPlayer(s, myParty[idx]) end)
|
||||
-- SwitchPlayerMon (engine/battle/core.asm:2419-2423); a post-faint
|
||||
-- replacement shares sendOutPlayer and prints neither line
|
||||
s:act(function()
|
||||
s:sayNextAuto(s:withdrawText(s.player.name), Timing.SWITCH_PLAYER_MON)
|
||||
s:queueRetreatAnim()
|
||||
s:actNext(function() sendOutPlayer(s, myParty[idx]) end)
|
||||
end)
|
||||
myAction = nil
|
||||
end
|
||||
if theirSwitch then
|
||||
s:act(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
|
||||
-- SwitchEnemyMon (engine/battle/trainer_ai.asm:596-599)
|
||||
s:act(function()
|
||||
s:sayNext(s:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
|
||||
theirName, s.enemy.name))
|
||||
s:actNext(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
|
||||
end)
|
||||
end
|
||||
|
||||
s:act(function()
|
||||
@@ -885,11 +897,21 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
|
||||
if hostMsg.kind == "switch" then
|
||||
local idx = hostMsg.index
|
||||
s:act(function() sendOutHost(s, hostParty[idx]) end)
|
||||
-- SwitchPlayerMon (engine/battle/core.asm:2419-2423)
|
||||
s:act(function()
|
||||
s:sayNextAuto(s:withdrawText(s.player.name), Timing.SWITCH_PLAYER_MON)
|
||||
s:queueRetreatAnim()
|
||||
s:actNext(function() sendOutHost(s, hostParty[idx]) end)
|
||||
end)
|
||||
end
|
||||
if guestMsg.kind == "switch" then
|
||||
local idx = guestMsg.index
|
||||
s:act(function() sendOutGuest(s, guestParty[idx]) end)
|
||||
-- SwitchEnemyMon (engine/battle/trainer_ai.asm:596-599)
|
||||
s:act(function()
|
||||
s:sayNext(s:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
|
||||
guestName, s.enemy.name))
|
||||
s:actNext(function() sendOutGuest(s, guestParty[idx]) end)
|
||||
end)
|
||||
end
|
||||
|
||||
s:act(function()
|
||||
|
||||
@@ -1711,8 +1711,8 @@ local function buildStartMenu()
|
||||
function overrides.new(game)
|
||||
local g = live() or game
|
||||
local save = g and g.save
|
||||
if save and save.startMenuIndex then
|
||||
Start2.lastIndex = save.startMenuIndex
|
||||
if g and g.startMenuIndex then
|
||||
Start2.lastIndex = g.startMenuIndex
|
||||
end
|
||||
return newOrig(g, {
|
||||
save = save,
|
||||
@@ -1741,7 +1741,7 @@ COVERAGE["src.ui.StartMenu"] = {
|
||||
new = "synthesises onClose (stack:pop) and onChoose "
|
||||
.. "(Game2:openStartMenuItem); without them the menu cannot be left",
|
||||
index = "the cursor is menu.list.index on Gold (a Chrome.List)",
|
||||
["save.startMenuIndex"] = "copied INTO StartMenu.lastIndex on construct "
|
||||
["game.startMenuIndex"] = "copied INTO StartMenu.lastIndex on construct "
|
||||
.. "and never copied back: Gold's cursor is a class field, not save data",
|
||||
tx = "the box is fixed at Chrome.box(10, 0, 10, h); tx/ty/tw/th/anchor/"
|
||||
.. "maxVisible/startCloses/noSound do not exist and writes are inert",
|
||||
|
||||
+14
-2
@@ -147,12 +147,19 @@ local SUPPORTED_REQUIRES = {
|
||||
local ENGINE_PREFIX = (debug.getinfo(1, "S").source or "")
|
||||
:gsub("^@", ""):gsub("mods[/\\]Loader%.lua$", "")
|
||||
|
||||
local ENGINE_CHUNKS = {
|
||||
["main.lua"] = true,
|
||||
["conf.lua"] = true,
|
||||
}
|
||||
|
||||
local function callerIsMod(level)
|
||||
if ENGINE_PREFIX == "" then return false end
|
||||
local info = debug.getinfo(level, "S")
|
||||
local source = info and info.source
|
||||
if not source or source:sub(1, 1) ~= "@" then return false end
|
||||
return source:sub(2, 1 + #ENGINE_PREFIX) ~= ENGINE_PREFIX
|
||||
local path = source:sub(2)
|
||||
if ENGINE_CHUNKS[path] then return false end
|
||||
return path:sub(1, #ENGINE_PREFIX) ~= ENGINE_PREFIX
|
||||
end
|
||||
|
||||
local function scanRequire(name)
|
||||
@@ -207,6 +214,11 @@ local function engineRequire(name)
|
||||
return module
|
||||
end
|
||||
|
||||
function Loader.endSession()
|
||||
devShim.generation = nil
|
||||
devShim.errors = nil
|
||||
end
|
||||
|
||||
function Loader:_installDevShim()
|
||||
for id, mod in pairs(self.mods) do
|
||||
devShim.permissions[id] = mod.manifest.permissionSet
|
||||
@@ -234,7 +246,7 @@ function Loader:_installDevShim()
|
||||
-- The Gen 1 name a mod asked for, answered by the Gen 2 arm behind it.
|
||||
-- Engine code keeps the real module: src/render/PaletteFX.lua:776
|
||||
-- requires src.core.Game on both generations and means it.
|
||||
if devShim.generation ~= 1 and Gen2Compat.serves(name)
|
||||
if devShim.generation == 2 and Gen2Compat.serves(name)
|
||||
and (owner or callerIsMod(3)) then
|
||||
local adapter = Gen2Compat.resolve(name, Runtime.currentMod)
|
||||
if adapter then
|
||||
|
||||
@@ -826,10 +826,14 @@ R.pokemon = {
|
||||
item = f.opt(f.id("items")),
|
||||
species = f.id("pokemon") }),
|
||||
spriteFront = f.path, spriteBack = f.path, frontSize = f.int(1, 7),
|
||||
-- text2 is the #DEX entry's second description page (Pokedex_asm's bare
|
||||
-- `page` macro, engine/pokedex/pokedex.asm): PokedexMenu:drawEntryBody
|
||||
-- shows `entry.text` on page 1 and `entry.text2` on page 2, so a
|
||||
-- translation needs both to cover the whole entry.
|
||||
dexEntry = f.opt(f.rec{ kind = f.str, heightFt = f.int(0),
|
||||
heightIn = f.int(0, 11), weight = f.num,
|
||||
heightM = f.opt(f.num), weightKg = f.opt(f.num),
|
||||
text = f.str }),
|
||||
text = f.str, text2 = f.opt(f.str) }),
|
||||
icon = f.opt(f.union{ f.str, f.rec{ image = f.path,
|
||||
frames = f.opt(f.int(1)) } }),
|
||||
cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")),
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
-- colour IS its palettes -- so one substitution at this seam recolours the
|
||||
-- whole game without a single screen knowing about it:
|
||||
--
|
||||
-- GBC the cart's own palettes. The default: this is a Game Boy
|
||||
-- Color game and its colour is the point.
|
||||
-- DMG the original grey Game Boy. Every palette collapses to the
|
||||
-- four hardware shades, and the sheets that are drawn straight
|
||||
-- (chrome, text) are already those shades, so the screen is
|
||||
@@ -32,9 +30,12 @@
|
||||
local GbcPalette = {}
|
||||
|
||||
GbcPalette.MODES = { "gbc", "dmg", "classic" }
|
||||
GbcPalette.MODE_LABELS = { gbc = "GBC", dmg = "DMG", classic = "CLASSIC" }
|
||||
GbcPalette.MODE_LABELS = { gbc = "GEN 2", dmg = "DMG", classic = "CLASSIC",
|
||||
custom = "GBC" }
|
||||
GbcPalette.mode = "gbc"
|
||||
|
||||
GbcPalette.CUSTOM_MODE = "custom"
|
||||
|
||||
-- rBGP's four shades as the hardware shows them.
|
||||
local DMG_SHADES = {
|
||||
{ 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 },
|
||||
@@ -162,11 +163,22 @@ local function channel(colors, index)
|
||||
return (c[1] or 0) / 255, (c[2] or 0) / 255, (c[3] or 0) / 255
|
||||
end
|
||||
|
||||
GbcPalette.customRamp = nil
|
||||
|
||||
function GbcPalette.setCustomRamp(ramp)
|
||||
local prev = GbcPalette.customRamp
|
||||
GbcPalette.customRamp = ramp
|
||||
if (prev ~= nil) ~= (ramp ~= nil) or prev ~= ramp then
|
||||
pcall(function() require("src.render.SpriteRenderer").invalidate() end)
|
||||
end
|
||||
end
|
||||
|
||||
-- What a palette actually draws as under the current COLOR mode. Anything
|
||||
-- that reads a colour out of a palette directly -- a canvas cleared to BG
|
||||
-- colour 0, say -- has to go through this too, or the backdrop would keep its
|
||||
-- cart colour while everything on top of it went grey.
|
||||
function GbcPalette.resolve(colors)
|
||||
if GbcPalette.customRamp then return GbcPalette.customRamp end
|
||||
if GbcPalette.mode == "gbc" then return colors end
|
||||
return DMG_SHADES
|
||||
end
|
||||
@@ -249,6 +261,10 @@ function GbcPalette.presentColors()
|
||||
end
|
||||
|
||||
function GbcPalette.setMode(mode)
|
||||
if mode == GbcPalette.CUSTOM_MODE then
|
||||
GbcPalette.mode = mode
|
||||
return mode
|
||||
end
|
||||
for _, name in ipairs(GbcPalette.MODES) do
|
||||
if name == mode then
|
||||
GbcPalette.mode = mode
|
||||
@@ -260,7 +276,7 @@ function GbcPalette.setMode(mode)
|
||||
end
|
||||
|
||||
function GbcPalette.modeLabel(mode)
|
||||
return GbcPalette.MODE_LABELS[mode or GbcPalette.mode] or "GBC"
|
||||
return GbcPalette.MODE_LABELS[mode or GbcPalette.mode] or "GEN 2"
|
||||
end
|
||||
|
||||
-- Advance GBC -> DMG -> CLASSIC -> GBC. `delta` may be -1 to step back, so
|
||||
@@ -273,11 +289,20 @@ function GbcPalette.cycle(delta)
|
||||
local count = #GbcPalette.MODES
|
||||
at = (at - 1 + (delta or 1)) % count + 1
|
||||
GbcPalette.mode = GbcPalette.MODES[at]
|
||||
GbcPalette.setCustomRamp(nil)
|
||||
return GbcPalette.mode
|
||||
end
|
||||
|
||||
function GbcPalette.applyOptions(opts)
|
||||
return GbcPalette.setMode(opts and opts.color or "gbc")
|
||||
local mode = GbcPalette.setMode(opts and opts.color or "gbc")
|
||||
local id = opts and opts.palette
|
||||
if id and id ~= "" then
|
||||
local ok, Palette = pcall(require, "src.render.Palette")
|
||||
GbcPalette.setCustomRamp(ok and Palette.ramp(id) or nil)
|
||||
else
|
||||
GbcPalette.setCustomRamp(nil)
|
||||
end
|
||||
return mode
|
||||
end
|
||||
|
||||
-- Point the shader at one 4-color palette. Colors are 0-255 triples, matching
|
||||
@@ -431,4 +456,14 @@ function GbcPalette.with(colors, body)
|
||||
return applied
|
||||
end
|
||||
|
||||
function GbcPalette.withRaw(colors, body)
|
||||
local previous = love and love.graphics and love.graphics.getShader
|
||||
and love.graphics.getShader() or nil
|
||||
local applied = GbcPalette.useRaw(colors)
|
||||
local ok, err = pcall(body)
|
||||
if love and love.graphics then love.graphics.setShader(previous) end
|
||||
if not ok then error(err, 0) end
|
||||
return applied
|
||||
end
|
||||
|
||||
return GbcPalette
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
-- UI LETTERBOX: what fills the window around a 160x144 screen.
|
||||
--
|
||||
-- AUTO keeps what each screen was authored with (Gen 1 black, Gen 2 mostly
|
||||
-- white, the Pokedex and credits black); the other three override every
|
||||
-- screen uniformly. PALETTE takes the active COLORS ramp's paper shade, so
|
||||
-- the bars match the panel they frame instead of a fixed grey.
|
||||
|
||||
local Letterbox = {}
|
||||
|
||||
-- Set from applyOptions on both generations, the same shape GbcPalette.setMode
|
||||
-- and Tilt.setLevel use, so a draw call needs no options table in hand.
|
||||
Letterbox.mode = "auto"
|
||||
|
||||
Letterbox.MODES = { "auto", "black", "white", "palette" }
|
||||
|
||||
local LABELS = {
|
||||
auto = "AUTO", black = "BLACK", white = "WHITE", palette = "PALETTE",
|
||||
}
|
||||
|
||||
function Letterbox.normalize(mode)
|
||||
for _, id in ipairs(Letterbox.MODES) do
|
||||
if mode == id then return id end
|
||||
end
|
||||
return "auto"
|
||||
end
|
||||
|
||||
function Letterbox.label(mode)
|
||||
return LABELS[Letterbox.normalize(mode)]
|
||||
end
|
||||
|
||||
function Letterbox.cycle(mode, dir)
|
||||
local at = 1
|
||||
for i, id in ipairs(Letterbox.MODES) do
|
||||
if id == Letterbox.normalize(mode) then at = i break end
|
||||
end
|
||||
local n = #Letterbox.MODES
|
||||
return Letterbox.MODES[(at - 1 + (dir or 1)) % n + 1]
|
||||
end
|
||||
|
||||
-- r, g, b for `mode`, falling back to the screen's authored colour on AUTO.
|
||||
--
|
||||
-- `paper` is supplied by the caller, not looked up here: the two generations
|
||||
-- keep their picked palette in different modules (Gen 1 in PaletteFX, Gen 2 in
|
||||
-- GbcPalette) and GbcPalette.available() is true on both, so anything that
|
||||
-- sniffed for it answered with Gen 2's white default on a Gen 1 game.
|
||||
function Letterbox.color(mode, r, g, b, paper)
|
||||
mode = Letterbox.normalize(mode)
|
||||
if mode == "black" then return 0, 0, 0 end
|
||||
if mode == "white" then return 1, 1, 1 end
|
||||
if mode == "palette" and paper then
|
||||
local pr, pg, pb = paper()
|
||||
if pr then return pr, pg, pb end
|
||||
end
|
||||
return r or 0, g or 0, b or 0
|
||||
end
|
||||
|
||||
function Letterbox.setMode(mode)
|
||||
Letterbox.mode = Letterbox.normalize(mode)
|
||||
return Letterbox.mode
|
||||
end
|
||||
|
||||
function Letterbox.applyOptions(options)
|
||||
return Letterbox.setMode(options and options.uiLetterbox)
|
||||
end
|
||||
|
||||
-- What a drawWidescreen should paint the window with, given the colour that
|
||||
-- screen was authored to use and its generation's paper reader.
|
||||
function Letterbox.fill(r, g, b, paper)
|
||||
return Letterbox.color(Letterbox.mode, r, g, b, paper)
|
||||
end
|
||||
|
||||
return Letterbox
|
||||
@@ -0,0 +1,249 @@
|
||||
|
||||
local Palette = {}
|
||||
|
||||
local pack = nil
|
||||
|
||||
function Palette.packs()
|
||||
if pack == nil then
|
||||
local ok, data = pcall(require, "data.gb_palettes")
|
||||
pack = ok and data or false
|
||||
end
|
||||
return pack or {}
|
||||
end
|
||||
|
||||
|
||||
local MONO_STEPS = { 1.00, 0.72, 0.38, 0.12 }
|
||||
|
||||
Palette.MONO_COLOURS = {
|
||||
{ "GREEN", 0x9b, 0xbc, 0x0f }, -- DMG
|
||||
{ "LIME", 0xa8, 0xd8, 0x38 },
|
||||
{ "OLIVE", 0xa6, 0xac, 0x84 }, -- Pocket
|
||||
{ "AMBER", 0xff, 0xb0, 0x00 }, -- plasma terminal
|
||||
{ "ORANGE", 0xff, 0x80, 0x30 },
|
||||
{ "RED", 0xff, 0x50, 0x50 },
|
||||
{ "ROSE", 0xff, 0x90, 0xb0 },
|
||||
{ "MAGENTA", 0xf0, 0x60, 0xd0 },
|
||||
{ "PURPLE", 0xb0, 0x70, 0xf0 },
|
||||
{ "INDIGO", 0x80, 0x80, 0xf0 },
|
||||
{ "BLUE", 0x50, 0xa0, 0xff },
|
||||
{ "CYAN", 0x40, 0xe0, 0xe0 }, -- VFD
|
||||
{ "TEAL", 0x40, 0xc0, 0xa0 },
|
||||
{ "MINT", 0x90, 0xe0, 0xb0 },
|
||||
{ "SAND", 0xe0, 0xc8, 0x90 },
|
||||
{ "BROWN", 0xc0, 0x90, 0x50 },
|
||||
{ "WHITE", 0xff, 0xff, 0xff }, -- plain DMG greys
|
||||
{ "SILVER", 0xd0, 0xd8, 0xe0 },
|
||||
}
|
||||
|
||||
local function monoRamp(r, g, b)
|
||||
local out = {}
|
||||
for i = 1, 4 do
|
||||
local f = MONO_STEPS[i]
|
||||
out[i] = { math.floor(r * f + 0.5),
|
||||
math.floor(g * f + 0.5),
|
||||
math.floor(b * f + 0.5) }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
Palette.monoRamp = monoRamp -- named for the suite
|
||||
|
||||
|
||||
function Palette.packId(group, name)
|
||||
return "p:" .. group .. "/" .. name
|
||||
end
|
||||
|
||||
function Palette.monoId(r, g, b)
|
||||
return ("m:%02x%02x%02x"):format(r, g, b)
|
||||
end
|
||||
|
||||
local function unhex(text)
|
||||
if type(text) ~= "string" or #text ~= 24 then return nil end
|
||||
local out = {}
|
||||
for i = 1, 4 do
|
||||
local at = (i - 1) * 6
|
||||
local r = tonumber(text:sub(at + 1, at + 2), 16)
|
||||
local g = tonumber(text:sub(at + 3, at + 4), 16)
|
||||
local b = tonumber(text:sub(at + 5, at + 6), 16)
|
||||
if not (r and g and b) then return nil end
|
||||
out[i] = { r, g, b }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
Palette.unhex = unhex -- named for the suite
|
||||
|
||||
local RAMP_INDEX = 2
|
||||
|
||||
local GREY_CHROMA = 12
|
||||
local HUE_ARC = 50
|
||||
|
||||
Palette.GREY_CHROMA = GREY_CHROMA -- named for the suite
|
||||
Palette.HUE_ARC = HUE_ARC
|
||||
|
||||
local function hueChroma(c)
|
||||
local r, g, b = c[1] or 0, c[2] or 0, c[3] or 0
|
||||
local mx = math.max(r, g, b)
|
||||
local mn = math.min(r, g, b)
|
||||
local chroma = mx - mn
|
||||
if chroma <= 0 then return 0, 0 end
|
||||
local h
|
||||
if mx == r then h = ((g - b) / chroma) % 6
|
||||
elseif mx == g then h = (b - r) / chroma + 2
|
||||
else h = (r - g) / chroma + 4 end
|
||||
return h * 60, chroma
|
||||
end
|
||||
|
||||
Palette.hueChroma = hueChroma -- named for the suite
|
||||
|
||||
local function hueArc(hues)
|
||||
local best = 360
|
||||
for i = 1, #hues do
|
||||
local widest = 0
|
||||
for j = 1, #hues do
|
||||
local d = (hues[j] - hues[i]) % 360
|
||||
if d > widest then widest = d end
|
||||
end
|
||||
if widest < best then best = widest end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
Palette.hueArc = hueArc -- named for the suite
|
||||
|
||||
function Palette.categoryOf(ramp)
|
||||
if type(ramp) ~= "table" or not ramp[4] then return nil end
|
||||
local hues = {}
|
||||
for i = 1, 4 do
|
||||
local h, chroma = hueChroma(ramp[i])
|
||||
if chroma > GREY_CHROMA then hues[#hues + 1] = h end
|
||||
end
|
||||
if #hues == 0 then return "grey" end
|
||||
return hueArc(hues) <= HUE_ARC and "single" or "full"
|
||||
end
|
||||
|
||||
Palette.CATEGORIES = {
|
||||
{ key = "full", label = "FULL COLOUR" },
|
||||
{ key = "single", label = "SINGLE COLOUR" },
|
||||
{ key = "grey", label = "GREYSCALE" },
|
||||
}
|
||||
|
||||
local byId, buckets = nil, nil
|
||||
|
||||
local function buildIndex()
|
||||
byId, buckets = {}, {}
|
||||
for _, category in ipairs(Palette.CATEGORIES) do buckets[category.key] = {} end
|
||||
for _, p in ipairs(Palette.packs()) do
|
||||
for _, entry in ipairs(p.palettes or {}) do
|
||||
local name = entry[1]
|
||||
if type(name) == "string" then
|
||||
local id = Palette.packId(p.name, name)
|
||||
byId[id] = entry
|
||||
local raw = unhex(entry[RAMP_INDEX] or entry[2])
|
||||
local bucket = raw and buckets[Palette.categoryOf(raw) or ""] or nil
|
||||
if bucket then
|
||||
bucket[#bucket + 1] = { name = name, pack = p.name, id = id }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function ensureIndex()
|
||||
if not byId then buildIndex() end
|
||||
end
|
||||
|
||||
function Palette.categories()
|
||||
ensureIndex()
|
||||
local out = {}
|
||||
for i, category in ipairs(Palette.CATEGORIES) do
|
||||
out[i] = { key = category.key, name = category.label,
|
||||
palettes = buckets[category.key] or {} }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Palette.category(key)
|
||||
ensureIndex()
|
||||
if not buckets[key] then return nil end
|
||||
for _, category in ipairs(Palette.CATEGORIES) do
|
||||
if category.key == key then
|
||||
return { key = key, name = category.label, palettes = buckets[key] }
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Palette.locate(id)
|
||||
if type(id) ~= "string" or id == "" then return nil end
|
||||
ensureIndex()
|
||||
for _, category in ipairs(Palette.CATEGORIES) do
|
||||
for i, item in ipairs(buckets[category.key] or {}) do
|
||||
if item.id == id then return category.key, i end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function findPack(group, name)
|
||||
ensureIndex()
|
||||
return byId[Palette.packId(group, name)]
|
||||
end
|
||||
|
||||
Palette.findPack = findPack -- named for the suite
|
||||
|
||||
function Palette.label(id)
|
||||
if type(id) ~= "string" or id == "" then return nil end
|
||||
local hex = id:match("^m:(%x+)$")
|
||||
if hex then
|
||||
local r = tonumber(hex:sub(1, 2), 16)
|
||||
local g = tonumber(hex:sub(3, 4), 16)
|
||||
local b = tonumber(hex:sub(5, 6), 16)
|
||||
for _, c in ipairs(Palette.MONO_COLOURS) do
|
||||
if c[2] == r and c[3] == g and c[4] == b then return c[1] end
|
||||
end
|
||||
return "MONO"
|
||||
end
|
||||
local name = id:match("^p:.*/([^/]+)$")
|
||||
return name or id
|
||||
end
|
||||
|
||||
local cacheId, cacheRamp = nil, nil
|
||||
|
||||
function Palette.ramp(id)
|
||||
if type(id) ~= "string" or id == "" then return nil end
|
||||
if id == cacheId then return cacheRamp end
|
||||
|
||||
local out = nil
|
||||
local hex = id:match("^m:(%x+)$")
|
||||
if hex and #hex == 6 then
|
||||
out = monoRamp(tonumber(hex:sub(1, 2), 16) or 0,
|
||||
tonumber(hex:sub(3, 4), 16) or 0,
|
||||
tonumber(hex:sub(5, 6), 16) or 0)
|
||||
else
|
||||
local group, name = id:match("^p:(.*)/([^/]+)$")
|
||||
if group then
|
||||
local entry = findPack(group, name)
|
||||
local ramp = entry and unhex(entry[RAMP_INDEX] or entry[2])
|
||||
if ramp then
|
||||
out = { ramp[4], ramp[3], ramp[2], ramp[1] }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
cacheId, cacheRamp = id, out
|
||||
return out
|
||||
end
|
||||
|
||||
function Palette.invalidate()
|
||||
cacheId, cacheRamp = nil, nil
|
||||
byId, buckets = nil, nil
|
||||
end
|
||||
|
||||
function Palette.swatch(id)
|
||||
local ramp = Palette.ramp(id)
|
||||
if not ramp then return nil end
|
||||
return { ramp[4], ramp[3], ramp[2], ramp[1] }
|
||||
end
|
||||
|
||||
return Palette
|
||||
+99
-35
@@ -20,6 +20,8 @@ local PaletteFX = {}
|
||||
local shader -- false = unavailable (headless / no shader support)
|
||||
local gbcPack -- false = missing; nil = not loaded yet
|
||||
local yellowPack -- false = missing; nil = not loaded yet
|
||||
local gbcYellowPack -- Yellow Advanced deltas; false = missing; nil = not loaded yet
|
||||
|
||||
|
||||
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
|
||||
-- come first (OG RED/BLUE/YELLOW = GBC hardware, SGB = per-map Super Game Boy,
|
||||
@@ -300,6 +302,38 @@ function PaletteFX.yellowPack()
|
||||
return yellowPack or nil
|
||||
end
|
||||
|
||||
-- Yellow-only Advanced deltas (BEACH_HOUSE, sprite remap, YELLOWMON). Never
|
||||
-- consulted on Red/Blue (#1639).
|
||||
function PaletteFX.gbcYellowPack()
|
||||
if gbcYellowPack == nil then
|
||||
local ok, pack = pcall(require, "data.palettes_gbc_yellow")
|
||||
gbcYellowPack = ok and pack or false
|
||||
end
|
||||
return gbcYellowPack or nil
|
||||
end
|
||||
|
||||
-- Active world bake tables for Advanced. Yellow merges gbc_yellow deltas
|
||||
-- via __index so shared Red tilesets stay identical byte-for-byte.
|
||||
function PaletteFX.worldPack()
|
||||
local pack = PaletteFX.gbcPack()
|
||||
local w = pack and pack.world
|
||||
if not w then return nil end
|
||||
if not GameVersion.isYellow() then return w end
|
||||
local y = PaletteFX.gbcYellowPack()
|
||||
local yw = y and y.world
|
||||
if not yw then return w end
|
||||
return {
|
||||
tileGroups = setmetatable(yw.tileGroups or {}, { __index = w.tileGroups }),
|
||||
groupColors = setmetatable(yw.groupColors or {}, { __index = w.groupColors }),
|
||||
roofGroup = w.roofGroup,
|
||||
roofByMapIndex = w.roofByMapIndex,
|
||||
spriteAssignment = yw.spriteAssignment or w.spriteAssignment,
|
||||
spritePalettes = yw.spritePalettes
|
||||
and setmetatable(yw.spritePalettes, { __index = w.spritePalettes })
|
||||
or w.spritePalettes,
|
||||
}
|
||||
end
|
||||
|
||||
function PaletteFX.usesGbcPack(mode)
|
||||
mode = mode or PaletteFX.mode
|
||||
return mode == "redpp"
|
||||
@@ -453,7 +487,10 @@ function PaletteFX.pal(data, name)
|
||||
if fromRom then return fromRom end
|
||||
end
|
||||
if GameVersion.isYellow() then
|
||||
if PaletteFX.usesYellowCgb() then
|
||||
-- OG YELLOW and Advanced both use CGBBase for named pals: SuperPalettes
|
||||
-- wash out yellows (title MEWMON/LOGO, YELLOWMON) to pale cream. World
|
||||
-- tile bake still comes from the Advanced GBC pack via worldPack (#1639).
|
||||
if PaletteFX.usesYellowCgb() or PaletteFX.usesGbcPack() then
|
||||
local fromCgb = yellowCgbNamedPal(data, name)
|
||||
if fromCgb then return fromCgb end
|
||||
end
|
||||
@@ -511,7 +548,8 @@ function PaletteFX.monPal(data, species, transformed)
|
||||
if pal then return pal end
|
||||
end
|
||||
local name = p.pokemon[species] or "MEWMON"
|
||||
if PaletteFX.usesYellowCgb() then
|
||||
if PaletteFX.usesYellowCgb()
|
||||
or (GameVersion.isYellow() and PaletteFX.usesGbcPack()) then
|
||||
local yc = PaletteFX.pal(data, name)
|
||||
if yc then return yc end
|
||||
end
|
||||
@@ -618,16 +656,14 @@ local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBel
|
||||
-- (false for a mod tileset with no pokered-gbc counterpart, or when the
|
||||
-- pack failed to load at all)
|
||||
function PaletteFX.hasWorldTileset(tileset)
|
||||
local pack = PaletteFX.gbcPack()
|
||||
local w = pack and pack.world
|
||||
local w = PaletteFX.worldPack()
|
||||
return (w and w.tileGroups[tileset]) ~= nil
|
||||
end
|
||||
|
||||
-- the palette-group (0-7) a tile GRAPHIC id resolves to in this tileset,
|
||||
-- with the current map's tile-id exceptions (if any) applied first
|
||||
function PaletteFX.worldGroupAt(tileset, mapId, tileId)
|
||||
local pack = PaletteFX.gbcPack()
|
||||
local w = pack and pack.world
|
||||
local w = PaletteFX.worldPack()
|
||||
local groups = w and w.tileGroups[tileset]
|
||||
if not groups then return nil end
|
||||
local exc = TILE_GROUP_EXCEPTIONS[mapId]
|
||||
@@ -642,8 +678,7 @@ end
|
||||
-- Saffron's roof colors while the player stands in its top 2 cell rows,
|
||||
-- like pokered's wYCoord check -- data is Game.data, for the map lookup)
|
||||
function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
|
||||
local pack = PaletteFX.gbcPack()
|
||||
local w = pack and pack.world
|
||||
local w = PaletteFX.worldPack()
|
||||
local base = w and w.groupColors[tileset]
|
||||
if not base then return nil end
|
||||
if not w.roofGroup[tileset] then return darkGroups(base) end
|
||||
@@ -678,17 +713,18 @@ end
|
||||
-- here): a stable hash instead, so the same NPC instance always shows the
|
||||
-- same one of the 4 SPR_PAL_* colors.
|
||||
function PaletteFX.spriteObp(spriteDef, seed)
|
||||
local pack = PaletteFX.gbcPack()
|
||||
local w = pack and pack.world
|
||||
local w = PaletteFX.worldPack()
|
||||
local src = spriteDef and (spriteDef.paletteSource or spriteDef.source)
|
||||
if not (w and src) then return nil end
|
||||
local idx = tonumber(src:match("%[(%d+)%]"))
|
||||
-- RedBikeSprite and SurfingPikachuSprite load outside
|
||||
-- SpriteSheetPointerTable, so their source has no bracketed index;
|
||||
-- they wear the player's OBP palette (spriteAssignment[0]).
|
||||
if not idx and (src:find("RedBikeSprite", 1, true)
|
||||
or src:find("SurfingPikachuSprite", 1, true)) then
|
||||
-- they wear the player's OBP palette (spriteAssignment[0]) on Red/Blue.
|
||||
-- On Yellow, Surfing Pikachu uses the Pikachu yellow OBJ group (#1639).
|
||||
if not idx and src:find("RedBikeSprite", 1, true) then
|
||||
idx = 0
|
||||
elseif not idx and src:find("SurfingPikachuSprite", 1, true) then
|
||||
idx = GameVersion.isYellow() and 60 or 0
|
||||
end
|
||||
local group = idx and w.spriteAssignment[idx]
|
||||
if group == nil then return nil end
|
||||
@@ -761,6 +797,18 @@ function PaletteFX.shadeMap()
|
||||
return shadeMap
|
||||
end
|
||||
|
||||
local function invalidateColorCaches()
|
||||
pcall(function() require("src.battle.BattleState").invalidate() end)
|
||||
pcall(function() require("src.render.SpriteRenderer").invalidate() end)
|
||||
pcall(function()
|
||||
require("src.world.MapLoader").invalidateAll()
|
||||
local Game = require("src.core.Game")
|
||||
if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then
|
||||
Game.overworld:reloadMap(Game.overworld.map.id, "colors")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function PaletteFX.setMode(mode)
|
||||
local prev = PaletteFX.mode
|
||||
local ok = false
|
||||
@@ -772,27 +820,35 @@ function PaletteFX.setMode(mode)
|
||||
end
|
||||
end
|
||||
if not ok then PaletteFX.mode = "gbc" end
|
||||
-- battle pics and overworld sprites bake the active pack into ImageData;
|
||||
-- drop those caches when the pack (or any COLORS mode) changes so the
|
||||
-- next draw re-tints
|
||||
if prev ~= PaletteFX.mode then
|
||||
pcall(function() require("src.battle.BattleState").invalidate() end)
|
||||
pcall(function() require("src.render.SpriteRenderer").invalidate() end)
|
||||
-- RED++'s baked tileset atlas (TileRenderer.getGbcAtlas) is built once
|
||||
-- per loaded map, so a mode toggle needs every cached Map/TileRenderer
|
||||
-- dropped and the currently-visible one rebuilt in place -- otherwise
|
||||
-- the on-screen map keeps its stale (wrong-mode) atlas until the next
|
||||
-- map transition happens to reload it.
|
||||
pcall(function()
|
||||
require("src.world.MapLoader").invalidateAll()
|
||||
local Game = require("src.core.Game")
|
||||
if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then
|
||||
Game.overworld:reloadMap(Game.overworld.map.id, "colors")
|
||||
end
|
||||
end)
|
||||
if prev ~= PaletteFX.mode then invalidateColorCaches() end
|
||||
end
|
||||
|
||||
PaletteFX.customRamp = nil
|
||||
|
||||
function PaletteFX.setCustomRamp(ramp)
|
||||
local prev = PaletteFX.customRamp
|
||||
PaletteFX.customRamp = ramp
|
||||
if (prev ~= nil) ~= (ramp ~= nil) or prev ~= ramp then
|
||||
invalidateColorCaches()
|
||||
end
|
||||
end
|
||||
|
||||
function PaletteFX.pickerActive()
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
local states = ok and Game.stack and Game.stack.states
|
||||
local top = states and states[#states]
|
||||
return top ~= nil and top.screenId == "PaletteScreen"
|
||||
end
|
||||
|
||||
-- Whether the active COLORS state needs a battle pic rendered as raw DMG
|
||||
-- grays instead of real colour, the same "forced-mono" contract OG/OG
|
||||
-- INV/CLASSIC already use (issue #207). A custom palette needs this too,
|
||||
-- since ensureZones re-thresholds the already-drawn frame. Keep this in
|
||||
-- sync with ensureZones and the mode checks in BattleState and WideBattle.
|
||||
function PaletteFX.forcesRawGrays()
|
||||
return PaletteFX.customRamp ~= nil
|
||||
end
|
||||
|
||||
function PaletteFX.cycleMode()
|
||||
local cur = PaletteFX.mode or "gbc"
|
||||
local idx = 1
|
||||
@@ -805,6 +861,13 @@ end
|
||||
|
||||
function PaletteFX.applyOptions(opts)
|
||||
PaletteFX.setMode(opts and opts.colors or "gbc")
|
||||
local id = opts and opts.palette
|
||||
if id and id ~= "" then
|
||||
local ok, Palette = pcall(require, "src.render.Palette")
|
||||
PaletteFX.setCustomRamp(ok and Palette.ramp(id) or nil)
|
||||
else
|
||||
PaletteFX.setCustomRamp(nil)
|
||||
end
|
||||
end
|
||||
|
||||
function PaletteFX.modeLabel(mode)
|
||||
@@ -817,12 +880,11 @@ function PaletteFX.modeLabel(mode)
|
||||
end
|
||||
|
||||
-- When a state exposes no SGB zones but COLORS needs a forced palette
|
||||
-- (OG / OG INV / CLASSIC), invent a whole-screen zone so the shade-remap
|
||||
-- shader still runs. GBC / RED++ / GBC INV leave nil alone (raw DMG canvas).
|
||||
function PaletteFX.ensureZones(zones)
|
||||
if zones and zones[1] then return zones end
|
||||
local mode = PaletteFX.mode or "gbc"
|
||||
if mode == "og" or mode == "og_inv" or mode == "classic" then
|
||||
if mode == "og" or mode == "og_inv" or mode == "classic"
|
||||
or (PaletteFX.forcesRawGrays() and not PaletteFX.pickerActive()) then
|
||||
return { PaletteFX.whole(PaletteFX.GRAYS) }
|
||||
end
|
||||
return zones
|
||||
@@ -852,7 +914,9 @@ function PaletteFX.effectiveColors(c)
|
||||
if not c then return nil end
|
||||
local mode = PaletteFX.mode or "gbc"
|
||||
local out = c
|
||||
if mode == "og" then
|
||||
if PaletteFX.customRamp and not PaletteFX.pickerActive() then
|
||||
out = PaletteFX.customRamp
|
||||
elseif mode == "og" then
|
||||
out = PaletteFX.GRAYS
|
||||
elseif mode == "og_inv" then
|
||||
out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP)
|
||||
|
||||
+25
-2
@@ -332,12 +332,14 @@ end
|
||||
|
||||
-- transparent: the world pass shows through (UI pass draws overlays only)
|
||||
function Renderer:beginFrame(transparent)
|
||||
self.uiOpaque = not transparent
|
||||
self.worldActive = false
|
||||
self.uprightActive = false
|
||||
self.worldOverride = nil
|
||||
-- warp-fade overlay from Transition (issue #121); cleared each frame so
|
||||
-- a popped transition cannot leave a sticky black veil
|
||||
self.worldFadeAlpha = nil
|
||||
self.worldFadeColor = nil
|
||||
-- battle-transition wipe, drawn over the whole surface (BattleTransition)
|
||||
self.battleWipe = nil
|
||||
-- whole-surface veil in screen space (battle-transition flash, the
|
||||
@@ -698,6 +700,18 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
|
||||
return
|
||||
end
|
||||
love.graphics.setShader(shader)
|
||||
-- data/sgb/sgb_packets.asm:123-127: zone 1 is the whole-screen ATTR_BLK,
|
||||
-- so the underpaint is only drawn when it leaves part of the box bare (#1866)
|
||||
local first = zoneList[1]
|
||||
if canvas == self.canvas and self.uiOpaque and first.colors
|
||||
and not (bx + first.x * zoneSx <= boxX
|
||||
and by + first.y * zoneSy <= boxY
|
||||
and bx + (first.x + first.w) * zoneSx >= boxX + boxW
|
||||
and by + (first.y + first.h) * zoneSy >= boxY + boxH) then
|
||||
PaletteFX.sendColors(shader, first.colors)
|
||||
love.graphics.setScissor(boxX, boxY, boxW, boxH)
|
||||
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
||||
end
|
||||
-- a colors == false zone is the trueColor opt-out: its rect draws with
|
||||
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
|
||||
-- list never toggles and issues exactly the calls it always did.
|
||||
@@ -971,6 +985,13 @@ function Renderer:endFrame(zones, worldZones)
|
||||
and not FaithfulRes.scaleCap() then
|
||||
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
|
||||
end
|
||||
-- UI LETTERBOX overrides whatever the rules above settled on, except
|
||||
-- under FAITHFUL RATIO's mobile lock, which promises black bars.
|
||||
if not FaithfulRes.scaleCap() then
|
||||
local Letterbox = require("src.render.Letterbox")
|
||||
clearR, clearG, clearB = Letterbox.fill(clearR, clearG, clearB,
|
||||
function() return PaletteFX.paperShade(Game and Game.data) end)
|
||||
end
|
||||
end
|
||||
if cut then
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
@@ -1045,7 +1066,8 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- the screen-space overlays the flat path draws over its composite
|
||||
local fade = self.worldFadeAlpha
|
||||
if fade and fade > 0 then
|
||||
love.graphics.setColor(0, 0, 0, fade)
|
||||
local c = self.worldFadeColor or { 0, 0, 0 }
|
||||
love.graphics.setColor(c[1], c[2], c[3], fade)
|
||||
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
@@ -1132,7 +1154,8 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- composite normally if one is ever stacked that way.
|
||||
local fade = self.worldFadeAlpha
|
||||
if fade and fade > 0 then
|
||||
love.graphics.setColor(0, 0, 0, fade)
|
||||
local c = self.worldFadeColor or { 0, 0, 0 }
|
||||
love.graphics.setColor(c[1], c[2], c[3], fade)
|
||||
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
+124
-9
@@ -11,6 +11,12 @@ local UIVisibility = require("src.battle.UIVisibility")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local Timing = require("src.core.Timing")
|
||||
|
||||
local Chrome2
|
||||
do
|
||||
local ok, v = pcall(require, "src.ui.gen2.Chrome")
|
||||
Chrome2 = ok and v or nil
|
||||
end
|
||||
|
||||
local TextBox = {}
|
||||
TextBox.__index = TextBox
|
||||
TextBox.isTextBox = true
|
||||
@@ -22,6 +28,52 @@ local MAX_COLS = 18
|
||||
-- pokegold constants/ram_constants.asm: TEXT_DELAY_FAST/MED/SLOW = 1/3/5
|
||||
local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 }
|
||||
|
||||
-- TextCommand_PAUSE (home/text.asm:492-504): a mid-string wait callers
|
||||
-- embed as TextBox.PAUSE, stripped here and re-anchored after pagination.
|
||||
TextBox.PAUSE = "\1"
|
||||
local PAUSE_FRAMES = 30
|
||||
|
||||
local function glyphs(str)
|
||||
return #Font.split((str:gsub("[\n\v\f]", "")))
|
||||
end
|
||||
|
||||
local function stripPauses(text)
|
||||
if not text:find(TextBox.PAUSE, 1, true) then return text, nil end
|
||||
local out, marks, count, pos = {}, {}, 0, 1
|
||||
while true do
|
||||
local i = text:find(TextBox.PAUSE, pos, true)
|
||||
if not i then
|
||||
out[#out + 1] = text:sub(pos)
|
||||
break
|
||||
end
|
||||
local chunk = text:sub(pos, i - 1)
|
||||
out[#out + 1] = chunk
|
||||
count = count + glyphs(chunk)
|
||||
marks[#marks + 1] = count
|
||||
pos = i + 1
|
||||
end
|
||||
return table.concat(out), marks
|
||||
end
|
||||
|
||||
-- glyph offsets into the whole text -> [page][line][char]
|
||||
local function mapPauses(pages, marks)
|
||||
local at, acc, mi = {}, 0, 1
|
||||
for pi, page in ipairs(pages) do
|
||||
for li, line in ipairs(page) do
|
||||
local n = #Font.split(line)
|
||||
while mi <= #marks and marks[mi] <= acc + n do
|
||||
local ci = marks[mi] - acc
|
||||
at[pi] = at[pi] or {}
|
||||
at[pi][li] = at[pi][li] or {}
|
||||
at[pi][li][ci] = mi
|
||||
mi = mi + 1
|
||||
end
|
||||
acc = acc + n
|
||||
end
|
||||
end
|
||||
return at
|
||||
end
|
||||
|
||||
-- opts.choice: when the last page has typed out, a YES/NO ChoiceBox pops
|
||||
-- up over the still-visible text (YesNoChoicePokeCenter and friends);
|
||||
-- the box then closes and choice(yes) runs instead of onDone.
|
||||
@@ -60,6 +112,8 @@ function TextBox.new(game, text, onDone, opts)
|
||||
self.stay = opts and opts.stay
|
||||
-- engine/events/hidden_events/cinnabar_gym_quiz.asm:119
|
||||
self.preSound = opts and opts.preSound
|
||||
-- pokegold engine/overworld/scripting.asm:485 WaitSFX
|
||||
self.sfxWait = opts and opts.sfxWait
|
||||
-- opts.instant: put the LAST page up already typed, with no typewriter and
|
||||
-- no page waits. A `yesorno` follows a `writetext` that has already been
|
||||
-- read, so re-typing the line under the YES/NO box would be wrong -- the
|
||||
@@ -75,7 +129,13 @@ function TextBox.new(game, text, onDone, opts)
|
||||
self.line1Y = (self.boxTy + 2) * 8
|
||||
self.line2Y = (self.boxTy + 4) * 8
|
||||
text = TextBox.substitute(game, text)
|
||||
local marks
|
||||
text, marks = stripPauses(text)
|
||||
self.pages = TextBox.paginate(text, self.maxCols)
|
||||
-- opts.pauseSounds[i] is the sfx the i-th marker fires once its wait is
|
||||
-- over (text_asm SFX_SWAP, engine/pokemon/learn_move.asm:210-213)
|
||||
self.pauseSounds = opts and opts.pauseSounds
|
||||
self.pauseAt = marks and mapPauses(self.pages, marks) or nil
|
||||
self.pageIndex = 1
|
||||
self.lineIndex = 1
|
||||
self.charIndex = 0
|
||||
@@ -270,6 +330,14 @@ function TextBox:visibleText()
|
||||
return #out > 0 and out or nil
|
||||
end
|
||||
|
||||
-- pokegold engine/overworld/scripting.asm:484-485 PlaySFX / WaitSFX
|
||||
function TextBox:sfxHeld()
|
||||
if not self.sfxWait then return false end
|
||||
if require("src.core.Sound").sfxBusy() then return true end
|
||||
self.sfxWait = nil
|
||||
return false
|
||||
end
|
||||
|
||||
function TextBox:update(dt)
|
||||
local input = self.game.input
|
||||
self.blink = (self.blink + 1) % 60
|
||||
@@ -291,6 +359,20 @@ function TextBox:update(dt)
|
||||
self.holdFrames = self.holdFrames - 1
|
||||
return
|
||||
end
|
||||
-- home/text.asm:492
|
||||
if self.pauseFrames then
|
||||
if self.pauseFrames > 0 then
|
||||
self.pauseFrames = self.pauseFrames - 1
|
||||
return
|
||||
end
|
||||
self.pauseFrames = nil
|
||||
local snd = self.pauseSounds and self.pauseSounds[self.pauseMark]
|
||||
if type(snd) == "function" then
|
||||
snd()
|
||||
elseif snd then
|
||||
require("src.core.Sound").play(self.game.data, snd)
|
||||
end
|
||||
end
|
||||
if self.done then
|
||||
-- opts.stay: the box is finished but stays up under whatever the caller
|
||||
-- pushed over it; StateStack updates the top state only, so this runs
|
||||
@@ -372,6 +454,7 @@ function TextBox:update(dt)
|
||||
end
|
||||
return
|
||||
end
|
||||
if self:sfxHeld() then return end
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
self.game.stack:pop()
|
||||
@@ -387,6 +470,7 @@ function TextBox:update(dt)
|
||||
self.preWait = self.preWait - 1
|
||||
return
|
||||
end
|
||||
if self:sfxHeld() then return end
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
self.waiting = false
|
||||
@@ -424,6 +508,15 @@ function TextBox:update(dt)
|
||||
self.charIndex = self.charIndex + 1
|
||||
local line = self.shown[#self.shown]
|
||||
line[#line + 1] = self.codes[self.charIndex]
|
||||
local marks = self.pauseAt and self.pauseAt[self.pageIndex]
|
||||
marks = marks and marks[self.lineIndex]
|
||||
if marks and marks[self.charIndex] then
|
||||
self.pauseMark = marks[self.charIndex]
|
||||
-- TextCommand_PAUSE reads hJoyHeld, so a held A/B skips the wait
|
||||
self.pauseFrames = (input:isDown("a") or input:isDown("b"))
|
||||
and 0 or PAUSE_FRAMES
|
||||
break
|
||||
end
|
||||
else
|
||||
-- line finished
|
||||
local page = self.pages[self.pageIndex]
|
||||
@@ -470,8 +563,21 @@ function TextBox:draw()
|
||||
-- (pokegold engine/pokegear/pokegear.asm TownMapPals sends every tile
|
||||
-- >= $60 to palette 0). Gen 1's Game has no textboxPaper, so it stays nil.
|
||||
local paper = self.game and self.game.textboxPaper and self.game:textboxPaper()
|
||||
Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, paper)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
|
||||
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 drawGlyph, finishGlyph = Font.drawCode, nil
|
||||
if Chrome then
|
||||
local base = paper and { paper, paper, paper, { 0, 0, 0 } }
|
||||
or Chrome.DEFAULT_BOX_PALETTE
|
||||
Chrome.paletteBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, base)
|
||||
local _, dg, fg = Chrome.paletteGlyphs(base)
|
||||
drawGlyph, finishGlyph = dg, fg
|
||||
else
|
||||
Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, paper)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
if self.scrollPx and self.scrollPx > 0 then
|
||||
self.scrollPx = self.scrollPx - 2
|
||||
if self.scrollPx <= 0 then self.scrollPx = nil end
|
||||
@@ -491,17 +597,25 @@ function TextBox:draw()
|
||||
-- measured with; every fixed-width page still lands on the 8px grid
|
||||
local pen = self.textX
|
||||
for _, code in ipairs(line) do
|
||||
Font.drawCode(code, pen, y)
|
||||
drawGlyph(code, pen, y)
|
||||
pen = pen + Font.advanceOf(code)
|
||||
end
|
||||
end
|
||||
if self.money then
|
||||
-- money box (engine/menus/text_box.asm:130): DisplayMoneyBox at
|
||||
-- hlcoord 11,0, the amount right-aligned on its middle row
|
||||
Font.drawBox(11, 0, 9, 3)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if Chrome then
|
||||
Chrome.paletteBox(11, 0, 9, 3, Chrome.DEFAULT_BOX_PALETTE)
|
||||
else
|
||||
Font.drawBox(11, 0, 9, 3)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
local money = ("¥%d"):format(self.money() or 0)
|
||||
Font.draw(money, 152 - Font.width(money), 8)
|
||||
local pen = 152 - Font.width(money)
|
||||
for _, code in ipairs(Font.encode(money)) do
|
||||
drawGlyph(code, pen, 8)
|
||||
pen = pen + Font.advanceOf(code)
|
||||
end
|
||||
end
|
||||
if (self.waiting or (self.done and not self.choice and not self.auto
|
||||
and (not self.stay
|
||||
@@ -509,10 +623,11 @@ function TextBox:draw()
|
||||
and self.blink < 30 then
|
||||
-- page-advance cursor: glyph $EE by default, the blinking down arrow
|
||||
-- the original prints via `ld a, "▼"` (home/text.asm)
|
||||
Font.drawCode(Theme.moreArrow or 0xEE,
|
||||
(self.boxTx + self.boxTw - 2) * 8,
|
||||
(self.boxTy + self.boxTh - 1) * 8 - 4)
|
||||
drawGlyph(Theme.moreArrow or 0xEE,
|
||||
(self.boxTx + self.boxTw - 2) * 8,
|
||||
(self.boxTy + self.boxTh - 1) * 8 - 4)
|
||||
end
|
||||
if finishGlyph then finishGlyph() end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
@@ -150,13 +150,10 @@ end
|
||||
|
||||
-- true while the spinner arrow tiles should show the 'blur' graphic; false
|
||||
-- means draw nothing extra (the static window tile shows through,
|
||||
-- matching the asm's restore-to-original behavior). The 8-tick
|
||||
-- half-period approximates one GB movement step (2px/frame); this is a
|
||||
-- deliberate approximation of wSimulatedJoypadStatesIndex bit-0 parity, not
|
||||
-- a cycle-accurate replication -- the port's tweened scriptMove has no
|
||||
-- direct equivalent discrete step counter.
|
||||
-- matching the asm's restore-to-original behavior).
|
||||
-- spinners.asm:17-22, home/overworld.asm:1844-1846, :49-52
|
||||
function TileRenderer.spinBlurActive()
|
||||
return spinning and (math.floor(animFrame / 8) % 2 == 0)
|
||||
return spinning and (math.floor(animFrame / 16) % 2 == 0)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
@@ -65,16 +65,24 @@ end
|
||||
-- (ViridianGym.asm .afterBeat, RocketHideoutB4F BeatGiovanniScript) call
|
||||
-- GBFadeOutToBlack -> GBFadeInFromBlack instead, so the default keeps the
|
||||
-- symmetric 32-frame fade back in (home/fade.asm:21, b = 4).
|
||||
function Transition.new(game, onMidpoint, onDone, warp)
|
||||
--
|
||||
-- opts.color = {r,g,b} in 0..1 (default black). opts.frames / opts.framesIn
|
||||
-- override duration. Fly/Teleport/Dig/Escape Rope use white
|
||||
-- GBFadeOutToWhite / GBFadeInFromWhite (#1644).
|
||||
function Transition.new(game, onMidpoint, onDone, warp, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, Transition)
|
||||
self.game = game
|
||||
self.onMidpoint = onMidpoint
|
||||
self.onDone = onDone
|
||||
self.t = 0
|
||||
self.phase = "out"
|
||||
self.color = opts.color or { 0, 0, 0 }
|
||||
local style = styleOf(game, "warp_fade")
|
||||
self.frames = style.frames or FRAMES
|
||||
if warp then
|
||||
self.frames = opts.frames or style.frames or FRAMES
|
||||
if opts.framesIn ~= nil then
|
||||
self.framesIn = opts.framesIn
|
||||
elseif warp then
|
||||
-- a style may still ask for a fade in (mods, and the record is
|
||||
-- data-driven); the built-in warp is 0, matching hardware
|
||||
self.framesIn = style.framesIn or FRAMES_IN
|
||||
@@ -125,6 +133,7 @@ end
|
||||
|
||||
function Transition:draw()
|
||||
local alpha = self:alpha()
|
||||
local c = self.color or { 0, 0, 0 }
|
||||
-- Survey zoom draws the overworld into a window-filling world canvas
|
||||
-- while the UI pass stays the classic 160x144 letterbox. A rect on the
|
||||
-- UI canvas only darkens that center box (issue #121); when the world
|
||||
@@ -133,9 +142,10 @@ function Transition:draw()
|
||||
local r = self.game and self.game.renderer
|
||||
if r and r.worldActive then
|
||||
r.worldFadeAlpha = alpha
|
||||
r.worldFadeColor = c
|
||||
return
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, alpha)
|
||||
love.graphics.setColor(c[1], c[2], c[3], alpha)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
-- GENERATED by tools/gen2_sram_offsets.py. Do not edit by hand.
|
||||
-- Regenerate from a pret/pokegold + pret/pokecrystal build; see that
|
||||
-- script's header for the derivation and the assertion behind it.
|
||||
local Gen2Layout = {}
|
||||
|
||||
Gen2Layout.goldSilver = {
|
||||
sCheckValue1 = 0x2008,
|
||||
sCheckValue2 = 0x2D6B,
|
||||
sChecksum = 0x2D69,
|
||||
sGameData = 0x2009,
|
||||
sGameDataEnd = 0x2D69,
|
||||
wPlayerName = 0x200B,
|
||||
wPlayerID = 0x2009,
|
||||
wMoney = 0x23DB,
|
||||
wCoins = 0x23E2,
|
||||
wBadges = 0x23E4,
|
||||
wKantoBadges = 0x23E5,
|
||||
wRivalName = 0x2021,
|
||||
wMomsName = 0x2016,
|
||||
wPartyCount = 0x288A,
|
||||
wPartySpecies = 0x288B,
|
||||
wPartyMons = 0x2892,
|
||||
wPartyMonNicknames = 0x29F4,
|
||||
wPartyMonOTs = 0x29B2,
|
||||
wNumItems = 0x241F,
|
||||
wItems = 0x2420,
|
||||
wNumKeyItems = 0x2449,
|
||||
wKeyItems = 0x244A,
|
||||
wNumBalls = 0x2464,
|
||||
wBalls = 0x2465,
|
||||
wTMsHMs = 0x23E6,
|
||||
wPokedexCaught = 0x2A4C,
|
||||
wPokedexSeen = 0x2A6C,
|
||||
wCurBox = 0x2724,
|
||||
wBoxNames = 0x2727,
|
||||
wMapGroup = 0x2868,
|
||||
wMapNumber = 0x2869,
|
||||
wXCoord = 0x286B,
|
||||
wYCoord = 0x286A,
|
||||
wEventFlags = 0x261F,
|
||||
wPlayerState = 0x24EA,
|
||||
wGameTimeHours = 0x2053,
|
||||
wGameTimeMinutes = 0x2055,
|
||||
-- The 14 archived boxes, listed rather than strided (see BOX_COUNT).
|
||||
boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 },
|
||||
}
|
||||
|
||||
Gen2Layout.crystal = {
|
||||
sCheckValue1 = 0x2008,
|
||||
sCheckValue2 = 0x2D0F,
|
||||
sChecksum = 0x2D0D,
|
||||
sGameData = 0x2009,
|
||||
sGameDataEnd = 0x2B83,
|
||||
wPlayerName = 0x200B,
|
||||
wPlayerID = 0x2009,
|
||||
wMoney = 0x23DC,
|
||||
wCoins = 0x23E3,
|
||||
wBadges = 0x23E5,
|
||||
wKantoBadges = 0x23E6,
|
||||
wRivalName = 0x2021,
|
||||
wMomsName = 0x2016,
|
||||
wPartyCount = 0x2865,
|
||||
wPartySpecies = 0x2866,
|
||||
wPartyMons = 0x286D,
|
||||
wPartyMonNicknames = 0x29CF,
|
||||
wPartyMonOTs = 0x298D,
|
||||
wNumItems = 0x2420,
|
||||
wItems = 0x2421,
|
||||
wNumKeyItems = 0x244A,
|
||||
wKeyItems = 0x244B,
|
||||
wNumBalls = 0x2465,
|
||||
wBalls = 0x2466,
|
||||
wTMsHMs = 0x23E7,
|
||||
wPokedexCaught = 0x2A27,
|
||||
wPokedexSeen = 0x2A47,
|
||||
wCurBox = 0x2700,
|
||||
wBoxNames = 0x2703,
|
||||
wMapGroup = 0x2843,
|
||||
wMapNumber = 0x2844,
|
||||
wXCoord = 0x2846,
|
||||
wYCoord = 0x2845,
|
||||
wEventFlags = 0x2600,
|
||||
wPlayerState = 0x24EB,
|
||||
wGameTimeHours = 0x2052,
|
||||
wGameTimeMinutes = 0x2054,
|
||||
-- The 14 archived boxes, listed rather than strided (see BOX_COUNT).
|
||||
boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 },
|
||||
-- The backup copy the game falls back to when the primary
|
||||
-- checksum fails. Same shape, shifted.
|
||||
backup = {
|
||||
sCheckValue1 = 0x1208,
|
||||
sCheckValue2 = 0x1F0F,
|
||||
sChecksum = 0x1F0D,
|
||||
sGameData = 0x1209,
|
||||
sGameDataEnd = 0x1D83,
|
||||
wPlayerName = 0x120B,
|
||||
wPlayerID = 0x1209,
|
||||
wMoney = 0x15DC,
|
||||
wCoins = 0x15E3,
|
||||
wBadges = 0x15E5,
|
||||
wKantoBadges = 0x15E6,
|
||||
wRivalName = 0x1221,
|
||||
wMomsName = 0x1216,
|
||||
wPartyCount = 0x1A65,
|
||||
wPartySpecies = 0x1A66,
|
||||
wPartyMons = 0x1A6D,
|
||||
wPartyMonNicknames = 0x1BCF,
|
||||
wPartyMonOTs = 0x1B8D,
|
||||
wNumItems = 0x1620,
|
||||
wItems = 0x1621,
|
||||
wNumKeyItems = 0x164A,
|
||||
wKeyItems = 0x164B,
|
||||
wNumBalls = 0x1665,
|
||||
wBalls = 0x1666,
|
||||
wTMsHMs = 0x15E7,
|
||||
wPokedexCaught = 0x1C27,
|
||||
wPokedexSeen = 0x1C47,
|
||||
wCurBox = 0x1900,
|
||||
wBoxNames = 0x1903,
|
||||
wMapGroup = 0x1A43,
|
||||
wMapNumber = 0x1A44,
|
||||
wXCoord = 0x1A46,
|
||||
wYCoord = 0x1A45,
|
||||
wEventFlags = 0x1800,
|
||||
wPlayerState = 0x16EB,
|
||||
wGameTimeHours = 0x1252,
|
||||
wGameTimeMinutes = 0x1254,
|
||||
boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 },
|
||||
},
|
||||
}
|
||||
|
||||
Gen2Layout.charmap = {
|
||||
[0x05] = "ガ",
|
||||
[0x06] = "ギ",
|
||||
[0x07] = "グ",
|
||||
[0x08] = "ゲ",
|
||||
[0x09] = "ゴ",
|
||||
[0x0A] = "ザ",
|
||||
[0x0B] = "ジ",
|
||||
[0x0C] = "ズ",
|
||||
[0x0D] = "ゼ",
|
||||
[0x0E] = "ゾ",
|
||||
[0x0F] = "ダ",
|
||||
[0x10] = "ヂ",
|
||||
[0x11] = "ヅ",
|
||||
[0x12] = "デ",
|
||||
[0x13] = "ド",
|
||||
[0x19] = "バ",
|
||||
[0x1A] = "ビ",
|
||||
[0x1B] = "ブ",
|
||||
[0x1C] = "ボ",
|
||||
[0x26] = "が",
|
||||
[0x27] = "ぎ",
|
||||
[0x28] = "ぐ",
|
||||
[0x29] = "げ",
|
||||
[0x2A] = "ご",
|
||||
[0x2B] = "ざ",
|
||||
[0x2C] = "じ",
|
||||
[0x2D] = "ず",
|
||||
[0x2E] = "ぜ",
|
||||
[0x2F] = "ぞ",
|
||||
[0x30] = "だ",
|
||||
[0x31] = "ぢ",
|
||||
[0x32] = "づ",
|
||||
[0x33] = "で",
|
||||
[0x34] = "ど",
|
||||
[0x3A] = "ば",
|
||||
[0x3B] = "び",
|
||||
[0x3C] = "ぶ",
|
||||
[0x3D] = "べ",
|
||||
[0x3E] = "ぼ",
|
||||
[0x3F] = "⁂",
|
||||
[0x40] = "パ",
|
||||
[0x41] = "ピ",
|
||||
[0x42] = "プ",
|
||||
[0x43] = "ポ",
|
||||
[0x44] = "ぱ",
|
||||
[0x45] = "ぴ",
|
||||
[0x46] = "ぷ",
|
||||
[0x47] = "ぺ",
|
||||
[0x48] = "ぽ",
|
||||
[0x50] = "@",
|
||||
[0x54] = "#",
|
||||
[0x60] = "■",
|
||||
[0x61] = "▲",
|
||||
[0x62] = "☎",
|
||||
[0x6E] = "ぃ",
|
||||
[0x6F] = "ぅ",
|
||||
[0x70] = "PO",
|
||||
[0x71] = "KE",
|
||||
[0x72] = "“",
|
||||
[0x73] = "”",
|
||||
[0x74] = "·",
|
||||
[0x75] = "…",
|
||||
[0x76] = "ぁ",
|
||||
[0x77] = "ぇ",
|
||||
[0x78] = "ぉ",
|
||||
[0x79] = "┌",
|
||||
[0x7A] = "─",
|
||||
[0x7B] = "┐",
|
||||
[0x7C] = "│",
|
||||
[0x7D] = "└",
|
||||
[0x7E] = "┘",
|
||||
[0x7F] = " ",
|
||||
[0x80] = "A",
|
||||
[0x81] = "B",
|
||||
[0x82] = "C",
|
||||
[0x83] = "D",
|
||||
[0x84] = "E",
|
||||
[0x85] = "F",
|
||||
[0x86] = "G",
|
||||
[0x87] = "H",
|
||||
[0x88] = "I",
|
||||
[0x89] = "J",
|
||||
[0x8A] = "K",
|
||||
[0x8B] = "L",
|
||||
[0x8C] = "M",
|
||||
[0x8D] = "N",
|
||||
[0x8E] = "O",
|
||||
[0x8F] = "P",
|
||||
[0x90] = "Q",
|
||||
[0x91] = "R",
|
||||
[0x92] = "S",
|
||||
[0x93] = "T",
|
||||
[0x94] = "U",
|
||||
[0x95] = "V",
|
||||
[0x96] = "W",
|
||||
[0x97] = "X",
|
||||
[0x98] = "Y",
|
||||
[0x99] = "Z",
|
||||
[0x9A] = "(",
|
||||
[0x9B] = ")",
|
||||
[0x9C] = ":",
|
||||
[0x9D] = ";",
|
||||
[0x9E] = "[",
|
||||
[0x9F] = "]",
|
||||
[0xA0] = "a",
|
||||
[0xA1] = "b",
|
||||
[0xA2] = "c",
|
||||
[0xA3] = "d",
|
||||
[0xA4] = "e",
|
||||
[0xA5] = "f",
|
||||
[0xA6] = "g",
|
||||
[0xA7] = "h",
|
||||
[0xA8] = "i",
|
||||
[0xA9] = "j",
|
||||
[0xAA] = "k",
|
||||
[0xAB] = "l",
|
||||
[0xAC] = "m",
|
||||
[0xAD] = "n",
|
||||
[0xAE] = "o",
|
||||
[0xAF] = "p",
|
||||
[0xB0] = "q",
|
||||
[0xB1] = "r",
|
||||
[0xB2] = "s",
|
||||
[0xB3] = "t",
|
||||
[0xB4] = "u",
|
||||
[0xB5] = "v",
|
||||
[0xB6] = "w",
|
||||
[0xB7] = "x",
|
||||
[0xB8] = "y",
|
||||
[0xB9] = "z",
|
||||
[0xBA] = "こ",
|
||||
[0xBB] = "さ",
|
||||
[0xBC] = "し",
|
||||
[0xBD] = "す",
|
||||
[0xBE] = "せ",
|
||||
[0xBF] = "そ",
|
||||
[0xC0] = "Ä",
|
||||
[0xC1] = "Ö",
|
||||
[0xC2] = "Ü",
|
||||
[0xC3] = "ä",
|
||||
[0xC4] = "ö",
|
||||
[0xC5] = "ü",
|
||||
[0xC6] = "に",
|
||||
[0xC7] = "ぬ",
|
||||
[0xC8] = "ね",
|
||||
[0xC9] = "の",
|
||||
[0xCA] = "は",
|
||||
[0xCB] = "ひ",
|
||||
[0xCC] = "ふ",
|
||||
[0xCD] = "へ",
|
||||
[0xCE] = "ほ",
|
||||
[0xCF] = "ま",
|
||||
[0xD0] = "'d",
|
||||
[0xD1] = "'l",
|
||||
[0xD2] = "'m",
|
||||
[0xD3] = "'r",
|
||||
[0xD4] = "'s",
|
||||
[0xD5] = "'t",
|
||||
[0xD6] = "'v",
|
||||
[0xD7] = "ら",
|
||||
[0xD8] = "り",
|
||||
[0xD9] = "る",
|
||||
[0xDA] = "れ",
|
||||
[0xDB] = "ろ",
|
||||
[0xDC] = "わ",
|
||||
[0xDD] = "を",
|
||||
[0xDE] = "ん",
|
||||
[0xDF] = "←",
|
||||
[0xE0] = "'",
|
||||
[0xE1] = "PK",
|
||||
[0xE2] = "MN",
|
||||
[0xE3] = "-",
|
||||
[0xE4] = "゚",
|
||||
[0xE5] = "゙",
|
||||
[0xE6] = "?",
|
||||
[0xE7] = "!",
|
||||
[0xE8] = ".",
|
||||
[0xE9] = "&",
|
||||
[0xEA] = "é",
|
||||
[0xEB] = "→",
|
||||
[0xEC] = "▷",
|
||||
[0xED] = "▶",
|
||||
[0xEE] = "▼",
|
||||
[0xEF] = "♂",
|
||||
[0xF0] = "¥",
|
||||
[0xF1] = "×",
|
||||
[0xF2] = ".",
|
||||
[0xF3] = "/",
|
||||
[0xF4] = ",",
|
||||
[0xF5] = "♀",
|
||||
[0xF6] = "0",
|
||||
[0xF7] = "1",
|
||||
[0xF8] = "2",
|
||||
[0xF9] = "3",
|
||||
[0xFA] = "4",
|
||||
[0xFB] = "5",
|
||||
[0xFC] = "6",
|
||||
[0xFD] = "7",
|
||||
[0xFE] = "8",
|
||||
[0xFF] = "9",
|
||||
}
|
||||
|
||||
return Gen2Layout
|
||||
@@ -0,0 +1,690 @@
|
||||
-- Vanilla Gen 2 (Gold/Silver/Crystal) raw SRAM <-> src/core/gen2/Save.lua.
|
||||
-- Companion to GenSave.lua, which is Gen 1 only.
|
||||
--
|
||||
-- Offsets come from Gen2Layout.lua, generated by tools/gen2_sram_offsets.py.
|
||||
-- Gold and Silver share a layout; Crystal does not.
|
||||
--
|
||||
-- Pure Lua, no love.* at require time, same as GenSave.
|
||||
|
||||
local Gen2Layout = require("src.save_convert.Gen2Layout")
|
||||
|
||||
local Gen2Save = {}
|
||||
|
||||
Gen2Save.SAVE_SIZE = 32768
|
||||
Gen2Save.PARTY_STRUCT = 48
|
||||
Gen2Save.NAME_LENGTH = 11
|
||||
Gen2Save.PARTY_LENGTH = 6
|
||||
|
||||
function Gen2Save.layoutFor(gameVersion)
|
||||
if gameVersion == "crystal" then return Gen2Layout.crystal end
|
||||
if gameVersion == "gold" or gameVersion == "silver" then return Gen2Layout.goldSilver end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function u8(b, o) return b:byte(o + 1) end
|
||||
local function be(b, o, n)
|
||||
local v = 0
|
||||
for i = 0, n - 1 do v = v * 256 + b:byte(o + i + 1) end
|
||||
return v
|
||||
end
|
||||
|
||||
-- The cart's text encoding, from Gen2Layout.charmap. 0x50 terminates.
|
||||
local function text(b, o, n)
|
||||
local out = {}
|
||||
for i = 0, n - 1 do
|
||||
local c = b:byte(o + i + 1)
|
||||
if not c or c == 0x50 then break end
|
||||
out[#out + 1] = Gen2Layout.charmap[c] or "?"
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- Both check values AND the 16-bit sum between them. A blank SRAM sums to a
|
||||
-- valid 0 == 0, so the check values are what reject it.
|
||||
function Gen2Save.checksumValid(bytes, L)
|
||||
if #bytes < Gen2Save.SAVE_SIZE then return nil end
|
||||
if u8(bytes, L.sCheckValue1) ~= 0x63 then return false end
|
||||
if u8(bytes, L.sCheckValue2) ~= 0x7F then return false end
|
||||
local sum = 0
|
||||
for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + u8(bytes, i)) % 65536 end
|
||||
local stored = u8(bytes, L.sChecksum) + u8(bytes, L.sChecksum + 1) * 256
|
||||
return sum == stored
|
||||
end
|
||||
|
||||
-- The cart stores numbers, the engine is keyed by name. Same shape
|
||||
-- GenSave.crosswalks builds for Gen 1. Without `data` the raw numbers survive.
|
||||
local function byIndex(defs)
|
||||
local out = {}
|
||||
for id, def in pairs(defs or {}) do
|
||||
if type(def) == "table" and def.index ~= nil then out[def.index] = id end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function toIndex(defs)
|
||||
local out = {}
|
||||
for id, def in pairs(defs or {}) do
|
||||
if type(def) == "table" and def.index ~= nil then out[id] = def.index end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Gen2Save.crosswalks(data)
|
||||
data = data or {}
|
||||
local items = byIndex(data.items)
|
||||
-- Maps are found by (group, number) rather than by a flat index.
|
||||
local maps = {}
|
||||
for id, def in pairs(data.maps or {}) do
|
||||
if type(def) == "table" and def.group and def.map then
|
||||
maps[def.group * 256 + def.map] = id
|
||||
end
|
||||
end
|
||||
local mapIds = {}
|
||||
for id, def in pairs(data.maps or {}) do
|
||||
if type(def) == "table" and def.group and def.map then
|
||||
mapIds[id] = { def.group, def.map }
|
||||
end
|
||||
end
|
||||
return {
|
||||
pokemon = byIndex(data.pokemon),
|
||||
moves = byIndex(data.moves),
|
||||
items = items,
|
||||
maps = maps,
|
||||
pokemonIndex = toIndex(data.pokemon),
|
||||
moveIndex = toIndex(data.moves),
|
||||
itemIndex = toIndex(data.items),
|
||||
mapIds = mapIds,
|
||||
itemDefs = data.items or {},
|
||||
}
|
||||
end
|
||||
|
||||
-- Name if the crosswalk knows it, the raw number if not. Never silently drops
|
||||
-- a value: an id this build cannot name is still the player's.
|
||||
local function named(map, index)
|
||||
if index == nil or index == 0 then return nil end
|
||||
return map[index] or index
|
||||
end
|
||||
|
||||
-- constants/battle_constants.asm: SLP_MASK is bits 0-2, PSN=3 BRN=4 FRZ=5
|
||||
-- PAR=6. Engine wants an ItemEffects.STATUS_CLASS key, nil when healthy.
|
||||
local STATUS_BITS = { { 3, "psn" }, { 4, "brn" }, { 5, "frz" }, { 6, "par" } }
|
||||
|
||||
local function encodeStatus(name, turns)
|
||||
if name == "slp" then return math.min(math.max(turns or 1, 1), 7) end
|
||||
for _, row in ipairs(STATUS_BITS) do
|
||||
if row[2] == name then return 2 ^ row[1] end
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
local function decodeStatus(byte)
|
||||
local turns = byte % 8
|
||||
if turns > 0 then return "slp", turns end
|
||||
for _, row in ipairs(STATUS_BITS) do
|
||||
if math.floor(byte / 2 ^ row[1]) % 2 == 1 then return row[2], nil end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
-- Four DVs as nibbles in two bytes; the HP DV is rebuilt from the low bit of
|
||||
-- each (pokecrystal engine/pokemon/health.asm).
|
||||
local function decodeDVs(bytes, o)
|
||||
local hi, lo = u8(bytes, o), u8(bytes, o + 1)
|
||||
local atk = math.floor(hi / 16)
|
||||
local def = hi % 16
|
||||
local spd = math.floor(lo / 16)
|
||||
local spc = lo % 16
|
||||
local hp = (atk % 2) * 8 + (def % 2) * 4 + (spd % 2) * 2 + (spc % 2)
|
||||
return { hp = hp, attack = atk, defense = def, speed = spd, special = spc }
|
||||
end
|
||||
|
||||
-- Five 16-bit stat experience words, in the cart's order.
|
||||
local function decodeStatExp(bytes, o)
|
||||
return {
|
||||
hp = be(bytes, o, 2), attack = be(bytes, o + 2, 2),
|
||||
defense = be(bytes, o + 4, 2), speed = be(bytes, o + 6, 2),
|
||||
special = be(bytes, o + 8, 2),
|
||||
}
|
||||
end
|
||||
|
||||
-- The first 32 bytes, which a box mon and a party mon share.
|
||||
local function decodeSharedMon(bytes, o, x)
|
||||
local moves, pp = {}, {}
|
||||
for i = 0, 3 do
|
||||
local id = named(x.moves, u8(bytes, o + 2 + i))
|
||||
if id then moves[#moves + 1] = id end
|
||||
end
|
||||
for i = 0, 3 do pp[i + 1] = u8(bytes, o + 0x17 + i) % 64 end
|
||||
return {
|
||||
species = named(x.pokemon, u8(bytes, o)),
|
||||
item = named(x.items, u8(bytes, o + 1)),
|
||||
moves = moves, pp = pp,
|
||||
otId = be(bytes, o + 6, 2),
|
||||
experience = be(bytes, o + 8, 3),
|
||||
statExp = decodeStatExp(bytes, o + 0x0B),
|
||||
dvs = decodeDVs(bytes, o + 0x15),
|
||||
happiness = u8(bytes, o + 0x1B),
|
||||
pokerus = u8(bytes, o + 0x1C),
|
||||
caughtData = be(bytes, o + 0x1D, 2),
|
||||
level = u8(bytes, o + 0x1F),
|
||||
}
|
||||
end
|
||||
|
||||
local function decodeMon(bytes, o, x)
|
||||
local mon = decodeSharedMon(bytes, o, x)
|
||||
mon.status, mon.statusTurns = decodeStatus(u8(bytes, o + 0x20))
|
||||
mon.hp = be(bytes, o + 0x22, 2)
|
||||
mon.maxHp = be(bytes, o + 0x24, 2)
|
||||
mon.stats = {
|
||||
hp = mon.maxHp,
|
||||
attack = be(bytes, o + 0x26, 2), defense = be(bytes, o + 0x28, 2),
|
||||
speed = be(bytes, o + 0x2A, 2), specialAttack = be(bytes, o + 0x2C, 2),
|
||||
specialDefense = be(bytes, o + 0x2E, 2),
|
||||
}
|
||||
return mon
|
||||
end
|
||||
|
||||
-- box_struct: OTs come BEFORE nicknames, and a box mon is 32 bytes with none
|
||||
-- of the party's computed stats.
|
||||
Gen2Save.BOX_CAPACITY = 20
|
||||
Gen2Save.BOX_MON_STRUCT = 32
|
||||
local BOX_SPECIES, BOX_MONS, BOX_OTS, BOX_NICKS = 0x01, 0x16, 0x296, 0x372
|
||||
|
||||
local decodeBoxMon = decodeSharedMon
|
||||
|
||||
-- A count past BOX_CAPACITY means the wrong layout, not an odd save.
|
||||
function Gen2Save.decodeBoxes(bytes, L, x)
|
||||
local boxes = {}
|
||||
for index, base in ipairs(L.boxes) do
|
||||
local count = u8(bytes, base)
|
||||
if count > Gen2Save.BOX_CAPACITY then
|
||||
return nil, ("box %d reports %d Pokemon, which is impossible; this is the "
|
||||
.. "wrong layout for this save"):format(index, count)
|
||||
end
|
||||
local mons = {}
|
||||
for i = 0, count - 1 do
|
||||
local mon = decodeBoxMon(bytes, base + BOX_MONS + i * Gen2Save.BOX_MON_STRUCT, x)
|
||||
-- The species list beside the mons must agree, which is a free check
|
||||
-- on the layout.
|
||||
local listed = named(x.pokemon, u8(bytes, base + BOX_SPECIES + i))
|
||||
if listed ~= mon.species then
|
||||
return nil, ("box %d slot %d: the species list says %s and the stored "
|
||||
.. "Pokemon says %s; wrong layout for this save")
|
||||
:format(index, i + 1, tostring(listed), tostring(mon.species))
|
||||
end
|
||||
mon.ot = text(bytes, base + BOX_OTS + i * Gen2Save.NAME_LENGTH, Gen2Save.NAME_LENGTH)
|
||||
mon.nickname = text(bytes, base + BOX_NICKS + i * Gen2Save.NAME_LENGTH, Gen2Save.NAME_LENGTH)
|
||||
mons[#mons + 1] = mon
|
||||
end
|
||||
boxes[index] = mons
|
||||
end
|
||||
return boxes
|
||||
end
|
||||
|
||||
-- The bag. Gen 2 splits it into four pockets, and they are not all shaped the
|
||||
-- same: ITEM and BALL are (id, quantity) pairs, KEY_ITEM is bare ids because a
|
||||
-- key item is unique, and TM_HM is a flat run of counts indexed by TM number.
|
||||
-- All of the list pockets are terminated by 0xFF as well as counted, and the
|
||||
-- count is trusted only as far as the terminator.
|
||||
-- One bag keyed by item id; PackMenu buckets it by each item's `pocket`.
|
||||
-- ITEM and BALL are (id, quantity) pairs, KEY_ITEM is bare ids.
|
||||
local function addPairs(out, bytes, countAt, listAt, cap, x)
|
||||
local n = u8(bytes, countAt)
|
||||
if n > cap then n = cap end
|
||||
for i = 0, n - 1 do
|
||||
local raw = u8(bytes, listAt + i * 2)
|
||||
if raw == 0xFF or raw == 0 then break end
|
||||
local id = named(x.items, raw)
|
||||
out[id] = (out[id] or 0) + u8(bytes, listAt + i * 2 + 1)
|
||||
end
|
||||
end
|
||||
|
||||
local function addIds(out, bytes, countAt, listAt, cap, x)
|
||||
local n = u8(bytes, countAt)
|
||||
if n > cap then n = cap end
|
||||
for i = 0, n - 1 do
|
||||
local raw = u8(bytes, listAt + i)
|
||||
if raw == 0xFF or raw == 0 then break end
|
||||
local id = named(x.items, raw)
|
||||
out[id] = (out[id] or 0) + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- TM_HM is a flat run of counts indexed by TM number.
|
||||
local function addMachines(out, bytes, at, x, items)
|
||||
local byNumber = {}
|
||||
for id, def in pairs(items or {}) do
|
||||
if type(def) == "table" and def.tmNumber then byNumber[def.tmNumber] = id end
|
||||
end
|
||||
for number, id in pairs(byNumber) do
|
||||
local count = u8(bytes, at + number - 1)
|
||||
if count > 0 then out[id] = (out[id] or 0) + count end
|
||||
end
|
||||
end
|
||||
|
||||
-- Byte index -> byte, which is what Save.scrubEvents validates.
|
||||
local function decodeFlagBytes(bytes, at, count)
|
||||
local out = {}
|
||||
for i = 0, count - 1 do out[i] = u8(bytes, at + i) end
|
||||
return out
|
||||
end
|
||||
|
||||
-- A set keyed by species id: save.pokedex.caught[species] = true.
|
||||
local function decodeDex(bytes, at, x)
|
||||
local out = {}
|
||||
for i = 0, Gen2Save.NUM_SPECIES - 1 do
|
||||
local byte = u8(bytes, at + math.floor(i / 8))
|
||||
if math.floor(byte / (2 ^ (i % 8))) % 2 == 1 then
|
||||
out[named(x.pokemon, i + 1) or (i + 1)] = true
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Badges by NAME, which is how FieldMoves.hasBadge and Battle:hasBadge read
|
||||
-- them. Bit position is accepted as a fallback key there, but the name is the
|
||||
-- primary and is what a save written by this project carries.
|
||||
Gen2Save.JOHTO_BADGES = {
|
||||
"ZEPHYR", "HIVE", "PLAIN", "FOG", "MINERAL", "STORM", "GLACIER", "RISING",
|
||||
}
|
||||
Gen2Save.KANTO_BADGES = {
|
||||
"BOULDER", "CASCADE", "THUNDER", "RAINBOW",
|
||||
"SOUL", "MARSH", "VOLCANO", "EARTH",
|
||||
}
|
||||
local function decodeBadges(byte, order)
|
||||
local out = {}
|
||||
for bit, name in ipairs(order) do
|
||||
if math.floor(byte / (2 ^ (bit - 1))) % 2 == 1 then out[name] = true end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
Gen2Save.NUM_SPECIES = 251
|
||||
Gen2Save.EVENT_BYTES = 256
|
||||
|
||||
-- decode(bytes, gameVersion, data) -> partial save table, err
|
||||
function Gen2Save.decode(bytes, gameVersion, data)
|
||||
local L = Gen2Save.layoutFor(gameVersion)
|
||||
if not L then return nil, "no Gen 2 layout for " .. tostring(gameVersion) end
|
||||
if type(bytes) ~= "string" or #bytes < Gen2Save.SAVE_SIZE then
|
||||
return nil, ("save must be at least %d bytes"):format(Gen2Save.SAVE_SIZE)
|
||||
end
|
||||
-- TryLoadSaveFile falls back to VerifyBackupChecksum and loads the backup
|
||||
-- copy, so a save the real cartridge would open must not be refused here.
|
||||
-- Crystal's backup is contiguous and laid out like the primary; Gold and
|
||||
-- Silver split theirs across three sections and have none to offer.
|
||||
if Gen2Save.checksumValid(bytes, L) ~= true then
|
||||
if L.backup and Gen2Save.checksumValid(bytes, L.backup) == true then
|
||||
L = L.backup
|
||||
else
|
||||
return nil, "save data checksum invalid (Gen 2 check values or sum mismatch)"
|
||||
end
|
||||
end
|
||||
|
||||
local x = Gen2Save.crosswalks(data)
|
||||
local count = u8(bytes, L.wPartyCount)
|
||||
if count > Gen2Save.PARTY_LENGTH then
|
||||
return nil, ("party count %d is impossible; this is probably the wrong layout")
|
||||
:format(count)
|
||||
end
|
||||
local party = {}
|
||||
for i = 0, count - 1 do
|
||||
local mon = decodeMon(bytes, L.wPartyMons + i * Gen2Save.PARTY_STRUCT, x)
|
||||
mon.nickname = text(bytes, L.wPartyMonNicknames + i * Gen2Save.NAME_LENGTH,
|
||||
Gen2Save.NAME_LENGTH)
|
||||
mon.ot = text(bytes, L.wPartyMonOTs + i * Gen2Save.NAME_LENGTH,
|
||||
Gen2Save.NAME_LENGTH)
|
||||
party[#party + 1] = mon
|
||||
end
|
||||
|
||||
local boxes, boxErr = Gen2Save.decodeBoxes(bytes, L, x)
|
||||
if not boxes then return nil, boxErr end
|
||||
|
||||
local inventory = {}
|
||||
addPairs(inventory, bytes, L.wNumItems, L.wItems, 20, x)
|
||||
addIds(inventory, bytes, L.wNumKeyItems, L.wKeyItems, 25, x)
|
||||
addPairs(inventory, bytes, L.wNumBalls, L.wBalls, 12, x)
|
||||
if L.wTMsHMs then addMachines(inventory, bytes, L.wTMsHMs, x, (data or {}).items) end
|
||||
|
||||
return {
|
||||
generation = 2,
|
||||
version = gameVersion,
|
||||
player = {
|
||||
name = text(bytes, L.wPlayerName, Gen2Save.NAME_LENGTH),
|
||||
id = be(bytes, L.wPlayerID, 2),
|
||||
money = be(bytes, L.wMoney, 3),
|
||||
coins = be(bytes, L.wCoins, 2),
|
||||
badges = decodeBadges(u8(bytes, L.wBadges), Gen2Save.JOHTO_BADGES),
|
||||
kantoBadges = decodeBadges(u8(bytes, L.wKantoBadges), Gen2Save.KANTO_BADGES),
|
||||
},
|
||||
rival = { name = text(bytes, L.wRivalName, Gen2Save.NAME_LENGTH) },
|
||||
mom = { name = text(bytes, L.wMomsName, Gen2Save.NAME_LENGTH) },
|
||||
party = party,
|
||||
boxes = boxes,
|
||||
currentBox = u8(bytes, L.wCurBox) % 16 + 1,
|
||||
inventory = inventory,
|
||||
-- Species ids, 1-based, so the set keys match save.pokedex.caught[species].
|
||||
pokedex = {
|
||||
caught = decodeDex(bytes, L.wPokedexCaught, x),
|
||||
seen = decodeDex(bytes, L.wPokedexSeen, x),
|
||||
},
|
||||
events = decodeFlagBytes(bytes, L.wEventFlags, Gen2Save.EVENT_BYTES),
|
||||
-- Save.summary does `save.position.map or save.spawn`.
|
||||
position = {
|
||||
map = x.maps[u8(bytes, L.wMapGroup) * 256 + u8(bytes, L.wMapNumber)],
|
||||
mapGroup = u8(bytes, L.wMapGroup), mapNumber = u8(bytes, L.wMapNumber),
|
||||
x = u8(bytes, L.wXCoord), y = u8(bytes, L.wYCoord),
|
||||
},
|
||||
playTime = {
|
||||
hours = be(bytes, L.wGameTimeHours, 2),
|
||||
minutes = u8(bytes, L.wGameTimeMinutes),
|
||||
seconds = 0, frames = 0,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- Everything the cart does not carry comes from a fresh game, exactly as
|
||||
-- Gen 1's mergeDefaults does: the decode above models what the SRAM holds,
|
||||
-- and mail, phone contacts, the unown dex, the hall of fame and the RTC are
|
||||
-- left to the engine's own defaults rather than invented here.
|
||||
--
|
||||
-- Loaded lazily. src/core/gen2/Save.lua pulls in love.filesystem at require
|
||||
-- time, and this module has to stay require-clean for the headless CLI and
|
||||
-- the tests, same rule GenSave follows.
|
||||
function Gen2Save.mergeDefaults(decoded, gameVersion)
|
||||
local ok, Save = pcall(require, "src.core.gen2.Save")
|
||||
if not ok then return decoded end
|
||||
local base = Save.newGame({ playerName = decoded.player and decoded.player.name })
|
||||
for k, v in pairs(decoded) do base[k] = v end
|
||||
base.version = gameVersion
|
||||
return base
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Export
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local function putU8(t, at, v) t[at] = v % 256 end
|
||||
local function putBE(t, at, v, n)
|
||||
for i = n - 1, 0, -1 do t[at + i] = v % 256; v = math.floor(v / 256) end
|
||||
end
|
||||
|
||||
-- Engine id back to the cart's number. A raw number passes through, which is
|
||||
-- how a save imported without a crosswalk round trips.
|
||||
local function indexOf(map, id)
|
||||
if id == nil then return 0 end
|
||||
if type(id) == "number" then return id end
|
||||
return map[id] or 0
|
||||
end
|
||||
|
||||
local function charBytes(str, i)
|
||||
local b = str:byte(i)
|
||||
if not b then return 0 end
|
||||
if b < 0x80 then return 1 end
|
||||
if b >= 0xF0 then return 4 end
|
||||
if b >= 0xE0 then return 3 end
|
||||
if b >= 0xC0 then return 2 end
|
||||
return 1
|
||||
end
|
||||
|
||||
local function glyphChars(glyph)
|
||||
local n, i = 0, 1
|
||||
while i <= #glyph do i = i + charBytes(glyph, i); n = n + 1 end
|
||||
return n
|
||||
end
|
||||
|
||||
-- One-CHARACTER glyphs plus PK and MN. The cart's table also carries the
|
||||
-- ligature halves PO and KE, and accepting those turns a name containing "PO"
|
||||
-- into 0x70 where the cart had a plain P. #glyph counts BYTES, so a byte test
|
||||
-- would also drop every multi-byte glyph and turn NIDORAN into NIDORAN?.
|
||||
local REVERSE = nil
|
||||
local function reverseCharmap()
|
||||
if REVERSE then return REVERSE end
|
||||
REVERSE = {}
|
||||
for code, glyph in pairs(Gen2Layout.charmap) do
|
||||
if glyphChars(glyph) == 1 then REVERSE[glyph] = code end
|
||||
end
|
||||
for code, glyph in pairs(Gen2Layout.charmap) do
|
||||
if glyph == "PK" or glyph == "MN" then REVERSE[glyph] = code end
|
||||
end
|
||||
return REVERSE
|
||||
end
|
||||
|
||||
local function putText(t, at, str, n)
|
||||
local codes = reverseCharmap()
|
||||
local i, written = 1, 0
|
||||
while i <= #str and written < n - 1 do
|
||||
local two = str:sub(i, i + 1)
|
||||
if (two == "PK" or two == "MN") and codes[two] then
|
||||
t[at + written] = codes[two]; i = i + 2
|
||||
else
|
||||
local w = charBytes(str, i)
|
||||
t[at + written] = codes[str:sub(i, i + w - 1)] or 0xE6
|
||||
i = i + w
|
||||
end
|
||||
written = written + 1
|
||||
end
|
||||
t[at + written] = 0x50
|
||||
end
|
||||
|
||||
local function putBadges(t, at, owned, order)
|
||||
local byte = 0
|
||||
for bit, name in ipairs(order) do
|
||||
if owned and owned[name] then byte = byte + 2 ^ (bit - 1) end
|
||||
end
|
||||
t[at] = byte
|
||||
end
|
||||
|
||||
local function putSharedMon(t, o, mon, x)
|
||||
putU8(t, o, indexOf(x.pokemonIndex, mon.species))
|
||||
putU8(t, o + 1, indexOf(x.itemIndex, mon.item))
|
||||
for i = 0, 3 do
|
||||
putU8(t, o + 2 + i, indexOf(x.moveIndex, (mon.moves or {})[i + 1]))
|
||||
end
|
||||
putBE(t, o + 6, mon.otId or 0, 2)
|
||||
putBE(t, o + 8, mon.experience or 0, 3)
|
||||
local se = mon.statExp or {}
|
||||
putBE(t, o + 0x0B, se.hp or 0, 2); putBE(t, o + 0x0D, se.attack or 0, 2)
|
||||
putBE(t, o + 0x0F, se.defense or 0, 2); putBE(t, o + 0x11, se.speed or 0, 2)
|
||||
putBE(t, o + 0x13, se.special or 0, 2)
|
||||
local d = mon.dvs or {}
|
||||
putU8(t, o + 0x15, (d.attack or 0) * 16 + (d.defense or 0))
|
||||
putU8(t, o + 0x16, (d.speed or 0) * 16 + (d.special or 0))
|
||||
for i = 0, 3 do
|
||||
putU8(t, o + 0x17 + i, (mon.ppRaw or {})[i + 1] or (mon.pp or {})[i + 1] or 0)
|
||||
end
|
||||
putU8(t, o + 0x1B, mon.happiness or 0)
|
||||
-- 0x1C-0x1E belong to the mon, not to the slot: leaving them to the template
|
||||
-- means reordering the party gives slot 1 the previous occupant's pokerus
|
||||
-- and caught data.
|
||||
putU8(t, o + 0x1C, mon.pokerus or 0)
|
||||
putBE(t, o + 0x1D, mon.caughtData or 0, 2)
|
||||
putU8(t, o + 0x1F, mon.level or 0)
|
||||
end
|
||||
|
||||
-- The bag back into its four pockets, by each item's own `pocket`.
|
||||
local function putBag(t, L, inventory, x)
|
||||
local buckets = { ITEM = {}, KEY_ITEM = {}, BALL = {}, TM_HM = {} }
|
||||
for id, count in pairs(inventory or {}) do
|
||||
local def = x.itemDefs[id]
|
||||
local pocket = (type(def) == "table" and def.pocket) or "ITEM"
|
||||
if buckets[pocket] == nil then pocket = "ITEM" end
|
||||
buckets[pocket][#buckets[pocket] + 1] = { id = id, count = count, def = def }
|
||||
end
|
||||
-- By cart index, so the order is deterministic. A flat inventory has no
|
||||
-- order of its own, so the pocket order a round trip produces is stable
|
||||
-- rather than original.
|
||||
for _, list in pairs(buckets) do
|
||||
table.sort(list, function(a, b)
|
||||
return indexOf(x.itemIndex, a.id) < indexOf(x.itemIndex, b.id)
|
||||
end)
|
||||
end
|
||||
|
||||
local overflow = nil
|
||||
local function writePairs(countAt, listAt, cap, list, pocket)
|
||||
if #list > cap then overflow = overflow or { pocket, #list, cap } end
|
||||
local n = math.min(#list, cap)
|
||||
putU8(t, countAt, n)
|
||||
for i = 1, n do
|
||||
putU8(t, listAt + (i - 1) * 2, indexOf(x.itemIndex, list[i].id))
|
||||
putU8(t, listAt + (i - 1) * 2 + 1, math.min(list[i].count, 99))
|
||||
end
|
||||
putU8(t, listAt + n * 2, 0xFF)
|
||||
end
|
||||
local function writeIds(countAt, listAt, cap, list, pocket)
|
||||
if #list > cap then overflow = overflow or { pocket, #list, cap } end
|
||||
local n = math.min(#list, cap)
|
||||
putU8(t, countAt, n)
|
||||
for i = 1, n do putU8(t, listAt + i - 1, indexOf(x.itemIndex, list[i].id)) end
|
||||
putU8(t, listAt + n, 0xFF)
|
||||
end
|
||||
|
||||
writePairs(L.wNumItems, L.wItems, 20, buckets.ITEM, "ITEM")
|
||||
writeIds(L.wNumKeyItems, L.wKeyItems, 25, buckets.KEY_ITEM, "KEY_ITEM")
|
||||
writePairs(L.wNumBalls, L.wBalls, 12, buckets.BALL, "BALL")
|
||||
if L.wTMsHMs then
|
||||
for _, row in ipairs(buckets.TM_HM) do
|
||||
local number = type(row.def) == "table" and row.def.tmNumber
|
||||
if number then putU8(t, L.wTMsHMs + number - 1, math.min(row.count, 99)) end
|
||||
end
|
||||
end
|
||||
return overflow
|
||||
end
|
||||
|
||||
local function putFlagSet(t, at, set, count, indexFor)
|
||||
for i = 0, count - 1 do
|
||||
local byteAt = at + math.floor(i / 8)
|
||||
local bit = 2 ^ (i % 8)
|
||||
local cur = t[byteAt] or 0
|
||||
local on = math.floor(cur / bit) % 2 == 1
|
||||
local want = set and set[indexFor(i)] == true
|
||||
if on ~= want then t[byteAt] = want and (cur + bit) or (cur - bit) end
|
||||
end
|
||||
end
|
||||
|
||||
-- encode(save, gameVersion, template, data) -> bytes, err
|
||||
--
|
||||
-- Writes into the cartridge image the save came from: Gen 2 SRAM holds a great
|
||||
-- deal this codec does not model and the real game trusts it on CONTINUE, so a
|
||||
-- save with no image behind it is refused rather than built from nothing.
|
||||
--
|
||||
-- Only the primary copy is written. TryLoadSaveFile rewrites the backup from
|
||||
-- the primary on every successful load.
|
||||
function Gen2Save.encode(save, gameVersion, template, data)
|
||||
local L = Gen2Save.layoutFor(gameVersion)
|
||||
if not L then return nil, "no Gen 2 layout for " .. tostring(gameVersion) end
|
||||
if type(save) ~= "table" then return nil, "expected a save table" end
|
||||
if type(template) ~= "string" or #template < Gen2Save.SAVE_SIZE then
|
||||
return nil, "this save has no cartridge image to write back into, and a "
|
||||
.. "Gen 2 save built from nothing does not boot on real hardware"
|
||||
end
|
||||
|
||||
local x = Gen2Save.crosswalks(data)
|
||||
local t = {}
|
||||
for i = 0, Gen2Save.SAVE_SIZE - 1 do t[i] = template:byte(i + 1) end
|
||||
|
||||
local p = save.player or {}
|
||||
putText(t, L.wPlayerName, p.name or "", Gen2Save.NAME_LENGTH)
|
||||
putBE(t, L.wPlayerID, p.id or 0, 2)
|
||||
putBE(t, L.wMoney, p.money or 0, 3)
|
||||
putBE(t, L.wCoins, p.coins or 0, 2)
|
||||
putBadges(t, L.wBadges, p.badges, Gen2Save.JOHTO_BADGES)
|
||||
putBadges(t, L.wKantoBadges, p.kantoBadges, Gen2Save.KANTO_BADGES)
|
||||
putText(t, L.wRivalName, (save.rival or {}).name or "", Gen2Save.NAME_LENGTH)
|
||||
putText(t, L.wMomsName, (save.mom or {}).name or "", Gen2Save.NAME_LENGTH)
|
||||
|
||||
local party = save.party or {}
|
||||
if #party > Gen2Save.PARTY_LENGTH then
|
||||
return nil, ("a party of %d cannot be written to a cartridge"):format(#party)
|
||||
end
|
||||
putU8(t, L.wPartyCount, #party)
|
||||
for i, mon in ipairs(party) do
|
||||
putU8(t, L.wPartySpecies + i - 1, indexOf(x.pokemonIndex, mon.species))
|
||||
local o = L.wPartyMons + (i - 1) * Gen2Save.PARTY_STRUCT
|
||||
putSharedMon(t, o, mon, x)
|
||||
putU8(t, o + 0x20, encodeStatus(mon.status, mon.statusTurns))
|
||||
putBE(t, o + 0x22, mon.hp or 0, 2)
|
||||
local st = mon.stats or {}
|
||||
putBE(t, o + 0x24, mon.maxHp or st.hp or 0, 2)
|
||||
putBE(t, o + 0x26, st.attack or 0, 2); putBE(t, o + 0x28, st.defense or 0, 2)
|
||||
putBE(t, o + 0x2A, st.speed or 0, 2); putBE(t, o + 0x2C, st.specialAttack or 0, 2)
|
||||
putBE(t, o + 0x2E, st.specialDefense or 0, 2)
|
||||
putText(t, L.wPartyMonNicknames + (i - 1) * Gen2Save.NAME_LENGTH,
|
||||
mon.nickname or "", Gen2Save.NAME_LENGTH)
|
||||
putText(t, L.wPartyMonOTs + (i - 1) * Gen2Save.NAME_LENGTH,
|
||||
mon.ot or "", Gen2Save.NAME_LENGTH)
|
||||
end
|
||||
putU8(t, L.wPartySpecies + #party, 0xFF)
|
||||
|
||||
for index, base in ipairs(L.boxes) do
|
||||
local box = (save.boxes or {})[index] or {}
|
||||
if #box > Gen2Save.BOX_CAPACITY then
|
||||
return nil, ("box %d holds %d, which a cartridge cannot"):format(index, #box)
|
||||
end
|
||||
putU8(t, base, #box)
|
||||
for i, mon in ipairs(box) do
|
||||
putU8(t, base + BOX_SPECIES + i - 1, indexOf(x.pokemonIndex, mon.species))
|
||||
putSharedMon(t, base + BOX_MONS + (i - 1) * Gen2Save.BOX_MON_STRUCT, mon, x)
|
||||
putText(t, base + BOX_OTS + (i - 1) * Gen2Save.NAME_LENGTH,
|
||||
mon.ot or "", Gen2Save.NAME_LENGTH)
|
||||
putText(t, base + BOX_NICKS + (i - 1) * Gen2Save.NAME_LENGTH,
|
||||
mon.nickname or "", Gen2Save.NAME_LENGTH)
|
||||
end
|
||||
putU8(t, base + BOX_SPECIES + #box, 0xFF)
|
||||
end
|
||||
|
||||
-- Refuse rather than drop. Without item defs every item buckets into ITEM,
|
||||
-- which holds 20, and a real bag is bigger than that.
|
||||
local overflow = putBag(t, L, save.inventory, x)
|
||||
if overflow then
|
||||
return nil, ("the %s pocket would need %d slots and a cartridge has %d; "
|
||||
.. "the item table for this game is needed to sort the bag")
|
||||
:format(overflow[1], overflow[2], overflow[3])
|
||||
end
|
||||
if save.currentBox then putU8(t, L.wCurBox, (save.currentBox - 1) % 16) end
|
||||
if save.boxNames and L.wBoxNames then
|
||||
for i = 1, 14 do
|
||||
putText(t, L.wBoxNames + (i - 1) * 9, save.boxNames[i] or "", 9)
|
||||
end
|
||||
end
|
||||
|
||||
if save.pokedex then
|
||||
local function species(i) return x.pokemon[i + 1] or (i + 1) end
|
||||
putFlagSet(t, L.wPokedexCaught, save.pokedex.caught, Gen2Save.NUM_SPECIES, species)
|
||||
putFlagSet(t, L.wPokedexSeen, save.pokedex.seen, Gen2Save.NUM_SPECIES, species)
|
||||
end
|
||||
for i = 0, Gen2Save.EVENT_BYTES - 1 do
|
||||
local byte = (save.events or {})[i]
|
||||
if type(byte) == "number" then putU8(t, L.wEventFlags + i, byte) end
|
||||
end
|
||||
|
||||
local pos = save.position
|
||||
if pos then
|
||||
local ids = pos.map and x.mapIds[pos.map]
|
||||
putU8(t, L.wMapGroup, (ids and ids[1]) or pos.mapGroup or 0)
|
||||
putU8(t, L.wMapNumber, (ids and ids[2]) or pos.mapNumber or 0)
|
||||
putU8(t, L.wXCoord, pos.x or 0)
|
||||
putU8(t, L.wYCoord, pos.y or 0)
|
||||
end
|
||||
local pt = save.playTime
|
||||
if pt then
|
||||
putBE(t, L.wGameTimeHours, pt.hours or 0, 2)
|
||||
putU8(t, L.wGameTimeMinutes, pt.minutes or 0)
|
||||
end
|
||||
|
||||
putU8(t, L.sCheckValue1, 0x63)
|
||||
putU8(t, L.sCheckValue2, 0x7F)
|
||||
local sum = 0
|
||||
for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + t[i]) % 65536 end
|
||||
putU8(t, L.sChecksum, sum % 256)
|
||||
putU8(t, L.sChecksum + 1, math.floor(sum / 256) % 256)
|
||||
|
||||
local out = {}
|
||||
for i = 0, Gen2Save.SAVE_SIZE - 1 do out[i + 1] = string.char(t[i]) end
|
||||
-- Whatever followed the 32 KiB of SRAM is the cart's RTC footer. Dropping it
|
||||
-- resets the clock, which costs the player daily events and the bug contest
|
||||
-- and earns them the clock-adjustment penalty.
|
||||
return table.concat(out) .. template:sub(Gen2Save.SAVE_SIZE + 1)
|
||||
end
|
||||
|
||||
return Gen2Save
|
||||
@@ -21,12 +21,23 @@
|
||||
-- require alone cannot see them there (#420).
|
||||
|
||||
local GenSave = require("src.save_convert.GenSave")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Gen2Save = require("src.save_convert.Gen2Save")
|
||||
|
||||
local SaveConvert = {}
|
||||
|
||||
SaveConvert.SAVE_SIZE = GenSave.SAVE_SIZE
|
||||
SaveConvert.mainChecksumValid = GenSave.mainChecksumValid
|
||||
-- Is this a real save for THIS GAME? Dispatches on the generation, because
|
||||
-- the two do not share a rule: Gen 1 stores a complement checksum of its main
|
||||
-- data block, Gen 2 stores two check values plus a 16-bit sum. Run one over
|
||||
-- the other's bytes and the answer is always no.
|
||||
--
|
||||
-- gameVersion is optional and defaults to Gen 1's rule, which is what every
|
||||
-- caller meant before Gen 2 had a codec.
|
||||
function SaveConvert.mainChecksumValid(bytes, gameVersion)
|
||||
local L = gameVersion and Gen2Save.layoutFor(gameVersion)
|
||||
if L then return Gen2Save.checksumValid(bytes, L) end
|
||||
return GenSave.mainChecksumValid(bytes)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Crosswalk data loading (cached). Mirrors src/core/Data.lua: prefer
|
||||
@@ -125,6 +136,24 @@ local function loadCacheTable(gameVersion, filePath)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The generated tables Gen2Save needs to turn cart numbers into the ids the
|
||||
-- engine is keyed by. Deliberately not ensureData: that one also demands Gen
|
||||
-- 1's charmap, event flags and hidden items, none of which a Gen 2 cache has
|
||||
-- or a Gen 2 save uses.
|
||||
local gen2Data = {}
|
||||
local function ensureGen2Data(gameVersion)
|
||||
local key = gameVersion or "*"
|
||||
if gen2Data[key] == nil then
|
||||
local out = {}
|
||||
for _, name in ipairs({ "pokemon", "moves", "items", "maps" }) do
|
||||
out[name] = loadCacheTable(gameVersion, "data/generated/" .. name .. ".lua")
|
||||
or (loadTable("data.generated." .. name, "data/generated/" .. name .. ".lua"))
|
||||
end
|
||||
gen2Data[key] = out
|
||||
end
|
||||
return gen2Data[key]
|
||||
end
|
||||
|
||||
-- Crosswalk sets keyed by the game whose cache they came from ("*" for the
|
||||
-- require-resolved set): Yellow's tables are not Red's, so one import must
|
||||
-- never be handed the previous import's data (#420).
|
||||
@@ -228,11 +257,36 @@ SaveConvert.mergeDefaults = mergeDefaults
|
||||
-- pokered's single sPlayerName..sMainDataCheckSum window), and no Gen 2
|
||||
-- codec exists yet. Both directions answer with a plain message the
|
||||
-- launcher's save card renders as-is, instead of pushing a Gen 2 save
|
||||
-- table through Gen 1 offsets and surfacing a codec traceback.
|
||||
local function gen2CartName(gameVersion)
|
||||
if not GameVersion.VERSIONS[gameVersion] then return nil end
|
||||
if GameVersion.generation(gameVersion) ~= 2 then return nil end
|
||||
return GameVersion.info(gameVersion).displayName
|
||||
-- Can a cart save for this game cross in or out at all? Public because the
|
||||
-- launcher has to ask about the GAME before it measures the BYTES.
|
||||
--
|
||||
-- SaveFileIO.importToSlot judges anything that is not exactly SAVE_SIZE with
|
||||
-- mainChecksumValid, which is pokered's main-data checksum. A Gen 2 cart is
|
||||
-- MBC3+TIMER, so a real Gold/Silver/Crystal .sav carries an RTC footer and is
|
||||
-- 32786 bytes: it misses the size test and is then measured against a rule
|
||||
-- that was never going to match it. The player is told "save data checksum
|
||||
-- invalid" about a save that is perfectly good (#1832).
|
||||
--
|
||||
-- Returns true, or false plus the same sentence importSav/exportSav would have
|
||||
-- answered with, so a caller that asks early and a caller that does not cannot
|
||||
-- describe the same game two different ways.
|
||||
-- Does this game use a Gen 2 cart save? The RTC footer that follows one is
|
||||
-- expected rather than surprising, which the import path needs to know.
|
||||
function SaveConvert.isGen2Cart(gameVersion)
|
||||
return Gen2Save.layoutFor(gameVersion) ~= nil
|
||||
end
|
||||
|
||||
function SaveConvert.importSupported(gameVersion)
|
||||
-- Gen 2 imports through Gen2Save now. Kept as a predicate rather than
|
||||
-- deleted: SaveFileIO asks it before it measures the bytes, and export
|
||||
-- still answers no.
|
||||
return true
|
||||
end
|
||||
|
||||
-- Gen 2 exports through Gen2Save now, but only for a save with a cartridge
|
||||
-- image behind it; encode says so itself.
|
||||
function SaveConvert.exportSupported(gameVersion)
|
||||
return true
|
||||
end
|
||||
|
||||
-- importSav(bytes, version, gameVersion) -> saveTable, err
|
||||
@@ -247,9 +301,16 @@ function SaveConvert.importSav(bytes, version, gameVersion)
|
||||
if type(bytes) ~= "string" then
|
||||
return nil, "expected raw save bytes as a string"
|
||||
end
|
||||
local gen2Name = gen2CartName(gameVersion)
|
||||
if gen2Name then
|
||||
return nil, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet."
|
||||
local supported, unsupportedWhy = SaveConvert.importSupported(gameVersion)
|
||||
if not supported then return nil, unsupportedWhy end
|
||||
-- Gen 2 is a different SRAM entirely: different bank map, different party
|
||||
-- struct, its own check values. Gen2Save owns it, and it needs no crosswalk
|
||||
-- tables because it decodes ids the engine already speaks.
|
||||
if Gen2Save.layoutFor(gameVersion) then
|
||||
local decoded, gen2Err = Gen2Save.decode(bytes, gameVersion,
|
||||
ensureGen2Data(gameVersion))
|
||||
if not decoded then return nil, gen2Err end
|
||||
return Gen2Save.mergeDefaults(decoded, gameVersion)
|
||||
end
|
||||
if #bytes ~= GenSave.SAVE_SIZE then
|
||||
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
|
||||
@@ -273,19 +334,22 @@ function SaveConvert.importSav(bytes, version, gameVersion)
|
||||
return mergeDefaults(decoded, version)
|
||||
end
|
||||
|
||||
-- exportSav(saveTable, gameVersion) -> bytes, err
|
||||
-- exportSav(saveTable, gameVersion, cartImage) -> bytes, err
|
||||
-- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware:
|
||||
-- if the table still carries the stashed import template (saveTable.rawImport)
|
||||
-- GenSave reproduces every unmodeled region from it; otherwise those regions
|
||||
-- are zero-filled. gameVersion selects the crosswalk tables exactly as in
|
||||
-- importSav. On failure returns nil + a message (never raises).
|
||||
function SaveConvert.exportSav(saveTable, gameVersion)
|
||||
function SaveConvert.exportSav(saveTable, gameVersion, cartImage)
|
||||
if type(saveTable) ~= "table" then
|
||||
return nil, "expected a save table"
|
||||
end
|
||||
local gen2Name = gen2CartName(gameVersion)
|
||||
if gen2Name then
|
||||
return nil, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet."
|
||||
local supported, unsupportedWhy = SaveConvert.exportSupported(gameVersion)
|
||||
if not supported then return nil, unsupportedWhy end
|
||||
-- Gen 2 has its own SRAM and its own codec, and needs no Gen 1 crosswalks.
|
||||
if Gen2Save.layoutFor(gameVersion) then
|
||||
return Gen2Save.encode(saveTable, gameVersion, cartImage,
|
||||
ensureGen2Data(gameVersion))
|
||||
end
|
||||
local data, derr = ensureData(gameVersion)
|
||||
if not data then return nil, derr end
|
||||
|
||||
@@ -451,12 +451,13 @@ function H.CutDownTreeOrGrass(ctx)
|
||||
end
|
||||
|
||||
-- DisappearWhirlpool is CutDownTreeOrGrass with PlayWhirlpoolSound in place of
|
||||
-- OWCutAnimation: the same block write, the same redraw, then the surf wash the
|
||||
-- port owns as World:playWhirlpoolSound. #1717
|
||||
-- OWCutAnimation, and the block only reaches the screen after the sound
|
||||
-- -- engine/events/overworld.asm:1157-1164 (#1717, #1862)
|
||||
function H.DisappearWhirlpool(ctx)
|
||||
local ret = H.CutDownTreeOrGrass(ctx)
|
||||
call(ctx, "playWhirlpoolSound")
|
||||
return ret
|
||||
local index = ctx and ctx.cutWhirlpoolBlockIndex
|
||||
local blockId = ctx and ctx.cutWhirlpoolReplacement
|
||||
call(ctx, "playWhirlpoolSound", index, blockId)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- BlindingFlash sets STATUSFLAGS_FLASH_F and reloads the palettes. Setting
|
||||
|
||||
@@ -152,6 +152,12 @@ local function answer(vm, value)
|
||||
vm.scriptVar = value or 0
|
||||
end
|
||||
|
||||
-- WaitSFX (pokegold home/audio.asm); a test stub that calls a handler off
|
||||
-- the coroutine has no sfx to drain.
|
||||
local function drainSfx()
|
||||
if coroutine.running() then coroutine.yield({ kind = "waitsfx" }) end
|
||||
end
|
||||
|
||||
-- Every routine that ends `call GetPokemonName / jp
|
||||
-- CopyPokemonName_Buffer1_Buffer3` puts a name where the next writetext's
|
||||
-- {STRBUF} will find it.
|
||||
@@ -452,8 +458,10 @@ H.BugContestJudging = function(vm)
|
||||
local what = (h.monName and h.monName(entry.species)) or entry.species
|
||||
or ""
|
||||
vm:showRaw(Strings(row.page, who, what))
|
||||
-- pokegold engine/events/bug_contest/judging.asm:29-32
|
||||
drainSfx()
|
||||
if h.playSfxNamed then h.playSfxNamed(row.sfx) end
|
||||
vm:showRaw(Strings(row.score, entry.score or 0))
|
||||
vm:showRaw(Strings(row.score, entry.score or 0), nil, nil, true)
|
||||
end
|
||||
end
|
||||
answer(vm, place)
|
||||
@@ -2033,12 +2041,10 @@ H.ProfOaksPCBoot = function(vm)
|
||||
vm:showRaw(Strings(OAK_PC_TEXT.counts, seen, caught))
|
||||
local rating = findOakRating(caught)
|
||||
local h = hooks(vm)
|
||||
-- pokegold engine/events/prof_oaks_pc.asm:18-20 PlaySFX / JoyWaitAorB / WaitSFX
|
||||
drainSfx()
|
||||
if h.playSfxNamed then h.playSfxNamed(rating.sfx) end
|
||||
vm:showRaw(Strings(rating.text))
|
||||
-- `call PlaySFX / call JoyWaitAorB / call WaitSFX`: the fanfare is left
|
||||
-- playing under the rating text, and the caller (OaksLab's own script)
|
||||
-- waits it out before closing the box.
|
||||
coroutine.yield({ kind = "waitsfx" })
|
||||
vm:showRaw(Strings(rating.text), nil, nil, true)
|
||||
end
|
||||
|
||||
-- The whose-PC menu's PROF.OAK's PC row (src/ui/gen2/CenterPcMenu.lua) runs
|
||||
|
||||
+10
-6
@@ -700,8 +700,10 @@ local function runCmd(self, cmd, op)
|
||||
-- GetPocketName fills from ItemPocketNames: KEY ITEMs, BALLs and TMs
|
||||
-- name their own pocket, not the ITEM one (data/text/common_2.asm
|
||||
-- :1351, data/items/pocket_names.asm:10-13).
|
||||
-- Script_specialsound's WaitSFX (scripting.asm:485): the box holds
|
||||
-- its press until the jingle ends.
|
||||
self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.",
|
||||
name, self:pocketName(item)))
|
||||
name, self:pocketName(item)), nil, nil, true)
|
||||
else
|
||||
self:showRaw(Strings("The %s\nis full…", self:pocketName(item)))
|
||||
end
|
||||
@@ -1510,8 +1512,8 @@ local function runCmd(self, cmd, op)
|
||||
self.playSoundFn(SFX_ITEM)
|
||||
end
|
||||
-- FruitTreeScript's tail is `specialsound / itemnotify` with NOTHING
|
||||
-- between them (engine/events/fruit_trees.asm:23-24), and Script_specialsound
|
||||
-- is a bare PlaySFX -- it does not wait either (scripting.asm:476-483).
|
||||
-- between them (engine/events/fruit_trees.asm:23-24);
|
||||
-- Script_specialsound ends PlaySFX / WaitSFX (scripting.asm:484-485)
|
||||
-- The port used to park here on a `waitsfx`, which is the same seam
|
||||
-- GiveItemScript's did: this port's box takes its own button and pops on
|
||||
-- it, so the park ran with an EMPTY state stack and the bare overworld
|
||||
@@ -1523,7 +1525,7 @@ local function runCmd(self, cmd, op)
|
||||
-- noun still comes from ItemPocketNames rather than from a third copy of
|
||||
-- the literal (data/items/pocket_names.asm:10-13).
|
||||
self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.",
|
||||
name, self:pocketName(item)))
|
||||
name, self:pocketName(item)), nil, nil, true)
|
||||
return "end"
|
||||
elseif op == "describedecoration" then
|
||||
-- `describedecoration byte` picks one of five DECODESC_* arms
|
||||
@@ -2427,7 +2429,7 @@ end
|
||||
-- the port's world does not tick while a box is on the stack (Game2:update
|
||||
-- stops at the top state), so the only clock that can count it is the box's;
|
||||
-- World:showText is where it lands.
|
||||
function Vm:showRaw(body, stay, hold)
|
||||
function Vm:showRaw(body, stay, hold, sfxWait)
|
||||
if not body or body == "" then body = "..." end
|
||||
if self.stringBuffer and self.stringBuffer ~= "" then
|
||||
body = body:gsub("{STRBUF}", self.stringBuffer)
|
||||
@@ -2438,6 +2440,8 @@ function Vm:showRaw(body, stay, hold)
|
||||
text = body,
|
||||
stay = (stay or self:textStays()) and true or false,
|
||||
hold = hold,
|
||||
-- pokegold engine/overworld/scripting.asm:485 WaitSFX
|
||||
sfxWait = sfxWait and true or nil,
|
||||
})
|
||||
end
|
||||
end
|
||||
@@ -2646,7 +2650,7 @@ function Vm:resume(resumeValue)
|
||||
if req and req.kind == "text" and self.showTextFn then
|
||||
self.showTextFn(req.text, function()
|
||||
self:resume()
|
||||
end, req.stay, req.hold)
|
||||
end, req.stay, req.hold, req.sfxWait)
|
||||
elseif req and req.kind == "wait" then
|
||||
self.waitLeft = req.frames or 0
|
||||
elseif req and req.kind == "waitbutton" then
|
||||
|
||||
+44
-8
@@ -116,6 +116,36 @@ function SyncEngine.overlaps(a, b)
|
||||
return aStart <= bEnd and bStart <= aEnd
|
||||
end
|
||||
|
||||
-- Inline, under .meta, or under .remoteMeta, depending on the endpoint.
|
||||
function SyncEngine.metaOf(row)
|
||||
if type(row) ~= "table" then return nil end
|
||||
if type(row.meta) == "table" then return row.meta end
|
||||
if type(row.remoteMeta) == "table" then return row.remoteMeta end
|
||||
return row
|
||||
end
|
||||
|
||||
-- Gen 2 stores playTime as a table, so meta.playTime is nil on a Gold save
|
||||
-- and summary.timeText is the only field that survives.
|
||||
local function playedMinutes(meta)
|
||||
if type(meta) ~= "table" then return nil end
|
||||
local summary = type(meta.summary) == "table" and meta.summary or nil
|
||||
local text = summary and summary.timeText
|
||||
if type(text) == "string" then
|
||||
local hours, minutes = text:match("^(%d+):(%d%d)$")
|
||||
if hours then return tonumber(hours) * 60 + tonumber(minutes) end
|
||||
end
|
||||
local seconds = tonumber(meta.playTime)
|
||||
if seconds then return math.floor(seconds / 60) end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Same minute of playtime means the same point in the playthrough, so there
|
||||
-- is no fork. Unknown playtime on either side is NOT a match.
|
||||
function SyncEngine.samePlaytime(a, b)
|
||||
local left, right = playedMinutes(a), playedMinutes(b)
|
||||
return left ~= nil and left == right
|
||||
end
|
||||
|
||||
function SyncEngine.new(opts)
|
||||
opts = opts or {}
|
||||
local eng = setmetatable({}, SyncEngine)
|
||||
@@ -457,7 +487,11 @@ function SyncEngine:_planFrom(remoteState)
|
||||
if not row then
|
||||
self:_queueUpload(entry, key, false)
|
||||
elseif localChanged and remoteChanged then
|
||||
self:_addConflict(entry, key, row)
|
||||
if SyncEngine.samePlaytime(entry.meta, SyncEngine.metaOf(row)) then
|
||||
self:_queueUpload(entry, key, true)
|
||||
else
|
||||
self:_addConflict(entry, key, row)
|
||||
end
|
||||
elseif localChanged then
|
||||
self:_queueUpload(entry, key, false)
|
||||
elseif remoteChanged and key ~= self.protectedKey then
|
||||
@@ -477,12 +511,7 @@ function SyncEngine:_planFrom(remoteState)
|
||||
end
|
||||
|
||||
function SyncEngine:_addConflict(entry, key, row)
|
||||
local remoteMeta = row
|
||||
if type(row.meta) == "table" then
|
||||
remoteMeta = row.meta
|
||||
elseif type(row.remoteMeta) == "table" then
|
||||
remoteMeta = row.remoteMeta
|
||||
end
|
||||
local remoteMeta = SyncEngine.metaOf(row)
|
||||
self.conflicts[#self.conflicts + 1] = {
|
||||
key = key,
|
||||
version = entry.version,
|
||||
@@ -528,7 +557,14 @@ function SyncEngine:_queueUpload(entry, key, force)
|
||||
end, function(e, res)
|
||||
if res.code == 409 then
|
||||
local row = res.data or {}
|
||||
e:_addConflict(entry, key, row)
|
||||
-- Retried with force only once: a forced write that still 409s is a
|
||||
-- real refusal, and retrying it would spin.
|
||||
if not force
|
||||
and SyncEngine.samePlaytime(entry.meta, SyncEngine.metaOf(row)) then
|
||||
e:_queueUpload(entry, key, true)
|
||||
else
|
||||
e:_addConflict(entry, key, row)
|
||||
end
|
||||
if not e:busy() then e:_finish() end
|
||||
return true
|
||||
end
|
||||
|
||||
+22
-5
@@ -54,6 +54,21 @@ local function showMessages(game, msgs, onDone, opts)
|
||||
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone, opts))
|
||||
end
|
||||
|
||||
-- PrintItemUseTextAndRemoveItem: used-line TextBox carries Heal_Ailment, then
|
||||
-- optional afterMessages (X Stat effect line) before onDone (#1635).
|
||||
local function showUseMessages(game, msgs, onDone, extra)
|
||||
local opts = (extra and extra.useJingle)
|
||||
and TextBox.soundOpts(game, "Heal_Ailment") or nil
|
||||
local after = extra and extra.afterMessages
|
||||
if after and #after > 0 then
|
||||
showMessages(game, msgs, function()
|
||||
showMessages(game, after, onDone)
|
||||
end, opts)
|
||||
else
|
||||
showMessages(game, msgs, onDone, opts)
|
||||
end
|
||||
end
|
||||
|
||||
-- run the use-flow for an item on a chosen target. `picker` is the party
|
||||
-- menu when it was opened with keepOpen (HP medicine only): it is still on
|
||||
-- the stack, so every exit that prints has to close it afterwards. For
|
||||
@@ -115,7 +130,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
if result == "consumed_escape" then -- Poké Doll
|
||||
consume(game, id, list)
|
||||
list:close()
|
||||
showMessages(game, payload, function()
|
||||
showUseMessages(game, payload, function()
|
||||
-- ItemUsePokeDoll sets wEscapedFromBattle and never touches
|
||||
-- wBattleResult, so a script that reads the result afterwards sees
|
||||
-- 0 -- "defeated". The ghost MAROWAK's script keys on exactly that
|
||||
@@ -125,7 +140,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
battle.result = "run"
|
||||
battle.afterQueue = "finish"
|
||||
battle.phase = "messages"
|
||||
end)
|
||||
end, extra)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -345,6 +360,8 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
-- .useItem_partyMenu jumps back to StartMenu_Item once UseItem
|
||||
-- returns, cursor still on the candy (start_sub_menus.asm) -- so
|
||||
-- mashing A burns through a stack of them (#796)
|
||||
-- RareCandyText carries sound_get_item_1
|
||||
-- (engine/menus/party_menu.asm:289-293)
|
||||
showMessages(game, payload, function()
|
||||
local StatBox = require("src.battle.BattleState").StatBox
|
||||
game.stack:push(StatBox.new(game, target, function()
|
||||
@@ -385,7 +402,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
end
|
||||
nextStep()
|
||||
end))
|
||||
end)
|
||||
end, TextBox.soundOpts(game, "Get_Item1"))
|
||||
return
|
||||
end
|
||||
-- HP medicine: fill the bar in the still-open picker first, then print
|
||||
@@ -403,9 +420,9 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
end
|
||||
if battle then
|
||||
list:close()
|
||||
showMessages(game, payload, function() battle:itemUsed({}) end)
|
||||
showUseMessages(game, payload, function() battle:itemUsed({}) end, extra)
|
||||
else
|
||||
showMessages(game, payload, closePicker)
|
||||
showUseMessages(game, payload, closePicker, extra)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
+85
-41
@@ -13,9 +13,21 @@ local Strings = require("src.core.Strings")
|
||||
|
||||
local BoxMenu = {}
|
||||
|
||||
local function monLabel(game, mon)
|
||||
-- PrintListMenuEntries prints the nickname at hlcoord 6,4 and PrintLevel
|
||||
-- one row down, 8 columns right (home/list_menu.asm:364-365, 459-461)
|
||||
local function monRow(game, mon, value)
|
||||
local def = game.data.pokemon[mon.species]
|
||||
return Strings("%s :L%d", mon.nickname or def.name, mon.level)
|
||||
return {
|
||||
label = mon.nickname or def.name,
|
||||
sub = Strings(":L%d", mon.level),
|
||||
value = value,
|
||||
}
|
||||
end
|
||||
|
||||
-- the $ff terminator's row (home/list_menu.asm:371-372, 523-528)
|
||||
local function withCancel(items)
|
||||
items[#items + 1] = { cancel = true, label = Strings("CANCEL") }
|
||||
return items
|
||||
end
|
||||
|
||||
local function monName(game, mon)
|
||||
@@ -61,14 +73,15 @@ local function withdraw(game)
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
for i, mon in ipairs(box) do
|
||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||
end
|
||||
game.stack:push(ListMenu.new(game,
|
||||
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
|
||||
for i, mon in ipairs(box) do table.insert(items, monRow(game, mon, i)) end
|
||||
game.stack:push(ListMenu.new(game, nil, withCancel(items), {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
kind = "pc_box_withdraw",
|
||||
-- DisplayMonListMenu draws LIST_MENU_BOX over the PC menu, which stays
|
||||
-- visible around it (home/list_menu.asm:29-31)
|
||||
itemBox = true,
|
||||
onChoose = function(item, list)
|
||||
if item.cancel then list:close() return end
|
||||
local mon = box[item.value]
|
||||
if not mon then return end
|
||||
monSubmenu(game, "WITHDRAW", mon, function()
|
||||
@@ -111,12 +124,14 @@ local function deposit(game)
|
||||
end
|
||||
local items = {}
|
||||
for i, mon in ipairs(game.save.party) do
|
||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||
table.insert(items, monRow(game, mon, i))
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
|
||||
game.stack:push(ListMenu.new(game, nil, withCancel(items), {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
kind = "pc_box_deposit",
|
||||
itemBox = true,
|
||||
onChoose = function(item, list)
|
||||
if item.cancel then list:close() return end
|
||||
local mon = game.save.party[item.value]
|
||||
if not mon then return end
|
||||
local Follower = require("src.world.PikachuFollower")
|
||||
@@ -163,14 +178,13 @@ local function release(game)
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
for i, mon in ipairs(box) do
|
||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||
end
|
||||
game.stack:push(ListMenu.new(game,
|
||||
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
|
||||
for i, mon in ipairs(box) do table.insert(items, monRow(game, mon, i)) end
|
||||
game.stack:push(ListMenu.new(game, nil, withCancel(items), {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
kind = "pc_box_release",
|
||||
onChoose = function(_, list)
|
||||
itemBox = true,
|
||||
onChoose = function(item, list)
|
||||
if item.cancel then list:close() return end
|
||||
local mon = box[list.index]
|
||||
if not mon then return end
|
||||
local name = monName(game, mon)
|
||||
@@ -202,37 +216,67 @@ local function release(game)
|
||||
}))
|
||||
end
|
||||
|
||||
local function changeBox(game)
|
||||
-- DisplayChangeBoxMenu: engine/menus/save.asm:437-506
|
||||
local function changeBoxMenu(game)
|
||||
local boxes = Boxes.ensure(game.save)
|
||||
local items = {}
|
||||
for i = 1, Boxes.COUNT do
|
||||
local mark = i == game.save.currentBox and "*" or " "
|
||||
table.insert(items, {
|
||||
label = Strings("%sBOX %2d", mark, i),
|
||||
right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY),
|
||||
value = i,
|
||||
})
|
||||
items[i] = {
|
||||
label = Strings("BOX%2d", i),
|
||||
onSelect = function()
|
||||
game.save.currentBox = i
|
||||
if game.writeSave then game:writeSave() end
|
||||
-- ChangeBox (engine/menus/save.asm) rings SFX_SAVE after SaveGameData (#1044)
|
||||
require("src.core.Sound").play(game.data, "Save")
|
||||
end,
|
||||
}
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
kind = "pc_box_change",
|
||||
onChoose = function(item, list)
|
||||
-- the original asks BEFORE switching ("When you change a #MON
|
||||
-- BOX, data will be saved. OK?"); declining aborts the change
|
||||
game.stack:push(TextBox.new(game,
|
||||
Strings("When you change a\nPOKéMON BOX, data\nwill be saved. OK?"), nil, {
|
||||
noSound = true,
|
||||
choice = function(yes)
|
||||
if not yes then return end
|
||||
game.save.currentBox = item.value
|
||||
if game.writeSave then game:writeSave() end
|
||||
-- ChangeBox (engine/menus/save.asm) rings SFX_SAVE after SaveGameData (#1044)
|
||||
require("src.core.Sound").play(game.data, "Save")
|
||||
list:close()
|
||||
end,
|
||||
}))
|
||||
local menu = Menu.new(game, items, {
|
||||
tx = 11, ty = 0, tw = 9, th = 14, rowStep = 1, itemY = 1, noSound = true,
|
||||
})
|
||||
menu.kind = "pc_box_change" -- screen.render_visible identity
|
||||
menu.index = math.max(1, math.min(Boxes.COUNT, game.save.currentBox or 1))
|
||||
local t = game.data.text
|
||||
local baseDraw = menu.draw
|
||||
function menu:draw()
|
||||
-- ChooseABoxText goes to the standard box, over BillsPCMenu's "What?"
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local y = 112
|
||||
for line in ((t._ChooseABoxText or Strings("Choose a\nPOKéMON BOX."))
|
||||
.. "\n"):gmatch("([^\n]*)\n") do
|
||||
Font.draw(line, 8, y)
|
||||
y = y + 16
|
||||
end
|
||||
Font.drawBox(0, 0, 11, 4)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("BOX No."), 8, 16)
|
||||
local n = game.save.currentBox or 1
|
||||
Font.draw(tostring(n), n >= 10 and 64 or 72, 16)
|
||||
baseDraw(self)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i = 1, Boxes.COUNT do
|
||||
if #boxes[i] > 0 then ListMenu.drawBall(148, i * 8 + 4) end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
game.stack:push(menu)
|
||||
end
|
||||
|
||||
-- ChangeBox (engine/menus/save.asm:358-368) asks before showing the menu
|
||||
-- and returns to the PC menu on No.
|
||||
local function changeBox(game)
|
||||
local t = game.data.text
|
||||
local ask = TextBox.new(game, t._WhenYouChangeBoxText
|
||||
or Strings("When you change a\nPOKéMON BOX, data\vwill be saved.\fIs that okay?"),
|
||||
nil, {
|
||||
noSound = true,
|
||||
choice = function(yes)
|
||||
if yes then changeBoxMenu(game) end
|
||||
end,
|
||||
}))
|
||||
})
|
||||
ask.kind = "pc_box_change"
|
||||
game.stack:push(ask)
|
||||
end
|
||||
|
||||
-- bills_pc.asm BillsPCMenu chrome: What? text box + BOX No. overlay
|
||||
|
||||
+37
-5
@@ -87,6 +87,15 @@ local function frameSheet(game)
|
||||
return frameCache[path]
|
||||
end
|
||||
|
||||
-- one tile of the sheet, for the CONTENTS screen's divider column
|
||||
-- (engine/menus/pokedex.asm:350, DrawPokedexVerticalLine)
|
||||
function DexEntryMenu.tile(game, code, tx, ty)
|
||||
local frame = frameSheet(game)
|
||||
if not (frame and code) then return end
|
||||
local quad = frame.quads[code - 0x60]
|
||||
if quad then love.graphics.draw(frame.img, quad, tx * 8, ty * 8) end
|
||||
end
|
||||
|
||||
-- engine/menus/pokedex.asm:601
|
||||
local DIVIDER = {
|
||||
0x68, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x6B,
|
||||
@@ -106,14 +115,28 @@ function DexEntryMenu.new(game, speciesOrOpts, onDone)
|
||||
self.sprite = ok and img or nil
|
||||
self.spriteTrueColor = self.sprite and trueColor or false
|
||||
self.page = 1
|
||||
self.blink = 0
|
||||
local pages = descPages(game, self.def, forceOwned)
|
||||
self.pageCount = pages and #pages or 1
|
||||
require("src.core.Sound").playCry(game.data, species)
|
||||
-- engine/menus/pokedex.asm:500-506, home/pokemon.asm:145-148
|
||||
self.crySrc = require("src.core.Sound").playCry(game.data, species)
|
||||
return self
|
||||
end
|
||||
|
||||
-- home/pokemon.asm:148 (jp WaitForSoundToFinish)
|
||||
function DexEntryMenu:crying()
|
||||
local src = self.crySrc
|
||||
if not src then return false end
|
||||
local ok, playing = pcall(src.isPlaying, src)
|
||||
if ok and playing then return true end
|
||||
self.crySrc = nil
|
||||
return false
|
||||
end
|
||||
|
||||
function DexEntryMenu:update(dt)
|
||||
local input = self.game.input
|
||||
self.blink = ((self.blink or 0) + 1) % 60
|
||||
if self:crying() then return end
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
-- home/text.asm:245
|
||||
if self.page < (self.pageCount or 1) then
|
||||
@@ -127,15 +150,18 @@ end
|
||||
|
||||
function DexEntryMenu:draw()
|
||||
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
|
||||
self.spriteTrueColor, self.page)
|
||||
self.spriteTrueColor, self.page,
|
||||
{ crying = self:crying(),
|
||||
arrow = (self.blink or 0) < 30 })
|
||||
end
|
||||
|
||||
-- Static entry-page renderer, shared with the printer stand-in
|
||||
-- (src/core/Printer.lua renders the same page into a PNG the way
|
||||
-- PrintPokedexEntry rendered it to the Game Boy Printer).
|
||||
-- engine/menus/pokedex.asm:399
|
||||
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
|
||||
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page, state)
|
||||
page = page or 1
|
||||
state = state or {}
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
local frame = frameSheet(game)
|
||||
@@ -185,6 +211,12 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
|
||||
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0),
|
||||
16, 64)
|
||||
local owned = ownedFor(game, def, forceOwned)
|
||||
-- engine/menus/pokedex.asm:516: everything below the divider waits on the
|
||||
-- cry the line above it started
|
||||
if state.crying then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
-- engine/menus/pokedex.asm:449, numbers only once owned
|
||||
if owned and e.heightFt then
|
||||
if e.heightM then
|
||||
@@ -202,8 +234,8 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
|
||||
for i, line in ipairs(lines) do
|
||||
Font.draw(line, 8, 72 + i * 16)
|
||||
end
|
||||
-- home/text.asm:245
|
||||
if page < #pages then
|
||||
-- home/text.asm:245 places the '▼' and home/joypad2.asm:55-83 blinks it
|
||||
if page < #pages and state.arrow ~= false then
|
||||
Font.drawCode(Theme.moreArrow, 144, 128)
|
||||
end
|
||||
else
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Whether a Pokémon's level is printed on a screen that would normally
|
||||
-- print it (RFC 0019).
|
||||
--
|
||||
-- Every Gen 1 screen that shows a level does it with the same pokered rule
|
||||
-- -- home/pokemon.asm:335-345 PrintLevel: the <LV> tile, then the digits
|
||||
-- left-aligned after it, with a level of 100 writing its third digit back
|
||||
-- over the tile -- and each screen hand-rolls that rule against its own
|
||||
-- coordinates. This module does not touch any of that. It answers one
|
||||
-- question, in one place, so that a mode which wants the number off does
|
||||
-- not have to know four call sites and a glyph code.
|
||||
--
|
||||
-- Default is true, and `Runtime.wantsHook` is checked first, so a build
|
||||
-- with no mod wrapping the hook prints exactly what it always did and pays
|
||||
-- nothing for the seam on a per-frame draw path.
|
||||
--
|
||||
-- `where` names the surface rather than the widget, because the number
|
||||
-- means different things on different screens: on the battle HUD an
|
||||
-- opponent's level is information about them, on the party and status
|
||||
-- screens your own level is information about you. A mode can hide one and
|
||||
-- keep the other.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local LevelDisplay = {}
|
||||
|
||||
-- where: "battle.enemy" | "battle.player" | "party" | "summary"
|
||||
function LevelDisplay.visible(mon, where, game)
|
||||
if not Runtime.wantsHook("pokemon.level_visible") then return true end
|
||||
return Runtime.call("pokemon.level_visible", function() return true end,
|
||||
mon, { where = where, game = game }) ~= false
|
||||
end
|
||||
|
||||
return LevelDisplay
|
||||
+41
-26
@@ -120,8 +120,9 @@ function ListMenu.new(game, title, items, opts)
|
||||
-- so their lists opt out of the A/B beep the same way Menu's noSound does
|
||||
self.noSound = opts.noSound or false
|
||||
-- the bag's item list: a partial box the map stays visible around, not a
|
||||
-- screen of its own (home/list_menu.asm:29-31)
|
||||
self.itemBox = opts.itemBox or false
|
||||
-- screen of its own (home/list_menu.asm:29-31). Every DisplayListMenuID
|
||||
-- caller gets the same box, the PC item lists included (#1845).
|
||||
self.itemBox = opts.itemBox or opts.messageBox or false
|
||||
if self.itemBox then
|
||||
self.isOpaque = false
|
||||
-- keep RunDefaultPaletteCommand's last palette: ItemMenuLoop never sets
|
||||
@@ -256,6 +257,34 @@ function ListMenu:close()
|
||||
if top == self then self.game.stack:pop() end
|
||||
end
|
||||
|
||||
-- standard bottom text box (PrintText); long prompts wrap and keep
|
||||
-- their last two lines, like the GB's scrolled box (#115/#174)
|
||||
local function drawMessageBox(self)
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if not self.footer then return end
|
||||
local flat = {}
|
||||
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
|
||||
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||
end
|
||||
local y = 112
|
||||
for i = math.max(1, #flat - 1), #flat do
|
||||
Font.draw(flat[i], 8, y)
|
||||
y = y + 16
|
||||
end
|
||||
end
|
||||
|
||||
-- the Pokédex owned-ball marker tile, also the CHANGE BOX screen's
|
||||
-- PokeballTileGraphics marker (engine/menus/save.asm:495-499)
|
||||
function ListMenu.drawBall(x, y)
|
||||
local r, g, b, a = love.graphics.getColor()
|
||||
love.graphics.circle("fill", x, y, 3.5)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", x - 3.5, y - 0.5, 7, 1)
|
||||
love.graphics.circle("fill", x, y, 1.2)
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
end
|
||||
|
||||
-- PrintListMenuEntries, minus the price column StartMenu_Item never asks for
|
||||
-- (wPrintItemPrices = 0, engine/menus/start_sub_menus.asm)
|
||||
function ListMenu:drawItemBox()
|
||||
@@ -274,7 +303,10 @@ function ListMenu:drawItemBox()
|
||||
if item.cancel then sawCancel = true end
|
||||
local y = ITEM_TOP_Y + (row - 1) * 16
|
||||
Font.draw(item.label, ITEM_NAME_X, y)
|
||||
if item.right then
|
||||
if item.sub then
|
||||
-- PrintLevel, one row down and 8 columns right (home/list_menu.asm:459-461)
|
||||
Font.draw(item.sub, ITEM_QTY_X, y + 8)
|
||||
elseif item.right then
|
||||
-- '×' at column 14, PrintNumber's two right-aligned digits after it
|
||||
-- (home/list_menu.asm:479-490)
|
||||
local count = item.right:sub(2)
|
||||
@@ -294,6 +326,9 @@ function ListMenu:drawItemBox()
|
||||
if shown == self.rows and not sawCancel then
|
||||
Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y)
|
||||
end
|
||||
-- players_pc.asm:97/151/205 PrintText the prompt before DisplayListMenuID,
|
||||
-- so the bottom box sits under the list from the first frame
|
||||
if self.messageBox or self.footer then drawMessageBox(self) end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -326,13 +361,7 @@ function ListMenu:draw()
|
||||
-- one blank glyph after the name, measured in glyph advances rather
|
||||
-- than bytes: NIDORAN♂/♀ carry a multi-byte charmap entry, so
|
||||
-- `#item.label` overcounted by 2 and pushed their ball 16px right (#285)
|
||||
local bx = 16 + Font.width(label) + 8 + 3
|
||||
local by = y + 3
|
||||
love.graphics.circle("fill", bx, by, 3.5)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", bx - 3.5, by - 0.5, 7, 1)
|
||||
love.graphics.circle("fill", bx, by, 1.2)
|
||||
love.graphics.setColor(unpack(textColor))
|
||||
ListMenu.drawBall(16 + Font.width(label) + 8 + 3, y + 3)
|
||||
end
|
||||
if item.right then
|
||||
Font.draw(item.right, 160 - 8 - Font.width(item.right), y)
|
||||
@@ -360,22 +389,8 @@ function ListMenu:draw()
|
||||
local money = ("¥%d"):format(self.money and self.money() or 0)
|
||||
Font.draw(money, 152 - Font.width(money), 8)
|
||||
end
|
||||
if self.dialogue or (self.messageBox and self.footer) then
|
||||
-- standard bottom text box (PrintText); long prompts wrap and keep
|
||||
-- their last two lines, like the GB's scrolled box (#115/#174)
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if self.footer then
|
||||
local flat = {}
|
||||
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
|
||||
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||
end
|
||||
local y = 112
|
||||
for i = math.max(1, #flat - 1), #flat do
|
||||
Font.draw(flat[i], 8, y)
|
||||
y = y + 16
|
||||
end
|
||||
end
|
||||
if self.dialogue then
|
||||
drawMessageBox(self)
|
||||
elseif self.footer then
|
||||
-- bare footer (bag money line, etc.)
|
||||
local flat = {}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Scrolling for a menu line too long for its box: hold, step one character
|
||||
-- at a time, hold on the tail, snap back. Shared by the Gen 1 four-box
|
||||
-- viewport (src/ui/OptionRows.lua) and the Gen 2 textbox
|
||||
-- (src/ui/gen2/OptionsMenu.lua) -- different widths, same problem.
|
||||
|
||||
local Marquee = {}
|
||||
|
||||
Marquee.HOLD = 1.0
|
||||
Marquee.STEP = 0.25
|
||||
|
||||
-- Only ever one row scrolls (the cursor's), so one shared phase keeps that
|
||||
-- row's label and value stepping together.
|
||||
local phase = { key = nil, start = 0 }
|
||||
|
||||
local function clock()
|
||||
local timer = love and love.timer and love.timer.getTime
|
||||
return timer and timer() or 0
|
||||
end
|
||||
|
||||
-- Pure, so a test can step it without a clock.
|
||||
function Marquee.at(text, maxChars, elapsed)
|
||||
text = text or ""
|
||||
local over = #text - maxChars
|
||||
if over <= 0 then return text end
|
||||
local span = Marquee.HOLD * 2 + over * Marquee.STEP
|
||||
local now = elapsed % span
|
||||
local offset
|
||||
if now < Marquee.HOLD then
|
||||
offset = 0
|
||||
elseif now < Marquee.HOLD + over * Marquee.STEP then
|
||||
offset = math.floor((now - Marquee.HOLD) / Marquee.STEP)
|
||||
else
|
||||
offset = over
|
||||
end
|
||||
return text:sub(offset + 1, offset + maxChars)
|
||||
end
|
||||
|
||||
-- `key` identifies the highlighted row; changing it restarts the cycle.
|
||||
function Marquee.scroll(text, maxChars, key)
|
||||
if not text or #text <= maxChars then return text or "" end
|
||||
if phase.key ~= key then phase.key, phase.start = key, clock() end
|
||||
return Marquee.at(text, maxChars, clock() - phase.start)
|
||||
end
|
||||
|
||||
function Marquee.clip(text, maxChars)
|
||||
return (text or ""):sub(1, maxChars)
|
||||
end
|
||||
|
||||
return Marquee
|
||||
@@ -120,14 +120,17 @@ function MoveLearnMenu:finish(learned)
|
||||
local opts
|
||||
if learned then
|
||||
-- pokered pages this as four texts in a row; _ForgotAndText carries
|
||||
-- the "And..." tail
|
||||
-- the "And..." tail. Each text_pause holds the box, and the first one
|
||||
-- is followed by SFX_SWAP (engine/pokemon/learn_move.asm:208-222).
|
||||
msg = romText(game.data, "_OneTwoAndText", "1, 2 and...")
|
||||
.. TextBox.PAUSE
|
||||
.. romText(game.data, "_PoofText", " Poof!")
|
||||
.. TextBox.PAUSE
|
||||
.. romText(game.data, "_ForgotAndText",
|
||||
"\f%s forgot\n%s!\fAnd...", name, self.forgot)
|
||||
.. "\f" .. romText(game.data, "_LearnedMove1Text",
|
||||
"%s learned\n%s!", name, mdef.name)
|
||||
opts = { auto = { sound = function()
|
||||
opts = { pauseSounds = { "Swap" }, auto = { sound = function()
|
||||
return require("src.core.Sound").play(game.data, self.learnedSound)
|
||||
end, wait = true } }
|
||||
else
|
||||
|
||||
+19
-3
@@ -7,12 +7,18 @@
|
||||
-- rows that open something instead (MODS, CANCEL stays the caller's).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Marquee = require("src.ui.Marquee")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
local OptionRows = {}
|
||||
|
||||
OptionRows.VISIBLE = 4 -- option boxes on screen at once (4 tiles each)
|
||||
|
||||
-- Font.drawBox(0, y, 20, 4) spends column 19 on the frame, so a line drawn
|
||||
-- to the 160px screen edge prints over its own border.
|
||||
local CONTENT_RIGHT = 152
|
||||
local function fits(x) return math.floor((CONTENT_RIGHT - x) / 8) end
|
||||
|
||||
-- keep the cursor's box inside the viewport; the fixed bottom row shows
|
||||
-- the tail of the list
|
||||
function OptionRows.clampScroll(index, scroll, total, bottomRow)
|
||||
@@ -27,7 +33,7 @@ function OptionRows.clampScroll(index, scroll, total, bottomRow)
|
||||
end
|
||||
|
||||
-- one bordered box per row, label line + value line, with the fixed
|
||||
-- bottom line below (CANCEL in the options menu, the manager's footer)
|
||||
-- bottom line below (BACK in the options menu, the manager's footer)
|
||||
function OptionRows.draw(game, rows, index, scroll, bottomLabel, bottomRow)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
@@ -37,8 +43,18 @@ function OptionRows.draw(game, rows, index, scroll, bottomLabel, bottomRow)
|
||||
if not row then break end
|
||||
Font.drawBox(0, (slot - 1) * 4, 20, 4)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(row.label, 16, ((slot - 1) * 4 + 1) * 8)
|
||||
Font.draw(row.value and row.value(game) or "", 24, ((slot - 1) * 4 + 2) * 8)
|
||||
local label = row.label or ""
|
||||
local value = row.value and row.value(game) or ""
|
||||
if i == index then
|
||||
local key = tostring(row.id or row.label) .. "\0" .. tostring(value)
|
||||
label = Marquee.scroll(label, fits(16), key)
|
||||
value = Marquee.scroll(value, fits(24), key)
|
||||
else
|
||||
label = Marquee.clip(label, fits(16))
|
||||
value = Marquee.clip(value, fits(24))
|
||||
end
|
||||
Font.draw(label, 16, ((slot - 1) * 4 + 1) * 8)
|
||||
Font.draw(value, 24, ((slot - 1) * 4 + 2) * 8)
|
||||
if i == index then
|
||||
Font.drawCode(Theme.cursor, 8, ((slot - 1) * 4 + 1) * 8)
|
||||
end
|
||||
|
||||
+138
-22
@@ -11,10 +11,12 @@
|
||||
-- bottom line like pokered's.
|
||||
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Palette = require("src.render.Palette")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local ShaderFX = require("src.render.ShaderFX")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local Letterbox = require("src.render.Letterbox")
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
@@ -127,14 +129,6 @@ local function stepVolume(v, dir)
|
||||
return math.max(0, math.min(7, (v or 7) + dir))
|
||||
end
|
||||
|
||||
local function colorIndex(opts)
|
||||
local cur = opts.colors or "gbc"
|
||||
for i, m in ipairs(PaletteFX.MODES) do
|
||||
if m == cur then return i end
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
local function wrapIndex(i, n)
|
||||
i = i % n
|
||||
if i < 0 then i = i + n end
|
||||
@@ -341,15 +335,14 @@ local function buildRows(game)
|
||||
end },
|
||||
{ id = "colors", label = Strings("COLORS"),
|
||||
value = function(g)
|
||||
local id = g.save.options.palette
|
||||
if id and id ~= "" then
|
||||
return Palette.label(id) or PaletteFX.modeLabel(g.save.options.colors or "gbc")
|
||||
end
|
||||
return PaletteFX.modeLabel(g.save.options.colors or "gbc")
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
local i = colorIndex(o)
|
||||
i = wrapIndex(i - 1 + dir, #PaletteFX.MODES) + 1
|
||||
o.colors = PaletteFX.MODES[i]
|
||||
PaletteFX.setMode(o.colors)
|
||||
return true
|
||||
activate = function(g)
|
||||
require("src.ui.Screens").push(g, "PaletteScreen")
|
||||
end },
|
||||
{ id = "tilt", label = Strings("TILT"),
|
||||
value = function(g) return Tilt.levelLabel(g.save.options.tilt or 0) end,
|
||||
@@ -376,6 +369,16 @@ local function buildRows(game)
|
||||
-- cycling in place on this row.
|
||||
-- ShaderFXScreen.lua does the actual list/activate; this row only opens
|
||||
-- it and shows what is currently active.
|
||||
{ id = "uiLetterbox", label = Strings("UI LETTERBOX"),
|
||||
value = function(g)
|
||||
return Strings(Letterbox.label(g.save.options.uiLetterbox))
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
o.uiLetterbox = Letterbox.cycle(o.uiLetterbox, dir)
|
||||
Letterbox.setMode(o.uiLetterbox)
|
||||
return true
|
||||
end },
|
||||
{ id = "shaderfx", label = Strings("SHADER FX"),
|
||||
value = function(g)
|
||||
return shaderfxLabel(ShaderFX.activeEntry("main"))
|
||||
@@ -643,8 +646,90 @@ local function buildRows(game)
|
||||
return rows
|
||||
end
|
||||
|
||||
-- Grouping runs AFTER the ui.options.rows hook and never touches self.rows,
|
||||
-- so a mod still sees and edits the flat list it always did; a row in no
|
||||
-- group stays where it is, which is where a mod's own additions land.
|
||||
local GROUPS = {
|
||||
{ id = "group.battle", label = "BATTLE OPTIONS",
|
||||
members = { "animations", "battleStyle", "battleLayout", "battleFit",
|
||||
"battleHud", "battleBg" } },
|
||||
{ id = "group.audio", label = "AUDIO",
|
||||
members = { "musicVol", "sfxVol", "pikaVol", "musicFilter" } },
|
||||
{ id = "group.video", label = "VIDEO",
|
||||
members = { "uiLayout", "videoMode", "orientation", "faithfulRes",
|
||||
"screenPos", "fpsCap" } },
|
||||
{ id = "group.speed", label = "SPEED",
|
||||
members = { "textSpeed", "speedOverworld", "speedBattle", "speedMenu" } },
|
||||
{ id = "group.graphics", label = "GRAPHICS",
|
||||
members = { "colors", "uiLetterbox", "shaderfx", "shaderfx2" } },
|
||||
-- A mod's Pipelines row splices in after TILT and is in no group, so it
|
||||
-- stays on the top level rather than being swallowed into this page.
|
||||
{ id = "group.extras", label = "EXTRAS",
|
||||
members = { "tilt", "zoom", "voidFill" } },
|
||||
}
|
||||
|
||||
-- The top level's order, groups and singles alike. Anything not named here
|
||||
-- (a mod's row, the touch rows) keeps its flat position after these.
|
||||
local ORDER = {
|
||||
"group.speed", "group.video", "group.graphics", "group.audio",
|
||||
"performance", "ruleset", "group.battle", "group.extras", "mods",
|
||||
}
|
||||
|
||||
-- Each group replaced by one opener.
|
||||
local function groupRows(game, rows)
|
||||
local owner, picked = {}, {}
|
||||
for _, group in ipairs(GROUPS) do
|
||||
for _, id in ipairs(group.members) do owner[id] = group end
|
||||
picked[group.id] = {}
|
||||
end
|
||||
for _, row in ipairs(rows) do
|
||||
local group = row.id and owner[row.id]
|
||||
if group then picked[group.id][#picked[group.id] + 1] = row end
|
||||
end
|
||||
local made = {}
|
||||
for _, group in ipairs(GROUPS) do
|
||||
local members = picked[group.id]
|
||||
if #members > 0 then
|
||||
made[group.id] = {
|
||||
id = group.id, label = Strings(group.label), group = true,
|
||||
value = function() return Strings("%d OPTIONS", #members) end,
|
||||
-- No onCancel: BACK out of a page returns here, it does not close
|
||||
-- OPTIONS, so the caller's close callback must not fire.
|
||||
activate = function(g)
|
||||
g.stack:push(OptionsMenu.new(g, { rows = members }))
|
||||
end,
|
||||
}
|
||||
end
|
||||
end
|
||||
local byId, view, taken = {}, {}, {}
|
||||
for _, row in ipairs(rows) do
|
||||
if row.id and not owner[row.id] then byId[row.id] = row end
|
||||
end
|
||||
for _, id in ipairs(ORDER) do
|
||||
local row = made[id] or byId[id]
|
||||
if row then
|
||||
view[#view + 1] = row
|
||||
taken[id] = true
|
||||
end
|
||||
end
|
||||
-- Whatever ORDER does not name, in the order the flat list had it: a mod's
|
||||
-- own rows, and the platform rows that come and go.
|
||||
for _, row in ipairs(rows) do
|
||||
local id = row.id
|
||||
if not (id and (owner[id] or taken[id])) then view[#view + 1] = row end
|
||||
end
|
||||
return view
|
||||
end
|
||||
|
||||
-- opts.rows makes a submenu: a fixed row list, no hook and no regrouping, so
|
||||
-- a group's page is this same screen driving the rows it was handed.
|
||||
function OptionsMenu.new(game, opts)
|
||||
opts = opts or {}
|
||||
if opts.rows then
|
||||
return setmetatable({ game = game, rows = opts.rows, view = opts.rows,
|
||||
index = 1, scroll = 0, sub = true,
|
||||
onCancel = opts.onCancel }, OptionsMenu)
|
||||
end
|
||||
local rows = buildRows(game)
|
||||
local hooked = Runtime.call("ui.options.rows", sameRows, game, rows)
|
||||
if type(hooked) == "table" then
|
||||
@@ -653,14 +738,44 @@ function OptionsMenu.new(game, opts)
|
||||
Logger.error("ui.options.rows returned %s; keeping the vanilla rows",
|
||||
type(hooked))
|
||||
end
|
||||
return setmetatable({ game = game, rows = rows, index = 1, scroll = 0,
|
||||
onCancel = opts.onCancel }, OptionsMenu)
|
||||
local self = setmetatable({ game = game, rows = rows, index = 1, scroll = 0,
|
||||
onCancel = opts.onCancel }, OptionsMenu)
|
||||
self.view = groupRows(game, rows)
|
||||
return self
|
||||
end
|
||||
|
||||
-- Cursor onto a row by id, opening its group page first if it lives in one.
|
||||
-- Returns the screen the row ended up on, so a caller can keep driving it.
|
||||
function OptionsMenu:focusRow(id)
|
||||
local rows = self.view or self.rows
|
||||
for i, row in ipairs(rows) do
|
||||
if row.id == id then
|
||||
self.index = i
|
||||
self.scroll = OptionRows.clampScroll(i, self.scroll or 0, #rows, #rows + 1)
|
||||
return self
|
||||
end
|
||||
end
|
||||
for i, row in ipairs(rows) do
|
||||
if row.group and row.activate then
|
||||
local group
|
||||
for _, g in ipairs(GROUPS) do if g.id == row.id then group = g end end
|
||||
for _, member in ipairs(group and group.members or {}) do
|
||||
if member == id then
|
||||
self.index = i
|
||||
row.activate(self.game)
|
||||
local sub = self.game.stack:top()
|
||||
return sub.focusRow and sub:focusRow(id) or sub
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function OptionsMenu:update(dt)
|
||||
local input = self.game.input
|
||||
local rows = self.rows
|
||||
-- CANCEL sits below the hook-built rows so a mod cannot orphan the exit
|
||||
local rows = self.view or self.rows
|
||||
-- BACK sits below the hook-built rows so a mod cannot orphan the exit
|
||||
local cancelRow = #rows + 1
|
||||
local changed = false
|
||||
if input:wasPressed("up") then
|
||||
@@ -702,14 +817,15 @@ function OptionsMenu:update(dt)
|
||||
end
|
||||
|
||||
function OptionsMenu:draw()
|
||||
-- Through Strings, like every other label on this menu. CANCEL is
|
||||
-- Through Strings, like every other label on this menu. BACK is
|
||||
-- appended AFTER the rows hook (see the header), which is what keeps a mod
|
||||
-- from orphaning the exit -- but it also means a translation mod never sees
|
||||
-- this string, and cannot: there is no row for it to rewrite. So the one
|
||||
-- word a Spanish player could not read on a fully translated OPTIONS menu
|
||||
-- was the way out of it.
|
||||
OptionRows.draw(self.game, self.rows, self.index, self.scroll or 0,
|
||||
Strings("CANCEL"), #self.rows + 1)
|
||||
local rows = self.view or self.rows
|
||||
OptionRows.draw(self.game, rows, self.index, self.scroll or 0,
|
||||
Strings("BACK"), #rows + 1)
|
||||
end
|
||||
|
||||
return OptionsMenu
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Palette = require("src.render.Palette")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local PaletteScreen = {}
|
||||
PaletteScreen.__index = PaletteScreen
|
||||
|
||||
PaletteScreen.isOpaque = true
|
||||
|
||||
local COLS, ROWS = 5, 4
|
||||
local TILE_W, TILE_H = 28, 20
|
||||
local GAP = 4
|
||||
local GRID_X, GRID_Y = 2, 14
|
||||
local PER_PAGE = COLS * ROWS
|
||||
|
||||
local SCREEN_W, SCREEN_H = 160, 144
|
||||
|
||||
local BACK = 0
|
||||
|
||||
local function sanitize(text)
|
||||
text = tostring(text or ""):upper():gsub("_", "'")
|
||||
text = text:gsub("[^A-Z0-9 %.'%-]", " ")
|
||||
text = text:gsub("%s+", " "):gsub("^ ", ""):gsub(" $", "")
|
||||
return text
|
||||
end
|
||||
|
||||
PaletteScreen.sanitize = sanitize -- named for the suite
|
||||
|
||||
local function wrap(text, width, lines)
|
||||
local out = {}
|
||||
local rest = text
|
||||
for i = 1, lines do
|
||||
if rest == "" then break end
|
||||
if #rest <= width then
|
||||
out[i] = rest
|
||||
rest = ""
|
||||
else
|
||||
local cut = rest:sub(1, width + 1):match("^.*() ")
|
||||
if not cut or cut < 2 then cut = width + 1 end
|
||||
out[i] = rest:sub(1, cut - 1)
|
||||
rest = rest:sub(cut):gsub("^ +", "")
|
||||
end
|
||||
end
|
||||
if rest ~= "" and #out > 0 then
|
||||
local last = out[#out]
|
||||
out[#out] = last:sub(1, math.max(0, width - 1)) .. "."
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
PaletteScreen.wrap = wrap -- named for the suite
|
||||
|
||||
local function packLabel(name, width)
|
||||
local text = sanitize(name)
|
||||
if #text > width then
|
||||
local leaf = tostring(name):match("([^/]+)$")
|
||||
if leaf then text = sanitize(leaf) end
|
||||
end
|
||||
return wrap(text, width, 1)[1] or ""
|
||||
end
|
||||
|
||||
PaletteScreen.packLabel = packLabel -- named for the suite
|
||||
|
||||
|
||||
local function fill(x, y, w, h, c)
|
||||
love.graphics.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1)
|
||||
love.graphics.rectangle("fill", x, y, w, h)
|
||||
end
|
||||
|
||||
local function frame(x, y, w, h, shade)
|
||||
love.graphics.setColor(shade, shade, shade, 1)
|
||||
love.graphics.rectangle("line", x + 0.5, y + 0.5, w - 1, h - 1)
|
||||
end
|
||||
|
||||
local function drawSwatch(x, y, w, h, swatch)
|
||||
if not swatch then
|
||||
frame(x, y, w, h, 0.5)
|
||||
return
|
||||
end
|
||||
local band = w / 4
|
||||
for i = 1, 4 do
|
||||
fill(x + math.floor((i - 1) * band), y,
|
||||
math.ceil(band), h, swatch[i])
|
||||
end
|
||||
end
|
||||
|
||||
PaletteScreen.drawSwatch = drawSwatch -- named for the suite
|
||||
|
||||
local function drawBack(selected)
|
||||
if selected then
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 10, 9)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
for i = 0, 3 do
|
||||
love.graphics.rectangle("fill", 2 + i, 4 - i, 1, 1 + i * 2)
|
||||
end
|
||||
end
|
||||
|
||||
PaletteScreen.drawBack = drawBack -- named for the suite
|
||||
|
||||
|
||||
local function engineModes()
|
||||
local labels = PaletteFX.MODE_LABELS or {}
|
||||
local out = {}
|
||||
for i, id in ipairs(PaletteFX.MODES) do
|
||||
out[i] = { PaletteFX.modeLabel(id) or labels[id] or id:upper(), id }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function dropConflictingMode(game)
|
||||
local mode = PaletteFX.mode
|
||||
local bakes = PaletteFX.usesGbcPack(mode) or PaletteFX.usesSpriteObp(mode)
|
||||
if not bakes then return end
|
||||
local o = game.save.options
|
||||
o.colors = "gbc"
|
||||
PaletteFX.setMode("gbc")
|
||||
end
|
||||
|
||||
local function liveOpts(game)
|
||||
dropConflictingMode(game)
|
||||
return {
|
||||
palette = Palette,
|
||||
get = function() return game.save.options.palette or "" end,
|
||||
set = function(v)
|
||||
local o = game.save.options
|
||||
o.palette = v
|
||||
if v ~= "" then dropConflictingMode(game) end
|
||||
PaletteFX.setCustomRamp(v ~= "" and Palette.ramp(v) or nil)
|
||||
if game.writeOptions then pcall(game.writeOptions, game) end
|
||||
end,
|
||||
modes = engineModes(),
|
||||
getMode = function() return game.save.options.colors or "gbc" end,
|
||||
setMode = function(v)
|
||||
local o = game.save.options
|
||||
o.colors = v
|
||||
PaletteFX.setMode(v)
|
||||
if game.writeOptions then pcall(game.writeOptions, game) end
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
function PaletteScreen.new(game, opts)
|
||||
opts = opts or liveOpts(game)
|
||||
local self = setmetatable({
|
||||
game = game,
|
||||
Palette = opts.palette,
|
||||
get = opts.get, set = opts.set,
|
||||
modes = opts.modes or {},
|
||||
getMode = opts.getMode, setMode = opts.setMode,
|
||||
onChange = opts.onChange,
|
||||
view = "root",
|
||||
index = 1,
|
||||
scroll = 0,
|
||||
group = nil,
|
||||
stackTrail = {},
|
||||
}, PaletteScreen)
|
||||
self.rows = self:buildRoot()
|
||||
self:enterFromSelection()
|
||||
return self
|
||||
end
|
||||
|
||||
|
||||
function PaletteScreen:buildRoot()
|
||||
local rows = {}
|
||||
for _, category in ipairs(self.Palette and self.Palette.categories() or {}) do
|
||||
if #(category.palettes or {}) > 0 then
|
||||
rows[#rows + 1] = { label = category.name, folder = true,
|
||||
view = "tiles", group = category }
|
||||
end
|
||||
end
|
||||
if self.Palette and self.Palette.MONO_COLOURS then
|
||||
rows[#rows + 1] = { label = "MONOCHROME", folder = true, view = "mono" }
|
||||
end
|
||||
for _, mode in ipairs(self.modes) do
|
||||
rows[#rows + 1] = { label = mode[1], mode = mode[2] }
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
function PaletteScreen:rootIndexFor(view, group)
|
||||
for i, row in ipairs(self.rows or {}) do
|
||||
if row.view == view then
|
||||
if view ~= "tiles" then return i end
|
||||
if row.group and group and row.group.key == group.key then return i end
|
||||
end
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
function PaletteScreen:enterFromSelection()
|
||||
local Palette = self.Palette
|
||||
if not Palette then return end
|
||||
local id = self.get and self.get() or ""
|
||||
if type(id) ~= "string" or id == "" then return end
|
||||
|
||||
if id:match("^m:") then
|
||||
for i, c in ipairs(Palette.MONO_COLOURS) do
|
||||
if Palette.monoId(c[2], c[3], c[4]) == id then
|
||||
self:openAt("mono", nil, i)
|
||||
return
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local key, at = Palette.locate(id)
|
||||
if key then self:openAt("tiles", Palette.category(key), at) end
|
||||
end
|
||||
|
||||
function PaletteScreen:openAt(view, group, index)
|
||||
self.stackTrail = { { view = "root", index = self:rootIndexFor(view, group),
|
||||
scroll = 0, group = nil } }
|
||||
self.view, self.group, self.index, self.scroll = view, group, index or 1, 0
|
||||
local items = self:items()
|
||||
self:clampScroll(#items, #items + 1, PER_PAGE)
|
||||
end
|
||||
|
||||
|
||||
function PaletteScreen:items()
|
||||
local Palette = self.Palette
|
||||
if self.view == "root" then return self.rows or {} end
|
||||
if self.view == "tiles" then
|
||||
return self.group and self.group.palettes or {}
|
||||
end
|
||||
if self.view == "mono" then
|
||||
return Palette and Palette.MONO_COLOURS or {}
|
||||
end
|
||||
return {}
|
||||
end
|
||||
|
||||
function PaletteScreen:isGrid()
|
||||
return self.view == "tiles" or self.view == "mono"
|
||||
end
|
||||
|
||||
function PaletteScreen:idAt(i)
|
||||
local Palette = self.Palette
|
||||
if not Palette then return nil end
|
||||
if self.view == "tiles" then
|
||||
local entry = self.group and (self.group.palettes or {})[i]
|
||||
return entry and entry.id or nil
|
||||
elseif self.view == "mono" then
|
||||
local c = Palette.MONO_COLOURS[i]
|
||||
return c and Palette.monoId(c[2], c[3], c[4]) or nil
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function PaletteScreen:title()
|
||||
if self.view == "root" then return Strings("COLOUR") end
|
||||
if self.view == "mono" then return Strings("MONOCHROME") end
|
||||
return sanitize(self.group and self.group.name or "")
|
||||
end
|
||||
|
||||
|
||||
function PaletteScreen:update()
|
||||
local input = self.game.input
|
||||
local items = self:items()
|
||||
local total = #items
|
||||
local cancel = total + 1
|
||||
|
||||
if input:wasPressed("b") then
|
||||
self:back()
|
||||
return
|
||||
end
|
||||
|
||||
if self:isGrid() then
|
||||
self:moveGrid(input, total, cancel)
|
||||
else
|
||||
self:moveList(input, total, cancel)
|
||||
end
|
||||
|
||||
if input:wasPressed("a") then
|
||||
self:activate(cancel)
|
||||
end
|
||||
end
|
||||
|
||||
function PaletteScreen:moveList(input, total, cancel)
|
||||
if self.index == BACK then
|
||||
if input:wasPressed("down") or input:wasPressed("right") then
|
||||
self.index = 1
|
||||
end
|
||||
return
|
||||
end
|
||||
if input:wasPressed("left") then
|
||||
self.index = BACK
|
||||
return
|
||||
end
|
||||
if input:wasPressed("up") then
|
||||
self.index = self.index > 1 and self.index - 1 or BACK
|
||||
elseif input:wasPressed("down") then
|
||||
self.index = self.index < cancel and self.index + 1 or 1
|
||||
end
|
||||
self:clampScroll(total, cancel, 12)
|
||||
end
|
||||
|
||||
function PaletteScreen:moveGrid(input, total, cancel)
|
||||
local i = self.index
|
||||
|
||||
if i == BACK then
|
||||
if input:wasPressed("down") or input:wasPressed("right") then
|
||||
self.index = self.scroll + 1
|
||||
if self.index > total then self.index = 1 end
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if i > total then
|
||||
if input:wasPressed("up") then
|
||||
self.index = total
|
||||
elseif input:wasPressed("down") then
|
||||
self.index = 1
|
||||
end
|
||||
self:clampScroll(total, cancel, PER_PAGE)
|
||||
return
|
||||
end
|
||||
|
||||
if input:wasPressed("left") then
|
||||
self.index = ((i - 1) % COLS == 0) and BACK or i - 1
|
||||
elseif input:wasPressed("right") then
|
||||
self.index = i < total and i + 1 or 1
|
||||
elseif input:wasPressed("up") then
|
||||
self.index = (i - COLS >= 1) and i - COLS or BACK
|
||||
elseif input:wasPressed("down") then
|
||||
if i + COLS <= total then
|
||||
self.index = i + COLS
|
||||
else
|
||||
self.index = cancel
|
||||
end
|
||||
end
|
||||
self:clampScroll(total, cancel, PER_PAGE)
|
||||
end
|
||||
|
||||
function PaletteScreen:clampScroll(total, cancel, visible)
|
||||
if self.index == BACK then return end
|
||||
if self.index >= cancel then
|
||||
self.scroll = math.max(0, total - visible)
|
||||
if self:isGrid() then
|
||||
self.scroll = math.floor(self.scroll / COLS) * COLS
|
||||
end
|
||||
return
|
||||
end
|
||||
local step = self:isGrid() and COLS or 1
|
||||
if self.index <= self.scroll then
|
||||
self.scroll = math.floor((self.index - 1) / step) * step
|
||||
elseif self.index > self.scroll + visible then
|
||||
self.scroll = math.floor((self.index - 1) / step) * step - visible + step
|
||||
end
|
||||
if self.scroll < 0 then self.scroll = 0 end
|
||||
end
|
||||
|
||||
function PaletteScreen:activate(cancel)
|
||||
if self.index == BACK or self.index >= cancel then
|
||||
self:back()
|
||||
return
|
||||
end
|
||||
|
||||
if self.view == "root" then
|
||||
local row = (self.rows or {})[self.index]
|
||||
if not row then return end
|
||||
if row.folder then
|
||||
self:push(row.view, row.group)
|
||||
elseif row.mode then
|
||||
if self.set then self.set("") end
|
||||
if self.setMode then self.setMode(row.mode) end
|
||||
self:changed()
|
||||
end
|
||||
|
||||
elseif self:isGrid() then
|
||||
local id = self:idAt(self.index)
|
||||
if id then
|
||||
if self.set then self.set(id) end
|
||||
self:changed()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function PaletteScreen:changed()
|
||||
if self.game.data and Sound and Sound.play then
|
||||
pcall(Sound.play, self.game.data, "Press_AB")
|
||||
end
|
||||
if self.onChange then self.onChange() end
|
||||
end
|
||||
|
||||
function PaletteScreen:push(view, group)
|
||||
self.stackTrail[#self.stackTrail + 1] =
|
||||
{ view = self.view, index = self.index, scroll = self.scroll,
|
||||
group = self.group }
|
||||
self.view, self.index, self.scroll = view, 1, 0
|
||||
self.group = group
|
||||
self:cursorToSelection()
|
||||
end
|
||||
|
||||
function PaletteScreen:cursorToSelection()
|
||||
local id = self.get and self.get() or ""
|
||||
if type(id) ~= "string" or id == "" then return end
|
||||
local items = self:items()
|
||||
for i = 1, #items do
|
||||
if self:idAt(i) == id then
|
||||
self.index = i
|
||||
self:clampScroll(#items, #items + 1,
|
||||
self:isGrid() and PER_PAGE or 12)
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function PaletteScreen:back()
|
||||
local prev = table.remove(self.stackTrail)
|
||||
if prev then
|
||||
self.view, self.index = prev.view, prev.index
|
||||
self.scroll, self.group = prev.scroll, prev.group
|
||||
return
|
||||
end
|
||||
self:close()
|
||||
end
|
||||
|
||||
function PaletteScreen:close()
|
||||
if self.game.data and Sound and Sound.play then
|
||||
pcall(Sound.play, self.game.data, "Press_AB")
|
||||
end
|
||||
self.game.stack:pop()
|
||||
end
|
||||
|
||||
|
||||
function PaletteScreen:sgbPalettes()
|
||||
return nil
|
||||
end
|
||||
|
||||
|
||||
function PaletteScreen:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H)
|
||||
|
||||
drawBack(self.index == BACK)
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(self:title(), 12, 0)
|
||||
|
||||
if self:isGrid() then
|
||||
self:drawGrid()
|
||||
else
|
||||
self:drawList()
|
||||
end
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local items = self:items()
|
||||
if self.index >= #items + 1 then
|
||||
Font.draw(Strings("CANCEL"), 16, SCREEN_H - 8)
|
||||
Font.drawCode(Theme.cursor, 8, SCREEN_H - 8)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function PaletteScreen:drawList()
|
||||
local items = self:items()
|
||||
local mode = self.getMode and self.getMode() or nil
|
||||
local visible = 12
|
||||
for slot = 1, visible do
|
||||
local i = self.scroll + slot
|
||||
local row = items[i]
|
||||
if not row then break end
|
||||
local y = 16 + (slot - 1) * 8
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings(row.label or ""), 16, y)
|
||||
if i == self.index then
|
||||
Font.drawCode(Theme.cursor, 8, y)
|
||||
end
|
||||
if row.folder then
|
||||
Font.drawCode(Theme.cursor, 8 + 8 * 17, y)
|
||||
elseif row.mode and mode and row.mode == mode then
|
||||
love.graphics.rectangle("fill", 8 + 8 * 17 + 2, y + 2, 4, 4)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function PaletteScreen:drawGrid()
|
||||
local Palette = self.Palette
|
||||
local items = self:items()
|
||||
local total = #items
|
||||
|
||||
for slot = 1, PER_PAGE do
|
||||
local i = self.scroll + slot
|
||||
if i > total then break end
|
||||
local col = (slot - 1) % COLS
|
||||
local row = math.floor((slot - 1) / COLS)
|
||||
local x = GRID_X + col * (TILE_W + GAP)
|
||||
local y = GRID_Y + row * (TILE_H + GAP)
|
||||
|
||||
local swatch
|
||||
if self.view == "mono" then
|
||||
local c = Palette and Palette.MONO_COLOURS[i]
|
||||
if c then
|
||||
local ramp = Palette.monoRamp(c[2], c[3], c[4])
|
||||
swatch = { ramp[4], ramp[3], ramp[2], ramp[1] }
|
||||
end
|
||||
else
|
||||
swatch = Palette and Palette.swatch(self:idAt(i)) or nil
|
||||
end
|
||||
drawSwatch(x, y, TILE_W, TILE_H, swatch)
|
||||
|
||||
if i == self.index then
|
||||
frame(x - 2, y - 2, TILE_W + 4, TILE_H + 4, 0)
|
||||
frame(x - 1, y - 1, TILE_W + 2, TILE_H + 2, 1)
|
||||
end
|
||||
end
|
||||
|
||||
self:drawFooter(total)
|
||||
end
|
||||
|
||||
function PaletteScreen:drawFooter(total)
|
||||
local Palette = self.Palette
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
|
||||
local name, pack = "", nil
|
||||
if self.index >= 1 and self.index <= total then
|
||||
if self.view == "mono" then
|
||||
local c = Palette and Palette.MONO_COLOURS[self.index]
|
||||
name = c and c[1] or ""
|
||||
else
|
||||
local entry = self.group and (self.group.palettes or {})[self.index]
|
||||
if entry then
|
||||
name = sanitize(entry.name)
|
||||
pack = entry.pack
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local lines = wrap(name, 20, 2)
|
||||
for i = 1, #lines do
|
||||
Font.draw(lines[i], 0, 110 + (i - 1) * 8)
|
||||
end
|
||||
|
||||
local at = 0
|
||||
if total > PER_PAGE then
|
||||
local page = math.floor(self.scroll / PER_PAGE) + 1
|
||||
local pages = math.ceil(total / PER_PAGE)
|
||||
Font.draw(("%d/%d"):format(page, pages), 0, 128)
|
||||
at = 40
|
||||
end
|
||||
if pack then
|
||||
Font.draw(packLabel(pack, (SCREEN_W - at) / 8), at, 128)
|
||||
end
|
||||
|
||||
local stored = self.get and self.get() or ""
|
||||
if type(stored) == "string" and stored ~= "" then
|
||||
for slot = 1, PER_PAGE do
|
||||
local i = self.scroll + slot
|
||||
if i > total then break end
|
||||
if self:idAt(i) == stored then
|
||||
local col = (slot - 1) % COLS
|
||||
local row = math.floor((slot - 1) / COLS)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle(
|
||||
"fill",
|
||||
GRID_X + col * (TILE_W + GAP) + TILE_W - 5,
|
||||
GRID_Y + row * (TILE_H + GAP) + TILE_H - 5, 4, 4)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle(
|
||||
"fill",
|
||||
GRID_X + col * (TILE_W + GAP) + TILE_W - 4,
|
||||
GRID_Y + row * (TILE_H + GAP) + TILE_H - 4, 2, 2)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return PaletteScreen
|
||||
+27
-6
@@ -11,6 +11,7 @@
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Font = require("src.render.Font")
|
||||
local LevelDisplay = require("src.ui.LevelDisplay")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
@@ -71,6 +72,12 @@ function PartyMenu:sgbPalettes(game)
|
||||
return zones
|
||||
end
|
||||
|
||||
-- data/moves/field_moves.asm: leftmost tile per field move name
|
||||
local FIELD_MOVE_X = {
|
||||
cut = 12, fly = 12, surf = 12, flash = 12, DIG = 12,
|
||||
strength = 10, TELEPORT = 10, softboiled = 8,
|
||||
}
|
||||
|
||||
local function sameItems(_, items) return items end
|
||||
|
||||
local function followerUnavailable(game, mon)
|
||||
@@ -706,6 +713,9 @@ function PartyMenu:update(dt)
|
||||
-- (text_box.asm .donePrintingNames). #768
|
||||
items[#items + 1] = { label = Strings("STATS"), action = "stats" }
|
||||
items[#items + 1] = { label = Strings("SWITCH"), action = "switch" }
|
||||
-- CANCEL closes the whole party menu (start_sub_menus.asm:71-75
|
||||
-- .exitMenu), where B returns to the party list. #1833
|
||||
items[#items + 1] = { label = Strings("CANCEL"), action = "cancel" }
|
||||
end
|
||||
local ctx = { battle = self.battle, overworld = ow }
|
||||
local hooked = Runtime.call("ui.party.submenu", sameItems,
|
||||
@@ -785,7 +795,11 @@ function PartyMenu:draw()
|
||||
-- level at column 13 (<LV> tile + digits, PrintLevel) AND the
|
||||
-- status/FNT text at column 17 (PrintStatusCondition), like the
|
||||
-- original rows -- statused mons keep their level display
|
||||
if mon.level < 100 then
|
||||
if not LevelDisplay.visible(mon, "party", self.game) then -- RFC 0019
|
||||
-- the level column is simply empty; the status/FNT column at 17 is a
|
||||
-- separate field and still prints, exactly as it does for a mon whose
|
||||
-- level is on screen
|
||||
elseif mon.level < 100 then
|
||||
HudTiles.tile(0x6E, 104, y) -- <LV>
|
||||
Font.draw(tostring(mon.level), 112, y)
|
||||
else
|
||||
@@ -873,12 +887,19 @@ function PartyMenu:draw()
|
||||
end
|
||||
if self.submenu then
|
||||
local n = #self.subItems
|
||||
Font.drawBox(9, 17 - n * 2 - 1, 11, n * 2 + 1)
|
||||
local y0 = (17 - n * 2) * 8
|
||||
for si, entry in ipairs(self.subItems) do
|
||||
Font.draw(entry.label, 88, y0 + (si - 1) * 16)
|
||||
-- engine/menus/text_box.asm:397-440, data/text_boxes.asm:33 #1819
|
||||
local lx = 12
|
||||
for _, entry in ipairs(self.subItems) do
|
||||
local mx = FIELD_MOVE_X[entry.move or entry.action]
|
||||
if mx and mx < lx then lx = mx end
|
||||
end
|
||||
Font.drawCode(Theme.cursor, 80, y0 + (self.subIndex - 1) * 16)
|
||||
local top = n > 3 and math.max(0, 16 - n * 2) or 11
|
||||
Font.drawBox(lx - 1, top, 21 - lx, 18 - top)
|
||||
local y0 = (18 - n * 2) * 8
|
||||
for si, entry in ipairs(self.subItems) do
|
||||
Font.draw(entry.label, (lx + 1) * 8, y0 + (si - 1) * 16)
|
||||
end
|
||||
Font.drawCode(Theme.cursor, lx * 8, y0 + (self.subIndex - 1) * 16)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
+46
-3
@@ -11,6 +11,8 @@ local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local PlayerPC = {}
|
||||
|
||||
@@ -36,9 +38,20 @@ local function buildItems(game, store, order)
|
||||
})
|
||||
end
|
||||
end
|
||||
-- the $ff terminator's row (home/list_menu.asm:371-372, 523-528)
|
||||
items[#items + 1] = { cancel = true, label = Strings("CANCEL") }
|
||||
return items
|
||||
end
|
||||
|
||||
-- A on CANCEL leaves the list exactly like B (home/list_menu.asm:105-110)
|
||||
local function leftOnCancel(item, list)
|
||||
if item and item.cancel then
|
||||
list:close()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Ask "How many?" (DepositHowManyText/WithdrawHowManyText →
|
||||
-- DisplayChooseQuantityMenu) capped at the stack count. Key items and
|
||||
-- HMs always move one, with no prompt (IsKeyItem in players_pc.asm).
|
||||
@@ -76,11 +89,21 @@ end
|
||||
|
||||
local function withdraw(game)
|
||||
local pc = game.save.pcItems
|
||||
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
|
||||
-- players_pc.asm:144-149: an empty list is never opened
|
||||
if next(pc) == nil then
|
||||
game.stack:push(TextBox.new(game, romText(game.data, "_NothingStoredText",
|
||||
"There is nothing\nstored."), nil, { noSound = true }))
|
||||
return
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, nil, buildItems(game, pc), {
|
||||
kind = "pc_item_withdraw",
|
||||
messageBox = true,
|
||||
-- players_pc.asm:151-152 WhatToWithdrawText, printed before the list
|
||||
footer = romText(game.data, "_WhatToWithdrawText",
|
||||
"What do you want\nto withdraw?"),
|
||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
if leftOnCancel(item, list) then return end
|
||||
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
|
||||
local Bag = require("src.inventory.Bag")
|
||||
if not Bag.add(game.save, item.value, qty, game.data) then
|
||||
@@ -112,11 +135,21 @@ local function deposit(game)
|
||||
local Bag = require("src.inventory.Bag")
|
||||
-- engine/menus/players_pc.asm:99 wListPointer = wNumBagItems, so deposit order == bag order
|
||||
local order = Bag.order(game.save, game.data)
|
||||
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, inv, order), {
|
||||
-- players_pc.asm:90-95: an empty bag never reaches the list
|
||||
if #order == 0 then
|
||||
game.stack:push(TextBox.new(game, romText(game.data, "_NothingToDepositText",
|
||||
"You have nothing\nto deposit."), nil, { noSound = true }))
|
||||
return
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, nil, buildItems(game, inv, order), {
|
||||
kind = "pc_item_deposit",
|
||||
messageBox = true,
|
||||
-- players_pc.asm:97-98 WhatToDepositText, printed before the list
|
||||
footer = romText(game.data, "_WhatToDepositText",
|
||||
"What do you want\nto deposit?"),
|
||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
if leftOnCancel(item, list) then return end
|
||||
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
|
||||
if pcFull(game, pc, item.value) then
|
||||
list.footer = Strings("No room left to\nstore items.")
|
||||
@@ -134,11 +167,21 @@ end
|
||||
|
||||
local function toss(game)
|
||||
local pc = game.save.pcItems
|
||||
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
|
||||
-- players_pc.asm:196-201: an empty list is never opened
|
||||
if next(pc) == nil then
|
||||
game.stack:push(TextBox.new(game, romText(game.data, "_NothingStoredText",
|
||||
"There is nothing\nstored."), nil, { noSound = true }))
|
||||
return
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, nil, buildItems(game, pc), {
|
||||
kind = "pc_item_toss",
|
||||
messageBox = true,
|
||||
-- players_pc.asm:205-206 WhatToTossText, printed before the list
|
||||
footer = romText(game.data, "_WhatToTossText",
|
||||
"What do you want\nto toss away?"),
|
||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
if leftOnCancel(item, list) then return end
|
||||
local def = game.data.items[item.value]
|
||||
if (def and def.keyItem) or item.value:find("^HM_") then
|
||||
list.footer = Strings("That's too impor-\ntant to toss!")
|
||||
|
||||
+273
-82
@@ -1,9 +1,38 @@
|
||||
-- Minimal Pokédex: dex-ordered list with seen/owned markers.
|
||||
-- Pokédex CONTENTS screen: engine/menus/pokedex.asm:157-348
|
||||
-- (Yellow: pokeyellow engine/menus/pokedex.asm:263-336).
|
||||
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
local PokedexMenu = {}
|
||||
PokedexMenu.__index = PokedexMenu
|
||||
PokedexMenu.isOpaque = true
|
||||
|
||||
-- engine/menus/pokedex.asm:226 (`ld d, 7`), :21 (top menu item Y 3, X 0)
|
||||
local ROWS = 7
|
||||
local FIRST_ROW_Y = 24
|
||||
local NUM_X, BALL_X, NAME_X, CURSOR_X = 8, 24, 32, 0
|
||||
|
||||
-- engine/menus/pokedex.asm:163-199
|
||||
-- (Yellow: pokeyellow engine/menus/pokedex.asm:266-306)
|
||||
local RED_ROWS = { seen = 2, own = 5, rule = 8, items = 10 }
|
||||
local YELLOW_ROWS = { seen = 1, own = 4, rule = 6, items = 8 }
|
||||
|
||||
-- engine/menus/pokedex.asm:350 (DrawPokedexVerticalLine): column 14, $71
|
||||
-- toggling to $70 down each nine-tile run, both runs restarting on $71
|
||||
local function dividerCodes()
|
||||
local codes = { [0] = 0x71 }
|
||||
for _, top in ipairs({ 1, 9 }) do
|
||||
local code = 0x71
|
||||
for i = 0, 8 do
|
||||
codes[top + i] = code
|
||||
code = code == 0x71 and 0x70 or 0x71
|
||||
end
|
||||
end
|
||||
return codes
|
||||
end
|
||||
local DIVIDER = dividerCodes()
|
||||
|
||||
-- SGB: PalPacket_Pokedex, whole screen
|
||||
function PokedexMenu:sgbPalettes(game)
|
||||
@@ -12,108 +41,270 @@ end
|
||||
|
||||
function PokedexMenu.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, PokedexMenu)
|
||||
self.game = game
|
||||
self.onCancel = opts.onCancel -- B returns to the start menu when opened from it
|
||||
self.index = 1
|
||||
self.scroll = 0
|
||||
self.rowsAt = require("src.core.GameVersion").isYellow()
|
||||
and YELLOW_ROWS or RED_ROWS
|
||||
local dex = game.save.pokedex or { seen = {}, owned = {} }
|
||||
local byDex = {}
|
||||
for species, def in pairs(game.data.pokemon) do
|
||||
if def.dex then byDex[def.dex] = def end
|
||||
end
|
||||
local items = {}
|
||||
local seen, owned = 0, 0
|
||||
-- dex bound and number width come from constants; the fallbacks keep a
|
||||
-- cache imported before those keys existed on the Kanto numbering
|
||||
local constants = game.data.constants or {}
|
||||
local numFmt = ("%%0%dd"):format(constants.dexDigits or 3)
|
||||
for n = 1, constants.dexSize or 151 do
|
||||
local dexSize = constants.dexSize or 151
|
||||
-- engine/menus/pokedex.asm:200-216: the list stops at wDexMaxSeenMon, the
|
||||
-- highest number the player has seen, not at the end of the roster
|
||||
local maxSeen = 0
|
||||
local seen, owned = 0, 0
|
||||
for n = 1, dexSize do
|
||||
local def = byDex[n]
|
||||
if def then
|
||||
local label
|
||||
if dex.owned[def.id] then
|
||||
label = (numFmt .. " %s"):format(n, def.name)
|
||||
owned = owned + 1
|
||||
seen = seen + 1
|
||||
maxSeen = n
|
||||
elseif dex.seen[def.id] then
|
||||
label = (numFmt .. " %s"):format(n, def.name)
|
||||
seen = seen + 1
|
||||
else
|
||||
label = (numFmt .. " -----"):format(n)
|
||||
maxSeen = n
|
||||
end
|
||||
table.insert(items, {
|
||||
label = label,
|
||||
end
|
||||
end
|
||||
local items = {}
|
||||
for n = 1, math.min(dexSize, maxSeen) do
|
||||
local def = byDex[n]
|
||||
if def then
|
||||
local known = dex.owned[def.id] or dex.seen[def.id]
|
||||
-- engine/menus/pokedex.asm:265 (.dashedLine) for anything unseen
|
||||
local name = known and def.name or "----------"
|
||||
items[#items + 1] = {
|
||||
num = numFmt:format(n),
|
||||
name = name,
|
||||
label = (numFmt .. " %s"):format(n, name),
|
||||
-- owned entries carry the pokéball marker like the original
|
||||
-- list; seen-only entries are just the name
|
||||
ball = dex.owned[def.id] or nil,
|
||||
value = (dex.owned[def.id] or dex.seen[def.id]) and def.id or nil,
|
||||
})
|
||||
value = known and def.id or nil,
|
||||
}
|
||||
end
|
||||
end
|
||||
local list = ListMenu.new(game, "POKéDEX", items, {
|
||||
-- SEEN / OWN in the original's fixed three-digit field: engine/menus/
|
||||
-- pokedex.asm HandlePokedexListMenu prints both counts with
|
||||
-- `lb bc, 1, 3` (hlcoord 16,3 and 16,6), labelled by PokedexSeenText
|
||||
-- and PokedexOwnText ("OWN", not "OWNED"). The width is load bearing
|
||||
-- here: a bare ListMenu footer goes through the 18-column text wrap,
|
||||
-- so the old 19-glyph "SEEN 100 OWNED 100" split in two and its first
|
||||
-- half landed on the list's last row at y=120 (#639).
|
||||
footer = Strings("SEEN %3d OWN %3d", seen, owned),
|
||||
pageJump = true, -- Left/Right page jumps like the original
|
||||
onCancel = opts.onCancel, -- B returns to the start menu when opened from it
|
||||
onChoose = function(item, dexList)
|
||||
if not item.value then return end
|
||||
-- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm
|
||||
-- PokedexMenuItemsText); CRY keeps the side menu open like the
|
||||
-- original. B is what returns to the list: HandlePokedexSideMenu
|
||||
-- hands back b=2 for B and b=1 for QUIT, and ShowPokedexMenu sends
|
||||
-- b=1 to .exitPokedex, so QUIT closes the whole Pokédex (#571)
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local entries = {
|
||||
{ label = Strings("DATA"), onSelect = function()
|
||||
Screens.push(game, "DexEntryMenu", item.value)
|
||||
end },
|
||||
{ label = Strings("CRY"), keepOpen = true, onSelect = function()
|
||||
require("src.core.Sound").playCry(game.data, item.value)
|
||||
end },
|
||||
{ label = Strings("AREA"), onSelect = function()
|
||||
Screens.push(game, "TownMap", { nestSpecies = item.value })
|
||||
end },
|
||||
}
|
||||
-- Yellow's PRNT item (engine/menus/pokedex.asm PokedexMenuItemsText
|
||||
-- _YELLOW branch -> PrintPokedexEntry): the Game Boy Printer job is
|
||||
-- stood in for by a PNG of the entry page saved under prints/.
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
entries[#entries + 1] = { label = Strings("PRNT"), onSelect = function()
|
||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||
local Printer = require("src.core.Printer")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local def = game.data.pokemon[item.value]
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
game.data, item.value, "front", { kind = "dex" })
|
||||
local ok, sprite = false, nil
|
||||
if path then ok, sprite = pcall(love.graphics.newImage, path) end
|
||||
local saved, err = Printer.save("dex_" .. item.value, 160, 144,
|
||||
function()
|
||||
DexEntryMenu.render(game, def, ok and sprite or nil, false)
|
||||
end)
|
||||
game.stack:push(TextBox.new(game, saved
|
||||
and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.",
|
||||
def.name, saved)
|
||||
or Strings("Printer error!\n%s", tostring(err))))
|
||||
end }
|
||||
end
|
||||
entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function()
|
||||
-- .exitPokedex: drop the dex list too and hand back to whoever
|
||||
-- opened it (the start menu, whose saved cursor is still on
|
||||
-- POKéDEX -- wBattleAndStartSavedMenuItem)
|
||||
dexList:close()
|
||||
if opts.onCancel then opts.onCancel() end
|
||||
end }
|
||||
game.stack:push(Menu.new(game, entries,
|
||||
{ tx = 12, ty = 8, tw = 8,
|
||||
th = #entries * 2 + 2 }))
|
||||
end,
|
||||
})
|
||||
list.sgbPalettes = PokedexMenu.sgbPalettes
|
||||
return list
|
||||
self.items = items
|
||||
-- SEEN / OWN, `lb bc, 1, 3` at hlcoord 16,3 and 16,6: engine/menus/
|
||||
-- pokedex.asm HandlePokedexListMenu (#639)
|
||||
self.seenCount, self.ownedCount = seen, owned
|
||||
self.footer = Strings("SEEN %3d OWN %3d", seen, owned)
|
||||
return self
|
||||
end
|
||||
|
||||
-- HandleMenuInput_ replays SFX_PRESS_AB for A and B (home/window.asm)
|
||||
local function beep(self)
|
||||
if not (self.game and self.game.data) then return end
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end
|
||||
|
||||
function PokedexMenu:rows()
|
||||
return math.min(ROWS, #self.items)
|
||||
end
|
||||
|
||||
-- engine/menus/pokedex.asm:290-313
|
||||
function PokedexMenu:syncScroll()
|
||||
local rows = self:rows()
|
||||
if rows == 0 then
|
||||
self.index, self.scroll = 1, 0
|
||||
return
|
||||
end
|
||||
self.index = math.max(1, math.min(#self.items, self.index))
|
||||
if self.index - self.scroll > rows then self.scroll = self.index - rows end
|
||||
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
||||
end
|
||||
|
||||
-- engine/menus/pokedex.asm:314-342: Left and Right move wListScrollOffset
|
||||
-- by seven and never touch wCurrentMenuItem, so the cursor keeps its row
|
||||
function PokedexMenu:pageScroll(dir)
|
||||
local n = #self.items
|
||||
if n < ROWS then return end
|
||||
local row = self.index - self.scroll
|
||||
local scroll = self.scroll + dir * ROWS
|
||||
scroll = math.max(0, math.min(n - ROWS, scroll))
|
||||
self.scroll = scroll
|
||||
self.index = math.min(n, scroll + row)
|
||||
end
|
||||
|
||||
function PokedexMenu:close()
|
||||
local top = self.game.stack:top()
|
||||
if top == self then self.game.stack:pop() end
|
||||
end
|
||||
|
||||
function PokedexMenu:update(dt)
|
||||
-- back on top of the stack, so the side menu's hollow '▷' is gone
|
||||
-- (engine/menus/pokedex.asm:61, PlaceUnfilledArrowMenuCursor)
|
||||
self.hollowIndex = nil
|
||||
local input = self.game.input
|
||||
if #self.items == 0 then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
beep(self)
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
end
|
||||
return
|
||||
end
|
||||
if input:wasPressed("up") then
|
||||
self.index = self.index - 1
|
||||
elseif input:wasPressed("down") then
|
||||
self.index = self.index + 1
|
||||
elseif input:wasPressed("left") then
|
||||
self:pageScroll(-1)
|
||||
elseif input:wasPressed("right") then
|
||||
self:pageScroll(1)
|
||||
elseif input:wasPressed("b") then
|
||||
beep(self)
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
return
|
||||
elseif input:wasPressed("a") then
|
||||
beep(self)
|
||||
self.onChoose(self.items[self.index], self)
|
||||
return
|
||||
end
|
||||
self:syncScroll()
|
||||
end
|
||||
|
||||
-- engine/menus/pokedex.asm:371-375, :81-85
|
||||
function PokedexMenu:sideItems()
|
||||
local labels = { Strings("DATA"), Strings("CRY"), Strings("AREA") }
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
labels[#labels + 1] = Strings("PRNT")
|
||||
end
|
||||
labels[#labels + 1] = Strings("QUIT")
|
||||
return labels
|
||||
end
|
||||
|
||||
local function sideRowY(menu, row)
|
||||
return (menu.ty + menu.th - 2 - (#menu.items - row) * 2) * 8
|
||||
end
|
||||
|
||||
local function drawSideMenu(self)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for row = 1, #self.items do
|
||||
Font.draw(self.items[row].label, (self.tx + 2) * 8, sideRowY(self, row))
|
||||
end
|
||||
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, sideRowY(self, self.index))
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- engine/menus/pokedex.asm HandlePokedexSideMenu, ShowPokedexMenu
|
||||
-- .exitPokedex (#571)
|
||||
local function chooseEntry(item, dexList)
|
||||
local game = dexList.game
|
||||
-- engine/menus/pokedex.asm:77-80: an unseen row hands back b=2 at once
|
||||
if not item.value then return end
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local entries = {
|
||||
{ label = Strings("DATA"), onSelect = function()
|
||||
Screens.push(game, "DexEntryMenu", item.value)
|
||||
end },
|
||||
{ label = Strings("CRY"), keepOpen = true, onSelect = function()
|
||||
require("src.core.Sound").playCry(game.data, item.value)
|
||||
end },
|
||||
{ label = Strings("AREA"), onSelect = function()
|
||||
Screens.push(game, "TownMap", { nestSpecies = item.value })
|
||||
end },
|
||||
}
|
||||
-- pokeyellow engine/menus/pokedex.asm PokedexMenuItemsText,
|
||||
-- PrintPokedexEntry
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
entries[#entries + 1] = { label = Strings("PRNT"), onSelect = function()
|
||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||
local Printer = require("src.core.Printer")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local def = game.data.pokemon[item.value]
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
game.data, item.value, "front", { kind = "dex" })
|
||||
local ok, sprite = false, nil
|
||||
if path then ok, sprite = pcall(love.graphics.newImage, path) end
|
||||
local saved, err = Printer.save("dex_" .. item.value, 160, 144,
|
||||
function()
|
||||
DexEntryMenu.render(game, def, ok and sprite or nil, false)
|
||||
end)
|
||||
game.stack:push(TextBox.new(game, saved
|
||||
and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.",
|
||||
def.name, saved)
|
||||
or Strings("Printer error!\n%s", tostring(err))))
|
||||
end }
|
||||
end
|
||||
entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function()
|
||||
-- engine/menus/pokedex.asm .exitPokedex
|
||||
dexList:close()
|
||||
if dexList.onCancel then dexList.onCancel() end
|
||||
end }
|
||||
local ty = dexList.rowsAt.items - 2
|
||||
local side = Menu.new(game, entries, { tx = 14, ty = ty, tw = 6 })
|
||||
-- the block is already on screen, so the side menu is the cursor alone:
|
||||
-- text at column 16, cursor at 15 (engine/menus/pokedex.asm:82-86)
|
||||
side.tx, side.tw, side.ty, side.th = 14, 6, ty, #entries * 2 + 2
|
||||
side.draw = drawSideMenu
|
||||
dexList.hollowIndex = dexList.index
|
||||
game.stack:push(side)
|
||||
end
|
||||
PokedexMenu.onChoose = chooseEntry
|
||||
|
||||
-- engine/menus/pokedex.asm:256 writes tile $72 in the column left of the
|
||||
-- name; the port paints the same marker rather than carry a one-tile sheet
|
||||
local function drawBall(x, y)
|
||||
love.graphics.circle("fill", x + 4, y + 4, 3.5)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", x + 0.5, y + 3.5, 7, 1)
|
||||
love.graphics.circle("fill", x + 4, y + 4, 1.2)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
|
||||
function PokedexMenu:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
local at = self.rowsAt
|
||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||
-- engine/menus/pokedex.asm:159-176: the divider column and the rule that
|
||||
-- fences SEEN / OWN off from the side-menu block
|
||||
for ty = 0, 17 do
|
||||
DexEntryMenu.tile(self.game, DIVIDER[ty], 14, ty)
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("─"):rep(5), 120, at.rule * 8)
|
||||
Font.draw(Strings("CONTENTS"), 8, 8)
|
||||
Font.draw(Strings("SEEN"), 128, at.seen * 8)
|
||||
Font.draw(Strings("OWN"), 128, at.own * 8)
|
||||
-- PrintNumber's fixed three-digit field ends on column 19
|
||||
local function count(n, ty)
|
||||
local text = tostring(n)
|
||||
Font.draw(text, 152 - Font.width(text), ty * 8)
|
||||
end
|
||||
count(self.seenCount, at.seen + 1)
|
||||
count(self.ownedCount, at.own + 1)
|
||||
local labels = self:sideItems()
|
||||
for i, label in ipairs(labels) do
|
||||
Font.draw(label, 128, (at.items + (i - 1) * 2) * 8)
|
||||
end
|
||||
for row = 1, self:rows() do
|
||||
local i = self.scroll + row
|
||||
local item = self.items[i]
|
||||
if not item then break end
|
||||
local y = FIRST_ROW_Y + (row - 1) * 16
|
||||
-- the number sits one row above its name (engine/menus/pokedex.asm:242-246)
|
||||
Font.draw(item.num, NUM_X, y - 8)
|
||||
if item.ball then drawBall(BALL_X, y) end
|
||||
Font.draw(item.name, NAME_X, y)
|
||||
if i == self.index then
|
||||
Font.drawCode(self.hollowIndex == i
|
||||
and Theme.cursorHollow or Theme.cursor, CURSOR_X, y)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return PokedexMenu
|
||||
|
||||
+14
-23
@@ -192,9 +192,9 @@ function SlotMachine.new(game, lucky)
|
||||
local self = setmetatable({}, SlotMachine)
|
||||
self.game = game
|
||||
self.wheels = game.data.field.slotWheels
|
||||
-- intro | bet | spinup | spin | reroll | flash | message | payout | onemore
|
||||
-- PromptUserToPlaySlots asks "Want to play?" before the session starts.
|
||||
self.stage = "intro"
|
||||
-- bet | spinup | spin | reroll | flash | message | payout | onemore
|
||||
-- PromptUserToPlaySlots: engine/slots/slot_machine.asm:9-23
|
||||
self.stage = "bet"
|
||||
self.yesno = 1 -- YES/NO cursor (1 = YES); wCurrentMenuItem
|
||||
-- CoinMultiplierSlotMachineText lists ×3/×2/×1 with the cursor defaulting to
|
||||
-- the top (wCurrentMenuItem 0), i.e. bet = 3 - menuItem.
|
||||
@@ -417,7 +417,7 @@ function SlotMachine:startPayout()
|
||||
self.flash = false
|
||||
end
|
||||
|
||||
-- YES/NO prompt shared by the intro ("Want to play?") and "One more go?".
|
||||
-- the "One more go?" YES/NO prompt.
|
||||
function SlotMachine:updateYesNo(onYes)
|
||||
local input = self.game.input
|
||||
if input:wasPressed("up") or input:wasPressed("down") then
|
||||
@@ -435,12 +435,6 @@ function SlotMachine:update(dt)
|
||||
local input = self.game.input
|
||||
local save = self.game.save
|
||||
|
||||
if self.stage == "intro" then
|
||||
-- PromptUserToPlaySlots: "A slot machine! Want to play?"
|
||||
self:updateYesNo(function() self:enterBet() end)
|
||||
return
|
||||
end
|
||||
|
||||
if self.stage == "message" then
|
||||
if self.exitTimer then
|
||||
-- OutOfCoinsSlotMachineText: DelayFrames 60, then leave
|
||||
@@ -638,9 +632,7 @@ end
|
||||
-- menu on the right (like MainSlotMachineLoop's TextBoxBorder + menus).
|
||||
function SlotMachine:drawBottom()
|
||||
local lines
|
||||
if self.stage == "intro" then
|
||||
lines = { "A slot machine!", "Want to play?" }
|
||||
elseif self.stage == "bet" then
|
||||
if self.stage == "bet" then
|
||||
lines = { "Bet how many", "coins?" }
|
||||
elseif self.stage == "onemore" then
|
||||
lines = { "One more", "go?" }
|
||||
@@ -661,22 +653,21 @@ function SlotMachine:drawBottom()
|
||||
Font.draw(lines[1] or "", 8, 14 * 8)
|
||||
Font.draw(lines[2] or "", 8, 16 * 8)
|
||||
if self.stage == "bet" then
|
||||
Font.drawBox(14, 11, 6, 5)
|
||||
-- hlcoord 14,11 with b=5 c=4 -- engine/slots/slot_machine.asm:83-86
|
||||
Font.drawBox(14, 11, 6, 7)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("×3", 16 * 8, 12 * 8)
|
||||
Font.draw("×2", 16 * 8, 13 * 8)
|
||||
Font.draw("×1", 16 * 8, 14 * 8)
|
||||
Font.drawCode(0xED, 15 * 8, (12 + self.betIndex) * 8)
|
||||
elseif self.stage == "intro" or self.stage == "onemore" then
|
||||
-- "One more go?" sits at the right of the box (hlcoord 14,12); the longer
|
||||
-- "A slot machine!" prompt would clip against it, so the intro's YES/NO
|
||||
-- floats above the reels instead.
|
||||
local by = self.stage == "intro" and 6 or 11
|
||||
Font.drawBox(13, by, 6, 5)
|
||||
elseif self.stage == "onemore" then
|
||||
-- hlcoord 14,12 with the YES_NO_MENU 4x3 body
|
||||
-- engine/slots/slot_machine.asm:136-138, data/yes_no_menu_strings.asm:10
|
||||
Font.drawBox(14, 12, 6, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("YES"), 15 * 8, (by + 1) * 8)
|
||||
Font.draw(Strings("NO"), 15 * 8, (by + 2) * 8)
|
||||
Font.drawCode(0xED, 14 * 8, (by + 1 + (self.yesno == 1 and 0 or 1)) * 8)
|
||||
Font.draw(Strings("YES"), 16 * 8, 13 * 8)
|
||||
Font.draw(Strings("NO"), 16 * 8, 14 * 8)
|
||||
Font.drawCode(0xED, 15 * 8, (13 + (self.yesno == 1 and 0 or 1)) * 8)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -200,12 +200,12 @@ function StartMenu.new(game)
|
||||
anchor = "topright" })
|
||||
-- the cursor position survives closing the menu
|
||||
-- (wBattleAndStartSavedMenuItem, home/start_menu.asm)
|
||||
menu.index = math.min(game.save.startMenuIndex or 1, #items)
|
||||
menu.index = math.min(game.startMenuIndex or 1, #items)
|
||||
menu:clampScroll()
|
||||
local baseUpdate = menu.update
|
||||
menu.update = function(self, dt)
|
||||
baseUpdate(self, dt)
|
||||
game.save.startMenuIndex = self.index
|
||||
game.startMenuIndex = self.index
|
||||
end
|
||||
|
||||
-- inside the Safari Zone the start menu also shows remaining steps and
|
||||
|
||||
+10
-3
@@ -12,6 +12,7 @@ local Font = require("src.render.Font")
|
||||
-- TypeChart.displayName maps it back to "PSYCHIC", like HallOfFame and the
|
||||
-- battle move-type box already do (#214).
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local LevelDisplay = require("src.ui.LevelDisplay")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local Status = require("src.battle.Status")
|
||||
@@ -137,7 +138,9 @@ function SummaryMenu:draw()
|
||||
if self.page == 1 then
|
||||
-- level is page 1 only: StatusScreen2 opens with ClearScreenArea over
|
||||
-- (9,2) 5x10 (status_screen.asm:303-305). #280
|
||||
printLevel(14, 2, mon.level)
|
||||
if LevelDisplay.visible(mon, "summary", self.game) then -- RFC 0019
|
||||
printLevel(14, 2, mon.level)
|
||||
end
|
||||
drawLineBox(19, 1, 6, 10)
|
||||
-- engine/pokemon/status_screen.asm:120-125
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
@@ -196,8 +199,12 @@ function SummaryMenu:draw()
|
||||
local nextExp = mon.level < 100
|
||||
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
|
||||
Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48)
|
||||
HudTiles.statusTile(0x70, 112, 48) -- '<to>' at (14,6), was missing (#280)
|
||||
printLevel(16, 6, math.min(100, mon.level + 1))
|
||||
if LevelDisplay.visible(mon, "summary", self.game) then -- RFC 0019
|
||||
-- the '<to>' arrow is half a sentence without the level it points at,
|
||||
-- so the pair is hidden together
|
||||
HudTiles.statusTile(0x70, 112, 48) -- '<to>' at (14,6), was missing (#280)
|
||||
printLevel(16, 6, math.min(100, mon.level + 1))
|
||||
end
|
||||
Font.drawBox(0, 8, 20, 10)
|
||||
for i = 1, 4 do
|
||||
local mv = mon.moves[i]
|
||||
|
||||
+43
-27
@@ -130,6 +130,25 @@ local function markerXY(loc)
|
||||
return loc.x * 8 + 16, loc.y * 8 + 8
|
||||
end
|
||||
|
||||
-- the marker wears its own sheet's OBJ palette and shade-0 keying
|
||||
-- engine/items/town_map.asm:342
|
||||
local function markerSheet(def, seed)
|
||||
local colors, group
|
||||
if PaletteFX.usesGbcPack() then
|
||||
colors, group = PaletteFX.spriteObp(def, seed)
|
||||
end
|
||||
if not colors then
|
||||
if PaletteFX.usesSpriteObp() then
|
||||
colors, group = PaletteFX.ogObj()
|
||||
else
|
||||
colors, group = PaletteFX.dmgObj()
|
||||
end
|
||||
end
|
||||
local ok, img = pcall(SpriteRenderer.obpImage, def and def.image, colors, group)
|
||||
if not (ok and img) then return nil, nil end
|
||||
return img, love.graphics.newQuad(0, 0, 16, 16, img:getDimensions())
|
||||
end
|
||||
|
||||
-- the row-0 name banner; fly mode prefixes "To " like LoadTownMap_Fly
|
||||
-- (engine/menus/town_map.asm prints the destination as "To <NAME>")
|
||||
function TownMap:bannerText(loc)
|
||||
@@ -224,32 +243,18 @@ function TownMap.new(game, opts)
|
||||
local mapId = game.overworld and game.overworld.map and game.overworld.map.id
|
||||
self.playerLoc = mapId and self.byMap[mapId] or nil
|
||||
-- engine/items/town_map.asm:347
|
||||
do
|
||||
local playerSprites = (game.data.field and game.data.field.playerSprites)
|
||||
or {}
|
||||
local sprites = game.data.sprites or {}
|
||||
local red = sprites[playerSprites.walk or "SPRITE_RED"]
|
||||
or sprites.SPRITE_RED
|
||||
-- the marker is the overworld walking sheet, so it wears that sheet's OBJ
|
||||
-- palette and shade-0 keying -- engine/items/town_map.asm:342
|
||||
local colors, group
|
||||
if PaletteFX.usesGbcPack() then
|
||||
colors, group = PaletteFX.spriteObp(red, "player")
|
||||
end
|
||||
if not colors then
|
||||
if PaletteFX.usesSpriteObp() then
|
||||
colors, group = PaletteFX.ogObj()
|
||||
else
|
||||
colors, group = PaletteFX.dmgObj()
|
||||
end
|
||||
end
|
||||
local ok, img = pcall(SpriteRenderer.obpImage,
|
||||
red and red.image, colors, group)
|
||||
if ok and img then
|
||||
self.playerSheet = img
|
||||
self.playerQuad = love.graphics.newQuad(0, 0, 16, 16,
|
||||
img:getDimensions())
|
||||
end
|
||||
local playerSprites = (game.data.field and game.data.field.playerSprites)
|
||||
or {}
|
||||
local sprites = game.data.sprites or {}
|
||||
self.playerSheet, self.playerQuad =
|
||||
markerSheet(sprites[playerSprites.walk or "SPRITE_RED"] or sprites.SPRITE_RED,
|
||||
"player")
|
||||
-- LoadTownMap_Fly overwrites the cursor tiles with BirdSprite and marks
|
||||
-- the destination with it -- engine/items/town_map.asm:146-149, 177-179
|
||||
if self.fly then
|
||||
self.birdSheet, self.birdQuad =
|
||||
markerSheet(sprites[playerSprites.fly or "SPRITE_BIRD"]
|
||||
or sprites.SPRITE_BIRD, "bird")
|
||||
end
|
||||
self.sel = 1
|
||||
-- LoadTownMap_Fly always opens with hl on wFlyLocationsList[0], the FIRST
|
||||
@@ -402,8 +407,19 @@ function TownMap:draw()
|
||||
end
|
||||
end
|
||||
-- WriteTownMapSpriteOAM carry quirk: -4 X, -3 Y for cursor and player alike -- engine/items/town_map.asm:454
|
||||
-- LoadTownMap_Fly's .inputLoop has no blinking animation
|
||||
-- engine/items/town_map.asm:190-198
|
||||
local showCursor = true
|
||||
if GameVersion.generation() == 1 then
|
||||
if self.fly and self.birdSheet then
|
||||
if selected then
|
||||
local x, y = markerXY(selected)
|
||||
love.graphics.draw(self.birdSheet, self.birdQuad, x - 4, y - 3)
|
||||
if PaletteFX.usesSpriteObp() or PaletteFX.usesGbcPack() then
|
||||
PaletteFX.markUiSpriteRedraw(self.birdSheet, self.birdQuad, x - 4, y - 3)
|
||||
end
|
||||
end
|
||||
showCursor = false
|
||||
elseif GameVersion.generation() == 1 then
|
||||
showCursor = self.blink < 25
|
||||
else
|
||||
showCursor = self.blink % 16 < 10
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
local bit = require("bit")
|
||||
local Assets = require("src.render.Assets")
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
|
||||
@@ -164,12 +165,8 @@ local function needsCanvas(runner)
|
||||
end
|
||||
|
||||
-- The battle background is BG colour 0 everywhere the two pic boxes and the
|
||||
-- text box are not, which Chrome.clear draws as plain white; anything the
|
||||
-- scanline blit exposes has to be that colour and not a hole.
|
||||
function BattleAnimView:fillBackground()
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H)
|
||||
Chrome.paletteFill(0, 0, SCREEN_W, SCREEN_H)
|
||||
end
|
||||
|
||||
-- One reusable quad, re-aimed per scanline. A row shifted by `dx` is drawn
|
||||
|
||||
+91
-35
@@ -605,23 +605,66 @@ function BattleState:animData(mon)
|
||||
local def = self.pokemon and mon and self.pokemon[mon.species]
|
||||
if not def then return nil end
|
||||
if mon.species == Unown.SPECIES and def.letters then
|
||||
local entry = def.letters[Unown.monLetter(mon)]
|
||||
local entry = def.letters[Unown.name(Unown.monLetter(mon))]
|
||||
if entry and entry.anim then return entry.anim end
|
||||
end
|
||||
return def.anim
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/gfx/load_pics.asm:105-131
|
||||
function BattleState:frontPicReplaced(mon)
|
||||
local def = self.pokemon and mon and self.pokemon[mon.species]
|
||||
local vanilla = def and def.spriteFront
|
||||
if mon and mon.species == Unown.SPECIES then
|
||||
vanilla = Unown.formSprite(self.pokemon, Unown.monLetter(mon), false)
|
||||
or vanilla
|
||||
end
|
||||
if type(vanilla) ~= "string" then return false end
|
||||
local _, _, path = self:pic(mon, false)
|
||||
if type(path) == "string" and path ~= vanilla then return true end
|
||||
return Assets.resolve(vanilla) ~= vanilla
|
||||
end
|
||||
|
||||
-- one cart asset, two files on the pokemon.sprite seam (#1827)
|
||||
-- ../pokecrystal/engine/gfx/load_pics.asm:132-158
|
||||
function BattleState:animSheetPath(mon, data)
|
||||
local path = data and data.sheet
|
||||
if type(path) ~= "string" then return nil, false end
|
||||
if not Runtime.wantsHook("pokemon.sprite") then
|
||||
return path, Assets.resolve(path) ~= path
|
||||
end
|
||||
local letter
|
||||
if mon and mon.species == Unown.SPECIES then letter = Unown.monLetter(mon) end
|
||||
local hooked = Runtime.call("pokemon.sprite",
|
||||
function(value) return value end, path, {
|
||||
species = mon and mon.species,
|
||||
side = "front",
|
||||
kind = "battle_anim",
|
||||
mon = mon,
|
||||
data = (self.game and self.game.data) or nil,
|
||||
letter = letter,
|
||||
shiny = mon and mon.shiny and true or false,
|
||||
})
|
||||
if type(hooked) == "string" and hooked ~= "" and hooked ~= path then
|
||||
return hooked, true
|
||||
end
|
||||
return path, Assets.resolve(path) ~= path
|
||||
end
|
||||
|
||||
-- ANIM_MON_NORMAL, the scene BattleStartMessage runs on the enemy's frontpic.
|
||||
-- ../pokecrystal/engine/battle/core.asm:9112-9113
|
||||
function BattleState:startFrontAnim(mon)
|
||||
self.frontAnim = nil
|
||||
local data = self:animData(mon)
|
||||
if not data then return end
|
||||
local cached = self.picCache[data.sheet]
|
||||
local sheet, sheetReplaced = self:animSheetPath(mon, data)
|
||||
if not sheet then return end
|
||||
if not sheetReplaced and self:frontPicReplaced(mon) then return end
|
||||
local cached = self.picCache[sheet]
|
||||
if cached == nil then
|
||||
local ok, image = pcall(Assets.image, data.sheet)
|
||||
local ok, image = pcall(Assets.image, sheet)
|
||||
cached = ok and image or false
|
||||
self.picCache[data.sheet] = cached
|
||||
self.picCache[sheet] = cached
|
||||
end
|
||||
if not cached then return end
|
||||
local runner = MonAnim.new(data, "battle")
|
||||
@@ -3520,7 +3563,7 @@ function BattleState:drawHud()
|
||||
-- And nothing at all before UpdateEnemyHUD has ever run: the intro bands
|
||||
-- slide in over a blanked tilemap (core.asm:8554/8564).
|
||||
if showStatus and self.showEnemyHud and not self:hudCleared("enemy") then
|
||||
Chrome.print(self:name(enemy), 1, 0)
|
||||
Chrome.printThrough(self:name(enemy), 1, 0, Chrome.DEFAULT_BOX_PALETTE)
|
||||
-- PrintLevel writes <LV> at the coordinate it is given and then LEFT-aligns
|
||||
-- the digits after it, so the glyph is pinned to column 6 whether the level
|
||||
-- is 5 or 100; only a three-digit level moves, and it does so by eating the
|
||||
@@ -3528,10 +3571,12 @@ function BattleState:drawHud()
|
||||
-- PlaceNonFaintStatus (engine/pokemon/mon_stats.asm): a statused mon's tag
|
||||
-- prints where the level goes, and DrawEnemyHUD's `.skip_level` arm drops
|
||||
-- the level entirely while one is up.
|
||||
Chrome.print(self:statusTag(enemy, "enemy")
|
||||
or ("<LV>" .. tostring(enemy.level or 1)), 6, 1)
|
||||
Chrome.printThrough(self:statusTag(enemy, "enemy")
|
||||
or ("<LV>" .. tostring(enemy.level or 1)), 6, 1, Chrome.DEFAULT_BOX_PALETTE)
|
||||
local enemyGender = self:genderSymbol(enemy)
|
||||
if enemyGender then Chrome.print(enemyGender, 9, 1) end
|
||||
if enemyGender then
|
||||
Chrome.printThrough(enemyGender, 9, 1, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
-- `ld a, [wBattleMode] / dec a / ret nz`, then CheckCaughtMon puts $5d at
|
||||
-- (1,1) (engine/battle/trainer_huds.asm:140-152).
|
||||
if self.battle and self.battle.wild and self.caughtMark then
|
||||
@@ -3580,17 +3625,20 @@ function BattleState:drawHud()
|
||||
Font.useBattleExtra(wasBattle)
|
||||
return
|
||||
end
|
||||
Chrome.print(self:name(player), 10, 7)
|
||||
Chrome.printThrough(self:name(player), 10, 7, Chrome.DEFAULT_BOX_PALETTE)
|
||||
-- PrintPlayerHUD places the same status tag at (14,8) and skips the level
|
||||
-- while it is up.
|
||||
Chrome.print(self:statusTag(player, "player")
|
||||
or ("<LV>" .. tostring(self.shownLevel or player.level or 1)), 14, 8)
|
||||
Chrome.printThrough(self:statusTag(player, "player")
|
||||
or ("<LV>" .. tostring(self.shownLevel or player.level or 1)), 14, 8,
|
||||
Chrome.DEFAULT_BOX_PALETTE)
|
||||
local playerGender = self:genderSymbol(player)
|
||||
if playerGender then Chrome.print(playerGender, 17, 8) end
|
||||
if playerGender then
|
||||
Chrome.printThrough(playerGender, 17, 8, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
self:drawHpBar(player, "player", 10, 9)
|
||||
local maxHp = player.maxHp or (player.stats and player.stats.hp) or 0
|
||||
Chrome.printRight(("%d/%d"):format(self:hudHp(player, "player"), maxHp),
|
||||
18, 10)
|
||||
Chrome.printRightThrough(("%d/%d"):format(self:hudHp(player, "player"), maxHp),
|
||||
18, 10, Chrome.DEFAULT_BOX_PALETTE)
|
||||
-- Stub on the RIGHT (tile $73), border from (18,10) laid leftward, exp bar
|
||||
-- at (10,11).
|
||||
-- The chased fill, not the mon's: AnimateExpBar crawls it, and reading the
|
||||
@@ -3659,8 +3707,8 @@ end
|
||||
function BattleState:printMessage()
|
||||
local lines = Chrome.wrap(self.message or "", TEXT_WIDTH)
|
||||
for i = 1, math.min(#lines, TEXT_ROWS) do
|
||||
Chrome.print(lines[i], TEXT_INNER_X,
|
||||
TEXT_INNER_Y + (i - 1) * TEXT_ROW_STEP)
|
||||
Chrome.printThrough(lines[i], TEXT_INNER_X,
|
||||
TEXT_INNER_Y + (i - 1) * TEXT_ROW_STEP, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3670,15 +3718,17 @@ function BattleState:drawMoveInfoBox(move)
|
||||
if not move then return end
|
||||
local fighter = self.battle and self.battle.player
|
||||
if fighter and self.battle:moveDisabled(fighter, move.id) then
|
||||
Chrome.print("Disabled!", 1, 10)
|
||||
Chrome.printThrough("Disabled!", 1, 10, Chrome.DEFAULT_BOX_PALETTE)
|
||||
return
|
||||
end
|
||||
local def = self.game and self.game.data and self.game.data.moves
|
||||
and self.game.data.moves[move.id]
|
||||
Chrome.print("TYPE/", 1, 9)
|
||||
Chrome.printThrough("TYPE/", 1, 9, Chrome.DEFAULT_BOX_PALETTE)
|
||||
local moveType = def and def.type
|
||||
Chrome.print(moveType and (TYPE_NAMES[moveType] or moveType) or "", 2, 10)
|
||||
Chrome.print(("%2d/%2d"):format(move.pp or 0, move.maxPp or 0), 5, 11)
|
||||
Chrome.printThrough(moveType and (TYPE_NAMES[moveType] or moveType) or "",
|
||||
2, 10, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printThrough(("%2d/%2d"):format(move.pp or 0, move.maxPp or 0),
|
||||
5, 11, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
|
||||
function BattleState:drawPanel()
|
||||
@@ -3687,7 +3737,7 @@ function BattleState:drawPanel()
|
||||
-- required there; everywhere else a missing side is a caller bug.
|
||||
local hasPlayer = self.battle and (self.battle.player or self.tutorial)
|
||||
if not (self.battle and hasPlayer and self.battle.enemy) then
|
||||
Chrome.print("NO BATTLE", 1, 1)
|
||||
Chrome.printThrough("NO BATTLE", 1, 1, Chrome.DEFAULT_BOX_PALETTE)
|
||||
return
|
||||
end
|
||||
self:drawHud()
|
||||
@@ -3719,8 +3769,10 @@ function BattleState:drawPanel()
|
||||
local col = ((i - 1) % 2) * spacing
|
||||
local row = math.floor((i - 1) / 2) * 2
|
||||
local tx, ty = boxX + 2 + col, 14 + row
|
||||
if i == self.menuIndex then Chrome.cursor(tx - 1, ty) end
|
||||
Chrome.print(label, tx, ty)
|
||||
if i == self.menuIndex then
|
||||
Chrome.cursorThrough(tx - 1, ty, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
Chrome.printThrough(label, tx, ty, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
elseif self.phase == "moves"
|
||||
or (self.phase == "choose-forget" and (self.messageTimer or 0) <= 0) then
|
||||
@@ -3740,21 +3792,24 @@ function BattleState:drawPanel()
|
||||
for i, move in ipairs(moves) do
|
||||
local ty = 13 + (i - 1)
|
||||
-- Cursor in the box's own gutter, not clipped against the border.
|
||||
if i == cursorRow then Chrome.cursor(cursorCol, ty) end
|
||||
if i == cursorRow then
|
||||
Chrome.cursorThrough(cursorCol, ty, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
-- The held slot's marker. `.battle_player_moves` writes '▷' into the
|
||||
-- row wSwappingMove names (engine/battle/core.asm:5157-5165) so a move
|
||||
-- picked up for a swap is visible while the cursor moves off it. It
|
||||
-- hlcoord 5, 13 is the cursor's own gutter, so PlaceMenuCursor covers
|
||||
-- the marker on the cursor's row.
|
||||
if not forgetting and self.moveSwapIndex == i and i ~= cursorRow then
|
||||
Chrome.print("\u{25B7}", cursorCol, ty)
|
||||
Chrome.printThrough("\u{25B7}", cursorCol, ty, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
local def = self.game and self.game.data and self.game.data.moves
|
||||
and self.game.data.moves[move.id]
|
||||
Chrome.print((def and def.name) or move.id, nameCol, ty)
|
||||
Chrome.printThrough((def and def.name) or move.id, nameCol, ty,
|
||||
Chrome.DEFAULT_BOX_PALETTE)
|
||||
if not moveMenu then
|
||||
Chrome.printRight(("%d/%d"):format(move.pp or 0, move.maxPp or 0),
|
||||
19, ty)
|
||||
Chrome.printRightThrough(("%d/%d"):format(move.pp or 0, move.maxPp or 0),
|
||||
19, ty, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
end
|
||||
if moveMenu then self:drawMoveInfoBox(moves[cursorRow]) end
|
||||
@@ -3773,13 +3828,14 @@ function BattleState:drawPanel()
|
||||
local left = (self.phase == "ask-shift" or self.phase == "ask-next-mon")
|
||||
and 1 or 14
|
||||
Chrome.box(left, 7, 6, 5)
|
||||
Chrome.print("YES", left + 2, 8)
|
||||
Chrome.print("NO", left + 2, 10)
|
||||
Chrome.printThrough("YES", left + 2, 8, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printThrough("NO", left + 2, 10, Chrome.DEFAULT_BOX_PALETTE)
|
||||
local index = self.phase == "ask-nickname" and self.nicknameIndex
|
||||
or self.phase == "ask-shift" and self.shiftIndex
|
||||
or self.phase == "ask-next-mon" and self.nextMonIndex
|
||||
or self.forgetChoice
|
||||
Chrome.cursor(left + 1, index == 1 and 8 or 10)
|
||||
Chrome.cursorThrough(left + 1, index == 1 and 8 or 10,
|
||||
Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
end
|
||||
if self.phase == "stats-box" and self.statsBoxMon then
|
||||
@@ -3808,8 +3864,9 @@ function BattleState:drawStatsBox(mon)
|
||||
Chrome.textbox(9, 0, 9, 10)
|
||||
for i, row in ipairs(STATS_BOX_ROWS) do
|
||||
local ty = 1 + (i - 1) * 2
|
||||
Chrome.print(Strings(row[1]), 11, ty)
|
||||
Chrome.printRight(("%d"):format(stats[row[2]] or 0), 19, ty + 1)
|
||||
Chrome.printThrough(Strings(row[1]), 11, ty, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printRightThrough(("%d"):format(stats[row[2]] or 0), 19, ty + 1,
|
||||
Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3893,8 +3950,7 @@ end
|
||||
|
||||
function BattleState:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
|
||||
G.push()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user