Merge pull request #1837 from bryanthaboi/dev

palettes. menu reordering. etc.
This commit is contained in:
bryanthaboi
2026-08-26 13:08:07 -04:00
committed by GitHub
136 changed files with 7556 additions and 667 deletions
+50
View File
@@ -333,6 +333,56 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 7 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: headless:
name: headless suites (no ROM) name: headless suites (no ROM)
runs-on: ubuntu-latest runs-on: ubuntu-latest
+171 -5
View File
@@ -143,7 +143,7 @@ jobs:
linux-arm64: linux-arm64:
name: build Linux arm64 AppImage name: build Linux arm64 AppImage
needs: [version, love-payload] needs: [version, love-payload, shaderfx-bridge]
# GitHub's free arm64 runner for public repos. It has to be arm64: the # GitHub's free arm64 runner for public repos. It has to be arm64: the
# AppImage compiles LÖVE natively inside a Debian bullseye arm64 # AppImage compiles LÖVE natively inside a Debian bullseye arm64
# container, and the qemu-emulated alternative takes hours. # container, and the qemu-emulated alternative takes hours.
@@ -155,13 +155,22 @@ jobs:
with: with:
name: gen1recomp-release-love name: gen1recomp-release-love
path: .bazinga/work 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 - name: Build Linux arm64 AppImage
env:
SHADERFX_BRIDGE_REQUIRED: "1"
run: | run: |
set -euo pipefail set -euo pipefail
scripts/build_linux_arm64.sh \ scripts/build_linux_arm64.sh \
--version "${{ needs.version.outputs.version }}" \ --version "${{ needs.version.outputs.version }}" \
--game-love .bazinga/work/game.love --game-love .bazinga/work/game.love
- name: Verify the AppImage is self-contained and bullseye-compatible - 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" run: bash scripts/linux-arm64/verify_appimage.sh "dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage"
- name: Upload Linux arm64 release - name: Upload Linux arm64 release
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
@@ -173,6 +182,45 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 1 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: xbox-uwp:
name: build Xbox UWP release name: build Xbox UWP release
needs: [version, love-payload] needs: [version, love-payload]
@@ -286,8 +334,82 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 1 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
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: release:
needs: [version, love-payload, xbox-uwp, linux-arm64, native-tls-win] needs: [version, love-payload, xbox-uwp, linux-arm64, linux-flatpak, native-tls-win, shaderfx-bridge]
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }} runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
steps: steps:
@@ -309,6 +431,28 @@ jobs:
name: gen1tls-win-x64 name: gen1tls-win-x64
path: dist/native/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 # 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 # every release asset ships one identical payload (build.sh's own pack
# would omit PATCH_NOTES.md and mobile/ios/app-repo.json). # would omit PATCH_NOTES.md and mobile/ios/app-repo.json).
@@ -358,6 +502,7 @@ jobs:
- name: Build macOS + Windows + Linux - name: Build macOS + Windows + Linux
env: env:
GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll
SHADERFX_BRIDGE_REQUIRED: "1"
run: | run: |
set -euo pipefail set -euo pipefail
# Sign in-build (identity auto-detected from the temp keychain); # Sign in-build (identity auto-detected from the temp keychain);
@@ -372,6 +517,8 @@ jobs:
--game-love dist/payload/game.love --game-love dist/payload/game.love
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \ unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; } || { 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 - name: Materialize Android release signing key
env: env:
@@ -429,6 +576,8 @@ jobs:
--version "${{ needs.version.outputs.version }}" --version "${{ needs.version.outputs.version }}"
- name: Build Anbernic RG34XXSP port - name: Build Anbernic RG34XXSP port
env:
SHADERFX_BRIDGE_REQUIRED: "1"
run: | run: |
set -euo pipefail set -euo pipefail
# Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5 # Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5
@@ -442,6 +591,7 @@ jobs:
# builds, while this explicit local override keeps CI source-aligned. # builds, while this explicit local override keeps CI source-aligned.
GEN1RECOMP_SOURCE_DIR: ${{ github.workspace }} GEN1RECOMP_SOURCE_DIR: ${{ github.workspace }}
GEN1RECOMP_RELEASE_TAG: v${{ needs.version.outputs.version }} GEN1RECOMP_RELEASE_TAG: v${{ needs.version.outputs.version }}
SHADERFX_BRIDGE_REQUIRED: "1"
run: | run: |
set -euo pipefail set -euo pipefail
# Same aarch64 PortMaster-style pack for Linux ARM SBC PortMaster. The build # Same aarch64 PortMaster-style pack for Linux ARM SBC PortMaster. The build
@@ -498,6 +648,13 @@ jobs:
name: gen1recomp-linux-arm64-release name: gen1recomp-linux-arm64-release
path: dist/linux-arm64 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 - name: Stage release assets
if: github.repository == 'bryanthaboi/gen1recomp' if: github.repository == 'bryanthaboi/gen1recomp'
id: assets id: assets
@@ -509,16 +666,24 @@ jobs:
mkdir -p "$outdir" mkdir -p "$outdir"
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip" cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip" cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip" # x86_64 desktop Linux: raw AppImage (no zip wrapper).
x64_appimage="dist/linux/gen1recomp-linux-x86_64.AppImage"
[ -f "$x64_appimage" ] || { echo "::error::$x64_appimage not found (expected from scripts/build.sh linux)"; exit 1; }
cp "$x64_appimage" "$outdir/gen1recomp-${v}-linux-x86_64.AppImage"
chmod +x "$outdir/gen1recomp-${v}-linux-x86_64.AppImage"
# arm64 desktop Linux (Raspberry Pi, Armbian, arm64 VMs). Built on # arm64 desktop Linux (Raspberry Pi, Armbian, arm64 VMs). Built on
# its own runner because LÖVE publishes no aarch64 binary and the # its own runner because LÖVE publishes no aarch64 binary and the
# AppImage has to be compiled natively; ships as a runnable # AppImage has to be compiled natively; ships as a runnable
# AppImage rather than a zip so `chmod +x && ./it` just works. # AppImage so `chmod +x && ./it` just works.
arm64_appimage="dist/linux-arm64/gen1recomp-${v}-linux-arm64.AppImage" 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; } [ -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" cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
chmod +x "$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)" apk="$(find dist/android/release -name '*.apk' | head -1)"
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/release"; exit 1; } [ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/release"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk" cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
@@ -656,8 +821,9 @@ jobs:
release_files=( release_files=(
"dist/release/gen1recomp-${v}-macos.zip" "dist/release/gen1recomp-${v}-macos.zip"
"dist/release/gen1recomp-${v}-windows.zip" "dist/release/gen1recomp-${v}-windows.zip"
"dist/release/gen1recomp-${v}-linux.zip" "dist/release/gen1recomp-${v}-linux-x86_64.AppImage"
"dist/release/gen1recomp-${v}-linux-arm64.AppImage" "dist/release/gen1recomp-${v}-linux-arm64.AppImage"
"dist/release/gen1recomp-${v}-linux.flatpak"
"dist/release/gen1recomp-${v}-android.apk" "dist/release/gen1recomp-${v}-android.apk"
"dist/release/gen1recomp++-${v}-ios.ipa" "dist/release/gen1recomp++-${v}-ios.ipa"
"dist/release/gen1recomp-${v}-switch.zip" "dist/release/gen1recomp-${v}-switch.zip"
+19 -10
View File
@@ -1,19 +1,19 @@
# Gen1Recomp # Gen1Recomp
A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map A native LÖVE2D recreation of Poke Red, Blue, Gold, Silver, and Crystal. The
behavior are hand-written Lua; game data and graphics are decoded from a ROM engine and map behavior are hand-written Lua; game data and graphics are
supplied by the player. 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*** 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) [Click Here for the AI Use Disclosure!](AIDisclosure.md)
> [!CAUTION] > [!CAUTION]
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project. Also, as I assumed would eventually happen, the idiot that made that website now pumped it full of adware. Please stay away from that website. > **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository, the Discord, and https://gen1re.com are the only official sources for this project. Also, as I assumed would eventually happen, the idiot that made that website now pumped it full of adware. Please stay away from that website.
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p> <p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu) # [SUPPORT / ANNOUNCEMENTS / MODS ALL FOUND ON THE DISCORD](https://bois.icu)
<p align="center"> <p align="center">
@@ -228,18 +228,27 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made | | `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
## Linux on arm64 (Raspberry Pi) ## Linux desktop (AppImage / Flatpak)
Alongside the x86_64 `gen1recomp-*-linux.zip`, every release ships Releases ship raw AppImages (no zip wrapper) plus an optional Flatpak bundle:
`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: - `gen1recomp-*-linux-x86_64.AppImage`
- `gen1recomp-*-linux-arm64.AppImage` (Raspberry Pi 4/5, Armbian, arm64 VMs)
- `gen1recomp-*-linux.flatpak` (see [docs/linux-flatpak.md](docs/linux-flatpak.md))
```sh
chmod +x gen1recomp-*-linux-x86_64.AppImage
./gen1recomp-*-linux-x86_64.AppImage
```
```sh ```sh
chmod +x gen1recomp-*-linux-arm64.AppImage chmod +x gen1recomp-*-linux-arm64.AppImage
./gen1recomp-*-linux-arm64.AppImage ./gen1recomp-*-linux-arm64.AppImage
``` ```
LÖVE publishes no aarch64 binary of any kind, so this artifact compiles the Shared troubleshooting (FUSE, curl, portable mode): [docs/linux-appimage.md](docs/linux-appimage.md).
LÖVE publishes no aarch64 binary of any kind, so the arm64 artifact compiles the
engine — and SDL2, OpenAL and the codecs — from source inside a Debian engine — and SDL2, OpenAL and the codecs — from source inside a Debian
bullseye arm64 container. It needs only glibc 2.29+, libstdc++, freetype and 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 zlib on the host; OpenGL, X11, Wayland, KMSDRM, ALSA and PulseAudio are all
+186 -50
View File
@@ -192,6 +192,20 @@ chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \ cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/" "$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. # Drop a short license pointer for the bundled LÖVE bits.
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF' cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
@@ -203,81 +217,203 @@ EOF
# Resolve the game directory from the launcher so this works with both # Resolve the game directory from the launcher so this works with both
# PortMaster-managed ports directories. # PortMaster-managed ports directories.
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF' cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
#!/bin/bash #!/usr/bin/env bash
# gen1recomp-sbc — Linux ARM SBC / PortMaster launcher # gen1recomp-sbc — High-Performance Native ARM64 Handheld Port Launcher
# Uses SHDIR-relative paths so firmware-specific mount points do not matter. # Compatible with TrimUI Brick, TrimUI Smart Pro, Anbernic (H700/RK3566), muOS, Knulli, ArkOS, Stock OS
export HOME="${HOME:-/root}" export HOME="${HOME:-/root}"
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
SHDIR="$(cd "$(dirname "$0")" && pwd)" SHDIR="$(cd "$(dirname "$0")" && pwd)"
if [ -d "/mnt/SDCARD/Apps/PortMaster/PortMaster/" ]; then
controlfolder="/mnt/SDCARD/Apps/PortMaster/PortMaster"
elif [ -d "/mnt/SDCARD/Roms/ports/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Roms/ports/PortMaster"
elif [ -d "/mnt/SDCARD/Data/PortMaster/" ]; then
controlfolder="/mnt/SDCARD/Data/PortMaster"
elif [ -d "$SHDIR/PortMaster" ]; then
controlfolder="$SHDIR/PortMaster"
elif [ -d "/opt/system/Tools/PortMaster/" ]; then
controlfolder="/opt/system/Tools/PortMaster"
elif [ -d "/opt/tools/PortMaster/" ]; then
controlfolder="/opt/tools/PortMaster"
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
controlfolder="$XDG_DATA_HOME/PortMaster"
elif [ -d "/roms/ports/PortMaster" ]; then
controlfolder="/roms/ports/PortMaster"
else
controlfolder="/mnt/SDCARD/Roms/PORTS/PortMaster"
fi
if [ ! -f "$controlfolder/control.txt" ]; then
echo "PortMaster control.txt not found under $controlfolder" >&2
exit 1
fi
# shellcheck disable=SC1090
source "$controlfolder/control.txt"
get_controls
if [ -n "${CFW_NAME:-}" ] && [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ]; then
# shellcheck disable=SC1090
source "${controlfolder}/mod_${CFW_NAME}.txt"
fi
GAMEDIR="$SHDIR/gen1recomp-sbc" GAMEDIR="$SHDIR/gen1recomp-sbc"
CONFDIR="$GAMEDIR/conf" CONFDIR="$GAMEDIR/conf"
mkdir -p "$CONFDIR" mkdir -p "$CONFDIR"
cd "$GAMEDIR" || exit 1 cd "$GAMEDIR" || exit 1
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
# Setup POSIX logging (compatible with busybox ash/dash/bash)
exec 1>"$GAMEDIR/log.txt" 2>&1
echo "========================================="
echo "gen1recomp Handheld Native ARM64 Launcher"
echo "SHDIR: $SHDIR"
echo "GAMEDIR: $GAMEDIR"
echo "Date: $(date 2>/dev/null || echo 'N/A')"
echo "========================================="
# PortMaster's control.txt wants a home tree under /mnt/SDCARD/Data; on a
# fresh card that tree may not exist yet and its mkdir fails noisily (log.txt:
# "mkdir: can't create directory '/mnt/SDCARD/Data/home'"). Pre-create it
# before any PortMaster script runs so the failure cannot occur.
if [ -d /mnt/SDCARD ]; then
mkdir -p /mnt/SDCARD/Data/home 2>/dev/null || true
fi
# 1. Locate PortMaster control directory if present
controlfolder=""
if [ -d "/mnt/SDCARD/Apps/PortMaster/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Apps/PortMaster/PortMaster"
elif [ -d "/mnt/SDCARD/PortMaster/PortMaster" ]; then
controlfolder="/mnt/SDCARD/PortMaster/PortMaster"
elif [ -d "/mnt/SDCARD/PortMaster" ]; then
controlfolder="/mnt/SDCARD/PortMaster"
elif [ -d "/mnt/SDCARD/Apps/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Apps/PortMaster"
elif [ -d "/mnt/SDCARD/Emus/PORTS/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Emus/PORTS/PortMaster"
elif [ -d "/mnt/SDCARD/Emus/PORTMASTER" ]; then
controlfolder="/mnt/SDCARD/Emus/PORTMASTER"
elif [ -d "/mnt/SDCARD/Roms/ports/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Roms/ports/PortMaster"
elif [ -d "/mnt/SDCARD/Roms/PORTS/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Roms/PORTS/PortMaster"
elif [ -d "/mnt/SDCARD/Ports/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Ports/PortMaster"
elif [ -d "$SHDIR/PortMaster" ]; then
controlfolder="$SHDIR/PortMaster"
elif [ -d "/opt/system/Tools/PortMaster" ]; then
controlfolder="/opt/system/Tools/PortMaster"
elif [ -d "/opt/tools/PortMaster" ]; then
controlfolder="/opt/tools/PortMaster"
elif [ -d "$XDG_DATA_HOME/PortMaster" ]; then
controlfolder="$XDG_DATA_HOME/PortMaster"
elif [ -d "/mnt/SDCARD/Emus/tg5040/PORTS.pak/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Emus/tg5040/PORTS.pak/PortMaster"
elif [ -d "/roms/PORTS/PortMaster" ]; then
controlfolder="/roms/PORTS/PortMaster"
elif [ -d "/roms/ports/PortMaster" ]; then
controlfolder="/roms/ports/PortMaster"
fi
if [ -n "$controlfolder" ] && [ -f "$controlfolder/control.txt" ]; then
echo "Found PortMaster control folder: $controlfolder"
# shellcheck disable=SC1090
. "$controlfolder/control.txt"
if type get_controls >/dev/null 2>&1; then
get_controls
fi
if [ -n "${CFW_NAME:-}" ] && [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ]; then
# shellcheck disable=SC1090
. "${controlfolder}/mod_${CFW_NAME}.txt"
fi
else
echo "PortMaster control.txt not found, using standalone handheld configuration."
fi
# 2. CPU governor
# Pinning every core to performance keeps handhelds at peak clock even when gameplay
# uses only a fraction of CPU. Schedutil is the default; set POKEPORT_CPU_GOVERNOR=performance
# only for profiling or devices needing an extra boost.
CPU_GOVERNOR_OVERRIDE="${POKEPORT_CPU_GOVERNOR:-schedutil}"
CPU_GOVERNOR_STATE=""
write_cpu_governor() {
local governor_path="$1"
local governor="$2"
if [ -n "${ESUDO:-}" ]; then
printf '%s\n' "$governor" | $ESUDO tee "$governor_path" >/dev/null 2>&1 || true
else
printf '%s\n' "$governor" > "$governor_path" 2>/dev/null || true
fi
}
restore_cpu_governors() {
[ -n "$CPU_GOVERNOR_STATE" ] || return 0
while IFS='|' read -r governor_path governor; do
[ -n "$governor_path" ] || continue
write_cpu_governor "$governor_path" "$governor"
done <<< "$CPU_GOVERNOR_STATE"
CPU_GOVERNOR_STATE=""
}
apply_cpu_governors() {
[ -n "$CPU_GOVERNOR_OVERRIDE" ] || return 0
for governor_path in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
[ -f "$governor_path" ] || continue
old_governor=$(cat "$governor_path" 2>/dev/null || true)
[ -n "$old_governor" ] || continue
CPU_GOVERNOR_STATE="${CPU_GOVERNOR_STATE}${governor_path}|${old_governor}"$'\n'
write_cpu_governor "$governor_path" "$CPU_GOVERNOR_OVERRIDE"
done
echo "CPU governor requested: $CPU_GOVERNOR_OVERRIDE"
}
# 3. Environment & Low-Level OS Tuning
export POKEPORT_HANDHELD=1
export HANDHELD=1
export PORTMASTER=1
export NINTENDO_LAYOUT=1
export XDG_DATA_HOME="$CONFDIR" export XDG_DATA_HOME="$CONFDIR"
export XDG_CONFIG_HOME="$CONFDIR" export XDG_CONFIG_HOME="$CONFDIR"
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}" export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:$GAMEDIR/libs:/usr/trimui/lib:/mnt/SDCARD/System/lib:/usr/lib64:/usr/lib:${LD_LIBRARY_PATH:-}"
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}" 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}" export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64 # Audio buffer size
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true export SDL_AUDIO_SAMPLES=1024
if [ -n "${GPTOKEYB:-}" ]; then # Chip-synth sample rate. The audio worker is the dominant CPU cost while
$GPTOKEYB "love.aarch64" & # idle (music keeps playing when the screen is static), and synthesis cost
# scales linearly with the rate. 22050 Hz roughly halves audio CPU vs the
# 44100 Hz default; the Game Boy's own DAC content is well below 11 kHz, so
# the handheld speaker sounds effectively identical.
export POKEPORT_AUDIO_RATE=22050
# Idle-power render governor: after 10s with no input on static screens,
# presentation drops to IDLE_FPS, cutting idle CPU/GPU compositing ~6x.
# Any button press restores full framerate next frame.
export POKEPORT_IDLE_FPS=6
# Memory allocator tuning (limit arena fragmentation on 1GB RAM SBCs)
export MALLOC_ARENA_MAX=2
export MALLOC_TRIM_THRESHOLD_=131072
# Process scheduling & CPU affinity
renice -n -5 -p $$ >/dev/null 2>&1 || true
taskset -cp 0-3 $$ >/dev/null 2>&1 || true
ionice -c 3 -p $$ >/dev/null 2>&1 || true
# Ensure executable permissions
if [ -n "${ESUDO:-}" ]; then
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || true
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
else
chmod a+x ./bin/love.aarch64 2>/dev/null || true
fi fi
# 4. Platform helper
if type pm_platform_helper >/dev/null 2>&1; then if type pm_platform_helper >/dev/null 2>&1; then
echo "Running pm_platform_helper..."
pm_platform_helper "$GAMEDIR/bin/love.aarch64" pm_platform_helper "$GAMEDIR/bin/love.aarch64"
fi 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" ./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 if type pm_finish >/dev/null 2>&1; then
pm_finish 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 fi
exit $EXIT_CODE
EOF EOF
chmod +x "$PORT_ROOT/$LAUNCHER_NAME" chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
+14
View File
@@ -156,6 +156,20 @@ chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \ cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/" "$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. # Drop a short license pointer for the bundled LÖVE bits.
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF' cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
+10 -1
View File
@@ -62,7 +62,16 @@ function love.conf(t)
t.window.minheight = 360 t.window.minheight = 360
end end
t.version = love._os == "iOS" and "12.0" or "11.5" t.version = love._os == "iOS" and "12.0" or "11.5"
t.window.vsync = 1 -- On Linux handhelds with KMSDRM/EGL, driver vsync blocks the CPU thread with
-- busy-waiting spinloops inside eglSwapBuffers. Disabling driver vsync allows
-- love.run to perform kernel nanosleep(), dropping idle CPU from 25% to 4%.
if os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1" then
t.window.vsync = 0
else
t.window.vsync = 1
end
t.modules.audio = not companion t.modules.audio = not companion
t.modules.joystick = not companion t.modules.joystick = not companion
t.modules.physics = false t.modules.physics = false
+765
View File
@@ -0,0 +1,765 @@
return {
{ name = "BGB", palettes = {
{ "BGB_v0.3", "142c3c548c74acd494ecfccc" },
{ "Blue", "0000600000c05f5fffc0c0ff" },
{ "GB_Kiosk", "1434046c6c04bcbc1cececb4" },
{ "GB_Light", "004f3b00694a009a7100b581" },
{ "Grey", "1414145c5c5ca4a4a4ececec" },
{ "Greys_High_Contrast", "000000686868b0b0b0ffffff" },
{ "KIG_Green", "20402030703040a04050d050" },
{ "LCD_Green", "0c1c24346c548cc474e4fcd4" },
{ "Red", "600000c00000ff6060ffc0c0" },
{ "Super_GB", "311852ad2921de944affefce" },
{ "Yellow", "202010686830b0a848f8f078" },
{ "Yellow_BGB", "556f3593a73bd2cf4df4ebbf" },
{ "NGM_Brown", "483818987838d8b058f8e088" },
} },
{ name = "BGB/Inverted", palettes = {
{ "BGB_v0.3 - Inverted", "ecfcccacd494548c74142c3c" },
{ "Blue - Inverted", "c0c0ff5f5fff0000c0000060" },
{ "GB_Kiosk - Inverted", "ececb4bcbc1c6c6c04143404" },
{ "GB_Light - Inverted", "00b581009a7100694a004f3b" },
{ "Grey - Inverted", "ecececa4a4a45c5c5c141414" },
{ "Greys_High_Contrast - Inverted", "ffffffb0b0b0686868000000" },
{ "KIG_Green - Inverted", "50d05040a040307030204020" },
{ "LCD_Green - Inverted", "e4fcd48cc474346c540c1c24" },
{ "Red - Inverted", "ffc0c0ff6060c00000600000" },
{ "Super_GB - Inverted", "ffefcede944aad2921311852" },
{ "Yellow - Inverted", "f8f078b0a848686830202010" },
{ "Yellow_BGB- Inverted", "f4ebbfd2cf4d93a73b556f35" },
{ "NGM_Brown - Inverted", "f8e088d8b058987838483818" },
} },
{ name = "GalleryWebApp Palettes", palettes = {
{ "Artistic Caffeinated Lactose", "2416069e754fdea963fdfef5" },
{ "AQP Pikes Peak", "2a201efa2b25868779ebeee7" },
{ "Azure Clouds", "00000012412732b66d47ff99" },
{ "BGB Emulator", "08182034685688c070e0f8d0" },
{ "Black & White", "000000555555aaaaaaffffff" },
{ "Black Zero", "2e463d385d49577b467e8416" },
{ "CGA Palette Crush 1", "000000ff55ff55ffffffffff" },
{ "CGA Palette Crush 2", "000000ff555555ffffffffff" },
{ "CMYKeystone", "373737fb00fa0be8fdffff00" },
{ "Candy Cotton Tower Raid", "3800168f0039e65790e6aec4" },
{ "Caramel Fudge Paranoia", "3f1711b01553cf7163cf9255" },
{ "Childhood in Greenland", "405010708028a0a840d0d058" },
{ "Cyanide Blues", "0e1e3d1e479321aff59efbe3" },
{ "Deep Haze Green", "00000027421f467818a1d909" },
{ "Dies ist meine Wassermelone", "222903558429f27d7affdbcb" },
{ "Drowning at Night", "030c2220293f586164a9b0b3" },
{ "DUN 2000 Remastered", "000000725441c09e7dfbf1cd" },
{ "Flowerfeldstrasse", "171f6275868fc5c5cee9d9cc" },
{ "Floyd Steinberg in Love", "4c1c2dd23c4e5fb1f5eaf5fa" },
{ "GB Light", "0b7a6d16a59619c7b31ddece" },
{ "GB Pocket", "1f1f1f4d533c8b956dc4cfa1" },
{ "Glowing Mountains", "2f1c35514f6ca1a8b8ffbf98" },
{ "Golden Elephant Curry", "0000004f3000c27600ff9c00" },
{ "Grafixkid Gray", "2b2b26706b66a89f94e0dbcd" },
{ "Grafixkid Green", "4c625a7b9278abc396dbf4b4" },
{ "Knee-Deep in the Wood", "2c24103c3ca9d5690ffffe6e" },
{ "LNK late Awakening", "5a39216b8c427bc67bffffb5" },
{ "MTR Aran Remixed", "2c1700b62558047e60aedf1e" },
{ "My Friend from Bavaria", "382977b60077df7925feda1b" },
{ "Nortorious Comadante", "0402ac04aaac54fefcfcfe54" },
{ "Original GB", "0f380f30623077a1129bbc0f" },
{ "Purple Rain", "68006aff00848570b2adfffc" },
{ "Romeros Garden", "323727574431649a57ebc4ab" },
{ "Rusted City Rain", "2c2137764462a96868edb4a1" },
{ "Space Haze Overload", "0b06306b1fb1cc3495f8e3c4" },
{ "Starlit Memories", "4f133f6f20966d53bd869ad9" },
{ "Sunflower Holidays", "000000881400ff5555ffff55" },
{ "Super Hyper Mega GB", "331e50a63725d68e49f7e7c6" },
{ "The death of Yung Columbus", "1d1414462917ff2261b5ff32" },
{ "The starry knight", "123f770f86b637cae5f5db37" },
{ "There is Always Money", "221a09977b25fed638fdfe0a" },
{ "Tramonto al Parco degli Acquedotti", "0c0a3e912978e64a4ef3c677" },
{ "VTB 1985", "000000520000db0000ff0000" },
{ "Waterfront Plaza", "10253342678e6f9edfcecece" },
{ "Youth Ikarus reloaded", "1e00009e0000f78e50cef7f7" },
} },
{ name = "GB Color (Combo Palettes)", palettes = {
{ "GBC - Down + A", "000000ff0000ffff00ffffff" },
{ "GBC - Down + B", "0000007b4a00ffff00ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
{ "GBC - Down", "0000009494ffff9494ffffa5" },
{ "GBC - Left + A", "00000052528c8c8cdeffffff", "000000943a3aff8484ffffff", "000000843100ffad63ffffff" },
{ "GBC - Left + B", "000000525252a5a5a5ffffff" },
{ "GBC - Left", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
{ "GBC - Right + A", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC - Right + B", "ffffffffde00008484000000" },
{ "GBC - Right", "000000ff420052ff00ffffff" },
{ "GBC - Up + A", "000000943a3aff8484ffffff", "0000000084007bff31ffffff", "0000000000ff63a5ffffffff" },
{ "GBC - Up + B", "5a3108846b29ce9c84ffe6c5", "000000843100ffad63ffffff", "000000843100ffad63ffffff" },
{ "GBC - Up", "000000843100ffad63ffffff" },
} },
{ name = "GB Color (Unique Palettes)", palettes = {
{ "GBC Palette 001 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 002 (World)", "000000006300ffff00a59cff" },
{ "GBC Palette 003 (USA, Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 004 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 005 (USA)", "ffffffffde00008484000000" },
{ "GBC Palette 006 (USA, Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 007 (USA, Europe)", "000000ff0000ff9c00ffffff" },
{ "GBC Palette 008 (World)", "ffffffffff00ff840052de00", "0000ff63a5ffffffffffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 009 (USA)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 010 (Europe)", "000000843100ffad63ffffff", "0000000084007bff31ffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 011 (Europe)", "000000843100ffad63ffffff", "0000000084007bff31ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 012 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 013 (World) (Rev A)", "000000ff0000ff9c00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 014 (World)", "000000ff0000ff9c00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 015 (Japan)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "0000ffff00005abdffffffff" },
{ "GBC Palette 016 (USA, Europe)", "003a3a63947394b5ffffff9c", "4a0000943a00ffd600ffc542", "000000943a3aff8484ffffff" },
{ "GBC Palette 017 (USA, Europe)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "0000ffff00005abdffffffff" },
{ "GBC Palette 018 (USA, Europe) (Rev A)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "0000ffff00005abdffffffff" },
{ "GBC Palette 019 (USA, Europe)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "0000ffff00005abdffffffff" },
{ "GBC Palette 020 (World) (Rev A)", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 021 (World)", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 022 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 023 (World) (Rev A)", "000000843100ffad63ffffff" },
{ "GBC Palette 024 (World)", "000000843100ffad63ffffff" },
{ "GBC Palette 025 (USA)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 026 (Europe)", "000000843100ffad63ffffff" },
{ "GBC Palette 027 (Japan)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 028 (Australia)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 029 (Japan)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 030 (Japan)", "00000042737badad84ffffff", "000000944200ff7300ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 031 (Europe)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 032 (USA) (Rev A)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 033 (USA)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 034 (World)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 035 (Japan) (Rev A)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
{ "GBC Palette 036 (Japan)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
{ "GBC Palette 037 (Japan)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
{ "GBC Palette 038 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 039 (Japan)", "00000052528c8c8cdeffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 040 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 041 (USA, Europe)", "00000052528c8c8cdeffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 042 (USA, Europe)", "00000052528c8c8cdeffffff", "000000943a3aff8484ffffff", "000000843100ffad63ffffff" },
{ "GBC Palette 043 (USA)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 044 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 045 (Japan)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
{ "GBC Palette 046 (Japan)", "000000843100ffad63ffffff" },
{ "GBC Palette 047 (Japan)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "000000630000d60000ff6352" },
{ "GBC Palette 048 (USA, Europe)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
{ "GBC Palette 049 (USA, Europe)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
{ "GBC Palette 050 (USA, Europe)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
{ "GBC Palette 051 (USA, Europe)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "000000630000d60000ff6352" },
{ "GBC Palette 052 (USA, Europe)", "000000843100ffad63ffffff" },
{ "GBC Palette 053 (France)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 054 (Germany)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 055 (USA, Europe) (Rev A)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 056 (USA, Europe) (Rev B)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 057 (USA, Europe)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 058 (Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 059 (Europe)", "000000ff420052ff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 060 (Japan)", "000000843100ffad63ffffff" },
{ "GBC Palette 061 (USA, Europe)", "000000843100ffad63ffffff" },
{ "GBC Palette 062 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 063 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 064 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 065 (World)", "0000000000ff63a5ffffffff", "000000630000ff0000ffff00", "0000000084007bff31ffffff" },
{ "GBC Palette 066 (Japan)", "00000042737badad84ffffff", "000000944200ff7300ffffff", "000000944200ff7300ffffff" },
{ "GBC Palette 067 (USA, Europe)", "00000042737badad84ffffff", "000000944200ff7300ffffff", "000000944200ff7300ffffff" },
{ "GBC Palette 068 (Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 069 (France)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 070 (Germany)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 071 (Europe)", "000000843100ffad63ffffff" },
{ "GBC Palette 072 (USA, Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 073 (Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 074 (USA)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 075 (Japan)", "000000843100ffad63ffffff" },
{ "GBC Palette 076 (Europe)", "00000052528c8c8cdeffffff", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542" },
{ "GBC Palette 077 (USA)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 078 (Europe)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "4a0000943a00ffd600ffc542" },
{ "GBC Palette 079 (Japan) (Rev A)", "0000009c6300ffce00ffffff" },
{ "GBC Palette 080 (Japan) (Rev 0A)", "000000ff0000ffff00ffffff" },
{ "GBC Palette 081 (Japan) (Rev B)", "000000ff0000ffff00ffffff" },
{ "GBC Palette 082 (Japan) (Rev C)", "000000ff0000ffff00ffffff" },
{ "GBC Palette 083 (Japan) (Rev D)", "000000ff0000ffff00ffffff" },
{ "GBC Palette 084 (Japan) (Rev A)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 085 (Japan)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 086 (Japan)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 087 (Japan) (Rev A)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 088 (Japan)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 089 (Germany)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 090 (USA, Europe)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 091 (Spain)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 092 (Spain)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 093 (USA, Europe)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 094 (Germany)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 095 (France)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 096 (France)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 097 (Italy)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 098 (Italy)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 099 (World)", "000000ff0000ffff00ffffff", "000000ff0000ffff00ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 100 (Japan)", "003a3a63947394b5ffffff9c" },
{ "GBC Palette 101 (USA, Europe)", "003a3a63947394b5ffffff9c" },
{ "GBC Palette 102 (Europe) (En,Fr,De)", "000000ff524affffff6bff00", "0000ff63a5ffffffffffffff", "000000843100ffad63ffffff" },
{ "GBC Palette 103 (World)", "ffffffffde00008484000000" },
{ "GBC Palette 104 (Europe)", "ffffffffde00008484000000" },
{ "GBC Palette 105 (USA)", "ffffffffde00008484000000" },
{ "GBC Palette 106 (USA, Europe) (Rev A)", "000000843100ffad63ffffff", "0000000084007bff31ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 107 (USA, Europe) (Rev A)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 108 (Japan)", "003a3a63947394b5ffffff9c", "4a0000943a00ffd600ffc542", "000000943a3aff8484ffffff" },
{ "GBC Palette 109 (World) (Rev A)", "000000ad5a42ffff94b5b5ff", "943a3aff8484ffffff000000", "943a3aff8484ffffff000000" },
{ "GBC Palette 110 (World)", "000000ad5a42ffff94b5b5ff", "943a3aff8484ffffff000000", "943a3aff8484ffffff000000" },
{ "GBC Palette 111 (USA, Europe) (Rev A)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 112 (USA, Europe) (Rev B)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 113 (USA, Europe)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 114 (Japan) (Rev B)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 115 (Japan)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 116 (USA, Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
{ "GBC Palette 117 (World)", "000000ff524affffff6bff00", "0000ff63a5ffffffffffffff", "000000843100ffad63ffffff" },
{ "GBC Palette 118 (World) (Rev A)", "000000ff0000ffff00ffffff" },
{ "GBC Palette 119 (World)", "000000ff0000ffff00ffffff" },
{ "GBC Palette 120 (Europe)", "000000ff0000ffff00ffffff", "000000ff0000ffff00ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 121 (USA)", "000000ff0000ffff00ffffff", "000000ff0000ffff00ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 122 (USA)", "000000ff420052ff00ffffff", "000000ff420052ff00ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 123 (USA, Europe) (Rev A)", "000000ff420052ff00ffffff", "000000ff420052ff00ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 124 (USA, Europe)", "000000ff0000ff9c00ffffff" },
{ "GBC Palette 125 (Japan)", "000000ff0000ffff00ffffff", "000000ff0000ffff00ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 126 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 127 (USA)", "000000ff524affffff6bff00", "0000ff63a5ffffffffffffff", "000000843100ffad63ffffff" },
{ "GBC Palette 128 (Europe)", "000000ff524affffff6bff00", "0000ff63a5ffffffffffffff", "000000843100ffad63ffffff" },
{ "GBC Palette 129 (Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 130 (USA, Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 131 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 132 (World)", "00000042737badad84ffffff", "000000944200ff7300ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 133 (USA, Europe)", "00000042737badad84ffffff", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 134 (USA, Europe)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff", "ff00000084ffffff7bffffff" },
{ "GBC Palette 135 (Japan)", "000000525252a5a5a5ffffff" },
{ "GBC Palette 136 (Japan) (Rev A)", "000000843100ffad63ffffff" },
{ "GBC Palette 137 (Japan)", "000000843100ffad63ffffff" },
{ "GBC Palette 138 (USA)", "000000ff420052ff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 139 (Japan)", "000000ff0000ff9c00ffffff", "000000ff0000ff9c00ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 140 (Japan)", "000000ff420052ff00ffffff", "000000ff420052ff00ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 141 (Japan)", "000000ff420052ff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "GBC Palette 142 (USA, Europe)", "000000ff0000ff9c00ffffff", "000000ff0000ff9c00ffffff", "0000ffff00005abdffffffff" },
{ "GBC Palette 143 (Japan) (Rev A)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
{ "GBC Palette 144 (Japan)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
} },
{ name = "GB Color (Unused Palettes)", palettes = {
{ "Unused0", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
{ "Unused1", "000000ff0000ffff00ffffff", "0000000000ff63a5ffffffff", "0000ffff00005abdffffffff" },
{ "Unused2", "000000ad5a42ffff94b5b5ff", "943a3aff8484ffffff000000", "943a3aff8484ffffff000000" },
{ "Unused3", "ffffffffde00008484000000", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
{ "Unused4", "000000525252a5a5a5ffffff" },
{ "Unused5", "0000009c6300ffce00ffffff" },
} },
{ name = "R.A.Helllord/Limited Edition Pockets", palettes = {
{ "Classic Blue LE", "0000000232741b559e5775af" },
{ "Classic Green LE", "00000001633f0b8a68549d80" },
{ "Classic Indigo LE", "0000002631725457a47b80b4" },
{ "Classic Pink LE", "000000d8648beb8cb3efa1bf" },
{ "Classic Red LE", "00000095030dbc2b2bcb5d5b" },
{ "Classic Silver LE", "000000908a85cececee0dfde" },
{ "Classic Spice Orange LE", "000000c96722e38f48e8a36c" },
{ "Classic Yellow LE", "000000cf8b08d2921de8be63" },
{ "Glow in the Dark LE", "00000005a56d4dcc808cf68a" },
{ "Transparent Blue LE", "0000001e124f361aa6646dcb", "1e124f361aa6646dcb7f8cff", "1e124f361aa6646dcb7f8cff" },
{ "Transparent Clear LE", "0000003e3b395959599c9a99", "3e3b395959599c9a99d7d7d7", "3e3b395959599c9a99d7d7d7" },
{ "Transparent Green LE", "000000003c0600b10003ce09", "003c0600b10003ce09afe4ab", "003c0600b10003ce09afe4ab" },
{ "Transparent Orange LE", "0000003c1405ad2d01e56400", "3c1405ad2d01e56400ebbf8f", "3c1405ad2d01e56400ebbf8f" },
{ "Transparent Purple LE", "0000003c2e498d6ea7a986bb", "3c2e498d6ea7a986bbe0d6e5", "3c2e498d6ea7a986bbe0d6e5" },
{ "Transparent Red LE", "0000006d1a1a991019c51d21", "6d1a1a991019c51d21eab5b1", "6d1a1a991019c51d21eab5b1" },
{ "Transparent Smoke LE", "000000282421363333686464", "282421363333686464bfbfbe", "282421363333686464bfbfbe" },
} },
{ name = "R.A.Helllord/Limited Edition Pockets Inverted", palettes = {
{ "Classic Blue LE - inverted", "5775af1b559e023274000000" },
{ "Classic Green LE - inverted", "549d800b8a6801633f000000" },
{ "Classic Indigo LE - inverted", "7b80b45457a4263172000000" },
{ "Classic Pink LE - inverted", "efa1bfeb8cb3d8648b000000" },
{ "Classic Red LE - inverted", "cb5d5bbc2b2b95030d000000" },
{ "Classic Silver LE - inverted", "e0dfdececece908a85000000" },
{ "Classic Spice Orange LE - inverted", "e8a36ce38f48c96722000000" },
{ "Classic Yellow LE - inverted", "e8be63d2921dcf8b08000000" },
{ "Glow in the Dark LE - inverted", "8cf68a4dcc8005a56d000000" },
{ "Transparent Blue LE - inverted", "646dcb361aa61e124f000000", "7f8cff646dcb361aa61e124f", "7f8cff646dcb361aa61e124f" },
{ "Transparent Clear LE - inverted", "9c9a995959593e3b39000000", "d7d7d79c9a995959593e3b39", "d7d7d79c9a995959593e3b39" },
{ "Transparent Green LE - inverted", "03ce0900b100003c06000000", "afe4ab03ce0900b100003c06", "afe4ab03ce0900b100003c06" },
{ "Transparent Orange LE - inverted", "e56400ad2d013c1405000000", "ebbf8fe56400ad2d013c1405", "ebbf8fe56400ad2d013c1405" },
{ "Transparent Purple LE - inverted", "a986bb8d6ea73c2e49000000", "e0d6e5a986bb8d6ea73c2e49", "e0d6e5a986bb8d6ea73c2e49" },
{ "Transparent Red LE - inverted", "c51d219910196d1a1a000000", "eab5b1c51d219910196d1a1a", "eab5b1c51d219910196d1a1a" },
{ "Transparent Smoke LE - inverted", "686464363333282421000000", "bfbfbe686464363333282421", "bfbfbe686464363333282421" },
} },
{ name = "R.A.Helllord/PPB", palettes = {
{ "PPB - Amber", "ffb13b8c61216043172d1f0b" },
{ "PPB - Blue", "28ccff16708c0f4b5e07242d" },
{ "PPB - Green", "00ee00008e00005f00002f00" },
{ "PPB - White", "c1ffff6a8c8c496060202b2b" },
{ "PPB inverted - Amber", "2d1f0b6043178c6121ffb13b" },
{ "PPB inverted - Blue", "07242d0f4b5e16708c28ccff" },
{ "PPB inverted - Green", "002f00005f00008e0000ee00" },
{ "PPB inverted - White", "202b2b4960606a8c8cc1ffff" },
} },
{ name = "SMY", palettes = {
{ "Desert", "3e2f30746657a49b83d2d0b1" },
{ "Evening", "012636185569539889aee4a6" },
{ "Fake Crystal", "21293129317b4a73bd9ce7f7" },
{ "Fog", "343c3756727386a39dbfd2c3" },
{ "Magic Eggplant", "36213c842e949d69c7b0e4f1" },
{ "Radioactive Pea", "00522106731f349e16b8ce03" },
{ "Seaweed", "15003f32654278a758dfe095" },
{ "Twilight", "15003f861246bd5462e9d397" },
} },
{ name = "Super GB", palettes = {
{ "1-A (Default)", "311852ad2921de944affefce" },
{ "1-B", "000000b55210ceb573dedec6" },
{ "1-C", "39399c9c3963ef9c52ffc6ff" },
{ "1-D", "521800ff0000c6844affffad" },
{ "1-E", "5a39216b8c427bc67bffdeb5" },
{ "1-F", "004210ad0000e78c52deefff" },
{ "1-G", "ffff5a7b7b0000a5ef000052" },
{ "1-H", "311800844200ffbd8cffefe7" },
{ "2-A", "000000297b00c68c4af7cea5" },
{ "2-B", "52005aff3100ffef52ffffff" },
{ "2-C", "29299c7b31efef8c8cffc6ff" },
{ "2-D", "000052ff310000ff00ffffa5" },
{ "2-E", "10081029106394b5e7ffce84" },
{ "2-F", "180000a50000ff9452d6ffff" },
{ "2-G", "001800e7bd84e752426bbd39" },
{ "2-H", "000000737373bdbdbdffffff" },
{ "3-A", "314a63ff632973c6c6ffd69c" },
{ "3-B", "001010005200e78421dedec6" },
{ "3-C", "21215a00bdffffff7be7adce" },
{ "3-D", "00000008ce00e7ad7bf7ffbd" },
{ "3-E", "524a73b57b21e7b56bffffc6" },
{ "3-F", "424242ffd600ff6bff7b7bce" },
{ "3-G", "390000ce3139ffffff63de52" },
{ "3-H", "0818004a8c187bce39e7ffa5" },
{ "4-A", "00007bd600d67badfff7ad6b" },
{ "4-B", "180808427b39efa563f7eff7" },
{ "4-C", "0800009ca5e7dea5d6ffe7e7" },
{ "4-D", "08214a4a6b7b94ceceffffbd" },
{ "4-E", "0021317b5a8ce7ad7bffdead" },
{ "4-F", "3900008400a5de84debdd6d6" },
{ "4-G", "008463291000bd215ab5e718" },
{ "4-H", "425229848c42bdc65affffce" },
} },
{ name = "Super GB 2 - Vaporwave Edition", palettes = {
{ "SGB2VE 1-A Sunset", "1039adef2994f7a552f7f794" },
{ "SGB2VE 1-B Kimochi WRO", "4a3973bd63a5f7a56bf7efbd" },
{ "SGB2VE 1-C Pachinko", "42427b9c42eff7947bf7deef" },
{ "SGB2VE 1-D Cotton Candy", "1039adffa5ff84e7eff7f7f7" },
{ "SGB2VE 1-E Absynthe", "7b4aada5b5ffadff5affff8c" },
{ "SGB2VE 1-F Arizona", "52529cff4273ffcecedefff7" },
{ "SGB2VE 1-G Hot Vibes", "8c18f7f72973f78c18f7ce18" },
{ "SGB2VE 1-H Sorbet Whip", "63184aff6b8cffc684f7ffc6" },
{ "SGB2VE 2-A Bufotoxin", "104a63009c84ef8c84f7de9c" },
{ "SGB2VE 2-B Dank", "4a184263528463b5bdf7f794" },
{ "SGB2VE 2-C Creampuff", "632963a573f7f79cadffdef7" },
{ "SGB2VE 2-D Modern Computing", "101018ff849c6bffbdf7f7f7" },
{ "SGB2VE 2-E Uranium Glass", "4a08ef9c42ef00deded6ff9c" },
{ "SGB2VE 2-F. Tenshi", "2142e7de5aceefadceadf7c6" },
{ "SGB2VE 2-G 87 Ninjas", "42427bd63994bdb5f7c6f7de" },
{ "SGB2VE 2-H Floral", "08101852b59cff849cefe7e7" },
{ "SGB2VE 3-A Mind Game", "4200f700b5f7f784f7f7f7f7" },
{ "SGB2VE 3-B Sewer Magic", "5a00848c7be784f7c6f7f794" },
{ "SGB2VE 3-C Galactic Rabbit Trip", "4a527352a5cebdceeff7f7ef" },
{ "SGB2VE 3-D Frenemy", "1039ad42d6d6ffd6b5bdffe7" },
{ "SGB2VE 3-E Agent Orange", "632963e76b84f7a56bffe742" },
{ "SGB2VE 3-F Backstreet", "294a8cff8cbdffceb5dedeff" },
{ "SGB2VE 3-G Eccojam", "8c42a5ad7be7946cff9cefff" },
{ "SGB2VE 3-H Sakura", "4a39b5ef73d6efb5bdf7efb5" },
{ "SGB2VE 4-A Vector", "1039ad9400f700b5f7f7d684" },
{ "SGB2VE 4-B Slumber", "944ab58c8cf794ceffd6ffff" },
{ "SGB2VE 4-C Lingonberry Butter", "21529c7b84e7ffadcefff7de" },
{ "SGB2VE 4-D Zangetsu", "104a6331a5c684e7eff7efbd" },
{ "SGB2VE 4-E Icy Hot", "8c00ffff39ced6c6ffbdfff7" },
{ "SGB2VE 4-F Night Driver", "214aff6b00c6ff31ef10006b" },
{ "SGB2VE 4-G Bug Hunt", "0839633952bdf7738cf7e7ad" },
{ "SGB2VE 4-H MGB-101", "004a84107ba500bdc621ffde" },
} },
{ name = "TheWolfBunny64/American Pop Culture Pack", palettes = {
{ "Absorbent and Yellow", "343b00687600aec600fff752" },
{ "BMO Ver.", "394c3d607f6699cca3c0ffcc" },
{ "BRB Pink", "480030790050c10080f200a1" },
{ "Bedrock Caveman Vision", "002f37005e6e009eb8ff7f00" },
{ "BKB Ver.", "6060002098f048f8e0f8f880" },
{ "BLS Pink", "492e357a4d59c47c8ef59bb2" },
{ "BBS Blue", "1e3a45326274509cba64c4e9" },
{ "BTC Green", "39422a5f6e4698b070bedc8d" },
{ "DNP Silver", "33383d555d668895a3abbbcc" },
{ "Fairly OddPalette", "3d1b2d7b365bce5a997bb850" },
{ "GRF Vision", "2d1309964220e59436f5ea8b" },
{ "HGW Goldius", "3631215b523891845ab6a571" },
{ "Invincible Yellow and Blue", "113c4622788d39c9ebfee566" },
{ "NCK Orange", "4c1e007f3300cc5200ff6700" },
{ "NJT Green", "28380b435e126b961d86bc25" },
{ "OPP Palette", "0015330047abd92121d3d3d3" },
{ "PTS Pink", "4c26297f4045cc666fff808b" },
{ "Retro Bogeda", "0000006408ffff6cfffbfd1b" },
{ "Rocket Portable Power", "09122d213f99bdd539fee998" },
{ "RGR Playtime Palette", "662d91ed1c24ffde17ffffff" },
{ "Sailor Spinach Green", "2434123d581e628c307bb03c" },
{ "SCD Mystery Ver.", "2a1a318f59a5f79321c6de31" },
{ "Smurfy Blue", "0d3747165c772394bf2cb9ef" },
{ "SPB Yellow", "4a45157b7424c5ba39f7e948" },
{ "SQW Sea Foam Green", "37403d5c6b6694aca4b9d7cd" },
{ "Swampy Ogre Green", "39400d606b179aab24c1d62e" },
{ "TMT Pink", "3815205e2436963957bc486d" },
{ "UNS Blue", "0020430136710256b4036ce2" },
} },
{ name = "TheWolfBunny64/Anime and Manga Pack", palettes = {
{ "ANX BLUE", "0f2d451a4c74297ab93499e8" },
{ "ALM Hero Palette", "212f79d12021f3db43eff0f0" },
{ "Anime DGV Ver.", "1b241d2d3d3148624f5b7b63" },
{ "Anime Expo Red", "47110f771d19be2f28ee3b33" },
{ "Anime Expo Ver.", "242a2d656e729ba3a6e5eaeb" },
{ "BRS Blood", "3806065d0a0a951010bb1414" },
{ "Blue Stripes Ver.", "2d2e2e5b5d5d999b9c8bd3e1" },
{ "CDC Pink", "430427e10e82eac3d6f2f4f7" },
{ "Colorful Horizons", "373939d1525260bdc7f6cf72" },
{ "CRR Orange", "49230a7a3a11c35d1bf47522" },
{ "DKU Alpha Emerald", "11332f1c564f2d8a7e39ad9e" },
{ "DKU Gamma Palette", "24262b166668b6bec8f0e8d6" },
{ "DKU Vigilante Palette", "13131538534e878b92ada89a" },
{ "DMS Gold", "3734195d572b948c44baaf56" },
{ "DRM Tricolor", "450000e6000000a8f4ffe800" },
{ "DGB Orange", "48270878410ec06817f0831d" },
{ "DYN Hero Palette", "16161629463dc84a31d1d0cc" },
{ "EV-01", "303345765898f99b2254cf54" },
{ "EDN Academy Uniform", "483e39cc4c47dcbc76f7f8f5" },
{ "FRG Pretense Palette", "0000008d0000697d738ea99b" },
{ "GDS VISION", "4723008e4700ee77009fa0a0" },
{ "GR5 Fever", "311d3da662cdc5cedff7f7f7" },
{ "GKU GT Gi", "1221313d6ea5f0ac18dbe3e6" },
{ "GKU Gi", "061222173f72e7612cf9f0e1" },
{ "Grand Ivory", "4140396c6b5fadab98d9d6be" },
{ "GRZ Coat", "3d0b32ce26a9fce72dfbfbfa" },
{ "HKG Orange", "462718754129bb6841ea8352" },
{ "KNS Sherbet", "440021890042e5006ef08200" },
{ "KWB Blue", "00283b004462006c9d0088c5" },
{ "Legendary SSJ", "31411b536d2d84ae48a6da5b" },
{ "LSA Orange!", "461c00752f00bc4b00eb5e01" },
{ "Perfect MJN Emperor", "2718328550a9a3b453fdc1bf" },
{ "Perfected ULI", "393c4060646c99a0acc0c8d8" },
{ "PRC Marble Raspberry", "400a1b6b112eab1b49d6225c" },
{ "PTG Gold", "3633275a5541908868b4aa82" },
{ "SYN Beast Silver", "2d2e344b4d57787c8c969baf" },
{ "SHN Green", "1b3a162d6125489c3b5ac34a" },
{ "STH Red", "4a18127c291ec64130f8523c" },
{ "SSJ 3", "4a3c107c641cc6a02cf8c838" },
{ "SSJ Blue Evolved", "082d3e0d4b681578a71b97d1" },
{ "SSJ Blue", "01383d025d660496a305bccc" },
{ "SSJ God", "40001d6b0131ac024ed70362" },
{ "SSJ Rose", "4a34357b5759c58c8ff7afb3" },
{ "SSJ", "4c4b397f7e60cbc99afefcc1" },
{ "SVC Uniform", "321d1a593d34ac7c59acaba9" },
{ "SWA Cyan", "1a3a432c6171479cb459c3e2" },
{ "TMR Uniform", "474f4d755e88e94e60eeefeb" },
{ "Tri DGV Ver.", "272a2442473c697260848f79" },
{ "UAH Uniform", "0c4856a02929a4aaafededed" },
{ "UTV VISION", "262c48e24465f1a7b5f4f4f4" },
{ "ULI Sign", "1b1f212d34374853585a686f" },
{ "URM Green", "1734282657443d8b6c4dae88" },
{ "VGT Armor", "0619321554a9e3c56cefefef" },
{ "ANM vision", "231815385eaaf9be00ffffff" },
{ "HLO blue", "063f5c3368d349c4f2b0edfa" },
} },
{ name = "TheWolfBunny64/Bandai Namco Pack", palettes = {
{ "1st Vision Pastel", "101c33385ead7faf5aea7cb1" },
{ "765P Ver.", "383a445d6272959cb6bbc4e4" },
{ "765P Pink", "481720792736c23f57f34f6d" },
{ "765P TRICOLOR", "0b143f2743d2e22b30b4e04b" },
{ "ARS BLUE", "0e192e182a4e26447c30559c" },
{ "AQR Blue", "0030450050740080ba00a0e9" },
{ "BDN Tricolor", "001f350069b1df4f61f6b700" },
{ "CND Blue", "0b263c1340641e67a02681c8" },
{ "IDOL WORLD TRICOLOR!!!", "0b263c2681c8f34f6dffc30b" },
{ "LLA Purple!", "31152e52234d84387ca5469b" },
{ "MLV GOLD!", "3d351d665930a48e4dcdb261" },
{ "MLN Yellow!", "4c3a037f6105cc9c08ffc30b" },
{ "Moonlight Vision", "1010103050783890e8f8d868" },
{ "MUS Pink", "44002672003fb60065e4007f" },
{ "NMC Idol Pink", "4c22377f3a5ccc5c93ff74b8" },
{ "NJG Orange", "482d00794c00c27900f39800" },
{ "PCM-PALETTE", "420000dc0000ff8c00ffd800" },
{ "PCM Vision", "0000003732ffffb897ffff00" },
{ "PCM Yellow", "4c44007f7100ccb500ffe300" },
{ "RADIANT SMILE RAMP", "1b1c81e6016b01b3c4fff89b" },
{ "RX-78-2 GDM Mode", "333f3fa73736dbad40dbdcd5" },
{ "RYG Sunset", "2b10399238befd99e1ffe43f" },
{ "SHINY Sky Blue", "2a384c465d7f7095cc8dbbff" },
{ "SNS Red", "3911105f1c1b982d2bbf3936" },
{ "School Idol Blue", "2830663960d787adf5f9f9f8" },
{ "School Idol Mix", "31152ea5469b00a0e9f39800" },
{ "SDM Green", "04392c075f4a0c98760fbe94" },
{ "Sunny Passion Paradise", "0920341e6caee06846f0ba40" },
{ "SYL Rose Pink", "452728744243b9696be88486" },
{ "Tarnished Gold", "392f18604e299a7d42c19d53" },
{ "Vulnerable Blue", "ffb897cc93787f5c4b3732ff" },
{ "Wind Ring Emerald", "0c3627145a4220906a28b585" },
} },
{ name = "TheWolfBunny64/Company Pack", palettes = {
{ "AMZ Vision", "252f3e008296ff9900ffffff" },
{ "AND Green", "1242271e6e4230b0693ddc84" },
{ "APL Silver", "30333351555681888aa2aaad" },
{ "DUO Green", "1a3d002c660146a30158cc02" },
{ "DRC Copper", "3c291b64442ea06d4ac8895d" },
{ "GGL Blue", "13274921427a346ac34285f4" },
{ "GGL Green", "0f32181a542929864234a853" },
{ "GGL Red", "46140f75211abb352aea4335" },
{ "GGL Yellow", "4b38017d5e02c89604fbbc05" },
{ "SPT Green", "09401c0f6b3018ac4c1ed760" },
} },
{ name = "TheWolfBunny64/Food and Drink Pack", palettes = {
{ "7EL Color Combo", "062218147350eb0f2aff6c00" },
{ "BJB Beach", "273d3b4e7a7683ccc5dbe441" },
{ "BJB Storm", "1f3a31346152539b8368c2a4" },
{ "BKR Sundae", "40202178284bc04078f05097" },
{ "BGK Color Combo", "502314d62300ff8732f5ebdc" },
{ "CHT Orange", "442300723b00b75e00e57600" },
{ "CRK Color Combo", "460d0a8d1b15ec2e24f99b2a" },
{ "CCL Vision", "000000f40009d7d7d7ffffff" },
{ "Dew Green", "2d40004b6b0078ac0097d700" },
{ "DMP Vision", "000000006491e31837ffffff" },
{ "DRP Red", "290a0e4511186e1b278a2231" },
{ "DNK Vision", "48032b910756f20c90ff6e0c" },
{ "KFR Red", "33070e550c17881325ab182f" },
{ "KKR Vision", "000000166938cf152dffffff" },
{ "Lemon-Lime Green", "18310e30631d51a631f1c545" },
{ "MTD Ver.", "29673cd82a34a1d23fffffff" },
{ "PCS BLUE", "001b35002e59004a8e015db2" },
{ "PPS Vision", "0000001414c8ff1400ffffff" },
{ "PZH Red", "441012711c1fb52c31e3383e" },
{ "SPR Green", "002e17004d27007c3e009b4e" },
} },
{ name = "TheWolfBunny64/Gaming Pack", palettes = {
{ "AVG Raging Volcano", "1c0000a81000f83800f8b800" },
{ "Blue Bomber Vision", "0000000d4dc4639afce2cda7" },
{ "BBN Blue", "093e4b0f687e18a7ca1fd1fd" },
{ "BBG Green", "1f420e356e1854b0276adc31" },
{ "CHE Green", "20800040980080c800a0e000" },
{ "Classic Blurple", "22294138446d5b6dae7289da" },
{ "DMG Pea Green", "204631527f39aec440d7e894" },
{ "EBT Prolouge", "2c17084a290b854a1dc08226" },
{ "GGR Orange", "45230e743b17ba5e25e9762f" },
{ "Giga Kiwi DMG", "384828607028a0a830d0e040" },
{ "Investigation Yellow", "4c4a077f7c0cccc714fff919" },
{ "Phantom Red", "4b0b117e131cca1e2dfd2639" },
{ "PYO Green", "15431024771b39b42b48e236" },
{ "SES Blue", "073e4c0c687f14a7cc19d1ff" },
{ "SGA Tokyo Blue", "00273f00416a0068a90082d4" },
{ "Scarlett Green", "2e48034d78057cc0089bf00b" },
{ "Slime Blue", "0e2a3d1746662570a32f8ccc" },
{ "SNC Mega Blue", "13274120426c3369ad4084d9" },
{ "STM Gray", "3b3a396261609d9c99c5c3c0" },
{ "TYV Brown", "372c1f5c4a34937654b89469" },
{ "Tropical Cyber Space", "1414394344c1ef58f763fdfb" },
{ "XBX Green", "2b3c1249641f74a03192c83e" },
} },
{ name = "TheWolfBunny64/Holiday Pack", palettes = {
{ "Christmas Gold", "39321660542599873cc0a94b" },
{ "Christmas Silver", "2c32354a545977868e95a8b2" },
{ "Christmas Ver.", "300f0fa0323220a465cbb96a" },
{ "Classy Christmas", "534d579e5c5e8bab95e8e7df" },
{ "Clover Green", "1136161c5a252d903b39b54a" },
{ "Cupid's Love Palette", "5e081eb51a3ae48397e4cdd3" },
{ "Festive Christmas", "0a1609224b21cc1902bb9b60" },
{ "Glacial Winter Blue", "2839434360716c9ab487c1e2" },
{ "Grinchy Green", "3639085b5f0e929816b7be1c" },
{ "Halloween Ver.", "2c13319540a5f68c00ffcc00" },
{ "Haunted Halloween", "2f094c9e1fffffa52ecaff37" },
{ "Independence Boy", "0000000a3161b31942ffffff" },
{ "Irish Green", "143923225f3b37985e45be76" },
{ "Spooky Purple", "2f253f4f3e697e63a89e7cd2" },
{ "Winter Christmas", "341113ae3b4065b08fdddddd" },
} },
{ name = "TheWolfBunny64/J-Pop Pack", palettes = {
{ "A48 Pink", "4923317b3b53c45e84f676a6" },
{ "ATG PARETTO", "030a5b7a5375c375bcf4a7eb" },
{ "BBM KITSUNE RED", "2c00034a000677000995000c" },
{ "H46 Blue", "1b39442d5f724898b75bbee5" },
{ "J-Pop Idol Sherbet", "260c2b8129905bbee5f19db5" },
{ "MKU Blue", "05333f08566a0d8aaa11add5" },
{ "NZU PrePlay Palette", "47120fef3f3500b8b0fff200" },
{ "N46 Purple", "260c2b401448672073812990" },
{ "OOR RED", "370f0c5c1a14942920b93429" },
{ "YSB AMARANTH", "480c1b79142dc12048f2285a" },
} },
{ name = "TheWolfBunny64/Japanese Company Pack", palettes = {
{ "ANA Flight Blue", "00146e223f9a3b8bc000b3f0" },
{ "FMM Vision", "00301300602600a040008cd6" },
{ "LWS BLUE", "001f3600345b0053920068b7" },
{ "NHK Silver Gray", "262626404040666666808080" },
{ "SIT SUPER BLUE", "0b2438133d5e1f6196277abc" },
{ "SIT SUPER GREEN", "0634270b5742118b6a16ae85" },
{ "SKO Timely Vision", "2021210050a54393e6ffbf00" },
{ "SB109 PASTEL", "5503a63dacb8fd87b2f2d53f" },
{ "SNR Pink", "83534de74b5af485a1f9c2d0" },
{ "TKS CLOUDY BLUE", "27363b415a6368909f82b5c7" },
{ "NCO sea green", "073a310c6152149c8319c3a4" },
} },
{ name = "TheWolfBunny64/K-Pop Pack", palettes = {
{ "BGT ARMY PURPLE", "26154040246c6639ac8048d8" },
{ "BKP BLINK PINK", "4932377a535dc38594f4a7ba" },
{ "EER COSMIC LATTE", "4c4a457f7c73ccc6b8fff8e7" },
{ "LSS FEARLESS BLUE", "26314a40527c6784c781a5f9" },
{ "NCZ YELLOW", "43451e717333b4b851e2e766" },
{ "SVT COOL CARAT", "2b323e57647d92a8d1f7cac9" },
{ "SHW GREEN", "22413e396d685cafa673dbd0" },
{ "SWN PINK", "48172579273ec13f63f24f7c" },
{ "SKD STAY PINK", "4c142d7f224ccc367aff4499" },
{ "TROPICAL TWC APRICOT", "4c1c30993961ff5fa2fcc89b" },
} },
{ name = "TheWolfBunny64/Legacy Palette Pack", palettes = {
{ "TDS VC Green", "4a4a4a5a8c429cef29bdff21" },
{ "TDS VC Ver.", "2929186b6b52a5a58ccecead" },
{ "BLL PC Screen", "000000783800b86000f87800" },
{ "DMG Ver.", "2a453b365d48577c447f860f" },
{ "DGV Ver.", "2a2a2246463970705c8c8c73" },
{ "GMT Ver.", "12424c255955437a636ba64a" },
{ "GNW LCD", "3b3c336365569ea28ac6cbad" },
{ "GMK Ver.", "184421405d3b6b9c638cce94" },
{ "GBU Classic Ver.", "26302540503f67806481a17e" },
{ "HGM Ver.", "2d2d2b414f53687f84829fa6" },
{ "Light Ver.", "004f3b00694a009a7100b581" },
{ "LKA DX Ver.", "58382068884078c078f8f8b0" },
{ "MBM Ver.", "303030505050808080a0a0a0" },
{ "MGM V Ver.", "08203040689070a0e0d0d0d0" },
{ "NGP Ver.", "101010707070b0b0b0f0f0f0" },
{ "NKA 3310 Ver.", "2231273953425c846973a684" },
{ "Pocket Tales Ver.", "00000038500088a000d0d860" },
{ "Pocket Ver.", "1f1f1f4d533c8b956dc4cfa1" },
{ "PKS Ver.", "2d2d284b4b4378786c969687" },
{ "PKM Pinball Ver.", "181820786030a0b050e8f8b8" },
{ "PKM Ver.", "181010807098f0b088f8e8f8" },
{ "PKM mini Ver.", "1b2d1b525c52849484a5b9a5" },
{ "PKT Ver.", "10281838503050805070b070" },
{ "Rocky-Valley Holiday", "204008805850d89078c0f0f8" },
{ "T83 Ver.", "2e332a4e55467c88709caa8c" },
{ "TMG Ver.", "3c383878787cc0c0c7f1f0f9" },
{ "TGC Ver.", "0000000f4f2f6f8f4fdfff8f" },
{ "Timing Hero Ver.", "202e004c67188c994ccccc99" },
{ "Travel Wood", "482810705030a08058f8d8b0" },
{ "VMU Ver.", "0814804466546ca38688cca8" },
{ "VTB Ver.", "ff0000aa0000550000000000" },
{ "WTS Ver.", "0c322c2c626454a68c7cc67c" },
{ "WDS Ver.", "2c2c2c686868c0c0c0fefefe" },
} },
{ name = "TheWolfBunny64/Misc. Palette Pack", palettes = {
{ "Camouflage Ver.", "37353879533dac7e54bcab90" },
{ "Cherry Blossom Pink", "482534783f58c0648cf07eb0" },
{ "Emerald Green", "183c2428643c40a06050c878" },
{ "Fool's Gold and Silver", "2d30345a606997a1b0c5c66d" },
{ "Glitchy Blue", "0f254b193f7d2864c8337efb" },
{ "Gold, Silver, and Bronze", "2d1f1499684386949abeb049" },
{ "Golden Trophy", "453e0774680cb9a613e8d018" },
{ "Greenscale Ver.", "0c360c2c62346e870a9cbe0c" },
{ "Leprechaun Green", "10281d1b44302c6c4d378861" },
{ "Nightvision Green", "66bf2f519825335f171e390e" },
{ "Rising Sun Red", "38000d5d0016960024bc002d" },
{ "Treasure Gold", "3c360a655a12a2901ccbb524" },
{ "Wild West Vision", "3a160e924a36c3976ad9d7c7" },
} },
{ name = "TheWolfBunny64/Console Pack", palettes = {
{ "Advanced Indigo", "241f373c355d605494796aba" },
{ "Aegis Cherry", "42111e6e1d32b02f50dd3b64" },
{ "Ancient HSU Brown", "352f2b594f488f7f73b39f90" },
{ "ANC Green", "002e25004d3f007c64009b7e" },
{ "Brilliant Diamond Blue", "2638433f5d706595b47fbbe1" },
{ "COLLECTION of SAG Ver.", "041820345d51769a67b2c0a8" },
{ "Champion's Tunic", "281b13875b40009edde2dcb1" },
{ "CHZ Blue", "1735432759703e8fb44eb3e1" },
{ "DKG Arcade Blue", "15304223516f3881b147a2de" },
{ "DKG Barrel Brown", "3a220e613a179c5c25c3742f" },
{ "DMG-099", "3132313f642f6bad1984b510" },
{ "DMG-GOLD", "30361c505a3080904ca1b560" },
{ "DMG-SWITCH", "214231426b296c94218cad28" },
{ "DRL GB Ver.", "48530e788b1db9d03af6ff70" },
{ "EVE Brown", "3c2a0f644619a07028c88d32" },
{ "FMC Disk Yellow", "483a00796100c29b00f3c200" },
{ "FMC Frenzy", "231916a32135d9be72efecda" },
{ "Frog Coin Green", "00390039840000ef00fff7de" },
{ "Fury Blue", "0c1c2d152f4c224c792b5f98" },
{ "GMC Glimmer", "3e1318cf415111a396b6bed3" },
{ "Golden Wild", "372f1e5c4f32947f50b99f65" },
{ "GMB Brown", "331a11552c1d88472faa593b" },
{ "Green Awakening", "000b1636705898db75f1ffdd" },
{ "Green Banana", "2149003969394a9e0063df08" },
{ "INK Tricolor", "1c114c603bffff505eeaff3d" },
{ "LCD Clock Green", "183626285a4040906650b580" },
{ "LBO Fawn", "4033226b5539ac885cd7aa73" },
{ "MRO Red", "430400700700b40c00e10f00" },
{ "Metallic PLD Brass", "302d0f514c1a817929a29834" },
{ "Neon Blue", "033745055c730894b80ab9e6" },
{ "Neon Green", "0942000f6e0018b0001edc00" },
{ "Neon Orange", "4b30017d5002c88004faa005" },
{ "Neon Pink", "4c0f247f193ccc2860ff3278" },
{ "Neon Purple", "3600455a00739000b8b400e6" },
{ "Neon Red", "4c120c7f1e14cc3020ff3c28" },
{ "Neon Yellow", "454c00737f00b8cc00e6ff00" },
{ "Odyssey Boy", "2224215054457e8e67acbe8c" },
{ "Odyssey Gold", "3a30006150009b8000c2a000" },
{ "POCKET SWT", "3038206372518d9c7bb5c69c" },
{ "PKC Yellow", "4c42007f6e00ccb000ffdc00" },
{ "PKD Red", "461918752a28bb4340ea5450" },
{ "Royal Blue", "151949232a7a3844c44655f5" },
{ "Shining Pearl Pink", "3f2a30694750a87180d28ea0" },
{ "Sky Pop Ivory", "52502586825cbebb95e5e0b8" },
{ "Super FMC Supreme", "0846bad9404044ac71feda5a" },
{ "Super Mushroom Vision", "000000923404cc9e22f7cec3" },
{ "Super Star Pink", "440d2372163bb7235fe52c77" },
{ "Superball Ivory", "646432828250bcbc8aeef0bc" },
{ "Timeless Gold and Red", "37090a6f1215b91e23c8aa50" },
{ "Ultra Black", "17181d2629313d414f4d5263" },
{ "WRW MicroBlue", "05293c0844650d6da11189ca" },
{ "Wonder Purple", "451a42732c6fb846b2e658df" },
{ "Yellow Banana", "734900ad6939de9e00ffdf08" },
{ "YSH Egg Green", "1e3a0e336218519c2666c430" },
} },
{ name = "TheWolfBunny64/Sports Pack", palettes = {
{ "MLB Vision", "041e42bf0d3e057affffffff" },
{ "NASCAR Ver.", "000000007ac2e4002bffd659" },
{ "NBA Vision", "000000253b73c8102effffff" },
{ "NCAA Blue", "002e42004e6f007cb1009cde" },
{ "NFL Vision", "000000013369d50a0affffff" },
{ "OLY Bronze", "3d2618664029a46741cd8152" },
{ "OLY Gold", "3f360a6a5b12aa911cd5b624" },
{ "OLY Silver", "2f312e4f524e7e847c9ea59c" },
{ "PGA Tour Vision", "000000003c80f1373dffffff" },
{ "WWE White and Red", "40070c810e19d7182affffff" },
} },
{ name = "TheWolfBunny64/Traditional JPN Colors Pack", palettes = {
{ "Aquatic Iro", "192f603e62ad2ca9e1a0d8ef" },
{ "Fruity Orange", "241a089f563af08300f3bf88" },
{ "Ghostly Aoi", "0f235019448e5a79ba84a2d4" },
{ "Golden Kiiro", "6a5d21a69425dccb18f8e58c" },
{ "Lime Midori", "4759507b8d42aacf53e0ebaf" },
{ "Oni Aka", "640125a22041d9333fec6d71" },
{ "Sakura Pink", "a25768e7609eeebbcbfdeff2" },
{ "Silver Shiro", "383c3c727171afafb0dcdddd" },
{ "Tea Midori", "33363102876088cb7fd6e9ca" },
{ "Wisteria Murasaki", "2e29307058a3a59acadbd0e6" },
} },
{ name = "Trashuncle/Artistic", palettes = {
{ "TU PSL Park", "9200aeb722d4d442f0ec89ff" },
{ "TU Quicksilver", "555555717286a4a5c3bbbce1" },
} },
{ name = "Trashuncle/Crush", palettes = {
{ "TU Blueberry Crush", "0000002811ff4dffffffffff" },
{ "TU Grape Crush", "0000003d43cb0083ebffffff" },
{ "TU Kiwi Crush", "000000059e322eef7affffff" },
{ "TU Lavender Crush", "000000812482ab6a9bffffff" },
{ "TU Orange Crush", "000000c23615ff632cffffff" },
{ "TU Pineapple Crush", "000000ffff00ffff80ffffff" },
{ "TU Strawberry Crush", "000000ea1211ff5250ffffff" },
} },
{ name = "Trashuncle/DMG", palettes = {
{ "TU DMG Bright", "0c3a1d2a61414c7b2b919707" },
{ "TU DMG Clean", "1749022b5402496503788603" },
{ "TU DMG Weak Alt", "2c4d2054733c597730677f31" },
{ "TU DMG Weak", "364e1a5772405e7a406d833d" },
} },
{ name = "Trashuncle/GBP", palettes = {
{ "TU GBP Bright", "5b5d5a879481abb8a7ffffff" },
{ "TU GBP Clean", "3d493b98a291adbfacedfff4" },
{ "TU GBP Weak Alt", "4c4b3170734e757952848459" },
{ "TU GBP Weak", "434738535a42535f49667159" },
} },
{ name = "Trashuncle/Light", palettes = {
{ "TU Light Bright", "00637400a1b100daea00e3f3" },
{ "TU Light Clean", "007577019fa000aeaf01b4b9" },
{ "TU Light Dark", "0076a80092d10098c500a5f2" },
{ "TU Light Idealized", "006e9d0093c500a6cc00ccd5" },
} },
}
+58
View File
@@ -0,0 +1,58 @@
# Linux AppImage packaging
Releases ship raw AppImages (no zip wrapper):
- `gen1recomp-<version>-linux-x86_64.AppImage`
- `gen1recomp-<version>-linux-arm64.AppImage`
```sh
chmod +x gen1recomp-*-linux-x86_64.AppImage
./gen1recomp-*-linux-x86_64.AppImage
```
Flatpak users should prefer the `.flatpak` bundle (see
[linux-flatpak.md](linux-flatpak.md)); it avoids host glibc / FUSE / curl
mismatches on immutable desktops.
## Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Won't start / `libfuse.so.2` | No FUSE | `sudo apt install libfuse2` (or `libfuse2t64`) **or** `./app.AppImage --appimage-extract-and-run` |
| Update check / mods / save sync fail | Host `curl` missing or broken by AppImage `LD_LIBRARY_PATH` | Install curl; current builds scrub `LD_LIBRARY_PATH` for **host** curl and keep it for a bundled AppDir curl |
| Settings / sync reset after quit | Portable mode next to a read-only AppImage parent (`/opt`, system dir) | Remove `portable.txt` or move the AppImage to a writable folder; the game falls back to the XDG save dir when the probe write fails |
| "Download AppImage update" | Shell/`minShell` gate needs a full native package | Download the new `.AppImage`, `chmod +x`, replace the old file |
| Steam / Game Mode weirdness after update | Overlay `LD_PRELOAD` / PID change | HostShell unsets `LD_PRELOAD` for children and `execv`s `$APPIMAGE` on restart |
## Network transport (HostShell)
Desktop Linux fetches go through host or bundled `curl`:
1. Flatpak `/app/bin/curl` (bundled, keep sandbox libs)
2. `$APPDIR/usr/bin/curl` or `$APPDIR/bin/curl` (keep `$APPDIR` on `LD_LIBRARY_PATH`)
3. Host `curl` (`env -u LD_LIBRARY_PATH`, and always `-u LD_PRELOAD`)
## Portable mode
Drop `portable.txt` beside the `.AppImage` to keep saves next to the binary.
The launcher probes writability with a unique `.write_probe_<time>_<rand>.tmp`
file. Read-only parents fail soft and use `love.filesystem` XDG saves instead.
Flatpak ignores `portable.txt`.
## Auto-update
In-place updates download `gen1recomp-X.Y.Z.love` into the save directory.
Full shell bumps open the matching AppImage (or Flatpak) download URL — there
is no silent in-place AppImage replace yet.
## Building
```sh
# x86_64 (macOS or Linux host with squashfs-tools)
scripts/build.sh linux --version X.Y.Z
# arm64 (aarch64 host + docker/podman)
scripts/build_linux_arm64.sh --version X.Y.Z
```
See [linux-arm64-build.md](linux-arm64-build.md) for the arm64 builder.
+19
View File
@@ -34,6 +34,25 @@ The package bundles PortMaster's LÖVE 11.5 aarch64 runtime. The launcher source
Suspend/resume uses the existing LÖVE focus/visibility lifecycle: input is reset on focus loss and the game resumes when the window becomes visible again. Exact power-button behavior remains firmware-dependent; hardware validation has been performed on the TrimUI Brick, not every SBC or H700 device. 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 ## Building
Release workflows build this automatically. Standalone builds resolve the latest published Gen1Recomp release by default: Release workflows build this automatically. Standalone builds resolve the latest published Gen1Recomp release by default:
+3 -2
View File
@@ -1,10 +1,11 @@
# Linux arm64 (aarch64) AppImage # Linux arm64 (aarch64) AppImage
Releases ship `gen1recomp-<version>-linux-arm64.AppImage` alongside the Releases ship `gen1recomp-<version>-linux-arm64.AppImage` alongside the
existing x86_64 `gen1recomp-<version>-linux.zip`. It targets 64-bit ARM existing x86_64 `gen1recomp-<version>-linux-x86_64.AppImage`. It targets 64-bit ARM
desktop Linux: Raspberry Pi 4/5 running Raspberry Pi OS, Armbian and other 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 SBC distros, arm64 VMs on Apple Silicon, Ampere/Graviton desktops, and the
aarch64 handhelds that run a full distro. aarch64 handhelds that run a full distro. See also
[linux-appimage.md](linux-appimage.md) for shared AppImage failure modes.
> The Anbernic RG34XXSP has its own PortMaster-style pack > The Anbernic RG34XXSP has its own PortMaster-style pack
> (`gen1recomp-*-rg34xxsp-stockos64-mod.zip`, see > (`gen1recomp-*-rg34xxsp-stockos64-mod.zip`, see
+51
View File
@@ -0,0 +1,51 @@
# Linux Flatpak
Releases may ship `gen1recomp-<version>-linux.flatpak` (x86_64 bundle).
## Install
```sh
flatpak install --user ./gen1recomp-<version>-linux.flatpak
flatpak run com.theboisclub.gen1recomp
```
Or open the `.flatpak` in Discover / GNOME Software. AppStream metainfo
includes a `<releases>` entry so those UIs can show the version.
## Permissions
The manifest requests:
- network (updater, mods, save sync)
- X11 / Wayland / DRI / PulseAudio
- `--device=all` (SDL2 gamepads, rumble, gyro via evdev/hidraw)
- `--filesystem=home` (ROM import)
Curl is bundled at `/app/bin/curl` so the game does not need host curl.
## Updates
Lua/engine payloads still use the in-app `.love` updater. A native shell bump
points at the new `.flatpak` asset (`Download Flatpak update`).
After a `.love` payload download, restart uses `love.event.quit("restart")`
(`execv` of `/proc/self/exe` inside bwrap). If a restart hangs after an
update, fully quit and relaunch — PhysFS can retain a lock on the previous
payload across a bad handoff.
## Building
```sh
scripts/build_flatpak.sh --version X.Y.Z
# → dist/flatpak/gen1recomp-X.Y.Z-linux.flatpak
```
Requires `flatpak` + `flatpak-builder` and the Freedesktop 24.08 runtime from
Flathub (installed automatically on first build).
Release CI builds the bundle on `ubuntu-24.04` (`linux-flatpak` job) from the
shared `game.love` payload and publishes `gen1recomp-<ver>-linux.flatpak`
alongside the AppImages. PR CI path-gates the same script when `flatpak/` or
`scripts/build_flatpak.sh` change.
Flathub store submission is out of scope for the GitHub bundle channel.
+42 -3
View File
@@ -214,12 +214,51 @@ finally the bare name handed to the system loader. Per-OS names are
`librashader_bridge.dylib` (macOS), and `liblibrashader_bridge.so` or `librashader_bridge.dylib` (macOS), and `liblibrashader_bridge.so` or
`librashader_bridge.so` (Linux and Android). Android resolves the bare name `librashader_bridge.so` (Linux and Android). Android resolves the bare name
because the `.so` ships as an ordinary `jniLibs` entry, so `dlopen` finds it because the `.so` ships as an ordinary `jniLibs` entry, so `dlopen` finds it
without a path. The desktop path is still a developer build sitting in cargo's without a path. `ShaderFX.canConvert()` reports whether the library resolved on this machine,
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 and `ShaderFX.bridgeError()` says why not. Activating an already-converted
preset never needs any of this. 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: **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 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 main image and are reachable only through `ffi.C`. The candidate list is empty
+3 -2
View File
@@ -63,8 +63,9 @@ mounted or deleted as stale; the launcher directs the player to a full package.
## Release assets ## Release assets
Each tagged release `vX.Y.Z` carries the existing per-platform archives Each tagged release `vX.Y.Z` carries the existing per-platform archives
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`, (`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`,
`-linux-arm64.AppImage`, `-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and `-linux-x86_64.AppImage`, `-linux-arm64.AppImage`, `-linux.flatpak`,
`-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and
PortMaster archives) plus two assets the updater itself consumes: PortMaster archives) plus two assets the updater itself consumes:
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern - `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=gen1recomp
Comment=Pokémon Gen 1/2 recompilation
Exec=gen1recomp
Icon=com.theboisclub.gen1recomp
Terminal=false
Categories=Game;
StartupWMClass=love
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.theboisclub.gen1recomp</id>
<name>gen1recomp</name>
<summary>Pokémon Gen 1/2 recompilation</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>LicenseRef-proprietary</project_license>
<developer_name>The Bois Club</developer_name>
<description>
<p>
A LÖVE-based recompilation of Pokémon Gen 1 and Gen 2 with a launcher,
ROM importer, mods, and optional cloud save sync. Requires your own
legal cartridge dumps.
</p>
</description>
<launchable type="desktop-id">com.theboisclub.gen1recomp.desktop</launchable>
<url type="homepage">https://github.com/bryanthaboi/gen1recomp</url>
<screenshots>
<screenshot type="default">
<caption>Launcher</caption>
<image>https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png</image>
</screenshot>
</screenshots>
<content_rating type="oars-1.1"/>
<releases>
<release version="0.0.0" date="1970-01-01"/>
</releases>
</component>
+68
View File
@@ -0,0 +1,68 @@
# Template consumed by scripts/build_flatpak.sh (placeholders substituted).
# Do not pass this file directly to flatpak-builder.
app-id: com.theboisclub.gen1recomp
runtime: org.freedesktop.Platform
runtime-version: "24.08"
sdk: org.freedesktop.Sdk
command: gen1recomp
finish-args:
- --share=network
- --share=ipc
- --socket=x11
- --socket=wayland
- --device=dri
- --socket=pulseaudio
# Full input device access so SDL2 sees udev/evdev gamepads + rumble/gyro
- --device=all
- --filesystem=home
modules:
- name: curl
buildsystem: simple
build-commands:
- tar -xJf curl-static.tar.xz
- install -Dm755 curl /app/bin/curl
sources:
- type: file
url: https://github.com/stunnel/static-curl/releases/download/8.21.0/curl-linux-x86_64-musl-8.21.0.tar.xz
sha256: e955f211202ded2536164588331acfc987dc4b7857efa3577717b1ffeab22029
dest-filename: curl-static.tar.xz
- name: love
buildsystem: simple
build-commands:
- chmod +x love-x86_64.AppImage
- ./love-x86_64.AppImage --appimage-extract
- mkdir -p /app/lib/love /app/bin /app/share/gen1recomp
- cp -a squashfs-root/bin/love /app/bin/love
- cp -a squashfs-root/lib/. /app/lib/love/
- |
cat > /app/bin/gen1recomp <<'EOF'
#!/bin/sh
export LD_LIBRARY_PATH="/app/lib/love:${LD_LIBRARY_PATH:-}"
exec /app/bin/love /app/share/gen1recomp/game.love "$@"
EOF
- chmod +x /app/bin/gen1recomp
- install -Dm644 game.love /app/share/gen1recomp/game.love
# BRIDGE-BEGIN
- install -Dm755 liblibrashader_bridge.so /app/share/gen1recomp/liblibrashader_bridge.so
# BRIDGE-END
- install -Dm644 com.theboisclub.gen1recomp.desktop /app/share/applications/com.theboisclub.gen1recomp.desktop
- install -Dm644 com.theboisclub.gen1recomp.metainfo.xml /app/share/metainfo/com.theboisclub.gen1recomp.metainfo.xml
- install -Dm644 icon.png /app/share/icons/hicolor/512x512/apps/com.theboisclub.gen1recomp.png
sources:
- type: file
url: https://github.com/love2d/love/releases/download/11.5/love-11.5-x86_64.AppImage
sha256: 65a673406431eff7167a15a032bf7a2e4ba50108e091eb7b176465831f9b5e00
dest-filename: love-x86_64.AppImage
- type: file
path: game.love
# BRIDGE-BEGIN
- type: file
path: liblibrashader_bridge.so
# BRIDGE-END
- type: file
path: com.theboisclub.gen1recomp.metainfo.xml
- type: file
path: com.theboisclub.gen1recomp.desktop
- type: file
path: icon.png
+76
View File
@@ -22,6 +22,56 @@ local PlatformHooks = require("src.core.PlatformHooks")
local HostDisplay = require("src.core.HostDisplay") local HostDisplay = require("src.core.HostDisplay")
local GameViewport = require("src.render.GameViewport") 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. -- Lua errors: persist a redacted trace in the save dir and surface a hint.
do do
local defaultErrorHandler = love.errorhandler or love.errhand local defaultErrorHandler = love.errorhandler or love.errhand
@@ -30,10 +80,33 @@ do
if ok and hint and type(msg) == "string" then if ok and hint and type(msg) == "string" then
msg = msg .. "\n\n" .. hint msg = msg .. "\n\n" .. hint
end 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 if defaultErrorHandler then
return defaultErrorHandler(msg) return defaultErrorHandler(msg)
end end
end end
love.errhand = love.errorhandler
end end
local Game, EditorApp, Importer, TouchEditor, Studio local Game, EditorApp, Importer, TouchEditor, Studio
@@ -559,6 +632,7 @@ function love.load(args)
end end
function love.update(dt) function love.update(dt)
checkEmergencyQuit(dt)
HostDisplay.update(dt) HostDisplay.update(dt)
SwitchDiagnostics.maybeFlush(false) SwitchDiagnostics.maybeFlush(false)
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL. -- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
@@ -1229,6 +1303,8 @@ function love.run()
if love.timer then dt = love.timer.step() end if love.timer then dt = love.timer.step() end
idleFor = idleFor + dt idleFor = idleFor + dt
checkEmergencyQuit(dt)
-- call update and draw -- call update and draw
if love.update then love.update(dt) end if love.update then love.update(dt) end
+72 -26
View File
@@ -11,7 +11,7 @@
# #
# Output: dist/mac/gen1recomp-macos.zip # Output: dist/mac/gen1recomp-macos.zip
# dist/win/gen1recomp-win64.zip # dist/win/gen1recomp-win64.zip
# dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage) # dist/linux/gen1recomp-linux-x86_64.AppImage (fused x86_64 AppImage)
# dist/android/debug/*.apk (full gradle output stays under # dist/android/debug/*.apk (full gradle output stays under
# mobile/android/app/build/outputs/apk/embedNoRecord/) # mobile/android/app/build/outputs/apk/embedNoRecord/)
# dist/ios/<Config>-<sdk>/gen1recomp++.app (full xcodebuild output stays # dist/ios/<Config>-<sdk>/gen1recomp++.app (full xcodebuild output stays
@@ -170,27 +170,53 @@ make_ico() { # $1 = output .ico path
# --------------------------------------------------------------- macOS # --------------------------------------------------------------- macOS
# ShaderFX's librashader bridge. Only the CONVERT action needs it, so a build # ShaderFX's librashader bridge. Only the CONVERT action needs it, so a build
# without it still runs presets that were converted elsewhere. # without it still runs presets that were converted elsewhere.
# SHADERFX_BRIDGE points at a prebuilt library; otherwise cargo builds it. # SHADERFX_BRIDGE_<PLAT> or dist/native/<plat>/ points at a prebuilt library;
# otherwise cargo builds it, host platform only.
shader_bridge_host_plat() {
case "$(uname -s)-$(uname -m)" in
Darwin-*) printf 'mac' ;;
Linux-x86_64) printf 'linux-x64' ;;
Linux-aarch64|Linux-arm64) printf 'linux-arm64' ;;
*) printf '' ;;
esac
}
bundle_shader_bridge() { bundle_shader_bridge() {
local dest="$1" name="$2" local dest="$1" name="$2" plat="$3"
local src="${SHADERFX_BRIDGE:-}"
local crate="$ROOT/tools/shaderfx-bridge" local crate="$ROOT/tools/shaderfx-bridge"
if [ -z "$src" ] && [ -f "$crate/target/release/$name" ]; then local src="" var
src="$crate/target/release/$name" [ -n "$plat" ] || fail "bundle_shader_bridge: no platform key for $name"
var="SHADERFX_BRIDGE_$(printf '%s' "$plat" | tr 'a-z-' 'A-Z_')"
eval "src=\${$var:-}"
if [ -z "$src" ] && [ -f "$DIST/native/$plat/$name" ]; then
src="$DIST/native/$plat/$name"
fi fi
if [ -z "$src" ] && command -v cargo >/dev/null 2>&1; then if [ -z "$src" ] && [ -n "${SHADERFX_BRIDGE:-}" ]; then
say "building the ShaderFX bridge with cargo" src="$SHADERFX_BRIDGE"
if (cd "$crate" && cargo build --release >/dev/null 2>&1); then fi
if [ -z "$src" ] && [ "$plat" = "$(shader_bridge_host_plat)" ]; then
if [ -f "$crate/target/release/$name" ]; then
src="$crate/target/release/$name" 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
fi fi
if [ -n "$src" ] && [ -f "$src" ]; then if [ -n "$src" ] && [ -f "$src" ]; then
mkdir -p "$dest" mkdir -p "$dest"
cp "$src" "$dest/$name" cp "$src" "$dest/$name"
say "bundled $name for SHADER FX preset conversion" say "bundled $name for SHADER FX preset conversion ($plat)"
else return 0
warn "$name not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE or install cargo)"
fi 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() { build_mac() {
@@ -205,7 +231,7 @@ build_mac() {
# drop any bundled placeholder .love and fuse ours in # drop any bundled placeholder .love and fuse ours in
find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete
cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love" cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love"
bundle_shader_bridge "$out_app/Contents/MacOS" "liblibrashader_bridge.dylib" bundle_shader_bridge "$out_app/Contents/MacOS" "liblibrashader_bridge.dylib" mac
local plist="$out_app/Contents/Info.plist" local plist="$out_app/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$plist" 2>/dev/null \ /usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$plist" 2>/dev/null \
@@ -322,7 +348,7 @@ build_win() {
warn "gen1tls.dll not found: Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)" warn "gen1tls.dll not found: Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)"
fi fi
bundle_shader_bridge "$out_dir" "librashader_bridge.dll" bundle_shader_bridge "$out_dir" "librashader_bridge.dll" win-x64
# The exe's icon lives in love.exe's PE resources, so it must be patched # 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 # BEFORE the .love is appended: peresed rewrites the whole file and would
@@ -419,7 +445,7 @@ build_linux() {
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
cp "$LOVE_FILE" "$appdir/game.love" cp "$LOVE_FILE" "$appdir/game.love"
bundle_shader_bridge "$appdir" "liblibrashader_bridge.so" bundle_shader_bridge "$appdir" "liblibrashader_bridge.so" linux-x64
# Replace LÖVE's own desktop entry rather than keeping it: it says # 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 # Name=LÖVE / Icon=love, which is what appimaged, app menus and file
@@ -457,15 +483,31 @@ EOF
# file-manager thumbnailers show for the file itself. # file-manager thumbnailers show for the file itself.
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC" [ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
rm -f "$appdir/love.svg" "$appdir/love.png" "$appdir/.DirIcon" rm -f "$appdir/love.svg" "$appdir/love.png" "$appdir/.DirIcon"
sips -z 512 512 "$ICON_SRC" --out "$appdir/$APP_NAME.png" >/dev/null if command -v sips >/dev/null 2>&1; then
sips -z 512 512 "$ICON_SRC" --out "$appdir/$APP_NAME.png" >/dev/null
elif command -v convert >/dev/null 2>&1; then
convert "$ICON_SRC" -resize 512x512 "$appdir/$APP_NAME.png"
else
fail "need sips (macOS) or ImageMagick convert to resize $ICON_SRC"
fi
cp "$appdir/$APP_NAME.png" "$appdir/.DirIcon" cp "$appdir/$APP_NAME.png" "$appdir/.DirIcon"
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun" # sed -i '' is BSD; GNU sed wants sed -i (no empty backup suffix).
if sed --version >/dev/null 2>&1; then
sed -i 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
else
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
fi
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \ grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)" || fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
sed -i '' 's|^exec "\$APPDIR/bin/love"|if [ -n "$WAYLAND_DISPLAY" ] \&\& [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\ local wayland_hook='if [ -n "$WAYLAND_DISPLAY" ] && [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\
exec "$APPDIR/bin/love"|' "$appdir/AppRun" exec "$APPDIR/bin/love"'
if sed --version >/dev/null 2>&1; then
sed -i "s|^exec \"\$APPDIR/bin/love\"|$wayland_hook|" "$appdir/AppRun"
else
sed -i '' "s|^exec \"\$APPDIR/bin/love\"|$wayland_hook|" "$appdir/AppRun"
fi
# Match the upstream image's compression (gzip, 128K blocks) so the # Match the upstream image's compression (gzip, 128K blocks) so the
# bundled runtime can read it. # bundled runtime can read it.
@@ -474,16 +516,20 @@ exec "$APPDIR/bin/love"|' "$appdir/AppRun"
mksquashfs "$appdir" "$sfs_out" \ mksquashfs "$appdir" "$sfs_out" \
-comp gzip -b 131072 -noappend -all-root -no-xattrs -quiet >/dev/null -comp gzip -b 131072 -noappend -all-root -no-xattrs -quiet >/dev/null
local out_bin="$WORK/$APP_NAME-x86_64.AppImage" mkdir -p "$DIST/linux"
rm -f "$out_bin" local out_bin="$DIST/linux/$APP_NAME-linux-x86_64.AppImage"
rm -f "$out_bin" "$out_bin.sha256"
head -c "$sfs_offset" "$love_appimage" > "$out_bin" head -c "$sfs_offset" "$love_appimage" > "$out_bin"
cat "$sfs_out" >> "$out_bin" cat "$sfs_out" >> "$out_bin"
chmod +x "$out_bin" chmod +x "$out_bin"
if command -v sha256sum >/dev/null 2>&1; then
local zip_out="$DIST/linux/$APP_NAME-linux.zip" printf '%s %s\n' "$(sha256sum "$out_bin" | awk '{print $1}')" "$(basename "$out_bin")" \
rm -f "$zip_out" > "$out_bin.sha256"
(cd "$WORK" && zip -q -9 -j "$zip_out" "$(basename "$out_bin")") else
say "Linux build: $zip_out" printf '%s %s\n' "$(shasum -a 256 "$out_bin" | awk '{print $1}')" "$(basename "$out_bin")" \
> "$out_bin.sha256"
fi
say "Linux build: $out_bin"
} }
# --------------------------------------------------------------- Android # --------------------------------------------------------------- Android
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# Build a single-file Flatpak bundle for x86_64 desktop Linux.
#
# Usage: scripts/build_flatpak.sh [--version X.Y.Z] [--game-love PATH]
#
# Output:
# dist/flatpak/gen1recomp-<version>-linux.flatpak
#
# Requires: flatpak, flatpak-builder, and network on first run (Freedesktop
# runtime + LÖVE AppImage download).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
APP_ID="com.theboisclub.gen1recomp"
VERSION="0.0.0"
GAME_LOVE_IN=""
DIST="$ROOT/dist/flatpak"
BUILD_DIR="$ROOT/.bazinga/flatpak-build"
REPO_DIR="$ROOT/.bazinga/flatpak-repo"
STATE_DIR="$ROOT/.bazinga/flatpak-state"
CACHE="$ROOT/.bazinga/flatpak-cache"
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--version) VERSION="$2"; shift ;;
--game-love) GAME_LOVE_IN="$2"; shift ;;
-h|--help)
sed -n '2,12p' "$0"
exit 0
;;
*) fail "unknown argument: $1" ;;
esac
shift
done
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
fail "invalid --version '$VERSION' (expected X.Y.Z)"
fi
command -v flatpak >/dev/null || fail "flatpak not found"
command -v flatpak-builder >/dev/null || fail "flatpak-builder not found"
mkdir -p "$DIST" "$BUILD_DIR" "$REPO_DIR" "$STATE_DIR" "$CACHE"
LOVE_FILE="$CACHE/game.love"
if [ -n "$GAME_LOVE_IN" ]; then
[ -f "$GAME_LOVE_IN" ] || fail "game.love not found: $GAME_LOVE_IN"
if [ "$(readlink -f "$GAME_LOVE_IN")" != "$(readlink -f "$LOVE_FILE")" ]; then
cp "$GAME_LOVE_IN" "$LOVE_FILE"
fi
else
say "packing game.love"
"$ROOT/scripts/pack_love.sh" --output "$LOVE_FILE" --version "$VERSION"
fi
# Stage local sources next to the generated manifest (flatpak path: is relative).
cp "$ROOT/flatpak/$APP_ID.desktop" "$CACHE/$APP_ID.desktop"
# AppStream rejects icons larger than the declared hicolor size.
if command -v magick >/dev/null 2>&1; then
magick "$ROOT/assets/logo/gen1recomp_cover.png" -resize 512x512 "$CACHE/icon.png"
elif command -v convert >/dev/null 2>&1; then
convert "$ROOT/assets/logo/gen1recomp_cover.png" -resize 512x512 "$CACHE/icon.png"
else
fail "need ImageMagick (magick/convert) to resize the Flatpak icon to 512x512"
fi
DATE="$(date -u +%Y-%m-%d)"
python3 - "$ROOT/flatpak/$APP_ID.metainfo.xml" "$CACHE/$APP_ID.metainfo.xml" "$VERSION" "$DATE" <<'PY'
import sys, pathlib, re
src, dst, ver, date = sys.argv[1:5]
text = pathlib.Path(src).read_text(encoding="utf-8")
release = f' <release version="{ver}" date="{date}"/>'
text = re.sub(
r"<releases>.*?</releases>",
"<releases>\n" + release + "\n </releases>",
text,
count=1,
flags=re.S,
)
pathlib.Path(dst).write_text(text, encoding="utf-8")
PY
BRIDGE_LIB="liblibrashader_bridge.so"
BRIDGE_SRC="${SHADERFX_BRIDGE_LINUX_X64:-}"
if [ -z "$BRIDGE_SRC" ] && [ -f "$ROOT/dist/native/linux-x64/$BRIDGE_LIB" ]; then
BRIDGE_SRC="$ROOT/dist/native/linux-x64/$BRIDGE_LIB"
fi
if [ -z "$BRIDGE_SRC" ] && [ -f "$ROOT/tools/shaderfx-bridge/target/release/$BRIDGE_LIB" ]; then
BRIDGE_SRC="$ROOT/tools/shaderfx-bridge/target/release/$BRIDGE_LIB"
fi
rm -f "$CACHE/$BRIDGE_LIB"
if [ -n "$BRIDGE_SRC" ] && [ -f "$BRIDGE_SRC" ]; then
cp "$BRIDGE_SRC" "$CACHE/$BRIDGE_LIB"
cp "$ROOT/flatpak/$APP_ID.yml" "$CACHE/$APP_ID.yml"
say "staged $BRIDGE_LIB for SHADER FX preset conversion"
elif [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then
fail "$BRIDGE_LIB not found: set SHADERFX_BRIDGE_LINUX_X64 or stage it at dist/native/linux-x64/$BRIDGE_LIB"
else
warn "$BRIDGE_LIB not found: this bundle can run converted presets but not CONVERT new ones"
sed '/# BRIDGE-BEGIN/,/# BRIDGE-END/d' "$ROOT/flatpak/$APP_ID.yml" > "$CACHE/$APP_ID.yml"
fi
say "installing Freedesktop runtime (no-op if present)"
flatpak --user remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo || true
flatpak --user install -y flathub \
org.freedesktop.Platform//24.08 \
org.freedesktop.Sdk//24.08 \
|| warn "runtime install reported an error; flatpak-builder may still reuse cache"
say "building Flatpak ($APP_ID $VERSION)"
# Run from CACHE so path: sources resolve.
(
cd "$CACHE"
flatpak-builder --user --force-clean \
--state-dir="$STATE_DIR" \
--repo="$REPO_DIR" \
"$BUILD_DIR" "$APP_ID.yml"
)
OUT="$DIST/gen1recomp-${VERSION}-linux.flatpak"
rm -f "$OUT"
say "exporting bundle $OUT"
flatpak build-bundle "$REPO_DIR" "$OUT" "$APP_ID" \
--runtime-repo=https://flathub.org/repo/flathub.flatpakrepo
chmod 644 "$OUT"
if command -v sha256sum >/dev/null 2>&1; then
printf '%s %s\n' "$(sha256sum "$OUT" | awk '{print $1}')" "$(basename "$OUT")" > "$OUT.sha256"
else
printf '%s %s\n' "$(shasum -a 256 "$OUT" | awk '{print $1}')" "$(basename "$OUT")" > "$OUT.sha256"
fi
if [ -f "$CACHE/$BRIDGE_LIB" ]; then
[ -f "$BUILD_DIR/files/share/gen1recomp/$BRIDGE_LIB" ] \
|| fail "$BRIDGE_LIB was staged but is missing from the built /app/share/gen1recomp"
say "verified $BRIDGE_LIB in the Flatpak"
fi
say "Flatpak build: $OUT ($(du -h "$OUT" | cut -f1))"
say "sha256: $(cut -d' ' -f1 "$OUT.sha256")"
+15
View File
@@ -100,6 +100,21 @@ then
fi fi
cp "$GAME_LOVE" "$IN_DIR/game.love" 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 # --------------------------------------------------------------- downloads
# Fetched on the host and checksum-pinned here so the container never needs # Fetched on the host and checksum-pinned here so the container never needs
# network access and every input is verified in exactly one place. # network access and every input is verified in exactly one place.
+9 -1
View File
@@ -327,6 +327,14 @@ bundle_needed "$APPDIR/lib/liblove-$LOVE_VERSION.so"
say "bundled $(ls "$APPDIR/lib" | wc -l) libraries: $(ls "$APPDIR/lib" | tr '\n' ' ')" say "bundled $(ls "$APPDIR/lib" | wc -l) libraries: $(ls "$APPDIR/lib" | tr '\n' ' ')"
# ------------------------------------------------- host dependency contract # ------------------------------------------------- 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 # 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 # README: these are the ONLY sonames the shipped objects may require from the
# host. Everything driver-, session- or audio-related has to be reached # host. Everything driver-, session- or audio-related has to be reached
@@ -339,7 +347,7 @@ say "bundled $(ls "$APPDIR/lib" | wc -l) libraries: $(ls "$APPDIR/lib" | tr '\n'
HOST_ALLOWED_RE='^(ld-linux-aarch64\.so\.1|libc\.so\.6|libm\.so\.6|libdl\.so\.2|libpthread\.so\.0|librt\.so\.1|libstdc\+\+\.so\.6|libgcc_s\.so\.1|libatomic\.so\.1|libfreetype\.so\.6|libpng[0-9]*\.so\.[0-9]+|libz\.so\.1|libbrotli(dec|common)\.so\.1)$' 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="" unexpected=""
for object in "$APPDIR/bin/love" "$APPDIR"/lib/*.so*; do for object in "$APPDIR/bin/love" "$APPDIR"/lib/*.so* ${SHADER_BRIDGE[@]+"${SHADER_BRIDGE[@]}"}; do
while read -r soname; do while read -r soname; do
[ -n "$soname" ] || continue [ -n "$soname" ] || continue
# Satisfied from inside the AppDir, so not a host requirement at all. # Satisfied from inside the AppDir, so not a host requirement at all.
+28 -3
View File
@@ -20,6 +20,23 @@ for required in AppRun bin/love game.love lib/liblove-11.5.so; do
|| { echo "::error::AppImage is missing $required"; exit 1; } || { echo "::error::AppImage is missing $required"; exit 1; }
done 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 # Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
# applied; an unresolved soname here is a user-visible launch crash. # applied; an unresolved soname here is a user-visible launch crash.
# #
@@ -28,14 +45,14 @@ done
# hard-links libpulse/libasound/libX11/libwayland, so it only ever # hard-links libpulse/libasound/libX11/libwayland, so it only ever
# started on a full desktop -- a bare runner is what exposed it. # started on a full desktop -- a bare runner is what exposed it.
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \ missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \ ldd "${scan[@]}" 2>/dev/null \
| grep 'not found' || true)" | grep 'not found' || true)"
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; } [ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
# Nothing may hard-link a driver, session or audio-stack library: # Nothing may hard-link a driver, session or audio-stack library:
# those must be reached through dlopen so the AppImage runs on a box # those must be reached through dlopen so the AppImage runs on a box
# with only ALSA, only Wayland, or only KMSDRM. # with only ALSA, only Wayland, or only KMSDRM.
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do linked="$(for f in "${scan[@]}"; do
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}' 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)" done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
[ -z "$linked" ] \ [ -z "$linked" ] \
@@ -45,7 +62,7 @@ done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo
# the builder to a newer base, the glibc floor silently rises and # 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 # every user on an older distro gets "GLIBC_2.xx not found" -- catch
# it here instead of in a release. # it here instead of in a release.
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \ floor="$(objdump -T "${scan[@]}" 2>/dev/null \
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)" | grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
echo "highest required glibc symbol version: $floor" echo "highest required glibc symbol version: $floor"
[ -n "$floor" ] \ [ -n "$floor" ] \
@@ -54,4 +71,12 @@ highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
[ "$highest" = "GLIBC_2.31" ] \ [ "$highest" = "GLIBC_2.31" ] \
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; } || { 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" echo "AppImage verified: $image"
+1
View File
@@ -118,6 +118,7 @@ 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 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 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 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 # 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. # 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 run_tier "T0 NX asset overlay fallback" "$LUA" tests/engine/assets_version_fallback_test.lua
+55 -22
View File
@@ -34,6 +34,12 @@ local RomText = require("src.core.RomText")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local WideBattle = require("src.battle.WideBattle") 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 romText = RomText
local BattleState = {} local BattleState = {}
@@ -461,7 +467,7 @@ function BattleState:picImage(img)
-- palettes (CYANMON reds 0.678/0.451) already rendered correctly. This mode -- palettes (CYANMON reds 0.678/0.451) already rendered correctly. This mode
-- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync. -- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync.
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
or PaletteFX.mode == "classic" or PaletteFX.mode == "classic" or PaletteFX.forcesRawGrays()
if self.grayPics or mono then return grayImage(img) end if self.grayPics or mono then return grayImage(img) end
-- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go -- 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 -- dark with the HP bars while the blackout text is up (#292). The intro
@@ -5989,7 +5995,7 @@ function BattleState:drawZonePass(src, sx, sy)
-- only the other two showed it. Keep this mode set in sync with picImage / -- only the other two showed it. Keep this mode set in sync with picImage /
-- PaletteFX.ensureZones / WideBattle.monoMode. -- PaletteFX.ensureZones / WideBattle.monoMode.
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
or PaletteFX.mode == "classic" or PaletteFX.mode == "classic" or PaletteFX.forcesRawGrays()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.setShader(shader) love.graphics.setShader(shader)
local shaking = sx ~= 0 or sy ~= 0 local shaking = sx ~= 0 or sy ~= 0
@@ -6399,8 +6405,35 @@ end
function BattleState:drawTextArea() function BattleState:drawTextArea()
if not self:bottomUIVisible() then return end if not self:bottomUIVisible() then return end
Font.drawBox(0, 12, 20, 6) -- Gold only: routes the command box and its labels through GbcPalette,
love.graphics.setColor(0, 0, 0, 1) -- the same fix TextBox.lua got for dialogue. moveSelect/mimicSelect below
-- aren't migrated yet since they need their own interior-patch handling
-- (see #240).
local gold = self.game and self.game.save
and (self.game.save.generation == 2 or self.game.save.version == "gold")
local Chrome = gold and Chrome2 or nil
local function box(tx, ty, tw, th)
if Chrome then
Chrome.paletteBox(tx, ty, tw, th)
else
Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1)
end
end
local drawGlyph, finishGlyph = Font.drawCode, nil
if Chrome then
local _, dg, fg = Chrome.paletteGlyphs(Chrome.DEFAULT_BOX_PALETTE)
drawGlyph, finishGlyph = dg, fg
end
local function text(str, x, y)
local pen = x
for _, code in ipairs(Font.encode(str)) do
drawGlyph(code, pen, y)
pen = pen + Font.advanceOf(code)
end
end
box(0, 12, 20, 6)
if self.phase == "messages" if self.phase == "messages"
and (self.current or self.animPlaying or self.msgHold) then and (self.current or self.animPlaying or self.msgHold) then
-- during the move animation self.current is nil but shown still holds -- during the move animation self.current is nil but shown still holds
@@ -6419,48 +6452,47 @@ function BattleState:drawTextArea()
for li, line in ipairs(self.shown or {}) do for li, line in ipairs(self.shown or {}) do
local y = (ys[li] or 128) + off local y = (ys[li] or 128) + off
for i = 1, #line do for i = 1, #line do
Font.drawCode(line[i], 8 + (i - 1) * 8, y) drawGlyph(line[i], 8 + (i - 1) * 8, y)
end end
end end
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait -- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait
-- (_ContText) or a typed-out page (PromptText) holds the box; both write -- (_ContText) or a typed-out page (PromptText) holds the box; both write
-- it at (18,16), bottom-right, like TextBox / home/text.asm (#317) -- it at (18,16), bottom-right, like TextBox / home/text.asm (#317)
if (self.msgWaiting or self.msgPrompt) and self.frame % 60 < 30 then if (self.msgWaiting or self.msgPrompt) and self.frame % 60 < 30 then
Font.drawCode(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4) drawGlyph(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4)
end end
elseif self.phase == "menu" and self.demo then elseif self.phase == "menu" and self.demo then
-- the old-man script (DisplayBattleMenu, core.asm:2038-2049): the -- the old-man script (DisplayBattleMenu, core.asm:2038-2049): the
-- standard menu, with the '▶' hand drawn by the scripted keystrokes -- standard menu, with the '▶' hand drawn by the scripted keystrokes
-- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16) -- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16)
Font.drawBox(8, 12, 12, 6) box(8, 12, 12, 6)
love.graphics.setColor(0, 0, 0, 1) text(Strings("FIGHT", "battle"), 80, 112)
Font.draw(Strings("FIGHT", "battle"), 80, 112) drawGlyph(0xE1, 128, 112); drawGlyph(0xE2, 136, 112)
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112) text(Strings("ITEM", "battle"), 80, 128); text(Strings("RUN", "battle"), 128, 128)
Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128) drawGlyph(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128)
Font.drawCode(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128)
elseif self.phase == "menu" then elseif self.phase == "menu" then
local col = (self.menuIndex - 1) % 2 local col = (self.menuIndex - 1) % 2
local row = math.floor((self.menuIndex - 1) / 2) local row = math.floor((self.menuIndex - 1) / 2)
if self.safari then if self.safari then
-- SAFARI_BATTLE_MENU_TEMPLATE: full-width box, "BALLx BAIT / -- SAFARI_BATTLE_MENU_TEMPLATE: full-width box, "BALLx BAIT /
-- THROW ROCK RUN" from (2,14) -- THROW ROCK RUN" from (2,14)
Font.drawBox(0, 12, 20, 6) box(0, 12, 20, 6)
Font.draw(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112) text(Strings("BALLx"), 16, 112); text(Strings("BAIT"), 112, 112)
Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN", "battle"), 112, 128) text(Strings("THROW ROCK"), 16, 128); text(Strings("RUN", "battle"), 112, 128)
-- DisplayBattleMenu .safariLeftColumn / .safariRightColumn print -- DisplayBattleMenu .safariLeftColumn / .safariRightColumn print
-- wNumSafariBalls at hlcoord 7,14 with `lb bc, 1, 2` -- one byte, two -- wNumSafariBalls at hlcoord 7,14 with `lb bc, 1, 2` -- one byte, two
-- digits, space padded -- right after the "BALLx" label at columns -- digits, space padded -- right after the "BALLx" label at columns
-- 2..6 (engine/battle/core.asm:2074-2079, 2107-2112) (#540) -- 2..6 (engine/battle/core.asm:2074-2079, 2107-2112) (#540)
Font.draw(("%2d"):format(self.safari.balls), 56, 112) text(("%2d"):format(self.safari.balls), 56, 112)
Font.drawCode(0xED, (col == 0 and 8 or 104), 112 + row * 16) drawGlyph(0xED, (col == 0 and 8 or 104), 112 + row * 16)
else else
-- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT <PK><MN> / -- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT <PK><MN> /
-- ITEM RUN" from (10,14); cursor columns 9 / 15 -- ITEM RUN" from (10,14); cursor columns 9 / 15
Font.drawBox(8, 12, 12, 6) box(8, 12, 12, 6)
Font.draw(Strings("FIGHT", "battle"), 80, 112) text(Strings("FIGHT", "battle"), 80, 112)
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112) drawGlyph(0xE1, 128, 112); drawGlyph(0xE2, 136, 112)
Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128) text(Strings("ITEM", "battle"), 80, 128); text(Strings("RUN", "battle"), 128, 128)
Font.drawCode(0xED, (col == 0 and 72 or 120), 112 + row * 16) drawGlyph(0xED, (col == 0 and 72 or 120), 112 + row * 16)
end end
elseif self.phase == "moveSelect" then elseif self.phase == "moveSelect" then
-- pokered MoveSelectionMenu: move list in a box at (4,12) 16x6, -- pokered MoveSelectionMenu: move list in a box at (4,12) 16x6,
@@ -6535,6 +6567,7 @@ function BattleState:drawTextArea()
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8) Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112) Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
end end
if finishGlyph then finishGlyph() end
end end
function BattleState:draw() function BattleState:draw()
+1 -5
View File
@@ -26,14 +26,10 @@ local WideBattle = {
FIELD_BOTTOM = 104, 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 function monoMode()
local m = PaletteFX.mode local m = PaletteFX.mode
return m == "og" or m == "og_inv" or m == "classic" return m == "og" or m == "og_inv" or m == "classic"
or PaletteFX.forcesRawGrays()
end end
local function shownHP(battler) local function shownHP(battler)
+1
View File
@@ -1258,6 +1258,7 @@ function Game:applyOptions(opts)
if Sound.applyOptions then Sound.applyOptions(opts) end if Sound.applyOptions then Sound.applyOptions(opts) end
require("src.render.PaletteFX").applyOptions(opts) require("src.render.PaletteFX").applyOptions(opts)
require("src.render.Tilt").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 -- after Tilt, so a persisted world pipeline can switch the tilt level it
-- just restored back off (the two are mutually exclusive) -- just restored back off (the two are mutually exclusive)
require("src.render.Pipelines").applyOptions(opts) require("src.render.Pipelines").applyOptions(opts)
+2
View File
@@ -1787,6 +1787,7 @@ function Game2:hotkey(key)
local GbcPalette = require("src.render.GbcPalette") local GbcPalette = require("src.render.GbcPalette")
GbcPalette.setMode(options.color or "gbc") GbcPalette.setMode(options.color or "gbc")
options.color = GbcPalette.cycle(1) options.color = GbcPalette.cycle(1)
options.palette = ""
persist() persist()
return true return true
elseif key == "3" then elseif key == "3" then
@@ -2040,6 +2041,7 @@ function Game2:applyOptions()
Zoom.allowSurvey = caps.survey Zoom.allowSurvey = caps.survey
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
require("src.render.Tilt").applyOptions(options) require("src.render.Tilt").applyOptions(options)
require("src.render.Letterbox").applyOptions(options)
require("src.render.GbcPalette").applyOptions(options) require("src.render.GbcPalette").applyOptions(options)
-- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame. -- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame.
Font.setFrame(options.frame or 1) Font.setFrame(options.frame or 1)
+18 -1
View File
@@ -57,7 +57,10 @@ function GamepadMap._setForceNXForTests(v)
end end
local function nxActive() local function nxActive()
if GamepadMap._forceNXForTests then return true end if GamepadMap._forceNXForTests == true then return true end
if GamepadMap._forceNXForTests == false and love and (love._os ~= "NX" and (not love.system or love.system.getOS() ~= "NX")) then
return false
end
if love and love._os == "NX" then return true end if love and love._os == "NX" then return true end
if love and love.system and love.system.getOS() == "NX" then return true end if love and love.system and love.system.getOS() == "NX" then return true end
return false return false
@@ -78,6 +81,20 @@ function GamepadMap.mapGamepadButton(button)
return GamepadMap.gamepadBindings()[button] return GamepadMap.gamepadBindings()[button]
end 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+face display chords (docs / Nintendo UX):
-- Select+A → "2" (COLORS), Select+B → "3" (TILT), -- Select+A → "2" (COLORS), Select+B → "3" (TILT),
-- Select+Y → "5", Select+X → "6", Select+L (leftshoulder) → "7". -- Select+Y → "5", Select+X → "6", Select+L (leftshoulder) → "7".
+114 -24
View File
@@ -4,18 +4,79 @@ local HostShell = {}
-- Our AppRun exports LD_LIBRARY_PATH="$APPDIR/lib:..." so every subprocess we -- 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 -- spawn tries to link against the libraries we're shipping instead of the
-- system ones. We want to unset the var so that any system tools can find -- system ones. Host tools (curl, zenity) need that unset. A *bundled* AppDir
-- their proper libraries. Only needed when running in an AppImage. -- curl must keep APPDIR on LD_LIBRARY_PATH so it resolves the bundled
-- LD_PRELOAD is Steam's overlay (#1470): its 32-bit half cannot load into a -- libssl/libcrypto -- scrubbing it forces host OpenSSL and segfaults across
-- 64-bit child, so ld.so prints an error into that child's output. -- distros. LD_PRELOAD is always scrubbed: Steam's overlay (#1470) cannot load
-- into a 64-bit child.
function HostShell.envPrefix() 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 = "" local unset = ""
if os.getenv("APPIMAGE") then unset = unset .. "-u LD_LIBRARY_PATH " end if kind ~= "bundled" and os.getenv("APPIMAGE") then
unset = unset .. "-u LD_LIBRARY_PATH "
end
if os.getenv("LD_PRELOAD") then unset = unset .. "-u LD_PRELOAD " end if os.getenv("LD_PRELOAD") then unset = unset .. "-u LD_PRELOAD " end
if unset == "" then return "" end if unset == "" then return "" end
return "env " .. unset return "env " .. unset
end 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 -- 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 -- 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 -- spawned through io.popen / os.execute, which run it under cmd.exe. A
@@ -126,12 +187,18 @@ local function withPopenLock(fn)
if not okAtomic then fn() end if not okAtomic then fn() end
end end
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed -- Wraps io.popen with the AppImage env fix applied and lua errors swallowed.
function HostShell.popen(command, mode) -- opts.envPrefix overrides the default HostShell.envPrefix() (pass "" to skip,
-- or curlEnvPrefix(kind) when spawning a resolved curl binary).
function HostShell.popen(command, mode, opts)
HostShell.releasePointerGrab() HostShell.releasePointerGrab()
local prefix = HostShell.envPrefix()
if type(opts) == "table" and opts.envPrefix ~= nil then
prefix = opts.envPrefix
end
local pipe local pipe
withPopenLock(function() withPopenLock(function()
local ok, p = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r") local ok, p = pcall(io.popen, prefix .. command, mode or "r")
pipe = (ok and p) or nil pipe = (ok and p) or nil
end) end)
return pipe return pipe
@@ -193,6 +260,12 @@ function HostShell.restart()
local appimage = os.getenv("APPIMAGE") local appimage = os.getenv("APPIMAGE")
if not appimage then 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") love.event.quit("restart")
return return
end end
@@ -341,7 +414,10 @@ local curlAvailable = nil
function HostShell.haveCurl() function HostShell.haveCurl()
if curlAvailable ~= nil then return curlAvailable end if curlAvailable ~= nil then return curlAvailable end
local pipe = HostShell.popen("curl --version") local path, kind = HostShell.resolveCurl()
local pipe = HostShell.popen(
HostShell.quote(path) .. " --version", "r",
{ envPrefix = HostShell.curlEnvPrefix(kind) })
if not pipe then curlAvailable = false return false end if not pipe then curlAvailable = false return false end
local readOk, out = pcall(function() return pipe:read("*a") end) local readOk, out = pcall(function() return pipe:read("*a") end)
HostShell.pclose(pipe) HostShell.pclose(pipe)
@@ -349,6 +425,12 @@ function HostShell.haveCurl()
return curlAvailable return curlAvailable
end 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" -- An older mobile build reports nil here and falls back to the "no transport"
-- error the callers already show. -- error the callers already show.
local function haveBridge() local function haveBridge()
@@ -409,9 +491,11 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime, etagPa
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
userAgent = userAgent or "gen1recomp" userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then if HostShell.haveCurl() then
local cmd = ("curl -fsSL --proto =http,https --proto-redir =http,https " local head, popts = curlCmd(
.. "--connect-timeout 15 --max-time %d ") ("-fsSL --proto =http,https --proto-redir =http,https "
:format(tonumber(maxTime) or 300) .. "--connect-timeout 15 --max-time %d ")
:format(tonumber(maxTime) or 300))
local cmd = head
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " " .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then if accept then
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " " cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
@@ -423,7 +507,7 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime, etagPa
cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " " cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " "
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1" .. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd) local pipe = HostShell.popen(cmd, "r", popts)
if not pipe then return nil, "could not start download" end if not pipe then return nil, "could not start download" end
local readOk, out = pcall(function() return pipe:read("*a") end) local readOk, out = pcall(function() return pipe:read("*a") end)
HostShell.pclose(pipe) HostShell.pclose(pipe)
@@ -466,16 +550,18 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
-- BODY, and on the two services this talks to that body is the whole -- 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 -- 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. -- tells a user to wait rather than to go hunting for a broken index.
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https " local head, popts = curlCmd(
.. "--connect-timeout 10 --max-time %d ") ("-sSL --proto =http,https --proto-redir =http,https "
:format(tonumber(maxTime) or 40) .. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40))
local cmd = head
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " " .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then if accept then
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " " cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
end end
cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1" .. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd) local pipe = HostShell.popen(cmd, "r", popts)
if not pipe then return nil, "could not run curl" end if not pipe then return nil, "could not run curl" end
local readOk, out = pcall(function() return pipe:read("*a") end) local readOk, out = pcall(function() return pipe:read("*a") end)
HostShell.pclose(pipe) HostShell.pclose(pipe)
@@ -562,9 +648,11 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
-- newlines. The body is staged above because io.popen cannot be opened -- newlines. The body is staged above because io.popen cannot be opened
-- for both writing and reading. No -f, matching httpGet: the response -- for both writing and reading. No -f, matching httpGet: the response
-- body is discarded anyway, and curl's stderr carries the diagnosis. -- body is discarded anyway, and curl's stderr carries the diagnosis.
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https " local head, popts = curlCmd(
.. "--connect-timeout 10 --max-time %d ") ("-sSL --proto =http,https --proto-redir =http,https "
:format(tonumber(maxTime) or 40) .. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40))
local cmd = head
.. "-X POST " .. "-X POST "
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " " .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if contentType then if contentType then
@@ -574,7 +662,7 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
.. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " " .. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " "
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1" .. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd) local pipe = HostShell.popen(cmd, "r", popts)
if not pipe then if not pipe then
pcall(os.remove, bodyPath) pcall(os.remove, bodyPath)
return nil, "could not run curl" return nil, "could not run curl"
@@ -751,8 +839,10 @@ function HostShell.httpRequest(url, opts)
pcall(os.remove, headerPath) pcall(os.remove, headerPath)
end end
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https " local head, popts = curlCmd(
.. "--connect-timeout 10 --max-time %d "):format(maxTime) ("-sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d "):format(maxTime))
local cmd = head
.. "-X " .. HostShell.quote(method) .. " " .. "-X " .. HostShell.quote(method) .. " "
.. "-H " .. HostShell.quote("@" .. headerPath) .. " " .. "-H " .. HostShell.quote("@" .. headerPath) .. " "
if body then if body then
@@ -761,7 +851,7 @@ function HostShell.httpRequest(url, opts)
cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1" .. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd) local pipe = HostShell.popen(cmd, "r", popts)
if not pipe then if not pipe then
cleanup() cleanup()
return nil, "could not run curl" return nil, "could not run curl"
+37 -2
View File
@@ -173,13 +173,40 @@ function SaveData.gameFolders()
end end
-- The game folder carrying portable.txt, or false. First candidate holding -- The game folder carrying portable.txt, or false. First candidate holding
-- the marker wins. -- the marker *and* a writable probe wins. Flatpak installs always use the
-- sandbox XDG save dir (portable USB mode does not apply). A read-only
-- parent (system /opt, immutable OS, etc.) falls back to LOVE's save
-- directory without throwing -- probe I/O is fully guarded.
local function dirIsWritable(dir)
if type(dir) ~= "string" or dir == "" then return false end
local ok, writable = pcall(function()
local name = string.format(".write_probe_%d_%d.tmp",
os.time() % 100000000, math.random(0, 999999))
local path = dir .. SEP .. name
local f, err = io.open(path, "wb")
if not f then return false end
local wrote = f:write("ok")
f:close()
pcall(os.remove, path)
return wrote ~= nil
end)
return ok and writable == true
end
local function detectPortable() local function detectPortable()
if portableChecked then return portableBase end if portableChecked then return portableBase end
portableChecked = true portableChecked = true
portableBase = false 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 for _, base in ipairs(SaveData.gameFolders()) do
if pathExists(base .. SEP .. PORTABLE_MARKER) then local markerOk = false
pcall(function()
markerOk = pathExists(base .. SEP .. PORTABLE_MARKER)
end)
if markerOk and dirIsWritable(base) then
portableBase = base portableBase = base
break break
end end
@@ -187,6 +214,13 @@ local function detectPortable()
return portableBase return portableBase
end 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() function SaveData.isPortable()
return detectPortable() ~= false return detectPortable() ~= false
end end
@@ -279,6 +313,7 @@ function SaveData.defaultOptions()
zoom = 0, zoom = 0,
-- OVERWORLD beyond-edge fill: trees | water | black -- OVERWORLD beyond-edge fill: trees | water | black
voidFill = "trees", voidFill = "trees",
uiLetterbox = "auto",
-- windowed | borderless (desktop fullscreen); ignored on mobile -- windowed | borderless (desktop fullscreen); ignored on mobile
videoMode = "windowed", videoMode = "windowed",
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see -- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
+2
View File
@@ -309,6 +309,7 @@ Save.DEFAULT_OPTIONS = {
-- Game Boy. The Gen 1 save's equivalent key is `colors` (SGB packs), which -- Game Boy. The Gen 1 save's equivalent key is `colors` (SGB packs), which
-- means something different, hence the different name. -- means something different, hence the different name.
color = "gbc", color = "gbc",
palette = "",
videoMode = "windowed", videoMode = "windowed",
fpsCap = 60, fpsCap = 60,
-- BATTLE BG (#1709): white | black, the surround around the battle screen. -- BATTLE BG (#1709): white | black, the surround around the battle screen.
@@ -316,6 +317,7 @@ Save.DEFAULT_OPTIONS = {
-- VOID FILL: fade | water | trees | black. fade is each map header's own -- VOID FILL: fade | water | trees | black. fade is each map header's own
-- border block with the dissolve across a boundary (#1418). -- border block with the dissolve across a boundary (#1418).
voidFill = "fade", voidFill = "fade",
uiLetterbox = "auto",
musicVol = 7, -- 0-7, like the GB's NR50 master volume musicVol = 7, -- 0-7, like the GB's NR50 master volume
sfxVol = 7, -- 0-7 sfxVol = 7, -- 0-7
musicFilter = 0, -- low-pass steps, 0 = off musicFilter = 0, -- low-pass steps, 0 = off
+1
View File
@@ -605,6 +605,7 @@ local function gen2Rows(opts, hooks, shared)
end end
opts.color = opts.color =
GbcPalette.MODES[wrapIndex(idx - 1 + dir, #GbcPalette.MODES) + 1] GbcPalette.MODES[wrapIndex(idx - 1 + dir, #GbcPalette.MODES) + 1]
opts.palette = ""
return true return true
end) end)
end end
+82 -9
View File
@@ -377,12 +377,19 @@ end
-- visible while typing, and blinks a caret on the importer's pulse clock. -- 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) local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, action)
Kit._audit("control", x, y, w, h, key) Kit._audit("control", x, y, w, h, key)
Kit.focusable(key, x, y, w, h) local isFocused = Kit.focusable(key, x, y, w, h)
Theme.fill(x, y, w, h, PAL.bg, 1) Theme.fill(x, y, w, h, PAL.bg, 1)
Theme.stroke(x, y, w, h, PAL.line, if isFocused then
focused and Theme.A.focus or local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
(Kit.hover(x, y, w, h) and Theme.A.hover or Theme.A.hairline), Theme.strokeRounded(x - 3, y - 3, w + 6, h + 6, PAL.ink, 0.35 + 0.25 * glowPulse, 2.5, 4)
focused and 2 or 1) Theme.strokeRounded(x, y, w, h, PAL.ink, 0.95 + 0.05 * glowPulse, 2, 2)
Theme.fillRounded(x, y, w, h, PAL.ink, 0.12 + 0.06 * glowPulse, 2)
else
Theme.stroke(x, y, w, h, PAL.line,
focused and Theme.A.focus or
(Kit.hover(x, y, w, h) and Theme.A.hover or Theme.A.hairline),
focused and 2 or 1)
end
local pad = math.floor(10 * Kit.scale) local pad = math.floor(10 * Kit.scale)
local ty = y + (h - Kit.textHeight("button")) / 2 local ty = y + (h - Kit.textHeight("button")) / 2
local text = rawText or "" local text = rawText or ""
@@ -397,8 +404,35 @@ local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, ac
Kit.textHeight("button"), PAL.ink, 1) Kit.textHeight("button"), PAL.ink, 1)
end end
end end
if action and (Kit.press(x, y, w, h) or Kit._activateId == key) then if (Kit.press(x, y, w, h) or Kit._activateId == key) then
queueAction(imp, key, action) if Kit.VirtualKeyboard then
Kit.VirtualKeyboard.open({
text = text,
targetId = key,
title = placeholder or "Enter Text",
onDone = function(newText, confirmed)
if confirmed then
if imp._indexPrompt and key:find("index") then
imp._indexPrompt.text = newText
elseif imp._rename and key:find("rename") then
imp._rename.text = newText
elseif imp._profileRenamePrompt and key:find("profren") then
imp._profileRenamePrompt.text = newText
elseif imp._profileSavePrompt and key:find("profsave") then
imp._profileSavePrompt.text = newText
elseif imp._settingsText and key:find("settext") then
imp._settingsText.text = newText
elseif imp.tab == "find" then
imp._findQuery = newText
if imp._refreshFind then imp:_refreshFind() end
end
end
end
})
end
if action then
queueAction(imp, key, action)
end
end end
end end
@@ -3406,13 +3440,19 @@ local function buildFooter(imp, m, y)
local bx = m.x + math.floor((m.w - topW) / 2) local bx = m.x + math.floor((m.w - topW) / 2)
local my = cy + math.floor((rowH - dh) / 2) local my = cy + math.floor((rowH - dh) / 2)
local chipY = cy + math.floor((rowH - chipH) / 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) 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.setShader(imp.invertShader)
love.graphics.setColor(1, 1, 1, hot and 1 or 0.85) love.graphics.setColor(1, 1, 1, (hot or isFocused) and 1 or 0.85)
love.graphics.draw(imp.bcg, Theme.snap(bx), Theme.snap(my), 0, scale, scale) love.graphics.draw(imp.bcg, Theme.snap(bx), Theme.snap(my), 0, scale, scale)
love.graphics.setShader() love.graphics.setShader()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
if Kit.press(bx, my, dw, dh) then if Kit.press(bx, my, dw, dh) or Kit._activateId == "bcg" then
queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end) queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end)
end end
local cx = bx + dw local cx = bx + dw
@@ -5604,6 +5644,8 @@ end
-- a click on the scrim lands on whatever button happens to be behind it. -- a click on the scrim lands on whatever button happens to be behind it.
-- Keep this list in sync with buildModals below. -- Keep this list in sync with buildModals below.
local function modalUp(imp) 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 return (imp._settingsText or imp._settings or imp._rename
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
or imp._appPatchNotes or imp._appPatchNotes
@@ -5616,6 +5658,14 @@ local function modalUp(imp)
end end
local function buildModals(imp, m) 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 if imp._profileRenamePrompt then
buildPrompt(imp, m, { buildPrompt(imp, m, {
key = "profren", title = Strings("Rename profile"), key = "profren", title = Strings("Rename profile"),
@@ -5787,6 +5837,10 @@ end
local function drawPadCursor(imp) local function drawPadCursor(imp)
if not imp._padCursorActive then return end 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 -- Pixel-snap on NX: subpixel polygon edges shimmer on the 720p Switch
-- framebuffer when the stick advances by fractional pixels each frame. -- framebuffer when the stick advances by fractional pixels each frame.
local x, y = imp._padCursor.x, imp._padCursor.y local x, y = imp._padCursor.x, imp._padCursor.y
@@ -5970,6 +6024,25 @@ function LauncherView.draw(imp)
Kit.endFrame() Kit.endFrame()
drawPadCursor(imp) 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 end
return LauncherView return LauncherView
+279 -33
View File
@@ -1263,7 +1263,12 @@ end
-- up the background worker or reach out to the network. -- up the background worker or reach out to the network.
local function updaterAllowed() local function updaterAllowed()
if not Platform.networkValidated() then return false end if not Platform.networkValidated() then return false end
if not (love.filesystem.isFused and love.filesystem.isFused()) then return false end local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
if not (love.filesystem.isFused and love.filesystem.isFused()) and not isHandheld then
return false
end
if os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER") then return false end if os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER") then return false end
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
return true return true
@@ -2055,8 +2060,42 @@ function RomImporter:chooseMod()
end end
return return
end 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() local path = chooseZip()
if path then self:_installMod(path) end if path then
self:_installMod(path)
return
end
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
if okKit and Kit.FileBrowser then
self._padCursorActive = false
Kit.FileBrowser.open({
title = "Select Mod (.zip)",
mode = "mod",
onSelect = function(pickedPath)
self:_installMod(pickedPath)
end,
})
return
end
end end
local function requiredManifest(self, modId) local function requiredManifest(self, modId)
@@ -2479,8 +2518,42 @@ function RomImporter:chooseSaveImport(version)
end end
return return
end 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() local path = chooseSav()
if path then self:_importSave(version, path) end if path then
self:_importSave(version, path)
return
end
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
if okKit and Kit.FileBrowser then
self._padCursorActive = false
Kit.FileBrowser.open({
title = "Select Save (.sav)",
mode = "save",
onSelect = function(pickedPath)
self:_importSave(version, pickedPath)
end,
})
return
end
end end
-- "Export save" button: write the active slot back out to a raw .sav in the save -- "Export save" button: write the active slot back out to a raw .sav in the save
@@ -2621,11 +2694,42 @@ function RomImporter:choose(version)
end end
return return
end 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) local path = chooseRom(GameVersion.info(self.chooseVersion).displayName)
if path then if path then
self:startPath(path) self:startPath(path)
return return
end 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 -- 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 -- 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 -- used on Android, which works when the game is launched as an unpacked
@@ -2998,6 +3102,14 @@ function RomImporter:_cycleTab(delta)
end end
function RomImporter:_updatePadCursor(dt) 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 if self.isNX then
self:_ensureNxPointerBridge() self:_ensureNxPointerBridge()
-- Cap dt so a hitch in the FlexLove immediate-mode frame does not fling -- Cap dt so a hitch in the FlexLove immediate-mode frame does not fling
@@ -3040,14 +3152,6 @@ function RomImporter:_updatePadCursor(dt)
local ny = self._padCursor.y + dy * speed * dt local ny = self._padCursor.y + dy * speed * dt
self._padCursor.x = math.max(ox, math.min(ox + w, nx)) self._padCursor.x = math.max(ox, math.min(ox + w, nx))
self._padCursor.y = math.max(oy, math.min(oy + h, ny)) 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 local overY = 0
if ny > oy + h then overY = ny - (oy + h) if ny > oy + h then overY = ny - (oy + h)
elseif ny < oy then overY = ny - oy end elseif ny < oy then overY = ny - oy end
@@ -3055,17 +3159,12 @@ function RomImporter:_updatePadCursor(dt)
if overY ~= 0 and self._flex then if overY ~= 0 and self._flex then
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48) require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
end 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 if not self.isNX and love.mouse.setPosition then
pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y) pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y)
self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y
end end
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 local ry = self._padAxis.righty or 0
if math.abs(ry) > PAD_DEAD and self._flex then if math.abs(ry) > PAD_DEAD and self._flex then
self:_activatePadCursor() self:_activatePadCursor()
@@ -3074,30 +3173,168 @@ function RomImporter:_updatePadCursor(dt)
end end
function RomImporter:gamepadpressed(_, button) function RomImporter:gamepadpressed(_, button)
self:_activatePadCursor() local action = (GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button
-- Map through GamepadMap so NX swaps SDL face labels to Nintendo A/B. local okKit, Kit = pcall(require, "src.ui.kit.Kit")
local action = GamepadMap.mapGamepadButton(button) if okKit then
if action == "a" then if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then
-- Instant click at the virtual pointer: dispatched straight into the if Kit.VirtualKeyboard.gamepadpressed(action) then return end
-- 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 end
elseif button == "leftshoulder" then if Kit.FileBrowser and Kit.FileBrowser.active then
if Kit.FileBrowser.gamepadpressed(action) then return end
end
end
-- Y button toggle between Native Controller Navigation and Virtual Pointer Cursor:
if action == "y" or button == "y" then
self._padCursorActive = not self._padCursorActive
if okKit then Kit._ringShown = not self._padCursorActive end
self._cursorModeToast = self._padCursorActive and "Cursor Navigation [Y]" or "Controller Menu Navigation [Y]"
self._cursorModeToastTime = love.timer.getTime()
return
end
-- Select button: toggle Virtual Keyboard
if button == "back" or button == "select" or action == "select" then
if okKit and Kit.VirtualKeyboard then
if Kit.VirtualKeyboard.active then
Kit.VirtualKeyboard.close(false)
return
end
if self._indexPrompt then
Kit.VirtualKeyboard.open({
text = self._indexPrompt.text or "",
title = "Add a mod index",
onDone = function(newText, confirmed)
if confirmed and self._indexPrompt then self._indexPrompt.text = newText end
end
})
return
elseif self._rename then
Kit.VirtualKeyboard.open({
text = self._rename.text or "",
title = "Name save slot",
onDone = function(newText, confirmed)
if confirmed and self._rename then self._rename.text = newText end
end
})
return
elseif self._profileRenamePrompt then
Kit.VirtualKeyboard.open({
text = self._profileRenamePrompt.text or "",
title = "Rename profile",
onDone = function(newText, confirmed)
if confirmed and self._profileRenamePrompt then self._profileRenamePrompt.text = newText end
end
})
return
elseif self._profileSavePrompt then
Kit.VirtualKeyboard.open({
text = self._profileSavePrompt.text or "",
title = "Save mod profile",
onDone = function(newText, confirmed)
if confirmed and self._profileSavePrompt then self._profileSavePrompt.text = newText end
end
})
return
elseif self._settingsText then
Kit.VirtualKeyboard.open({
text = self._settingsText.text or "",
title = (self._settingsText.row and self._settingsText.row.label) or "Edit Text",
onDone = function(newText, confirmed)
if confirmed and self._settingsText then self._settingsText.text = newText end
end
})
return
elseif self.tab == "find" then
Kit.VirtualKeyboard.open({
text = self._findQuery or "",
title = "Search Mods",
onDone = function(newText, confirmed)
if confirmed then
self._findQuery = newText
if self._refreshFind then self:_refreshFind() end
end
end
})
return
else
Kit.VirtualKeyboard.open({
text = "",
title = "Virtual Keyboard",
onDone = function() end
})
return
end
end
end
-- Shoulder buttons: cycle tabs
if button == "leftshoulder" then
self:_cycleTab(-1) self:_cycleTab(-1)
return
elseif button == "rightshoulder" then elseif button == "rightshoulder" then
self:_cycleTab(1) self:_cycleTab(1)
elseif button == "dpup" or button == "dpdown" return
or button == "dpleft" or button == "dpright" then end
self._padDir[button] = true
elseif button == "start" or button == "back" then -- Start button: Play / Choose ROM
-- Start / Select: Play if ready, else Choose ROM on the active game tab. if button == "start" then
if self.workState == "working" then return end if self.workState == "working" then return end
local version = self.tab local version = self.tab
if GameVersion.VERSIONS[version] then if GameVersion.VERSIONS[version] then
if self.ready[version] then self:play(version) else self:_romAction(version) end if self.ready[version] then self:play(version) else self:_romAction(version) end
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
end end
@@ -3111,9 +3348,9 @@ end
function RomImporter:gamepadaxis(_, axis, value) function RomImporter:gamepadaxis(_, axis, value)
if axis == "leftx" or axis == "lefty" or axis == "righty" then if axis == "leftx" or axis == "lefty" or axis == "righty" then
self._padAxis[axis] = value self._padAxis[axis] = value
if math.abs(value) > PAD_DEAD then if self._padCursorActive and math.abs(value) > PAD_DEAD then
self:_activatePadCursor() self:_activatePadCursor()
elseif axis == "lefty" then elseif axis == "lefty" and math.abs(value) <= PAD_DEAD then
self._padStickCentered = true self._padStickCentered = true
end end
end end
@@ -4062,6 +4299,15 @@ function RomImporter:fileUrl(path)
end end
function RomImporter:keypressed(key) 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 self._profileSavePrompt then
if key == "backspace" then if key == "backspace" then
self._profileSavePrompt.text = utf8Back(self._profileSavePrompt.text or "") self._profileSavePrompt.text = utf8Back(self._profileSavePrompt.text or "")
+40 -5
View File
@@ -18,8 +18,6 @@
-- colour IS its palettes -- so one substitution at this seam recolours the -- colour IS its palettes -- so one substitution at this seam recolours the
-- whole game without a single screen knowing about it: -- 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 -- DMG the original grey Game Boy. Every palette collapses to the
-- four hardware shades, and the sheets that are drawn straight -- four hardware shades, and the sheets that are drawn straight
-- (chrome, text) are already those shades, so the screen is -- (chrome, text) are already those shades, so the screen is
@@ -32,9 +30,12 @@
local GbcPalette = {} local GbcPalette = {}
GbcPalette.MODES = { "gbc", "dmg", "classic" } GbcPalette.MODES = { "gbc", "dmg", "classic" }
GbcPalette.MODE_LABELS = { gbc = "GBC", dmg = "DMG", classic = "CLASSIC" } GbcPalette.MODE_LABELS = { gbc = "GEN 2", dmg = "DMG", classic = "CLASSIC",
custom = "GBC" }
GbcPalette.mode = "gbc" GbcPalette.mode = "gbc"
GbcPalette.CUSTOM_MODE = "custom"
-- rBGP's four shades as the hardware shows them. -- rBGP's four shades as the hardware shows them.
local DMG_SHADES = { local DMG_SHADES = {
{ 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 }, { 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 },
@@ -162,11 +163,22 @@ local function channel(colors, index)
return (c[1] or 0) / 255, (c[2] or 0) / 255, (c[3] or 0) / 255 return (c[1] or 0) / 255, (c[2] or 0) / 255, (c[3] or 0) / 255
end 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 -- 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 -- 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 -- 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. -- cart colour while everything on top of it went grey.
function GbcPalette.resolve(colors) function GbcPalette.resolve(colors)
if GbcPalette.customRamp then return GbcPalette.customRamp end
if GbcPalette.mode == "gbc" then return colors end if GbcPalette.mode == "gbc" then return colors end
return DMG_SHADES return DMG_SHADES
end end
@@ -249,6 +261,10 @@ function GbcPalette.presentColors()
end end
function GbcPalette.setMode(mode) function GbcPalette.setMode(mode)
if mode == GbcPalette.CUSTOM_MODE then
GbcPalette.mode = mode
return mode
end
for _, name in ipairs(GbcPalette.MODES) do for _, name in ipairs(GbcPalette.MODES) do
if name == mode then if name == mode then
GbcPalette.mode = mode GbcPalette.mode = mode
@@ -260,7 +276,7 @@ function GbcPalette.setMode(mode)
end end
function GbcPalette.modeLabel(mode) function GbcPalette.modeLabel(mode)
return GbcPalette.MODE_LABELS[mode or GbcPalette.mode] or "GBC" return GbcPalette.MODE_LABELS[mode or GbcPalette.mode] or "GEN 2"
end end
-- Advance GBC -> DMG -> CLASSIC -> GBC. `delta` may be -1 to step back, so -- Advance GBC -> DMG -> CLASSIC -> GBC. `delta` may be -1 to step back, so
@@ -273,11 +289,20 @@ function GbcPalette.cycle(delta)
local count = #GbcPalette.MODES local count = #GbcPalette.MODES
at = (at - 1 + (delta or 1)) % count + 1 at = (at - 1 + (delta or 1)) % count + 1
GbcPalette.mode = GbcPalette.MODES[at] GbcPalette.mode = GbcPalette.MODES[at]
GbcPalette.setCustomRamp(nil)
return GbcPalette.mode return GbcPalette.mode
end end
function GbcPalette.applyOptions(opts) function GbcPalette.applyOptions(opts)
return GbcPalette.setMode(opts and opts.color or "gbc") local mode = GbcPalette.setMode(opts and opts.color or "gbc")
local id = opts and opts.palette
if id and id ~= "" then
local ok, Palette = pcall(require, "src.render.Palette")
GbcPalette.setCustomRamp(ok and Palette.ramp(id) or nil)
else
GbcPalette.setCustomRamp(nil)
end
return mode
end end
-- Point the shader at one 4-color palette. Colors are 0-255 triples, matching -- Point the shader at one 4-color palette. Colors are 0-255 triples, matching
@@ -431,4 +456,14 @@ function GbcPalette.with(colors, body)
return applied return applied
end 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 return GbcPalette
+72
View File
@@ -0,0 +1,72 @@
-- UI LETTERBOX: what fills the window around a 160x144 screen.
--
-- AUTO keeps what each screen was authored with (Gen 1 black, Gen 2 mostly
-- white, the Pokedex and credits black); the other three override every
-- screen uniformly. PALETTE takes the active COLORS ramp's paper shade, so
-- the bars match the panel they frame instead of a fixed grey.
local Letterbox = {}
-- Set from applyOptions on both generations, the same shape GbcPalette.setMode
-- and Tilt.setLevel use, so a draw call needs no options table in hand.
Letterbox.mode = "auto"
Letterbox.MODES = { "auto", "black", "white", "palette" }
local LABELS = {
auto = "AUTO", black = "BLACK", white = "WHITE", palette = "PALETTE",
}
function Letterbox.normalize(mode)
for _, id in ipairs(Letterbox.MODES) do
if mode == id then return id end
end
return "auto"
end
function Letterbox.label(mode)
return LABELS[Letterbox.normalize(mode)]
end
function Letterbox.cycle(mode, dir)
local at = 1
for i, id in ipairs(Letterbox.MODES) do
if id == Letterbox.normalize(mode) then at = i break end
end
local n = #Letterbox.MODES
return Letterbox.MODES[(at - 1 + (dir or 1)) % n + 1]
end
-- r, g, b for `mode`, falling back to the screen's authored colour on AUTO.
--
-- `paper` is supplied by the caller, not looked up here: the two generations
-- keep their picked palette in different modules (Gen 1 in PaletteFX, Gen 2 in
-- GbcPalette) and GbcPalette.available() is true on both, so anything that
-- sniffed for it answered with Gen 2's white default on a Gen 1 game.
function Letterbox.color(mode, r, g, b, paper)
mode = Letterbox.normalize(mode)
if mode == "black" then return 0, 0, 0 end
if mode == "white" then return 1, 1, 1 end
if mode == "palette" and paper then
local pr, pg, pb = paper()
if pr then return pr, pg, pb end
end
return r or 0, g or 0, b or 0
end
function Letterbox.setMode(mode)
Letterbox.mode = Letterbox.normalize(mode)
return Letterbox.mode
end
function Letterbox.applyOptions(options)
return Letterbox.setMode(options and options.uiLetterbox)
end
-- What a drawWidescreen should paint the window with, given the colour that
-- screen was authored to use and its generation's paper reader.
function Letterbox.fill(r, g, b, paper)
return Letterbox.color(Letterbox.mode, r, g, b, paper)
end
return Letterbox
+249
View File
@@ -0,0 +1,249 @@
local Palette = {}
local pack = nil
function Palette.packs()
if pack == nil then
local ok, data = pcall(require, "data.gb_palettes")
pack = ok and data or false
end
return pack or {}
end
local MONO_STEPS = { 1.00, 0.72, 0.38, 0.12 }
Palette.MONO_COLOURS = {
{ "GREEN", 0x9b, 0xbc, 0x0f }, -- DMG
{ "LIME", 0xa8, 0xd8, 0x38 },
{ "OLIVE", 0xa6, 0xac, 0x84 }, -- Pocket
{ "AMBER", 0xff, 0xb0, 0x00 }, -- plasma terminal
{ "ORANGE", 0xff, 0x80, 0x30 },
{ "RED", 0xff, 0x50, 0x50 },
{ "ROSE", 0xff, 0x90, 0xb0 },
{ "MAGENTA", 0xf0, 0x60, 0xd0 },
{ "PURPLE", 0xb0, 0x70, 0xf0 },
{ "INDIGO", 0x80, 0x80, 0xf0 },
{ "BLUE", 0x50, 0xa0, 0xff },
{ "CYAN", 0x40, 0xe0, 0xe0 }, -- VFD
{ "TEAL", 0x40, 0xc0, 0xa0 },
{ "MINT", 0x90, 0xe0, 0xb0 },
{ "SAND", 0xe0, 0xc8, 0x90 },
{ "BROWN", 0xc0, 0x90, 0x50 },
{ "WHITE", 0xff, 0xff, 0xff }, -- plain DMG greys
{ "SILVER", 0xd0, 0xd8, 0xe0 },
}
local function monoRamp(r, g, b)
local out = {}
for i = 1, 4 do
local f = MONO_STEPS[i]
out[i] = { math.floor(r * f + 0.5),
math.floor(g * f + 0.5),
math.floor(b * f + 0.5) }
end
return out
end
Palette.monoRamp = monoRamp -- named for the suite
function Palette.packId(group, name)
return "p:" .. group .. "/" .. name
end
function Palette.monoId(r, g, b)
return ("m:%02x%02x%02x"):format(r, g, b)
end
local function unhex(text)
if type(text) ~= "string" or #text ~= 24 then return nil end
local out = {}
for i = 1, 4 do
local at = (i - 1) * 6
local r = tonumber(text:sub(at + 1, at + 2), 16)
local g = tonumber(text:sub(at + 3, at + 4), 16)
local b = tonumber(text:sub(at + 5, at + 6), 16)
if not (r and g and b) then return nil end
out[i] = { r, g, b }
end
return out
end
Palette.unhex = unhex -- named for the suite
local RAMP_INDEX = 2
local GREY_CHROMA = 12
local HUE_ARC = 50
Palette.GREY_CHROMA = GREY_CHROMA -- named for the suite
Palette.HUE_ARC = HUE_ARC
local function hueChroma(c)
local r, g, b = c[1] or 0, c[2] or 0, c[3] or 0
local mx = math.max(r, g, b)
local mn = math.min(r, g, b)
local chroma = mx - mn
if chroma <= 0 then return 0, 0 end
local h
if mx == r then h = ((g - b) / chroma) % 6
elseif mx == g then h = (b - r) / chroma + 2
else h = (r - g) / chroma + 4 end
return h * 60, chroma
end
Palette.hueChroma = hueChroma -- named for the suite
local function hueArc(hues)
local best = 360
for i = 1, #hues do
local widest = 0
for j = 1, #hues do
local d = (hues[j] - hues[i]) % 360
if d > widest then widest = d end
end
if widest < best then best = widest end
end
return best
end
Palette.hueArc = hueArc -- named for the suite
function Palette.categoryOf(ramp)
if type(ramp) ~= "table" or not ramp[4] then return nil end
local hues = {}
for i = 1, 4 do
local h, chroma = hueChroma(ramp[i])
if chroma > GREY_CHROMA then hues[#hues + 1] = h end
end
if #hues == 0 then return "grey" end
return hueArc(hues) <= HUE_ARC and "single" or "full"
end
Palette.CATEGORIES = {
{ key = "full", label = "FULL COLOUR" },
{ key = "single", label = "SINGLE COLOUR" },
{ key = "grey", label = "GREYSCALE" },
}
local byId, buckets = nil, nil
local function buildIndex()
byId, buckets = {}, {}
for _, category in ipairs(Palette.CATEGORIES) do buckets[category.key] = {} end
for _, p in ipairs(Palette.packs()) do
for _, entry in ipairs(p.palettes or {}) do
local name = entry[1]
if type(name) == "string" then
local id = Palette.packId(p.name, name)
byId[id] = entry
local raw = unhex(entry[RAMP_INDEX] or entry[2])
local bucket = raw and buckets[Palette.categoryOf(raw) or ""] or nil
if bucket then
bucket[#bucket + 1] = { name = name, pack = p.name, id = id }
end
end
end
end
end
local function ensureIndex()
if not byId then buildIndex() end
end
function Palette.categories()
ensureIndex()
local out = {}
for i, category in ipairs(Palette.CATEGORIES) do
out[i] = { key = category.key, name = category.label,
palettes = buckets[category.key] or {} }
end
return out
end
function Palette.category(key)
ensureIndex()
if not buckets[key] then return nil end
for _, category in ipairs(Palette.CATEGORIES) do
if category.key == key then
return { key = key, name = category.label, palettes = buckets[key] }
end
end
return nil
end
function Palette.locate(id)
if type(id) ~= "string" or id == "" then return nil end
ensureIndex()
for _, category in ipairs(Palette.CATEGORIES) do
for i, item in ipairs(buckets[category.key] or {}) do
if item.id == id then return category.key, i end
end
end
return nil
end
local function findPack(group, name)
ensureIndex()
return byId[Palette.packId(group, name)]
end
Palette.findPack = findPack -- named for the suite
function Palette.label(id)
if type(id) ~= "string" or id == "" then return nil end
local hex = id:match("^m:(%x+)$")
if hex then
local r = tonumber(hex:sub(1, 2), 16)
local g = tonumber(hex:sub(3, 4), 16)
local b = tonumber(hex:sub(5, 6), 16)
for _, c in ipairs(Palette.MONO_COLOURS) do
if c[2] == r and c[3] == g and c[4] == b then return c[1] end
end
return "MONO"
end
local name = id:match("^p:.*/([^/]+)$")
return name or id
end
local cacheId, cacheRamp = nil, nil
function Palette.ramp(id)
if type(id) ~= "string" or id == "" then return nil end
if id == cacheId then return cacheRamp end
local out = nil
local hex = id:match("^m:(%x+)$")
if hex and #hex == 6 then
out = monoRamp(tonumber(hex:sub(1, 2), 16) or 0,
tonumber(hex:sub(3, 4), 16) or 0,
tonumber(hex:sub(5, 6), 16) or 0)
else
local group, name = id:match("^p:(.*)/([^/]+)$")
if group then
local entry = findPack(group, name)
local ramp = entry and unhex(entry[RAMP_INDEX] or entry[2])
if ramp then
out = { ramp[4], ramp[3], ramp[2], ramp[1] }
end
end
end
cacheId, cacheRamp = id, out
return out
end
function Palette.invalidate()
cacheId, cacheRamp = nil, nil
byId, buckets = nil, nil
end
function Palette.swatch(id)
local ramp = Palette.ramp(id)
if not ramp then return nil end
return { ramp[4], ramp[3], ramp[2], ramp[1] }
end
return Palette
+50 -22
View File
@@ -797,6 +797,18 @@ function PaletteFX.shadeMap()
return shadeMap return shadeMap
end 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) function PaletteFX.setMode(mode)
local prev = PaletteFX.mode local prev = PaletteFX.mode
local ok = false local ok = false
@@ -808,27 +820,35 @@ function PaletteFX.setMode(mode)
end end
end end
if not ok then PaletteFX.mode = "gbc" end if not ok then PaletteFX.mode = "gbc" end
-- battle pics and overworld sprites bake the active pack into ImageData; if prev ~= PaletteFX.mode then invalidateColorCaches() end
-- drop those caches when the pack (or any COLORS mode) changes so the end
-- next draw re-tints
if prev ~= PaletteFX.mode then PaletteFX.customRamp = nil
pcall(function() require("src.battle.BattleState").invalidate() end)
pcall(function() require("src.render.SpriteRenderer").invalidate() end) function PaletteFX.setCustomRamp(ramp)
-- RED++'s baked tileset atlas (TileRenderer.getGbcAtlas) is built once local prev = PaletteFX.customRamp
-- per loaded map, so a mode toggle needs every cached Map/TileRenderer PaletteFX.customRamp = ramp
-- dropped and the currently-visible one rebuilt in place -- otherwise if (prev ~= nil) ~= (ramp ~= nil) or prev ~= ramp then
-- the on-screen map keeps its stale (wrong-mode) atlas until the next invalidateColorCaches()
-- 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
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() function PaletteFX.cycleMode()
local cur = PaletteFX.mode or "gbc" local cur = PaletteFX.mode or "gbc"
local idx = 1 local idx = 1
@@ -841,6 +861,13 @@ end
function PaletteFX.applyOptions(opts) function PaletteFX.applyOptions(opts)
PaletteFX.setMode(opts and opts.colors or "gbc") 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 end
function PaletteFX.modeLabel(mode) function PaletteFX.modeLabel(mode)
@@ -853,12 +880,11 @@ function PaletteFX.modeLabel(mode)
end end
-- When a state exposes no SGB zones but COLORS needs a forced palette -- 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) function PaletteFX.ensureZones(zones)
if zones and zones[1] then return zones end if zones and zones[1] then return zones end
local mode = PaletteFX.mode or "gbc" local mode = PaletteFX.mode or "gbc"
if mode == "og" or mode == "og_inv" or mode == "classic" then if mode == "og" or mode == "og_inv" or mode == "classic"
or (PaletteFX.forcesRawGrays() and not PaletteFX.pickerActive()) then
return { PaletteFX.whole(PaletteFX.GRAYS) } return { PaletteFX.whole(PaletteFX.GRAYS) }
end end
return zones return zones
@@ -888,7 +914,9 @@ function PaletteFX.effectiveColors(c)
if not c then return nil end if not c then return nil end
local mode = PaletteFX.mode or "gbc" local mode = PaletteFX.mode or "gbc"
local out = c local out = c
if mode == "og" then if PaletteFX.customRamp and not PaletteFX.pickerActive() then
out = PaletteFX.customRamp
elseif mode == "og" then
out = PaletteFX.GRAYS out = PaletteFX.GRAYS
elseif mode == "og_inv" then elseif mode == "og_inv" then
out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP) out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP)
+7
View File
@@ -972,6 +972,13 @@ function Renderer:endFrame(zones, worldZones)
and not FaithfulRes.scaleCap() then and not FaithfulRes.scaleCap() then
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data) clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
end 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 end
if cut then if cut then
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
+37 -9
View File
@@ -11,6 +11,12 @@ local UIVisibility = require("src.battle.UIVisibility")
local Theme = require("src.ui.Theme") local Theme = require("src.ui.Theme")
local Timing = require("src.core.Timing") 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 = {} local TextBox = {}
TextBox.__index = TextBox TextBox.__index = TextBox
TextBox.isTextBox = true TextBox.isTextBox = true
@@ -470,8 +476,21 @@ function TextBox:draw()
-- (pokegold engine/pokegear/pokegear.asm TownMapPals sends every tile -- (pokegold engine/pokegear/pokegear.asm TownMapPals sends every tile
-- >= $60 to palette 0). Gen 1's Game has no textboxPaper, so it stays nil. -- >= $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 paper = self.game and self.game.textboxPaper and self.game:textboxPaper()
Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, paper)
love.graphics.setColor(0, 0, 0, 1) local gold = self.game and self.game.save
and (self.game.save.generation == 2 or self.game.save.version == "gold")
local Chrome = gold and Chrome2 or nil
local drawGlyph, finishGlyph = Font.drawCode, nil
if Chrome then
local base = paper and { paper, paper, paper, { 0, 0, 0 } }
or Chrome.DEFAULT_BOX_PALETTE
Chrome.paletteBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, base)
local _, dg, fg = Chrome.paletteGlyphs(base)
drawGlyph, finishGlyph = dg, fg
else
Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, paper)
love.graphics.setColor(0, 0, 0, 1)
end
if self.scrollPx and self.scrollPx > 0 then if self.scrollPx and self.scrollPx > 0 then
self.scrollPx = self.scrollPx - 2 self.scrollPx = self.scrollPx - 2
if self.scrollPx <= 0 then self.scrollPx = nil end if self.scrollPx <= 0 then self.scrollPx = nil end
@@ -491,17 +510,25 @@ function TextBox:draw()
-- measured with; every fixed-width page still lands on the 8px grid -- measured with; every fixed-width page still lands on the 8px grid
local pen = self.textX local pen = self.textX
for _, code in ipairs(line) do for _, code in ipairs(line) do
Font.drawCode(code, pen, y) drawGlyph(code, pen, y)
pen = pen + Font.advanceOf(code) pen = pen + Font.advanceOf(code)
end end
end end
if self.money then if self.money then
-- money box (engine/menus/text_box.asm:130): DisplayMoneyBox at -- money box (engine/menus/text_box.asm:130): DisplayMoneyBox at
-- hlcoord 11,0, the amount right-aligned on its middle row -- hlcoord 11,0, the amount right-aligned on its middle row
Font.drawBox(11, 0, 9, 3) if Chrome then
love.graphics.setColor(0, 0, 0, 1) Chrome.paletteBox(11, 0, 9, 3, Chrome.DEFAULT_BOX_PALETTE)
else
Font.drawBox(11, 0, 9, 3)
love.graphics.setColor(0, 0, 0, 1)
end
local money = ("¥%d"):format(self.money() or 0) local money = ("¥%d"):format(self.money() or 0)
Font.draw(money, 152 - Font.width(money), 8) local pen = 152 - Font.width(money)
for _, code in ipairs(Font.encode(money)) do
drawGlyph(code, pen, 8)
pen = pen + Font.advanceOf(code)
end
end end
if (self.waiting or (self.done and not self.choice and not self.auto if (self.waiting or (self.done and not self.choice and not self.auto
and (not self.stay and (not self.stay
@@ -509,10 +536,11 @@ function TextBox:draw()
and self.blink < 30 then and self.blink < 30 then
-- page-advance cursor: glyph $EE by default, the blinking down arrow -- page-advance cursor: glyph $EE by default, the blinking down arrow
-- the original prints via `ld a, "▼"` (home/text.asm) -- the original prints via `ld a, "▼"` (home/text.asm)
Font.drawCode(Theme.moreArrow or 0xEE, drawGlyph(Theme.moreArrow or 0xEE,
(self.boxTx + self.boxTw - 2) * 8, (self.boxTx + self.boxTw - 2) * 8,
(self.boxTy + self.boxTh - 1) * 8 - 4) (self.boxTy + self.boxTh - 1) * 8 - 4)
end end
if finishGlyph then finishGlyph() end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+44 -8
View File
@@ -116,6 +116,36 @@ function SyncEngine.overlaps(a, b)
return aStart <= bEnd and bStart <= aEnd return aStart <= bEnd and bStart <= aEnd
end 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) function SyncEngine.new(opts)
opts = opts or {} opts = opts or {}
local eng = setmetatable({}, SyncEngine) local eng = setmetatable({}, SyncEngine)
@@ -457,7 +487,11 @@ function SyncEngine:_planFrom(remoteState)
if not row then if not row then
self:_queueUpload(entry, key, false) self:_queueUpload(entry, key, false)
elseif localChanged and remoteChanged then elseif localChanged and remoteChanged then
self:_addConflict(entry, key, row) if SyncEngine.samePlaytime(entry.meta, SyncEngine.metaOf(row)) then
self:_queueUpload(entry, key, true)
else
self:_addConflict(entry, key, row)
end
elseif localChanged then elseif localChanged then
self:_queueUpload(entry, key, false) self:_queueUpload(entry, key, false)
elseif remoteChanged and key ~= self.protectedKey then elseif remoteChanged and key ~= self.protectedKey then
@@ -477,12 +511,7 @@ function SyncEngine:_planFrom(remoteState)
end end
function SyncEngine:_addConflict(entry, key, row) function SyncEngine:_addConflict(entry, key, row)
local remoteMeta = row local remoteMeta = SyncEngine.metaOf(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] = { self.conflicts[#self.conflicts + 1] = {
key = key, key = key,
version = entry.version, version = entry.version,
@@ -528,7 +557,14 @@ function SyncEngine:_queueUpload(entry, key, force)
end, function(e, res) end, function(e, res)
if res.code == 409 then if res.code == 409 then
local row = res.data or {} local row = res.data or {}
e:_addConflict(entry, key, row) -- Retried with force only once: a forced write that still 409s is a
-- real refusal, and retrying it would spin.
if not force
and SyncEngine.samePlaytime(entry.meta, SyncEngine.metaOf(row)) then
e:_queueUpload(entry, key, true)
else
e:_addConflict(entry, key, row)
end
if not e:busy() then e:_finish() end if not e:busy() then e:_finish() end
return true return true
end end
+49
View File
@@ -0,0 +1,49 @@
-- Scrolling for a menu line too long for its box: hold, step one character
-- at a time, hold on the tail, snap back. Shared by the Gen 1 four-box
-- viewport (src/ui/OptionRows.lua) and the Gen 2 textbox
-- (src/ui/gen2/OptionsMenu.lua) -- different widths, same problem.
local Marquee = {}
Marquee.HOLD = 1.0
Marquee.STEP = 0.25
-- Only ever one row scrolls (the cursor's), so one shared phase keeps that
-- row's label and value stepping together.
local phase = { key = nil, start = 0 }
local function clock()
local timer = love and love.timer and love.timer.getTime
return timer and timer() or 0
end
-- Pure, so a test can step it without a clock.
function Marquee.at(text, maxChars, elapsed)
text = text or ""
local over = #text - maxChars
if over <= 0 then return text end
local span = Marquee.HOLD * 2 + over * Marquee.STEP
local now = elapsed % span
local offset
if now < Marquee.HOLD then
offset = 0
elseif now < Marquee.HOLD + over * Marquee.STEP then
offset = math.floor((now - Marquee.HOLD) / Marquee.STEP)
else
offset = over
end
return text:sub(offset + 1, offset + maxChars)
end
-- `key` identifies the highlighted row; changing it restarts the cycle.
function Marquee.scroll(text, maxChars, key)
if not text or #text <= maxChars then return text or "" end
if phase.key ~= key then phase.key, phase.start = key, clock() end
return Marquee.at(text, maxChars, clock() - phase.start)
end
function Marquee.clip(text, maxChars)
return (text or ""):sub(1, maxChars)
end
return Marquee
+19 -3
View File
@@ -7,12 +7,18 @@
-- rows that open something instead (MODS, CANCEL stays the caller's). -- rows that open something instead (MODS, CANCEL stays the caller's).
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Marquee = require("src.ui.Marquee")
local Theme = require("src.ui.Theme") local Theme = require("src.ui.Theme")
local OptionRows = {} local OptionRows = {}
OptionRows.VISIBLE = 4 -- option boxes on screen at once (4 tiles each) 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 -- keep the cursor's box inside the viewport; the fixed bottom row shows
-- the tail of the list -- the tail of the list
function OptionRows.clampScroll(index, scroll, total, bottomRow) function OptionRows.clampScroll(index, scroll, total, bottomRow)
@@ -27,7 +33,7 @@ function OptionRows.clampScroll(index, scroll, total, bottomRow)
end end
-- one bordered box per row, label line + value line, with the fixed -- one bordered box per row, label line + value line, with the fixed
-- bottom line below (CANCEL in the options menu, the manager's footer) -- bottom line below (BACK in the options menu, the manager's footer)
function OptionRows.draw(game, rows, index, scroll, bottomLabel, bottomRow) function OptionRows.draw(game, rows, index, scroll, bottomLabel, bottomRow)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
@@ -37,8 +43,18 @@ function OptionRows.draw(game, rows, index, scroll, bottomLabel, bottomRow)
if not row then break end if not row then break end
Font.drawBox(0, (slot - 1) * 4, 20, 4) Font.drawBox(0, (slot - 1) * 4, 20, 4)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(row.label, 16, ((slot - 1) * 4 + 1) * 8) local label = row.label or ""
Font.draw(row.value and row.value(game) or "", 24, ((slot - 1) * 4 + 2) * 8) local value = row.value and row.value(game) or ""
if i == index then
local key = tostring(row.id or row.label) .. "\0" .. tostring(value)
label = Marquee.scroll(label, fits(16), key)
value = Marquee.scroll(value, fits(24), key)
else
label = Marquee.clip(label, fits(16))
value = Marquee.clip(value, fits(24))
end
Font.draw(label, 16, ((slot - 1) * 4 + 1) * 8)
Font.draw(value, 24, ((slot - 1) * 4 + 2) * 8)
if i == index then if i == index then
Font.drawCode(Theme.cursor, 8, ((slot - 1) * 4 + 1) * 8) Font.drawCode(Theme.cursor, 8, ((slot - 1) * 4 + 1) * 8)
end end
+138 -22
View File
@@ -11,10 +11,12 @@
-- bottom line like pokered's. -- bottom line like pokered's.
local PaletteFX = require("src.render.PaletteFX") local PaletteFX = require("src.render.PaletteFX")
local Palette = require("src.render.Palette")
local Pipelines = require("src.render.Pipelines") local Pipelines = require("src.render.Pipelines")
local Tilt = require("src.render.Tilt") local Tilt = require("src.render.Tilt")
local ShaderFX = require("src.render.ShaderFX") local ShaderFX = require("src.render.ShaderFX")
local Zoom = require("src.render.Zoom") local Zoom = require("src.render.Zoom")
local Letterbox = require("src.render.Letterbox")
local TileRenderer = require("src.render.TileRenderer") local TileRenderer = require("src.render.TileRenderer")
local GameSpeed = require("src.core.GameSpeed") local GameSpeed = require("src.core.GameSpeed")
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
@@ -127,14 +129,6 @@ local function stepVolume(v, dir)
return math.max(0, math.min(7, (v or 7) + dir)) return math.max(0, math.min(7, (v or 7) + dir))
end 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) local function wrapIndex(i, n)
i = i % n i = i % n
if i < 0 then i = i + n end if i < 0 then i = i + n end
@@ -341,15 +335,14 @@ local function buildRows(game)
end }, end },
{ id = "colors", label = Strings("COLORS"), { id = "colors", label = Strings("COLORS"),
value = function(g) 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") return PaletteFX.modeLabel(g.save.options.colors or "gbc")
end, end,
step = function(g, dir) activate = function(g)
local o = g.save.options require("src.ui.Screens").push(g, "PaletteScreen")
local i = colorIndex(o)
i = wrapIndex(i - 1 + dir, #PaletteFX.MODES) + 1
o.colors = PaletteFX.MODES[i]
PaletteFX.setMode(o.colors)
return true
end }, end },
{ id = "tilt", label = Strings("TILT"), { id = "tilt", label = Strings("TILT"),
value = function(g) return Tilt.levelLabel(g.save.options.tilt or 0) end, value = function(g) return Tilt.levelLabel(g.save.options.tilt or 0) end,
@@ -376,6 +369,16 @@ local function buildRows(game)
-- cycling in place on this row. -- cycling in place on this row.
-- ShaderFXScreen.lua does the actual list/activate; this row only opens -- ShaderFXScreen.lua does the actual list/activate; this row only opens
-- it and shows what is currently active. -- 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"), { id = "shaderfx", label = Strings("SHADER FX"),
value = function(g) value = function(g)
return shaderfxLabel(ShaderFX.activeEntry("main")) return shaderfxLabel(ShaderFX.activeEntry("main"))
@@ -643,8 +646,90 @@ local function buildRows(game)
return rows return rows
end 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) function OptionsMenu.new(game, opts)
opts = opts or {} 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 rows = buildRows(game)
local hooked = Runtime.call("ui.options.rows", sameRows, game, rows) local hooked = Runtime.call("ui.options.rows", sameRows, game, rows)
if type(hooked) == "table" then if type(hooked) == "table" then
@@ -653,14 +738,44 @@ function OptionsMenu.new(game, opts)
Logger.error("ui.options.rows returned %s; keeping the vanilla rows", Logger.error("ui.options.rows returned %s; keeping the vanilla rows",
type(hooked)) type(hooked))
end end
return setmetatable({ game = game, rows = rows, index = 1, scroll = 0, local self = setmetatable({ game = game, rows = rows, index = 1, scroll = 0,
onCancel = opts.onCancel }, OptionsMenu) onCancel = opts.onCancel }, OptionsMenu)
self.view = groupRows(game, rows)
return self
end
-- Cursor onto a row by id, opening its group page first if it lives in one.
-- Returns the screen the row ended up on, so a caller can keep driving it.
function OptionsMenu:focusRow(id)
local rows = self.view or self.rows
for i, row in ipairs(rows) do
if row.id == id then
self.index = i
self.scroll = OptionRows.clampScroll(i, self.scroll or 0, #rows, #rows + 1)
return self
end
end
for i, row in ipairs(rows) do
if row.group and row.activate then
local group
for _, g in ipairs(GROUPS) do if g.id == row.id then group = g end end
for _, member in ipairs(group and group.members or {}) do
if member == id then
self.index = i
row.activate(self.game)
local sub = self.game.stack:top()
return sub.focusRow and sub:focusRow(id) or sub
end
end
end
end
return nil
end end
function OptionsMenu:update(dt) function OptionsMenu:update(dt)
local input = self.game.input local input = self.game.input
local rows = self.rows local rows = self.view or self.rows
-- CANCEL sits below the hook-built rows so a mod cannot orphan the exit -- BACK sits below the hook-built rows so a mod cannot orphan the exit
local cancelRow = #rows + 1 local cancelRow = #rows + 1
local changed = false local changed = false
if input:wasPressed("up") then if input:wasPressed("up") then
@@ -702,14 +817,15 @@ function OptionsMenu:update(dt)
end end
function OptionsMenu:draw() function OptionsMenu:draw()
-- Through Strings, like every other label on this menu. CANCEL is -- Through Strings, like every other label on this menu. BACK is
-- appended AFTER the rows hook (see the header), which is what keeps a mod -- 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 -- 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 -- 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 -- word a Spanish player could not read on a fully translated OPTIONS menu
-- was the way out of it. -- was the way out of it.
OptionRows.draw(self.game, self.rows, self.index, self.scroll or 0, local rows = self.view or self.rows
Strings("CANCEL"), #self.rows + 1) OptionRows.draw(self.game, rows, self.index, self.scroll or 0,
Strings("BACK"), #rows + 1)
end end
return OptionsMenu return OptionsMenu
+578
View File
@@ -0,0 +1,578 @@
local Font = require("src.render.Font")
local Theme = require("src.ui.Theme")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local Palette = require("src.render.Palette")
local PaletteFX = require("src.render.PaletteFX")
local PaletteScreen = {}
PaletteScreen.__index = PaletteScreen
PaletteScreen.isOpaque = true
local COLS, ROWS = 5, 4
local TILE_W, TILE_H = 28, 20
local GAP = 4
local GRID_X, GRID_Y = 2, 14
local PER_PAGE = COLS * ROWS
local SCREEN_W, SCREEN_H = 160, 144
local BACK = 0
local function sanitize(text)
text = tostring(text or ""):upper():gsub("_", "'")
text = text:gsub("[^A-Z0-9 %.'%-]", " ")
text = text:gsub("%s+", " "):gsub("^ ", ""):gsub(" $", "")
return text
end
PaletteScreen.sanitize = sanitize -- named for the suite
local function wrap(text, width, lines)
local out = {}
local rest = text
for i = 1, lines do
if rest == "" then break end
if #rest <= width then
out[i] = rest
rest = ""
else
local cut = rest:sub(1, width + 1):match("^.*() ")
if not cut or cut < 2 then cut = width + 1 end
out[i] = rest:sub(1, cut - 1)
rest = rest:sub(cut):gsub("^ +", "")
end
end
if rest ~= "" and #out > 0 then
local last = out[#out]
out[#out] = last:sub(1, math.max(0, width - 1)) .. "."
end
return out
end
PaletteScreen.wrap = wrap -- named for the suite
local function packLabel(name, width)
local text = sanitize(name)
if #text > width then
local leaf = tostring(name):match("([^/]+)$")
if leaf then text = sanitize(leaf) end
end
return wrap(text, width, 1)[1] or ""
end
PaletteScreen.packLabel = packLabel -- named for the suite
local function fill(x, y, w, h, c)
love.graphics.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1)
love.graphics.rectangle("fill", x, y, w, h)
end
local function frame(x, y, w, h, shade)
love.graphics.setColor(shade, shade, shade, 1)
love.graphics.rectangle("line", x + 0.5, y + 0.5, w - 1, h - 1)
end
local function drawSwatch(x, y, w, h, swatch)
if not swatch then
frame(x, y, w, h, 0.5)
return
end
local band = w / 4
for i = 1, 4 do
fill(x + math.floor((i - 1) * band), y,
math.ceil(band), h, swatch[i])
end
end
PaletteScreen.drawSwatch = drawSwatch -- named for the suite
local function drawBack(selected)
if selected then
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", 0, 0, 10, 9)
love.graphics.setColor(1, 1, 1, 1)
else
love.graphics.setColor(0, 0, 0, 1)
end
for i = 0, 3 do
love.graphics.rectangle("fill", 2 + i, 4 - i, 1, 1 + i * 2)
end
end
PaletteScreen.drawBack = drawBack -- named for the suite
local function engineModes()
local labels = PaletteFX.MODE_LABELS or {}
local out = {}
for i, id in ipairs(PaletteFX.MODES) do
out[i] = { PaletteFX.modeLabel(id) or labels[id] or id:upper(), id }
end
return out
end
local function dropConflictingMode(game)
local mode = PaletteFX.mode
local bakes = PaletteFX.usesGbcPack(mode) or PaletteFX.usesSpriteObp(mode)
if not bakes then return end
local o = game.save.options
o.colors = "gbc"
PaletteFX.setMode("gbc")
end
local function liveOpts(game)
dropConflictingMode(game)
return {
palette = Palette,
get = function() return game.save.options.palette or "" end,
set = function(v)
local o = game.save.options
o.palette = v
if v ~= "" then dropConflictingMode(game) end
PaletteFX.setCustomRamp(v ~= "" and Palette.ramp(v) or nil)
if game.writeOptions then pcall(game.writeOptions, game) end
end,
modes = engineModes(),
getMode = function() return game.save.options.colors or "gbc" end,
setMode = function(v)
local o = game.save.options
o.colors = v
PaletteFX.setMode(v)
if game.writeOptions then pcall(game.writeOptions, game) end
end,
}
end
function PaletteScreen.new(game, opts)
opts = opts or liveOpts(game)
local self = setmetatable({
game = game,
Palette = opts.palette,
get = opts.get, set = opts.set,
modes = opts.modes or {},
getMode = opts.getMode, setMode = opts.setMode,
onChange = opts.onChange,
view = "root",
index = 1,
scroll = 0,
group = nil,
stackTrail = {},
}, PaletteScreen)
self.rows = self:buildRoot()
self:enterFromSelection()
return self
end
function PaletteScreen:buildRoot()
local rows = {}
for _, category in ipairs(self.Palette and self.Palette.categories() or {}) do
if #(category.palettes or {}) > 0 then
rows[#rows + 1] = { label = category.name, folder = true,
view = "tiles", group = category }
end
end
if self.Palette and self.Palette.MONO_COLOURS then
rows[#rows + 1] = { label = "MONOCHROME", folder = true, view = "mono" }
end
for _, mode in ipairs(self.modes) do
rows[#rows + 1] = { label = mode[1], mode = mode[2] }
end
return rows
end
function PaletteScreen:rootIndexFor(view, group)
for i, row in ipairs(self.rows or {}) do
if row.view == view then
if view ~= "tiles" then return i end
if row.group and group and row.group.key == group.key then return i end
end
end
return 1
end
function PaletteScreen:enterFromSelection()
local Palette = self.Palette
if not Palette then return end
local id = self.get and self.get() or ""
if type(id) ~= "string" or id == "" then return end
if id:match("^m:") then
for i, c in ipairs(Palette.MONO_COLOURS) do
if Palette.monoId(c[2], c[3], c[4]) == id then
self:openAt("mono", nil, i)
return
end
end
return
end
local key, at = Palette.locate(id)
if key then self:openAt("tiles", Palette.category(key), at) end
end
function PaletteScreen:openAt(view, group, index)
self.stackTrail = { { view = "root", index = self:rootIndexFor(view, group),
scroll = 0, group = nil } }
self.view, self.group, self.index, self.scroll = view, group, index or 1, 0
local items = self:items()
self:clampScroll(#items, #items + 1, PER_PAGE)
end
function PaletteScreen:items()
local Palette = self.Palette
if self.view == "root" then return self.rows or {} end
if self.view == "tiles" then
return self.group and self.group.palettes or {}
end
if self.view == "mono" then
return Palette and Palette.MONO_COLOURS or {}
end
return {}
end
function PaletteScreen:isGrid()
return self.view == "tiles" or self.view == "mono"
end
function PaletteScreen:idAt(i)
local Palette = self.Palette
if not Palette then return nil end
if self.view == "tiles" then
local entry = self.group and (self.group.palettes or {})[i]
return entry and entry.id or nil
elseif self.view == "mono" then
local c = Palette.MONO_COLOURS[i]
return c and Palette.monoId(c[2], c[3], c[4]) or nil
end
return nil
end
function PaletteScreen:title()
if self.view == "root" then return Strings("COLOUR") end
if self.view == "mono" then return Strings("MONOCHROME") end
return sanitize(self.group and self.group.name or "")
end
function PaletteScreen:update()
local input = self.game.input
local items = self:items()
local total = #items
local cancel = total + 1
if input:wasPressed("b") then
self:back()
return
end
if self:isGrid() then
self:moveGrid(input, total, cancel)
else
self:moveList(input, total, cancel)
end
if input:wasPressed("a") then
self:activate(cancel)
end
end
function PaletteScreen:moveList(input, total, cancel)
if self.index == BACK then
if input:wasPressed("down") or input:wasPressed("right") then
self.index = 1
end
return
end
if input:wasPressed("left") then
self.index = BACK
return
end
if input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or BACK
elseif input:wasPressed("down") then
self.index = self.index < cancel and self.index + 1 or 1
end
self:clampScroll(total, cancel, 12)
end
function PaletteScreen:moveGrid(input, total, cancel)
local i = self.index
if i == BACK then
if input:wasPressed("down") or input:wasPressed("right") then
self.index = self.scroll + 1
if self.index > total then self.index = 1 end
end
return
end
if i > total then
if input:wasPressed("up") then
self.index = total
elseif input:wasPressed("down") then
self.index = 1
end
self:clampScroll(total, cancel, PER_PAGE)
return
end
if input:wasPressed("left") then
self.index = ((i - 1) % COLS == 0) and BACK or i - 1
elseif input:wasPressed("right") then
self.index = i < total and i + 1 or 1
elseif input:wasPressed("up") then
self.index = (i - COLS >= 1) and i - COLS or BACK
elseif input:wasPressed("down") then
if i + COLS <= total then
self.index = i + COLS
else
self.index = cancel
end
end
self:clampScroll(total, cancel, PER_PAGE)
end
function PaletteScreen:clampScroll(total, cancel, visible)
if self.index == BACK then return end
if self.index >= cancel then
self.scroll = math.max(0, total - visible)
if self:isGrid() then
self.scroll = math.floor(self.scroll / COLS) * COLS
end
return
end
local step = self:isGrid() and COLS or 1
if self.index <= self.scroll then
self.scroll = math.floor((self.index - 1) / step) * step
elseif self.index > self.scroll + visible then
self.scroll = math.floor((self.index - 1) / step) * step - visible + step
end
if self.scroll < 0 then self.scroll = 0 end
end
function PaletteScreen:activate(cancel)
if self.index == BACK or self.index >= cancel then
self:back()
return
end
if self.view == "root" then
local row = (self.rows or {})[self.index]
if not row then return end
if row.folder then
self:push(row.view, row.group)
elseif row.mode then
if self.set then self.set("") end
if self.setMode then self.setMode(row.mode) end
self:changed()
end
elseif self:isGrid() then
local id = self:idAt(self.index)
if id then
if self.set then self.set(id) end
self:changed()
end
end
end
function PaletteScreen:changed()
if self.game.data and Sound and Sound.play then
pcall(Sound.play, self.game.data, "Press_AB")
end
if self.onChange then self.onChange() end
end
function PaletteScreen:push(view, group)
self.stackTrail[#self.stackTrail + 1] =
{ view = self.view, index = self.index, scroll = self.scroll,
group = self.group }
self.view, self.index, self.scroll = view, 1, 0
self.group = group
self:cursorToSelection()
end
function PaletteScreen:cursorToSelection()
local id = self.get and self.get() or ""
if type(id) ~= "string" or id == "" then return end
local items = self:items()
for i = 1, #items do
if self:idAt(i) == id then
self.index = i
self:clampScroll(#items, #items + 1,
self:isGrid() and PER_PAGE or 12)
return
end
end
end
function PaletteScreen:back()
local prev = table.remove(self.stackTrail)
if prev then
self.view, self.index = prev.view, prev.index
self.scroll, self.group = prev.scroll, prev.group
return
end
self:close()
end
function PaletteScreen:close()
if self.game.data and Sound and Sound.play then
pcall(Sound.play, self.game.data, "Press_AB")
end
self.game.stack:pop()
end
function PaletteScreen:sgbPalettes()
return nil
end
function PaletteScreen:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H)
drawBack(self.index == BACK)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self:title(), 12, 0)
if self:isGrid() then
self:drawGrid()
else
self:drawList()
end
love.graphics.setColor(0, 0, 0, 1)
local items = self:items()
if self.index >= #items + 1 then
Font.draw(Strings("CANCEL"), 16, SCREEN_H - 8)
Font.drawCode(Theme.cursor, 8, SCREEN_H - 8)
end
love.graphics.setColor(1, 1, 1, 1)
end
function PaletteScreen:drawList()
local items = self:items()
local mode = self.getMode and self.getMode() or nil
local visible = 12
for slot = 1, visible do
local i = self.scroll + slot
local row = items[i]
if not row then break end
local y = 16 + (slot - 1) * 8
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings(row.label or ""), 16, y)
if i == self.index then
Font.drawCode(Theme.cursor, 8, y)
end
if row.folder then
Font.drawCode(Theme.cursor, 8 + 8 * 17, y)
elseif row.mode and mode and row.mode == mode then
love.graphics.rectangle("fill", 8 + 8 * 17 + 2, y + 2, 4, 4)
end
end
end
function PaletteScreen:drawGrid()
local Palette = self.Palette
local items = self:items()
local total = #items
for slot = 1, PER_PAGE do
local i = self.scroll + slot
if i > total then break end
local col = (slot - 1) % COLS
local row = math.floor((slot - 1) / COLS)
local x = GRID_X + col * (TILE_W + GAP)
local y = GRID_Y + row * (TILE_H + GAP)
local swatch
if self.view == "mono" then
local c = Palette and Palette.MONO_COLOURS[i]
if c then
local ramp = Palette.monoRamp(c[2], c[3], c[4])
swatch = { ramp[4], ramp[3], ramp[2], ramp[1] }
end
else
swatch = Palette and Palette.swatch(self:idAt(i)) or nil
end
drawSwatch(x, y, TILE_W, TILE_H, swatch)
if i == self.index then
frame(x - 2, y - 2, TILE_W + 4, TILE_H + 4, 0)
frame(x - 1, y - 1, TILE_W + 2, TILE_H + 2, 1)
end
end
self:drawFooter(total)
end
function PaletteScreen:drawFooter(total)
local Palette = self.Palette
love.graphics.setColor(0, 0, 0, 1)
local name, pack = "", nil
if self.index >= 1 and self.index <= total then
if self.view == "mono" then
local c = Palette and Palette.MONO_COLOURS[self.index]
name = c and c[1] or ""
else
local entry = self.group and (self.group.palettes or {})[self.index]
if entry then
name = sanitize(entry.name)
pack = entry.pack
end
end
end
local lines = wrap(name, 20, 2)
for i = 1, #lines do
Font.draw(lines[i], 0, 110 + (i - 1) * 8)
end
local at = 0
if total > PER_PAGE then
local page = math.floor(self.scroll / PER_PAGE) + 1
local pages = math.ceil(total / PER_PAGE)
Font.draw(("%d/%d"):format(page, pages), 0, 128)
at = 40
end
if pack then
Font.draw(packLabel(pack, (SCREEN_W - at) / 8), at, 128)
end
local stored = self.get and self.get() or ""
if type(stored) == "string" and stored ~= "" then
for slot = 1, PER_PAGE do
local i = self.scroll + slot
if i > total then break end
if self:idAt(i) == stored then
local col = (slot - 1) % COLS
local row = math.floor((slot - 1) / COLS)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle(
"fill",
GRID_X + col * (TILE_W + GAP) + TILE_W - 5,
GRID_Y + row * (TILE_H + GAP) + TILE_H - 5, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle(
"fill",
GRID_X + col * (TILE_W + GAP) + TILE_W - 4,
GRID_Y + row * (TILE_H + GAP) + TILE_H - 4, 2, 2)
break
end
end
end
end
return PaletteScreen
+2 -5
View File
@@ -18,6 +18,7 @@
local bit = require("bit") local bit = require("bit")
local Assets = require("src.render.Assets") local Assets = require("src.render.Assets")
local Chrome = require("src.ui.gen2.Chrome")
local GbcPalette = require("src.render.GbcPalette") local GbcPalette = require("src.render.GbcPalette")
local Palettes = require("src.world.gen2.Palettes") local Palettes = require("src.world.gen2.Palettes")
@@ -164,12 +165,8 @@ local function needsCanvas(runner)
end end
-- The battle background is BG colour 0 everywhere the two pic boxes and the -- 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() function BattleAnimView:fillBackground()
local G = love.graphics Chrome.paletteFill(0, 0, SCREEN_W, SCREEN_H)
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H)
end end
-- One reusable quad, re-aimed per scanline. A row shifted by `dx` is drawn -- One reusable quad, re-aimed per scanline. A row shifted by `dx` is drawn
+44 -31
View File
@@ -3520,7 +3520,7 @@ function BattleState:drawHud()
-- And nothing at all before UpdateEnemyHUD has ever run: the intro bands -- And nothing at all before UpdateEnemyHUD has ever run: the intro bands
-- slide in over a blanked tilemap (core.asm:8554/8564). -- slide in over a blanked tilemap (core.asm:8554/8564).
if showStatus and self.showEnemyHud and not self:hudCleared("enemy") then if showStatus and self.showEnemyHud and not self:hudCleared("enemy") then
Chrome.print(self:name(enemy), 1, 0) Chrome.printThrough(self:name(enemy), 1, 0, Chrome.DEFAULT_BOX_PALETTE)
-- PrintLevel writes <LV> at the coordinate it is given and then LEFT-aligns -- 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 -- 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 -- is 5 or 100; only a three-digit level moves, and it does so by eating the
@@ -3528,10 +3528,12 @@ function BattleState:drawHud()
-- PlaceNonFaintStatus (engine/pokemon/mon_stats.asm): a statused mon's tag -- PlaceNonFaintStatus (engine/pokemon/mon_stats.asm): a statused mon's tag
-- prints where the level goes, and DrawEnemyHUD's `.skip_level` arm drops -- prints where the level goes, and DrawEnemyHUD's `.skip_level` arm drops
-- the level entirely while one is up. -- the level entirely while one is up.
Chrome.print(self:statusTag(enemy, "enemy") Chrome.printThrough(self:statusTag(enemy, "enemy")
or ("<LV>" .. tostring(enemy.level or 1)), 6, 1) or ("<LV>" .. tostring(enemy.level or 1)), 6, 1, Chrome.DEFAULT_BOX_PALETTE)
local enemyGender = self:genderSymbol(enemy) local enemyGender = self:genderSymbol(enemy)
if enemyGender then Chrome.print(enemyGender, 9, 1) end if enemyGender then
Chrome.printThrough(enemyGender, 9, 1, Chrome.DEFAULT_BOX_PALETTE)
end
-- `ld a, [wBattleMode] / dec a / ret nz`, then CheckCaughtMon puts $5d at -- `ld a, [wBattleMode] / dec a / ret nz`, then CheckCaughtMon puts $5d at
-- (1,1) (engine/battle/trainer_huds.asm:140-152). -- (1,1) (engine/battle/trainer_huds.asm:140-152).
if self.battle and self.battle.wild and self.caughtMark then if self.battle and self.battle.wild and self.caughtMark then
@@ -3580,17 +3582,20 @@ function BattleState:drawHud()
Font.useBattleExtra(wasBattle) Font.useBattleExtra(wasBattle)
return return
end end
Chrome.print(self:name(player), 10, 7) Chrome.printThrough(self:name(player), 10, 7, Chrome.DEFAULT_BOX_PALETTE)
-- PrintPlayerHUD places the same status tag at (14,8) and skips the level -- PrintPlayerHUD places the same status tag at (14,8) and skips the level
-- while it is up. -- while it is up.
Chrome.print(self:statusTag(player, "player") Chrome.printThrough(self:statusTag(player, "player")
or ("<LV>" .. tostring(self.shownLevel or player.level or 1)), 14, 8) or ("<LV>" .. tostring(self.shownLevel or player.level or 1)), 14, 8,
Chrome.DEFAULT_BOX_PALETTE)
local playerGender = self:genderSymbol(player) local playerGender = self:genderSymbol(player)
if playerGender then Chrome.print(playerGender, 17, 8) end if playerGender then
Chrome.printThrough(playerGender, 17, 8, Chrome.DEFAULT_BOX_PALETTE)
end
self:drawHpBar(player, "player", 10, 9) self:drawHpBar(player, "player", 10, 9)
local maxHp = player.maxHp or (player.stats and player.stats.hp) or 0 local maxHp = player.maxHp or (player.stats and player.stats.hp) or 0
Chrome.printRight(("%d/%d"):format(self:hudHp(player, "player"), maxHp), Chrome.printRightThrough(("%d/%d"):format(self:hudHp(player, "player"), maxHp),
18, 10) 18, 10, Chrome.DEFAULT_BOX_PALETTE)
-- Stub on the RIGHT (tile $73), border from (18,10) laid leftward, exp bar -- Stub on the RIGHT (tile $73), border from (18,10) laid leftward, exp bar
-- at (10,11). -- at (10,11).
-- The chased fill, not the mon's: AnimateExpBar crawls it, and reading the -- The chased fill, not the mon's: AnimateExpBar crawls it, and reading the
@@ -3659,8 +3664,8 @@ end
function BattleState:printMessage() function BattleState:printMessage()
local lines = Chrome.wrap(self.message or "", TEXT_WIDTH) local lines = Chrome.wrap(self.message or "", TEXT_WIDTH)
for i = 1, math.min(#lines, TEXT_ROWS) do for i = 1, math.min(#lines, TEXT_ROWS) do
Chrome.print(lines[i], TEXT_INNER_X, Chrome.printThrough(lines[i], TEXT_INNER_X,
TEXT_INNER_Y + (i - 1) * TEXT_ROW_STEP) TEXT_INNER_Y + (i - 1) * TEXT_ROW_STEP, Chrome.DEFAULT_BOX_PALETTE)
end end
end end
@@ -3670,15 +3675,17 @@ function BattleState:drawMoveInfoBox(move)
if not move then return end if not move then return end
local fighter = self.battle and self.battle.player local fighter = self.battle and self.battle.player
if fighter and self.battle:moveDisabled(fighter, move.id) then if fighter and self.battle:moveDisabled(fighter, move.id) then
Chrome.print("Disabled!", 1, 10) Chrome.printThrough("Disabled!", 1, 10, Chrome.DEFAULT_BOX_PALETTE)
return return
end end
local def = self.game and self.game.data and self.game.data.moves local def = self.game and self.game.data and self.game.data.moves
and self.game.data.moves[move.id] and self.game.data.moves[move.id]
Chrome.print("TYPE/", 1, 9) Chrome.printThrough("TYPE/", 1, 9, Chrome.DEFAULT_BOX_PALETTE)
local moveType = def and def.type local moveType = def and def.type
Chrome.print(moveType and (TYPE_NAMES[moveType] or moveType) or "", 2, 10) Chrome.printThrough(moveType and (TYPE_NAMES[moveType] or moveType) or "",
Chrome.print(("%2d/%2d"):format(move.pp or 0, move.maxPp or 0), 5, 11) 2, 10, Chrome.DEFAULT_BOX_PALETTE)
Chrome.printThrough(("%2d/%2d"):format(move.pp or 0, move.maxPp or 0),
5, 11, Chrome.DEFAULT_BOX_PALETTE)
end end
function BattleState:drawPanel() function BattleState:drawPanel()
@@ -3687,7 +3694,7 @@ function BattleState:drawPanel()
-- required there; everywhere else a missing side is a caller bug. -- required there; everywhere else a missing side is a caller bug.
local hasPlayer = self.battle and (self.battle.player or self.tutorial) local hasPlayer = self.battle and (self.battle.player or self.tutorial)
if not (self.battle and hasPlayer and self.battle.enemy) then if not (self.battle and hasPlayer and self.battle.enemy) then
Chrome.print("NO BATTLE", 1, 1) Chrome.printThrough("NO BATTLE", 1, 1, Chrome.DEFAULT_BOX_PALETTE)
return return
end end
self:drawHud() self:drawHud()
@@ -3719,8 +3726,10 @@ function BattleState:drawPanel()
local col = ((i - 1) % 2) * spacing local col = ((i - 1) % 2) * spacing
local row = math.floor((i - 1) / 2) * 2 local row = math.floor((i - 1) / 2) * 2
local tx, ty = boxX + 2 + col, 14 + row local tx, ty = boxX + 2 + col, 14 + row
if i == self.menuIndex then Chrome.cursor(tx - 1, ty) end if i == self.menuIndex then
Chrome.print(label, tx, ty) Chrome.cursorThrough(tx - 1, ty, Chrome.DEFAULT_BOX_PALETTE)
end
Chrome.printThrough(label, tx, ty, Chrome.DEFAULT_BOX_PALETTE)
end end
elseif self.phase == "moves" elseif self.phase == "moves"
or (self.phase == "choose-forget" and (self.messageTimer or 0) <= 0) then or (self.phase == "choose-forget" and (self.messageTimer or 0) <= 0) then
@@ -3740,21 +3749,24 @@ function BattleState:drawPanel()
for i, move in ipairs(moves) do for i, move in ipairs(moves) do
local ty = 13 + (i - 1) local ty = 13 + (i - 1)
-- Cursor in the box's own gutter, not clipped against the border. -- Cursor in the box's own gutter, not clipped against the border.
if i == cursorRow then Chrome.cursor(cursorCol, ty) end if i == cursorRow then
Chrome.cursorThrough(cursorCol, ty, Chrome.DEFAULT_BOX_PALETTE)
end
-- The held slot's marker. `.battle_player_moves` writes '▷' into the -- The held slot's marker. `.battle_player_moves` writes '▷' into the
-- row wSwappingMove names (engine/battle/core.asm:5157-5165) so a move -- 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 -- 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 -- hlcoord 5, 13 is the cursor's own gutter, so PlaceMenuCursor covers
-- the marker on the cursor's row. -- the marker on the cursor's row.
if not forgetting and self.moveSwapIndex == i and i ~= cursorRow then if not forgetting and self.moveSwapIndex == i and i ~= cursorRow then
Chrome.print("\u{25B7}", cursorCol, ty) Chrome.printThrough("\u{25B7}", cursorCol, ty, Chrome.DEFAULT_BOX_PALETTE)
end end
local def = self.game and self.game.data and self.game.data.moves local def = self.game and self.game.data and self.game.data.moves
and self.game.data.moves[move.id] and self.game.data.moves[move.id]
Chrome.print((def and def.name) or move.id, nameCol, ty) Chrome.printThrough((def and def.name) or move.id, nameCol, ty,
Chrome.DEFAULT_BOX_PALETTE)
if not moveMenu then if not moveMenu then
Chrome.printRight(("%d/%d"):format(move.pp or 0, move.maxPp or 0), Chrome.printRightThrough(("%d/%d"):format(move.pp or 0, move.maxPp or 0),
19, ty) 19, ty, Chrome.DEFAULT_BOX_PALETTE)
end end
end end
if moveMenu then self:drawMoveInfoBox(moves[cursorRow]) end if moveMenu then self:drawMoveInfoBox(moves[cursorRow]) end
@@ -3773,13 +3785,14 @@ function BattleState:drawPanel()
local left = (self.phase == "ask-shift" or self.phase == "ask-next-mon") local left = (self.phase == "ask-shift" or self.phase == "ask-next-mon")
and 1 or 14 and 1 or 14
Chrome.box(left, 7, 6, 5) Chrome.box(left, 7, 6, 5)
Chrome.print("YES", left + 2, 8) Chrome.printThrough("YES", left + 2, 8, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print("NO", left + 2, 10) Chrome.printThrough("NO", left + 2, 10, Chrome.DEFAULT_BOX_PALETTE)
local index = self.phase == "ask-nickname" and self.nicknameIndex local index = self.phase == "ask-nickname" and self.nicknameIndex
or self.phase == "ask-shift" and self.shiftIndex or self.phase == "ask-shift" and self.shiftIndex
or self.phase == "ask-next-mon" and self.nextMonIndex or self.phase == "ask-next-mon" and self.nextMonIndex
or self.forgetChoice or self.forgetChoice
Chrome.cursor(left + 1, index == 1 and 8 or 10) Chrome.cursorThrough(left + 1, index == 1 and 8 or 10,
Chrome.DEFAULT_BOX_PALETTE)
end end
end end
if self.phase == "stats-box" and self.statsBoxMon then if self.phase == "stats-box" and self.statsBoxMon then
@@ -3808,8 +3821,9 @@ function BattleState:drawStatsBox(mon)
Chrome.textbox(9, 0, 9, 10) Chrome.textbox(9, 0, 9, 10)
for i, row in ipairs(STATS_BOX_ROWS) do for i, row in ipairs(STATS_BOX_ROWS) do
local ty = 1 + (i - 1) * 2 local ty = 1 + (i - 1) * 2
Chrome.print(Strings(row[1]), 11, ty) Chrome.printThrough(Strings(row[1]), 11, ty, Chrome.DEFAULT_BOX_PALETTE)
Chrome.printRight(("%d"):format(stats[row[2]] or 0), 19, ty + 1) Chrome.printRightThrough(("%d"):format(stats[row[2]] or 0), 19, ty + 1,
Chrome.DEFAULT_BOX_PALETTE)
end end
end end
@@ -3893,8 +3907,7 @@ end
function BattleState:drawWidescreen(winW, winH) function BattleState:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale) local ox, oy = Chrome.fitOrigin(winW, winH, scale)
G.push() G.push()
+1 -2
View File
@@ -945,8 +945,7 @@ end
function BoxMenu:drawWidescreen(winW, winH) function BoxMenu:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -968,8 +968,7 @@ end
function CardFlip:drawWidescreen(winW, winH) function CardFlip:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -309,8 +309,7 @@ end
function CenterPcMenu:drawWidescreen(winW, winH) function CenterPcMenu:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+156 -41
View File
@@ -27,13 +27,39 @@ Chrome.SCREEN_H = 18
-- charmap.asm "¥", the money field's own prefix tile. -- charmap.asm "¥", the money field's own prefix tile.
local YEN = "\xc2\xa5" local YEN = "\xc2\xa5"
function Chrome.clear() function Chrome.paletteFill(px, py, pw, ph, palette)
palette = palette or Chrome.DEFAULT_BOX_PALETTE
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) 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.setColor(0, 0, 0, 1) G.setColor(0, 0, 0, 1)
end 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. -- 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 -- One GB pixel has to cover a WHOLE number of window pixels or the 8x8 grid
@@ -77,10 +103,24 @@ function Chrome.positionLift(winW, winH, scale)
or Chrome.fitScale(winW, winH)), ScreenPosition.safeTop()) or Chrome.fitScale(winW, winH)), ScreenPosition.safeTop())
end 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. -- A bordered box, tile coords. Leaves the draw color black for text.
function Chrome.box(tx, ty, tw, th) function Chrome.box(tx, ty, tw, th)
Font.drawBox(tx, ty, tw, th) Chrome.paletteBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1)
end end
-- Gold's Textbox helper takes an interior width/height and draws the border -- Gold's Textbox helper takes an interior width/height and draws the border
@@ -90,11 +130,16 @@ function Chrome.textbox(tx, ty, interiorW, interiorH)
Chrome.box(tx, ty, interiorW + 2, interiorH + 2) Chrome.box(tx, ty, interiorW + 2, interiorH + 2)
end end
function Chrome.print(text, tx, ty) local function flatPrint(text, tx, ty)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
return Font.draw(text, tx * 8, ty * 8) return Font.draw(text, tx * 8, ty * 8)
end 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 -- 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 -- cells they land on, and both their ink and their blank pixels go through
-- that screen's own BG palette. -- that screen's own BG palette.
@@ -124,32 +169,39 @@ function Chrome.throughPalette(palette, invert)
return GbcPalette.remap(pal, GbcPalette.bgp) return GbcPalette.remap(pal, GbcPalette.bgp)
end end
-- `palette` is a 4-colour table of 0-255 triples. `invert` is what function Chrome.rawPalette(palette, invert)
-- Chrome.printInverted passes; see there. if invert then return { palette[4], palette[3], palette[2], palette[1] } end
function Chrome.printThrough(text, tx, ty, palette, invert) 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)
if not (palette and GbcPalette.available()) then if not (palette and GbcPalette.available()) then
return Chrome.print(text, tx, ty) return nil, Font.drawCode, function() end
end end
-- `pal` is used below and never touches the caller's palette again -- -- `pal` is used below and never touches the caller's palette again --
-- including useRaw for the draw, since useRaw skips the fold GbcPalette.use -- including useRaw for the draw, since useRaw skips the fold GbcPalette.use
-- would otherwise apply and folding it a second time would undo this. -- would otherwise apply and folding it a second time would undo this.
local pal = Chrome.throughPalette(palette, invert) local pal = raw and Chrome.rawPalette(palette, invert)
local width = Font.width(text) or 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, 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 ink = pal[4] or { 0, 0, 0 }
local previous = love.graphics.getShader() local previous = love.graphics.getShader()
local shaded = false local shaded = false
local pen = tx * 8 local function drawGlyph(code, x, y)
for _, code in ipairs(Font.encode(text)) do
if code >= Font.TTF_BASE then 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. -- 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 -- 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 -- white texture, which this shader reads back as shade 0 no matter how
@@ -167,11 +219,28 @@ function Chrome.printThrough(text, tx, ty, palette, invert)
GbcPalette.useRaw(pal) GbcPalette.useRaw(pal)
shaded = true shaded = true
end end
Font.drawCode(code, pen, ty * 8) 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)
pen = pen + Font.advanceOf(code) pen = pen + Font.advanceOf(code)
end end
if shaded then love.graphics.setShader(previous) end finish()
love.graphics.setColor(0, 0, 0, 1)
return width return width
end end
@@ -186,12 +255,34 @@ end
-- Right-aligned within a field that ends at tile `txEnd` (exclusive), which is -- Right-aligned within a field that ends at tile `txEnd` (exclusive), which is
-- how Gold prints numbers (PrintNum fills from the right). -- how Gold prints numbers (PrintNum fills from the right).
function Chrome.printRight(text, txEnd, ty) local function flatPrintRight(text, txEnd, ty)
local width = Font.width(text) local width = Font.width(text)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
return Font.draw(text, txEnd * 8 - width, ty * 8) return Font.draw(text, txEnd * 8 - width, ty * 8)
end 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 -- 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 -- 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. -- past tile 20: text that does is drawn outside the GB frame entirely.
@@ -227,21 +318,27 @@ function Chrome.printWrapped(text, tx, ty, width, rows)
return #lines return #lines
end end
function Chrome.cursor(tx, ty, hollow) local function flatCursor(tx, ty, hollow)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.drawCode(hollow and Chrome.CURSOR_HOLLOW or Chrome.CURSOR, tx * 8, ty * 8) Font.drawCode(hollow and Chrome.CURSOR_HOLLOW or Chrome.CURSOR, tx * 8, ty * 8)
end 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. -- The cursor glyph through a palette, the way Chrome.printThrough draws text.
-- A screen whose font page is inverted (the #DEX: Pokedex_LoadInvertedFont -- A screen whose font page is inverted (the #DEX: Pokedex_LoadInvertedFont
-- xors both bitplanes, so it runs white on black) needs its cursor inverted -- 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 -- with everything else -- Chrome.cursor's flat black is invisible against that
-- ground, which is what made the dex's action arrow look absent. -- ground, which is what made the dex's action arrow look absent.
function Chrome.cursorThrough(tx, ty, palette, invert, hollow) function Chrome.cursorThrough(tx, ty, palette, invert, hollow, raw)
if not (palette and GbcPalette.available()) then if not (palette and GbcPalette.available()) then
return Chrome.cursor(tx, ty, hollow) return Chrome.cursor(tx, ty, hollow)
end end
local pal = Chrome.throughPalette(palette, invert) local pal = raw and Chrome.rawPalette(palette, invert)
or Chrome.throughPalette(palette, invert)
local paper = pal[1] or { 255, 255, 255 } local paper = pal[1] or { 255, 255, 255 }
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
love.graphics.rectangle("fill", tx * 8, ty * 8, 8, 8) love.graphics.rectangle("fill", tx * 8, ty * 8, 8, 8)
@@ -286,8 +383,8 @@ end
-- PRINTNUM_LEADINGZEROS. -- PRINTNUM_LEADINGZEROS.
function Chrome.coinBalanceBox(coins) function Chrome.coinBalanceBox(coins)
Chrome.textbox(11, 0, 7, 1) Chrome.textbox(11, 0, 7, 1)
Chrome.print("COIN", 12, 0) Chrome.printThrough("COIN", 12, 0, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print(Chrome.number(coins, 4, true), 13, 1) Chrome.printThrough(Chrome.number(coins, 4, true), 13, 1, Chrome.DEFAULT_BOX_PALETTE)
end end
-- DisplayMoneyAndCoinBalance: one Textbox at (5,0) with a 13x3 interior -- DisplayMoneyAndCoinBalance: one Textbox at (5,0) with a 13x3 interior
@@ -295,10 +392,10 @@ end
-- "COIN" at (6,3) with the four-digit count at (15,3). -- "COIN" at (6,3) with the four-digit count at (15,3).
function Chrome.moneyAndCoinBalanceBox(money, coins) function Chrome.moneyAndCoinBalanceBox(money, coins)
Chrome.textbox(5, 0, 13, 3) Chrome.textbox(5, 0, 13, 3)
Chrome.print("MONEY", 6, 1) Chrome.printThrough("MONEY", 6, 1, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print(Chrome.money(money), 12, 1) Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print("COIN", 6, 3) Chrome.printThrough("COIN", 6, 3, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print(Chrome.number(coins, 4, true), 15, 3) Chrome.printThrough(Chrome.number(coins, 4, true), 15, 3, Chrome.DEFAULT_BOX_PALETTE)
end end
-- PlaceMoneyTopRight: MoneyTopRightMenuHeader is `menu_coords 11, 0, -- PlaceMoneyTopRight: MoneyTopRightMenuHeader is `menu_coords 11, 0,
@@ -306,7 +403,7 @@ end
-- prints the number at MenuBoxCoord2Tile + SCREEN_WIDTH + 1, i.e. (12,1). -- prints the number at MenuBoxCoord2Tile + SCREEN_WIDTH + 1, i.e. (12,1).
function Chrome.moneyBalanceBox(money) function Chrome.moneyBalanceBox(money)
Chrome.box(11, 0, 9, 3) Chrome.box(11, 0, 9, 3)
Chrome.print(Chrome.money(money), 12, 1) Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE)
end end
-- A vertical cursor list. -- A vertical cursor list.
@@ -344,6 +441,7 @@ function List.new(opts)
self.onChoose = opts.onChoose self.onChoose = opts.onChoose
self.onCancel = opts.onCancel self.onCancel = opts.onCancel
self.onMove = opts.onMove 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.index = math.max(1, math.min(opts.index or 1, math.max(1, #self.items)))
self.scroll = 0 self.scroll = 0
self:ensureVisible() self:ensureVisible()
@@ -409,15 +507,32 @@ function List:draw()
local item = self.items[i] local item = self.items[i]
if item then if item then
local ty = self.y + (row - 1) * self.spacing local ty = self.y + (row - 1) * self.spacing
if i == self.index then Chrome.cursor(self.x - 1, ty) end if i == self.index then
Chrome.print(item.label, self.x, ty) 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
end end
end end
-- Scrolling lists get the ▼ hint Gold shows when there is more below. -- 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.rows < #self.items and self.scroll + self.rows < #self.items then
love.graphics.setColor(0, 0, 0, 1) if self.palette then
Font.drawCode(Chrome.DOWN_ARROW, (self.x - 1) * 8, local _, drawGlyph, finish = Chrome.paletteGlyphs(self.palette)
(self.y + self.rows * self.spacing - 1) * 8) 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
end end
end end
+1 -2
View File
@@ -176,8 +176,7 @@ end
function ContestMenu:drawWidescreen(winW, winH) function ContestMenu:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -841,8 +841,7 @@ end
function Credits:drawWidescreen(winW, winH) function Credits:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(0, 0, 0, 1) Chrome.letterbox(winW, winH, 0, 0, 0)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -1275,8 +1275,7 @@ end
function CrystalIntro:drawWidescreen(winW, winH) function CrystalIntro:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
local fill = self:surroundColor() local fill = self:surroundColor()
G.setColor(fill[1] / 255, fill[2] / 255, fill[3] / 255, 1) Chrome.letterbox(winW, winH, fill[1] / 255, fill[2] / 255, fill[3] / 255)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -410,8 +410,7 @@ end
function CrystalSplash:drawWidescreen(winW, winH) function CrystalSplash:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
local backdrop = GbcPalette.color(self.bgColors, 1) or BLACK local backdrop = GbcPalette.color(self.bgColors, 1) or BLACK
G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) Chrome.letterbox(winW, winH, backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -263,8 +263,7 @@ end
function DecorationMenu:drawWidescreen(winW, winH) function DecorationMenu:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -180,8 +180,7 @@ function Diploma:drawWidescreen(winW, winH)
-- Nil palette degrades to the DMG ramp's colour 1, i.e. white, matching the -- 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. -- Chrome.clear() the panel falls back to when the cache has no gfx/diploma.
local paper = GbcPalette.color(self:palette(), 1) local paper = GbcPalette.color(self:palette(), 1)
G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) Chrome.letterbox(winW, winH, paper[1] / 255, paper[2] / 255, paper[3] / 255)
G.rectangle("fill", 0, 0, winW, winH)
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale) local ox, oy = Chrome.fitOrigin(winW, winH, scale)
+1 -2
View File
@@ -440,8 +440,7 @@ end
function EggHatchAnim:drawWidescreen(winW, winH) function EggHatchAnim:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -567,8 +567,7 @@ end
function EvolutionAnim:drawWidescreen(winW, winH) function EvolutionAnim:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -369,8 +369,7 @@ end
function GameFreakPresents:drawWidescreen(winW, winH) function GameFreakPresents:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
local backdrop = GbcPalette.color(self.bgColors, 1) or BLACK local backdrop = GbcPalette.color(self.bgColors, 1) or BLACK
G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) Chrome.letterbox(winW, winH, backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -125,8 +125,7 @@ end
function GenderSelect:drawWidescreen(winW, winH) function GenderSelect:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale) local ox, oy = Chrome.fitOrigin(winW, winH, scale)
G.push() G.push()
+1 -2
View File
@@ -1029,8 +1029,7 @@ end
function GoldSilverIntro:drawWidescreen(winW, winH) function GoldSilverIntro:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(0, 0, 0, 1) Chrome.letterbox(winW, winH, 0, 0, 0)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -668,8 +668,7 @@ end
function HallOfFame:drawWidescreen(winW, winH) function HallOfFame:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(0, 0, 0, 1) Chrome.letterbox(winW, winH, 0, 0, 0)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -358,8 +358,7 @@ end
function InitClock:drawWidescreen(winW, winH) function InitClock:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale) local ox, oy = Chrome.fitOrigin(winW, winH, scale)
G.push() G.push()
+1 -2
View File
@@ -598,8 +598,7 @@ end
function ItemPcMenu:drawWidescreen(winW, winH) function ItemPcMenu:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -444,8 +444,7 @@ function MagnetTrainRide:drawsWidescreen() return true end
function MagnetTrainRide:drawWidescreen(winW, winH) function MagnetTrainRide:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
local backdrop = self:backdrop() local backdrop = self:backdrop()
G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) Chrome.letterbox(winW, winH, backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255)
G.rectangle("fill", 0, 0, winW, winH)
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale) local ox, oy = Chrome.fitOrigin(winW, winH, scale)
+1 -2
View File
@@ -336,8 +336,7 @@ end
function MailCompose:drawWidescreen(winW, winH) function MailCompose:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(0.62, 0.62, 0.62, 1) Chrome.letterbox(winW, winH, 0.62, 0.62, 0.62)
G.rectangle("fill", 0, 0, winW, winH)
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
+1 -2
View File
@@ -90,8 +90,7 @@ end
function MailRead:drawWidescreen(winW, winH) function MailRead:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -215,8 +215,7 @@ end
function MainMenu:drawWidescreen(winW, winH) function MainMenu:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -150,8 +150,7 @@ end
-- edge, at the one integer scale Chrome.fitScale picks. -- edge, at the one integer scale Chrome.fitScale picks.
function MoveTutor:drawWidescreen(winW, winH) function MoveTutor:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -239,8 +239,7 @@ end
function NamePick:drawWidescreen(winW, winH) function NamePick:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -566,8 +566,7 @@ function NamingScreen:drawWidescreen(winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale) local ox, oy = Chrome.fitOrigin(winW, winH, scale)
local paper = self:paperColor() local paper = self:paperColor()
G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) Chrome.letterbox(winW, winH, paper[1] / 255, paper[2] / 255, paper[3] / 255)
G.rectangle("fill", 0, 0, winW, winH)
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
if self.tiles.border then if self.tiles.border then
local tilesX = math.ceil(winW / (8 * scale)) local tilesX = math.ceil(winW / (8 * scale))
+1 -2
View File
@@ -719,8 +719,7 @@ end
function OakSpeech:drawWidescreen(winW, winH) function OakSpeech:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale) local ox, oy = Chrome.fitOrigin(winW, winH, scale)
G.push() G.push()
+208 -26
View File
@@ -6,7 +6,7 @@
-- literal "TYPE". The cursor is a ▶ in column 1 at row 2 + index * 2. -- literal "TYPE". The cursor is a ▶ in column 1 at row 2 + index * 2.
-- --
-- Up/down move between rows, left/right change the value under the cursor, and -- Up/down move between rows, left/right change the value under the cursor, and
-- START or B leaves. CANCEL is a row like any other that simply exits. -- START or B leaves. BACK is a row like any other that simply exits.
-- --
-- Values are stored on the save's options table by name (see -- Values are stored on the save's options table by name (see
-- src/core/gen2/Save.lua DEFAULT_OPTIONS) rather than as the packed wOptions -- src/core/gen2/Save.lua DEFAULT_OPTIONS) rather than as the packed wOptions
@@ -20,6 +20,8 @@
local Chrome = require("src.ui.gen2.Chrome") local Chrome = require("src.ui.gen2.Chrome")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Marquee = require("src.ui.Marquee")
local Palette = require("src.render.Palette")
local Performance = require("src.core.Performance") local Performance = require("src.core.Performance")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Save = require("src.core.gen2.Save") local Save = require("src.core.gen2.Save")
@@ -41,6 +43,45 @@ local function stepVolume(v, delta)
return math.max(0, math.min(7, (v or 7) + delta)) return math.max(0, math.min(7, (v or 7) + delta))
end end
local function dropClassic(options)
local GbcPalette = require("src.render.GbcPalette")
if GbcPalette.mode ~= "classic" then return end
local target = (options.palette and options.palette ~= "")
and GbcPalette.CUSTOM_MODE or "gbc"
options.color = target
GbcPalette.setMode(target)
end
local function colorPickerOpts(game)
local GbcPalette = require("src.render.GbcPalette")
local options = game.options
dropClassic(options)
local labels = GbcPalette.MODE_LABELS or {}
local modes = {}
for i, id in ipairs(GbcPalette.MODES) do
modes[i] = { labels[id] or id:upper(), id }
end
return {
palette = Palette,
get = function() return options.palette or "" end,
set = function(v)
options.palette = v
local target = (v ~= "") and GbcPalette.CUSTOM_MODE or "gbc"
options.color = target
GbcPalette.setMode(target)
GbcPalette.setCustomRamp(v ~= "" and Palette.ramp(v) or nil)
if game.persistOptions then pcall(game.persistOptions, game) end
end,
modes = modes,
getMode = function() return options.color or "gbc" end,
setMode = function(v)
options.color = v
GbcPalette.setMode(v)
if game.persistOptions then pcall(game.persistOptions, game) end
end,
}
end
-- Each row: the label, the option key it edits, and the cycle of values with -- Each row: the label, the option key it edits, and the cycle of values with
-- the exact strings the cart prints (trailing spaces included -- they are what -- the exact strings the cart prints (trailing spaces included -- they are what
-- blank the longer previous value, e.g. "MID " over "SLOW"). -- blank the longer previous value, e.g. "MID " over "SLOW").
@@ -191,24 +232,30 @@ local ROWS = {
text = function(options) text = function(options)
return require("src.render.Tilt").levelLabel(options.tilt or 0) return require("src.render.Tilt").levelLabel(options.tilt or 0)
end }, end },
-- COLOR is the Gen 2 answer to the Gen 1 screen's COLORS row. Gold is a
-- CGB game whose colour comes from its own palettes, so there are no packs
-- to swap -- what there is instead is the choice to turn that colour OFF,
-- down to the grey Game Boy or the green one. GBC is the default.
{ label = Strings.source("COLOR"), key = "color", port = true, { label = Strings.source("COLOR"), key = "color", port = true,
cycle = function(options, delta)
local GbcPalette = require("src.render.GbcPalette")
GbcPalette.setMode(options.color or "gbc")
options.color = GbcPalette.cycle(delta)
end,
text = function(options) text = function(options)
local name = Palette.label(options.palette)
if name then return name end
return require("src.render.GbcPalette").modeLabel(options.color or "gbc") return require("src.render.GbcPalette").modeLabel(options.color or "gbc")
end,
activate = function(game)
require("src.ui.Screens").push(game, "PaletteScreen", colorPickerOpts(game))
end }, end },
-- SHADER FX reaches Gen 2 too, not just Gen 1. Same "activate" shape as -- SHADER FX reaches Gen 2 too, not just Gen 1. Same "activate" shape as
-- CONTROLS/TOUCH LAYOUT below (a pushed screen, not a `cycle` ladder) -- -- CONTROLS/TOUCH LAYOUT below (a pushed screen, not a `cycle` ladder) --
-- ShaderFXScreen is the shared list screen both generations push, `id` -- ShaderFXScreen is the shared list screen both generations push, `id`
-- matching the Gen 1 row's so a mod filtering "shaderfx" on Red also -- matching the Gen 1 row's so a mod filtering "shaderfx" on Red also
-- reaches Gold. -- reaches Gold.
{ id = "uiLetterbox", label = Strings.source("UI LETTERBOX"), port = true,
cycle = function(options, delta)
local Letterbox = require("src.render.Letterbox")
options.uiLetterbox = Letterbox.cycle(options.uiLetterbox, delta)
Letterbox.setMode(options.uiLetterbox)
end,
text = function(options)
local Letterbox = require("src.render.Letterbox")
return Strings(Letterbox.label(options.uiLetterbox))
end },
{ id = "shaderfx", label = Strings.source("SHADER FX"), port = true, { id = "shaderfx", label = Strings.source("SHADER FX"), port = true,
text = function(options) text = function(options)
local ShaderFX = require("src.render.ShaderFX") local ShaderFX = require("src.render.ShaderFX")
@@ -294,7 +341,7 @@ local ROWS = {
{ label = Strings.source("BATTLE BG"), key = "battleBg", port = true, { label = Strings.source("BATTLE BG"), key = "battleBg", port = true,
values = { "white", "black" }, values = { "white", "black" },
display = { white = "WHITE", black = "BLACK" } }, display = { white = "WHITE", black = "BLACK" } },
{ label = Strings.source("CANCEL"), cancel = true }, { label = Strings.source("BACK"), cancel = true },
} }
-- The cart's screen is one full-height textbox with every row on it. This one -- The cart's screen is one full-height textbox with every row on it. This one
@@ -303,6 +350,69 @@ local ROWS = {
-- only when the cursor would leave it. -- only when the cursor would leave it.
local VISIBLE_ROWS = 7 local VISIBLE_ROWS = 7
-- The same grouping the Gen 1 screen uses (src/ui/OptionsMenu.lua GROUPS),
-- with Gold's own rows in it: FRAME is a graphics setting here and SOUND is
-- an audio one. Runs after the ui.options.rows hook and only builds
-- self.view, so self.rows stays the flat list a mod reads and edits.
local GROUPS = {
{ id = "group.speed", label = Strings.source("SPEED"),
members = { "textSpeed", "speed" } },
{ id = "group.video", label = Strings.source("VIDEO"),
members = { "videoMode", "screenPos", "fpsCap" } },
{ id = "group.graphics", label = Strings.source("GRAPHICS"),
members = { "color", "uiLetterbox", "shaderfx", "shaderfx2", "frame" } },
{ id = "group.audio", label = Strings.source("AUDIO"),
members = { "sound", "musicVol", "sfxVol", "musicFilter" } },
{ id = "group.battle", label = Strings.source("BATTLE OPTIONS"),
members = { "battleScene", "battleStyle", "battleBg" } },
{ id = "group.extras", label = Strings.source("EXTRAS"),
members = { "zoom", "voidFill", "tilt" } },
}
local ORDER = {
"group.speed", "group.video", "group.graphics", "group.audio",
"performance", "group.battle", "group.extras",
}
-- A group's page is this same screen driving the rows it was handed, with
-- its own BACK on the bottom.
local function groupView(rows, open)
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
if row.id and owner[row.id] then
local into = picked[owner[row.id].id]
into[#into + 1] = row
end
end
local made, byId = {}, {}
for _, group in ipairs(GROUPS) do
local members = picked[group.id]
if #members > 0 then
-- No value line: the value column is eight characters wide here, and a
-- group has no setting of its own to show in it anyway.
made[group.id] = { id = group.id, label = group.label, group = true,
activate = function(game) open(game, members) end }
end
end
for _, row in ipairs(rows) do
if row.id and not owner[row.id] then byId[row.id] = row end
end
local view, taken = {}, {}
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
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
-- ui.options.rows identity: an unhooked build hands its own rows back. -- ui.options.rows identity: an unhooked build hands its own rows back.
local function sameRows(_, rows) return rows end local function sameRows(_, rows) return rows end
@@ -323,8 +433,17 @@ local function buildRows()
local showTouch = env == "1" local showTouch = env == "1"
or (env ~= "0" and (osName == "Android" or osName == "iOS")) or (env ~= "0" and (osName == "Android" or osName == "iOS"))
for i, row in ipairs(ROWS) do for i, row in ipairs(ROWS) do
if showTouch or (row.id ~= "touchControls" and row.id ~= "touchLayout" -- PRINT is wGBPrinterBrightness, how dark the Game Boy Printer prints
and row.id ~= "haptics") then -- (GBPRINTER_LIGHTEST..DARKEST, constants/ram_constants.asm:67-70).
-- There is no printer here (src/script/gen2/Specials.lua answers the
-- print specials with "no Game Boy Printer"), so nothing reads the
-- value and the row is hidden rather than offering a dead setting.
-- The descriptor and the save key stay, so a build that grows a printer
-- only has to drop this test.
local hidden = row.key == "print"
or (not showTouch and (row.id == "touchControls"
or row.id == "touchLayout" or row.id == "haptics"))
if not hidden then
local copy = {} local copy = {}
for key, value in pairs(row) do copy[key] = value end for key, value in pairs(row) do copy[key] = value end
copy.id = copy.id or copy.key or (copy.cancel and "cancel") or nil copy.id = copy.id or copy.key or (copy.cancel and "cancel") or nil
@@ -338,6 +457,22 @@ function OptionsMenu:wantsFillScale() return true end
function OptionsMenu:drawsWidescreen() return true end function OptionsMenu:drawsWidescreen() return true end
-- opts: options (the table to edit in place), onDone(options) -- opts: options (the table to edit in place), onDone(options)
-- The page edits the SAME options table the parent screen does, so a value
-- changed on it lands where the caller's onDone will read it.
local function pushGroup(parent, members)
local page = setmetatable({}, OptionsMenu)
page.game = parent.game
page.rows = members
page.options = parent.options
page.index, page.scroll, page.sub = 1, 0, true
page.view = {}
for i, row in ipairs(members) do page.view[i] = row end
page.view[#page.view + 1] = { label = Strings.source("BACK"), cancel = true,
id = "cancel" }
parent.game.stack:push(page)
return page
end
function OptionsMenu.new(game, opts) function OptionsMenu.new(game, opts)
opts = opts or {} opts = opts or {}
local self = setmetatable({}, OptionsMenu) local self = setmetatable({}, OptionsMenu)
@@ -363,21 +498,57 @@ function OptionsMenu.new(game, opts)
self.onDone = opts.onDone self.onDone = opts.onDone
self.index = 1 self.index = 1
self.scroll = 0 self.scroll = 0
self.view = groupView(rows, function(_, members)
return pushGroup(self, members)
end)
return self return self
end end
function OptionsMenu:visible()
return self.view or self.rows
end
function OptionsMenu:ensureVisible() function OptionsMenu:ensureVisible()
local rows = self:visible()
if self.index <= self.scroll then if self.index <= self.scroll then
self.scroll = self.index - 1 self.scroll = self.index - 1
elseif self.index > self.scroll + VISIBLE_ROWS then elseif self.index > self.scroll + VISIBLE_ROWS then
self.scroll = self.index - VISIBLE_ROWS self.scroll = self.index - VISIBLE_ROWS
end end
self.scroll = math.max(0, self.scroll = math.max(0,
math.min(self.scroll, math.max(0, #self.rows - VISIBLE_ROWS))) math.min(self.scroll, math.max(0, #rows - VISIBLE_ROWS)))
end end
function OptionsMenu:row() function OptionsMenu:row()
return self.rows[self.index] return self:visible()[self.index]
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:visible()
for i, row in ipairs(rows) do
if row.id == id then
self.index = i
self:ensureVisible()
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 page = self.game.stack:top()
return page.focusRow and page:focusRow(id) or page
end
end
end
end
return nil
end end
function OptionsMenu:cycle(row, delta) function OptionsMenu:cycle(row, delta)
@@ -437,12 +608,13 @@ function OptionsMenu:update(_dt)
self:leave_() self:leave_()
return return
end end
local rows = self:visible()
if input:wasPressed("up") then if input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or #self.rows self.index = self.index > 1 and self.index - 1 or #rows
self:ensureVisible() self:ensureVisible()
return return
elseif input:wasPressed("down") then elseif input:wasPressed("down") then
self.index = self.index < #self.rows and self.index + 1 or 1 self.index = self.index < #rows and self.index + 1 or 1
self:ensureVisible() self:ensureVisible()
return return
end end
@@ -473,35 +645,46 @@ function OptionsMenu:drawPanel()
Chrome.clear() Chrome.clear()
-- hlcoord 0,0 with b = SCREEN_HEIGHT - 2, c = SCREEN_WIDTH - 2. -- hlcoord 0,0 with b = SCREEN_HEIGHT - 2, c = SCREEN_WIDTH - 2.
Chrome.textbox(0, 0, Chrome.SCREEN_W - 2, Chrome.SCREEN_H - 2) Chrome.textbox(0, 0, Chrome.SCREEN_W - 2, Chrome.SCREEN_H - 2)
for slot = 1, math.min(VISIBLE_ROWS, #self.rows) do -- The textbox spends column 19 on its frame, so a line drawn past column
-- 18 prints over the border: 17 characters from the label's column 2,
-- 8 from the value's column 11. Anything longer scrolls under the cursor.
local rows = self:visible()
for slot = 1, math.min(VISIBLE_ROWS, #rows) do
local i = slot + self.scroll local i = slot + self.scroll
local row = self.rows[i] local row = rows[i]
if row then if row then
local labelY = 2 + (slot - 1) * 2 local labelY = 2 + (slot - 1) * 2
Chrome.print(Strings(row.label), 2, labelY) local hot = i == self.index
local key = tostring(row.id or row.label)
local function fit(text, col)
local room = 19 - col
if hot then return Marquee.scroll(text, room, key) end
return Marquee.clip(text, room)
end
Chrome.print(fit(Strings(row.label), 2), 2, labelY)
if row.frame then if row.frame then
Chrome.print(Strings(":TYPE"), 10, labelY + 1) Chrome.print(Strings(":TYPE"), 10, labelY + 1)
Chrome.print(tostring(self.options.frame or 1), 16, labelY + 1) Chrome.print(tostring(self.options.frame or 1), 16, labelY + 1)
elseif row.text then elseif row.text then
Chrome.print(":", 10, labelY + 1) Chrome.print(":", 10, labelY + 1)
Chrome.print(row.text(self.options), 11, labelY + 1) Chrome.print(fit(row.text(self.options), 11), 11, labelY + 1)
elseif row.values then elseif row.values then
Chrome.print(":", 10, labelY + 1) Chrome.print(":", 10, labelY + 1)
local value = self.options[row.key] local value = self.options[row.key]
local text = row.display and Strings(row.display[value]) or tostring(value) local text = row.display and Strings(row.display[value]) or tostring(value)
Chrome.print(text, 11, labelY + 1) Chrome.print(fit(text, 11), 11, labelY + 1)
elseif type(row.value) == "function" then elseif type(row.value) == "function" then
-- the Gen 1 row's value reader (src/ui/OptionRows.lua:4), so a mod row -- the Gen 1 row's value reader (src/ui/OptionRows.lua:4), so a mod row
-- shows its setting here instead of drawing a bare label -- shows its setting here instead of drawing a bare label
local ok, text = pcall(row.value, self.game) local ok, text = pcall(row.value, self.game)
Chrome.print(":", 10, labelY + 1) Chrome.print(":", 10, labelY + 1)
Chrome.print(ok and tostring(text) or "?", 11, labelY + 1) Chrome.print(fit(ok and tostring(text) or "?", 11), 11, labelY + 1)
end end
end end
end end
Chrome.cursor(1, 2 + (self.index - self.scroll - 1) * 2) Chrome.cursor(1, 2 + (self.index - self.scroll - 1) * 2)
-- The ▼ hint every scrolling Gen 2 list shows when there is more below. -- The ▼ hint every scrolling Gen 2 list shows when there is more below.
if self.scroll + VISIBLE_ROWS < #self.rows then if self.scroll + VISIBLE_ROWS < #rows then
local Font = require("src.render.Font") local Font = require("src.render.Font")
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.drawCode(Chrome.DOWN_ARROW, 1 * 8, (2 + VISIBLE_ROWS * 2 - 1) * 8) Font.drawCode(Chrome.DOWN_ARROW, 1 * 8, (2 + VISIBLE_ROWS * 2 - 1) * 8)
@@ -514,8 +697,7 @@ end
function OptionsMenu:drawWidescreen(winW, winH) function OptionsMenu:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+2 -3
View File
@@ -21,6 +21,7 @@
local Assets = require("src.render.Assets") local Assets = require("src.render.Assets")
local GbcPalette = require("src.render.GbcPalette") local GbcPalette = require("src.render.GbcPalette")
local Chrome = require("src.ui.gen2.Chrome")
local PackGfx = {} local PackGfx = {}
PackGfx.__index = PackGfx PackGfx.__index = PackGfx
@@ -111,9 +112,7 @@ end
-- Everything behind the item list: the header strip, the background column, -- Everything behind the item list: the header strip, the background column,
-- the bag picture for this pocket, and the pocket plaque. -- the bag picture for this pocket, and the pocket plaque.
function PackGfx:draw(pocketId) function PackGfx:draw(pocketId)
local G = love.graphics Chrome.paletteFill(0, 0, SCREEN_W * 8, SCREEN_H * 8, Chrome.DEFAULT_BOX_PALETTE)
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, SCREEN_W * 8, SCREEN_H * 8)
-- ◀▶ POCKET ▼▲ ITEMS: 20 running tiles from $28. -- ◀▶ POCKET ▼▲ ITEMS: 20 running tiles from $28.
local header = self.gfx.headerFirstTile or 0x28 local header = self.gfx.headerFirstTile or 0x28
+19 -18
View File
@@ -973,22 +973,22 @@ function PackMenu:drawList(listX, listY)
local entry = self.rows[i] local entry = self.rows[i]
-- engine/items/tmhm.asm:355-385 (#1695) -- engine/items/tmhm.asm:355-385 (#1695)
if entry.tmhmLabel then if entry.tmhmLabel then
Chrome.print(entry.tmhmLabel, listX - 3, ty) Chrome.printThrough(entry.tmhmLabel, listX - 3, ty, Chrome.DEFAULT_BOX_PALETTE)
end end
if i == self.index then if i == self.index then
self:cursorAt(listX - 1, ty) self:cursorAt(listX - 1, ty)
elseif i == self.switching then elseif i == self.switching then
self:cursorAt(listX - 1, ty, true) self:cursorAt(listX - 1, ty, true)
end end
Chrome.print((entry.tmhmLabel and entry.teaches) or entry.name, Chrome.printThrough((entry.tmhmLabel and entry.teaches) or entry.name,
listX, ty) listX, ty, Chrome.DEFAULT_BOX_PALETTE)
if entry.showCount then if entry.showCount then
Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2), Chrome.printThrough("\xc3\x97" .. Chrome.number(entry.count, 2),
listX + 9, ty + 1) listX + 9, ty + 1, Chrome.DEFAULT_BOX_PALETTE)
end end
elseif i == self:total() then elseif i == self:total() then
if i == self.index then self:cursorAt(listX - 1, ty) end if i == self.index then self:cursorAt(listX - 1, ty) end
Chrome.print("CANCEL", listX, ty) Chrome.printThrough("CANCEL", listX, ty, Chrome.DEFAULT_BOX_PALETTE)
end end
end end
end end
@@ -1006,7 +1006,8 @@ function PackMenu:drawDescription(ty)
local top, step = ty, 2 local top, step = ty, 2
if #lines > 2 then top, step = ty - 1, 1 end if #lines > 2 then top, step = ty - 1, 1 end
for i, line in ipairs(lines) do for i, line in ipairs(lines) do
Chrome.print((line:gsub("{PLAYER}", name)), 1, top + (i - 1) * step) Chrome.printThrough((line:gsub("{PLAYER}", name)), 1, top + (i - 1) * step,
Chrome.DEFAULT_BOX_PALETTE)
end end
return return
end end
@@ -1019,8 +1020,8 @@ function PackMenu:drawDescription(ty)
-- the second line is row 16. '\n' covers hand-written data. -- the second line is row 16. '\n' covers hand-written data.
local first, second = description:match("^(.-)<NEXT>(.*)$") local first, second = description:match("^(.-)<NEXT>(.*)$")
if not first then first, second = description:match("^(.-)\n(.*)$") end if not first then first, second = description:match("^(.-)\n(.*)$") end
Chrome.print(first or description, 1, ty) Chrome.printThrough(first or description, 1, ty, Chrome.DEFAULT_BOX_PALETTE)
if second then Chrome.print(second, 1, ty + 2) end if second then Chrome.printThrough(second, 1, ty + 2, Chrome.DEFAULT_BOX_PALETTE) end
end end
-- The submenu box. Every one of the seven headers is `menu_coords 0, top, -- The submenu box. Every one of the seven headers is `menu_coords 0, top,
@@ -1037,23 +1038,24 @@ function PackMenu:drawSubmenu()
Chrome.box(0, top, 7, bottom - top + 1) Chrome.box(0, top, 7, bottom - top + 1)
for i, id in ipairs(menu.rows) do for i, id in ipairs(menu.rows) do
local ty = top + 1 + (i - 1) * 2 local ty = top + 1 + (i - 1) * 2
if i == menu.index then Chrome.cursor(1, ty) end if i == menu.index then Chrome.cursorThrough(1, ty, Chrome.DEFAULT_BOX_PALETTE) end
Chrome.print(SUBMENU_LABEL[id] or id, 2, ty) Chrome.printThrough(SUBMENU_LABEL[id] or id, 2, ty, Chrome.DEFAULT_BOX_PALETTE)
end end
end end
-- engine/items/buy_sell_toss.asm:133 BuySellToss_UpdateQuantityDisplay -- engine/items/buy_sell_toss.asm:133 BuySellToss_UpdateQuantityDisplay
function PackMenu:drawQuantity() function PackMenu:drawQuantity()
Chrome.box(15, 9, 5, 3) Chrome.box(15, 9, 5, 3)
Chrome.print("\xc3\x97" .. Chrome.number(self.qtyState.qty, 2, true), 16, 10) Chrome.printThrough("\xc3\x97" .. Chrome.number(self.qtyState.qty, 2, true), 16, 10,
Chrome.DEFAULT_BOX_PALETTE)
end end
-- YesNoBox's own coords, the same box every other Gen 2 screen here draws. -- YesNoBox's own coords, the same box every other Gen 2 screen here draws.
function PackMenu:drawYesNo() function PackMenu:drawYesNo()
Chrome.box(14, 7, 6, 5) Chrome.box(14, 7, 6, 5)
Chrome.print("YES", 16, 8) Chrome.printThrough("YES", 16, 8, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print("NO", 16, 10) Chrome.printThrough("NO", 16, 10, Chrome.DEFAULT_BOX_PALETTE)
Chrome.cursor(15, self.confirm.choice == 1 and 8 or 10) Chrome.cursorThrough(15, self.confirm.choice == 1 and 8 or 10, Chrome.DEFAULT_BOX_PALETTE)
end end
function PackMenu:drawOverlays() function PackMenu:drawOverlays()
@@ -1080,7 +1082,7 @@ function PackMenu:drawPanel()
-- boxes, the layout this screen shipped with. -- boxes, the layout this screen shipped with.
Chrome.clear() Chrome.clear()
Chrome.box(0, 0, 20, 3) Chrome.box(0, 0, 20, 3)
Chrome.print(self:pocket().label, 2, 1) Chrome.printThrough(self:pocket().label, 2, 1, Chrome.DEFAULT_BOX_PALETTE)
Chrome.box(0, 3, 20, 12) Chrome.box(0, 3, 20, 12)
self:drawList(5, 4) self:drawList(5, 4)
Chrome.box(0, 12, 20, 6) Chrome.box(0, 12, 20, 6)
@@ -1095,8 +1097,7 @@ end
function PackMenu:drawWidescreen(winW, winH) function PackMenu:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -845,8 +845,7 @@ end
function PartyMenu:drawWidescreen(winW, winH) function PartyMenu:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -456,8 +456,7 @@ end
function PcMenu:drawWidescreen(winW, winH) function PcMenu:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -202,8 +202,7 @@ function PhotoStudio:drawsWidescreen() return true end
function PhotoStudio:drawWidescreen(winW, winH) function PhotoStudio:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale) local ox, oy = Chrome.fitOrigin(winW, winH, scale)
G.push() G.push()
+1 -2
View File
@@ -1458,8 +1458,7 @@ end
function PokedexMenu:drawWidescreen(winW, winH) function PokedexMenu:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(0, 0, 0, 1) Chrome.letterbox(winW, winH, 0, 0, 0)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+53 -26
View File
@@ -22,6 +22,7 @@
local Chrome = require("src.ui.gen2.Chrome") local Chrome = require("src.ui.gen2.Chrome")
local FieldMoves = require("src.world.gen2.FieldMoves") local FieldMoves = require("src.world.gen2.FieldMoves")
local GbcPalette = require("src.render.GbcPalette")
local Gen2Save = require("src.core.gen2.Save") local Gen2Save = require("src.core.gen2.Save")
local Clock = require("src.core.gen2.Clock") local Clock = require("src.core.gen2.Clock")
local Font = require("src.render.Font") local Font = require("src.render.Font")
@@ -971,6 +972,7 @@ function Pokegear.new(game, opts)
if gfx then if gfx then
self.sheet = TileSheet.new({ self.sheet = TileSheet.new({
path = gfx.tiles, wide = gfx.tilesWide or 16, firstTile = 0, path = gfx.tiles, wide = gfx.tilesWide or 16, firstTile = 0,
raw = true,
paletteFor = function(tile) return self:colorsFor(tile) end, paletteFor = function(tile) return self:colorsFor(tile) end,
}) })
end end
@@ -997,10 +999,9 @@ end
-- Every string on a Pokegear card is a run of font tiles laid straight into -- Every string on a Pokegear card is a run of font tiles laid straight into
-- the tilemap, so it wears BG palette 0 (PokegearPals' first entry) rather -- the tilemap, so it wears BG palette 0 (PokegearPals' first entry) rather
-- than drawing as black ink over whatever was underneath.
function Pokegear:text(str, tx, ty) function Pokegear:text(str, tx, ty)
local pals = self:pals() local pals = self:pals()
return Chrome.printThrough(str, tx, ty, pals and pals[1]) return Chrome.printThrough(str, tx, ty, pals and pals[1], false, true)
end end
-- wPokegearFlags' four card bits are ENGINE flags: EngineFlags rows 0-3 are -- wPokegearFlags' four card bits are ENGINE flags: EngineFlags rows 0-3 are
@@ -2411,6 +2412,8 @@ function Pokegear:drawPlain()
end end
end end
Pokegear.CUSTOM_RAMP_FILM = true
function Pokegear:drawPanel() function Pokegear:drawPanel()
if not self:styled() then if not self:styled() then
self:drawPlain() self:drawPlain()
@@ -2418,29 +2421,54 @@ function Pokegear:drawPanel()
return return
end end
local G = love.graphics local G = love.graphics
-- InitPokegearTilemap ByteFills the whole SCREEN_AREA with $4f before the local function paint()
-- card's tilemap goes down (engine/pokegear/pokegear.asm InitPokegearTilemap), -- InitPokegearTilemap ByteFills the whole SCREEN_AREA with $4f before the
-- and every cell the card leaves blank is a font-page tile on palette 0, so -- card's tilemap goes down (engine/pokegear/pokegear.asm InitPokegearTilemap),
-- the ground under a card is the gear's paper, not white. See -- and every cell the card leaves blank is a font-page tile on palette 0, so
-- Pokegear:paperColor. -- the ground under a card is the gear's paper, not white. See
local ground = self:groundColor() -- Pokegear:paperColor.
G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1) local ground = self:groundColor()
G.rectangle("fill", 0, 0, SCREEN_W * 8, SCREEN_H * 8) G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1)
local id = self:card() and self:card().id G.rectangle("fill", 0, 0, SCREEN_W * 8, SCREEN_H * 8)
if id == "map" then local id = self:card() and self:card().id
self:drawMap() if id == "map" then
elseif id == "radio" then self:drawMap()
self:drawRadio() elseif id == "radio" then
elseif id == "phone" then self:drawRadio()
self:drawPhone() elseif id == "phone" then
else self:drawPhone()
self:drawClock() else
self:drawClock()
end
-- Last: the arrow is an OBJ and composites over whatever the card drew.
-- _FlyMap has no card strip and never animates it
-- (engine/pokegear/pokegear.asm:1999); neither does _TownMap
-- (../pokecrystal/engine/pokegear/pokegear.asm:1757).
if not (self.fly or self.townMap) then self:drawModeArrow() end
end
if Pokegear.CUSTOM_RAMP_FILM and GbcPalette.customRamp
and GbcPalette.available() then
if not self.filmCanvas then
self.filmCanvas = G.newCanvas(SCREEN_W * 8, SCREEN_H * 8)
self.filmCanvas:setFilter("nearest", "nearest")
end
local previousCanvas = G.getCanvas()
G.setCanvas(self.filmCanvas)
G.clear(0, 0, 0, 0)
G.push()
G.origin()
local ok, err = pcall(paint)
G.pop()
G.setCanvas(previousCanvas)
if not ok then error(err, 0) end
G.setColor(1, 1, 1, 1)
GbcPalette.with(GbcPalette.customRamp, function()
G.draw(self.filmCanvas, 0, 0)
end)
else
paint()
end end
-- Last: the arrow is an OBJ and composites over whatever the card drew.
-- _FlyMap has no card strip and never animates it
-- (engine/pokegear/pokegear.asm:1999); neither does _TownMap
-- (../pokecrystal/engine/pokegear/pokegear.asm:1757).
if not (self.fly or self.townMap) then self:drawModeArrow() end
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
end end
@@ -2454,8 +2482,7 @@ function Pokegear:drawWidescreen(winW, winH)
-- black card sitting inside a cream frame. It follows groundColor for the -- black card sitting inside a cream frame. It follows groundColor for the
-- same reason drawPanel does: $4f is what the screen is filled with. -- same reason drawPanel does: $4f is what the screen is filled with.
local ground = self:groundColor() local ground = self:groundColor()
G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1) Chrome.letterbox(winW, winH, ground[1] / 255, ground[2] / 255, ground[3] / 255)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale) local ox, oy = Chrome.fitOrigin(winW, winH, scale)
G.push() G.push()
+1 -2
View File
@@ -556,8 +556,7 @@ end
function PrizeMenu:drawWidescreen(winW, winH) function PrizeMenu:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -260,8 +260,7 @@ end
function SaveMenu:drawWidescreen(winW, winH) function SaveMenu:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -1531,8 +1531,7 @@ end
function SlotMachine:drawWidescreen(winW, winH) function SlotMachine:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -3
View File
@@ -295,9 +295,7 @@ function StartMenu:draw()
if not desc then return end if not desc then return end
-- ._DrawMenuAccount ClearBox (0,13) 5 rows by 10, .PrintMenuAccount decoord -- ._DrawMenuAccount ClearBox (0,13) 5 rows by 10, .PrintMenuAccount decoord
-- 0, 14 and the desc's `next` steps two rows (start_menu.asm:366-382). -- 0, 14 and the desc's `next` steps two rows (start_menu.asm:366-382).
local G = love.graphics Chrome.paletteFill(0, 13 * 8, 10 * 8, 5 * 8)
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 13 * 8, 10 * 8, 5 * 8)
Chrome.print(desc[1] or "", 0, 14) Chrome.print(desc[1] or "", 0, 14)
Chrome.print(desc[2] or "", 0, 16) Chrome.print(desc[2] or "", 0, 16)
end end
+1 -2
View File
@@ -1266,8 +1266,7 @@ end
function SummaryMenu:drawWidescreen(winW, winH) function SummaryMenu:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+6 -2
View File
@@ -18,7 +18,6 @@ local TileSheet = {}
TileSheet.__index = TileSheet TileSheet.__index = TileSheet
-- opts: path, wide, firstTile, palette (a 4-colour table) or palettes + a -- opts: path, wide, firstTile, palette (a 4-colour table) or palettes + a
-- paletteFor(tile, tx, ty) hook, transparent
function TileSheet.new(opts) function TileSheet.new(opts)
local self = setmetatable({}, TileSheet) local self = setmetatable({}, TileSheet)
self.path = opts and opts.path self.path = opts and opts.path
@@ -26,6 +25,7 @@ function TileSheet.new(opts)
self.firstTile = (opts and opts.firstTile) or 0 self.firstTile = (opts and opts.firstTile) or 0
self.palette = opts and opts.palette self.palette = opts and opts.palette
self.paletteFor = opts and opts.paletteFor self.paletteFor = opts and opts.paletteFor
self.raw = opts and opts.raw
self.quads = {} self.quads = {}
return self return self
end end
@@ -79,7 +79,11 @@ function TileSheet:draw(tile, tx, ty)
if self.paletteFor then colors = self.paletteFor(tile, tx, ty) or colors end if self.paletteFor then colors = self.paletteFor(tile, tx, ty) or colors end
local function body() G.draw(image, quad, tx * 8, ty * 8) end local function body() G.draw(image, quad, tx * 8, ty * 8) end
if colors and GbcPalette.available() then if colors and GbcPalette.available() then
GbcPalette.with(colors, body) if self.raw then
GbcPalette.withRaw(colors, body)
else
GbcPalette.with(colors, body)
end
else else
body() body()
end end
+1 -4
View File
@@ -316,11 +316,8 @@ function TitleState:update(_dt)
end end
end end
-- Which of the two baked sets is showing. GBC is the cart's colour; every
-- other COLOR mode wants the grey source, and CLASSIC's green comes from the
-- present pass over the finished frame rather than from a third set of art.
function TitleState:gray() function TitleState:gray()
return GbcPalette.mode ~= "gbc" return GbcPalette.mode == "dmg" or GbcPalette.mode == "classic"
end end
function TitleState:art() function TitleState:art()
+1 -2
View File
@@ -870,8 +870,7 @@ end
function TradeAnimView:drawWidescreen(winW, winH) function TradeAnimView:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+41 -34
View File
@@ -271,16 +271,24 @@ end
-- TrainerCard_PrintTopHalfOfCard runs once, in .InitRAM, and no page redraws -- TrainerCard_PrintTopHalfOfCard runs once, in .InitRAM, and no page redraws
-- it -- so the name, ID, money and portrait stay on screen behind the badge -- it -- so the name, ID, money and portrait stay on screen behind the badge
-- pages too. -- pages too.
function TrainerCard:print(text, tx, ty)
Chrome.printThrough(text, tx, ty, self:colorsAt(tx, ty))
end
function TrainerCard:cursor(tx, ty, hollow)
Chrome.cursorThrough(tx, ty, self:colorsAt(tx, ty), false, hollow)
end
function TrainerCard:drawTopHalf() function TrainerCard:drawTopHalf()
local player = (self.save or {}).player or {} local player = (self.save or {}).player or {}
self:frame(0, 5) self:frame(0, 5)
Chrome.print("NAME/", 2, 2) self:print("NAME/", 2, 2)
Chrome.print(player.name or "GOLD", 7, 2) self:print(player.name or "GOLD", 7, 2)
self:tile(self.card, TILE_ID_NO[1], 2, 4) self:tile(self.card, TILE_ID_NO[1], 2, 4)
self:tile(self.card, TILE_ID_NO[2], 3, 4) self:tile(self.card, TILE_ID_NO[2], 3, 4)
Chrome.print(Chrome.number(player.id or 0, 5, true), 5, 4) self:print(Chrome.number(player.id or 0, 5, true), 5, 4)
Chrome.print("MONEY", 2, 6) self:print("MONEY", 2, 6)
Chrome.print(moneyText(player.money), 7, 6) self:print(moneyText(player.money), 7, 6)
for x = 1, 12 do self:tile(self.card, TILE_DIVIDER, x, 3) end for x = 1, 12 do self:tile(self.card, TILE_DIVIDER, x, 3) end
self:tile(self.card, TILE_DIVIDER_END, 13, 3) self:tile(self.card, TILE_DIVIDER_END, 13, 3)
self:drawPortrait() self:drawPortrait()
@@ -298,22 +306,22 @@ function TrainerCard:drawCard()
-- `#` is the compression byte for POKé, four tiles, so spelling it out is -- `#` is the compression byte for POKé, four tiles, so spelling it out is
-- what the cart actually draws. -- what the cart actually draws.
Chrome.print("POKéDEX", 2, 10) self:print("POKéDEX", 2, 10)
Chrome.print("PLAY TIME", 2, 12) self:print("PLAY TIME", 2, 12)
Chrome.print(Chrome.number(self:caughtCount(), 3), 15, 10) self:print(Chrome.number(self:caughtCount(), 3), 15, 10)
local time = save.playTime or {} local time = save.playTime or {}
Chrome.print(Chrome.number(time.hours or 0, 4), 11, 12) self:print(Chrome.number(time.hours or 0, 4), 11, 12)
-- The colon is $2e, which belongs to CardStatusGFX rather than the card -- The colon is $2e, which belongs to CardStatusGFX rather than the card
-- sheet, and TrainerCard_Page1_PrintGameTime xors it with ' ' every 32 -- sheet, and TrainerCard_Page1_PrintGameTime xors it with ' ' every 32
-- frames -- which is what makes the clock look like it is running. -- frames -- which is what makes the clock look like it is running.
if math.floor(self.frames / 32) % 2 == 0 then if math.floor(self.frames / 32) % 2 == 0 then
self:tile(self.status, TILE_COLON, 15, 12) self:tile(self.status, TILE_COLON, 15, 12)
end end
Chrome.print(Chrome.number(time.minutes or 0, 2, true), 16, 12) self:print(Chrome.number(time.minutes or 0, 2, true), 16, 12)
Chrome.print("BADGES", 12, 15) self:print("BADGES", 12, 15)
Chrome.cursor(18, 15) self:cursor(18, 15)
end end
-- TrainerCard_Page2_3_PlaceLeadersFaces: four tiles across the top row, then -- TrainerCard_Page2_3_PlaceLeadersFaces: four tiles across the top row, then
@@ -397,22 +405,22 @@ function TrainerCard:drawPlain()
Chrome.clear() Chrome.clear()
if self.page == 1 then if self.page == 1 then
Chrome.box(0, 0, 20, 9) Chrome.box(0, 0, 20, 9)
Chrome.print("NAME/", 2, 2) Chrome.printThrough("NAME/", 2, 2, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print(player.name or "GOLD", 7, 2) Chrome.printThrough(player.name or "GOLD", 7, 2, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print("ID No", 2, 4) Chrome.printThrough("ID No", 2, 4, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print(Chrome.number(player.id or 0, 5, true), 5, 4) Chrome.printThrough(Chrome.number(player.id or 0, 5, true), 5, 4, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print("MONEY", 2, 6) Chrome.printThrough("MONEY", 2, 6, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print(moneyText(player.money), 7, 6) Chrome.printThrough(moneyText(player.money), 7, 6, Chrome.DEFAULT_BOX_PALETTE)
Chrome.box(0, 8, 20, 10) Chrome.box(0, 8, 20, 10)
Chrome.print("POKéDEX", 2, 10) Chrome.printThrough("POKéDEX", 2, 10, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print(Chrome.number(self:caughtCount(), 3), 15, 10) Chrome.printThrough(Chrome.number(self:caughtCount(), 3), 15, 10, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print("PLAY TIME", 2, 12) Chrome.printThrough("PLAY TIME", 2, 12, Chrome.DEFAULT_BOX_PALETTE)
local time = save.playTime or {} local time = save.playTime or {}
Chrome.print(Chrome.number(time.hours or 0, 4), 11, 12) Chrome.printThrough(Chrome.number(time.hours or 0, 4), 11, 12, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print(":", 15, 12) Chrome.printThrough(":", 15, 12, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print(Chrome.number(time.minutes or 0, 2, true), 16, 12) Chrome.printThrough(Chrome.number(time.minutes or 0, 2, true), 16, 12, Chrome.DEFAULT_BOX_PALETTE)
Chrome.print("BADGES", 12, 15) Chrome.printThrough("BADGES", 12, 15, Chrome.DEFAULT_BOX_PALETTE)
Chrome.cursor(18, 15) Chrome.cursorThrough(18, 15, Chrome.DEFAULT_BOX_PALETTE)
return return
end end
local names = self.page == 2 and JOHTO_BADGES or KANTO_BADGES local names = self.page == 2 and JOHTO_BADGES or KANTO_BADGES
@@ -423,12 +431,14 @@ function TrainerCard:drawPlain()
-- player.badges on both pages to match. -- player.badges on both pages to match.
local held = player.badges or {} local held = player.badges or {}
Chrome.box(0, 0, 20, 9) Chrome.box(0, 0, 20, 9)
Chrome.print(self.page == 2 and "JOHTO BADGES" or "KANTO BADGES", 2, 2) Chrome.printThrough(self.page == 2 and "JOHTO BADGES" or "KANTO BADGES", 2, 2,
Chrome.DEFAULT_BOX_PALETTE)
Chrome.box(0, 8, 20, 10) Chrome.box(0, 8, 20, 10)
for i, name in ipairs(names) do for i, name in ipairs(names) do
local tx = 2 + ((i - 1) % 4) * 4 local tx = 2 + ((i - 1) % 4) * 4
local ty = 10 + math.floor((i - 1) / 4) * 3 local ty = 10 + math.floor((i - 1) / 4) * 3
Chrome.print((held[i] or held[name]) and name:sub(1, 4) or "----", tx, ty) Chrome.printThrough((held[i] or held[name]) and name:sub(1, 4) or "----", tx, ty,
Chrome.DEFAULT_BOX_PALETTE)
end end
end end
@@ -438,9 +448,7 @@ function TrainerCard:drawPanel()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
return return
end end
local G = love.graphics Chrome.paletteFill(0, 0, SCREEN_W * 8, SCREEN_H * 8, Chrome.DEFAULT_BOX_PALETTE)
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, SCREEN_W * 8, SCREEN_H * 8)
local player = (self.save and self.save.player) or {} local player = (self.save and self.save.player) or {}
if self.page == 1 then if self.page == 1 then
self:drawCard() self:drawCard()
@@ -453,7 +461,7 @@ function TrainerCard:drawPanel()
-- the same table page 2 reads. -- the same table page 2 reads.
self:drawBadges(JOHTO_BADGES, player.badges or {}) self:drawBadges(JOHTO_BADGES, player.badges or {})
end end
G.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
function TrainerCard:draw() function TrainerCard:draw()
@@ -462,8 +470,7 @@ end
function TrainerCard:drawWidescreen(winW, winH) function TrainerCard:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+1 -2
View File
@@ -191,8 +191,7 @@ function UnownPrinter:drawsWidescreen() return true end
function UnownPrinter:drawWidescreen(winW, winH) function UnownPrinter:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) Chrome.letterbox(winW, winH, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale) local ox, oy = Chrome.fitOrigin(winW, winH, scale)
G.push() G.push()
+1 -2
View File
@@ -576,8 +576,7 @@ function UnownPuzzle:drawsWidescreen() return true end
function UnownPuzzle:drawWidescreen(winW, winH) function UnownPuzzle:drawWidescreen(winW, winH)
local G = love.graphics local G = love.graphics
G.setColor(0, 0, 0, 1) Chrome.letterbox(winW, winH, 0, 0, 0)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH) local scale = Chrome.fitScale(winW, winH)
G.push() G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale)) G.translate(Chrome.fitOrigin(winW, winH, scale))
+366
View File
@@ -0,0 +1,366 @@
-- Immediate-mode in-launcher File Browser for handhelds and gamepads.
-- Allows navigating directories to pick ROM files (.gb/.gbc), import save files (.sav),
-- and select mod/skin archives (.zip) without requiring host desktop GUI pickers (zenity/kdialog).
local Theme = require("src.ui.kit.Theme")
local PAL = Theme.PAL
local Kit = nil
local function getKit()
if not Kit then Kit = require("src.ui.kit.Kit") end
return Kit
end
local FileBrowser = {
active = false,
title = "Select File",
mode = "rom", -- "rom", "save", "mod", "all"
currentDir = "/",
entries = {},
selectedIdx = 1,
page = 1,
perPage = 8,
onSelect = nil,
onCancel = nil,
shortcutIdx = 1,
}
local SHORTCUTS = {
{ label = "SD Card", path = "/mnt/SDCARD" },
{ label = "GB ROMs", path = "/roms/gb" },
{ label = "GBC ROMs", path = "/roms/gbc" },
{ label = "Ports", path = "/roms/ports" },
{ label = "Game Dir", path = "." },
{ label = "Storage", path = "/storage" },
{ label = "Userdata", path = "/userdata/roms" },
{ label = "Root /", path = "/" },
}
local function findSdCardRoot()
local candidates = {
"/mnt/SDCARD",
"/mnt/sdcard",
"/mnt/mmc",
"/sdcard",
"/roms",
"/storage/roms",
"/userdata/roms",
"/storage",
"/userdata",
}
for _, path in ipairs(candidates) do
local ok, h = pcall(io.popen, string.format('test -d "%s" && echo "yes"', path))
if ok and h then
local res = h:read("*a")
h:close()
if res and res:match("yes") then
return path
end
end
end
return "/"
end
local function normalizePath(p)
if not p or p == "" then return "/" end
p = p:gsub("\\", "/")
if p:sub(1, 1) ~= "/" and p:sub(1, 2) ~= "./" and p:sub(1, 3) ~= "../" then
p = "/" .. p
end
p = p:gsub("/+", "/")
return p
end
local function parentDir(p)
p = normalizePath(p)
if p == "/" or p == "." then return "/" end
local parent = p:match("^(.*)/[^/]+/?$")
if not parent or parent == "" then return "/" end
return parent
end
local function isMatchingFilter(name, isDir, mode)
if isDir then return true end
local ext = name:match("%.([^.]+)$")
if not ext then return (mode == "all") end
ext = ext:lower()
if mode == "rom" then
return (ext == "gb" or ext == "gbc" or ext == "zip")
elseif mode == "save" then
return (ext == "sav")
elseif mode == "mod" then
return (ext == "zip")
end
return true
end
local function scanDirectory(dir, mode)
dir = normalizePath(dir)
local entries = {}
local ok, handle = pcall(io.popen, string.format('ls -1ap "%s" 2>/dev/null', dir:gsub('"', '\\"')))
if ok and handle then
local output = handle:read("*a")
handle:close()
if output and #output > 0 then
for line in output:gmatch("[^\r\n]+") do
if line ~= "./" and line ~= "../" and line ~= "." and line ~= ".." then
local isDir = (line:sub(-1) == "/")
local cleanName = isDir and line:sub(1, -2) or line
if isMatchingFilter(cleanName, isDir, mode) then
table.insert(entries, {
name = cleanName,
isDir = isDir,
path = (dir == "/" and "/" .. cleanName) or (dir .. "/" .. cleanName),
})
end
end
end
end
end
if #entries == 0 and love and love.filesystem and love.filesystem.getDirectoryItems then
local okFs, items = pcall(love.filesystem.getDirectoryItems, dir)
if okFs and items then
for _, name in ipairs(items) do
local full = (dir == "/" and "/" .. name) or (dir .. "/" .. name)
local info = love.filesystem.getInfo and love.filesystem.getInfo(full)
local isDir = info and info.type == "directory"
if isMatchingFilter(name, isDir, mode) then
table.insert(entries, {
name = name,
isDir = isDir,
path = full,
})
end
end
end
end
table.sort(entries, function(a, b)
if a.isDir ~= b.isDir then
return a.isDir
end
return a.name:lower() < b.name:lower()
end)
return entries
end
function FileBrowser.open(opts)
opts = opts or {}
FileBrowser.active = true
FileBrowser.title = opts.title or "Select File"
FileBrowser.mode = opts.mode or "rom"
FileBrowser.onSelect = opts.onSelect
FileBrowser.onCancel = opts.onCancel
FileBrowser.selectedIdx = 1
FileBrowser.page = 1
local initPath = opts.initialPath or findSdCardRoot()
FileBrowser.currentDir = initPath
FileBrowser.entries = scanDirectory(FileBrowser.currentDir, FileBrowser.mode)
if #FileBrowser.entries == 0 and initPath ~= "/" then
FileBrowser.currentDir = "/"
FileBrowser.entries = scanDirectory(FileBrowser.currentDir, FileBrowser.mode)
end
end
function FileBrowser.close(path)
if not FileBrowser.active then return end
local onSelect = FileBrowser.onSelect
local onCancel = FileBrowser.onCancel
FileBrowser.active = false
FileBrowser.onSelect = nil
FileBrowser.onCancel = nil
if path and onSelect then
onSelect(path)
elseif not path and onCancel then
onCancel()
end
end
function FileBrowser.setDirectory(dir)
FileBrowser.currentDir = normalizePath(dir)
FileBrowser.entries = scanDirectory(FileBrowser.currentDir, FileBrowser.mode)
FileBrowser.selectedIdx = 1
FileBrowser.page = 1
end
function FileBrowser.cycleShortcut()
FileBrowser.shortcutIdx = (FileBrowser.shortcutIdx % #SHORTCUTS) + 1
local target = SHORTCUTS[FileBrowser.shortcutIdx]
FileBrowser.setDirectory(target.path)
end
function FileBrowser.gamepadpressed(button)
if not FileBrowser.active then return false end
local total = #FileBrowser.entries
local perPage = FileBrowser.perPage
local maxPage = math.max(1, math.ceil(total / perPage))
if button == "dpup" then
if FileBrowser.selectedIdx > 1 then
FileBrowser.selectedIdx = FileBrowser.selectedIdx - 1
FileBrowser.page = math.floor((FileBrowser.selectedIdx - 1) / perPage) + 1
end
return true
elseif button == "dpdown" then
if FileBrowser.selectedIdx < total then
FileBrowser.selectedIdx = FileBrowser.selectedIdx + 1
FileBrowser.page = math.floor((FileBrowser.selectedIdx - 1) / perPage) + 1
end
return true
elseif button == "dpleft" or button == "leftshoulder" or button == "triggerleft" or button == "lefttrigger" or button == "l2" then
if FileBrowser.page > 1 then
FileBrowser.page = FileBrowser.page - 1
FileBrowser.selectedIdx = (FileBrowser.page - 1) * perPage + 1
end
return true
elseif button == "dpright" or button == "rightshoulder" or button == "triggerright" or button == "righttrigger" or button == "r2" then
if FileBrowser.page < maxPage then
FileBrowser.page = FileBrowser.page + 1
FileBrowser.selectedIdx = math.min(total, (FileBrowser.page - 1) * perPage + 1)
end
return true
elseif button == "a" or button == "start" then
local entry = FileBrowser.entries[FileBrowser.selectedIdx]
if entry then
if entry.isDir then
FileBrowser.setDirectory(entry.path)
else
FileBrowser.close(entry.path)
end
end
return true
elseif button == "b" then
if FileBrowser.currentDir ~= "/" and FileBrowser.currentDir ~= "." then
FileBrowser.setDirectory(parentDir(FileBrowser.currentDir))
else
FileBrowser.close(nil)
end
return true
elseif button == "x" then
FileBrowser.cycleShortcut()
return true
elseif button == "back" or button == "select" then
FileBrowser.close(nil)
return true
end
return true
end
function FileBrowser.keypressed(key)
if not FileBrowser.active then return false end
if key == "escape" then
FileBrowser.close(nil)
return true
elseif key == "up" then
return FileBrowser.gamepadpressed("dpup")
elseif key == "down" then
return FileBrowser.gamepadpressed("dpdown")
elseif key == "left" then
return FileBrowser.gamepadpressed("dpleft")
elseif key == "right" then
return FileBrowser.gamepadpressed("dpright")
elseif key == "return" then
return FileBrowser.gamepadpressed("a")
elseif key == "backspace" then
return FileBrowser.gamepadpressed("b")
end
return false
end
function FileBrowser.draw(m)
if not FileBrowser.active then return end
local Kit = getKit()
local s = m and m.s or Kit.scale or 1
local W = (m and m.W) or (love.graphics and love.graphics.getWidth()) or 640
local H = (m and m.H) or (love.graphics and love.graphics.getHeight()) or 480
Theme.fill(0, 0, W, H, PAL.bg, 0.94)
Kit.blockClicks = true
local pad = math.floor(14 * s)
local modalW = math.floor(math.min(560 * s, W - 20 * s))
local modalH = math.floor(math.min(420 * s, H - 20 * s))
local px = math.floor((W - modalW) / 2)
local py = math.floor((H - modalH) / 2)
Kit.card(px, py, modalW, modalH, true)
local cy = py + pad
-- Header
Kit.text("button", FileBrowser.title, px + pad, cy, PAL.heading)
Kit.tag(px + modalW - pad - math.floor(60 * s), cy, math.floor(60 * s), math.floor(18 * s),
FileBrowser.mode:upper(), PAL.blue)
cy = cy + Kit.textHeight("button") + math.floor(6 * s)
-- Breadcrumb / Current directory bar
local pathBarH = math.floor(24 * s)
Theme.fillRounded(px + pad, cy, modalW - 2 * pad, pathBarH, PAL.bg, 1)
Theme.strokeRounded(px + pad, cy, modalW - 2 * pad, pathBarH, PAL.line, Theme.A.hairline, 1)
local pathStr = Kit.ellipsizeLeft("small", FileBrowser.currentDir, modalW - 4 * pad)
Kit.text("small", pathStr, px + pad + math.floor(6 * s), cy + math.floor(4 * s), PAL.heading)
cy = cy + pathBarH + math.floor(8 * s)
-- File / Directory List
local total = #FileBrowser.entries
local perPage = FileBrowser.perPage
local startIdx = (FileBrowser.page - 1) * perPage + 1
local endIdx = math.min(total, startIdx + perPage - 1)
local rowH = math.floor(26 * s)
local rowGap = math.floor(3 * s)
if total == 0 then
Theme.fillRounded(px + pad, cy, modalW - 2 * pad, rowH * 4, PAL.bg, 1)
Kit.textCenter("small", "No files found in this folder.", px + pad, cy + rowH * 1.5, modalW - 2 * pad, PAL.muted)
cy = cy + rowH * 4 + math.floor(8 * s)
else
for i = startIdx, endIdx do
local entry = FileBrowser.entries[i]
local isSel = (FileBrowser.selectedIdx == i)
local rx = px + pad
local rw = modalW - 2 * pad
if Kit.press(rx, cy, rw, rowH) then
FileBrowser.selectedIdx = i
if entry.isDir then
FileBrowser.setDirectory(entry.path)
else
FileBrowser.close(entry.path)
end
end
if isSel then
Theme.fillRounded(rx, cy, rw, rowH, PAL.ink, 1)
local icon = entry.isDir and "[DIR] " or "[FILE] "
Kit.text("small", icon .. entry.name, rx + math.floor(8 * s), cy + math.floor(4 * s), PAL.inverse)
else
Theme.fillRounded(rx, cy, rw, rowH, PAL.surface, 1)
Theme.strokeRounded(rx, cy, rw, rowH, PAL.line, Theme.A.hairline, 1)
local icon = entry.isDir and "[DIR] " or "[FILE] "
local col = entry.isDir and PAL.blue or PAL.text
Kit.text("small", icon .. entry.name, rx + math.floor(8 * s), cy + math.floor(4 * s), col)
end
cy = cy + rowH + rowGap
end
end
-- Pager info & Controls guide
local bottomY = py + modalH - pad - math.floor(18 * s)
local pageInfo = string.format("Page %d of %d (%d items)", FileBrowser.page, math.max(1, math.ceil(total / perPage)), total)
Kit.text("micro", pageInfo, px + pad, bottomY, PAL.muted)
local guide = "A: Open/Select B: Parent X: Shortcut Start: Confirm Select: Cancel"
Kit.textRight("micro", guide, px + modalW - pad, bottomY, PAL.muted)
Kit.blockClicks = false
end
return FileBrowser
+178 -54
View File
@@ -43,19 +43,22 @@
local Theme = require("src.ui.kit.Theme") local Theme = require("src.ui.kit.Theme")
local PAL = Theme.PAL local PAL = Theme.PAL
local VirtualKeyboard = require("src.ui.kit.VirtualKeyboard")
local FileBrowser = require("src.ui.kit.FileBrowser")
local Kit = {} local Kit = {
Kit.Theme = Theme scale = 1,
Kit.PAL = PAL time = 0,
focus = nil, -- active text-input id (nil = no focused field)
focusId = nil, -- spatial-nav ring id (nil = nothing selected by pad/arrows)
VirtualKeyboard = VirtualKeyboard,
FileBrowser = FileBrowser,
}
Kit.mouseX, Kit.mouseY = 0, 0 Kit.mouseX, Kit.mouseY = 0, 0
Kit.mouseClicked = false -- left button pressed this frame Kit.mouseClicked = false -- left button pressed this frame
Kit.mouseDown = false -- held, polled (drag / press-and-hold) Kit.mouseDown = false -- held, polled (drag / press-and-hold)
Kit.wheelY = 0 -- wheel notches queued since the last frame Kit.wheelY = 0 -- wheel notches queued since the last frame
Kit.focus = nil -- id of the text field receiving keystrokes
Kit.focusId = nil -- id of the keyboard/gamepad focus ring target
Kit.time = 0
Kit.fonts = {}
Kit.scale = 1 Kit.scale = 1
Kit.blockClicks = false Kit.blockClicks = false
Kit.audit = nil Kit.audit = nil
@@ -339,6 +342,32 @@ function Kit.beginFrame(mx, my, clicked, wheel)
Kit._navN = 0 Kit._navN = 0
end end
local function getNavLayer(slot)
if not slot then return 3 end
local id = tostring(slot.id or "")
local y = slot.y or 0
-- Layer 1: Top Bar (Settings / Gear, Close / Quit)
if id == "gear" or id == "settings" or id == "close" or id == "quit" or (y < 45 * Kit.scale and not id:match("^tab%-")) then
return 1
end
-- Layer 2: ROM Select & Feature Tabs (Red, Blue, Yellow, Gold, Silver, Crystal, Mods, Find, Skins, Bug)
if id:match("^tab%-") or (y >= 45 * Kit.scale and y < 105 * Kit.scale and (slot.h and slot.h < 50 * Kit.scale)) then
return 2
end
-- Layer 4: Footer (Patch notes, Updater, BCG, Export, etc.)
local screenH = (love and love.graphics and love.graphics.getHeight and love.graphics.getHeight()) or 480
if id == "patch-notes" or id == "updater" or id == "bcg"
or id:match("^footer") or (y > screenH - 55 * Kit.scale) then
return 4
end
-- Layer 3: Main content panel (Import ROM / Play, Manage, Save slots, Mods list, Find mods, etc.)
return 3
end
-- Retire this frame's keystrokes, wheel notches and one-shot activations. -- Retire this frame's keystrokes, wheel notches and one-shot activations.
-- Anything typed while no field had focus is dropped here rather than -- Anything typed while no field had focus is dropped here rather than
-- replayed into the next field that gets clicked. -- replayed into the next field that gets clicked.
@@ -349,10 +378,26 @@ function Kit.endFrame()
-- This frame's focusables become next frame's navigation graph. -- This frame's focusables become next frame's navigation graph.
local n = Kit._navN or 0 local n = Kit._navN or 0
Kit._navPrevN = n Kit._navPrevN = n
-- If the focused id vanished (panel switch, list repaged), park the ring -- If the focused id vanished or not set yet, park the ring on Layer 3 (Import ROM / Play)
-- on the first focusable so the keyboard is never stranded. if (not Kit.focusId or not Kit._navSeen[Kit.focusId]) and n > 0 then
if Kit.focusId and not Kit._navSeen[Kit.focusId] and n > 0 then -- 1. Prefer ROM Action (Layer 3)
Kit.focusId = Kit._nav[1] and Kit._nav[1].id or nil local chosen = nil
for i = 1, n do
local slot = Kit._nav[i]
if slot and slot.id and tostring(slot.id):match("^rom%-") then
chosen = slot.id; break
end
end
-- 2. Prefer any Layer 3 control
if not chosen then
for i = 1, n do
local slot = Kit._nav[i]
if slot and getNavLayer(slot) == 3 then
chosen = slot.id; break
end
end
end
Kit.focusId = chosen or (Kit._nav[1] and Kit._nav[1].id) or nil
end end
for k in pairs(Kit._navSeen) do Kit._navSeen[k] = nil end for k in pairs(Kit._navSeen) do Kit._navSeen[k] = nil end
end end
@@ -360,21 +405,16 @@ end
-- ------------------------------------------------------------ focus ring -- ------------------------------------------------------------ focus ring
-- Spatial navigation. Every focusable control registers its rect as it -- Spatial navigation. Every focusable control registers its rect as it
-- draws; a queued direction picks the nearest candidate in that direction -- draws; a queued direction picks the nearest candidate in that direction
-- from the previous frame's set. Spatial rather than index-order because -- from the previous frame's set.
-- the launcher is a multi-column layout: tab-order would zigzag between
-- columns, while "press right, go right" is what both a keyboard and a
-- d-pad user expects.
Kit._nav = {} Kit._nav = {}
Kit._navN = 0 Kit._navN = 0
Kit._navPrevN = 0 Kit._navPrevN = 0
Kit._navSeen = {} Kit._navSeen = {}
Kit._navQueue = nil Kit._navQueue = nil
Kit._activateId = nil Kit._activateId = nil
Kit._ringShown = false Kit._ringShown = true
-- Register a focusable. Returns true when it currently holds the ring. -- Register a focusable. Returns true when it currently holds the ring.
-- Shielded widgets do not register: while a modal owns the frame the ring
-- must not wander through (or Enter-activate) the controls underneath it.
function Kit.focusable(id, x, y, w, h) function Kit.focusable(id, x, y, w, h)
if Kit.blockClicks then return false end if Kit.blockClicks then return false end
local n = (Kit._navN or 0) + 1 local n = (Kit._navN or 0) + 1
@@ -383,9 +423,9 @@ function Kit.focusable(id, x, y, w, h)
if not slot then slot = {}; Kit._nav[n] = slot end if not slot then slot = {}; Kit._nav[n] = slot end
slot.id, slot.x, slot.y, slot.w, slot.h = id, x, y, w, h slot.id, slot.x, slot.y, slot.w, slot.h = id, x, y, w, h
Kit._navSeen[id] = true Kit._navSeen[id] = true
-- First focusable ever drawn adopts the ring, so keyboard users start if Kit.focusId == nil and tostring(id):match("^rom%-") then
-- somewhere rather than nowhere. Kit.focusId = id
if Kit.focusId == nil then Kit.focusId = id end end
return Kit._ringShown and Kit.focusId == id return Kit._ringShown and Kit.focusId == id
end end
@@ -403,10 +443,6 @@ function Kit.setFocus(id)
Kit._ringShown = id ~= nil Kit._ringShown = id ~= nil
end end
-- Pick the nearest focusable in `dir` from the current one. Candidates must
-- lie in the half-plane of the direction; the score prefers a small step
-- along the axis of travel and penalises drift across it, which keeps a
-- column walk inside its column.
function Kit._resolveNav() function Kit._resolveNav()
local dir = Kit._navQueue local dir = Kit._navQueue
Kit._navQueue = nil Kit._navQueue = nil
@@ -414,38 +450,75 @@ function Kit._resolveNav()
if not dir or n == 0 then return end if not dir or n == 0 then return end
local cur local cur
for i = 1, n do for i = 1, n do
if Kit._nav[i].id == Kit.focusId then cur = Kit._nav[i] break end if Kit._nav[i].id == Kit.focusId then cur = Kit._nav[i]; break end
end end
if not cur then if not cur then
Kit.focusId = Kit._nav[1].id Kit.focusId = Kit._nav[1].id
return return
end end
local curLayer = getNavLayer(cur)
local cx, cy = cur.x + cur.w / 2, cur.y + cur.h / 2 local cx, cy = cur.x + cur.w / 2, cur.y + cur.h / 2
local best, bestScore
for i = 1, n do if dir == "left" or dir == "right" then
local c = Kit._nav[i] -- STRICT SAME-LAYER HORIZONTAL NAVIGATION (Left/Right NEVER jumps between layers)
if c.id ~= cur.id then local best, bestDx
local dx = (c.x + c.w / 2) - cx for i = 1, n do
local dy = (c.y + c.h / 2) - cy local c = Kit._nav[i]
local along, across if c.id ~= cur.id and getNavLayer(c) == curLayer then
if dir == "left" then along, across = -dx, math.abs(dy) local cMidX = c.x + c.w / 2
elseif dir == "right" then along, across = dx, math.abs(dy) local dx = cMidX - cx
elseif dir == "up" then along, across = -dy, math.abs(dx) if dir == "left" and dx < -1 then
else along, across = dy, math.abs(dx) end local dist = -dx
-- A control merely overlapping on the travel axis is not "in that if not bestDx or dist < bestDx then
-- direction"; require real separation so a tall row's neighbours do best, bestDx = c, dist
-- not all qualify. end
if along > 1 then elseif dir == "right" and dx > 1 then
local score = along + across * 2 local dist = dx
if not bestScore or score < bestScore then best, bestScore = c, score end if not bestDx or dist < bestDx then
best, bestDx = c, dist
end
end
end end
end end
if best then
Kit.focusId = best.id
return
end
return
elseif dir == "up" or dir == "down" then
-- VERTICAL LAYER NAVIGATION (Up/Down steps between layers: 1 <-> 2 <-> 3 <-> 4)
local targetLayer = dir == "up" and (curLayer - 1) or (curLayer + 1)
targetLayer = math.max(1, math.min(4, targetLayer))
local best, bestScore
for i = 1, n do
local c = Kit._nav[i]
if c.id ~= cur.id then
local l = getNavLayer(c)
if (dir == "up" and l < curLayer) or (dir == "down" and l > curLayer) then
local layerDiff = math.abs(l - targetLayer)
local cMidX = c.x + c.w / 2
local xDist = math.abs(cMidX - cx)
local score = layerDiff * 10000 + xDist
if not bestScore or score < bestScore then
best, bestScore = c, score
end
end
end
end
if best then
Kit.focusId = best.id
return
end
end end
if best then Kit.focusId = best.id end
end end
-- ------------------------------------------------------------ input plumbing -- ------------------------------------------------------------ input plumbing
function Kit.textinput(text) function Kit.textinput(text)
if VirtualKeyboard.active then
return VirtualKeyboard.textinput(text)
end
if not Kit.focus then return false end if not Kit.focus then return false end
edits[#edits + 1] = text edits[#edits + 1] = text
return true return true
@@ -454,6 +527,12 @@ end
-- Returns true when the key was consumed, so the host can leave its own -- Returns true when the key was consumed, so the host can leave its own
-- shortcuts alone while the user is typing or driving the ring. -- shortcuts alone while the user is typing or driving the ring.
function Kit.keypressed(key) function Kit.keypressed(key)
if VirtualKeyboard.active then
return VirtualKeyboard.keypressed(key)
end
if FileBrowser.active then
return FileBrowser.keypressed(key)
end
if Kit.focus then if Kit.focus then
if key == "backspace" then edits[#edits + 1] = "\b" return true if key == "backspace" then edits[#edits + 1] = "\b" return true
elseif key == "return" or key == "kpenter" or key == "escape" then elseif key == "return" or key == "kpenter" or key == "escape" then
@@ -474,11 +553,19 @@ end
-- Gamepad d-pad / stick, routed by the host's pad handling. -- Gamepad d-pad / stick, routed by the host's pad handling.
function Kit.gamepadpressed(button) function Kit.gamepadpressed(button)
if button == "dpup" then Kit.navigate("up") return true local GamepadMap = require("src.core.GamepadMap")
elseif button == "dpdown" then Kit.navigate("down") return true local action = (GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button
elseif button == "dpleft" then Kit.navigate("left") return true if VirtualKeyboard.active then
elseif button == "dpright" then Kit.navigate("right") return true return VirtualKeyboard.gamepadpressed(action)
elseif button == "a" then Kit.activateFocused() return true end end
if FileBrowser.active then
return FileBrowser.gamepadpressed(action)
end
if action == "dpup" then Kit.navigate("up") return true
elseif action == "dpdown" then Kit.navigate("down") return true
elseif action == "dpleft" then Kit.navigate("left") return true
elseif action == "dpright" then Kit.navigate("right") return true
elseif action == "a" then Kit.activateFocused() return true end
return false return false
end end
@@ -548,9 +635,15 @@ function Kit.row(x, y, w, h, selected, id)
-- The focus ring is a second inset outline, so it reads on both a black -- The focus ring is a second inset outline, so it reads on both a black
-- row and a white selected one. -- row and a white selected one.
if focused then if focused then
Theme.strokeRounded(x + 2, y + 2, w - 4, h - 4, local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
selected and PAL.inverse or PAL.lineStrong, Theme.A.focus, 1, -- Outer white soft aura
Theme.radius()) Theme.strokeRounded(x - 3, y - 3, w + 6, h + 6,
PAL.ink, 0.35 + 0.25 * glowPulse, 2.5, Theme.radius() + 3)
-- Bright white inner stroke
Theme.strokeRounded(x, y, w, h,
PAL.ink, 0.95 + 0.05 * glowPulse, 2, Theme.radius())
-- Luminous white fill overlay
Theme.fillRounded(x, y, w, h, PAL.ink, 0.12 + 0.06 * glowPulse, Theme.radius())
end end
local clicked = Kit.press(x, y, w, h) local clicked = Kit.press(x, y, w, h)
or (id ~= nil and Kit._activateId == id) or (id ~= nil and Kit._activateId == id)
@@ -668,9 +761,17 @@ function Kit.button(x, y, w, h, label, opts)
Theme.strokeRounded(x, y, w, h, stroke, strokeA, 1, radius) Theme.strokeRounded(x, y, w, h, stroke, strokeA, 1, radius)
end end
if doRing then if doRing then
local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
-- 1. Outer bright white soft glow aura
Theme.strokeRounded(x - B.ringPad - 3, y - B.ringPad - 3,
w + 2 * (B.ringPad + 3), h + 2 * (B.ringPad + 3), PAL.ink,
0.35 + 0.25 * glowPulse, 2.5, radius + B.ringPad + 3)
-- 2. Inner solid white focus border
Theme.strokeRounded(x - B.ringPad, y - B.ringPad, Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong, w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.ink,
Theme.A.focus, B.ringWidth, radius + B.ringPad) 0.95 + 0.05 * glowPulse, 2.5, radius + B.ringPad)
-- 3. Subtle luminous white fill overlay so the entire button body shines
Theme.fillRounded(x, y, w, h, PAL.ink, 0.12 + 0.06 * glowPulse, radius)
elseif glowA then elseif glowA then
Theme.strokeRounded(x - B.ringPad, y - B.ringPad, Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong, w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong,
@@ -829,6 +930,29 @@ function Kit.textfield(id, x, y, w, h, value, placeholder)
audit("control", x, y, w, h, id) audit("control", x, y, w, h, id)
local focusRing = Kit.focusable(id, x, y, w, h) local focusRing = Kit.focusable(id, x, y, w, h)
value = tostring(value or "") value = tostring(value or "")
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"
local clickedOrActivated = (Kit._activateId == id) or (isHandheld and Kit.press(x, y, w, h))
if clickedOrActivated and not mobile() then
VirtualKeyboard.open({
text = value,
targetId = id,
title = placeholder or "Enter Text",
onDone = function(newText, confirmed)
if confirmed then
edits[#edits + 1] = "\r"
end
end
})
end
if VirtualKeyboard.active and VirtualKeyboard.targetId == id then
value = VirtualKeyboard.text
end
if Kit.press(x, y, w, h) or (Kit._activateId == id) then Kit.focus = id end if Kit.press(x, y, w, h) or (Kit._activateId == id) then Kit.focus = id end
local focused = (Kit.focus == id) local focused = (Kit.focus == id)
if focused then if focused then
+265
View File
@@ -0,0 +1,265 @@
-- Controller-driven on-screen keyboard (OSK) for handhelds and gamepads.
-- Allows typing URLs (mod index, repositories), mod search queries, and custom names
-- without needing a physical keyboard.
local Theme = require("src.ui.kit.Theme")
local PAL = Theme.PAL
local Kit = nil
local function getKit()
if not Kit then Kit = require("src.ui.kit.Kit") end
return Kit
end
local VirtualKeyboard = {
active = false,
text = "",
title = "Enter Text",
targetId = nil,
onDone = nil,
row = 1,
col = 1,
mode = 1, -- 1: lower, 2: upper, 3: symbols
}
local ROWS_LOWER = {
{ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "=" },
{ "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "/", ":" },
{ "a", "s", "d", "f", "g", "h", "j", "k", "l", ".", "_", "@" },
{ "z", "x", "c", "v", "b", "n", "m", "?", "!", "&", "%", "#" },
{ { id = "shift", label = "Shift", w = 2 }, { id = "sym", label = "Sym", w = 2 }, { id = "space", label = "Space", w = 4 }, { id = "back", label = "Bksp", w = 2 }, { id = "done", label = "Done", w = 2 } },
{ { id = "url_https", label = "https://", w = 3 }, { id = "url_http", label = "http://", w = 2 }, { id = "url_json", label = ".json", w = 2 }, { id = "url_zip", label = ".zip", w = 2 }, { id = "clear", label = "Clear", w = 3 } },
}
local ROWS_UPPER = {
{ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "=" },
{ "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "/", ":" },
{ "A", "S", "D", "F", "G", "H", "J", "K", "L", ".", "_", "@" },
{ "Z", "X", "C", "V", "B", "N", "M", "?", "!", "&", "%", "#" },
{ { id = "shift", label = "shift", w = 2 }, { id = "sym", label = "Sym", w = 2 }, { id = "space", label = "Space", w = 4 }, { id = "back", label = "Bksp", w = 2 }, { id = "done", label = "Done", w = 2 } },
{ { id = "url_https", label = "https://", w = 3 }, { id = "url_http", label = "http://", w = 2 }, { id = "url_json", label = ".json", w = 2 }, { id = "url_zip", label = ".zip", w = 2 }, { id = "clear", label = "Clear", w = 3 } },
}
local ROWS_SYM = {
{ "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "_", "+" },
{ "~", "`", "{", "}", "[", "]", "|", "\\", ";", ":", "'", "\"" },
{ "<", ">", ",", ".", "/", "?", "=", "-", "*", "/", "\\", "~" },
{ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", ".", "=" },
{ { id = "shift", label = "ABC", w = 2 }, { id = "sym", label = "123", w = 2 }, { id = "space", label = "Space", w = 4 }, { id = "back", label = "Bksp", w = 2 }, { id = "done", label = "Done", w = 2 } },
{ { id = "url_https", label = "https://", w = 3 }, { id = "url_http", label = "http://", w = 2 }, { id = "url_json", label = ".json", w = 2 }, { id = "url_zip", label = ".zip", w = 2 }, { id = "clear", label = "Clear", w = 3 } },
}
function VirtualKeyboard.open(opts)
opts = opts or {}
VirtualKeyboard.active = true
VirtualKeyboard.text = tostring(opts.text or "")
VirtualKeyboard.title = opts.title or "Enter Text"
VirtualKeyboard.targetId = opts.targetId
VirtualKeyboard.onDone = opts.onDone
VirtualKeyboard.row = 1
VirtualKeyboard.col = 1
VirtualKeyboard.mode = 1
end
function VirtualKeyboard.close(confirmed)
if not VirtualKeyboard.active then return end
local txt = VirtualKeyboard.text
local cb = VirtualKeyboard.onDone
VirtualKeyboard.active = false
VirtualKeyboard.targetId = nil
VirtualKeyboard.onDone = nil
if cb then
cb(txt, confirmed == true)
end
end
local function currentRows()
if VirtualKeyboard.mode == 2 then return ROWS_UPPER end
if VirtualKeyboard.mode == 3 then return ROWS_SYM end
return ROWS_LOWER
end
local function triggerKey(key)
if type(key) == "string" then
VirtualKeyboard.text = VirtualKeyboard.text .. key
elseif type(key) == "table" then
if key.id == "shift" then
VirtualKeyboard.mode = (VirtualKeyboard.mode == 2) and 1 or 2
elseif key.id == "sym" then
VirtualKeyboard.mode = (VirtualKeyboard.mode == 3) and 1 or 3
elseif key.id == "space" then
VirtualKeyboard.text = VirtualKeyboard.text .. " "
elseif key.id == "back" then
VirtualKeyboard.text = VirtualKeyboard.text:sub(1, -2)
elseif key.id == "clear" then
VirtualKeyboard.text = ""
elseif key.id == "done" then
VirtualKeyboard.close(true)
elseif key.id == "url_https" then
VirtualKeyboard.text = VirtualKeyboard.text .. "https://"
elseif key.id == "url_http" then
VirtualKeyboard.text = VirtualKeyboard.text .. "http://"
elseif key.id == "url_json" then
VirtualKeyboard.text = VirtualKeyboard.text .. ".json"
elseif key.id == "url_zip" then
VirtualKeyboard.text = VirtualKeyboard.text .. ".zip"
end
end
end
function VirtualKeyboard.gamepadpressed(button)
if not VirtualKeyboard.active then return false end
local rows = currentRows()
local r = VirtualKeyboard.row
local c = VirtualKeyboard.col
if button == "dpup" then
VirtualKeyboard.row = math.max(1, r - 1)
local maxC = #rows[VirtualKeyboard.row]
if VirtualKeyboard.col > maxC then VirtualKeyboard.col = maxC end
return true
elseif button == "dpdown" then
VirtualKeyboard.row = math.min(#rows, r + 1)
local maxC = #rows[VirtualKeyboard.row]
if VirtualKeyboard.col > maxC then VirtualKeyboard.col = maxC end
return true
elseif button == "dpleft" then
VirtualKeyboard.col = math.max(1, c - 1)
return true
elseif button == "dpright" then
VirtualKeyboard.col = math.min(#rows[r], c + 1)
return true
elseif button == "a" then
local key = rows[r] and rows[r][c]
if key then triggerKey(key) end
return true
elseif button == "b" then
VirtualKeyboard.close(false)
return true
elseif button == "x" then
VirtualKeyboard.text = VirtualKeyboard.text:sub(1, -2)
return true
elseif button == "y" then
VirtualKeyboard.text = VirtualKeyboard.text .. " "
return true
elseif button == "leftshoulder" or button == "rightshoulder" then
VirtualKeyboard.mode = (VirtualKeyboard.mode % 3) + 1
local maxC = #currentRows()[VirtualKeyboard.row]
if VirtualKeyboard.col > maxC then VirtualKeyboard.col = maxC end
return true
elseif button == "start" then
VirtualKeyboard.close(true)
return true
end
return true -- consume all input while keyboard modal is up
end
function VirtualKeyboard.keypressed(key)
if not VirtualKeyboard.active then return false end
if key == "escape" then
VirtualKeyboard.close(false)
return true
elseif key == "return" then
VirtualKeyboard.close(true)
return true
elseif key == "backspace" then
VirtualKeyboard.text = VirtualKeyboard.text:sub(1, -2)
return true
end
return false
end
function VirtualKeyboard.textinput(text)
if not VirtualKeyboard.active then return false end
VirtualKeyboard.text = VirtualKeyboard.text .. text
return true
end
function VirtualKeyboard.draw(m)
if not VirtualKeyboard.active then return end
local Kit = getKit()
local s = m and m.s or Kit.scale or 1
local W = (m and m.W) or (love.graphics and love.graphics.getWidth()) or 640
local H = (m and m.H) or (love.graphics and love.graphics.getHeight()) or 480
-- Scrim background
Theme.fill(0, 0, W, H, PAL.bg, 0.92)
Kit.blockClicks = true
local pad = math.floor(12 * s)
local modalW = math.floor(math.min(540 * s, W - 20 * s))
local modalH = math.floor(math.min(360 * s, H - 20 * s))
local px = math.floor((W - modalW) / 2)
local py = math.floor((H - modalH) / 2)
Kit.card(px, py, modalW, modalH, true)
local cy = py + pad
-- Title & hint
Kit.text("button", VirtualKeyboard.title, px + pad, cy, PAL.heading)
Kit.textRight("micro", "L1/R1: Mode X: Bksp Y: Space Start: Done B: Cancel",
px + modalW - pad, cy + 2 * s, PAL.muted)
cy = cy + Kit.textHeight("button") + math.floor(8 * s)
-- Preview textfield box
local fieldH = math.floor(32 * s)
Theme.fillRounded(px + pad, cy, modalW - 2 * pad, fieldH, PAL.bg, 1)
Theme.strokeRounded(px + pad, cy, modalW - 2 * pad, fieldH, PAL.lineStrong, 1, 2)
local shown = Kit.ellipsizeLeft("mono", VirtualKeyboard.text .. "_", modalW - 4 * pad)
Kit.text("mono", shown, px + pad + math.floor(8 * s), cy + math.floor(7 * s), PAL.heading)
cy = cy + fieldH + math.floor(10 * s)
-- Keyboard Rows
local rows = currentRows()
local rowH = math.floor(30 * s)
local gap = math.floor(4 * s)
local availableW = modalW - 2 * pad
for rIdx, rData in ipairs(rows) do
local totalUnits = 0
for _, key in ipairs(rData) do
totalUnits = totalUnits + (type(key) == "table" and (key.w or 1) or 1)
end
local unitW = (availableW - (gap * (#rData - 1))) / totalUnits
local kx = px + pad
for cIdx, key in ipairs(rData) do
local units = type(key) == "table" and (key.w or 1) or 1
local kw = math.floor(unitW * units)
local isSelected = (VirtualKeyboard.row == rIdx and VirtualKeyboard.col == cIdx)
local label = type(key) == "table" and key.label or key
local isSpecial = type(key) == "table"
-- Click / touch detection
if Kit.press(kx, cy, kw, rowH) then
VirtualKeyboard.row = rIdx
VirtualKeyboard.col = cIdx
triggerKey(key)
end
if isSelected then
Theme.fillRounded(kx, cy, kw, rowH, PAL.ink, 1)
Kit.textCenterBold("small", label, kx, cy + math.floor(6 * s), kw, PAL.inverse)
else
Theme.fillRounded(kx, cy, kw, rowH, isSpecial and PAL.raised or PAL.surface, 1)
Theme.strokeRounded(kx, cy, kw, rowH, PAL.line, Theme.A.hairline, 1)
Kit.textCenter("small", label, kx, cy + math.floor(6 * s), kw, isSpecial and PAL.heading or PAL.text)
end
kx = kx + kw + gap
end
cy = cy + rowH + gap
end
Kit.blockClicks = false
end
return VirtualKeyboard
+11 -1
View File
@@ -43,10 +43,13 @@ local function fullAssetName(version, osName, arch, port)
return "gen1recomp-" .. version .. "-xbox-uwp.zip" return "gen1recomp-" .. version .. "-xbox-uwp.zip"
elseif osName == "NX" then elseif osName == "NX" then
return "gen1recomp-" .. version .. "-switch.zip" return "gen1recomp-" .. version .. "-switch.zip"
elseif osName == "Linux" and (port == "flatpak"
or (type(os.getenv) == "function" and os.getenv("FLATPAK_ID"))) then
return "gen1recomp-" .. version .. "-linux.flatpak"
elseif osName == "Linux" and (arch == "arm64" or arch == "aarch64") then elseif osName == "Linux" and (arch == "arm64" or arch == "aarch64") then
return "gen1recomp-" .. version .. "-linux-arm64.AppImage" return "gen1recomp-" .. version .. "-linux-arm64.AppImage"
elseif osName == "Linux" then elseif osName == "Linux" then
return "gen1recomp-" .. version .. "-linux.zip" return "gen1recomp-" .. version .. "-linux-x86_64.AppImage"
end end
return nil return nil
end end
@@ -288,6 +291,13 @@ function Check.fullUpdateAction()
"https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json" } "https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json" }
elseif osName == "UWP" then elseif osName == "UWP" then
return { label = "Open Xbox install guide", url = Check.releaseUrl() } return { label = "Open Xbox install guide", url = Check.releaseUrl() }
elseif osName == "Linux" and type(st.full) == "table"
and type(st.full.url) == "string" then
local name = type(st.full.name) == "string" and st.full.name or ""
if name:find("%.flatpak$") or os.getenv("FLATPAK_ID") then
return { label = "Download Flatpak update", url = st.full.url }
end
return { label = "Download AppImage update", url = st.full.url }
elseif type(st.full) == "table" and type(st.full.url) == "string" then elseif type(st.full) == "table" and type(st.full.url) == "string" then
return { label = "Download full update", url = st.full.url } return { label = "Download full update", url = st.full.url }
end end
+7 -2
View File
@@ -310,8 +310,13 @@ local function launchDownload(url, partAbs, doneAbs)
.. "type nul > \"" .. doneAbs .. "\"\r\n") .. "type nul > \"" .. doneAbs .. "\"\r\n")
os.execute('start "" /b ' .. shq(saveDir .. "/" .. batRel)) os.execute('start "" /b ' .. shq(saveDir .. "/" .. batRel))
else else
-- ( ... ) & backgrounds the whole group so os.execute returns at once -- ( ... ) & backgrounds the whole group so os.execute returns at once.
os.execute("( " .. HostShell.envPrefix() .. "curl -fsSL --connect-timeout 15 --max-time 900 -o " -- Use the same dual-env curl resolution as HostShell.http*: bundled
-- AppDir/Flatpak curl keeps APPDIR libs; host curl scrubs them.
local curlPath, curlKind = HostShell.resolveCurl()
os.execute("( " .. HostShell.curlEnvPrefix(curlKind)
.. HostShell.quote(curlPath)
.. " -fsSL --connect-timeout 15 --max-time 900 -o "
.. shq(partAbs) .. " " .. shq(url) .. shq(partAbs) .. " " .. shq(url)
.. " ; touch " .. shq(doneAbs) .. " ) >/dev/null 2>&1 &") .. " ; touch " .. shq(doneAbs) .. " ) >/dev/null 2>&1 &")
end end
+2 -1
View File
@@ -4,6 +4,7 @@
-- SpecialKabutoChamber, the four routines that open the chambers' walls. -- SpecialKabutoChamber, the four routines that open the chambers' walls.
local Assets = require("src.render.Assets") local Assets = require("src.render.Assets")
local Chrome = require("src.ui.gen2.Chrome")
local Font = require("src.render.Font") local Font = require("src.render.Font")
local GbcPalette = require("src.render.GbcPalette") local GbcPalette = require("src.render.GbcPalette")
local Palettes = require("src.world.gen2.Palettes") local Palettes = require("src.world.gen2.Palettes")
@@ -189,7 +190,7 @@ function UnownWords:draw()
local wall = self.wall local wall = self.wall
if not wall then return end if not wall then return end
local bx, by, bw, bh = UnownWords.boxRect(wall) local bx, by, bw, bh = UnownWords.boxRect(wall)
Font.drawBox(bx, by, bw, bh) Chrome.paletteBox(bx, by, bw, bh)
local tileset, atlas = self:tileset() local tileset, atlas = self:tileset()
if not tileset then return end if not tileset then return end
local perRow = tileset.tilesPerRow or 16 local perRow = tileset.tilesPerRow or 16
+7 -5
View File
@@ -8217,7 +8217,8 @@ function World:mapCacheKey(mapId)
local daytime = self.daytime local daytime = self.daytime
local flicker = (daytime == "DARK") and self.flickerPhase or 1 local flicker = (daytime == "DARK") and self.flickerPhase or 1
return mapId .. "|" .. tostring(daytime) return mapId .. "|" .. tostring(daytime)
.. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(flicker) .. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(GbcPalette.customRamp)
.. "|" .. tostring(flicker)
end end
function World:imageFor(mapId) function World:imageFor(mapId)
@@ -8440,7 +8441,8 @@ function World:borderImageFor(mapId)
-- stale bake (#1418). -- stale bake (#1418).
local waterFrame = self:borderWaterFrame(def, tileset) local waterFrame = self:borderWaterFrame(def, tileset)
local cacheKey = BorderFill.cacheKey(mapId .. "|" .. tostring(daytime) local cacheKey = BorderFill.cacheKey(mapId .. "|" .. tostring(daytime)
.. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(flicker) .. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(GbcPalette.customRamp)
.. "|" .. tostring(flicker)
.. "|" .. tostring(BorderFill.voidFill or "fade") .. "|" .. tostring(BorderFill.voidFill or "fade")
.. "|" .. tostring(blockId) .. "|" .. tostring(blockId)
.. "|" .. tostring(waterFrame and waterFrame.row or 0)) .. "|" .. tostring(waterFrame and waterFrame.row or 0))
@@ -10500,12 +10502,12 @@ end
-- The COLOR option can change under a standing world (the hotkey, or the -- The COLOR option can change under a standing world (the hotkey, or the
-- OPTION screen closing), and the map is a baked canvas rather than a live -- OPTION screen closing), and the map is a baked canvas rather than a live
-- draw -- so the cached references have to be re-fetched when it does. The -- draw -- so the cached references have to be re-fetched when it does. The
-- bakes themselves are keyed by mode in imageFor, so this is a pointer swap
-- after the first time each mode is seen, not a re-bake.
function World:refreshColorMode() function World:refreshColorMode()
local mode = GbcPalette.mode local mode = GbcPalette.mode
if self.colorMode == mode then return end local ramp = GbcPalette.customRamp
if self.colorMode == mode and self.colorRamp == ramp then return end
self.colorMode = mode self.colorMode = mode
self.colorRamp = ramp
if not self.map then return end if not self.map then return end
self.mapImage = self:imageFor(self.map.id) self.mapImage = self:imageFor(self.map.id)
self:rebuildNeighbors() self:rebuildNeighbors()
@@ -20,8 +20,8 @@ return function(game)
end end
assert(layoutRow and hudRow, "battle layout/HUD rows are present") assert(layoutRow and hudRow, "battle layout/HUD rows are present")
menu.index = 6 -- BATTLE HUD lives on the BATTLE OPTIONS page now, so focus it there.
menu.scroll = 3 assert(menu:focusRow("battleHud"), "BATTLE HUD row is reachable")
assert(hudRow.value(game) == "EXTENDED", "WIDE displays EXTENDED") assert(hudRow.value(game) == "EXTENDED", "WIDE displays EXTENDED")
assert(U.shot(game, BEFORE), "WIDE/EXTENDED screenshot was written") assert(U.shot(game, BEFORE), "WIDE/EXTENDED screenshot was written")

Some files were not shown because too many files have changed in this diff Show More