Compare commits

..

1 Commits

Author SHA1 Message Date
bryanthaboi 9af0d5a723 Update video link and thumbnail in README 2026-08-25 16:02:28 -04:00
236 changed files with 1154 additions and 14786 deletions
-50
View File
@@ -333,56 +333,6 @@ 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
+5 -174
View File
@@ -143,7 +143,7 @@ jobs:
linux-arm64:
name: build Linux arm64 AppImage
needs: [version, love-payload, shaderfx-bridge]
needs: [version, love-payload]
# GitHub's free arm64 runner for public repos. It has to be arm64: the
# AppImage compiles LÖVE natively inside a Debian bullseye arm64
# container, and the qemu-emulated alternative takes hours.
@@ -155,22 +155,13 @@ 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
@@ -182,45 +173,6 @@ 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]
@@ -334,85 +286,8 @@ 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, linux-flatpak, native-tls-win, shaderfx-bridge]
needs: [version, love-payload, xbox-uwp, linux-arm64, native-tls-win]
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
steps:
@@ -434,28 +309,6 @@ 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).
@@ -505,7 +358,6 @@ 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);
@@ -520,8 +372,6 @@ 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:
@@ -579,8 +429,6 @@ 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
@@ -594,7 +442,6 @@ 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
@@ -651,13 +498,6 @@ 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
@@ -669,24 +509,16 @@ 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"
# 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"
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
# arm64 desktop Linux (Raspberry Pi, Armbian, arm64 VMs). Built on
# its own runner because LÖVE publishes no aarch64 binary and the
# AppImage has to be compiled natively; ships as a runnable
# AppImage so `chmod +x && ./it` just works.
# AppImage rather than a zip so `chmod +x && ./it` just works.
arm64_appimage="dist/linux-arm64/gen1recomp-${v}-linux-arm64.AppImage"
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
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"
@@ -824,9 +656,8 @@ jobs:
release_files=(
"dist/release/gen1recomp-${v}-macos.zip"
"dist/release/gen1recomp-${v}-windows.zip"
"dist/release/gen1recomp-${v}-linux-x86_64.AppImage"
"dist/release/gen1recomp-${v}-linux.zip"
"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"
+10 -19
View File
@@ -1,19 +1,19 @@
# Gen1Recomp
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.
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.
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, 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.
> **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.
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
# [SUPPORT / ANNOUNCEMENTS / MODS ALL FOUND ON THE DISCORD](https://bois.icu)
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
<p align="center">
@@ -228,27 +228,18 @@ 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 desktop (AppImage / Flatpak)
## Linux on arm64 (Raspberry Pi)
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
```
Alongside the x86_64 `gen1recomp-*-linux.zip`, every release ships
`gen1recomp-*-linux-arm64.AppImage` for 64-bit ARM desktop Linux — Raspberry
Pi 4/5, Armbian and other SBC distros, and arm64 VMs on Apple Silicon:
```sh
chmod +x gen1recomp-*-linux-arm64.AppImage
./gen1recomp-*-linux-arm64.AppImage
```
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
LÖVE publishes no aarch64 binary of any kind, so this artifact compiles the
engine — and SDL2, OpenAL and the codecs — from source inside a Debian
bullseye arm64 container. It needs only glibc 2.29+, libstdc++, freetype and
zlib on the host; OpenGL, X11, Wayland, KMSDRM, ALSA and PulseAudio are all
+38 -174
View File
@@ -192,20 +192,6 @@ 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
@@ -217,203 +203,81 @@ EOF
# Resolve the game directory from the launcher so this works with both
# PortMaster-managed ports directories.
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
#!/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
#!/bin/bash
# gen1recomp-sbc — Linux ARM SBC / PortMaster launcher
# Uses SHDIR-relative paths so firmware-specific mount points do not matter.
export HOME="${HOME:-/root}"
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
SHDIR="$(cd "$(dirname "$0")" && pwd)"
GAMEDIR="$SHDIR/gen1recomp-sbc"
CONFDIR="$GAMEDIR/conf"
mkdir -p "$CONFDIR"
cd "$GAMEDIR" || exit 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
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 "/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
elif [ -d "/opt/system/Tools/PortMaster/" ]; then
controlfolder="/opt/system/Tools/PortMaster"
elif [ -d "/opt/tools/PortMaster" ]; then
elif [ -d "/opt/tools/PortMaster/" ]; then
controlfolder="/opt/tools/PortMaster"
elif [ -d "$XDG_DATA_HOME/PortMaster" ]; then
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."
controlfolder="/mnt/SDCARD/Roms/PORTS/PortMaster"
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=""
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
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
}
GAMEDIR="$SHDIR/gen1recomp-sbc"
CONFDIR="$GAMEDIR/conf"
mkdir -p "$CONFDIR"
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=""
}
cd "$GAMEDIR" || exit 1
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
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:$GAMEDIR/libs:/usr/trimui/lib:/mnt/SDCARD/System/lib:/usr/lib64:/usr/lib:${LD_LIBRARY_PATH:-}"
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${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}"
# Audio buffer size
export SDL_AUDIO_SAMPLES=1024
$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
# 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
if [ -n "${GPTOKEYB:-}" ]; then
$GPTOKEYB "love.aarch64" &
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"
-14
View File
@@ -156,20 +156,6 @@ 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
+1 -10
View File
@@ -62,16 +62,7 @@ function love.conf(t)
t.window.minheight = 360
end
t.version = love._os == "iOS" and "12.0" or "11.5"
-- 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.window.vsync = 1
t.modules.audio = not companion
t.modules.joystick = not companion
t.modules.physics = false
-765
View File
@@ -1,765 +0,0 @@
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" },
} },
}
+12 -19
View File
@@ -1251,26 +1251,19 @@ local function pokemonTower2FRivalScript(playerX)
and TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT
or TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN
return {
{ "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" },
{ "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
{ "play_music", "Music_MeetRival", { start = "rival" } },
{ "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" },
{ "walk_npc", 1, exitDirs }, -- 9
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
{ "jump", "end" }, -- 11
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 12
}
end
+21 -20
View File
@@ -167,11 +167,13 @@ 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). text/PokemonTower6F.asm:1-5 (#1849)
-- (#867). play_cry arms the next show_text, so the cry rides
-- the calmed box's open with the button prompt kept, and the
-- wait row stands in for the asm's 30-frame gap.
local rows = {
{ "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!" },
@@ -189,8 +191,7 @@ M.POKEMON_TOWER_6F = {
end
ow:afterBattle(result, battle)
end
-- InitWildBattle runs the wipe before the disguise (core.asm:6695-6702)
ow:pushBattle(battle)
game.stack:push(battle)
end))
return true
end,
@@ -442,9 +443,6 @@ 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)
@@ -453,19 +451,22 @@ M.ROCKET_HIDEOUT_B4F = {
end
game.save.defeatedTrainers[npc.id] = true
game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = true
-- 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-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))
end))
end))
end
-58
View File
@@ -1,58 +0,0 @@
# 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.
-19
View File
@@ -34,25 +34,6 @@ 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:
+2 -3
View File
@@ -1,11 +1,10 @@
# Linux arm64 (aarch64) AppImage
Releases ship `gen1recomp-<version>-linux-arm64.AppImage` alongside the
existing x86_64 `gen1recomp-<version>-linux-x86_64.AppImage`. It targets 64-bit ARM
existing x86_64 `gen1recomp-<version>-linux.zip`. It targets 64-bit ARM
desktop Linux: Raspberry Pi 4/5 running Raspberry Pi OS, Armbian and other
SBC distros, arm64 VMs on Apple Silicon, Ampere/Graviton desktops, and the
aarch64 handhelds that run a full distro. See also
[linux-appimage.md](linux-appimage.md) for shared AppImage failure modes.
aarch64 handhelds that run a full distro.
> The Anbernic RG34XXSP has its own PortMaster-style pack
> (`gen1recomp-*-rg34xxsp-stockos64-mod.zip`, see
-51
View File
@@ -1,51 +0,0 @@
# 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.
-31
View File
@@ -1111,37 +1111,6 @@ 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
+1 -1
View File
@@ -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, text2?, weight, weightKg?} | no |
| `dexEntry` | {heightFt, heightIn, heightM?, kind, text, weight, weightKg?} | no |
| `evolutions` | list of {item?, level?, method, species} | yes |
| `frontSize` | integer 1..7 | yes |
| `growthRate` | growth_rates id | yes |
-157
View File
@@ -1,157 +0,0 @@
# 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.
+3 -42
View File
@@ -214,51 +214,12 @@ 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. `ShaderFX.canConvert()` reports whether the library resolved on this machine,
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,
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
+2 -3
View File
@@ -63,9 +63,8 @@ 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-x86_64.AppImage`, `-linux-arm64.AppImage`, `-linux.flatpak`,
`-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
`-linux-arm64.AppImage`, `-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
@@ -1,9 +0,0 @@
[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
@@ -1,29 +0,0 @@
<?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>
-68
View File
@@ -1,68 +0,0 @@
# 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
-76
View File
@@ -22,56 +22,6 @@ 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
@@ -80,33 +30,10 @@ 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
@@ -632,7 +559,6 @@ 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.
@@ -1303,8 +1229,6 @@ 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
-133
View File
@@ -12,139 +12,6 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"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",
"size": 14060528,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.24/gen1recomp++-0.2.24-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.23",
"date": "2026-08-24",
"size": 14060437,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.23/gen1recomp++-0.2.23-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.22",
"date": "2026-08-24",
"size": 14060365,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.22/gen1recomp++-0.2.22-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @ShaneMcGovernIE\n- @thibautbus"
},
{
"version": "0.2.21",
"date": "2026-08-24",
"size": 14058979,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.21/gen1recomp++-0.2.21-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1483 (Pokémon Gold) Missing HM/TM fanfare when receiving them\n- #1610 Wrong text when choosing Pokemon to trade\n- #1615 Fishing Guru dialogue issue\n- #1640 Sync doesnt work on XBOX build\n- #1646 Cashier dialogue + sound effects\n- #1649 Pokemon cries missing in Vermilion City\n- #1651 Ticket taker asks for ticket too much\n- #1653 Wrap animation is too fast and has no screen shake\n- #1656 Map visible in between end of fight & evolution\n- #1683 Music not changing when talking to trainer\n- #1685 Cancel option missing in item menu\n- #1686 Issues with teaching TMs & HMs\n- #1687 Pokemon cries missing on S.S. Anne\n- #1688 S.S. Anne rival dialogue\n- #1689 S.S. Anne departing cutscene broken\n- #1690 Wrong dialogue when taking back entrance to museum\n- #1693 (Gold) Issues not properly fixed\n- #1694 (Gold) Arrow when seeing items in the bag should be red, not black\n- #1695 (Gold) Wrong layout in TMs/HMs pocket\n- #1696 (Gold) Caught pokémon don't get healed when automatically sent to the PC\n- #1697 Standardize identification of mod-created options/settings screens\n- #1702 Lt. Surge's door opening isn't synced with the sound effect\n- #1704 Ledge jumping on the bike doesn't slow you down\n- #1705 The bike shouldn't have Use/Toss options\n- #1706 (Gold) Cursor wraps around the edge in the battle interface\n- #1707 (Gold) Pokémon sometimes wake up in the same turn they were put to sleep\n- #1708 (Gold) Missing rod when fishing\n- #1710 (Gold) You shouldn't be able to change boxes by pressing LEFT or RIGHT\n- #1711 (Gold) Missing animation for FLY in the overworld\n- #1712 (Gold) Wrong cursor in the map when using FLY\n- #1713 (Gold) Low framerate when jumping off ledges\n- #1716 (Gold) Player can go through a whirlpool even without having the HM\n- #1717 (Gold) Missing animation and SFX when using whirlpool in the overworld\n- #1718 (Gold) Can't pause in the Cycling Road while moving down\n- #1719 (Gold) Pokémon given by NPCs not being registered in the pokédex\n- #1720 Critical hit on multi-hit moves repeats the message\n- #1721 Rival not visible while talking if you lose first fight\n- #1724 Centralize ROM cache readiness and publication in an engine-owned contract\n- #1725 (Gold) Text not spaced nor centered correctly for secondary actions in the bag\n- #1732 Cursor doesn't stay on last used item\n- #1745 Viewing items makes ingame menu disappear\n- #1748 (Gold) Overworld pokémon sprite are stactic\n- #1749 (Gold) You shouldn't be able to surf on Cycling Road\n- #1751 (Gold) Synthesis and Morning Sun always recover 50% of max HP\n- #1754 Leaving building with bike speed\n- #1756 No cursor or gamepad controls in skin menu, locked out on Xbox\n\n## Contributors\n\n- @1Jamie\n- @BoLayerDev\n- @bryanthaboi\n- @campavao\n- @caorthann-celt\n- @dlloa\n- @syybott\n- @thibautbus\n- MaxTomahawk"
},
{
"version": "0.2.20",
"date": "2026-08-22",
"size": 13812226,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.20/gen1recomp++-0.2.20-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1536 Magnet Train Cutscene visual bugs\n- #1597 Link cable looks broken\n- #1643 Pikachu + Title uses an incorrect Colour Palette\n- #1662 The game glitches after exiting, returning to the menu, and choosing another one.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @colsonrice"
},
{
"version": "0.2.19",
"date": "2026-08-22",
"size": 13778435,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.19/gen1recomp++-0.2.19-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1274 Choose ROM imports a different version than the one selected when several dumps are pending (Linux, no file picker)\n- #1484 (Pokémon Gold) Bill's PC doesn't show equipped items + missing orange filter on pokémon sprite\n- #1486 (Pokémon Gold) Can't deposit key items\n- #1513 Experience share issue different case from #1464\n- #1517 Game Crash Opening Stats\n- #1548 Colisión\n- #1556 (Gold) Missing SFX for a lot of actions\n- #1564 Bide's storing turn is missing the screen shake\n- #1567 TM/HM case not in order\n- #1663 transition pops the top state, destroying anything pushed from a midpoint callback\n- #1664 gold bide spends pp every turn instead of only the turn it is selected\n- #1665 gold has no bide lock-in, so a mid-bide switch of move strands the counter\n- #1666 sleep talk is inert in gold: EFFECT_SLEEP_TALK has no move effect entry\n- #1667 gold: a fainted party mon keeps its exp participant credit and halves the survivor's exp\n- #1668 summary page 2 formats a move's pp with no numeric guard\n- #1670 gold tm/hm pocket capacity is an invented 64 where the cart has 57 slots\n- #1672 bill's pc move screen is missing the box name arrows\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @colsonrice\n- @jramiresbrito"
},
{
"version": "0.2.18",
"date": "2026-08-21",
"size": 13772120,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.18/gen1recomp++-0.2.18-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.17",
"date": "2026-08-21",
"size": 13771903,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.17/gen1recomp++-0.2.17-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.16",
"date": "2026-08-21",
"size": 13769300,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.16/gen1recomp++-0.2.16-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1367 Android auto scrolling down bug\n- #1570 Pokemon Gold isn't noticing my controller inputs on...ANY controller I own, despite all of them working in the other games, and in the main launcher\n- #1585 Power-saving mode or dark mode closes the game on Android.\n- #1611 bug: keyboard gets stuck IOS\n- #1612 Fold 7 infinite scrolling mouse.\n- #1636 Game freezes when trainer battle starts\n- #1638 Game screen orientation on smartphone\n- #1641 [Gold] [Android] PKMN Evolutions sequence freezes the Game \n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @HighDrexler\n- MaxTomahawk"
},
{
"version": "0.2.15",
"date": "2026-08-21",
"size": 13753007,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.15/gen1recomp++-0.2.15-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1589 Player sprite still blinking on the Town Map\n- #1595 Pokedex completion certificate is not displaying correctly\n- #1597 Link cable looks broken\n- #1613 Visual differences in Pokemon trades\n- #1619 Save Menu cutting off and Put into another place\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
},
{
"version": "0.2.14",
"date": "2026-08-20",
"size": 13749372,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.14/gen1recomp++-0.2.14-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.13",
"date": "2026-08-20",
"size": 13749377,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.13/gen1recomp++-0.2.13-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1474 (Pokémon Gold) Pokédex mode not being saved\n- #1475 (Pokémon Gold) Vital Throw attacking before the opponent\n- #1477 (Pokémon Gold) Map when using fly keeps the arrow from the pokégear\n- #1478 (Pokémon Gold) Wrong move layout in battles\n- #1479 [Gold] tiles broken at Route 28 and Mt Silver\n- #1482 (Pokémon Gold) Nothing happens when trying to use Coin Case\n- #1488 Pokemon EXP calculation after trading went back to issue #984\n- #1510 Title Screen Transition is Missing\n- #1511 Broken Trainer Rival Name Layout\n- #1512 Gen 2 Post battle interactions don't exist\n- #1514 Health damage timing\n- #1516 Move learning timing\n- #1521 Different Item Menu\n- #1522 Wrong Save Game Layout\n- #1545 Pikachu not sliding out when switching Pokemon.\n- #1557 (Gold) Impossible to get TM 03 in Celadon Mansion at night\n- #1558 (Gold) Missing colors and symbol in stats screen\n- #1563 Player mon pic cuts away instead of shrinking when recalled\n- #1565 Thrash doesn't lock in when the first use misses\n- #1566 Center PC missing the PKMN LEAGUE entry after the Hall of Fame\n- #1569 Goldenrod Gift Spearow Bugged\n- #1577 Thrash has no animation past the first turn\n- #1578 Hitting yourself in confusion animation missing\n- #1579 Missing dialogue for Cerulean City Rocket\n- #1594 Pokemon menu closing too soon when using rare candy\n- #1596 Evolution dialogue & missing jingle\n- #1606 Misty dialogue issue\n- #1608 \"There's no will to fight!\" message issues\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.12",
"date": "2026-08-20",
"size": 13737259,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.12/gen1recomp++-0.2.12-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1582 Sync not working between steamdeck and windows\n- #1583 Cant sync between iOS and windows\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @thibautbus"
},
{
"version": "0.2.11",
"date": "2026-08-20",
+26 -72
View File
@@ -11,7 +11,7 @@
#
# Output: dist/mac/gen1recomp-macos.zip
# dist/win/gen1recomp-win64.zip
# dist/linux/gen1recomp-linux-x86_64.AppImage (fused x86_64 AppImage)
# dist/linux/gen1recomp-linux.zip (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,53 +170,27 @@ make_ico() { # $1 = output .ico path
# --------------------------------------------------------------- macOS
# ShaderFX's librashader bridge. Only the CONVERT action needs it, so a build
# without it still runs presets that were converted elsewhere.
# SHADERFX_BRIDGE_<PLAT> or dist/native/<plat>/ points at a prebuilt library;
# otherwise cargo builds it, host platform only.
shader_bridge_host_plat() {
case "$(uname -s)-$(uname -m)" in
Darwin-*) printf 'mac' ;;
Linux-x86_64) printf 'linux-x64' ;;
Linux-aarch64|Linux-arm64) printf 'linux-arm64' ;;
*) printf '' ;;
esac
}
# SHADERFX_BRIDGE points at a prebuilt library; otherwise cargo builds it.
bundle_shader_bridge() {
local dest="$1" name="$2" plat="$3"
local dest="$1" name="$2"
local src="${SHADERFX_BRIDGE:-}"
local crate="$ROOT/tools/shaderfx-bridge"
local src="" var
[ -n "$plat" ] || fail "bundle_shader_bridge: no platform key for $name"
var="SHADERFX_BRIDGE_$(printf '%s' "$plat" | tr 'a-z-' 'A-Z_')"
eval "src=\${$var:-}"
if [ -z "$src" ] && [ -f "$DIST/native/$plat/$name" ]; then
src="$DIST/native/$plat/$name"
if [ -z "$src" ] && [ -f "$crate/target/release/$name" ]; then
src="$crate/target/release/$name"
fi
if [ -z "$src" ] && [ -n "${SHADERFX_BRIDGE:-}" ]; then
src="$SHADERFX_BRIDGE"
fi
if [ -z "$src" ] && [ "$plat" = "$(shader_bridge_host_plat)" ]; then
if [ -f "$crate/target/release/$name" ]; then
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
src="$crate/target/release/$name"
elif command -v cargo >/dev/null 2>&1; then
say "building the ShaderFX bridge with cargo"
if (cd "$crate" && cargo build --release >/dev/null 2>&1); then
src="$crate/target/release/$name"
fi
fi
fi
if [ -n "$src" ] && [ -f "$src" ]; then
mkdir -p "$dest"
cp "$src" "$dest/$name"
say "bundled $name for SHADER FX preset conversion ($plat)"
return 0
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)"
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() {
@@ -231,7 +205,7 @@ build_mac() {
# drop any bundled placeholder .love and fuse ours in
find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete
cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love"
bundle_shader_bridge "$out_app/Contents/MacOS" "liblibrashader_bridge.dylib" mac
bundle_shader_bridge "$out_app/Contents/MacOS" "liblibrashader_bridge.dylib"
local plist="$out_app/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$plist" 2>/dev/null \
@@ -348,7 +322,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" win-x64
bundle_shader_bridge "$out_dir" "librashader_bridge.dll"
# 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
@@ -445,7 +419,7 @@ build_linux() {
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
cp "$LOVE_FILE" "$appdir/game.love"
bundle_shader_bridge "$appdir" "liblibrashader_bridge.so" linux-x64
bundle_shader_bridge "$appdir" "liblibrashader_bridge.so"
# 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
@@ -483,31 +457,15 @@ 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"
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
sips -z 512 512 "$ICON_SRC" --out "$appdir/$APP_NAME.png" >/dev/null
cp "$appdir/$APP_NAME.png" "$appdir/.DirIcon"
# 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
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
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
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"
# Match the upstream image's compression (gzip, 128K blocks) so the
# bundled runtime can read it.
@@ -516,20 +474,16 @@ exec "$APPDIR/bin/love"'
mksquashfs "$appdir" "$sfs_out" \
-comp gzip -b 131072 -noappend -all-root -no-xattrs -quiet >/dev/null
mkdir -p "$DIST/linux"
local out_bin="$DIST/linux/$APP_NAME-linux-x86_64.AppImage"
rm -f "$out_bin" "$out_bin.sha256"
local out_bin="$WORK/$APP_NAME-x86_64.AppImage"
rm -f "$out_bin"
head -c "$sfs_offset" "$love_appimage" > "$out_bin"
cat "$sfs_out" >> "$out_bin"
chmod +x "$out_bin"
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"
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"
}
# --------------------------------------------------------------- Android
-143
View File
@@ -1,143 +0,0 @@
#!/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")"
-15
View File
@@ -100,21 +100,6 @@ 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.
+1 -9
View File
@@ -327,14 +327,6 @@ 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
@@ -347,7 +339,7 @@ fi
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* ${SHADER_BRIDGE[@]+"${SHADER_BRIDGE[@]}"}; do
for object in "$APPDIR/bin/love" "$APPDIR"/lib/*.so*; do
while read -r soname; do
[ -n "$soname" ] || continue
# Satisfied from inside the AppDir, so not a host requirement at all.
+3 -28
View File
@@ -20,23 +20,6 @@ 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.
#
@@ -45,14 +28,14 @@ echo "scanning ${#scan[@]} ELF objects: ${scan[*]#squashfs-root/}"
# 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 "${scan[@]}" 2>/dev/null \
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
| grep 'not found' || true)"
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
# Nothing may hard-link a driver, session or audio-stack library:
# those must be reached through dlopen so the AppImage runs on a box
# with only ALSA, only Wayland, or only KMSDRM.
linked="$(for f in "${scan[@]}"; do
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
[ -z "$linked" ] \
@@ -62,7 +45,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 "${scan[@]}" 2>/dev/null \
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
echo "highest required glibc symbol version: $floor"
[ -n "$floor" ] \
@@ -71,12 +54,4 @@ 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"
-1
View File
@@ -118,7 +118,6 @@ 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
+9 -11
View File
@@ -104,9 +104,7 @@ local SE_FRAMES = {
SE_FLASH_MON_PIC = 4, SE_FLASH_ENEMY_MON_PIC = 4,
SE_TRANSFORM_MON = 4,
SE_SUBSTITUTE_MON = 3,
-- AnimationWavyScreen: `ld c, $ff` counts outer passes, and the inner
-- loop exits twice per displayed frame (animations.asm:1884-1903)
SE_WAVY_SCREEN = 128,
SE_WAVY_SCREEN = 255, -- AnimationWavyScreen: ld c, $ff frames
}
-- data/battle_anims/special_effects.asm AnimationIdSpecialEffects:
@@ -504,10 +502,15 @@ 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: animations.asm:739-747, :623-627
local pendingTink = false
-- 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
for _ = 1, (opts and opts.shakes) or 1 do
pendingTink = opts and opts.shakes ~= nil
if opts and opts.shakes then
events[#events + 1] = { effect = "SFX_TINK", frame = frame }
emit(40)
end
local dest = 1 -- PlaySubanimation resets the OAM cursor per row
local nblocks = math.abs(last - first) + 1
local played = 0
@@ -565,11 +568,6 @@ 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
+34 -101
View File
@@ -16,7 +16,6 @@ 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")
@@ -35,12 +34,6 @@ 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 = {}
@@ -341,22 +334,6 @@ 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")
@@ -484,7 +461,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.forcesRawGrays()
or PaletteFX.mode == "classic"
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
@@ -914,8 +891,7 @@ 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))
-- _EnemyAppearedText (data/text/text_2.asm:1251-1255) has no article
self.introText = self:romText("_EnemyAppearedText", "%s\nappeared!", self.enemy.name)
self.introText = Strings("The GHOST\nappeared!")
end
-- Pokémon Tower ghosts (engine/battle/core.asm): without the Silph Scope
@@ -959,11 +935,7 @@ 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 }
local ghostObj = objPic("assets/generated/battle/front/ghost.png")
if ghostObj then self.enemy.sprite = ghostObj end
end)
self:act(function() self.ghostReveal = { t = 0 } 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))
@@ -3286,9 +3258,9 @@ function BattleState:applyAnimEffect(ev)
end
fx.hudShakeProg = prog
elseif e == "SE_WAVY_SCREEN" then
-- AnimationWavyScreen: 255 outer passes, two per displayed frame
-- (animations.asm:1884-1903), walking WavyScreenLineOffsets
fx.wavy = { left = 128, phase = 0 }
-- AnimationWavyScreen: 255 frames of per-scanline SCX offsets
-- walking WavyScreenLineOffsets
fx.wavy = { left = 255, phase = 0 }
-- ---------------------------------------------- mon pic effects
elseif e == "SE_SLIDE_MON_OFF" then
@@ -3672,7 +3644,7 @@ function BattleState:updateFx()
end
if fx.wavy then
fx.wavy.left = fx.wavy.left - 1
fx.wavy.phase = fx.wavy.phase + 2
fx.wavy.phase = fx.wavy.phase + 1
if fx.wavy.left <= 0 then fx.wavy = nil end
end
end
@@ -3726,22 +3698,12 @@ function BattleState:updateFx()
local real = self.ghostReal
if real then
self.enemy.name = real.name or self.enemy.name
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
self.enemy.sprite = real.sprite or self.enemy.sprite
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
@@ -3857,9 +3819,6 @@ 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,
@@ -5985,7 +5944,8 @@ 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 }
-- WavyScreen_SetSCX: animations.asm:1916-1927
-- wave the BG canvas one scanline at a time; the offset table walks
-- one entry per frame like the asm's advancing pointer
function BattleState:applyWavy(src)
local wavy = self.fx and self.fx.wavy
if not wavy then return src end
@@ -5998,7 +5958,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 * 2 + wavy.phase) % 32 + 1], line)
WAVY_OFFSETS[(line + wavy.phase) % 32 + 1], line)
end
g.setCanvas(prev)
return self.waveCanvas
@@ -6029,7 +5989,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.forcesRawGrays()
or PaletteFX.mode == "classic"
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setShader(shader)
local shaking = sx ~= 0 or sy ~= 0
@@ -6338,7 +6298,7 @@ function BattleState:drawHUDs(slide)
end
if self.enemy.shownStatus then
Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8)
elseif LevelDisplay.visible(self.enemy.mon, "battle.enemy", self.game) then
else
hudTile(0x6E, 32, 8) -- <LV>
Font.draw(tostring(self.enemy.mon.level), 40, 8)
end
@@ -6422,7 +6382,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)
elseif LevelDisplay.visible(self.player.mon, "battle.player", self.game) then
else
hudTile(0x6E, 112, 64) -- <LV>
Font.draw(tostring(self.player.mon.level), 120, 64)
end
@@ -6439,35 +6399,8 @@ end
function BattleState:drawTextArea()
if not self:bottomUIVisible() then return end
-- 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)
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
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
@@ -6486,47 +6419,48 @@ function BattleState:drawTextArea()
for li, line in ipairs(self.shown or {}) do
local y = (ys[li] or 128) + off
for i = 1, #line do
drawGlyph(line[i], 8 + (i - 1) * 8, y)
Font.drawCode(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
drawGlyph(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4)
Font.drawCode(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)
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)
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)
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)
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)
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)
-- 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)
text(("%2d"):format(self.safari.balls), 56, 112)
drawGlyph(0xED, (col == 0 and 8 or 104), 112 + row * 16)
Font.draw(("%2d"):format(self.safari.balls), 56, 112)
Font.drawCode(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
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)
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)
end
elseif self.phase == "moveSelect" then
-- pokered MoveSelectionMenu: move list in a box at (4,12) 16x6,
@@ -6601,7 +6535,6 @@ 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()
+5 -1
View File
@@ -26,10 +26,14 @@ 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)
-1
View File
@@ -88,7 +88,6 @@ 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 {}
+2 -9
View File
@@ -186,8 +186,7 @@ function Game:makeTitleState()
onContinue = function()
local loaded, recovered = SaveData.load()
if loaded then
self:restoreSave(loaded, recovered,
{ freshBoot = true, continued = true })
self:restoreSave(loaded, recovered, { freshBoot = true })
end
end,
onExit = self.onExit,
@@ -1259,7 +1258,6 @@ 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)
@@ -1323,9 +1321,6 @@ 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)
@@ -1340,10 +1335,8 @@ 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, facing,
loaded.player.x, loaded.player.y, loaded.player.facing,
{ via = "boot", freshBoot = opts and opts.freshBoot })
self.saveReport = report
if not SaveData.emptyReport(report) then
-7
View File
@@ -1024,11 +1024,6 @@ 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
@@ -1792,7 +1787,6 @@ 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
@@ -2046,7 +2040,6 @@ 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)
+2 -21
View File
@@ -40,10 +40,8 @@ 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", [3] = "x", [4] = "y",
[5] = "leftshoulder", [6] = "rightshoulder",
[1] = "a", [2] = "b",
[7] = "back", [8] = "start", [9] = "back", [10] = "start",
[11] = "triggerleft", [12] = "triggerright",
}
GamepadMap.NX_RAW_TO_GAMEPAD_BUTTON = {
@@ -59,10 +57,7 @@ function GamepadMap._setForceNXForTests(v)
end
local function nxActive()
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 GamepadMap._forceNXForTests then return true 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
@@ -83,20 +78,6 @@ 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".
+24 -127
View File
@@ -4,79 +4,18 @@ 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. 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.
-- 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.
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 kind ~= "bundled" and os.getenv("APPIMAGE") then
unset = unset .. "-u LD_LIBRARY_PATH "
end
if 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
@@ -187,19 +126,12 @@ local function withPopenLock(fn)
if not okAtomic then fn() end
end
-- 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)
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed
function HostShell.popen(command, mode)
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, line, mode or "r")
local ok, p = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r")
pipe = (ok and p) or nil
end)
return pipe
@@ -261,12 +193,6 @@ function HostShell.restart()
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
@@ -364,18 +290,6 @@ 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)
@@ -427,10 +341,7 @@ local curlAvailable = nil
function HostShell.haveCurl()
if curlAvailable ~= nil then return curlAvailable end
local path, kind = HostShell.resolveCurl()
local pipe = HostShell.popen(
HostShell.quote(path) .. " --version", "r",
{ envPrefix = HostShell.curlEnvPrefix(kind) })
local pipe = HostShell.popen("curl --version")
if not pipe then curlAvailable = false return false end
local readOk, out = pcall(function() return pipe:read("*a") end)
HostShell.pclose(pipe)
@@ -438,12 +349,6 @@ 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()
@@ -504,11 +409,9 @@ 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 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
local cmd = ("curl -fsSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 15 --max-time %d ")
:format(tonumber(maxTime) or 300)
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
@@ -520,7 +423,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, "r", popts)
local pipe = HostShell.popen(cmd)
if not pipe then return nil, "could not start download" end
local readOk, out = pcall(function() return pipe:read("*a") end)
HostShell.pclose(pipe)
@@ -563,18 +466,16 @@ 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 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
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40)
.. "-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, "r", popts)
local pipe = HostShell.popen(cmd)
if not pipe then return nil, "could not run curl" end
local readOk, out = pcall(function() return pipe:read("*a") end)
HostShell.pclose(pipe)
@@ -661,11 +562,9 @@ 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 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
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40)
.. "-X POST "
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if contentType then
@@ -675,7 +574,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, "r", popts)
local pipe = HostShell.popen(cmd)
if not pipe then
pcall(os.remove, bodyPath)
return nil, "could not run curl"
@@ -852,10 +751,8 @@ function HostShell.httpRequest(url, opts)
pcall(os.remove, headerPath)
end
local head, popts = curlCmd(
("-sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d "):format(maxTime))
local cmd = head
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d "):format(maxTime)
.. "-X " .. HostShell.quote(method) .. " "
.. "-H " .. HostShell.quote("@" .. headerPath) .. " "
if body then
@@ -864,7 +761,7 @@ function HostShell.httpRequest(url, opts)
cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd, "r", popts)
local pipe = HostShell.popen(cmd)
if not pipe then
cleanup()
return nil, "could not run curl"
+2 -37
View File
@@ -173,40 +173,13 @@ function SaveData.gameFolders()
end
-- The game folder carrying portable.txt, or false. First candidate holding
-- 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
-- the marker wins.
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
local markerOk = false
pcall(function()
markerOk = pathExists(base .. SEP .. PORTABLE_MARKER)
end)
if markerOk and dirIsWritable(base) then
if pathExists(base .. SEP .. PORTABLE_MARKER) then
portableBase = base
break
end
@@ -214,13 +187,6 @@ 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
@@ -313,7 +279,6 @@ 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
+2 -4
View File
@@ -1101,10 +1101,8 @@ 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
-- 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.ot = egg.ot or (save.player and save.player.name)
hatched.otId = egg.otId or (save.player and save.player.id)
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.
-39
View File
@@ -1,39 +0,0 @@
-- 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
-2
View File
@@ -309,7 +309,6 @@ 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.
@@ -317,7 +316,6 @@ 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
-1
View File
@@ -605,7 +605,6 @@ local function gen2Rows(opts, hooks, shared)
end
opts.color =
GbcPalette.MODES[wrapIndex(idx - 1 + dir, #GbcPalette.MODES) + 1]
opts.palette = ""
return true
end)
end
+13 -88
View File
@@ -377,19 +377,12 @@ 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)
local isFocused = Kit.focusable(key, x, y, w, h)
Kit.focusable(key, x, y, w, h)
Theme.fill(x, y, w, h, PAL.bg, 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
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)
local pad = math.floor(10 * Kit.scale)
local ty = y + (h - Kit.textHeight("button")) / 2
local text = rawText or ""
@@ -404,35 +397,8 @@ local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, ac
Kit.textHeight("button"), PAL.ink, 1)
end
end
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
if action and (Kit.press(x, y, w, h) or Kit._activateId == key) then
queueAction(imp, key, action)
end
end
@@ -1394,9 +1360,7 @@ 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.
-- 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 clusterW = 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
@@ -1430,8 +1394,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 = not imp.ios and rx - gear or nil
if quitX then rx = quitX - math.floor(6 * m.s) end
local quitX = rx - gear
rx = quitX - math.floor(6 * m.s)
-- Settings gear. It now also owns the CONTROL settings (touch overlay
-- editor, reset rebinds), which used to be buttons stacked in the game
@@ -1443,7 +1407,7 @@ local function buildHeader(imp, m)
chrome.gear.image = imp._gearIcon
btn(imp, rx, by, gear, gear, "gear", "", chrome.gear)
if quitX then btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit) end
btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit)
-- 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
@@ -3442,19 +3406,13 @@ 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 or isFocused) and 1 or 0.85)
love.graphics.setColor(1, 1, 1, hot 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) or Kit._activateId == "bcg" then
if Kit.press(bx, my, dw, dh) then
queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end)
end
local cx = bx + dw
@@ -5646,8 +5604,6 @@ 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
@@ -5660,14 +5616,6 @@ 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"),
@@ -5839,10 +5787,6 @@ 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
@@ -6026,25 +5970,6 @@ 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
+1 -2
View File
@@ -2753,8 +2753,7 @@ function RomExtractorGen2:extractAudio(maps)
local speciesOrder = self.manifest.constants.speciesOrder or {}
local cries = {}
for index, species in ipairs(speciesOrder) do
-- data/pokemon/cries.asm:209 gives UNOWN a real cry row
if not tostring(species):match("^UNUSED") then
if species ~= "UNOWN" and 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
+37 -342
View File
@@ -1263,12 +1263,7 @@ end
-- up the background worker or reach out to the network.
local function updaterAllowed()
if not Platform.networkValidated() 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 not (love.filesystem.isFused and love.filesystem.isFused()) 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
@@ -2060,42 +2055,8 @@ 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)
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
if path then self:_installMod(path) end
end
local function requiredManifest(self, modId)
@@ -2296,8 +2257,8 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed)
}
return nil
end
if spec.format ~= "n64" and (size == nil
or size > RequiredImports.LARGE_WARN_BYTES) then
if type(size) == "number" and size > RequiredImports.LARGE_WARN_BYTES
and spec.format ~= "n64" then
local ok, result = streamRequiredImport(manifest, importId, source)
if ok then
self.requiredImportNotice = nil
@@ -2518,42 +2479,8 @@ 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)
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
if path then self:_importSave(version, path) end
end
-- "Export save" button: write the active slot back out to a raw .sav in the save
@@ -2694,42 +2621,11 @@ 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
@@ -3102,14 +2998,6 @@ 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
@@ -3152,6 +3040,14 @@ 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
@@ -3159,12 +3055,17 @@ 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()
@@ -3173,184 +3074,30 @@ function RomImporter:_updatePadCursor(dt)
end
function RomImporter:gamepadpressed(_, button)
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
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)
end
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
elseif button == "leftshoulder" then
self:_cycleTab(-1)
return
elseif button == "rightshoulder" then
self:_cycleTab(1)
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
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.
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
@@ -3364,29 +3111,11 @@ end
function RomImporter:gamepadaxis(_, axis, value)
if axis == "leftx" or axis == "lefty" or axis == "righty" then
self._padAxis[axis] = value
if self._padCursorActive and math.abs(value) > PAD_DEAD then
if math.abs(value) > PAD_DEAD then
self:_activatePadCursor()
elseif axis == "lefty" and math.abs(value) <= PAD_DEAD then
elseif axis == "lefty" 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
@@ -3414,24 +3143,6 @@ 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
@@ -4266,7 +3977,6 @@ function RomImporter:_toggleSafeMode()
self.mods = nil
self.findInstalled = nil
self._modSortCache = nil
self._cartCaptureCache = nil
self._modInfoFetch = nil
self.modNotice = nil
end
@@ -4352,15 +4062,6 @@ 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 "")
@@ -4593,7 +4294,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, self._cartCaptureCache = nil, nil, nil
self.mods, self._modSortCache = nil, nil
local scope = self:slotScope(version)
self.slots[scope] = nil
self.slotScroll[scope] = nil
@@ -4892,11 +4593,7 @@ 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
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
return #cartModRows(self, version)
end
function RomImporter:_cartAuthor()
@@ -5255,7 +4952,6 @@ 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
@@ -5290,7 +4986,6 @@ 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
+4 -51
View File
@@ -20,37 +20,6 @@ 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:
@@ -109,17 +78,8 @@ 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, version)
local check = SaveConvert.mainChecksumValid(bytes)
if check == nil then
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
:format(SAVE_SIZE, #bytes)
@@ -127,12 +87,7 @@ function SaveFileIO.importToSlot(source, version, force)
if check == false then
return false, "save data checksum invalid (main data checksum mismatch)"
end
-- 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
if #bytes > SAVE_SIZE and not force then
return false, nil, { needsConfirm = true, size = #bytes }
end
bytes = #bytes > SAVE_SIZE and bytes:sub(1, SAVE_SIZE)
@@ -156,7 +111,6 @@ 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
@@ -171,10 +125,9 @@ 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 slotId = SaveData.activeSlot(version) or "save"
local bytes, exportErr = SaveConvert.exportSav(save, version,
readCart(version, slotId))
local bytes, exportErr = SaveConvert.exportSav(save, version)
if not bytes then return false, exportErr end
local slotId = SaveData.activeSlot(version) or "save"
-- 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
+2 -5
View File
@@ -94,11 +94,9 @@ function ItemEffects.healsHP(id)
end
-- .useRareCandy prints over the still-drawn party menu
-- (engine/items/item_effects.asm:1392-1418); .useVitamin ends at
-- RemoveUsedItem the same way (engine/items/item_effects.asm:1315-1322)
-- (engine/items/item_effects.asm:1392-1418)
function ItemEffects.keepsPartyMenuOpen(id)
return ItemEffects.healsHP(id) or id == "RARE_CANDY"
or VITAMINS[id] ~= nil
end
function ItemEffects.isBattleMedicine(id)
@@ -528,9 +526,8 @@ 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])) }, { useJingle = true }
Strings(STAT_LABEL[vitaminStat])) }
end
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
+4 -26
View File
@@ -23,7 +23,6 @@ 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 = {}
@@ -474,22 +473,11 @@ function LinkBattle.new(game, net, opts)
-- switches happen before attacks (both may switch)
if myMsg.kind == "switch" then
local idx = myMsg.index
-- 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)
s:act(function() sendOutPlayer(s, myParty[idx]) end)
myAction = nil
end
if theirSwitch then
-- 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)
s:act(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
end
s:act(function()
@@ -897,21 +885,11 @@ function LinkBattle.newSpectator(game, net, opts)
if hostMsg.kind == "switch" then
local idx = hostMsg.index
-- 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)
s:act(function() sendOutHost(s, hostParty[idx]) end)
end
if guestMsg.kind == "switch" then
local idx = guestMsg.index
-- 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)
s:act(function() sendOutGuest(s, guestParty[idx]) end)
end
s:act(function()
+3 -3
View File
@@ -1711,8 +1711,8 @@ local function buildStartMenu()
function overrides.new(game)
local g = live() or game
local save = g and g.save
if g and g.startMenuIndex then
Start2.lastIndex = g.startMenuIndex
if save and save.startMenuIndex then
Start2.lastIndex = save.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)",
["game.startMenuIndex"] = "copied INTO StartMenu.lastIndex on construct "
["save.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",
+1 -5
View File
@@ -826,14 +826,10 @@ 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, text2 = f.opt(f.str) }),
text = 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")),
+5 -40
View File
@@ -18,6 +18,8 @@
-- 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
@@ -30,12 +32,9 @@
local GbcPalette = {}
GbcPalette.MODES = { "gbc", "dmg", "classic" }
GbcPalette.MODE_LABELS = { gbc = "GEN 2", dmg = "DMG", classic = "CLASSIC",
custom = "GBC" }
GbcPalette.MODE_LABELS = { gbc = "GBC", dmg = "DMG", classic = "CLASSIC" }
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 },
@@ -163,22 +162,11 @@ 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
@@ -261,10 +249,6 @@ 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
@@ -276,7 +260,7 @@ function GbcPalette.setMode(mode)
end
function GbcPalette.modeLabel(mode)
return GbcPalette.MODE_LABELS[mode or GbcPalette.mode] or "GEN 2"
return GbcPalette.MODE_LABELS[mode or GbcPalette.mode] or "GBC"
end
-- Advance GBC -> DMG -> CLASSIC -> GBC. `delta` may be -1 to step back, so
@@ -289,20 +273,11 @@ 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)
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
return GbcPalette.setMode(opts and opts.color or "gbc")
end
-- Point the shader at one 4-color palette. Colors are 0-255 triples, matching
@@ -456,14 +431,4 @@ 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
-72
View File
@@ -1,72 +0,0 @@
-- 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
-249
View File
@@ -1,249 +0,0 @@
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
+22 -50
View File
@@ -797,18 +797,6 @@ 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
@@ -820,35 +808,27 @@ function PaletteFX.setMode(mode)
end
end
if not ok then PaletteFX.mode = "gbc" 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()
-- 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)
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
@@ -861,13 +841,6 @@ 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)
@@ -880,11 +853,12 @@ 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"
or (PaletteFX.forcesRawGrays() and not PaletteFX.pickerActive()) then
if mode == "og" or mode == "og_inv" or mode == "classic" then
return { PaletteFX.whole(PaletteFX.GRAYS) }
end
return zones
@@ -914,9 +888,7 @@ function PaletteFX.effectiveColors(c)
if not c then return nil end
local mode = PaletteFX.mode or "gbc"
local out = c
if PaletteFX.customRamp and not PaletteFX.pickerActive() then
out = PaletteFX.customRamp
elseif mode == "og" then
if mode == "og" then
out = PaletteFX.GRAYS
elseif mode == "og_inv" then
out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP)
-20
View File
@@ -332,7 +332,6 @@ 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
@@ -700,18 +699,6 @@ 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.
@@ -985,13 +972,6 @@ 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)
+9 -124
View File
@@ -11,12 +11,6 @@ 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
@@ -28,52 +22,6 @@ 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.
@@ -112,8 +60,6 @@ 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
@@ -129,13 +75,7 @@ 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
@@ -330,14 +270,6 @@ 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
@@ -359,20 +291,6 @@ 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
@@ -454,7 +372,6 @@ 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()
@@ -470,7 +387,6 @@ 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
@@ -508,15 +424,6 @@ 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]
@@ -563,21 +470,8 @@ 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()
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
Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, paper)
love.graphics.setColor(0, 0, 0, 1)
if self.scrollPx and self.scrollPx > 0 then
self.scrollPx = self.scrollPx - 2
if self.scrollPx <= 0 then self.scrollPx = nil end
@@ -597,25 +491,17 @@ 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
drawGlyph(code, pen, y)
Font.drawCode(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
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
Font.drawBox(11, 0, 9, 3)
love.graphics.setColor(0, 0, 0, 1)
local money = ("¥%d"):format(self.money() or 0)
local pen = 152 - Font.width(money)
for _, code in ipairs(Font.encode(money)) do
drawGlyph(code, pen, 8)
pen = pen + Font.advanceOf(code)
end
Font.draw(money, 152 - Font.width(money), 8)
end
if (self.waiting or (self.done and not self.choice and not self.auto
and (not self.stay
@@ -623,11 +509,10 @@ 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)
drawGlyph(Theme.moreArrow or 0xEE,
(self.boxTx + self.boxTw - 2) * 8,
(self.boxTy + self.boxTh - 1) * 8 - 4)
Font.drawCode(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
+6 -3
View File
@@ -150,10 +150,13 @@ 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).
-- spinners.asm:17-22, home/overworld.asm:1844-1846, :49-52
-- 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.
function TileRenderer.spinBlurActive()
return spinning and (math.floor(animFrame / 16) % 2 == 0)
return spinning and (math.floor(animFrame / 8) % 2 == 0)
end
-- ------------------------------------------------------------------
-335
View File
@@ -1,335 +0,0 @@
-- 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
-690
View File
@@ -1,690 +0,0 @@
-- 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
+15 -79
View File
@@ -21,23 +21,12 @@
-- require alone cannot see them there (#420).
local GenSave = require("src.save_convert.GenSave")
local Gen2Save = require("src.save_convert.Gen2Save")
local GameVersion = require("src.core.GameVersion")
local SaveConvert = {}
SaveConvert.SAVE_SIZE = GenSave.SAVE_SIZE
-- 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
SaveConvert.mainChecksumValid = GenSave.mainChecksumValid
-- ------------------------------------------------------------------
-- Crosswalk data loading (cached). Mirrors src/core/Data.lua: prefer
@@ -136,24 +125,6 @@ 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).
@@ -257,36 +228,11 @@ 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
-- 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
-- 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
end
-- importSav(bytes, version, gameVersion) -> saveTable, err
@@ -301,16 +247,9 @@ function SaveConvert.importSav(bytes, version, gameVersion)
if type(bytes) ~= "string" then
return nil, "expected raw save bytes as a string"
end
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)
local gen2Name = gen2CartName(gameVersion)
if gen2Name then
return nil, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet."
end
if #bytes ~= GenSave.SAVE_SIZE then
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
@@ -334,22 +273,19 @@ function SaveConvert.importSav(bytes, version, gameVersion)
return mergeDefaults(decoded, version)
end
-- exportSav(saveTable, gameVersion, cartImage) -> bytes, err
-- exportSav(saveTable, gameVersion) -> 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, cartImage)
function SaveConvert.exportSav(saveTable, gameVersion)
if type(saveTable) ~= "table" then
return nil, "expected a save table"
end
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))
local gen2Name = gen2CartName(gameVersion)
if gen2Name then
return nil, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet."
end
local data, derr = ensureData(gameVersion)
if not data then return nil, derr end
+5 -6
View File
@@ -451,13 +451,12 @@ function H.CutDownTreeOrGrass(ctx)
end
-- DisappearWhirlpool is CutDownTreeOrGrass with PlayWhirlpoolSound in place of
-- OWCutAnimation, and the block only reaches the screen after the sound
-- -- engine/events/overworld.asm:1157-1164 (#1717, #1862)
-- OWCutAnimation: the same block write, the same redraw, then the surf wash the
-- port owns as World:playWhirlpoolSound. #1717
function H.DisappearWhirlpool(ctx)
local index = ctx and ctx.cutWhirlpoolBlockIndex
local blockId = ctx and ctx.cutWhirlpoolReplacement
call(ctx, "playWhirlpoolSound", index, blockId)
return nil
local ret = H.CutDownTreeOrGrass(ctx)
call(ctx, "playWhirlpoolSound")
return ret
end
-- BlindingFlash sets STATUSFLAGS_FLASH_F and reloads the palettes. Setting
+6 -12
View File
@@ -152,12 +152,6 @@ 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.
@@ -458,10 +452,8 @@ 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), nil, nil, true)
vm:showRaw(Strings(row.score, entry.score or 0))
end
end
answer(vm, place)
@@ -2041,10 +2033,12 @@ 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), nil, nil, true)
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" })
end
-- The whose-PC menu's PROF.OAK's PC row (src/ui/gen2/CenterPcMenu.lua) runs
+6 -10
View File
@@ -700,10 +700,8 @@ 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)), nil, nil, true)
name, self:pocketName(item)))
else
self:showRaw(Strings("The %s\nis full…", self:pocketName(item)))
end
@@ -1512,8 +1510,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);
-- Script_specialsound ends PlaySFX / WaitSFX (scripting.asm:484-485)
-- 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).
-- 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
@@ -1525,7 +1523,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)), nil, nil, true)
name, self:pocketName(item)))
return "end"
elseif op == "describedecoration" then
-- `describedecoration byte` picks one of five DECODESC_* arms
@@ -2429,7 +2427,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, sfxWait)
function Vm:showRaw(body, stay, hold)
if not body or body == "" then body = "..." end
if self.stringBuffer and self.stringBuffer ~= "" then
body = body:gsub("{STRBUF}", self.stringBuffer)
@@ -2440,8 +2438,6 @@ function Vm:showRaw(body, stay, hold, sfxWait)
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
@@ -2650,7 +2646,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, req.sfxWait)
end, req.stay, req.hold)
elseif req and req.kind == "wait" then
self.waitLeft = req.frames or 0
elseif req and req.kind == "waitbutton" then
+8 -44
View File
@@ -116,36 +116,6 @@ 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)
@@ -487,11 +457,7 @@ function SyncEngine:_planFrom(remoteState)
if not row then
self:_queueUpload(entry, key, false)
elseif localChanged and remoteChanged then
if SyncEngine.samePlaytime(entry.meta, SyncEngine.metaOf(row)) then
self:_queueUpload(entry, key, true)
else
self:_addConflict(entry, key, row)
end
self:_addConflict(entry, key, row)
elseif localChanged then
self:_queueUpload(entry, key, false)
elseif remoteChanged and key ~= self.protectedKey then
@@ -511,7 +477,12 @@ function SyncEngine:_planFrom(remoteState)
end
function SyncEngine:_addConflict(entry, key, row)
local remoteMeta = SyncEngine.metaOf(row)
local remoteMeta = row
if type(row.meta) == "table" then
remoteMeta = row.meta
elseif type(row.remoteMeta) == "table" then
remoteMeta = row.remoteMeta
end
self.conflicts[#self.conflicts + 1] = {
key = key,
version = entry.version,
@@ -557,14 +528,7 @@ function SyncEngine:_queueUpload(entry, key, force)
end, function(e, res)
if res.code == 409 then
local row = res.data or {}
-- 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
e:_addConflict(entry, key, row)
if not e:busy() then e:_finish() end
return true
end
+1 -3
View File
@@ -360,8 +360,6 @@ 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()
@@ -402,7 +400,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
end
nextStep()
end))
end, TextBox.soundOpts(game, "Get_Item1"))
end)
return
end
-- HP medicine: fill the bar in the still-open picker first, then print
+41 -85
View File
@@ -13,21 +13,9 @@ local Strings = require("src.core.Strings")
local BoxMenu = {}
-- 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 function monLabel(game, mon)
local def = game.data.pokemon[mon.species]
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
return Strings("%s :L%d", mon.nickname or def.name, mon.level)
end
local function monName(game, mon)
@@ -73,15 +61,14 @@ local function withdraw(game)
return
end
local 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), {
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, {
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()
@@ -124,14 +111,12 @@ local function deposit(game)
end
local items = {}
for i, mon in ipairs(game.save.party) do
table.insert(items, monRow(game, mon, i))
table.insert(items, { label = monLabel(game, mon), value = i })
end
game.stack:push(ListMenu.new(game, nil, withCancel(items), {
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", 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")
@@ -178,13 +163,14 @@ local function release(game)
return
end
local 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), {
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, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
kind = "pc_box_release",
itemBox = true,
onChoose = function(item, list)
if item.cancel then list:close() return end
onChoose = function(_, list)
local mon = box[list.index]
if not mon then return end
local name = monName(game, mon)
@@ -216,67 +202,37 @@ local function release(game)
}))
end
-- DisplayChangeBoxMenu: engine/menus/save.asm:437-506
local function changeBoxMenu(game)
local function changeBox(game)
local boxes = Boxes.ensure(game.save)
local items = {}
for i = 1, Boxes.COUNT do
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,
}
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,
})
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
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,
}))
end,
})
ask.kind = "pc_box_change"
game.stack:push(ask)
}))
end
-- bills_pc.asm BillsPCMenu chrome: What? text box + BOX No. overlay
+5 -37
View File
@@ -87,15 +87,6 @@ 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,
@@ -115,28 +106,14 @@ 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
-- engine/menus/pokedex.asm:500-506, home/pokemon.asm:145-148
self.crySrc = require("src.core.Sound").playCry(game.data, species)
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
@@ -150,18 +127,15 @@ end
function DexEntryMenu:draw()
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
self.spriteTrueColor, self.page,
{ crying = self:crying(),
arrow = (self.blink or 0) < 30 })
self.spriteTrueColor, self.page)
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, state)
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
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)
@@ -211,12 +185,6 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page, sta
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
@@ -234,8 +202,8 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page, sta
for i, line in ipairs(lines) do
Font.draw(line, 8, 72 + i * 16)
end
-- home/text.asm:245 places the '▼' and home/joypad2.asm:55-83 blinks it
if page < #pages and state.arrow ~= false then
-- home/text.asm:245
if page < #pages then
Font.drawCode(Theme.moreArrow, 144, 128)
end
else
-33
View File
@@ -1,33 +0,0 @@
-- 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
+26 -41
View File
@@ -120,9 +120,8 @@ 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). Every DisplayListMenuID
-- caller gets the same box, the PC item lists included (#1845).
self.itemBox = opts.itemBox or opts.messageBox or false
-- screen of its own (home/list_menu.asm:29-31)
self.itemBox = opts.itemBox or false
if self.itemBox then
self.isOpaque = false
-- keep RunDefaultPaletteCommand's last palette: ItemMenuLoop never sets
@@ -257,34 +256,6 @@ 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()
@@ -303,10 +274,7 @@ 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.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
if 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)
@@ -326,9 +294,6 @@ 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
@@ -361,7 +326,13 @@ 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)
ListMenu.drawBall(16 + Font.width(label) + 8 + 3, y + 3)
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))
end
if item.right then
Font.draw(item.right, 160 - 8 - Font.width(item.right), y)
@@ -389,8 +360,22 @@ 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 then
drawMessageBox(self)
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
elseif self.footer then
-- bare footer (bag money line, etc.)
local flat = {}
-49
View File
@@ -1,49 +0,0 @@
-- 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
+2 -5
View File
@@ -120,17 +120,14 @@ function MoveLearnMenu:finish(learned)
local opts
if learned then
-- pokered pages this as four texts in a row; _ForgotAndText carries
-- 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).
-- the "And..." tail
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 = { pauseSounds = { "Swap" }, auto = { sound = function()
opts = { auto = { sound = function()
return require("src.core.Sound").play(game.data, self.learnedSound)
end, wait = true } }
else
+3 -19
View File
@@ -7,18 +7,12 @@
-- 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)
@@ -33,7 +27,7 @@ function OptionRows.clampScroll(index, scroll, total, bottomRow)
end
-- one bordered box per row, label line + value line, with the fixed
-- bottom line below (BACK in the options menu, the manager's footer)
-- bottom line below (CANCEL 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)
@@ -43,18 +37,8 @@ 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)
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)
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)
if i == index then
Font.drawCode(Theme.cursor, 8, ((slot - 1) * 4 + 1) * 8)
end
+22 -138
View File
@@ -11,12 +11,10 @@
-- 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")
@@ -129,6 +127,14 @@ 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
@@ -335,14 +341,15 @@ 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,
activate = function(g)
require("src.ui.Screens").push(g, "PaletteScreen")
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
end },
{ id = "tilt", label = Strings("TILT"),
value = function(g) return Tilt.levelLabel(g.save.options.tilt or 0) end,
@@ -369,16 +376,6 @@ 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"))
@@ -646,90 +643,8 @@ 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
@@ -738,44 +653,14 @@ function OptionsMenu.new(game, opts)
Logger.error("ui.options.rows returned %s; keeping the vanilla rows",
type(hooked))
end
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
return setmetatable({ game = game, rows = rows, index = 1, scroll = 0,
onCancel = opts.onCancel }, OptionsMenu)
end
function OptionsMenu:update(dt)
local input = self.game.input
local rows = self.view or self.rows
-- BACK sits below the hook-built rows so a mod cannot orphan the exit
local rows = self.rows
-- CANCEL 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
@@ -817,15 +702,14 @@ function OptionsMenu:update(dt)
end
function OptionsMenu:draw()
-- Through Strings, like every other label on this menu. BACK is
-- Through Strings, like every other label on this menu. CANCEL 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.
local rows = self.view or self.rows
OptionRows.draw(self.game, rows, self.index, self.scroll or 0,
Strings("BACK"), #rows + 1)
OptionRows.draw(self.game, self.rows, self.index, self.scroll or 0,
Strings("CANCEL"), #self.rows + 1)
end
return OptionsMenu
-578
View File
@@ -1,578 +0,0 @@
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
+5 -26
View File
@@ -11,7 +11,6 @@
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")
@@ -72,12 +71,6 @@ 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)
@@ -713,9 +706,6 @@ 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,
@@ -795,11 +785,7 @@ 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 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
if mon.level < 100 then
HudTiles.tile(0x6E, 104, y) -- <LV>
Font.draw(tostring(mon.level), 112, y)
else
@@ -887,19 +873,12 @@ function PartyMenu:draw()
end
if self.submenu then
local n = #self.subItems
-- 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
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
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, (lx + 1) * 8, y0 + (si - 1) * 16)
Font.draw(entry.label, 88, y0 + (si - 1) * 16)
end
Font.drawCode(Theme.cursor, lx * 8, y0 + (self.subIndex - 1) * 16)
Font.drawCode(Theme.cursor, 80, y0 + (self.subIndex - 1) * 16)
end
love.graphics.setColor(1, 1, 1, 1)
end
+3 -46
View File
@@ -11,8 +11,6 @@ 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 = {}
@@ -38,20 +36,9 @@ 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).
@@ -89,21 +76,11 @@ end
local function withdraw(game)
local pc = game.save.pcItems
-- 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), {
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", 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
@@ -135,21 +112,11 @@ 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)
-- 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), {
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", 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.")
@@ -167,21 +134,11 @@ end
local function toss(game)
local pc = game.save.pcItems
-- 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), {
game.stack:push(ListMenu.new(game, "TOSS ITEM", 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!")
+83 -274
View File
@@ -1,38 +1,9 @@
-- Pokédex CONTENTS screen: engine/menus/pokedex.asm:157-348
-- (Yellow: pokeyellow engine/menus/pokedex.asm:263-336).
-- Minimal Pokédex: dex-ordered list with seen/owned markers.
local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu")
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)
@@ -41,270 +12,108 @@ 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)
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
for n = 1, constants.dexSize or 151 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
maxSeen = n
else
label = (numFmt .. " -----"):format(n)
end
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),
table.insert(items, {
label = label,
-- 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 = known and def.id or nil,
value = (dex.owned[def.id] or dex.seen[def.id]) 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 },
}
end
end
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)
-- 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
end
return PokedexMenu
+23 -14
View File
@@ -192,9 +192,9 @@ function SlotMachine.new(game, lucky)
local self = setmetatable({}, SlotMachine)
self.game = game
self.wheels = game.data.field.slotWheels
-- bet | spinup | spin | reroll | flash | message | payout | onemore
-- PromptUserToPlaySlots: engine/slots/slot_machine.asm:9-23
self.stage = "bet"
-- intro | bet | spinup | spin | reroll | flash | message | payout | onemore
-- PromptUserToPlaySlots asks "Want to play?" before the session starts.
self.stage = "intro"
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
-- the "One more go?" YES/NO prompt.
-- YES/NO prompt shared by the intro ("Want to play?") and "One more go?".
function SlotMachine:updateYesNo(onYes)
local input = self.game.input
if input:wasPressed("up") or input:wasPressed("down") then
@@ -435,6 +435,12 @@ 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
@@ -632,7 +638,9 @@ end
-- menu on the right (like MainSlotMachineLoop's TextBoxBorder + menus).
function SlotMachine:drawBottom()
local lines
if self.stage == "bet" then
if self.stage == "intro" then
lines = { "A slot machine!", "Want to play?" }
elseif self.stage == "bet" then
lines = { "Bet how many", "coins?" }
elseif self.stage == "onemore" then
lines = { "One more", "go?" }
@@ -653,21 +661,22 @@ function SlotMachine:drawBottom()
Font.draw(lines[1] or "", 8, 14 * 8)
Font.draw(lines[2] or "", 8, 16 * 8)
if self.stage == "bet" then
-- hlcoord 14,11 with b=5 c=4 -- engine/slots/slot_machine.asm:83-86
Font.drawBox(14, 11, 6, 7)
Font.drawBox(14, 11, 6, 5)
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 == "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)
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)
love.graphics.setColor(0, 0, 0, 1)
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)
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)
end
love.graphics.setColor(1, 1, 1, 1)
end
+2 -2
View File
@@ -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.startMenuIndex or 1, #items)
menu.index = math.min(game.save.startMenuIndex or 1, #items)
menu:clampScroll()
local baseUpdate = menu.update
menu.update = function(self, dt)
baseUpdate(self, dt)
game.startMenuIndex = self.index
game.save.startMenuIndex = self.index
end
-- inside the Safari Zone the start menu also shows remaining steps and
+3 -10
View File
@@ -12,7 +12,6 @@ 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")
@@ -138,9 +137,7 @@ 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
if LevelDisplay.visible(mon, "summary", self.game) then -- RFC 0019
printLevel(14, 2, mon.level)
end
printLevel(14, 2, mon.level)
drawLineBox(19, 1, 6, 10)
-- engine/pokemon/status_screen.asm:120-125
local PaletteFX = require("src.render.PaletteFX")
@@ -199,12 +196,8 @@ 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)
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
HudTiles.statusTile(0x70, 112, 48) -- '<to>' at (14,6), was missing (#280)
printLevel(16, 6, math.min(100, mon.level + 1))
Font.drawBox(0, 8, 20, 10)
for i = 1, 4 do
local mv = mon.moves[i]
+27 -43
View File
@@ -130,25 +130,6 @@ 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)
@@ -243,18 +224,32 @@ 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
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")
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
end
self.sel = 1
-- LoadTownMap_Fly always opens with hl on wFlyLocationsList[0], the FIRST
@@ -407,19 +402,8 @@ 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 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
if GameVersion.generation() == 1 then
showCursor = self.blink < 25
else
showCursor = self.blink % 16 < 10
+5 -2
View File
@@ -18,7 +18,6 @@
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")
@@ -165,8 +164,12 @@ 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()
Chrome.paletteFill(0, 0, SCREEN_W, SCREEN_H)
local G = love.graphics
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H)
end
-- One reusable quad, re-aimed per scanline. A row shifted by `dx` is drawn
+35 -91
View File
@@ -605,66 +605,23 @@ 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.name(Unown.monLetter(mon))]
local entry = def.letters[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 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]
local cached = self.picCache[data.sheet]
if cached == nil then
local ok, image = pcall(Assets.image, sheet)
local ok, image = pcall(Assets.image, data.sheet)
cached = ok and image or false
self.picCache[sheet] = cached
self.picCache[data.sheet] = cached
end
if not cached then return end
local runner = MonAnim.new(data, "battle")
@@ -3563,7 +3520,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.printThrough(self:name(enemy), 1, 0, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print(self:name(enemy), 1, 0)
-- 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
@@ -3571,12 +3528,10 @@ 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.printThrough(self:statusTag(enemy, "enemy")
or ("<LV>" .. tostring(enemy.level or 1)), 6, 1, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print(self:statusTag(enemy, "enemy")
or ("<LV>" .. tostring(enemy.level or 1)), 6, 1)
local enemyGender = self:genderSymbol(enemy)
if enemyGender then
Chrome.printThrough(enemyGender, 9, 1, Chrome.DEFAULT_BOX_PALETTE)
end
if enemyGender then Chrome.print(enemyGender, 9, 1) 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
@@ -3625,20 +3580,17 @@ function BattleState:drawHud()
Font.useBattleExtra(wasBattle)
return
end
Chrome.printThrough(self:name(player), 10, 7, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print(self:name(player), 10, 7)
-- PrintPlayerHUD places the same status tag at (14,8) and skips the level
-- while it is up.
Chrome.printThrough(self:statusTag(player, "player")
or ("<LV>" .. tostring(self.shownLevel or player.level or 1)), 14, 8,
Chrome.DEFAULT_BOX_PALETTE)
Chrome.print(self:statusTag(player, "player")
or ("<LV>" .. tostring(self.shownLevel or player.level or 1)), 14, 8)
local playerGender = self:genderSymbol(player)
if playerGender then
Chrome.printThrough(playerGender, 17, 8, Chrome.DEFAULT_BOX_PALETTE)
end
if playerGender then Chrome.print(playerGender, 17, 8) end
self:drawHpBar(player, "player", 10, 9)
local maxHp = player.maxHp or (player.stats and player.stats.hp) or 0
Chrome.printRightThrough(("%d/%d"):format(self:hudHp(player, "player"), maxHp),
18, 10, Chrome.DEFAULT_BOX_PALETTE)
Chrome.printRight(("%d/%d"):format(self:hudHp(player, "player"), maxHp),
18, 10)
-- 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
@@ -3707,8 +3659,8 @@ end
function BattleState:printMessage()
local lines = Chrome.wrap(self.message or "", TEXT_WIDTH)
for i = 1, math.min(#lines, TEXT_ROWS) do
Chrome.printThrough(lines[i], TEXT_INNER_X,
TEXT_INNER_Y + (i - 1) * TEXT_ROW_STEP, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print(lines[i], TEXT_INNER_X,
TEXT_INNER_Y + (i - 1) * TEXT_ROW_STEP)
end
end
@@ -3718,17 +3670,15 @@ 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.printThrough("Disabled!", 1, 10, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print("Disabled!", 1, 10)
return
end
local def = self.game and self.game.data and self.game.data.moves
and self.game.data.moves[move.id]
Chrome.printThrough("TYPE/", 1, 9, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print("TYPE/", 1, 9)
local moveType = def and def.type
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)
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)
end
function BattleState:drawPanel()
@@ -3737,7 +3687,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.printThrough("NO BATTLE", 1, 1, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print("NO BATTLE", 1, 1)
return
end
self:drawHud()
@@ -3769,10 +3719,8 @@ 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.cursorThrough(tx - 1, ty, Chrome.DEFAULT_BOX_PALETTE)
end
Chrome.printThrough(label, tx, ty, Chrome.DEFAULT_BOX_PALETTE)
if i == self.menuIndex then Chrome.cursor(tx - 1, ty) end
Chrome.print(label, tx, ty)
end
elseif self.phase == "moves"
or (self.phase == "choose-forget" and (self.messageTimer or 0) <= 0) then
@@ -3792,24 +3740,21 @@ 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.cursorThrough(cursorCol, ty, Chrome.DEFAULT_BOX_PALETTE)
end
if i == cursorRow then Chrome.cursor(cursorCol, ty) 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.printThrough("\u{25B7}", cursorCol, ty, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print("\u{25B7}", cursorCol, ty)
end
local def = self.game and self.game.data and self.game.data.moves
and self.game.data.moves[move.id]
Chrome.printThrough((def and def.name) or move.id, nameCol, ty,
Chrome.DEFAULT_BOX_PALETTE)
Chrome.print((def and def.name) or move.id, nameCol, ty)
if not moveMenu then
Chrome.printRightThrough(("%d/%d"):format(move.pp or 0, move.maxPp or 0),
19, ty, Chrome.DEFAULT_BOX_PALETTE)
Chrome.printRight(("%d/%d"):format(move.pp or 0, move.maxPp or 0),
19, ty)
end
end
if moveMenu then self:drawMoveInfoBox(moves[cursorRow]) end
@@ -3828,14 +3773,13 @@ 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.printThrough("YES", left + 2, 8, Chrome.DEFAULT_BOX_PALETTE)
Chrome.printThrough("NO", left + 2, 10, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print("YES", left + 2, 8)
Chrome.print("NO", left + 2, 10)
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.cursorThrough(left + 1, index == 1 and 8 or 10,
Chrome.DEFAULT_BOX_PALETTE)
Chrome.cursor(left + 1, index == 1 and 8 or 10)
end
end
if self.phase == "stats-box" and self.statsBoxMon then
@@ -3864,9 +3808,8 @@ 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.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)
Chrome.print(Strings(row[1]), 11, ty)
Chrome.printRight(("%d"):format(stats[row[2]] or 0), 19, ty + 1)
end
end
@@ -3950,7 +3893,8 @@ end
function BattleState:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 1, 1, 1)
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
G.push()
+2 -1
View File
@@ -945,7 +945,8 @@ end
function BoxMenu:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 1, 1, 1)
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
+2 -1
View File
@@ -968,7 +968,8 @@ end
function CardFlip:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 1, 1, 1)
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
+2 -1
View File
@@ -309,7 +309,8 @@ end
function CenterPcMenu:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 1, 1, 1)
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
+41 -156
View File
@@ -27,39 +27,13 @@ Chrome.SCREEN_H = 18
-- charmap.asm "¥", the money field's own prefix tile.
local YEN = "\xc2\xa5"
function Chrome.paletteFill(px, py, pw, ph, palette)
palette = palette or Chrome.DEFAULT_BOX_PALETTE
function Chrome.clear()
local G = love.graphics
G.setColor(1, 1, 1, 1)
if GbcPalette.available() then
GbcPalette.with(palette, function() G.rectangle("fill", px, py, pw, ph) end)
else
G.rectangle("fill", px, py, pw, ph)
end
G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8)
G.setColor(0, 0, 0, 1)
end
-- Every drawWidescreen paints the window around its 160x144 panel through
-- here, so UI LETTERBOX has exactly one place to override. r/g/b is what the
-- screen was authored with and is what AUTO keeps.
local function letterboxPaper()
local c = GbcPalette.resolve(Chrome.DEFAULT_BOX_PALETTE)
c = c and c[1]
if not c then return nil end
return c[1] / 255, c[2] / 255, c[3] / 255
end
function Chrome.letterbox(winW, winH, r, g, b)
local Letterbox = require("src.render.Letterbox")
local G = love.graphics
G.setColor(Letterbox.fill(r or 1, g or 1, b or 1, letterboxPaper))
G.rectangle("fill", 0, 0, winW, winH)
end
function Chrome.clear()
Chrome.paletteFill(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8)
end
-- The blit scale every `drawWidescreen` paints its 160x144 panel at.
--
-- One GB pixel has to cover a WHOLE number of window pixels or the 8x8 grid
@@ -103,24 +77,10 @@ function Chrome.positionLift(winW, winH, scale)
or Chrome.fitScale(winW, winH)), ScreenPosition.safeTop())
end
Chrome.DEFAULT_BOX_PALETTE = {
{ 255, 255, 255 }, { 255, 255, 255 }, { 255, 255, 255 }, { 0, 0, 0 },
}
function Chrome.paletteBox(tx, ty, tw, th, palette)
palette = palette or Chrome.DEFAULT_BOX_PALETTE
if GbcPalette.available() then
love.graphics.setColor(1, 1, 1, 1)
GbcPalette.with(palette, function() Font.drawBox(tx, ty, tw, th) end)
else
Font.drawBox(tx, ty, tw, th, palette[1])
end
love.graphics.setColor(0, 0, 0, 1)
end
-- A bordered box, tile coords. Leaves the draw color black for text.
function Chrome.box(tx, ty, tw, th)
Chrome.paletteBox(tx, ty, tw, th)
Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1)
end
-- Gold's Textbox helper takes an interior width/height and draws the border
@@ -130,16 +90,11 @@ function Chrome.textbox(tx, ty, interiorW, interiorH)
Chrome.box(tx, ty, interiorW + 2, interiorH + 2)
end
local function flatPrint(text, tx, ty)
function Chrome.print(text, tx, ty)
love.graphics.setColor(0, 0, 0, 1)
return Font.draw(text, tx * 8, ty * 8)
end
function Chrome.print(text, tx, ty)
if not GbcPalette.available() then return flatPrint(text, tx, ty) end
return Chrome.printThrough(text, tx, ty, Chrome.DEFAULT_BOX_PALETTE)
end
-- Prints a string the way a tilemap screen does: the glyph tiles replace the
-- cells they land on, and both their ink and their blank pixels go through
-- that screen's own BG palette.
@@ -169,39 +124,32 @@ function Chrome.throughPalette(palette, invert)
return GbcPalette.remap(pal, GbcPalette.bgp)
end
function Chrome.rawPalette(palette, invert)
if invert then return { palette[4], palette[3], palette[2], palette[1] } end
return palette
end
-- The per-glyph shader/tint switch printThrough needs, factored out so a
-- caller that positions its own glyphs (TextBox's typewriter/scroll
-- animation) gets the same shaded ink without re-deriving the TTF-vs-tile
-- split.
--
-- Returns (pal, drawGlyph, finish):
-- pal the resolved ramp, or nil if there's no shader to draw
-- through; a caller that also draws a paper rect should skip
-- it and take the plain Chrome.print degrade instead.
-- drawGlyph (code, x, y): draws one glyph, shaded through `pal` for a
-- tile glyph or tinted with pal[4] for a TTF one (see the
-- inline comment below, gen1recomp#1642).
-- finish call once after the last glyph to restore the previous
-- shader and draw colour.
function Chrome.paletteGlyphs(palette, invert, raw)
-- `palette` is a 4-colour table of 0-255 triples. `invert` is what
-- Chrome.printInverted passes; see there.
function Chrome.printThrough(text, tx, ty, palette, invert)
if not (palette and GbcPalette.available()) then
return nil, Font.drawCode, function() end
return Chrome.print(text, tx, ty)
end
-- `pal` is used below and never touches the caller's palette again --
-- including useRaw for the draw, since useRaw skips the fold GbcPalette.use
-- would otherwise apply and folding it a second time would undo this.
local pal = raw and Chrome.rawPalette(palette, invert)
or Chrome.throughPalette(palette, invert)
local pal = Chrome.throughPalette(palette, invert)
local width = Font.width(text)
local paper = pal[1] or { 255, 255, 255 }
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
love.graphics.rectangle("fill", tx * 8, ty * 8, width, 8)
-- Per glyph, not per string: a TTF-mod build still keeps the multi-byte
-- charmap sequences (the naming screen's <PK>/<MN>, the 'd/'l/'s ligatures)
-- and anything the mod names in ttf.tiles (Font.lua's own note on keeping
-- digits tile-based for column alignment) on their ROM tiles, so a string
-- can freely mix the two kinds of glyph.
local ink = pal[4] or { 0, 0, 0 }
local previous = love.graphics.getShader()
local shaded = false
local function drawGlyph(code, x, y)
local pen = tx * 8
for _, code in ipairs(Font.encode(text)) do
if code >= Font.TTF_BASE then
-- The shader below recovers a shade from the RED CHANNEL of an already
-- flat-shaded 2bpp tile sheet -- exactly what a TTF glyph is not.
-- LÖVE's font rasterizer stores glyph coverage as alpha over a plain
-- white texture, which this shader reads back as shade 0 no matter how
@@ -219,28 +167,11 @@ function Chrome.paletteGlyphs(palette, invert, raw)
GbcPalette.useRaw(pal)
shaded = true
end
Font.drawCode(code, x, y)
end
local function finish()
if shaded then love.graphics.setShader(previous) end
love.graphics.setColor(0, 0, 0, 1)
end
return pal, drawGlyph, finish
end
function Chrome.printThrough(text, tx, ty, palette, invert, raw)
local pal, drawGlyph, finish = Chrome.paletteGlyphs(palette, invert, raw)
if not pal then return Chrome.print(text, tx, ty) end
local width = Font.width(text)
local paper = pal[1] or { 255, 255, 255 }
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
love.graphics.rectangle("fill", tx * 8, ty * 8, width, 8)
local pen = tx * 8
for _, code in ipairs(Font.encode(text)) do
drawGlyph(code, pen, ty * 8)
Font.drawCode(code, pen, ty * 8)
pen = pen + Font.advanceOf(code)
end
finish()
if shaded then love.graphics.setShader(previous) end
love.graphics.setColor(0, 0, 0, 1)
return width
end
@@ -255,34 +186,12 @@ end
-- Right-aligned within a field that ends at tile `txEnd` (exclusive), which is
-- how Gold prints numbers (PrintNum fills from the right).
local function flatPrintRight(text, txEnd, ty)
function Chrome.printRight(text, txEnd, ty)
local width = Font.width(text)
love.graphics.setColor(0, 0, 0, 1)
return Font.draw(text, txEnd * 8 - width, ty * 8)
end
function Chrome.printRight(text, txEnd, ty)
if not GbcPalette.available() then return flatPrintRight(text, txEnd, ty) end
return Chrome.printRightThrough(text, txEnd, ty, Chrome.DEFAULT_BOX_PALETTE)
end
function Chrome.printRightThrough(text, txEnd, ty, palette, invert, raw)
local pal, drawGlyph, finish = Chrome.paletteGlyphs(palette, invert, raw)
if not pal then return Chrome.printRight(text, txEnd, ty) end
local width = Font.width(text)
local tx = txEnd * 8 - width
local paper = pal[1] or { 255, 255, 255 }
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
love.graphics.rectangle("fill", tx, ty * 8, width, 8)
local pen = tx
for _, code in ipairs(Font.encode(text)) do
drawGlyph(code, pen, ty * 8)
pen = pen + Font.advanceOf(code)
end
finish()
return width
end
-- Wrap text to `width` tiles, measuring with the real font so a proportional
-- page wraps where it actually overflows. Nothing on a 160px screen may print
-- past tile 20: text that does is drawn outside the GB frame entirely.
@@ -318,27 +227,21 @@ function Chrome.printWrapped(text, tx, ty, width, rows)
return #lines
end
local function flatCursor(tx, ty, hollow)
function Chrome.cursor(tx, ty, hollow)
love.graphics.setColor(0, 0, 0, 1)
Font.drawCode(hollow and Chrome.CURSOR_HOLLOW or Chrome.CURSOR, tx * 8, ty * 8)
end
function Chrome.cursor(tx, ty, hollow)
if not GbcPalette.available() then return flatCursor(tx, ty, hollow) end
return Chrome.cursorThrough(tx, ty, Chrome.DEFAULT_BOX_PALETTE, nil, hollow)
end
-- The cursor glyph through a palette, the way Chrome.printThrough draws text.
-- A screen whose font page is inverted (the #DEX: Pokedex_LoadInvertedFont
-- xors both bitplanes, so it runs white on black) needs its cursor inverted
-- with everything else -- Chrome.cursor's flat black is invisible against that
-- ground, which is what made the dex's action arrow look absent.
function Chrome.cursorThrough(tx, ty, palette, invert, hollow, raw)
function Chrome.cursorThrough(tx, ty, palette, invert, hollow)
if not (palette and GbcPalette.available()) then
return Chrome.cursor(tx, ty, hollow)
end
local pal = raw and Chrome.rawPalette(palette, invert)
or Chrome.throughPalette(palette, invert)
local pal = Chrome.throughPalette(palette, invert)
local paper = pal[1] or { 255, 255, 255 }
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
love.graphics.rectangle("fill", tx * 8, ty * 8, 8, 8)
@@ -383,8 +286,8 @@ end
-- PRINTNUM_LEADINGZEROS.
function Chrome.coinBalanceBox(coins)
Chrome.textbox(11, 0, 7, 1)
Chrome.printThrough("COIN", 12, 0, Chrome.DEFAULT_BOX_PALETTE)
Chrome.printThrough(Chrome.number(coins, 4, true), 13, 1, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print("COIN", 12, 0)
Chrome.print(Chrome.number(coins, 4, true), 13, 1)
end
-- DisplayMoneyAndCoinBalance: one Textbox at (5,0) with a 13x3 interior
@@ -392,10 +295,10 @@ end
-- "COIN" at (6,3) with the four-digit count at (15,3).
function Chrome.moneyAndCoinBalanceBox(money, coins)
Chrome.textbox(5, 0, 13, 3)
Chrome.printThrough("MONEY", 6, 1, Chrome.DEFAULT_BOX_PALETTE)
Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE)
Chrome.printThrough("COIN", 6, 3, Chrome.DEFAULT_BOX_PALETTE)
Chrome.printThrough(Chrome.number(coins, 4, true), 15, 3, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print("MONEY", 6, 1)
Chrome.print(Chrome.money(money), 12, 1)
Chrome.print("COIN", 6, 3)
Chrome.print(Chrome.number(coins, 4, true), 15, 3)
end
-- PlaceMoneyTopRight: MoneyTopRightMenuHeader is `menu_coords 11, 0,
@@ -403,7 +306,7 @@ end
-- prints the number at MenuBoxCoord2Tile + SCREEN_WIDTH + 1, i.e. (12,1).
function Chrome.moneyBalanceBox(money)
Chrome.box(11, 0, 9, 3)
Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print(Chrome.money(money), 12, 1)
end
-- A vertical cursor list.
@@ -441,7 +344,6 @@ function List.new(opts)
self.onChoose = opts.onChoose
self.onCancel = opts.onCancel
self.onMove = opts.onMove
self.palette = opts.palette == nil and Chrome.DEFAULT_BOX_PALETTE or opts.palette
self.index = math.max(1, math.min(opts.index or 1, math.max(1, #self.items)))
self.scroll = 0
self:ensureVisible()
@@ -507,32 +409,15 @@ function List:draw()
local item = self.items[i]
if item then
local ty = self.y + (row - 1) * self.spacing
if i == self.index then
if self.palette then
Chrome.cursorThrough(self.x - 1, ty, self.palette)
else
flatCursor(self.x - 1, ty)
end
end
if self.palette then
Chrome.printThrough(item.label, self.x, ty, self.palette)
else
flatPrint(item.label, self.x, ty)
end
if i == self.index then Chrome.cursor(self.x - 1, ty) end
Chrome.print(item.label, self.x, ty)
end
end
-- Scrolling lists get the ▼ hint Gold shows when there is more below.
if self.rows < #self.items and self.scroll + self.rows < #self.items then
if self.palette then
local _, drawGlyph, finish = Chrome.paletteGlyphs(self.palette)
drawGlyph(Chrome.DOWN_ARROW, (self.x - 1) * 8,
(self.y + self.rows * self.spacing - 1) * 8)
finish()
else
love.graphics.setColor(0, 0, 0, 1)
Font.drawCode(Chrome.DOWN_ARROW, (self.x - 1) * 8,
(self.y + self.rows * self.spacing - 1) * 8)
end
love.graphics.setColor(0, 0, 0, 1)
Font.drawCode(Chrome.DOWN_ARROW, (self.x - 1) * 8,
(self.y + self.rows * self.spacing - 1) * 8)
end
end
+2 -1
View File
@@ -176,7 +176,8 @@ end
function ContestMenu:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 1, 1, 1)
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
+2 -1
View File
@@ -841,7 +841,8 @@ end
function Credits:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 0, 0, 0)
G.setColor(0, 0, 0, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
+2 -1
View File
@@ -1275,7 +1275,8 @@ end
function CrystalIntro:drawWidescreen(winW, winH)
local G = love.graphics
local fill = self:surroundColor()
Chrome.letterbox(winW, winH, fill[1] / 255, fill[2] / 255, fill[3] / 255)
G.setColor(fill[1] / 255, fill[2] / 255, fill[3] / 255, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
+2 -1
View File
@@ -410,7 +410,8 @@ end
function CrystalSplash:drawWidescreen(winW, winH)
local G = love.graphics
local backdrop = GbcPalette.color(self.bgColors, 1) or BLACK
Chrome.letterbox(winW, winH, backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255)
G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
+2 -1
View File
@@ -263,7 +263,8 @@ end
function DecorationMenu:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 1, 1, 1)
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
+2 -1
View File
@@ -180,7 +180,8 @@ function Diploma:drawWidescreen(winW, winH)
-- Nil palette degrades to the DMG ramp's colour 1, i.e. white, matching the
-- Chrome.clear() the panel falls back to when the cache has no gfx/diploma.
local paper = GbcPalette.color(self:palette(), 1)
Chrome.letterbox(winW, winH, paper[1] / 255, paper[2] / 255, paper[3] / 255)
G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
G.rectangle("fill", 0, 0, winW, winH)
G.setColor(1, 1, 1, 1)
local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
+2 -1
View File
@@ -440,7 +440,8 @@ end
function EggHatchAnim:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 1, 1, 1)
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
+2 -1
View File
@@ -567,7 +567,8 @@ end
function EvolutionAnim:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 1, 1, 1)
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
+2 -1
View File
@@ -369,7 +369,8 @@ end
function GameFreakPresents:drawWidescreen(winW, winH)
local G = love.graphics
local backdrop = GbcPalette.color(self.bgColors, 1) or BLACK
Chrome.letterbox(winW, winH, backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255)
G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
+2 -1
View File
@@ -125,7 +125,8 @@ end
function GenderSelect:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 1, 1, 1)
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
G.push()
+2 -1
View File
@@ -1029,7 +1029,8 @@ end
function GoldSilverIntro:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 0, 0, 0)
G.setColor(0, 0, 0, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
+2 -1
View File
@@ -668,7 +668,8 @@ end
function HallOfFame:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 0, 0, 0)
G.setColor(0, 0, 0, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
+2 -1
View File
@@ -358,7 +358,8 @@ end
function InitClock:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 1, 1, 1)
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
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