diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61e838dc..587e557c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -333,6 +333,56 @@ jobs: if-no-files-found: error retention-days: 7 + linux-flatpak-changes: + name: detect Linux Flatpak changes + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.paths.outputs.changed }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - id: paths + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.sha }} + run: | + if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_flatpak\.sh$|flatpak/|scripts/pack_love\.sh$|docs/linux-flatpak\.md$|\.github/workflows/(ci|release)\.yml$)'; then + echo "changed=true" >> "$GITHUB_OUTPUT" + else + echo "changed=false" >> "$GITHUB_OUTPUT" + fi + + linux-flatpak-build: + name: Linux Flatpak build + needs: linux-flatpak-changes + if: needs.linux-flatpak-changes.outputs.changed == 'true' + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - name: Install flatpak-builder deps + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y flatpak flatpak-builder elfutils imagemagick + - name: Build Flatpak bundle + run: | + set -euo pipefail + scripts/build_flatpak.sh --version 0.0.0 + - name: Upload the Flatpak + uses: actions/upload-artifact@v7 + with: + name: gen1recomp-linux-flatpak + path: | + dist/flatpak/gen1recomp-0.0.0-linux.flatpak + dist/flatpak/gen1recomp-0.0.0-linux.flatpak.sha256 + if-no-files-found: error + retention-days: 7 + headless: name: headless suites (no ROM) runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e3df3ec4..30470e7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -143,7 +143,7 @@ jobs: linux-arm64: name: build Linux arm64 AppImage - needs: [version, love-payload] + needs: [version, love-payload, shaderfx-bridge] # GitHub's free arm64 runner for public repos. It has to be arm64: the # AppImage compiles LÖVE natively inside a Debian bullseye arm64 # container, and the qemu-emulated alternative takes hours. @@ -155,13 +155,22 @@ jobs: with: name: gen1recomp-release-love path: .bazinga/work + - name: Download the ShaderFX bridge + uses: actions/download-artifact@v8 + with: + name: shaderfx-bridge-linux-arm64 + path: dist/native/linux-arm64 - name: Build Linux arm64 AppImage + env: + SHADERFX_BRIDGE_REQUIRED: "1" run: | set -euo pipefail scripts/build_linux_arm64.sh \ --version "${{ needs.version.outputs.version }}" \ --game-love .bazinga/work/game.love - name: Verify the AppImage is self-contained and bullseye-compatible + env: + SHADERFX_BRIDGE_REQUIRED: "1" run: bash scripts/linux-arm64/verify_appimage.sh "dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage" - name: Upload Linux arm64 release uses: actions/upload-artifact@v7 @@ -173,6 +182,45 @@ jobs: if-no-files-found: error retention-days: 1 + linux-flatpak: + name: build Linux Flatpak + needs: [version, love-payload, shaderfx-bridge] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - name: Install flatpak-builder deps + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y flatpak flatpak-builder elfutils imagemagick + - name: Download shared payload + uses: actions/download-artifact@v8 + with: + name: gen1recomp-release-love + path: .bazinga/work + - name: Download the ShaderFX bridge + uses: actions/download-artifact@v8 + with: + name: shaderfx-bridge-linux-x64 + path: dist/native/linux-x64 + - name: Build Flatpak bundle + env: + SHADERFX_BRIDGE_REQUIRED: "1" + run: | + set -euo pipefail + scripts/build_flatpak.sh \ + --version "${{ needs.version.outputs.version }}" \ + --game-love .bazinga/work/game.love + - name: Upload Flatpak release + uses: actions/upload-artifact@v7 + with: + name: gen1recomp-linux-flatpak-release + path: | + dist/flatpak/gen1recomp-${{ needs.version.outputs.version }}-linux.flatpak + dist/flatpak/gen1recomp-${{ needs.version.outputs.version }}-linux.flatpak.sha256 + if-no-files-found: error + retention-days: 1 + xbox-uwp: name: build Xbox UWP release needs: [version, love-payload] @@ -286,8 +334,82 @@ jobs: if-no-files-found: error retention-days: 1 + shaderfx-bridge: + name: build ShaderFX bridge (${{ matrix.plat }}) + needs: version + strategy: + fail-fast: false + matrix: + include: + - plat: win-x64 + runs-on: windows-2022 + lib: librashader_bridge.dll + - plat: mac + runs-on: macos-latest + lib: liblibrashader_bridge.dylib + - plat: linux-x64 + runs-on: ubuntu-24.04 + lib: liblibrashader_bridge.so + glibc_triple: x86_64-unknown-linux-gnu.2.17 + - plat: linux-arm64 + runs-on: ubuntu-24.04-arm + lib: liblibrashader_bridge.so + glibc_triple: aarch64-unknown-linux-gnu.2.17 + runs-on: ${{ matrix.runs-on }} + steps: + - uses: actions/checkout@v7 + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + - name: Cache cargo registry and build dir + uses: Swatinem/rust-cache@v2 + with: + workspaces: tools/shaderfx-bridge + key: ${{ matrix.plat }}-${{ matrix.glibc_triple }} + - name: Build the bridge + shell: bash + env: + PLAT: ${{ matrix.plat }} + LIB: ${{ matrix.lib }} + GLIBC_TRIPLE: ${{ matrix.glibc_triple }} + run: | + set -euo pipefail + out="dist/native/$PLAT" + mkdir -p "$out" + cd tools/shaderfx-bridge + case "$PLAT" in + win-x64) + rustup target add x86_64-pc-windows-msvc + cargo build --release --target x86_64-pc-windows-msvc + cp "target/x86_64-pc-windows-msvc/release/$LIB" "$GITHUB_WORKSPACE/$out/$LIB" + ;; + mac) + rustup target add x86_64-apple-darwin aarch64-apple-darwin + cargo build --release --target x86_64-apple-darwin + cargo build --release --target aarch64-apple-darwin + lipo -create -output "$GITHUB_WORKSPACE/$out/$LIB" \ + "target/x86_64-apple-darwin/release/$LIB" \ + "target/aarch64-apple-darwin/release/$LIB" + lipo -info "$GITHUB_WORKSPACE/$out/$LIB" + ;; + linux-x64|linux-arm64) + pipx install cargo-zigbuild + pipx inject cargo-zigbuild ziglang + triple="$GLIBC_TRIPLE" + rustup target add "${triple%%.*}" + cargo zigbuild --release --target "$triple" + cp "target/${triple%%.*}/release/$LIB" "$GITHUB_WORKSPACE/$out/$LIB" + ;; + esac + - name: Upload the bridge + uses: actions/upload-artifact@v7 + with: + name: shaderfx-bridge-${{ matrix.plat }} + path: dist/native/${{ matrix.plat }}/${{ matrix.lib }} + if-no-files-found: error + retention-days: 1 + release: - needs: [version, love-payload, xbox-uwp, linux-arm64, native-tls-win] + needs: [version, love-payload, xbox-uwp, linux-arm64, linux-flatpak, native-tls-win, shaderfx-bridge] runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }} steps: @@ -309,6 +431,28 @@ jobs: name: gen1tls-win-x64 path: dist/native/win-x64 + - name: Download ShaderFX bridge libraries + uses: actions/download-artifact@v8 + with: + pattern: shaderfx-bridge-* + path: dist/native + - name: Flatten the ShaderFX bridge artifact layout + run: | + set -euo pipefail + shopt -s nullglob + for d in dist/native/shaderfx-bridge-*/; do + d="${d%/}" + plat="${d#dist/native/shaderfx-bridge-}" + mkdir -p "dist/native/$plat" + for f in "$d"/*; do mv "$f" "dist/native/$plat/"; done + rmdir "$d" + done + for plat in mac win-x64 linux-x64 linux-arm64; do + ls "dist/native/$plat" >/dev/null \ + || { echo "::error::no ShaderFX bridge staged for $plat"; exit 1; } + done + ls -lR dist/native + # The same game.love the arm64 AppImage and Xbox UWP builds fused, so # every release asset ships one identical payload (build.sh's own pack # would omit PATCH_NOTES.md and mobile/ios/app-repo.json). @@ -358,6 +502,7 @@ jobs: - name: Build macOS + Windows + Linux env: GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll + SHADERFX_BRIDGE_REQUIRED: "1" run: | set -euo pipefail # Sign in-build (identity auto-detected from the temp keychain); @@ -372,6 +517,8 @@ jobs: --game-love dist/payload/game.love unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \ || { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; } + unzip -l dist/win/gen1recomp-win64.zip | grep -F librashader_bridge.dll \ + || { echo "::error::Windows zip is missing librashader_bridge.dll"; exit 1; } - name: Materialize Android release signing key env: @@ -429,6 +576,8 @@ jobs: --version "${{ needs.version.outputs.version }}" - name: Build Anbernic RG34XXSP port + env: + SHADERFX_BRIDGE_REQUIRED: "1" run: | set -euo pipefail # Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5 @@ -442,6 +591,7 @@ jobs: # builds, while this explicit local override keeps CI source-aligned. GEN1RECOMP_SOURCE_DIR: ${{ github.workspace }} GEN1RECOMP_RELEASE_TAG: v${{ needs.version.outputs.version }} + SHADERFX_BRIDGE_REQUIRED: "1" run: | set -euo pipefail # Same aarch64 PortMaster-style pack for Linux ARM SBC PortMaster. The build @@ -498,6 +648,13 @@ jobs: name: gen1recomp-linux-arm64-release path: dist/linux-arm64 + - name: Download Linux Flatpak release + if: github.repository == 'bryanthaboi/gen1recomp' + uses: actions/download-artifact@v8 + with: + name: gen1recomp-linux-flatpak-release + path: dist/flatpak + - name: Stage release assets if: github.repository == 'bryanthaboi/gen1recomp' id: assets @@ -509,16 +666,24 @@ jobs: mkdir -p "$outdir" cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip" cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip" - cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip" + # x86_64 desktop Linux: raw AppImage (no zip wrapper). + x64_appimage="dist/linux/gen1recomp-linux-x86_64.AppImage" + [ -f "$x64_appimage" ] || { echo "::error::$x64_appimage not found (expected from scripts/build.sh linux)"; exit 1; } + cp "$x64_appimage" "$outdir/gen1recomp-${v}-linux-x86_64.AppImage" + chmod +x "$outdir/gen1recomp-${v}-linux-x86_64.AppImage" # arm64 desktop Linux (Raspberry Pi, Armbian, arm64 VMs). Built on # its own runner because LÖVE publishes no aarch64 binary and the # AppImage has to be compiled natively; ships as a runnable - # AppImage rather than a zip so `chmod +x && ./it` just works. + # AppImage so `chmod +x && ./it` just works. arm64_appimage="dist/linux-arm64/gen1recomp-${v}-linux-arm64.AppImage" [ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; } cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage" chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage" + + flatpak_bundle="dist/flatpak/gen1recomp-${v}-linux.flatpak" + [ -f "$flatpak_bundle" ] || { echo "::error::$flatpak_bundle not found (expected from the linux-flatpak job)"; exit 1; } + cp "$flatpak_bundle" "$outdir/gen1recomp-${v}-linux.flatpak" apk="$(find dist/android/release -name '*.apk' | head -1)" [ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/release"; exit 1; } cp "$apk" "$outdir/gen1recomp-${v}-android.apk" @@ -656,8 +821,9 @@ jobs: release_files=( "dist/release/gen1recomp-${v}-macos.zip" "dist/release/gen1recomp-${v}-windows.zip" - "dist/release/gen1recomp-${v}-linux.zip" + "dist/release/gen1recomp-${v}-linux-x86_64.AppImage" "dist/release/gen1recomp-${v}-linux-arm64.AppImage" + "dist/release/gen1recomp-${v}-linux.flatpak" "dist/release/gen1recomp-${v}-android.apk" "dist/release/gen1recomp++-${v}-ios.ipa" "dist/release/gen1recomp-${v}-switch.zip" diff --git a/README.md b/README.md index 8591c88b..720e12f4 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ # Gen1Recomp -A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map -behavior are hand-written Lua; game data and graphics are decoded from a ROM -supplied by the player. +A native LÖVE2D recreation of Poke Red, Blue, Gold, Silver, and Crystal. The +engine and map behavior are hand-written Lua; game data and graphics are +decoded from a ROM supplied by the player. And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. ***Reverse Engineering Causes Obsessive Mental Problems*** [Click Here for the AI Use Disclosure!](AIDisclosure.md) > [!CAUTION] -> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project. Also, as I assumed would eventually happen, the idiot that made that website now pumped it full of adware. Please stay away from that website. +> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository, the Discord, and https://gen1re.com are the only official sources for this project. Also, as I assumed would eventually happen, the idiot that made that website now pumped it full of adware. Please stay away from that website.

-**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu) +# [SUPPORT / ANNOUNCEMENTS / MODS ALL FOUND ON THE DISCORD](https://bois.icu)

@@ -228,18 +228,27 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend. | `--launcher` | open the launcher anyway, so you can edit a shortcut you already made | -## Linux on arm64 (Raspberry Pi) +## Linux desktop (AppImage / Flatpak) -Alongside the x86_64 `gen1recomp-*-linux.zip`, every release ships -`gen1recomp-*-linux-arm64.AppImage` for 64-bit ARM desktop Linux — Raspberry -Pi 4/5, Armbian and other SBC distros, and arm64 VMs on Apple Silicon: +Releases ship raw AppImages (no zip wrapper) plus an optional Flatpak bundle: + +- `gen1recomp-*-linux-x86_64.AppImage` +- `gen1recomp-*-linux-arm64.AppImage` (Raspberry Pi 4/5, Armbian, arm64 VMs) +- `gen1recomp-*-linux.flatpak` (see [docs/linux-flatpak.md](docs/linux-flatpak.md)) + +```sh +chmod +x gen1recomp-*-linux-x86_64.AppImage +./gen1recomp-*-linux-x86_64.AppImage +``` ```sh chmod +x gen1recomp-*-linux-arm64.AppImage ./gen1recomp-*-linux-arm64.AppImage ``` -LÖVE publishes no aarch64 binary of any kind, so this artifact compiles the +Shared troubleshooting (FUSE, curl, portable mode): [docs/linux-appimage.md](docs/linux-appimage.md). + +LÖVE publishes no aarch64 binary of any kind, so the arm64 artifact compiles the engine — and SDL2, OpenAL and the codecs — from source inside a Debian bullseye arm64 container. It needs only glibc 2.29+, libstdc++, freetype and zlib on the host; OpenGL, X11, Wayland, KMSDRM, ALSA and PulseAudio are all diff --git a/build-linux-arm-sbc.sh b/build-linux-arm-sbc.sh index 43ee86a1..058c5cbe 100755 --- a/build-linux-arm-sbc.sh +++ b/build-linux-arm-sbc.sh @@ -192,6 +192,20 @@ chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64" cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \ "$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/" +BRIDGE_LIB="liblibrashader_bridge.so" +BRIDGE_SRC="${SHADERFX_BRIDGE_LINUX_ARM64:-}" +if [ -z "$BRIDGE_SRC" ] && [ -f "$ROOT/dist/native/linux-arm64/$BRIDGE_LIB" ]; then + BRIDGE_SRC="$ROOT/dist/native/linux-arm64/$BRIDGE_LIB" +fi +if [ -n "$BRIDGE_SRC" ] && [ -f "$BRIDGE_SRC" ]; then + cp "$BRIDGE_SRC" "$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/$BRIDGE_LIB" + say "bundled $BRIDGE_LIB for SHADER FX preset conversion" +elif [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then + fail "$BRIDGE_LIB not found: set SHADERFX_BRIDGE_LINUX_ARM64 or stage it at dist/native/linux-arm64/$BRIDGE_LIB" +else + warn "$BRIDGE_LIB not found: this port can run converted presets but not CONVERT new ones" +fi + # Drop a short license pointer for the bundled LÖVE bits. cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF' This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster @@ -203,81 +217,203 @@ EOF # Resolve the game directory from the launcher so this works with both # PortMaster-managed ports directories. cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF' -#!/bin/bash -# gen1recomp-sbc — Linux ARM SBC / PortMaster launcher -# Uses SHDIR-relative paths so firmware-specific mount points do not matter. +#!/usr/bin/env bash +# gen1recomp-sbc — High-Performance Native ARM64 Handheld Port Launcher +# Compatible with TrimUI Brick, TrimUI Smart Pro, Anbernic (H700/RK3566), muOS, Knulli, ArkOS, Stock OS export HOME="${HOME:-/root}" XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" SHDIR="$(cd "$(dirname "$0")" && pwd)" - -if [ -d "/mnt/SDCARD/Apps/PortMaster/PortMaster/" ]; then - controlfolder="/mnt/SDCARD/Apps/PortMaster/PortMaster" -elif [ -d "/mnt/SDCARD/Roms/ports/PortMaster" ]; then - controlfolder="/mnt/SDCARD/Roms/ports/PortMaster" -elif [ -d "/mnt/SDCARD/Data/PortMaster/" ]; then - controlfolder="/mnt/SDCARD/Data/PortMaster" -elif [ -d "$SHDIR/PortMaster" ]; then - controlfolder="$SHDIR/PortMaster" -elif [ -d "/opt/system/Tools/PortMaster/" ]; then - controlfolder="/opt/system/Tools/PortMaster" -elif [ -d "/opt/tools/PortMaster/" ]; then - controlfolder="/opt/tools/PortMaster" -elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then - controlfolder="$XDG_DATA_HOME/PortMaster" -elif [ -d "/roms/ports/PortMaster" ]; then - controlfolder="/roms/ports/PortMaster" -else - controlfolder="/mnt/SDCARD/Roms/PORTS/PortMaster" -fi - -if [ ! -f "$controlfolder/control.txt" ]; then - echo "PortMaster control.txt not found under $controlfolder" >&2 - exit 1 -fi -# shellcheck disable=SC1090 -source "$controlfolder/control.txt" -get_controls -if [ -n "${CFW_NAME:-}" ] && [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ]; then - # shellcheck disable=SC1090 - source "${controlfolder}/mod_${CFW_NAME}.txt" -fi - GAMEDIR="$SHDIR/gen1recomp-sbc" CONFDIR="$GAMEDIR/conf" + mkdir -p "$CONFDIR" - cd "$GAMEDIR" || exit 1 -> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1 +# Setup POSIX logging (compatible with busybox ash/dash/bash) +exec 1>"$GAMEDIR/log.txt" 2>&1 + +echo "=========================================" +echo "gen1recomp Handheld Native ARM64 Launcher" +echo "SHDIR: $SHDIR" +echo "GAMEDIR: $GAMEDIR" +echo "Date: $(date 2>/dev/null || echo 'N/A')" +echo "=========================================" + +# PortMaster's control.txt wants a home tree under /mnt/SDCARD/Data; on a +# fresh card that tree may not exist yet and its mkdir fails noisily (log.txt: +# "mkdir: can't create directory '/mnt/SDCARD/Data/home'"). Pre-create it +# before any PortMaster script runs so the failure cannot occur. +if [ -d /mnt/SDCARD ]; then + mkdir -p /mnt/SDCARD/Data/home 2>/dev/null || true +fi + +# 1. Locate PortMaster control directory if present +controlfolder="" +if [ -d "/mnt/SDCARD/Apps/PortMaster/PortMaster" ]; then + controlfolder="/mnt/SDCARD/Apps/PortMaster/PortMaster" +elif [ -d "/mnt/SDCARD/PortMaster/PortMaster" ]; then + controlfolder="/mnt/SDCARD/PortMaster/PortMaster" +elif [ -d "/mnt/SDCARD/PortMaster" ]; then + controlfolder="/mnt/SDCARD/PortMaster" +elif [ -d "/mnt/SDCARD/Apps/PortMaster" ]; then + controlfolder="/mnt/SDCARD/Apps/PortMaster" +elif [ -d "/mnt/SDCARD/Emus/PORTS/PortMaster" ]; then + controlfolder="/mnt/SDCARD/Emus/PORTS/PortMaster" +elif [ -d "/mnt/SDCARD/Emus/PORTMASTER" ]; then + controlfolder="/mnt/SDCARD/Emus/PORTMASTER" +elif [ -d "/mnt/SDCARD/Roms/ports/PortMaster" ]; then + controlfolder="/mnt/SDCARD/Roms/ports/PortMaster" +elif [ -d "/mnt/SDCARD/Roms/PORTS/PortMaster" ]; then + controlfolder="/mnt/SDCARD/Roms/PORTS/PortMaster" +elif [ -d "/mnt/SDCARD/Ports/PortMaster" ]; then + controlfolder="/mnt/SDCARD/Ports/PortMaster" +elif [ -d "$SHDIR/PortMaster" ]; then + controlfolder="$SHDIR/PortMaster" +elif [ -d "/opt/system/Tools/PortMaster" ]; then + controlfolder="/opt/system/Tools/PortMaster" +elif [ -d "/opt/tools/PortMaster" ]; then + controlfolder="/opt/tools/PortMaster" +elif [ -d "$XDG_DATA_HOME/PortMaster" ]; then + controlfolder="$XDG_DATA_HOME/PortMaster" +elif [ -d "/mnt/SDCARD/Emus/tg5040/PORTS.pak/PortMaster" ]; then + controlfolder="/mnt/SDCARD/Emus/tg5040/PORTS.pak/PortMaster" +elif [ -d "/roms/PORTS/PortMaster" ]; then + controlfolder="/roms/PORTS/PortMaster" +elif [ -d "/roms/ports/PortMaster" ]; then + controlfolder="/roms/ports/PortMaster" +fi + +if [ -n "$controlfolder" ] && [ -f "$controlfolder/control.txt" ]; then + echo "Found PortMaster control folder: $controlfolder" + # shellcheck disable=SC1090 + . "$controlfolder/control.txt" + if type get_controls >/dev/null 2>&1; then + get_controls + fi + if [ -n "${CFW_NAME:-}" ] && [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ]; then + # shellcheck disable=SC1090 + . "${controlfolder}/mod_${CFW_NAME}.txt" + fi +else + echo "PortMaster control.txt not found, using standalone handheld configuration." +fi + +# 2. CPU governor +# Pinning every core to performance keeps handhelds at peak clock even when gameplay +# uses only a fraction of CPU. Schedutil is the default; set POKEPORT_CPU_GOVERNOR=performance +# only for profiling or devices needing an extra boost. +CPU_GOVERNOR_OVERRIDE="${POKEPORT_CPU_GOVERNOR:-schedutil}" +CPU_GOVERNOR_STATE="" + +write_cpu_governor() { + local governor_path="$1" + local governor="$2" + if [ -n "${ESUDO:-}" ]; then + printf '%s\n' "$governor" | $ESUDO tee "$governor_path" >/dev/null 2>&1 || true + else + printf '%s\n' "$governor" > "$governor_path" 2>/dev/null || true + fi +} + +restore_cpu_governors() { + [ -n "$CPU_GOVERNOR_STATE" ] || return 0 + while IFS='|' read -r governor_path governor; do + [ -n "$governor_path" ] || continue + write_cpu_governor "$governor_path" "$governor" + done <<< "$CPU_GOVERNOR_STATE" + CPU_GOVERNOR_STATE="" +} + +apply_cpu_governors() { + [ -n "$CPU_GOVERNOR_OVERRIDE" ] || return 0 + for governor_path in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do + [ -f "$governor_path" ] || continue + old_governor=$(cat "$governor_path" 2>/dev/null || true) + [ -n "$old_governor" ] || continue + CPU_GOVERNOR_STATE="${CPU_GOVERNOR_STATE}${governor_path}|${old_governor}"$'\n' + write_cpu_governor "$governor_path" "$CPU_GOVERNOR_OVERRIDE" + done + echo "CPU governor requested: $CPU_GOVERNOR_OVERRIDE" +} + +# 3. Environment & Low-Level OS Tuning +export POKEPORT_HANDHELD=1 +export HANDHELD=1 +export PORTMASTER=1 +export NINTENDO_LAYOUT=1 export XDG_DATA_HOME="$CONFDIR" export XDG_CONFIG_HOME="$CONFDIR" -export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}" +export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:$GAMEDIR/libs:/usr/trimui/lib:/mnt/SDCARD/System/lib:/usr/lib64:/usr/lib:${LD_LIBRARY_PATH:-}" export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}" -# GLES is the common path on ARM SBC handhelds; firmware may override it. export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}" -$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64 -$ESUDO chmod 666 /dev/uinput 2>/dev/null || true +# Audio buffer size +export SDL_AUDIO_SAMPLES=1024 -if [ -n "${GPTOKEYB:-}" ]; then - $GPTOKEYB "love.aarch64" & +# Chip-synth sample rate. The audio worker is the dominant CPU cost while +# idle (music keeps playing when the screen is static), and synthesis cost +# scales linearly with the rate. 22050 Hz roughly halves audio CPU vs the +# 44100 Hz default; the Game Boy's own DAC content is well below 11 kHz, so +# the handheld speaker sounds effectively identical. +export POKEPORT_AUDIO_RATE=22050 + +# Idle-power render governor: after 10s with no input on static screens, +# presentation drops to IDLE_FPS, cutting idle CPU/GPU compositing ~6x. +# Any button press restores full framerate next frame. +export POKEPORT_IDLE_FPS=6 + +# Memory allocator tuning (limit arena fragmentation on 1GB RAM SBCs) +export MALLOC_ARENA_MAX=2 +export MALLOC_TRIM_THRESHOLD_=131072 + +# Process scheduling & CPU affinity +renice -n -5 -p $$ >/dev/null 2>&1 || true +taskset -cp 0-3 $$ >/dev/null 2>&1 || true +ionice -c 3 -p $$ >/dev/null 2>&1 || true + +# Ensure executable permissions +if [ -n "${ESUDO:-}" ]; then + $ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || true + $ESUDO chmod 666 /dev/uinput 2>/dev/null || true +else + chmod a+x ./bin/love.aarch64 2>/dev/null || true fi + +# 4. Platform helper if type pm_platform_helper >/dev/null 2>&1; then + echo "Running pm_platform_helper..." pm_platform_helper "$GAMEDIR/bin/love.aarch64" fi +# Apply after PortMaster's helper: some firmware helpers reset cpufreq policy +# while preparing the runtime. The saved values are restored on exit. +apply_cpu_governors +trap restore_cpu_governors EXIT + +# PulseAudio runtime: PortMaster's helper points it at conf/pulse on the vfat +# SD card, where the runtime symlink fails ("Operation not permitted" -- vfat +# has no symlinks). Force the runtime onto tmpfs (real dirs, no symlinks). +export PULSE_RUNTIME_PATH="/tmp/pulse-$PPID" +mkdir -p "$PULSE_RUNTIME_PATH" 2>/dev/null || true + +echo "Launching ./bin/love.aarch64 $GAMEDIR/lovegame..." +echo "LD_LIBRARY_PATH: $LD_LIBRARY_PATH" + +# 5. Launch ./bin/love.aarch64 "$GAMEDIR/lovegame" +EXIT_CODE=$? + +echo "Game exited with code: $EXIT_CODE" + +# 6. Cleanup +restore_cpu_governors if type pm_finish >/dev/null 2>&1; then pm_finish -else - if [ -n "${ESUDO:-}" ]; then - $ESUDO kill -9 $(pidof gptokeyb) 2>/dev/null || true - else - kill -9 $(pidof gptokeyb) 2>/dev/null || true - fi fi + +exit $EXIT_CODE EOF chmod +x "$PORT_ROOT/$LAUNCHER_NAME" diff --git a/build-rg34xxsp.sh b/build-rg34xxsp.sh index 85c2bcfb..06b5ede7 100755 --- a/build-rg34xxsp.sh +++ b/build-rg34xxsp.sh @@ -156,6 +156,20 @@ chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64" cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \ "$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/" +BRIDGE_LIB="liblibrashader_bridge.so" +BRIDGE_SRC="${SHADERFX_BRIDGE_LINUX_ARM64:-}" +if [ -z "$BRIDGE_SRC" ] && [ -f "$ROOT/dist/native/linux-arm64/$BRIDGE_LIB" ]; then + BRIDGE_SRC="$ROOT/dist/native/linux-arm64/$BRIDGE_LIB" +fi +if [ -n "$BRIDGE_SRC" ] && [ -f "$BRIDGE_SRC" ]; then + cp "$BRIDGE_SRC" "$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/$BRIDGE_LIB" + say "bundled $BRIDGE_LIB for SHADER FX preset conversion" +elif [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then + fail "$BRIDGE_LIB not found: set SHADERFX_BRIDGE_LINUX_ARM64 or stage it at dist/native/linux-arm64/$BRIDGE_LIB" +else + warn "$BRIDGE_LIB not found: this port can run converted presets but not CONVERT new ones" +fi + # Drop a short license pointer for the bundled LÖVE bits. cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF' This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster diff --git a/conf.lua b/conf.lua index 974a9c86..ec39916c 100644 --- a/conf.lua +++ b/conf.lua @@ -62,7 +62,16 @@ function love.conf(t) t.window.minheight = 360 end t.version = love._os == "iOS" and "12.0" or "11.5" - t.window.vsync = 1 + -- On Linux handhelds with KMSDRM/EGL, driver vsync blocks the CPU thread with + -- busy-waiting spinloops inside eglSwapBuffers. Disabling driver vsync allows + -- love.run to perform kernel nanosleep(), dropping idle CPU from 25% to 4%. + if os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1" + or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1" + or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1" then + t.window.vsync = 0 + else + t.window.vsync = 1 + end t.modules.audio = not companion t.modules.joystick = not companion t.modules.physics = false diff --git a/data/gb_palettes.lua b/data/gb_palettes.lua new file mode 100644 index 00000000..9534ba5e --- /dev/null +++ b/data/gb_palettes.lua @@ -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" }, + } }, +} diff --git a/docs/linux-appimage.md b/docs/linux-appimage.md new file mode 100644 index 00000000..2a6797dc --- /dev/null +++ b/docs/linux-appimage.md @@ -0,0 +1,58 @@ +# Linux AppImage packaging + +Releases ship raw AppImages (no zip wrapper): + +- `gen1recomp--linux-x86_64.AppImage` +- `gen1recomp--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_

Pokémon Gen 1/2 recompilation + CC0-1.0 + LicenseRef-proprietary + The Bois Club + +

+ 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. +

+
+ com.theboisclub.gen1recomp.desktop + https://github.com/bryanthaboi/gen1recomp + + + Launcher + https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png + + + + + + + diff --git a/flatpak/com.theboisclub.gen1recomp.yml b/flatpak/com.theboisclub.gen1recomp.yml new file mode 100644 index 00000000..11aabef2 --- /dev/null +++ b/flatpak/com.theboisclub.gen1recomp.yml @@ -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 diff --git a/main.lua b/main.lua index eda842d2..65cf1083 100644 --- a/main.lua +++ b/main.lua @@ -22,6 +22,56 @@ local PlatformHooks = require("src.core.PlatformHooks") local HostDisplay = require("src.core.HostDisplay") local GameViewport = require("src.render.GameViewport") +-- Global emergency quit: holding Start + Select for 5 seconds forcefully terminates LOVE. +local emergencyQuitTimer = 0 + +local function checkEmergencyQuit(dt) + local held = false + if love.joystick and love.joystick.getJoysticks then + local joysticks = love.joystick.getJoysticks() + for _, j in ipairs(joysticks) do + if j:isGamepad() then + local start = j:isGamepadDown("start") + local selectBtn = j:isGamepadDown("back") or j:isGamepadDown("guide") + if start and selectBtn then + held = true + break + end + else + local bCount = j:getButtonCount() + local s1 = (bCount >= 7 and j:isDown(7)) or (bCount >= 9 and j:isDown(9)) + local s2 = (bCount >= 8 and j:isDown(8)) or (bCount >= 10 and j:isDown(10)) + if s1 and s2 then + held = true + break + end + end + end + end + + if love.keyboard and love.keyboard.isDown then + if (love.keyboard.isDown("escape") and love.keyboard.isDown("return")) + or (love.keyboard.isDown("lalt") and love.keyboard.isDown("f4")) then + held = true + end + end + + if held then + emergencyQuitTimer = emergencyQuitTimer + (dt or 0.016) + if emergencyQuitTimer >= 5.0 then + print("[FORCE QUIT] Start + Select held for 5 seconds. Exiting forcefully.") + pcall(function() + if love.audio and love.audio.stop then love.audio.stop() end + if love.window and love.window.close then love.window.close() end + end) + local exitFn = os["exit"] + exitFn(0) + end + else + emergencyQuitTimer = 0 + end +end + -- Lua errors: persist a redacted trace in the save dir and surface a hint. do local defaultErrorHandler = love.errorhandler or love.errhand @@ -30,10 +80,33 @@ do if ok and hint and type(msg) == "string" then msg = msg .. "\n\n" .. hint end + + if love.window and love.window.isOpen and love.window.isOpen() and love.graphics and love.graphics.isActive() then + local fullMsg = tostring(msg) .. "\n\n" .. tostring(debug.traceback()) .. "\n\n[Hold START + SELECT for 5s to Force Quit]" + return function() + love.event.pump() + for e, a in love.event.poll() do + if e == "quit" or (e == "keypressed" and a == "escape") then + return 1 + elseif e == "gamepadpressed" and (a == "start" or a == "back") then + return 1 + end + end + checkEmergencyQuit(0.016) + love.graphics.origin() + love.graphics.clear(0.10, 0.10, 0.12) + love.graphics.setColor(1, 0.4, 0.4, 1) + love.graphics.printf(fullMsg, 20, 20, love.graphics.getWidth() - 40) + love.graphics.present() + love.timer.sleep(0.016) + end + end + if defaultErrorHandler then return defaultErrorHandler(msg) end end + love.errhand = love.errorhandler end local Game, EditorApp, Importer, TouchEditor, Studio @@ -559,6 +632,7 @@ function love.load(args) end function love.update(dt) + checkEmergencyQuit(dt) HostDisplay.update(dt) SwitchDiagnostics.maybeFlush(false) -- NX only (no-op elsewhere): follow dock/undock without waiting for SDL. @@ -1229,6 +1303,8 @@ function love.run() if love.timer then dt = love.timer.step() end idleFor = idleFor + dt + checkEmergencyQuit(dt) + -- call update and draw if love.update then love.update(dt) end diff --git a/scripts/build.sh b/scripts/build.sh index b527a594..b9b9caf0 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -11,7 +11,7 @@ # # Output: dist/mac/gen1recomp-macos.zip # dist/win/gen1recomp-win64.zip -# dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage) +# dist/linux/gen1recomp-linux-x86_64.AppImage (fused x86_64 AppImage) # dist/android/debug/*.apk (full gradle output stays under # mobile/android/app/build/outputs/apk/embedNoRecord/) # dist/ios/-/gen1recomp++.app (full xcodebuild output stays @@ -170,27 +170,53 @@ make_ico() { # $1 = output .ico path # --------------------------------------------------------------- macOS # ShaderFX's librashader bridge. Only the CONVERT action needs it, so a build # without it still runs presets that were converted elsewhere. -# SHADERFX_BRIDGE points at a prebuilt library; otherwise cargo builds it. +# SHADERFX_BRIDGE_ or dist/native// points at a prebuilt library; +# otherwise cargo builds it, host platform only. +shader_bridge_host_plat() { + case "$(uname -s)-$(uname -m)" in + Darwin-*) printf 'mac' ;; + Linux-x86_64) printf 'linux-x64' ;; + Linux-aarch64|Linux-arm64) printf 'linux-arm64' ;; + *) printf '' ;; + esac +} + bundle_shader_bridge() { - local dest="$1" name="$2" - local src="${SHADERFX_BRIDGE:-}" + local dest="$1" name="$2" plat="$3" local crate="$ROOT/tools/shaderfx-bridge" - if [ -z "$src" ] && [ -f "$crate/target/release/$name" ]; then - src="$crate/target/release/$name" + local src="" var + [ -n "$plat" ] || fail "bundle_shader_bridge: no platform key for $name" + + var="SHADERFX_BRIDGE_$(printf '%s' "$plat" | tr 'a-z-' 'A-Z_')" + eval "src=\${$var:-}" + + if [ -z "$src" ] && [ -f "$DIST/native/$plat/$name" ]; then + src="$DIST/native/$plat/$name" fi - if [ -z "$src" ] && command -v cargo >/dev/null 2>&1; then - say "building the ShaderFX bridge with cargo" - if (cd "$crate" && cargo build --release >/dev/null 2>&1); then + if [ -z "$src" ] && [ -n "${SHADERFX_BRIDGE:-}" ]; then + src="$SHADERFX_BRIDGE" + fi + if [ -z "$src" ] && [ "$plat" = "$(shader_bridge_host_plat)" ]; then + if [ -f "$crate/target/release/$name" ]; then src="$crate/target/release/$name" + elif command -v cargo >/dev/null 2>&1; then + say "building the ShaderFX bridge with cargo" + if (cd "$crate" && cargo build --release >/dev/null 2>&1); then + src="$crate/target/release/$name" + fi fi fi + if [ -n "$src" ] && [ -f "$src" ]; then mkdir -p "$dest" cp "$src" "$dest/$name" - say "bundled $name for SHADER FX preset conversion" - else - warn "$name not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE or install cargo)" + say "bundled $name for SHADER FX preset conversion ($plat)" + return 0 fi + if [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then + fail "$name ($plat) not found: set $var or stage it at dist/native/$plat/$name" + fi + warn "$name not found: this build can run converted presets but not CONVERT new ones (set $var or install cargo)" } build_mac() { @@ -205,7 +231,7 @@ build_mac() { # drop any bundled placeholder .love and fuse ours in find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love" - bundle_shader_bridge "$out_app/Contents/MacOS" "liblibrashader_bridge.dylib" + bundle_shader_bridge "$out_app/Contents/MacOS" "liblibrashader_bridge.dylib" mac local plist="$out_app/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$plist" 2>/dev/null \ @@ -322,7 +348,7 @@ build_win() { warn "gen1tls.dll not found: Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)" fi - bundle_shader_bridge "$out_dir" "librashader_bridge.dll" + bundle_shader_bridge "$out_dir" "librashader_bridge.dll" win-x64 # The exe's icon lives in love.exe's PE resources, so it must be patched # BEFORE the .love is appended: peresed rewrites the whole file and would @@ -419,7 +445,7 @@ build_linux() { unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null cp "$LOVE_FILE" "$appdir/game.love" - bundle_shader_bridge "$appdir" "liblibrashader_bridge.so" + bundle_shader_bridge "$appdir" "liblibrashader_bridge.so" linux-x64 # Replace LÖVE's own desktop entry rather than keeping it: it says # Name=LÖVE / Icon=love, which is what appimaged, app menus and file @@ -457,15 +483,31 @@ EOF # file-manager thumbnailers show for the file itself. [ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC" rm -f "$appdir/love.svg" "$appdir/love.png" "$appdir/.DirIcon" - sips -z 512 512 "$ICON_SRC" --out "$appdir/$APP_NAME.png" >/dev/null + if command -v sips >/dev/null 2>&1; then + sips -z 512 512 "$ICON_SRC" --out "$appdir/$APP_NAME.png" >/dev/null + elif command -v convert >/dev/null 2>&1; then + convert "$ICON_SRC" -resize 512x512 "$appdir/$APP_NAME.png" + else + fail "need sips (macOS) or ImageMagick convert to resize $ICON_SRC" + fi cp "$appdir/$APP_NAME.png" "$appdir/.DirIcon" - sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun" + # sed -i '' is BSD; GNU sed wants sed -i (no empty backup suffix). + if sed --version >/dev/null 2>&1; then + sed -i 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun" + else + sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun" + fi grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \ || fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)" - sed -i '' 's|^exec "\$APPDIR/bin/love"|if [ -n "$WAYLAND_DISPLAY" ] \&\& [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\ -exec "$APPDIR/bin/love"|' "$appdir/AppRun" + local wayland_hook='if [ -n "$WAYLAND_DISPLAY" ] && [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\ +exec "$APPDIR/bin/love"' + if sed --version >/dev/null 2>&1; then + sed -i "s|^exec \"\$APPDIR/bin/love\"|$wayland_hook|" "$appdir/AppRun" + else + sed -i '' "s|^exec \"\$APPDIR/bin/love\"|$wayland_hook|" "$appdir/AppRun" + fi # Match the upstream image's compression (gzip, 128K blocks) so the # bundled runtime can read it. @@ -474,16 +516,20 @@ exec "$APPDIR/bin/love"|' "$appdir/AppRun" mksquashfs "$appdir" "$sfs_out" \ -comp gzip -b 131072 -noappend -all-root -no-xattrs -quiet >/dev/null - local out_bin="$WORK/$APP_NAME-x86_64.AppImage" - rm -f "$out_bin" + mkdir -p "$DIST/linux" + local out_bin="$DIST/linux/$APP_NAME-linux-x86_64.AppImage" + rm -f "$out_bin" "$out_bin.sha256" head -c "$sfs_offset" "$love_appimage" > "$out_bin" cat "$sfs_out" >> "$out_bin" chmod +x "$out_bin" - - local zip_out="$DIST/linux/$APP_NAME-linux.zip" - rm -f "$zip_out" - (cd "$WORK" && zip -q -9 -j "$zip_out" "$(basename "$out_bin")") - say "Linux build: $zip_out" + if command -v sha256sum >/dev/null 2>&1; then + printf '%s %s\n' "$(sha256sum "$out_bin" | awk '{print $1}')" "$(basename "$out_bin")" \ + > "$out_bin.sha256" + else + printf '%s %s\n' "$(shasum -a 256 "$out_bin" | awk '{print $1}')" "$(basename "$out_bin")" \ + > "$out_bin.sha256" + fi + say "Linux build: $out_bin" } # --------------------------------------------------------------- Android diff --git a/scripts/build_flatpak.sh b/scripts/build_flatpak.sh new file mode 100755 index 00000000..630cb077 --- /dev/null +++ b/scripts/build_flatpak.sh @@ -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--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' ' +text = re.sub( + r".*?", + "\n" + release + "\n ", + 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")" diff --git a/scripts/build_linux_arm64.sh b/scripts/build_linux_arm64.sh index 4df2ff00..57a3cda4 100755 --- a/scripts/build_linux_arm64.sh +++ b/scripts/build_linux_arm64.sh @@ -100,6 +100,21 @@ then fi cp "$GAME_LOVE" "$IN_DIR/game.love" +BRIDGE_LIB="liblibrashader_bridge.so" +BRIDGE_SRC="${SHADERFX_BRIDGE_LINUX_ARM64:-}" +if [ -z "$BRIDGE_SRC" ] && [ -f "$ROOT/dist/native/linux-arm64/$BRIDGE_LIB" ]; then + BRIDGE_SRC="$ROOT/dist/native/linux-arm64/$BRIDGE_LIB" +fi +rm -f "$IN_DIR/$BRIDGE_LIB" +if [ -n "$BRIDGE_SRC" ] && [ -f "$BRIDGE_SRC" ]; then + cp "$BRIDGE_SRC" "$IN_DIR/$BRIDGE_LIB" + say "staged $BRIDGE_LIB for SHADER FX preset conversion" +elif [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then + fail "$BRIDGE_LIB not found: set SHADERFX_BRIDGE_LINUX_ARM64 or stage it at dist/native/linux-arm64/$BRIDGE_LIB" +else + warn "$BRIDGE_LIB not found: this build can run converted presets but not CONVERT new ones" +fi + # --------------------------------------------------------------- downloads # Fetched on the host and checksum-pinned here so the container never needs # network access and every input is verified in exactly one place. diff --git a/scripts/linux-arm64/build_appimage.sh b/scripts/linux-arm64/build_appimage.sh index 1c6af56d..385cffea 100755 --- a/scripts/linux-arm64/build_appimage.sh +++ b/scripts/linux-arm64/build_appimage.sh @@ -327,6 +327,14 @@ bundle_needed "$APPDIR/lib/liblove-$LOVE_VERSION.so" say "bundled $(ls "$APPDIR/lib" | wc -l) libraries: $(ls "$APPDIR/lib" | tr '\n' ' ')" # ------------------------------------------------- host dependency contract +SHADER_BRIDGE=() +if [ -f "$IN/liblibrashader_bridge.so" ]; then + cp "$IN/liblibrashader_bridge.so" "$APPDIR/liblibrashader_bridge.so" + chmod 0755 "$APPDIR/liblibrashader_bridge.so" + SHADER_BRIDGE=("$APPDIR/liblibrashader_bridge.so") + say "bundled liblibrashader_bridge.so for SHADER FX preset conversion" +fi + # The portability promise, stated as an assertion instead of a paragraph in a # README: these are the ONLY sonames the shipped objects may require from the # host. Everything driver-, session- or audio-related has to be reached @@ -339,7 +347,7 @@ say "bundled $(ls "$APPDIR/lib" | wc -l) libraries: $(ls "$APPDIR/lib" | tr '\n' HOST_ALLOWED_RE='^(ld-linux-aarch64\.so\.1|libc\.so\.6|libm\.so\.6|libdl\.so\.2|libpthread\.so\.0|librt\.so\.1|libstdc\+\+\.so\.6|libgcc_s\.so\.1|libatomic\.so\.1|libfreetype\.so\.6|libpng[0-9]*\.so\.[0-9]+|libz\.so\.1|libbrotli(dec|common)\.so\.1)$' unexpected="" -for object in "$APPDIR/bin/love" "$APPDIR"/lib/*.so*; do +for object in "$APPDIR/bin/love" "$APPDIR"/lib/*.so* ${SHADER_BRIDGE[@]+"${SHADER_BRIDGE[@]}"}; do while read -r soname; do [ -n "$soname" ] || continue # Satisfied from inside the AppDir, so not a host requirement at all. diff --git a/scripts/linux-arm64/verify_appimage.sh b/scripts/linux-arm64/verify_appimage.sh index b437ebc5..4ff60225 100755 --- a/scripts/linux-arm64/verify_appimage.sh +++ b/scripts/linux-arm64/verify_appimage.sh @@ -20,6 +20,23 @@ for required in AppRun bin/love game.love lib/liblove-11.5.so; do || { echo "::error::AppImage is missing $required"; exit 1; } done +bridge="squashfs-root/liblibrashader_bridge.so" +if [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ] && [ ! -e "$bridge" ]; then + echo "::error::AppImage is missing liblibrashader_bridge.so" + exit 1 +fi + +# Discovered, not enumerated: bin/love and lib/*.so* miss anything native at +# the AppDir root, which is where ShaderFX's bridge sits and where the next +# root-level library would land too. +scan=() +while IFS= read -r f; do + scan+=("$f") +done < <(find squashfs-root -type f -exec sh -c \ + 'head -c4 "$1" | od -An -tx1 | tr -d " \n" | grep -q "^7f454c46$"' _ {} \; -print | sort) +[ "${#scan[@]}" -gt 0 ] || { echo "::error::found no ELF objects in the AppDir"; exit 1; } +echo "scanning ${#scan[@]} ELF objects: ${scan[*]#squashfs-root/}" + # Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is # applied; an unresolved soname here is a user-visible launch crash. # @@ -28,14 +45,14 @@ done # hard-links libpulse/libasound/libX11/libwayland, so it only ever # started on a full desktop -- a bare runner is what exposed it. missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \ - ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \ + ldd "${scan[@]}" 2>/dev/null \ | grep 'not found' || true)" [ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; } # Nothing may hard-link a driver, session or audio-stack library: # those must be reached through dlopen so the AppImage runs on a box # with only ALSA, only Wayland, or only KMSDRM. -linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do +linked="$(for f in "${scan[@]}"; do objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}' done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)" [ -z "$linked" ] \ @@ -45,7 +62,7 @@ done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo # the builder to a newer base, the glibc floor silently rises and # every user on an older distro gets "GLIBC_2.xx not found" -- catch # it here instead of in a release. -floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \ +floor="$(objdump -T "${scan[@]}" 2>/dev/null \ | grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)" echo "highest required glibc symbol version: $floor" [ -n "$floor" ] \ @@ -54,4 +71,12 @@ highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)" [ "$highest" = "GLIBC_2.31" ] \ || { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; } +# The floor grep proves symbol versions; an actual dlopen additionally proves +# relocations and constructors. Native arch only, so it is skipped elsewhere. +if [ -e "$bridge" ] && [ "$(uname -m)" = "aarch64" ] && command -v python3 >/dev/null 2>&1; then + python3 -c "import ctypes,sys; ctypes.CDLL(sys.argv[1])" "$PWD/$bridge" \ + || { echo "::error::liblibrashader_bridge.so failed to dlopen"; exit 1; } + echo "librashader bridge dlopens cleanly" +fi + echo "AppImage verified: $image" diff --git a/scripts/test.sh b/scripts/test.sh index 966b6e3e..d7fe201e 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -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 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua +run_tier "T0 ShaderFX bridge packaging gate" "$LUA" tests/shaderfx_bridge_packaging_test.lua # NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a # Sound.lua / overlay regression is not gated only on switch-changes paths. run_tier "T0 NX asset overlay fallback" "$LUA" tests/engine/assets_version_fallback_test.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 2889573c..c6b41847 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -34,6 +34,12 @@ local RomText = require("src.core.RomText") local Strings = require("src.core.Strings") local WideBattle = require("src.battle.WideBattle") +local Chrome2 +do + local ok, v = pcall(require, "src.ui.gen2.Chrome") + Chrome2 = ok and v or nil +end + local romText = RomText local BattleState = {} @@ -461,7 +467,7 @@ function BattleState:picImage(img) -- palettes (CYANMON reds 0.678/0.451) already rendered correctly. This mode -- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync. local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" - or PaletteFX.mode == "classic" + or PaletteFX.mode == "classic" or PaletteFX.forcesRawGrays() if self.grayPics or mono then return grayImage(img) end -- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go -- dark with the HP bars while the blackout text is up (#292). The intro @@ -5989,7 +5995,7 @@ function BattleState:drawZonePass(src, sx, sy) -- only the other two showed it. Keep this mode set in sync with picImage / -- PaletteFX.ensureZones / WideBattle.monoMode. local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" - or PaletteFX.mode == "classic" + or PaletteFX.mode == "classic" or PaletteFX.forcesRawGrays() love.graphics.setColor(1, 1, 1, 1) love.graphics.setShader(shader) local shaking = sx ~= 0 or sy ~= 0 @@ -6399,8 +6405,35 @@ end function BattleState:drawTextArea() if not self:bottomUIVisible() then return end - Font.drawBox(0, 12, 20, 6) - love.graphics.setColor(0, 0, 0, 1) + -- Gold only: routes the command box and its labels through GbcPalette, + -- the same fix TextBox.lua got for dialogue. moveSelect/mimicSelect below + -- aren't migrated yet since they need their own interior-patch handling + -- (see #240). + local gold = self.game and self.game.save + and (self.game.save.generation == 2 or self.game.save.version == "gold") + local Chrome = gold and Chrome2 or nil + local function box(tx, ty, tw, th) + if Chrome then + Chrome.paletteBox(tx, ty, tw, th) + else + Font.drawBox(tx, ty, tw, th) + love.graphics.setColor(0, 0, 0, 1) + end + end + local drawGlyph, finishGlyph = Font.drawCode, nil + if Chrome then + local _, dg, fg = Chrome.paletteGlyphs(Chrome.DEFAULT_BOX_PALETTE) + drawGlyph, finishGlyph = dg, fg + end + local function text(str, x, y) + local pen = x + for _, code in ipairs(Font.encode(str)) do + drawGlyph(code, pen, y) + pen = pen + Font.advanceOf(code) + end + end + + box(0, 12, 20, 6) if self.phase == "messages" and (self.current or self.animPlaying or self.msgHold) then -- during the move animation self.current is nil but shown still holds @@ -6419,48 +6452,47 @@ function BattleState:drawTextArea() for li, line in ipairs(self.shown or {}) do local y = (ys[li] or 128) + off for i = 1, #line do - Font.drawCode(line[i], 8 + (i - 1) * 8, y) + drawGlyph(line[i], 8 + (i - 1) * 8, y) end end -- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait -- (_ContText) or a typed-out page (PromptText) holds the box; both write -- it at (18,16), bottom-right, like TextBox / home/text.asm (#317) if (self.msgWaiting or self.msgPrompt) and self.frame % 60 < 30 then - Font.drawCode(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4) + drawGlyph(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4) end elseif self.phase == "menu" and self.demo then -- the old-man script (DisplayBattleMenu, core.asm:2038-2049): the -- standard menu, with the '▶' hand drawn by the scripted keystrokes -- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16) - Font.drawBox(8, 12, 12, 6) - love.graphics.setColor(0, 0, 0, 1) - Font.draw(Strings("FIGHT", "battle"), 80, 112) - Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112) - Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128) - Font.drawCode(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128) + box(8, 12, 12, 6) + text(Strings("FIGHT", "battle"), 80, 112) + drawGlyph(0xE1, 128, 112); drawGlyph(0xE2, 136, 112) + text(Strings("ITEM", "battle"), 80, 128); text(Strings("RUN", "battle"), 128, 128) + drawGlyph(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128) elseif self.phase == "menu" then local col = (self.menuIndex - 1) % 2 local row = math.floor((self.menuIndex - 1) / 2) if self.safari then -- SAFARI_BATTLE_MENU_TEMPLATE: full-width box, "BALLx BAIT / -- THROW ROCK RUN" from (2,14) - Font.drawBox(0, 12, 20, 6) - Font.draw(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112) - Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN", "battle"), 112, 128) + box(0, 12, 20, 6) + text(Strings("BALLx"), 16, 112); text(Strings("BAIT"), 112, 112) + text(Strings("THROW ROCK"), 16, 128); text(Strings("RUN", "battle"), 112, 128) -- DisplayBattleMenu .safariLeftColumn / .safariRightColumn print -- wNumSafariBalls at hlcoord 7,14 with `lb bc, 1, 2` -- one byte, two -- digits, space padded -- right after the "BALLx" label at columns -- 2..6 (engine/battle/core.asm:2074-2079, 2107-2112) (#540) - Font.draw(("%2d"):format(self.safari.balls), 56, 112) - Font.drawCode(0xED, (col == 0 and 8 or 104), 112 + row * 16) + text(("%2d"):format(self.safari.balls), 56, 112) + drawGlyph(0xED, (col == 0 and 8 or 104), 112 + row * 16) else -- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT / -- ITEM RUN" from (10,14); cursor columns 9 / 15 - Font.drawBox(8, 12, 12, 6) - Font.draw(Strings("FIGHT", "battle"), 80, 112) - Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112) - Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128) - Font.drawCode(0xED, (col == 0 and 72 or 120), 112 + row * 16) + box(8, 12, 12, 6) + text(Strings("FIGHT", "battle"), 80, 112) + drawGlyph(0xE1, 128, 112); drawGlyph(0xE2, 136, 112) + text(Strings("ITEM", "battle"), 80, 128); text(Strings("RUN", "battle"), 128, 128) + drawGlyph(0xED, (col == 0 and 72 or 120), 112 + row * 16) end elseif self.phase == "moveSelect" then -- pokered MoveSelectionMenu: move list in a box at (4,12) 16x6, @@ -6535,6 +6567,7 @@ function BattleState:drawTextArea() Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8) Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112) end + if finishGlyph then finishGlyph() end end function BattleState:draw() diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 32508356..b66045fb 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -26,14 +26,10 @@ local WideBattle = { FIELD_BOTTOM = 104, } --- The forced-mono display modes re-threshold the whole finished frame --- through the shade shader, and picImage hands them raw DMG grays for that --- (#207). The wide layout has to know: it exposes a matching whole-surface --- zone and leaves the HP bar fill gray, exactly like the zone pass does in --- the classic layout (#229). Keep in sync with picImage / ensureZones. local function monoMode() local m = PaletteFX.mode return m == "og" or m == "og_inv" or m == "classic" + or PaletteFX.forcesRawGrays() end local function shownHP(battler) diff --git a/src/core/Game.lua b/src/core/Game.lua index 72a13157..c505f456 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1258,6 +1258,7 @@ function Game:applyOptions(opts) if Sound.applyOptions then Sound.applyOptions(opts) end require("src.render.PaletteFX").applyOptions(opts) require("src.render.Tilt").applyOptions(opts) + require("src.render.Letterbox").applyOptions(opts) -- after Tilt, so a persisted world pipeline can switch the tilt level it -- just restored back off (the two are mutually exclusive) require("src.render.Pipelines").applyOptions(opts) diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 44390f1a..dbc6a0fc 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -1787,6 +1787,7 @@ function Game2:hotkey(key) local GbcPalette = require("src.render.GbcPalette") GbcPalette.setMode(options.color or "gbc") options.color = GbcPalette.cycle(1) + options.palette = "" persist() return true elseif key == "3" then @@ -2040,6 +2041,7 @@ function Game2:applyOptions() Zoom.allowSurvey = caps.survey if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end require("src.render.Tilt").applyOptions(options) + require("src.render.Letterbox").applyOptions(options) require("src.render.GbcPalette").applyOptions(options) -- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame. Font.setFrame(options.frame or 1) diff --git a/src/core/GamepadMap.lua b/src/core/GamepadMap.lua index 87e90e31..fe1fe88c 100644 --- a/src/core/GamepadMap.lua +++ b/src/core/GamepadMap.lua @@ -57,7 +57,10 @@ function GamepadMap._setForceNXForTests(v) end local function nxActive() - if GamepadMap._forceNXForTests then return true end + if GamepadMap._forceNXForTests == true then return true end + if GamepadMap._forceNXForTests == false and love and (love._os ~= "NX" and (not love.system or love.system.getOS() ~= "NX")) then + return false + end if love and love._os == "NX" then return true end if love and love.system and love.system.getOS() == "NX" then return true end return false @@ -78,6 +81,20 @@ function GamepadMap.mapGamepadButton(button) return GamepadMap.gamepadBindings()[button] end +function GamepadMap.mapLauncherButton(button) + if nxActive() then + if button == "a" then return "b" + elseif button == "b" then return "a" + elseif button == "x" then return "y" + elseif button == "y" then return "x" + elseif button == "back" then return "select" + end + elseif button == "back" then + return "select" + end + return button +end + -- Select+face display chords (docs / Nintendo UX): -- Select+A → "2" (COLORS), Select+B → "3" (TILT), -- Select+Y → "5", Select+X → "6", Select+L (leftshoulder) → "7". diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 43d40bc5..be287beb 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -4,18 +4,79 @@ local HostShell = {} -- Our AppRun exports LD_LIBRARY_PATH="$APPDIR/lib:..." so every subprocess we -- spawn tries to link against the libraries we're shipping instead of the --- system ones. We want to unset the var so that any system tools can find --- their proper libraries. Only needed when running in an AppImage. --- LD_PRELOAD is Steam's overlay (#1470): its 32-bit half cannot load into a --- 64-bit child, so ld.so prints an error into that child's output. +-- system ones. Host tools (curl, zenity) need that unset. A *bundled* AppDir +-- curl must keep APPDIR on LD_LIBRARY_PATH so it resolves the bundled +-- libssl/libcrypto -- scrubbing it forces host OpenSSL and segfaults across +-- distros. LD_PRELOAD is always scrubbed: Steam's overlay (#1470) cannot load +-- into a 64-bit child. function HostShell.envPrefix() + return HostShell.curlEnvPrefix("host") +end + +-- kind "bundled" = AppDir / Flatpak curl (keep LD_LIBRARY_PATH, scrub preload). +-- kind "host" = system curl (scrub both). +function HostShell.curlEnvPrefix(kind) local unset = "" - if os.getenv("APPIMAGE") then unset = unset .. "-u LD_LIBRARY_PATH " end + if kind ~= "bundled" and os.getenv("APPIMAGE") then + unset = unset .. "-u LD_LIBRARY_PATH " + end if os.getenv("LD_PRELOAD") then unset = unset .. "-u LD_PRELOAD " end if unset == "" then return "" end return "env " .. unset end +local curlResolved = nil -- { path=, kind= } once per Lua state + +local function pathIsExecutable(path) + if type(path) ~= "string" or path == "" then return false end + local f = io.open(path, "rb") + if not f then return false end + f:close() + -- Best-effort: existence is enough; exec bit is checked by the spawn. + return true +end + +-- Prefer Flatpak /app/bin/curl, then $APPDIR/{usr/,}bin/curl, else host "curl". +function HostShell.resolveCurl() + if curlResolved ~= nil then return curlResolved.path, curlResolved.kind end + local candidates = {} + if os.getenv("FLATPAK_ID") then + candidates[#candidates + 1] = { "/app/bin/curl", "bundled" } + end + local appdir = os.getenv("APPDIR") + if type(appdir) == "string" and appdir ~= "" then + candidates[#candidates + 1] = { appdir .. "/usr/bin/curl", "bundled" } + candidates[#candidates + 1] = { appdir .. "/bin/curl", "bundled" } + end + for _, c in ipairs(candidates) do + if pathIsExecutable(c[1]) then + curlResolved = { path = c[1], kind = c[2] } + return curlResolved.path, curlResolved.kind + end + end + curlResolved = { path = "curl", kind = "host" } + return curlResolved.path, curlResolved.kind +end + +-- Quoted curl argv0 for a shell command, plus the matching env prefix. +function HostShell.curlInvocation() + local path, kind = HostShell.resolveCurl() + return HostShell.curlEnvPrefix(kind) .. HostShell.quote(path), kind +end + +-- Diagnostics for support / About screens. +function HostShell.curlDiagnostics() + local path, kind = HostShell.resolveCurl() + return { + path = path, + kind = kind, + appimage = os.getenv("APPIMAGE") ~= nil, + appdir = os.getenv("APPDIR"), + flatpakId = os.getenv("FLATPAK_ID"), + haveCurl = HostShell.haveCurl(), + } +end + -- Windows: every host tool we shell out to (curl for the update and mod-index -- fetches, the PowerShell ROM picker, the update downloader's `start /b`) is -- spawned through io.popen / os.execute, which run it under cmd.exe. A @@ -126,12 +187,18 @@ local function withPopenLock(fn) if not okAtomic then fn() end end --- Wraps io.popen with the AppImage env fix applied and lua errors swallowed -function HostShell.popen(command, mode) +-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed. +-- opts.envPrefix overrides the default HostShell.envPrefix() (pass "" to skip, +-- or curlEnvPrefix(kind) when spawning a resolved curl binary). +function HostShell.popen(command, mode, opts) HostShell.releasePointerGrab() + local prefix = HostShell.envPrefix() + if type(opts) == "table" and opts.envPrefix ~= nil then + prefix = opts.envPrefix + end local pipe 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 end) return pipe @@ -193,6 +260,12 @@ function HostShell.restart() local appimage = os.getenv("APPIMAGE") if not appimage then + -- Flatpak and plain desktop: love.event.quit("restart") uses + -- execv(/proc/self/exe). Inside bwrap that path is valid, but PhysFS + -- locks on a mounted .love payload can survive the exec boundary and + -- hang the next Boot.mount. Callers that just wrote updates/*.love + -- should already have closed archive handles; Boot.run still owns + -- crash-guard rollback if a mid-handoff restart dies. love.event.quit("restart") return end @@ -341,7 +414,10 @@ local curlAvailable = nil function HostShell.haveCurl() if curlAvailable ~= nil then return curlAvailable end - local pipe = HostShell.popen("curl --version") + local path, kind = HostShell.resolveCurl() + local pipe = HostShell.popen( + HostShell.quote(path) .. " --version", "r", + { envPrefix = HostShell.curlEnvPrefix(kind) }) if not pipe then curlAvailable = false return false end local readOk, out = pcall(function() return pipe:read("*a") end) HostShell.pclose(pipe) @@ -349,6 +425,12 @@ function HostShell.haveCurl() return curlAvailable end +local function curlCmd(flags) + local path, kind = HostShell.resolveCurl() + return HostShell.quote(path) .. " " .. flags, + { envPrefix = HostShell.curlEnvPrefix(kind) } +end + -- An older mobile build reports nil here and falls back to the "no transport" -- error the callers already show. local function haveBridge() @@ -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 userAgent = userAgent or "gen1recomp" if HostShell.haveCurl() then - local cmd = ("curl -fsSL --proto =http,https --proto-redir =http,https " - .. "--connect-timeout 15 --max-time %d ") - :format(tonumber(maxTime) or 300) + local head, popts = curlCmd( + ("-fsSL --proto =http,https --proto-redir =http,https " + .. "--connect-timeout 15 --max-time %d ") + :format(tonumber(maxTime) or 300)) + local cmd = head .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " " if accept then cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " " @@ -423,7 +507,7 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime, etagPa cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " " .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " .. HostShell.quote(url) .. " 2>&1" - local pipe = HostShell.popen(cmd) + local pipe = HostShell.popen(cmd, "r", popts) if not pipe then return nil, "could not start download" end local readOk, out = pcall(function() return pipe:read("*a") end) HostShell.pclose(pipe) @@ -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 -- diagnosis: GitHub's 403 says "API rate limit exceeded for ", which -- tells a user to wait rather than to go hunting for a broken index. - local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https " - .. "--connect-timeout 10 --max-time %d ") - :format(tonumber(maxTime) or 40) + local head, popts = curlCmd( + ("-sSL --proto =http,https --proto-redir =http,https " + .. "--connect-timeout 10 --max-time %d ") + :format(tonumber(maxTime) or 40)) + local cmd = head .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " " if accept then cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " " end cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " .. HostShell.quote(url) .. " 2>&1" - local pipe = HostShell.popen(cmd) + local pipe = HostShell.popen(cmd, "r", popts) if not pipe then return nil, "could not run curl" end local readOk, out = pcall(function() return pipe:read("*a") end) HostShell.pclose(pipe) @@ -562,9 +648,11 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime) -- newlines. The body is staged above because io.popen cannot be opened -- for both writing and reading. No -f, matching httpGet: the response -- body is discarded anyway, and curl's stderr carries the diagnosis. - local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https " - .. "--connect-timeout 10 --max-time %d ") - :format(tonumber(maxTime) or 40) + local head, popts = curlCmd( + ("-sSL --proto =http,https --proto-redir =http,https " + .. "--connect-timeout 10 --max-time %d ") + :format(tonumber(maxTime) or 40)) + local cmd = head .. "-X POST " .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " " if contentType then @@ -574,7 +662,7 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime) .. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " " .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " .. HostShell.quote(url) .. " 2>&1" - local pipe = HostShell.popen(cmd) + local pipe = HostShell.popen(cmd, "r", popts) if not pipe then pcall(os.remove, bodyPath) return nil, "could not run curl" @@ -751,8 +839,10 @@ function HostShell.httpRequest(url, opts) pcall(os.remove, headerPath) end - local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https " - .. "--connect-timeout 10 --max-time %d "):format(maxTime) + local head, popts = curlCmd( + ("-sSL --proto =http,https --proto-redir =http,https " + .. "--connect-timeout 10 --max-time %d "):format(maxTime)) + local cmd = head .. "-X " .. HostShell.quote(method) .. " " .. "-H " .. HostShell.quote("@" .. headerPath) .. " " if body then @@ -761,7 +851,7 @@ function HostShell.httpRequest(url, opts) cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " .. HostShell.quote(url) .. " 2>&1" - local pipe = HostShell.popen(cmd) + local pipe = HostShell.popen(cmd, "r", popts) if not pipe then cleanup() return nil, "could not run curl" diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index dde0f760..bc198403 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -173,13 +173,40 @@ function SaveData.gameFolders() end -- The game folder carrying portable.txt, or false. First candidate holding --- the marker wins. +-- the marker *and* a writable probe wins. Flatpak installs always use the +-- sandbox XDG save dir (portable USB mode does not apply). A read-only +-- parent (system /opt, immutable OS, etc.) falls back to LOVE's save +-- directory without throwing -- probe I/O is fully guarded. +local function dirIsWritable(dir) + if type(dir) ~= "string" or dir == "" then return false end + local ok, writable = pcall(function() + local name = string.format(".write_probe_%d_%d.tmp", + os.time() % 100000000, math.random(0, 999999)) + local path = dir .. SEP .. name + local f, err = io.open(path, "wb") + if not f then return false end + local wrote = f:write("ok") + f:close() + pcall(os.remove, path) + return wrote ~= nil + end) + return ok and writable == true +end + local function detectPortable() if portableChecked then return portableBase end portableChecked = true portableBase = false + -- Flatpak: never honor portable.txt (sandbox home is the persistence root). + if type(os.getenv) == "function" and os.getenv("FLATPAK_ID") then + return portableBase + end for _, base in ipairs(SaveData.gameFolders()) do - if pathExists(base .. SEP .. PORTABLE_MARKER) then + local markerOk = false + pcall(function() + markerOk = pathExists(base .. SEP .. PORTABLE_MARKER) + end) + if markerOk and dirIsWritable(base) then portableBase = base break end @@ -187,6 +214,13 @@ local function detectPortable() return portableBase end +-- Soft-reset cache so tests can re-run detect after env changes. +function SaveData._resetPortableCacheForTests() + portableChecked = false + portableBase = false + portableFsCache = nil +end + function SaveData.isPortable() return detectPortable() ~= false end @@ -279,6 +313,7 @@ function SaveData.defaultOptions() zoom = 0, -- OVERWORLD beyond-edge fill: trees | water | black voidFill = "trees", + uiLetterbox = "auto", -- windowed | borderless (desktop fullscreen); ignored on mobile videoMode = "windowed", -- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index ad84293a..6bee7106 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -309,6 +309,7 @@ Save.DEFAULT_OPTIONS = { -- Game Boy. The Gen 1 save's equivalent key is `colors` (SGB packs), which -- means something different, hence the different name. color = "gbc", + palette = "", videoMode = "windowed", fpsCap = 60, -- BATTLE BG (#1709): white | black, the surround around the battle screen. @@ -316,6 +317,7 @@ Save.DEFAULT_OPTIONS = { -- VOID FILL: fade | water | trees | black. fade is each map header's own -- border block with the dissolve across a boundary (#1418). voidFill = "fade", + uiLetterbox = "auto", musicVol = 7, -- 0-7, like the GB's NR50 master volume sfxVol = 7, -- 0-7 musicFilter = 0, -- low-pass steps, 0 = off diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index ecba935c..071b60fa 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -605,6 +605,7 @@ local function gen2Rows(opts, hooks, shared) end opts.color = GbcPalette.MODES[wrapIndex(idx - 1 + dir, #GbcPalette.MODES) + 1] + opts.palette = "" return true end) end diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 1c54d58b..796868f3 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -377,12 +377,19 @@ end -- visible while typing, and blinks a caret on the importer's pulse clock. local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, action) Kit._audit("control", x, y, w, h, key) - Kit.focusable(key, x, y, w, h) + local isFocused = Kit.focusable(key, x, y, w, h) Theme.fill(x, y, w, h, PAL.bg, 1) - Theme.stroke(x, y, w, h, PAL.line, - focused and Theme.A.focus or - (Kit.hover(x, y, w, h) and Theme.A.hover or Theme.A.hairline), - focused and 2 or 1) + if isFocused then + local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5) + Theme.strokeRounded(x - 3, y - 3, w + 6, h + 6, PAL.ink, 0.35 + 0.25 * glowPulse, 2.5, 4) + Theme.strokeRounded(x, y, w, h, PAL.ink, 0.95 + 0.05 * glowPulse, 2, 2) + Theme.fillRounded(x, y, w, h, PAL.ink, 0.12 + 0.06 * glowPulse, 2) + else + Theme.stroke(x, y, w, h, PAL.line, + focused and Theme.A.focus or + (Kit.hover(x, y, w, h) and Theme.A.hover or Theme.A.hairline), + focused and 2 or 1) + end local pad = math.floor(10 * Kit.scale) local ty = y + (h - Kit.textHeight("button")) / 2 local text = rawText or "" @@ -397,8 +404,35 @@ local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, ac Kit.textHeight("button"), PAL.ink, 1) end end - if action and (Kit.press(x, y, w, h) or Kit._activateId == key) then - queueAction(imp, key, action) + if (Kit.press(x, y, w, h) or Kit._activateId == key) then + if Kit.VirtualKeyboard then + Kit.VirtualKeyboard.open({ + text = text, + targetId = key, + title = placeholder or "Enter Text", + onDone = function(newText, confirmed) + if confirmed then + if imp._indexPrompt and key:find("index") then + imp._indexPrompt.text = newText + elseif imp._rename and key:find("rename") then + imp._rename.text = newText + elseif imp._profileRenamePrompt and key:find("profren") then + imp._profileRenamePrompt.text = newText + elseif imp._profileSavePrompt and key:find("profsave") then + imp._profileSavePrompt.text = newText + elseif imp._settingsText and key:find("settext") then + imp._settingsText.text = newText + elseif imp.tab == "find" then + imp._findQuery = newText + if imp._refreshFind then imp:_refreshFind() end + end + end + end + }) + end + if action then + queueAction(imp, key, action) + end end end @@ -3406,13 +3440,19 @@ local function buildFooter(imp, m, y) local bx = m.x + math.floor((m.w - topW) / 2) local my = cy + math.floor((rowH - dh) / 2) local chipY = cy + math.floor((rowH - chipH) / 2) + local isFocused = Kit.focusable("bcg", bx, my, dw, dh) local hot = Kit.hover(bx, my, dw, dh) + if isFocused then + local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5) + Theme.strokeRounded(bx - 3, my - 3, dw + 6, dh + 6, PAL.ink, 0.35 + 0.25 * glowPulse, 2.5, 4) + Theme.strokeRounded(bx, my, dw, dh, PAL.ink, 0.95 + 0.05 * glowPulse, 2, 2) + end love.graphics.setShader(imp.invertShader) - love.graphics.setColor(1, 1, 1, hot and 1 or 0.85) + love.graphics.setColor(1, 1, 1, (hot or isFocused) and 1 or 0.85) love.graphics.draw(imp.bcg, Theme.snap(bx), Theme.snap(my), 0, scale, scale) love.graphics.setShader() love.graphics.setColor(1, 1, 1, 1) - if Kit.press(bx, my, dw, dh) then + if Kit.press(bx, my, dw, dh) or Kit._activateId == "bcg" then queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end) end local cx = bx + dw @@ -5604,6 +5644,8 @@ end -- a click on the scrim lands on whatever button happens to be behind it. -- Keep this list in sync with buildModals below. local function modalUp(imp) + if Kit.FileBrowser and Kit.FileBrowser.active then return true end + if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then return true end return (imp._settingsText or imp._settings or imp._rename or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes or imp._appPatchNotes @@ -5616,6 +5658,14 @@ local function modalUp(imp) end local function buildModals(imp, m) + if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then + Kit.VirtualKeyboard.draw(m) + return true + end + if Kit.FileBrowser and Kit.FileBrowser.active then + Kit.FileBrowser.draw(m) + return true + end if imp._profileRenamePrompt then buildPrompt(imp, m, { key = "profren", title = Strings("Rename profile"), @@ -5787,6 +5837,10 @@ end local function drawPadCursor(imp) if not imp._padCursorActive then return end + if (Kit.FileBrowser and Kit.FileBrowser.active) + or (Kit.VirtualKeyboard and Kit.VirtualKeyboard.active) then + return + end -- Pixel-snap on NX: subpixel polygon edges shimmer on the 720p Switch -- framebuffer when the stick advances by fractional pixels each frame. local x, y = imp._padCursor.x, imp._padCursor.y @@ -5970,6 +6024,25 @@ function LauncherView.draw(imp) Kit.endFrame() drawPadCursor(imp) + + if imp._cursorModeToast and imp._cursorModeToastTime then + local elapsed = love.timer.getTime() - imp._cursorModeToastTime + if elapsed < 2.2 then + local alpha = 1.0 + if elapsed > 1.7 then alpha = math.max(0, (2.2 - elapsed) / 0.5) end + local msg = imp._cursorModeToast + local tw = Kit.textWidth("small", msg) + 36 * m.s + local th = 34 * m.s + local tx = (m.W - tw) / 2 + local ty = 16 * m.s + Theme.fillRounded(tx, ty, tw, th, PAL.surface, 0.95 * alpha, 6) + Theme.strokeRounded(tx - 2, ty - 2, tw + 4, th + 4, PAL.railBlue, 0.35 * alpha, 2, 8) + Theme.strokeRounded(tx, ty, tw, th, PAL.lineStrong, 0.85 * alpha, 1.5, 6) + Kit.textCenterBold("small", msg, tx, ty + 8 * m.s, tw, PAL.heading) + else + imp._cursorModeToast = nil + end + end end return LauncherView diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 364de276..5c5970f3 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1263,7 +1263,12 @@ end -- up the background worker or reach out to the network. local function updaterAllowed() if not Platform.networkValidated() then return false end - if not (love.filesystem.isFused and love.filesystem.isFused()) then return false end + local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1" + or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1" + or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1" + if not (love.filesystem.isFused and love.filesystem.isFused()) and not isHandheld then + return false + end if os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER") then return false end if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end return true @@ -2055,8 +2060,42 @@ function RomImporter:chooseMod() end return end + local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1" + or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1" + or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1" + + if isHandheld then + local okKit, Kit = pcall(require, "src.ui.kit.Kit") + if okKit and Kit.FileBrowser then + self._padCursorActive = false + Kit.FileBrowser.open({ + title = "Select Mod (.zip)", + mode = "mod", + onSelect = function(pickedPath) + self:_installMod(pickedPath) + end, + }) + return + end + end + local path = chooseZip() - if path then self:_installMod(path) end + if path then + self:_installMod(path) + return + end + local okKit, Kit = pcall(require, "src.ui.kit.Kit") + if okKit and Kit.FileBrowser then + self._padCursorActive = false + Kit.FileBrowser.open({ + title = "Select Mod (.zip)", + mode = "mod", + onSelect = function(pickedPath) + self:_installMod(pickedPath) + end, + }) + return + end end local function requiredManifest(self, modId) @@ -2479,8 +2518,42 @@ function RomImporter:chooseSaveImport(version) end return end + local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1" + or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1" + or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1" + + if isHandheld then + local okKit, Kit = pcall(require, "src.ui.kit.Kit") + if okKit and Kit.FileBrowser then + self._padCursorActive = false + Kit.FileBrowser.open({ + title = "Select Save (.sav)", + mode = "save", + onSelect = function(pickedPath) + self:_importSave(version, pickedPath) + end, + }) + return + end + end + local path = chooseSav() - if path then self:_importSave(version, path) end + if path then + self:_importSave(version, path) + return + end + local okKit, Kit = pcall(require, "src.ui.kit.Kit") + if okKit and Kit.FileBrowser then + self._padCursorActive = false + Kit.FileBrowser.open({ + title = "Select Save (.sav)", + mode = "save", + onSelect = function(pickedPath) + self:_importSave(version, pickedPath) + end, + }) + return + end end -- "Export save" button: write the active slot back out to a raw .sav in the save @@ -2621,11 +2694,42 @@ function RomImporter:choose(version) end return end + local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1" + or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1" + or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1" + + if isHandheld then + local okKit, Kit = pcall(require, "src.ui.kit.Kit") + if okKit and Kit.FileBrowser then + self._padCursorActive = false + Kit.FileBrowser.open({ + title = "Select " .. (GameVersion.info(self.chooseVersion).displayName or "ROM"), + mode = "rom", + onSelect = function(pickedPath) + self:startPath(pickedPath) + end, + }) + return + end + end + local path = chooseRom(GameVersion.info(self.chooseVersion).displayName) if path then self:startPath(path) return end + local okKit, Kit = pcall(require, "src.ui.kit.Kit") + if okKit and Kit.FileBrowser then + self._padCursorActive = false + Kit.FileBrowser.open({ + title = "Select " .. (GameVersion.info(self.chooseVersion).displayName or "ROM"), + mode = "rom", + onSelect = function(pickedPath) + self:startPath(pickedPath) + end, + }) + return + end -- Handheld Linux (Anbernic stock OS / PortMaster) rarely has zenity or -- kdialog. Fall back to the same "drop a .gb/.gbc next to the game" scan -- used on Android, which works when the game is launched as an unpacked @@ -2998,6 +3102,14 @@ function RomImporter:_cycleTab(delta) end function RomImporter:_updatePadCursor(dt) + local okKit, Kit = pcall(require, "src.ui.kit.Kit") + if okKit then + if (Kit.FileBrowser and Kit.FileBrowser.active) + or (Kit.VirtualKeyboard and Kit.VirtualKeyboard.active) then + return + end + end + if self.isNX then self:_ensureNxPointerBridge() -- Cap dt so a hitch in the FlexLove immediate-mode frame does not fling @@ -3040,14 +3152,6 @@ function RomImporter:_updatePadCursor(dt) local ny = self._padCursor.y + dy * speed * dt self._padCursor.x = math.max(ox, math.min(ox + w, nx)) self._padCursor.y = math.max(oy, math.min(oy + h, ny)) - -- Pushing INTO the top/bottom edge scrolls the page instead of stalling. - -- The cursor is clamped to the safe area above, so on a short window the - -- rows below the fold are unreachable on a stickless handheld: no mouse - -- wheel, no touchscreen, and no right stick to feed the existing wheel - -- path. Only the OVERSHOOT scrolls -- parking the cursor at the edge does - -- nothing, it has to be actively pushed -- and this block only runs on pad - -- input, so a real mouse is unaffected. /48 matches the pixels-per-notch - -- LauncherView.draw multiplies back out. local overY = 0 if ny > oy + h then overY = ny - (oy + h) elseif ny < oy then overY = ny - oy end @@ -3055,17 +3159,12 @@ function RomImporter:_updatePadCursor(dt) if overY ~= 0 and self._flex then require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48) end - -- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer. - -- NX: the getPosition bridge already returns pad coords -- skip setPosition. if not self.isNX and love.mouse.setPosition then pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y) self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y end end - -- Right stick scrolls whatever the pad pointer sits over, through the - -- view's wheel path, so the page and the modal scrollers all behave like a - -- mouse wheel would. local ry = self._padAxis.righty or 0 if math.abs(ry) > PAD_DEAD and self._flex then self:_activatePadCursor() @@ -3074,30 +3173,168 @@ function RomImporter:_updatePadCursor(dt) end function RomImporter:gamepadpressed(_, button) - self:_activatePadCursor() - -- Map through GamepadMap so NX swaps SDL face labels to Nintendo A/B. - local action = GamepadMap.mapGamepadButton(button) - if action == "a" then - -- Instant click at the virtual pointer: dispatched straight into the - -- view, since the launcher no longer hit-tests presses itself. - if self._flex then - require("src.import.LauncherView").clickAt(self, - self._padCursor.x, self._padCursor.y) + local action = (GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button + local okKit, Kit = pcall(require, "src.ui.kit.Kit") + if okKit then + if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then + if Kit.VirtualKeyboard.gamepadpressed(action) then return end end - elseif button == "leftshoulder" then + if Kit.FileBrowser and Kit.FileBrowser.active then + if Kit.FileBrowser.gamepadpressed(action) then return end + end + end + + -- Y button toggle between Native Controller Navigation and Virtual Pointer Cursor: + if action == "y" or button == "y" then + self._padCursorActive = not self._padCursorActive + if okKit then Kit._ringShown = not self._padCursorActive end + self._cursorModeToast = self._padCursorActive and "Cursor Navigation [Y]" or "Controller Menu Navigation [Y]" + self._cursorModeToastTime = love.timer.getTime() + return + end + + -- Select button: toggle Virtual Keyboard + 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) + return elseif button == "rightshoulder" then self:_cycleTab(1) - elseif button == "dpup" or button == "dpdown" - or button == "dpleft" or button == "dpright" then - self._padDir[button] = true - elseif button == "start" or button == "back" then - -- Start / Select: Play if ready, else Choose ROM on the active game tab. + return + end + + -- Start button: Play / Choose ROM + if button == "start" then if self.workState == "working" then return end local version = self.tab if GameVersion.VERSIONS[version] then if self.ready[version] then self:play(version) else self:_romAction(version) end end + return + end + + if not self._padCursorActive and not self.isNX then + if okKit then Kit._ringShown = true end + if action == "a" then + if okKit and Kit.focusId then + Kit.activateFocused() + elseif self._flex then + require("src.import.LauncherView").clickAt(self, + self._padCursor.x, self._padCursor.y) + end + return + elseif action == "b" then + if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return + elseif self._rename then self._rename = nil; self:_disarmTextInput(); return + elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return + elseif self._profileSavePrompt then self._profileSavePrompt = nil; self:_disarmTextInput(); return + elseif self._settingsText then self._settingsText = nil; self:_disarmTextInput(); return + end + elseif button == "dpup" or action == "dpup" then + if okKit then Kit.navigate("up") end + return + elseif button == "dpdown" or action == "dpdown" then + if okKit then Kit.navigate("down") end + return + elseif button == "dpleft" or action == "dpleft" then + if okKit then Kit.navigate("left") end + return + elseif button == "dpright" or action == "dpright" then + if okKit then Kit.navigate("right") end + return + end + else + self:_activatePadCursor() + if action == "a" then + if self._flex then + require("src.import.LauncherView").clickAt(self, + self._padCursor.x, self._padCursor.y) + end + elseif action == "b" then + if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return + elseif self._rename then self._rename = nil; self:_disarmTextInput(); return + elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return + elseif self._profileSavePrompt then self._profileSavePrompt = nil; self:_disarmTextInput(); return + elseif self._settingsText then self._settingsText = nil; self:_disarmTextInput(); return + end + elseif button == "dpup" or button == "dpdown" + or button == "dpleft" or button == "dpright" then + self._padDir[button] = true + end end end @@ -3111,9 +3348,9 @@ end function RomImporter:gamepadaxis(_, axis, value) if axis == "leftx" or axis == "lefty" or axis == "righty" then self._padAxis[axis] = value - if math.abs(value) > PAD_DEAD then + if self._padCursorActive and math.abs(value) > PAD_DEAD then self:_activatePadCursor() - elseif axis == "lefty" then + elseif axis == "lefty" and math.abs(value) <= PAD_DEAD then self._padStickCentered = true end end @@ -4062,6 +4299,15 @@ function RomImporter:fileUrl(path) end function RomImporter:keypressed(key) + local okKit, Kit = pcall(require, "src.ui.kit.Kit") + if okKit then + if Kit.FileBrowser and Kit.FileBrowser.active then + if Kit.FileBrowser.keypressed(key) then return end + end + if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then + if Kit.VirtualKeyboard.keypressed(key) then return end + end + end if self._profileSavePrompt then if key == "backspace" then self._profileSavePrompt.text = utf8Back(self._profileSavePrompt.text or "") diff --git a/src/render/GbcPalette.lua b/src/render/GbcPalette.lua index 0ee1d212..9a5e7c67 100644 --- a/src/render/GbcPalette.lua +++ b/src/render/GbcPalette.lua @@ -18,8 +18,6 @@ -- colour IS its palettes -- so one substitution at this seam recolours the -- whole game without a single screen knowing about it: -- --- GBC the cart's own palettes. The default: this is a Game Boy --- Color game and its colour is the point. -- DMG the original grey Game Boy. Every palette collapses to the -- four hardware shades, and the sheets that are drawn straight -- (chrome, text) are already those shades, so the screen is @@ -32,9 +30,12 @@ local GbcPalette = {} GbcPalette.MODES = { "gbc", "dmg", "classic" } -GbcPalette.MODE_LABELS = { gbc = "GBC", dmg = "DMG", classic = "CLASSIC" } +GbcPalette.MODE_LABELS = { gbc = "GEN 2", dmg = "DMG", classic = "CLASSIC", + custom = "GBC" } GbcPalette.mode = "gbc" +GbcPalette.CUSTOM_MODE = "custom" + -- rBGP's four shades as the hardware shows them. local DMG_SHADES = { { 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 }, @@ -162,11 +163,22 @@ local function channel(colors, index) return (c[1] or 0) / 255, (c[2] or 0) / 255, (c[3] or 0) / 255 end +GbcPalette.customRamp = nil + +function GbcPalette.setCustomRamp(ramp) + local prev = GbcPalette.customRamp + GbcPalette.customRamp = ramp + if (prev ~= nil) ~= (ramp ~= nil) or prev ~= ramp then + pcall(function() require("src.render.SpriteRenderer").invalidate() end) + end +end + -- What a palette actually draws as under the current COLOR mode. Anything -- that reads a colour out of a palette directly -- a canvas cleared to BG -- colour 0, say -- has to go through this too, or the backdrop would keep its -- cart colour while everything on top of it went grey. function GbcPalette.resolve(colors) + if GbcPalette.customRamp then return GbcPalette.customRamp end if GbcPalette.mode == "gbc" then return colors end return DMG_SHADES end @@ -249,6 +261,10 @@ function GbcPalette.presentColors() end function GbcPalette.setMode(mode) + if mode == GbcPalette.CUSTOM_MODE then + GbcPalette.mode = mode + return mode + end for _, name in ipairs(GbcPalette.MODES) do if name == mode then GbcPalette.mode = mode @@ -260,7 +276,7 @@ function GbcPalette.setMode(mode) end function GbcPalette.modeLabel(mode) - return GbcPalette.MODE_LABELS[mode or GbcPalette.mode] or "GBC" + return GbcPalette.MODE_LABELS[mode or GbcPalette.mode] or "GEN 2" end -- Advance GBC -> DMG -> CLASSIC -> GBC. `delta` may be -1 to step back, so @@ -273,11 +289,20 @@ function GbcPalette.cycle(delta) local count = #GbcPalette.MODES at = (at - 1 + (delta or 1)) % count + 1 GbcPalette.mode = GbcPalette.MODES[at] + GbcPalette.setCustomRamp(nil) return GbcPalette.mode end function GbcPalette.applyOptions(opts) - return GbcPalette.setMode(opts and opts.color or "gbc") + local mode = GbcPalette.setMode(opts and opts.color or "gbc") + local id = opts and opts.palette + if id and id ~= "" then + local ok, Palette = pcall(require, "src.render.Palette") + GbcPalette.setCustomRamp(ok and Palette.ramp(id) or nil) + else + GbcPalette.setCustomRamp(nil) + end + return mode end -- Point the shader at one 4-color palette. Colors are 0-255 triples, matching @@ -431,4 +456,14 @@ function GbcPalette.with(colors, body) return applied end +function GbcPalette.withRaw(colors, body) + local previous = love and love.graphics and love.graphics.getShader + and love.graphics.getShader() or nil + local applied = GbcPalette.useRaw(colors) + local ok, err = pcall(body) + if love and love.graphics then love.graphics.setShader(previous) end + if not ok then error(err, 0) end + return applied +end + return GbcPalette diff --git a/src/render/Letterbox.lua b/src/render/Letterbox.lua new file mode 100644 index 00000000..1b428c31 --- /dev/null +++ b/src/render/Letterbox.lua @@ -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 diff --git a/src/render/Palette.lua b/src/render/Palette.lua new file mode 100644 index 00000000..21fbd3a5 --- /dev/null +++ b/src/render/Palette.lua @@ -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 diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index f94ff2ca..7cf4c752 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -797,6 +797,18 @@ function PaletteFX.shadeMap() return shadeMap end +local function invalidateColorCaches() + pcall(function() require("src.battle.BattleState").invalidate() end) + pcall(function() require("src.render.SpriteRenderer").invalidate() end) + pcall(function() + require("src.world.MapLoader").invalidateAll() + local Game = require("src.core.Game") + if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then + Game.overworld:reloadMap(Game.overworld.map.id, "colors") + end + end) +end + function PaletteFX.setMode(mode) local prev = PaletteFX.mode local ok = false @@ -808,27 +820,35 @@ function PaletteFX.setMode(mode) end end if not ok then PaletteFX.mode = "gbc" end - -- battle pics and overworld sprites bake the active pack into ImageData; - -- drop those caches when the pack (or any COLORS mode) changes so the - -- next draw re-tints - if prev ~= PaletteFX.mode then - pcall(function() require("src.battle.BattleState").invalidate() end) - pcall(function() require("src.render.SpriteRenderer").invalidate() end) - -- RED++'s baked tileset atlas (TileRenderer.getGbcAtlas) is built once - -- per loaded map, so a mode toggle needs every cached Map/TileRenderer - -- dropped and the currently-visible one rebuilt in place -- otherwise - -- the on-screen map keeps its stale (wrong-mode) atlas until the next - -- map transition happens to reload it. - pcall(function() - require("src.world.MapLoader").invalidateAll() - local Game = require("src.core.Game") - if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then - Game.overworld:reloadMap(Game.overworld.map.id, "colors") - end - end) + if prev ~= PaletteFX.mode then invalidateColorCaches() end +end + +PaletteFX.customRamp = nil + +function PaletteFX.setCustomRamp(ramp) + local prev = PaletteFX.customRamp + PaletteFX.customRamp = ramp + if (prev ~= nil) ~= (ramp ~= nil) or prev ~= ramp then + invalidateColorCaches() end end +function PaletteFX.pickerActive() + local ok, Game = pcall(require, "src.core.Game") + local states = ok and Game.stack and Game.stack.states + local top = states and states[#states] + return top ~= nil and top.screenId == "PaletteScreen" +end + +-- Whether the active COLORS state needs a battle pic rendered as raw DMG +-- grays instead of real colour, the same "forced-mono" contract OG/OG +-- INV/CLASSIC already use (issue #207). A custom palette needs this too, +-- since ensureZones re-thresholds the already-drawn frame. Keep this in +-- sync with ensureZones and the mode checks in BattleState and WideBattle. +function PaletteFX.forcesRawGrays() + return PaletteFX.customRamp ~= nil +end + function PaletteFX.cycleMode() local cur = PaletteFX.mode or "gbc" local idx = 1 @@ -841,6 +861,13 @@ end function PaletteFX.applyOptions(opts) PaletteFX.setMode(opts and opts.colors or "gbc") + local id = opts and opts.palette + if id and id ~= "" then + local ok, Palette = pcall(require, "src.render.Palette") + PaletteFX.setCustomRamp(ok and Palette.ramp(id) or nil) + else + PaletteFX.setCustomRamp(nil) + end end function PaletteFX.modeLabel(mode) @@ -853,12 +880,11 @@ function PaletteFX.modeLabel(mode) end -- When a state exposes no SGB zones but COLORS needs a forced palette --- (OG / OG INV / CLASSIC), invent a whole-screen zone so the shade-remap --- shader still runs. GBC / RED++ / GBC INV leave nil alone (raw DMG canvas). function PaletteFX.ensureZones(zones) if zones and zones[1] then return zones end local mode = PaletteFX.mode or "gbc" - if mode == "og" or mode == "og_inv" or mode == "classic" then + if mode == "og" or mode == "og_inv" or mode == "classic" + or (PaletteFX.forcesRawGrays() and not PaletteFX.pickerActive()) then return { PaletteFX.whole(PaletteFX.GRAYS) } end return zones @@ -888,7 +914,9 @@ function PaletteFX.effectiveColors(c) if not c then return nil end local mode = PaletteFX.mode or "gbc" local out = c - if mode == "og" then + if PaletteFX.customRamp and not PaletteFX.pickerActive() then + out = PaletteFX.customRamp + elseif mode == "og" then out = PaletteFX.GRAYS elseif mode == "og_inv" then out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP) diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 4c795875..7dc88eb4 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -972,6 +972,13 @@ function Renderer:endFrame(zones, worldZones) and not FaithfulRes.scaleCap() then clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data) end + -- UI LETTERBOX overrides whatever the rules above settled on, except + -- under FAITHFUL RATIO's mobile lock, which promises black bars. + if not FaithfulRes.scaleCap() then + local Letterbox = require("src.render.Letterbox") + clearR, clearG, clearB = Letterbox.fill(clearR, clearG, clearB, + function() return PaletteFX.paperShade(Game and Game.data) end) + end end if cut then love.graphics.setColor(0, 0, 0, 1) diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index fc324825..c8ce982a 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -11,6 +11,12 @@ local UIVisibility = require("src.battle.UIVisibility") local Theme = require("src.ui.Theme") local Timing = require("src.core.Timing") +local Chrome2 +do + local ok, v = pcall(require, "src.ui.gen2.Chrome") + Chrome2 = ok and v or nil +end + local TextBox = {} TextBox.__index = TextBox TextBox.isTextBox = true @@ -470,8 +476,21 @@ function TextBox:draw() -- (pokegold engine/pokegear/pokegear.asm TownMapPals sends every tile -- >= $60 to palette 0). Gen 1's Game has no textboxPaper, so it stays nil. local paper = self.game and self.game.textboxPaper and self.game:textboxPaper() - Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, paper) - love.graphics.setColor(0, 0, 0, 1) + + local gold = self.game and self.game.save + and (self.game.save.generation == 2 or self.game.save.version == "gold") + local Chrome = gold and Chrome2 or nil + local drawGlyph, finishGlyph = Font.drawCode, nil + if Chrome then + local base = paper and { paper, paper, paper, { 0, 0, 0 } } + or Chrome.DEFAULT_BOX_PALETTE + Chrome.paletteBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, base) + local _, dg, fg = Chrome.paletteGlyphs(base) + drawGlyph, finishGlyph = dg, fg + else + Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, paper) + love.graphics.setColor(0, 0, 0, 1) + end if self.scrollPx and self.scrollPx > 0 then self.scrollPx = self.scrollPx - 2 if self.scrollPx <= 0 then self.scrollPx = nil end @@ -491,17 +510,25 @@ function TextBox:draw() -- measured with; every fixed-width page still lands on the 8px grid local pen = self.textX for _, code in ipairs(line) do - Font.drawCode(code, pen, y) + drawGlyph(code, pen, y) pen = pen + Font.advanceOf(code) end end if self.money then -- money box (engine/menus/text_box.asm:130): DisplayMoneyBox at -- hlcoord 11,0, the amount right-aligned on its middle row - Font.drawBox(11, 0, 9, 3) - love.graphics.setColor(0, 0, 0, 1) + if Chrome then + Chrome.paletteBox(11, 0, 9, 3, Chrome.DEFAULT_BOX_PALETTE) + else + Font.drawBox(11, 0, 9, 3) + love.graphics.setColor(0, 0, 0, 1) + end local money = ("¥%d"):format(self.money() or 0) - Font.draw(money, 152 - Font.width(money), 8) + local pen = 152 - Font.width(money) + for _, code in ipairs(Font.encode(money)) do + drawGlyph(code, pen, 8) + pen = pen + Font.advanceOf(code) + end end if (self.waiting or (self.done and not self.choice and not self.auto and (not self.stay @@ -509,10 +536,11 @@ function TextBox:draw() and self.blink < 30 then -- page-advance cursor: glyph $EE by default, the blinking down arrow -- the original prints via `ld a, "▼"` (home/text.asm) - Font.drawCode(Theme.moreArrow or 0xEE, - (self.boxTx + self.boxTw - 2) * 8, - (self.boxTy + self.boxTh - 1) * 8 - 4) + drawGlyph(Theme.moreArrow or 0xEE, + (self.boxTx + self.boxTw - 2) * 8, + (self.boxTy + self.boxTh - 1) * 8 - 4) end + if finishGlyph then finishGlyph() end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/sync/SyncEngine.lua b/src/sync/SyncEngine.lua index 83be5644..d2d4313a 100644 --- a/src/sync/SyncEngine.lua +++ b/src/sync/SyncEngine.lua @@ -116,6 +116,36 @@ function SyncEngine.overlaps(a, b) return aStart <= bEnd and bStart <= aEnd end +-- Inline, under .meta, or under .remoteMeta, depending on the endpoint. +function SyncEngine.metaOf(row) + if type(row) ~= "table" then return nil end + if type(row.meta) == "table" then return row.meta end + if type(row.remoteMeta) == "table" then return row.remoteMeta end + return row +end + +-- Gen 2 stores playTime as a table, so meta.playTime is nil on a Gold save +-- and summary.timeText is the only field that survives. +local function playedMinutes(meta) + if type(meta) ~= "table" then return nil end + local summary = type(meta.summary) == "table" and meta.summary or nil + local text = summary and summary.timeText + if type(text) == "string" then + local hours, minutes = text:match("^(%d+):(%d%d)$") + if hours then return tonumber(hours) * 60 + tonumber(minutes) end + end + local seconds = tonumber(meta.playTime) + if seconds then return math.floor(seconds / 60) end + return nil +end + +-- Same minute of playtime means the same point in the playthrough, so there +-- is no fork. Unknown playtime on either side is NOT a match. +function SyncEngine.samePlaytime(a, b) + local left, right = playedMinutes(a), playedMinutes(b) + return left ~= nil and left == right +end + function SyncEngine.new(opts) opts = opts or {} local eng = setmetatable({}, SyncEngine) @@ -457,7 +487,11 @@ function SyncEngine:_planFrom(remoteState) if not row then self:_queueUpload(entry, key, false) elseif localChanged and remoteChanged then - self:_addConflict(entry, key, row) + if SyncEngine.samePlaytime(entry.meta, SyncEngine.metaOf(row)) then + self:_queueUpload(entry, key, true) + else + self:_addConflict(entry, key, row) + end elseif localChanged then self:_queueUpload(entry, key, false) elseif remoteChanged and key ~= self.protectedKey then @@ -477,12 +511,7 @@ function SyncEngine:_planFrom(remoteState) end function SyncEngine:_addConflict(entry, key, row) - local remoteMeta = row - if type(row.meta) == "table" then - remoteMeta = row.meta - elseif type(row.remoteMeta) == "table" then - remoteMeta = row.remoteMeta - end + local remoteMeta = SyncEngine.metaOf(row) self.conflicts[#self.conflicts + 1] = { key = key, version = entry.version, @@ -528,7 +557,14 @@ function SyncEngine:_queueUpload(entry, key, force) end, function(e, res) if res.code == 409 then local row = res.data or {} - e:_addConflict(entry, key, row) + -- Retried with force only once: a forced write that still 409s is a + -- real refusal, and retrying it would spin. + if not force + and SyncEngine.samePlaytime(entry.meta, SyncEngine.metaOf(row)) then + e:_queueUpload(entry, key, true) + else + e:_addConflict(entry, key, row) + end if not e:busy() then e:_finish() end return true end diff --git a/src/ui/Marquee.lua b/src/ui/Marquee.lua new file mode 100644 index 00000000..521cb46d --- /dev/null +++ b/src/ui/Marquee.lua @@ -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 diff --git a/src/ui/OptionRows.lua b/src/ui/OptionRows.lua index 1a39b71c..f4e1a68e 100644 --- a/src/ui/OptionRows.lua +++ b/src/ui/OptionRows.lua @@ -7,12 +7,18 @@ -- rows that open something instead (MODS, CANCEL stays the caller's). local Font = require("src.render.Font") +local Marquee = require("src.ui.Marquee") local Theme = require("src.ui.Theme") local OptionRows = {} OptionRows.VISIBLE = 4 -- option boxes on screen at once (4 tiles each) +-- Font.drawBox(0, y, 20, 4) spends column 19 on the frame, so a line drawn +-- to the 160px screen edge prints over its own border. +local CONTENT_RIGHT = 152 +local function fits(x) return math.floor((CONTENT_RIGHT - x) / 8) end + -- keep the cursor's box inside the viewport; the fixed bottom row shows -- the tail of the list function OptionRows.clampScroll(index, scroll, total, bottomRow) @@ -27,7 +33,7 @@ function OptionRows.clampScroll(index, scroll, total, bottomRow) end -- one bordered box per row, label line + value line, with the fixed --- bottom line below (CANCEL in the options menu, the manager's footer) +-- bottom line below (BACK in the options menu, the manager's footer) function OptionRows.draw(game, rows, index, scroll, bottomLabel, bottomRow) love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) @@ -37,8 +43,18 @@ function OptionRows.draw(game, rows, index, scroll, bottomLabel, bottomRow) if not row then break end Font.drawBox(0, (slot - 1) * 4, 20, 4) love.graphics.setColor(0, 0, 0, 1) - Font.draw(row.label, 16, ((slot - 1) * 4 + 1) * 8) - Font.draw(row.value and row.value(game) or "", 24, ((slot - 1) * 4 + 2) * 8) + local label = row.label or "" + local value = row.value and row.value(game) or "" + if i == index then + local key = tostring(row.id or row.label) .. "\0" .. tostring(value) + label = Marquee.scroll(label, fits(16), key) + value = Marquee.scroll(value, fits(24), key) + else + label = Marquee.clip(label, fits(16)) + value = Marquee.clip(value, fits(24)) + end + Font.draw(label, 16, ((slot - 1) * 4 + 1) * 8) + Font.draw(value, 24, ((slot - 1) * 4 + 2) * 8) if i == index then Font.drawCode(Theme.cursor, 8, ((slot - 1) * 4 + 1) * 8) end diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 7e2fdc3d..b095d866 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -11,10 +11,12 @@ -- bottom line like pokered's. local PaletteFX = require("src.render.PaletteFX") +local Palette = require("src.render.Palette") local Pipelines = require("src.render.Pipelines") local Tilt = require("src.render.Tilt") local ShaderFX = require("src.render.ShaderFX") local Zoom = require("src.render.Zoom") +local Letterbox = require("src.render.Letterbox") local TileRenderer = require("src.render.TileRenderer") local GameSpeed = require("src.core.GameSpeed") local GameVersion = require("src.core.GameVersion") @@ -127,14 +129,6 @@ local function stepVolume(v, dir) return math.max(0, math.min(7, (v or 7) + dir)) end -local function colorIndex(opts) - local cur = opts.colors or "gbc" - for i, m in ipairs(PaletteFX.MODES) do - if m == cur then return i end - end - return 1 -end - local function wrapIndex(i, n) i = i % n if i < 0 then i = i + n end @@ -341,15 +335,14 @@ local function buildRows(game) end }, { id = "colors", label = Strings("COLORS"), value = function(g) + local id = g.save.options.palette + if id and id ~= "" then + return Palette.label(id) or PaletteFX.modeLabel(g.save.options.colors or "gbc") + end return PaletteFX.modeLabel(g.save.options.colors or "gbc") end, - step = function(g, dir) - local o = g.save.options - local i = colorIndex(o) - i = wrapIndex(i - 1 + dir, #PaletteFX.MODES) + 1 - o.colors = PaletteFX.MODES[i] - PaletteFX.setMode(o.colors) - return true + activate = function(g) + require("src.ui.Screens").push(g, "PaletteScreen") end }, { id = "tilt", label = Strings("TILT"), value = function(g) return Tilt.levelLabel(g.save.options.tilt or 0) end, @@ -376,6 +369,16 @@ local function buildRows(game) -- cycling in place on this row. -- ShaderFXScreen.lua does the actual list/activate; this row only opens -- it and shows what is currently active. + { id = "uiLetterbox", label = Strings("UI LETTERBOX"), + value = function(g) + return Strings(Letterbox.label(g.save.options.uiLetterbox)) + end, + step = function(g, dir) + local o = g.save.options + o.uiLetterbox = Letterbox.cycle(o.uiLetterbox, dir) + Letterbox.setMode(o.uiLetterbox) + return true + end }, { id = "shaderfx", label = Strings("SHADER FX"), value = function(g) return shaderfxLabel(ShaderFX.activeEntry("main")) @@ -643,8 +646,90 @@ local function buildRows(game) return rows end +-- Grouping runs AFTER the ui.options.rows hook and never touches self.rows, +-- so a mod still sees and edits the flat list it always did; a row in no +-- group stays where it is, which is where a mod's own additions land. +local GROUPS = { + { id = "group.battle", label = "BATTLE OPTIONS", + members = { "animations", "battleStyle", "battleLayout", "battleFit", + "battleHud", "battleBg" } }, + { id = "group.audio", label = "AUDIO", + members = { "musicVol", "sfxVol", "pikaVol", "musicFilter" } }, + { id = "group.video", label = "VIDEO", + members = { "uiLayout", "videoMode", "orientation", "faithfulRes", + "screenPos", "fpsCap" } }, + { id = "group.speed", label = "SPEED", + members = { "textSpeed", "speedOverworld", "speedBattle", "speedMenu" } }, + { id = "group.graphics", label = "GRAPHICS", + members = { "colors", "uiLetterbox", "shaderfx", "shaderfx2" } }, + -- A mod's Pipelines row splices in after TILT and is in no group, so it + -- stays on the top level rather than being swallowed into this page. + { id = "group.extras", label = "EXTRAS", + members = { "tilt", "zoom", "voidFill" } }, +} + +-- The top level's order, groups and singles alike. Anything not named here +-- (a mod's row, the touch rows) keeps its flat position after these. +local ORDER = { + "group.speed", "group.video", "group.graphics", "group.audio", + "performance", "ruleset", "group.battle", "group.extras", "mods", +} + +-- Each group replaced by one opener. +local function groupRows(game, rows) + local owner, picked = {}, {} + for _, group in ipairs(GROUPS) do + for _, id in ipairs(group.members) do owner[id] = group end + picked[group.id] = {} + end + for _, row in ipairs(rows) do + local group = row.id and owner[row.id] + if group then picked[group.id][#picked[group.id] + 1] = row end + end + local made = {} + for _, group in ipairs(GROUPS) do + local members = picked[group.id] + if #members > 0 then + made[group.id] = { + id = group.id, label = Strings(group.label), group = true, + value = function() return Strings("%d OPTIONS", #members) end, + -- No onCancel: BACK out of a page returns here, it does not close + -- OPTIONS, so the caller's close callback must not fire. + activate = function(g) + g.stack:push(OptionsMenu.new(g, { rows = members })) + end, + } + end + end + local byId, view, taken = {}, {}, {} + for _, row in ipairs(rows) do + if row.id and not owner[row.id] then byId[row.id] = row end + end + for _, id in ipairs(ORDER) do + local row = made[id] or byId[id] + if row then + view[#view + 1] = row + taken[id] = true + end + end + -- Whatever ORDER does not name, in the order the flat list had it: a mod's + -- own rows, and the platform rows that come and go. + for _, row in ipairs(rows) do + local id = row.id + if not (id and (owner[id] or taken[id])) then view[#view + 1] = row end + end + return view +end + +-- opts.rows makes a submenu: a fixed row list, no hook and no regrouping, so +-- a group's page is this same screen driving the rows it was handed. function OptionsMenu.new(game, opts) opts = opts or {} + if opts.rows then + return setmetatable({ game = game, rows = opts.rows, view = opts.rows, + index = 1, scroll = 0, sub = true, + onCancel = opts.onCancel }, OptionsMenu) + end local rows = buildRows(game) local hooked = Runtime.call("ui.options.rows", sameRows, game, rows) if type(hooked) == "table" then @@ -653,14 +738,44 @@ function OptionsMenu.new(game, opts) Logger.error("ui.options.rows returned %s; keeping the vanilla rows", type(hooked)) end - return setmetatable({ game = game, rows = rows, index = 1, scroll = 0, - onCancel = opts.onCancel }, OptionsMenu) + local self = setmetatable({ game = game, rows = rows, index = 1, scroll = 0, + onCancel = opts.onCancel }, OptionsMenu) + self.view = groupRows(game, rows) + return self +end + +-- Cursor onto a row by id, opening its group page first if it lives in one. +-- Returns the screen the row ended up on, so a caller can keep driving it. +function OptionsMenu:focusRow(id) + local rows = self.view or self.rows + for i, row in ipairs(rows) do + if row.id == id then + self.index = i + self.scroll = OptionRows.clampScroll(i, self.scroll or 0, #rows, #rows + 1) + return self + end + end + for i, row in ipairs(rows) do + if row.group and row.activate then + local group + for _, g in ipairs(GROUPS) do if g.id == row.id then group = g end end + for _, member in ipairs(group and group.members or {}) do + if member == id then + self.index = i + row.activate(self.game) + local sub = self.game.stack:top() + return sub.focusRow and sub:focusRow(id) or sub + end + end + end + end + return nil end function OptionsMenu:update(dt) local input = self.game.input - local rows = self.rows - -- CANCEL sits below the hook-built rows so a mod cannot orphan the exit + local rows = self.view or self.rows + -- BACK sits below the hook-built rows so a mod cannot orphan the exit local cancelRow = #rows + 1 local changed = false if input:wasPressed("up") then @@ -702,14 +817,15 @@ function OptionsMenu:update(dt) end function OptionsMenu:draw() - -- Through Strings, like every other label on this menu. CANCEL is + -- Through Strings, like every other label on this menu. BACK is -- appended AFTER the rows hook (see the header), which is what keeps a mod -- from orphaning the exit -- but it also means a translation mod never sees -- this string, and cannot: there is no row for it to rewrite. So the one -- word a Spanish player could not read on a fully translated OPTIONS menu -- was the way out of it. - OptionRows.draw(self.game, self.rows, self.index, self.scroll or 0, - Strings("CANCEL"), #self.rows + 1) + local rows = self.view or self.rows + OptionRows.draw(self.game, rows, self.index, self.scroll or 0, + Strings("BACK"), #rows + 1) end return OptionsMenu diff --git a/src/ui/PaletteScreen.lua b/src/ui/PaletteScreen.lua new file mode 100644 index 00000000..a6d8f983 --- /dev/null +++ b/src/ui/PaletteScreen.lua @@ -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 diff --git a/src/ui/gen2/BattleAnimView.lua b/src/ui/gen2/BattleAnimView.lua index 13991c53..7a34938b 100644 --- a/src/ui/gen2/BattleAnimView.lua +++ b/src/ui/gen2/BattleAnimView.lua @@ -18,6 +18,7 @@ local bit = require("bit") local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") local GbcPalette = require("src.render.GbcPalette") local Palettes = require("src.world.gen2.Palettes") @@ -164,12 +165,8 @@ local function needsCanvas(runner) end -- The battle background is BG colour 0 everywhere the two pic boxes and the --- text box are not, which Chrome.clear draws as plain white; anything the --- scanline blit exposes has to be that colour and not a hole. function BattleAnimView:fillBackground() - local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H) + Chrome.paletteFill(0, 0, SCREEN_W, SCREEN_H) end -- One reusable quad, re-aimed per scanline. A row shifted by `dx` is drawn diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index cabda30f..4cb11ee5 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -3520,7 +3520,7 @@ function BattleState:drawHud() -- And nothing at all before UpdateEnemyHUD has ever run: the intro bands -- slide in over a blanked tilemap (core.asm:8554/8564). if showStatus and self.showEnemyHud and not self:hudCleared("enemy") then - Chrome.print(self:name(enemy), 1, 0) + Chrome.printThrough(self:name(enemy), 1, 0, Chrome.DEFAULT_BOX_PALETTE) -- PrintLevel writes at the coordinate it is given and then LEFT-aligns -- the digits after it, so the glyph is pinned to column 6 whether the level -- is 5 or 100; only a three-digit level moves, and it does so by eating the @@ -3528,10 +3528,12 @@ function BattleState:drawHud() -- PlaceNonFaintStatus (engine/pokemon/mon_stats.asm): a statused mon's tag -- prints where the level goes, and DrawEnemyHUD's `.skip_level` arm drops -- the level entirely while one is up. - Chrome.print(self:statusTag(enemy, "enemy") - or ("" .. tostring(enemy.level or 1)), 6, 1) + Chrome.printThrough(self:statusTag(enemy, "enemy") + or ("" .. tostring(enemy.level or 1)), 6, 1, Chrome.DEFAULT_BOX_PALETTE) local enemyGender = self:genderSymbol(enemy) - if enemyGender then Chrome.print(enemyGender, 9, 1) end + if enemyGender then + Chrome.printThrough(enemyGender, 9, 1, Chrome.DEFAULT_BOX_PALETTE) + end -- `ld a, [wBattleMode] / dec a / ret nz`, then CheckCaughtMon puts $5d at -- (1,1) (engine/battle/trainer_huds.asm:140-152). if self.battle and self.battle.wild and self.caughtMark then @@ -3580,17 +3582,20 @@ function BattleState:drawHud() Font.useBattleExtra(wasBattle) return end - Chrome.print(self:name(player), 10, 7) + Chrome.printThrough(self:name(player), 10, 7, Chrome.DEFAULT_BOX_PALETTE) -- PrintPlayerHUD places the same status tag at (14,8) and skips the level -- while it is up. - Chrome.print(self:statusTag(player, "player") - or ("" .. tostring(self.shownLevel or player.level or 1)), 14, 8) + Chrome.printThrough(self:statusTag(player, "player") + or ("" .. tostring(self.shownLevel or player.level or 1)), 14, 8, + Chrome.DEFAULT_BOX_PALETTE) local playerGender = self:genderSymbol(player) - if playerGender then Chrome.print(playerGender, 17, 8) end + if playerGender then + Chrome.printThrough(playerGender, 17, 8, Chrome.DEFAULT_BOX_PALETTE) + end self:drawHpBar(player, "player", 10, 9) local maxHp = player.maxHp or (player.stats and player.stats.hp) or 0 - Chrome.printRight(("%d/%d"):format(self:hudHp(player, "player"), maxHp), - 18, 10) + Chrome.printRightThrough(("%d/%d"):format(self:hudHp(player, "player"), maxHp), + 18, 10, Chrome.DEFAULT_BOX_PALETTE) -- Stub on the RIGHT (tile $73), border from (18,10) laid leftward, exp bar -- at (10,11). -- The chased fill, not the mon's: AnimateExpBar crawls it, and reading the @@ -3659,8 +3664,8 @@ end function BattleState:printMessage() local lines = Chrome.wrap(self.message or "", TEXT_WIDTH) for i = 1, math.min(#lines, TEXT_ROWS) do - Chrome.print(lines[i], TEXT_INNER_X, - TEXT_INNER_Y + (i - 1) * TEXT_ROW_STEP) + Chrome.printThrough(lines[i], TEXT_INNER_X, + TEXT_INNER_Y + (i - 1) * TEXT_ROW_STEP, Chrome.DEFAULT_BOX_PALETTE) end end @@ -3670,15 +3675,17 @@ function BattleState:drawMoveInfoBox(move) if not move then return end local fighter = self.battle and self.battle.player if fighter and self.battle:moveDisabled(fighter, move.id) then - Chrome.print("Disabled!", 1, 10) + Chrome.printThrough("Disabled!", 1, 10, Chrome.DEFAULT_BOX_PALETTE) return end local def = self.game and self.game.data and self.game.data.moves and self.game.data.moves[move.id] - Chrome.print("TYPE/", 1, 9) + Chrome.printThrough("TYPE/", 1, 9, Chrome.DEFAULT_BOX_PALETTE) local moveType = def and def.type - Chrome.print(moveType and (TYPE_NAMES[moveType] or moveType) or "", 2, 10) - Chrome.print(("%2d/%2d"):format(move.pp or 0, move.maxPp or 0), 5, 11) + Chrome.printThrough(moveType and (TYPE_NAMES[moveType] or moveType) or "", + 2, 10, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(("%2d/%2d"):format(move.pp or 0, move.maxPp or 0), + 5, 11, Chrome.DEFAULT_BOX_PALETTE) end function BattleState:drawPanel() @@ -3687,7 +3694,7 @@ function BattleState:drawPanel() -- required there; everywhere else a missing side is a caller bug. local hasPlayer = self.battle and (self.battle.player or self.tutorial) if not (self.battle and hasPlayer and self.battle.enemy) then - Chrome.print("NO BATTLE", 1, 1) + Chrome.printThrough("NO BATTLE", 1, 1, Chrome.DEFAULT_BOX_PALETTE) return end self:drawHud() @@ -3719,8 +3726,10 @@ function BattleState:drawPanel() local col = ((i - 1) % 2) * spacing local row = math.floor((i - 1) / 2) * 2 local tx, ty = boxX + 2 + col, 14 + row - if i == self.menuIndex then Chrome.cursor(tx - 1, ty) end - Chrome.print(label, tx, ty) + if i == self.menuIndex then + Chrome.cursorThrough(tx - 1, ty, Chrome.DEFAULT_BOX_PALETTE) + end + Chrome.printThrough(label, tx, ty, Chrome.DEFAULT_BOX_PALETTE) end elseif self.phase == "moves" or (self.phase == "choose-forget" and (self.messageTimer or 0) <= 0) then @@ -3740,21 +3749,24 @@ function BattleState:drawPanel() for i, move in ipairs(moves) do local ty = 13 + (i - 1) -- Cursor in the box's own gutter, not clipped against the border. - if i == cursorRow then Chrome.cursor(cursorCol, ty) end + if i == cursorRow then + Chrome.cursorThrough(cursorCol, ty, Chrome.DEFAULT_BOX_PALETTE) + end -- The held slot's marker. `.battle_player_moves` writes '▷' into the -- row wSwappingMove names (engine/battle/core.asm:5157-5165) so a move -- picked up for a swap is visible while the cursor moves off it. It -- hlcoord 5, 13 is the cursor's own gutter, so PlaceMenuCursor covers -- the marker on the cursor's row. if not forgetting and self.moveSwapIndex == i and i ~= cursorRow then - Chrome.print("\u{25B7}", cursorCol, ty) + Chrome.printThrough("\u{25B7}", cursorCol, ty, Chrome.DEFAULT_BOX_PALETTE) end local def = self.game and self.game.data and self.game.data.moves and self.game.data.moves[move.id] - Chrome.print((def and def.name) or move.id, nameCol, ty) + Chrome.printThrough((def and def.name) or move.id, nameCol, ty, + Chrome.DEFAULT_BOX_PALETTE) if not moveMenu then - Chrome.printRight(("%d/%d"):format(move.pp or 0, move.maxPp or 0), - 19, ty) + Chrome.printRightThrough(("%d/%d"):format(move.pp or 0, move.maxPp or 0), + 19, ty, Chrome.DEFAULT_BOX_PALETTE) end end if moveMenu then self:drawMoveInfoBox(moves[cursorRow]) end @@ -3773,13 +3785,14 @@ function BattleState:drawPanel() local left = (self.phase == "ask-shift" or self.phase == "ask-next-mon") and 1 or 14 Chrome.box(left, 7, 6, 5) - Chrome.print("YES", left + 2, 8) - Chrome.print("NO", left + 2, 10) + Chrome.printThrough("YES", left + 2, 8, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough("NO", left + 2, 10, Chrome.DEFAULT_BOX_PALETTE) local index = self.phase == "ask-nickname" and self.nicknameIndex or self.phase == "ask-shift" and self.shiftIndex or self.phase == "ask-next-mon" and self.nextMonIndex or self.forgetChoice - Chrome.cursor(left + 1, index == 1 and 8 or 10) + Chrome.cursorThrough(left + 1, index == 1 and 8 or 10, + Chrome.DEFAULT_BOX_PALETTE) end end if self.phase == "stats-box" and self.statsBoxMon then @@ -3808,8 +3821,9 @@ function BattleState:drawStatsBox(mon) Chrome.textbox(9, 0, 9, 10) for i, row in ipairs(STATS_BOX_ROWS) do local ty = 1 + (i - 1) * 2 - Chrome.print(Strings(row[1]), 11, ty) - Chrome.printRight(("%d"):format(stats[row[2]] or 0), 19, ty + 1) + Chrome.printThrough(Strings(row[1]), 11, ty, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printRightThrough(("%d"):format(stats[row[2]] or 0), 19, ty + 1, + Chrome.DEFAULT_BOX_PALETTE) end end @@ -3893,8 +3907,7 @@ end function BattleState:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) G.push() diff --git a/src/ui/gen2/BoxMenu.lua b/src/ui/gen2/BoxMenu.lua index 027ccf42..9847ca13 100644 --- a/src/ui/gen2/BoxMenu.lua +++ b/src/ui/gen2/BoxMenu.lua @@ -945,8 +945,7 @@ end function BoxMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/CardFlip.lua b/src/ui/gen2/CardFlip.lua index 88e7a170..3b777f0f 100644 --- a/src/ui/gen2/CardFlip.lua +++ b/src/ui/gen2/CardFlip.lua @@ -968,8 +968,7 @@ end function CardFlip:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/CenterPcMenu.lua b/src/ui/gen2/CenterPcMenu.lua index 222f7213..3007170b 100644 --- a/src/ui/gen2/CenterPcMenu.lua +++ b/src/ui/gen2/CenterPcMenu.lua @@ -309,8 +309,7 @@ end function CenterPcMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/Chrome.lua b/src/ui/gen2/Chrome.lua index 6a147077..58141124 100644 --- a/src/ui/gen2/Chrome.lua +++ b/src/ui/gen2/Chrome.lua @@ -27,13 +27,39 @@ Chrome.SCREEN_H = 18 -- charmap.asm "¥", the money field's own prefix tile. 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 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) end +-- Every drawWidescreen paints the window around its 160x144 panel through +-- here, so UI LETTERBOX has exactly one place to override. r/g/b is what the +-- screen was authored with and is what AUTO keeps. +local function letterboxPaper() + local c = GbcPalette.resolve(Chrome.DEFAULT_BOX_PALETTE) + c = c and c[1] + if not c then return nil end + return c[1] / 255, c[2] / 255, c[3] / 255 +end + +function Chrome.letterbox(winW, winH, r, g, b) + local Letterbox = require("src.render.Letterbox") + local G = love.graphics + G.setColor(Letterbox.fill(r or 1, g or 1, b or 1, letterboxPaper)) + G.rectangle("fill", 0, 0, winW, winH) +end + +function Chrome.clear() + Chrome.paletteFill(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) +end + -- The blit scale every `drawWidescreen` paints its 160x144 panel at. -- -- One GB pixel has to cover a WHOLE number of window pixels or the 8x8 grid @@ -77,10 +103,24 @@ function Chrome.positionLift(winW, winH, scale) or Chrome.fitScale(winW, winH)), ScreenPosition.safeTop()) end +Chrome.DEFAULT_BOX_PALETTE = { + { 255, 255, 255 }, { 255, 255, 255 }, { 255, 255, 255 }, { 0, 0, 0 }, +} + +function Chrome.paletteBox(tx, ty, tw, th, palette) + palette = palette or Chrome.DEFAULT_BOX_PALETTE + if GbcPalette.available() then + love.graphics.setColor(1, 1, 1, 1) + GbcPalette.with(palette, function() Font.drawBox(tx, ty, tw, th) end) + else + Font.drawBox(tx, ty, tw, th, palette[1]) + end + love.graphics.setColor(0, 0, 0, 1) +end + -- A bordered box, tile coords. Leaves the draw color black for text. function Chrome.box(tx, ty, tw, th) - Font.drawBox(tx, ty, tw, th) - love.graphics.setColor(0, 0, 0, 1) + Chrome.paletteBox(tx, ty, tw, th) end -- 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) end -function Chrome.print(text, tx, ty) +local function flatPrint(text, tx, ty) love.graphics.setColor(0, 0, 0, 1) return Font.draw(text, tx * 8, ty * 8) end +function Chrome.print(text, tx, ty) + if not GbcPalette.available() then return flatPrint(text, tx, ty) end + return Chrome.printThrough(text, tx, ty, Chrome.DEFAULT_BOX_PALETTE) +end + -- Prints a string the way a tilemap screen does: the glyph tiles replace the -- cells they land on, and both their ink and their blank pixels go through -- that screen's own BG palette. @@ -124,32 +169,39 @@ function Chrome.throughPalette(palette, invert) return GbcPalette.remap(pal, GbcPalette.bgp) end --- `palette` is a 4-colour table of 0-255 triples. `invert` is what --- Chrome.printInverted passes; see there. -function Chrome.printThrough(text, tx, ty, palette, invert) +function Chrome.rawPalette(palette, invert) + if invert then return { palette[4], palette[3], palette[2], palette[1] } end + return palette +end + +-- The per-glyph shader/tint switch printThrough needs, factored out so a +-- caller that positions its own glyphs (TextBox's typewriter/scroll +-- animation) gets the same shaded ink without re-deriving the TTF-vs-tile +-- split. +-- +-- Returns (pal, drawGlyph, finish): +-- pal the resolved ramp, or nil if there's no shader to draw +-- through; a caller that also draws a paper rect should skip +-- it and take the plain Chrome.print degrade instead. +-- drawGlyph (code, x, y): draws one glyph, shaded through `pal` for a +-- tile glyph or tinted with pal[4] for a TTF one (see the +-- inline comment below, gen1recomp#1642). +-- finish call once after the last glyph to restore the previous +-- shader and draw colour. +function Chrome.paletteGlyphs(palette, invert, raw) if not (palette and GbcPalette.available()) then - return Chrome.print(text, tx, ty) + return nil, Font.drawCode, function() end end -- `pal` is used below and never touches the caller's palette again -- -- including useRaw for the draw, since useRaw skips the fold GbcPalette.use -- would otherwise apply and folding it a second time would undo this. - local pal = Chrome.throughPalette(palette, invert) - local width = Font.width(text) - local paper = pal[1] or { 255, 255, 255 } - love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) - love.graphics.rectangle("fill", tx * 8, ty * 8, width, 8) - -- Per glyph, not per string: a TTF-mod build still keeps the multi-byte - -- charmap sequences (the naming screen's /, 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 pal = raw and Chrome.rawPalette(palette, invert) + or Chrome.throughPalette(palette, invert) local ink = pal[4] or { 0, 0, 0 } local previous = love.graphics.getShader() local shaded = false - local pen = tx * 8 - for _, code in ipairs(Font.encode(text)) do + local function drawGlyph(code, x, y) if code >= Font.TTF_BASE then - -- The shader below recovers a shade from the RED CHANNEL of an already -- flat-shaded 2bpp tile sheet -- exactly what a TTF glyph is not. -- LÖVE's font rasterizer stores glyph coverage as alpha over a plain -- white texture, which this shader reads back as shade 0 no matter how @@ -167,11 +219,28 @@ function Chrome.printThrough(text, tx, ty, palette, invert) GbcPalette.useRaw(pal) shaded = true 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) end - if shaded then love.graphics.setShader(previous) end - love.graphics.setColor(0, 0, 0, 1) + finish() return width end @@ -186,12 +255,34 @@ end -- Right-aligned within a field that ends at tile `txEnd` (exclusive), which is -- 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) love.graphics.setColor(0, 0, 0, 1) return Font.draw(text, txEnd * 8 - width, ty * 8) end +function Chrome.printRight(text, txEnd, ty) + if not GbcPalette.available() then return flatPrintRight(text, txEnd, ty) end + return Chrome.printRightThrough(text, txEnd, ty, Chrome.DEFAULT_BOX_PALETTE) +end + +function Chrome.printRightThrough(text, txEnd, ty, palette, invert, raw) + local pal, drawGlyph, finish = Chrome.paletteGlyphs(palette, invert, raw) + if not pal then return Chrome.printRight(text, txEnd, ty) end + local width = Font.width(text) + local tx = txEnd * 8 - width + local paper = pal[1] or { 255, 255, 255 } + love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + love.graphics.rectangle("fill", tx, ty * 8, width, 8) + local pen = tx + for _, code in ipairs(Font.encode(text)) do + drawGlyph(code, pen, ty * 8) + pen = pen + Font.advanceOf(code) + end + finish() + return width +end + -- Wrap text to `width` tiles, measuring with the real font so a proportional -- page wraps where it actually overflows. Nothing on a 160px screen may print -- past tile 20: text that does is drawn outside the GB frame entirely. @@ -227,21 +318,27 @@ function Chrome.printWrapped(text, tx, ty, width, rows) return #lines end -function Chrome.cursor(tx, ty, hollow) +local function flatCursor(tx, ty, hollow) love.graphics.setColor(0, 0, 0, 1) Font.drawCode(hollow and Chrome.CURSOR_HOLLOW or Chrome.CURSOR, tx * 8, ty * 8) end +function Chrome.cursor(tx, ty, hollow) + if not GbcPalette.available() then return flatCursor(tx, ty, hollow) end + return Chrome.cursorThrough(tx, ty, Chrome.DEFAULT_BOX_PALETTE, nil, hollow) +end + -- The cursor glyph through a palette, the way Chrome.printThrough draws text. -- A screen whose font page is inverted (the #DEX: Pokedex_LoadInvertedFont -- xors both bitplanes, so it runs white on black) needs its cursor inverted -- with everything else -- Chrome.cursor's flat black is invisible against that -- ground, which is what made the dex's action arrow look absent. -function Chrome.cursorThrough(tx, ty, palette, invert, hollow) +function Chrome.cursorThrough(tx, ty, palette, invert, hollow, raw) if not (palette and GbcPalette.available()) then return Chrome.cursor(tx, ty, hollow) 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 } love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) love.graphics.rectangle("fill", tx * 8, ty * 8, 8, 8) @@ -286,8 +383,8 @@ end -- PRINTNUM_LEADINGZEROS. function Chrome.coinBalanceBox(coins) Chrome.textbox(11, 0, 7, 1) - Chrome.print("COIN", 12, 0) - Chrome.print(Chrome.number(coins, 4, true), 13, 1) + Chrome.printThrough("COIN", 12, 0, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.number(coins, 4, true), 13, 1, Chrome.DEFAULT_BOX_PALETTE) end -- 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). function Chrome.moneyAndCoinBalanceBox(money, coins) Chrome.textbox(5, 0, 13, 3) - Chrome.print("MONEY", 6, 1) - Chrome.print(Chrome.money(money), 12, 1) - Chrome.print("COIN", 6, 3) - Chrome.print(Chrome.number(coins, 4, true), 15, 3) + Chrome.printThrough("MONEY", 6, 1, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough("COIN", 6, 3, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.number(coins, 4, true), 15, 3, Chrome.DEFAULT_BOX_PALETTE) end -- PlaceMoneyTopRight: MoneyTopRightMenuHeader is `menu_coords 11, 0, @@ -306,7 +403,7 @@ end -- prints the number at MenuBoxCoord2Tile + SCREEN_WIDTH + 1, i.e. (12,1). function Chrome.moneyBalanceBox(money) Chrome.box(11, 0, 9, 3) - Chrome.print(Chrome.money(money), 12, 1) + Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE) end -- A vertical cursor list. @@ -344,6 +441,7 @@ function List.new(opts) self.onChoose = opts.onChoose self.onCancel = opts.onCancel self.onMove = opts.onMove + self.palette = opts.palette == nil and Chrome.DEFAULT_BOX_PALETTE or opts.palette self.index = math.max(1, math.min(opts.index or 1, math.max(1, #self.items))) self.scroll = 0 self:ensureVisible() @@ -409,15 +507,32 @@ function List:draw() local item = self.items[i] if item then local ty = self.y + (row - 1) * self.spacing - if i == self.index then Chrome.cursor(self.x - 1, ty) end - Chrome.print(item.label, self.x, ty) + if i == self.index then + if self.palette then + Chrome.cursorThrough(self.x - 1, ty, self.palette) + else + flatCursor(self.x - 1, ty) + end + end + if self.palette then + Chrome.printThrough(item.label, self.x, ty, self.palette) + else + flatPrint(item.label, self.x, ty) + end end end -- Scrolling lists get the ▼ hint Gold shows when there is more below. if self.rows < #self.items and self.scroll + self.rows < #self.items then - love.graphics.setColor(0, 0, 0, 1) - Font.drawCode(Chrome.DOWN_ARROW, (self.x - 1) * 8, - (self.y + self.rows * self.spacing - 1) * 8) + if self.palette then + local _, drawGlyph, finish = Chrome.paletteGlyphs(self.palette) + drawGlyph(Chrome.DOWN_ARROW, (self.x - 1) * 8, + (self.y + self.rows * self.spacing - 1) * 8) + finish() + else + love.graphics.setColor(0, 0, 0, 1) + Font.drawCode(Chrome.DOWN_ARROW, (self.x - 1) * 8, + (self.y + self.rows * self.spacing - 1) * 8) + end end end diff --git a/src/ui/gen2/ContestMenu.lua b/src/ui/gen2/ContestMenu.lua index 52f5a52d..d644b7d4 100644 --- a/src/ui/gen2/ContestMenu.lua +++ b/src/ui/gen2/ContestMenu.lua @@ -176,8 +176,7 @@ end function ContestMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/Credits.lua b/src/ui/gen2/Credits.lua index 8ab716e0..11405ca9 100644 --- a/src/ui/gen2/Credits.lua +++ b/src/ui/gen2/Credits.lua @@ -841,8 +841,7 @@ end function Credits:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(0, 0, 0, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 0, 0, 0) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/CrystalIntro.lua b/src/ui/gen2/CrystalIntro.lua index 9611403b..7a22cf0b 100644 --- a/src/ui/gen2/CrystalIntro.lua +++ b/src/ui/gen2/CrystalIntro.lua @@ -1275,8 +1275,7 @@ end function CrystalIntro:drawWidescreen(winW, winH) local G = love.graphics local fill = self:surroundColor() - G.setColor(fill[1] / 255, fill[2] / 255, fill[3] / 255, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, fill[1] / 255, fill[2] / 255, fill[3] / 255) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/CrystalSplash.lua b/src/ui/gen2/CrystalSplash.lua index 6436a6fa..acd03cd9 100644 --- a/src/ui/gen2/CrystalSplash.lua +++ b/src/ui/gen2/CrystalSplash.lua @@ -410,8 +410,7 @@ end function CrystalSplash:drawWidescreen(winW, winH) local G = love.graphics local backdrop = GbcPalette.color(self.bgColors, 1) or BLACK - G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/DecorationMenu.lua b/src/ui/gen2/DecorationMenu.lua index f8c100f6..6cd77472 100644 --- a/src/ui/gen2/DecorationMenu.lua +++ b/src/ui/gen2/DecorationMenu.lua @@ -263,8 +263,7 @@ end function DecorationMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/Diploma.lua b/src/ui/gen2/Diploma.lua index a8dd55ce..73ea6ba3 100644 --- a/src/ui/gen2/Diploma.lua +++ b/src/ui/gen2/Diploma.lua @@ -180,8 +180,7 @@ function Diploma:drawWidescreen(winW, winH) -- Nil palette degrades to the DMG ramp's colour 1, i.e. white, matching the -- Chrome.clear() the panel falls back to when the cache has no gfx/diploma. local paper = GbcPalette.color(self:palette(), 1) - G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, paper[1] / 255, paper[2] / 255, paper[3] / 255) G.setColor(1, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) diff --git a/src/ui/gen2/EggHatchAnim.lua b/src/ui/gen2/EggHatchAnim.lua index 931fd3b4..af8cde8a 100644 --- a/src/ui/gen2/EggHatchAnim.lua +++ b/src/ui/gen2/EggHatchAnim.lua @@ -440,8 +440,7 @@ end function EggHatchAnim:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/EvolutionAnim.lua b/src/ui/gen2/EvolutionAnim.lua index 753d2880..80ad94be 100644 --- a/src/ui/gen2/EvolutionAnim.lua +++ b/src/ui/gen2/EvolutionAnim.lua @@ -567,8 +567,7 @@ end function EvolutionAnim:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/GameFreakPresents.lua b/src/ui/gen2/GameFreakPresents.lua index c7a4650f..9d7ec584 100644 --- a/src/ui/gen2/GameFreakPresents.lua +++ b/src/ui/gen2/GameFreakPresents.lua @@ -369,8 +369,7 @@ end function GameFreakPresents:drawWidescreen(winW, winH) local G = love.graphics local backdrop = GbcPalette.color(self.bgColors, 1) or BLACK - G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/GenderSelect.lua b/src/ui/gen2/GenderSelect.lua index 07aca9b9..97ed3bdf 100644 --- a/src/ui/gen2/GenderSelect.lua +++ b/src/ui/gen2/GenderSelect.lua @@ -125,8 +125,7 @@ end function GenderSelect:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) G.push() diff --git a/src/ui/gen2/GoldSilverIntro.lua b/src/ui/gen2/GoldSilverIntro.lua index 1d6aabb3..65ba1310 100644 --- a/src/ui/gen2/GoldSilverIntro.lua +++ b/src/ui/gen2/GoldSilverIntro.lua @@ -1029,8 +1029,7 @@ end function GoldSilverIntro:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(0, 0, 0, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 0, 0, 0) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/HallOfFame.lua b/src/ui/gen2/HallOfFame.lua index d1940f67..761e9dea 100644 --- a/src/ui/gen2/HallOfFame.lua +++ b/src/ui/gen2/HallOfFame.lua @@ -668,8 +668,7 @@ end function HallOfFame:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(0, 0, 0, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 0, 0, 0) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/InitClock.lua b/src/ui/gen2/InitClock.lua index b957c956..c86e36ce 100644 --- a/src/ui/gen2/InitClock.lua +++ b/src/ui/gen2/InitClock.lua @@ -358,8 +358,7 @@ end function InitClock:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) G.push() diff --git a/src/ui/gen2/ItemPcMenu.lua b/src/ui/gen2/ItemPcMenu.lua index 0bdfaec8..9498f11a 100644 --- a/src/ui/gen2/ItemPcMenu.lua +++ b/src/ui/gen2/ItemPcMenu.lua @@ -598,8 +598,7 @@ end function ItemPcMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/MagnetTrainRide.lua b/src/ui/gen2/MagnetTrainRide.lua index f9f40830..52d3d5e1 100644 --- a/src/ui/gen2/MagnetTrainRide.lua +++ b/src/ui/gen2/MagnetTrainRide.lua @@ -444,8 +444,7 @@ function MagnetTrainRide:drawsWidescreen() return true end function MagnetTrainRide:drawWidescreen(winW, winH) local G = love.graphics local backdrop = self:backdrop() - G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255) G.setColor(1, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) diff --git a/src/ui/gen2/MailCompose.lua b/src/ui/gen2/MailCompose.lua index 4101c4c9..5ad8c25c 100644 --- a/src/ui/gen2/MailCompose.lua +++ b/src/ui/gen2/MailCompose.lua @@ -336,8 +336,7 @@ end function MailCompose:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(0.62, 0.62, 0.62, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 0.62, 0.62, 0.62) G.setColor(1, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() diff --git a/src/ui/gen2/MailRead.lua b/src/ui/gen2/MailRead.lua index b96c857a..a0ff3237 100644 --- a/src/ui/gen2/MailRead.lua +++ b/src/ui/gen2/MailRead.lua @@ -90,8 +90,7 @@ end function MailRead:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/MainMenu.lua b/src/ui/gen2/MainMenu.lua index da0d42e9..0092de39 100644 --- a/src/ui/gen2/MainMenu.lua +++ b/src/ui/gen2/MainMenu.lua @@ -215,8 +215,7 @@ end function MainMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/MoveTutor.lua b/src/ui/gen2/MoveTutor.lua index ad6f0dc4..a7f9bfc3 100644 --- a/src/ui/gen2/MoveTutor.lua +++ b/src/ui/gen2/MoveTutor.lua @@ -150,8 +150,7 @@ end -- edge, at the one integer scale Chrome.fitScale picks. function MoveTutor:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/NamePick.lua b/src/ui/gen2/NamePick.lua index dec13836..ca6a8ee1 100644 --- a/src/ui/gen2/NamePick.lua +++ b/src/ui/gen2/NamePick.lua @@ -239,8 +239,7 @@ end function NamePick:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/NamingScreen.lua b/src/ui/gen2/NamingScreen.lua index 6cca1deb..f5cc0087 100644 --- a/src/ui/gen2/NamingScreen.lua +++ b/src/ui/gen2/NamingScreen.lua @@ -566,8 +566,7 @@ function NamingScreen:drawWidescreen(winW, winH) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) local paper = self:paperColor() - G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, paper[1] / 255, paper[2] / 255, paper[3] / 255) G.setColor(1, 1, 1, 1) if self.tiles.border then local tilesX = math.ceil(winW / (8 * scale)) diff --git a/src/ui/gen2/OakSpeech.lua b/src/ui/gen2/OakSpeech.lua index 230693a1..15d5883c 100644 --- a/src/ui/gen2/OakSpeech.lua +++ b/src/ui/gen2/OakSpeech.lua @@ -719,8 +719,7 @@ end function OakSpeech:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) G.push() diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index 42218d21..c2a9b269 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -6,7 +6,7 @@ -- 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 --- 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 -- 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 Logger = require("src.core.Logger") +local Marquee = require("src.ui.Marquee") +local Palette = require("src.render.Palette") local Performance = require("src.core.Performance") local Runtime = require("src.mods.Runtime") 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)) 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 -- the exact strings the cart prints (trailing spaces included -- they are what -- blank the longer previous value, e.g. "MID " over "SLOW"). @@ -191,24 +232,30 @@ local ROWS = { text = function(options) return require("src.render.Tilt").levelLabel(options.tilt or 0) 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, - 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) + local name = Palette.label(options.palette) + if name then return name end return require("src.render.GbcPalette").modeLabel(options.color or "gbc") + end, + activate = function(game) + require("src.ui.Screens").push(game, "PaletteScreen", colorPickerOpts(game)) end }, -- 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) -- -- ShaderFXScreen is the shared list screen both generations push, `id` -- matching the Gen 1 row's so a mod filtering "shaderfx" on Red also -- 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, text = function(options) local ShaderFX = require("src.render.ShaderFX") @@ -294,7 +341,7 @@ local ROWS = { { label = Strings.source("BATTLE BG"), key = "battleBg", port = true, values = { "white", "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 @@ -303,6 +350,69 @@ local ROWS = { -- only when the cursor would leave it. 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. local function sameRows(_, rows) return rows end @@ -323,8 +433,17 @@ local function buildRows() local showTouch = env == "1" or (env ~= "0" and (osName == "Android" or osName == "iOS")) for i, row in ipairs(ROWS) do - if showTouch or (row.id ~= "touchControls" and row.id ~= "touchLayout" - and row.id ~= "haptics") then + -- PRINT is wGBPrinterBrightness, how dark the Game Boy Printer prints + -- (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 = {} 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 @@ -338,6 +457,22 @@ function OptionsMenu:wantsFillScale() return true end function OptionsMenu:drawsWidescreen() return true end -- 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) opts = opts or {} local self = setmetatable({}, OptionsMenu) @@ -363,21 +498,57 @@ function OptionsMenu.new(game, opts) self.onDone = opts.onDone self.index = 1 self.scroll = 0 + self.view = groupView(rows, function(_, members) + return pushGroup(self, members) + end) return self end +function OptionsMenu:visible() + return self.view or self.rows +end + function OptionsMenu:ensureVisible() + local rows = self:visible() if self.index <= self.scroll then self.scroll = self.index - 1 elseif self.index > self.scroll + VISIBLE_ROWS then self.scroll = self.index - VISIBLE_ROWS end 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 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 function OptionsMenu:cycle(row, delta) @@ -437,12 +608,13 @@ function OptionsMenu:update(_dt) self:leave_() return end + local rows = self:visible() 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() return 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() return end @@ -473,35 +645,46 @@ function OptionsMenu:drawPanel() Chrome.clear() -- hlcoord 0,0 with b = SCREEN_HEIGHT - 2, c = SCREEN_WIDTH - 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 row = self.rows[i] + local row = rows[i] if row then 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 Chrome.print(Strings(":TYPE"), 10, labelY + 1) Chrome.print(tostring(self.options.frame or 1), 16, labelY + 1) elseif row.text then 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 Chrome.print(":", 10, labelY + 1) local value = self.options[row.key] 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 -- 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 local ok, text = pcall(row.value, self.game) 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 Chrome.cursor(1, 2 + (self.index - self.scroll - 1) * 2) -- 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") love.graphics.setColor(0, 0, 0, 1) Font.drawCode(Chrome.DOWN_ARROW, 1 * 8, (2 + VISIBLE_ROWS * 2 - 1) * 8) @@ -514,8 +697,7 @@ end function OptionsMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/PackGfx.lua b/src/ui/gen2/PackGfx.lua index 68103dc8..2dde75d8 100644 --- a/src/ui/gen2/PackGfx.lua +++ b/src/ui/gen2/PackGfx.lua @@ -21,6 +21,7 @@ local Assets = require("src.render.Assets") local GbcPalette = require("src.render.GbcPalette") +local Chrome = require("src.ui.gen2.Chrome") local PackGfx = {} PackGfx.__index = PackGfx @@ -111,9 +112,7 @@ end -- Everything behind the item list: the header strip, the background column, -- the bag picture for this pocket, and the pocket plaque. function PackGfx:draw(pocketId) - local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, SCREEN_W * 8, SCREEN_H * 8) + Chrome.paletteFill(0, 0, SCREEN_W * 8, SCREEN_H * 8, Chrome.DEFAULT_BOX_PALETTE) -- ◀▶ POCKET ▼▲ ITEMS: 20 running tiles from $28. local header = self.gfx.headerFirstTile or 0x28 diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index 3a5d970c..4c17968c 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -973,22 +973,22 @@ function PackMenu:drawList(listX, listY) local entry = self.rows[i] -- engine/items/tmhm.asm:355-385 (#1695) if entry.tmhmLabel then - Chrome.print(entry.tmhmLabel, listX - 3, ty) + Chrome.printThrough(entry.tmhmLabel, listX - 3, ty, Chrome.DEFAULT_BOX_PALETTE) end if i == self.index then self:cursorAt(listX - 1, ty) elseif i == self.switching then self:cursorAt(listX - 1, ty, true) end - Chrome.print((entry.tmhmLabel and entry.teaches) or entry.name, - listX, ty) + Chrome.printThrough((entry.tmhmLabel and entry.teaches) or entry.name, + listX, ty, Chrome.DEFAULT_BOX_PALETTE) if entry.showCount then - Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2), - listX + 9, ty + 1) + Chrome.printThrough("\xc3\x97" .. Chrome.number(entry.count, 2), + listX + 9, ty + 1, Chrome.DEFAULT_BOX_PALETTE) end elseif i == self:total() then 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 @@ -1006,7 +1006,8 @@ function PackMenu:drawDescription(ty) local top, step = ty, 2 if #lines > 2 then top, step = ty - 1, 1 end 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 return end @@ -1019,8 +1020,8 @@ function PackMenu:drawDescription(ty) -- the second line is row 16. '\n' covers hand-written data. local first, second = description:match("^(.-)(.*)$") if not first then first, second = description:match("^(.-)\n(.*)$") end - Chrome.print(first or description, 1, ty) - if second then Chrome.print(second, 1, ty + 2) end + Chrome.printThrough(first or description, 1, ty, Chrome.DEFAULT_BOX_PALETTE) + if second then Chrome.printThrough(second, 1, ty + 2, Chrome.DEFAULT_BOX_PALETTE) end end -- 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) for i, id in ipairs(menu.rows) do local ty = top + 1 + (i - 1) * 2 - if i == menu.index then Chrome.cursor(1, ty) end - Chrome.print(SUBMENU_LABEL[id] or id, 2, ty) + if i == menu.index then Chrome.cursorThrough(1, ty, Chrome.DEFAULT_BOX_PALETTE) end + Chrome.printThrough(SUBMENU_LABEL[id] or id, 2, ty, Chrome.DEFAULT_BOX_PALETTE) end end -- engine/items/buy_sell_toss.asm:133 BuySellToss_UpdateQuantityDisplay function PackMenu:drawQuantity() 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 -- YesNoBox's own coords, the same box every other Gen 2 screen here draws. function PackMenu:drawYesNo() Chrome.box(14, 7, 6, 5) - Chrome.print("YES", 16, 8) - Chrome.print("NO", 16, 10) - Chrome.cursor(15, self.confirm.choice == 1 and 8 or 10) + Chrome.printThrough("YES", 16, 8, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough("NO", 16, 10, Chrome.DEFAULT_BOX_PALETTE) + Chrome.cursorThrough(15, self.confirm.choice == 1 and 8 or 10, Chrome.DEFAULT_BOX_PALETTE) end function PackMenu:drawOverlays() @@ -1080,7 +1082,7 @@ function PackMenu:drawPanel() -- boxes, the layout this screen shipped with. Chrome.clear() 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) self:drawList(5, 4) Chrome.box(0, 12, 20, 6) @@ -1095,8 +1097,7 @@ end function PackMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/PartyMenu.lua b/src/ui/gen2/PartyMenu.lua index cefa59aa..1caa3151 100644 --- a/src/ui/gen2/PartyMenu.lua +++ b/src/ui/gen2/PartyMenu.lua @@ -845,8 +845,7 @@ end function PartyMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/PcMenu.lua b/src/ui/gen2/PcMenu.lua index a7fdd744..05717c55 100644 --- a/src/ui/gen2/PcMenu.lua +++ b/src/ui/gen2/PcMenu.lua @@ -456,8 +456,7 @@ end function PcMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/PhotoStudio.lua b/src/ui/gen2/PhotoStudio.lua index d719e709..dd4dee58 100644 --- a/src/ui/gen2/PhotoStudio.lua +++ b/src/ui/gen2/PhotoStudio.lua @@ -202,8 +202,7 @@ function PhotoStudio:drawsWidescreen() return true end function PhotoStudio:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) G.push() diff --git a/src/ui/gen2/PokedexMenu.lua b/src/ui/gen2/PokedexMenu.lua index a0c4a685..3b023465 100644 --- a/src/ui/gen2/PokedexMenu.lua +++ b/src/ui/gen2/PokedexMenu.lua @@ -1458,8 +1458,7 @@ end function PokedexMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(0, 0, 0, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 0, 0, 0) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/Pokegear.lua b/src/ui/gen2/Pokegear.lua index 242a3515..e5ac4eca 100644 --- a/src/ui/gen2/Pokegear.lua +++ b/src/ui/gen2/Pokegear.lua @@ -22,6 +22,7 @@ local Chrome = require("src.ui.gen2.Chrome") local FieldMoves = require("src.world.gen2.FieldMoves") +local GbcPalette = require("src.render.GbcPalette") local Gen2Save = require("src.core.gen2.Save") local Clock = require("src.core.gen2.Clock") local Font = require("src.render.Font") @@ -971,6 +972,7 @@ function Pokegear.new(game, opts) if gfx then self.sheet = TileSheet.new({ path = gfx.tiles, wide = gfx.tilesWide or 16, firstTile = 0, + raw = true, paletteFor = function(tile) return self:colorsFor(tile) end, }) end @@ -997,10 +999,9 @@ end -- 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 --- than drawing as black ink over whatever was underneath. function Pokegear:text(str, tx, ty) 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 -- wPokegearFlags' four card bits are ENGINE flags: EngineFlags rows 0-3 are @@ -2411,6 +2412,8 @@ function Pokegear:drawPlain() end end +Pokegear.CUSTOM_RAMP_FILM = true + function Pokegear:drawPanel() if not self:styled() then self:drawPlain() @@ -2418,29 +2421,54 @@ function Pokegear:drawPanel() return end local G = love.graphics - -- InitPokegearTilemap ByteFills the whole SCREEN_AREA with $4f before the - -- card's tilemap goes down (engine/pokegear/pokegear.asm InitPokegearTilemap), - -- and every cell the card leaves blank is a font-page tile on palette 0, so - -- the ground under a card is the gear's paper, not white. See - -- Pokegear:paperColor. - local ground = self:groundColor() - G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1) - G.rectangle("fill", 0, 0, SCREEN_W * 8, SCREEN_H * 8) - local id = self:card() and self:card().id - if id == "map" then - self:drawMap() - elseif id == "radio" then - self:drawRadio() - elseif id == "phone" then - self:drawPhone() - else - self:drawClock() + local function paint() + -- InitPokegearTilemap ByteFills the whole SCREEN_AREA with $4f before the + -- card's tilemap goes down (engine/pokegear/pokegear.asm InitPokegearTilemap), + -- and every cell the card leaves blank is a font-page tile on palette 0, so + -- the ground under a card is the gear's paper, not white. See + -- Pokegear:paperColor. + local ground = self:groundColor() + G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1) + G.rectangle("fill", 0, 0, SCREEN_W * 8, SCREEN_H * 8) + local id = self:card() and self:card().id + if id == "map" then + self:drawMap() + elseif id == "radio" then + self:drawRadio() + elseif id == "phone" then + self:drawPhone() + 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 - -- 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) end @@ -2454,8 +2482,7 @@ function Pokegear:drawWidescreen(winW, winH) -- black card sitting inside a cream frame. It follows groundColor for the -- same reason drawPanel does: $4f is what the screen is filled with. local ground = self:groundColor() - G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, ground[1] / 255, ground[2] / 255, ground[3] / 255) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) G.push() diff --git a/src/ui/gen2/PrizeMenu.lua b/src/ui/gen2/PrizeMenu.lua index 41f43756..168b4473 100644 --- a/src/ui/gen2/PrizeMenu.lua +++ b/src/ui/gen2/PrizeMenu.lua @@ -556,8 +556,7 @@ end function PrizeMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/SaveMenu.lua b/src/ui/gen2/SaveMenu.lua index bbcfd70e..8b2ff4b8 100644 --- a/src/ui/gen2/SaveMenu.lua +++ b/src/ui/gen2/SaveMenu.lua @@ -260,8 +260,7 @@ end function SaveMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/SlotMachine.lua b/src/ui/gen2/SlotMachine.lua index fe0bded1..5eb81920 100644 --- a/src/ui/gen2/SlotMachine.lua +++ b/src/ui/gen2/SlotMachine.lua @@ -1531,8 +1531,7 @@ end function SlotMachine:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/StartMenu.lua b/src/ui/gen2/StartMenu.lua index b3d90d8c..bb203096 100644 --- a/src/ui/gen2/StartMenu.lua +++ b/src/ui/gen2/StartMenu.lua @@ -295,9 +295,7 @@ function StartMenu:draw() if not desc then return end -- ._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). - local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 13 * 8, 10 * 8, 5 * 8) + Chrome.paletteFill(0, 13 * 8, 10 * 8, 5 * 8) Chrome.print(desc[1] or "", 0, 14) Chrome.print(desc[2] or "", 0, 16) end diff --git a/src/ui/gen2/SummaryMenu.lua b/src/ui/gen2/SummaryMenu.lua index 431b778f..7ce631d8 100644 --- a/src/ui/gen2/SummaryMenu.lua +++ b/src/ui/gen2/SummaryMenu.lua @@ -1266,8 +1266,7 @@ end function SummaryMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/TileSheet.lua b/src/ui/gen2/TileSheet.lua index d2907d68..d264e258 100644 --- a/src/ui/gen2/TileSheet.lua +++ b/src/ui/gen2/TileSheet.lua @@ -18,7 +18,6 @@ local TileSheet = {} TileSheet.__index = TileSheet -- opts: path, wide, firstTile, palette (a 4-colour table) or palettes + a --- paletteFor(tile, tx, ty) hook, transparent function TileSheet.new(opts) local self = setmetatable({}, TileSheet) self.path = opts and opts.path @@ -26,6 +25,7 @@ function TileSheet.new(opts) self.firstTile = (opts and opts.firstTile) or 0 self.palette = opts and opts.palette self.paletteFor = opts and opts.paletteFor + self.raw = opts and opts.raw self.quads = {} return self end @@ -79,7 +79,11 @@ function TileSheet:draw(tile, tx, ty) 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 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 body() end diff --git a/src/ui/gen2/TitleState.lua b/src/ui/gen2/TitleState.lua index 1dfdbe75..cb7db333 100644 --- a/src/ui/gen2/TitleState.lua +++ b/src/ui/gen2/TitleState.lua @@ -316,11 +316,8 @@ function TitleState:update(_dt) 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() - return GbcPalette.mode ~= "gbc" + return GbcPalette.mode == "dmg" or GbcPalette.mode == "classic" end function TitleState:art() diff --git a/src/ui/gen2/TradeAnim.lua b/src/ui/gen2/TradeAnim.lua index bd404038..76dc62d1 100644 --- a/src/ui/gen2/TradeAnim.lua +++ b/src/ui/gen2/TradeAnim.lua @@ -870,8 +870,7 @@ end function TradeAnimView:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/TrainerCard.lua b/src/ui/gen2/TrainerCard.lua index 859fc424..c05a27a7 100644 --- a/src/ui/gen2/TrainerCard.lua +++ b/src/ui/gen2/TrainerCard.lua @@ -271,16 +271,24 @@ end -- TrainerCard_PrintTopHalfOfCard runs once, in .InitRAM, and no page redraws -- it -- so the name, ID, money and portrait stay on screen behind the badge -- 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() local player = (self.save or {}).player or {} self:frame(0, 5) - Chrome.print("NAME/", 2, 2) - Chrome.print(player.name or "GOLD", 7, 2) + self:print("NAME/", 2, 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[2], 3, 4) - Chrome.print(Chrome.number(player.id or 0, 5, true), 5, 4) - Chrome.print("MONEY", 2, 6) - Chrome.print(moneyText(player.money), 7, 6) + self:print(Chrome.number(player.id or 0, 5, true), 5, 4) + self:print("MONEY", 2, 6) + self:print(moneyText(player.money), 7, 6) for x = 1, 12 do self:tile(self.card, TILE_DIVIDER, x, 3) end self:tile(self.card, TILE_DIVIDER_END, 13, 3) self:drawPortrait() @@ -298,22 +306,22 @@ function TrainerCard:drawCard() -- `#` is the compression byte for POKé, four tiles, so spelling it out is -- what the cart actually draws. - Chrome.print("POKéDEX", 2, 10) - Chrome.print("PLAY TIME", 2, 12) - Chrome.print(Chrome.number(self:caughtCount(), 3), 15, 10) + self:print("POKéDEX", 2, 10) + self:print("PLAY TIME", 2, 12) + self:print(Chrome.number(self:caughtCount(), 3), 15, 10) 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 -- sheet, and TrainerCard_Page1_PrintGameTime xors it with ' ' every 32 -- frames -- which is what makes the clock look like it is running. if math.floor(self.frames / 32) % 2 == 0 then self:tile(self.status, TILE_COLON, 15, 12) 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) - Chrome.cursor(18, 15) + self:print("BADGES", 12, 15) + self:cursor(18, 15) end -- TrainerCard_Page2_3_PlaceLeadersFaces: four tiles across the top row, then @@ -397,22 +405,22 @@ function TrainerCard:drawPlain() Chrome.clear() if self.page == 1 then Chrome.box(0, 0, 20, 9) - Chrome.print("NAME/", 2, 2) - Chrome.print(player.name or "GOLD", 7, 2) - Chrome.print("ID No", 2, 4) - Chrome.print(Chrome.number(player.id or 0, 5, true), 5, 4) - Chrome.print("MONEY", 2, 6) - Chrome.print(moneyText(player.money), 7, 6) + Chrome.printThrough("NAME/", 2, 2, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(player.name or "GOLD", 7, 2, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough("ID No", 2, 4, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.number(player.id or 0, 5, true), 5, 4, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough("MONEY", 2, 6, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(moneyText(player.money), 7, 6, Chrome.DEFAULT_BOX_PALETTE) Chrome.box(0, 8, 20, 10) - Chrome.print("POKéDEX", 2, 10) - Chrome.print(Chrome.number(self:caughtCount(), 3), 15, 10) - Chrome.print("PLAY TIME", 2, 12) + Chrome.printThrough("POKéDEX", 2, 10, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.number(self:caughtCount(), 3), 15, 10, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough("PLAY TIME", 2, 12, Chrome.DEFAULT_BOX_PALETTE) local time = save.playTime or {} - Chrome.print(Chrome.number(time.hours or 0, 4), 11, 12) - Chrome.print(":", 15, 12) - Chrome.print(Chrome.number(time.minutes or 0, 2, true), 16, 12) - Chrome.print("BADGES", 12, 15) - Chrome.cursor(18, 15) + Chrome.printThrough(Chrome.number(time.hours or 0, 4), 11, 12, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(":", 15, 12, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.number(time.minutes or 0, 2, true), 16, 12, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough("BADGES", 12, 15, Chrome.DEFAULT_BOX_PALETTE) + Chrome.cursorThrough(18, 15, Chrome.DEFAULT_BOX_PALETTE) return end 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. local held = player.badges or {} 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) for i, name in ipairs(names) do local tx = 2 + ((i - 1) % 4) * 4 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 @@ -438,9 +448,7 @@ function TrainerCard:drawPanel() love.graphics.setColor(1, 1, 1, 1) return end - local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, SCREEN_W * 8, SCREEN_H * 8) + Chrome.paletteFill(0, 0, SCREEN_W * 8, SCREEN_H * 8, Chrome.DEFAULT_BOX_PALETTE) local player = (self.save and self.save.player) or {} if self.page == 1 then self:drawCard() @@ -453,7 +461,7 @@ function TrainerCard:drawPanel() -- the same table page 2 reads. self:drawBadges(JOHTO_BADGES, player.badges or {}) end - G.setColor(1, 1, 1, 1) + love.graphics.setColor(1, 1, 1, 1) end function TrainerCard:draw() @@ -462,8 +470,7 @@ end function TrainerCard:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/UnownPrinter.lua b/src/ui/gen2/UnownPrinter.lua index 332b4ab2..b3f0ed32 100644 --- a/src/ui/gen2/UnownPrinter.lua +++ b/src/ui/gen2/UnownPrinter.lua @@ -191,8 +191,7 @@ function UnownPrinter:drawsWidescreen() return true end function UnownPrinter:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) G.push() diff --git a/src/ui/gen2/UnownPuzzle.lua b/src/ui/gen2/UnownPuzzle.lua index ff8e7d8a..262adbd2 100644 --- a/src/ui/gen2/UnownPuzzle.lua +++ b/src/ui/gen2/UnownPuzzle.lua @@ -576,8 +576,7 @@ function UnownPuzzle:drawsWidescreen() return true end function UnownPuzzle:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(0, 0, 0, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 0, 0, 0) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/kit/FileBrowser.lua b/src/ui/kit/FileBrowser.lua new file mode 100755 index 00000000..11c4f85d --- /dev/null +++ b/src/ui/kit/FileBrowser.lua @@ -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 diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua index 56adc505..e00ea4b6 100644 --- a/src/ui/kit/Kit.lua +++ b/src/ui/kit/Kit.lua @@ -43,19 +43,22 @@ local Theme = require("src.ui.kit.Theme") local PAL = Theme.PAL +local VirtualKeyboard = require("src.ui.kit.VirtualKeyboard") +local FileBrowser = require("src.ui.kit.FileBrowser") -local Kit = {} -Kit.Theme = Theme -Kit.PAL = PAL +local Kit = { + scale = 1, + 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.mouseClicked = false -- left button pressed this frame Kit.mouseDown = false -- held, polled (drag / press-and-hold) 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.blockClicks = false Kit.audit = nil @@ -339,6 +342,32 @@ function Kit.beginFrame(mx, my, clicked, wheel) Kit._navN = 0 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. -- Anything typed while no field had focus is dropped here rather than -- 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. local n = Kit._navN or 0 Kit._navPrevN = n - -- If the focused id vanished (panel switch, list repaged), park the ring - -- on the first focusable so the keyboard is never stranded. - if Kit.focusId and not Kit._navSeen[Kit.focusId] and n > 0 then - Kit.focusId = Kit._nav[1] and Kit._nav[1].id or nil + -- If the focused id vanished or not set yet, park the ring on Layer 3 (Import ROM / Play) + if (not Kit.focusId or not Kit._navSeen[Kit.focusId]) and n > 0 then + -- 1. Prefer ROM Action (Layer 3) + 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 for k in pairs(Kit._navSeen) do Kit._navSeen[k] = nil end end @@ -360,21 +405,16 @@ end -- ------------------------------------------------------------ focus ring -- Spatial navigation. Every focusable control registers its rect as it -- draws; a queued direction picks the nearest candidate in that direction --- from the previous frame's set. Spatial rather than index-order because --- 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. +-- from the previous frame's set. Kit._nav = {} Kit._navN = 0 Kit._navPrevN = 0 Kit._navSeen = {} Kit._navQueue = nil Kit._activateId = nil -Kit._ringShown = false +Kit._ringShown = true -- 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) if Kit.blockClicks then return false end 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 slot.id, slot.x, slot.y, slot.w, slot.h = id, x, y, w, h Kit._navSeen[id] = true - -- First focusable ever drawn adopts the ring, so keyboard users start - -- somewhere rather than nowhere. - if Kit.focusId == nil then Kit.focusId = id end + if Kit.focusId == nil and tostring(id):match("^rom%-") then + Kit.focusId = id + end return Kit._ringShown and Kit.focusId == id end @@ -403,10 +443,6 @@ function Kit.setFocus(id) Kit._ringShown = id ~= nil 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() local dir = Kit._navQueue Kit._navQueue = nil @@ -414,38 +450,75 @@ function Kit._resolveNav() if not dir or n == 0 then return end local cur 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 if not cur then Kit.focusId = Kit._nav[1].id return end + + local curLayer = getNavLayer(cur) local cx, cy = cur.x + cur.w / 2, cur.y + cur.h / 2 - local best, bestScore - for i = 1, n do - local c = Kit._nav[i] - if c.id ~= cur.id then - local dx = (c.x + c.w / 2) - cx - local dy = (c.y + c.h / 2) - cy - local along, across - if dir == "left" then along, across = -dx, math.abs(dy) - elseif dir == "right" then along, across = dx, math.abs(dy) - elseif dir == "up" then along, across = -dy, math.abs(dx) - else along, across = dy, math.abs(dx) end - -- A control merely overlapping on the travel axis is not "in that - -- direction"; require real separation so a tall row's neighbours do - -- not all qualify. - if along > 1 then - local score = along + across * 2 - if not bestScore or score < bestScore then best, bestScore = c, score end + + if dir == "left" or dir == "right" then + -- STRICT SAME-LAYER HORIZONTAL NAVIGATION (Left/Right NEVER jumps between layers) + local best, bestDx + for i = 1, n do + local c = Kit._nav[i] + if c.id ~= cur.id and getNavLayer(c) == curLayer then + local cMidX = c.x + c.w / 2 + local dx = cMidX - cx + if dir == "left" and dx < -1 then + local dist = -dx + if not bestDx or dist < bestDx then + best, bestDx = c, dist + end + elseif dir == "right" and dx > 1 then + local dist = dx + if not bestDx or dist < bestDx then + best, bestDx = c, dist + 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 - if best then Kit.focusId = best.id end end -- ------------------------------------------------------------ input plumbing function Kit.textinput(text) + if VirtualKeyboard.active then + return VirtualKeyboard.textinput(text) + end if not Kit.focus then return false end edits[#edits + 1] = text return true @@ -454,6 +527,12 @@ end -- 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. 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 key == "backspace" then edits[#edits + 1] = "\b" return true 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. function Kit.gamepadpressed(button) - if button == "dpup" then Kit.navigate("up") return true - elseif button == "dpdown" then Kit.navigate("down") return true - elseif button == "dpleft" then Kit.navigate("left") return true - elseif button == "dpright" then Kit.navigate("right") return true - elseif button == "a" then Kit.activateFocused() return true end + local GamepadMap = require("src.core.GamepadMap") + local action = (GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button + if VirtualKeyboard.active then + return VirtualKeyboard.gamepadpressed(action) + 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 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 -- row and a white selected one. if focused then - Theme.strokeRounded(x + 2, y + 2, w - 4, h - 4, - selected and PAL.inverse or PAL.lineStrong, Theme.A.focus, 1, - Theme.radius()) + local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5) + -- Outer white soft aura + 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 local clicked = Kit.press(x, y, w, h) 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) end 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, - w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong, - Theme.A.focus, B.ringWidth, radius + B.ringPad) + w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.ink, + 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 Theme.strokeRounded(x - B.ringPad, y - B.ringPad, 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) local focusRing = Kit.focusable(id, x, y, w, h) 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 local focused = (Kit.focus == id) if focused then diff --git a/src/ui/kit/VirtualKeyboard.lua b/src/ui/kit/VirtualKeyboard.lua new file mode 100755 index 00000000..669e3fa2 --- /dev/null +++ b/src/ui/kit/VirtualKeyboard.lua @@ -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 diff --git a/src/update/Check.lua b/src/update/Check.lua index 938889e0..2c7389b4 100644 --- a/src/update/Check.lua +++ b/src/update/Check.lua @@ -43,10 +43,13 @@ local function fullAssetName(version, osName, arch, port) return "gen1recomp-" .. version .. "-xbox-uwp.zip" elseif osName == "NX" then 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 return "gen1recomp-" .. version .. "-linux-arm64.AppImage" elseif osName == "Linux" then - return "gen1recomp-" .. version .. "-linux.zip" + return "gen1recomp-" .. version .. "-linux-x86_64.AppImage" end return nil end @@ -288,6 +291,13 @@ function Check.fullUpdateAction() "https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json" } elseif osName == "UWP" then 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 return { label = "Download full update", url = st.full.url } end diff --git a/src/update/check_worker.lua b/src/update/check_worker.lua index 896db5ab..7b8fdeb7 100644 --- a/src/update/check_worker.lua +++ b/src/update/check_worker.lua @@ -310,8 +310,13 @@ local function launchDownload(url, partAbs, doneAbs) .. "type nul > \"" .. doneAbs .. "\"\r\n") os.execute('start "" /b ' .. shq(saveDir .. "/" .. batRel)) else - -- ( ... ) & backgrounds the whole group so os.execute returns at once - os.execute("( " .. HostShell.envPrefix() .. "curl -fsSL --connect-timeout 15 --max-time 900 -o " + -- ( ... ) & backgrounds the whole group so os.execute returns at once. + -- 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) .. " ; touch " .. shq(doneAbs) .. " ) >/dev/null 2>&1 &") end diff --git a/src/world/gen2/UnownWords.lua b/src/world/gen2/UnownWords.lua index 852679eb..08900fdb 100644 --- a/src/world/gen2/UnownWords.lua +++ b/src/world/gen2/UnownWords.lua @@ -4,6 +4,7 @@ -- SpecialKabutoChamber, the four routines that open the chambers' walls. local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") local Font = require("src.render.Font") local GbcPalette = require("src.render.GbcPalette") local Palettes = require("src.world.gen2.Palettes") @@ -189,7 +190,7 @@ function UnownWords:draw() local wall = self.wall if not wall then return end 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() if not tileset then return end local perRow = tileset.tilesPerRow or 16 diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index ce3b82ec..56bbdba9 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -8217,7 +8217,8 @@ function World:mapCacheKey(mapId) local daytime = self.daytime local flicker = (daytime == "DARK") and self.flickerPhase or 1 return mapId .. "|" .. tostring(daytime) - .. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(flicker) + .. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(GbcPalette.customRamp) + .. "|" .. tostring(flicker) end function World:imageFor(mapId) @@ -8440,7 +8441,8 @@ function World:borderImageFor(mapId) -- stale bake (#1418). local waterFrame = self:borderWaterFrame(def, tileset) 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(blockId) .. "|" .. 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 -- 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 --- 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() 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.colorRamp = ramp if not self.map then return end self.mapImage = self:imageFor(self.map.id) self:rebuildNeighbors() diff --git a/tests/drivers/battle_hud_layout_lock_test.lua b/tests/drivers/battle_hud_layout_lock_test.lua index 24b44732..f0c85767 100644 --- a/tests/drivers/battle_hud_layout_lock_test.lua +++ b/tests/drivers/battle_hud_layout_lock_test.lua @@ -20,8 +20,8 @@ return function(game) end assert(layoutRow and hudRow, "battle layout/HUD rows are present") - menu.index = 6 - menu.scroll = 3 + -- BATTLE HUD lives on the BATTLE OPTIONS page now, so focus it there. + assert(menu:focusRow("battleHud"), "BATTLE HUD row is reachable") assert(hudRow.value(game) == "EXTENDED", "WIDE displays EXTENDED") assert(U.shot(game, BEFORE), "WIDE/EXTENDED screenshot was written") diff --git a/tests/drivers/custom_ramp_probe.lua b/tests/drivers/custom_ramp_probe.lua new file mode 100644 index 00000000..87662fe3 --- /dev/null +++ b/tests/drivers/custom_ramp_probe.lua @@ -0,0 +1,46 @@ +-- Diagnostic probe: forces a loud custom COLORS ramp (hot pink paper, blue +-- ink) and shoots StartMenu, PartyMenu, and PackMenu, so a gap between +-- those screens and the rest of the UI can be checked by eye. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/custom_ramp_probe.lua love . +local U = require("tests.drivers.util") + +local GbcPalette = require("src.render.GbcPalette") +local StartMenu = require("src.ui.gen2.StartMenu") +local PartyMenu = require("src.ui.gen2.PartyMenu") +local PackMenu = require("src.ui.gen2.PackMenu") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/custom-ramp-probe" + + local function shot(name) + U.wait(3) + U.shot(game, ("%s/%s.png"):format(out, name)) + end + + local function show(name, state) + game.stack:push(state) + shot(name) + game.stack:pop() + end + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + print("[driver] GbcPalette.available() = " .. tostring(GbcPalette.available())) + + GbcPalette.setCustomRamp({ + { 255, 100, 220 }, { 220, 60, 180 }, { 140, 20, 120 }, { 20, 0, 60 }, + }) + print("[driver] customRamp set, mode=" .. tostring(GbcPalette.mode)) + + local save = game.save + save.party = save.party or {} + save.inventory = { POTION = 5, SUPER_POTION = 2, ANTIDOTE = 1 } + + show("01-startmenu-custom-ramp", StartMenu.new(game, { save = save })) + show("02-party-custom-ramp", PartyMenu.new(game, { prompt = "choose" })) + show("03-pack-items-custom-ramp", PackMenu.new(game, { pocket = "ITEM" })) + + print("[driver] PASS custom ramp probe shots in " .. out) +end diff --git a/tests/drivers/fill_extended_auto_menu_test.lua b/tests/drivers/fill_extended_auto_menu_test.lua index c9745fc1..acf2d28a 100644 --- a/tests/drivers/fill_extended_auto_menu_test.lua +++ b/tests/drivers/fill_extended_auto_menu_test.lua @@ -28,8 +28,8 @@ return function(game) assert(bgRow.step(game, 1) == false, "AUTO background row is locked") assert(options.battleBg == "black", "locked AUTO does not overwrite the stored value") - menu.index = bgIndex - menu.scroll = math.max(0, bgIndex - 5) + -- BATTLE BG lives on the BATTLE OPTIONS page now, so focus it there. + assert(menu:focusRow("battleBg"), "BATTLE BG row is reachable") U.wait(2) local autoPath = DIR .. "/fill_extended_auto_menu.png" os.remove(autoPath) diff --git a/tests/drivers/gold_options_groups_shots.lua b/tests/drivers/gold_options_groups_shots.lua new file mode 100644 index 00000000..1d90d08d --- /dev/null +++ b/tests/drivers/gold_options_groups_shots.lua @@ -0,0 +1,30 @@ +-- The grouped Gen 2 OPTION screen: the top level, one page, and a long +-- COLOR value scrolling under the cursor. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_options_groups_shots.lua love . +local U = require("tests.drivers.util") + +return function(game) + local OptionsMenu = require("src.ui.gen2.OptionsMenu") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + U.wait(5) + local menu = OptionsMenu.new(game, { options = game.options }) + game.stack:push(menu) + U.wait(3) + U.shot(game, DIR .. "/g2_0_top.png") + + local page = menu:focusRow("color") + U.wait(2) + U.shot(game, DIR .. "/g2_1_graphics.png") + game.options.palette = "1-A (Default) (BGB)" + U.wait(2) + U.shot(game, DIR .. "/g2_2_color_start.png") + U.wait(85) + U.shot(game, DIR .. "/g2_3_color_scrolled.png") + + page.index = #page.view + U.wait(3) + U.shot(game, DIR .. "/g2_4_back.png") + U.log("GOLD_OPTIONS_GROUPS_DONE") + game.driverDone = true +end diff --git a/tests/drivers/options_groups_shots.lua b/tests/drivers/options_groups_shots.lua new file mode 100644 index 00000000..b09c10f0 --- /dev/null +++ b/tests/drivers/options_groups_shots.lua @@ -0,0 +1,33 @@ +-- Driver: the grouped OPTION screen -- the top level with its group openers, +-- one group's page, and the COLORS row scrolling its overlong value. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + local OptionsMenu = require("src.ui.OptionsMenu") + local menu = OptionsMenu.new(game) + game.stack:push(menu) + U.wait(5) + U.shot(game, DIR .. "/opt_0_top.png") + + menu:focusRow("group.battle") + U.wait(2) + U.shot(game, DIR .. "/opt_1_battle_opener.png") + U.tap(game, "a"); U.wait(3) + U.shot(game, DIR .. "/opt_2_battle_page.png") + U.tap(game, "b"); U.wait(3) + + game.save.options.palette = "1-A (Default) (BGB)" + -- COLORS is on the GRAPHICS page, so focusRow hands back that page. + local graphics = menu:focusRow("colors") + U.wait(2) + U.shot(game, DIR .. "/opt_3_colors_start.png") + U.wait(85) + U.shot(game, DIR .. "/opt_4_colors_scrolled.png") + + graphics.index = #graphics.rows + 1 -- the page's own BACK + U.wait(3) + U.shot(game, DIR .. "/opt_5_back.png") + U.log("OPTIONS_GROUPS_SHOTS_DONE") + game.driverDone = true +end diff --git a/tests/drivers/options_test.lua b/tests/drivers/options_test.lua index 02c2f532..318759b3 100644 --- a/tests/drivers/options_test.lua +++ b/tests/drivers/options_test.lua @@ -1,18 +1,21 @@ -- Driver: the options screen with the port's audio rows (MUSIC VOL / -- SFX VOL / MUSIC FILTER) to prove the 4-box viewport, the ▼ scroll --- marker, and CANCEL fixed on the bottom line. The menu is pushed --- directly (title-menu row order shifts when a save file exists, so --- blind taps are unreliable). +-- marker, and BACK fixed on the bottom line. The audio rows live on the +-- AUDIO page now, so the menu focuses its way there rather than tapping +-- down a fixed number of times. The menu is pushed directly (title-menu +-- row order shifts when a save file exists, so blind taps are unreliable). return function(game) local U = dofile("tests/drivers/util.lua") local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" U.teleport(game, "PALLET_TOWN", 10, 8, "down") local OptionsMenu = require("src.ui.OptionsMenu") - game.stack:push(OptionsMenu.new(game)) + local menu = OptionsMenu.new(game) + game.stack:push(menu) U.wait(5) U.shot(game, DIR .. "/options_0_top.png") - for _ = 1, 4 do U.tap(game, "down"); U.wait(2) end - U.shot(game, DIR .. "/options_1_musicvol.png") -- scrolled, ▼ visible + local audio = menu:focusRow("musicVol") + U.wait(2) + U.shot(game, DIR .. "/options_1_musicvol.png") -- the AUDIO page U.tap(game, "left"); U.wait(2) U.tap(game, "left"); U.wait(2) U.shot(game, DIR .. "/options_2_musicvol_5.png") @@ -20,6 +23,7 @@ return function(game) U.tap(game, "down"); U.wait(2) U.tap(game, "right"); U.wait(2) -- MUSIC FILTER -> 1X U.shot(game, DIR .. "/options_3_filter_1x.png") - U.tap(game, "down"); U.wait(2) -- CANCEL, tail rows behind it - U.shot(game, DIR .. "/options_4_cancel.png") + audio.index = #audio.view + 1 -- BACK, the page's rows behind it + U.wait(2) + U.shot(game, DIR .. "/options_4_back.png") end diff --git a/tests/drivers/palette_picker_shots.lua b/tests/drivers/palette_picker_shots.lua new file mode 100644 index 00000000..aab07b2e --- /dev/null +++ b/tests/drivers/palette_picker_shots.lua @@ -0,0 +1,17 @@ +-- The COLORS picker after the palette rename: the root folder list and one +-- pack's contents. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + local Screens = require("src.ui.Screens") + Screens.push(game, "PaletteScreen") + U.wait(5) + U.shot(game, DIR .. "/pal_0_root.png") + for _ = 1, 6 do U.tap(game, "down"); U.wait(1) end + U.shot(game, DIR .. "/pal_1_scrolled.png") + U.tap(game, "a"); U.wait(4) + U.shot(game, DIR .. "/pal_2_inside.png") + U.log("PALETTE_PICKER_SHOTS_DONE") + game.driverDone = true +end diff --git a/tests/engine/flatpak_manifest_test.lua b/tests/engine/flatpak_manifest_test.lua new file mode 100644 index 00000000..d8b04ab1 --- /dev/null +++ b/tests/engine/flatpak_manifest_test.lua @@ -0,0 +1,25 @@ +-- Flatpak finish-args / packaging contract. +-- luajit tests/engine/flatpak_manifest_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check + +local f = assert(io.open("flatpak/com.theboisclub.gen1recomp.yml", "rb")) +local yml = f:read("*a") +f:close() + +check(yml:find("%-%-device=all", 1, false) or yml:find("--device=all", 1, true), + "Flatpak finish-args include --device=all for gamepads") +check(yml:find("--share=network", 1, true), "Flatpak shares network") +check(yml:find("--filesystem=home", 1, true), "Flatpak allows home for ROM import") +check(yml:find("com.theboisclub.gen1recomp", 1, true), "Flatpak app-id present") + +local meta = assert(io.open("flatpak/com.theboisclub.gen1recomp.metainfo.xml", "rb")) +local xml = meta:read("*a") +meta:close() +check(xml:find("", 1, true), "AppStream metainfo includes releases") +check(xml:find(" 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "BattleAnimView:fillBackground() reaches the GbcPalette seam") +end + +-- Unthemed / no-shader boot must still degrade to the plain literal white +-- fill, byte-identical to before this fix. +do + GbcPalette.available = function() return false end + calls = {} + spy("resolve"); spy("use"); spy("with") + local view = BattleAnimView.new({}, {}) + local ok = pcall(function() view:fillBackground() end) + T.check(ok, "BattleAnimView:fillBackground() does not error with no shader") + T.eq((calls.resolve or 0) + (calls.use or 0) + (calls.with or 0), 0, + "BattleAnimView:fillBackground() does not touch GbcPalette with no shader") +end + +T.finish() diff --git a/tests/engine/gen2_battle_menu_palette_test.lua b/tests/engine/gen2_battle_menu_palette_test.lua new file mode 100644 index 00000000..b9bb10ad --- /dev/null +++ b/tests/engine/gen2_battle_menu_palette_test.lua @@ -0,0 +1,58 @@ +-- BattleState:drawTextArea drew the FIGHT/ITEM/RUN command box with a flat +-- setColor, the same gap TextBox.lua had. Gen 1 gets colour from +-- PaletteFX's whole-frame remap, but Gen 2 has no such pass outside +-- CLASSIC mode, so the command box needed its own fix. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local BattleState = require("src.battle.BattleState") + +GbcPalette.available = function() return true end +GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +local function fakeState() + return { + phase = "menu", menuIndex = 1, safari = nil, demo = false, + bottomUIVisible = function() return true end, + game = { save = { generation = 2, version = "gold" } }, + } +end + +do + calls = {} + spy("resolve"); spy("use"); spy("with") + BattleState.drawTextArea(fakeState()) + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "Gen 2 battle command box reaches the GbcPalette seam") +end + +-- Gen 1 recolours through PaletteFX's whole-frame zone remap, not per draw +-- call, so this must stay untouched. Same guard gen2_textbox_palette_test +-- checks for TextBox. +do + calls = {} + spy("resolve"); spy("use"); spy("with") + local state = fakeState() + state.game = { save = { generation = 1, version = "red" } } + BattleState.drawTextArea(state) + T.eq((calls.resolve or 0) + (calls.use or 0) + (calls.with or 0), 0, + "Gen 1 battle command box is untouched, it never calls GbcPalette") +end + +T.finish() diff --git a/tests/engine/gen2_battle_state_text_palette_test.lua b/tests/engine/gen2_battle_state_text_palette_test.lua new file mode 100644 index 00000000..77b2d3bc --- /dev/null +++ b/tests/engine/gen2_battle_state_text_palette_test.lua @@ -0,0 +1,69 @@ +-- src/ui/gen2/BattleState.lua is the real Gen 2 battle screen (wired in +-- through Gen2Compat.lua's facade); it drew every label through flat +-- Chrome.print/cursor/printRight, never touching GbcPalette. Added +-- Chrome.printRightThrough alongside this fix since printRight had no +-- palette-aware counterpart. +-- +-- No real shader runs headless, so this asserts on which seam gets called +-- rather than on rendered pixels. Exercises drawMoveInfoBox, drawStatsBox, +-- and drawPanel's no-battle fallback as representative cases. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local BattleState = require("src.ui.gen2.BattleState") + +GbcPalette.available = function() return true end +GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +local function hits() + return (calls.resolve or 0) + (calls.use or 0) + (calls.with or 0) +end + +do + calls = {} + spy("resolve"); spy("use"); spy("with") + BattleState.drawMoveInfoBox({}, { id = "TACKLE", pp = 10, maxPp = 35 }) + T.check(hits() > 0, "drawMoveInfoBox's TYPE/PP labels reach the GbcPalette seam") +end + +do + calls = {} + spy("resolve"); spy("use"); spy("with") + BattleState.drawMoveInfoBox({}, nil) + T.eq(hits(), 0, "drawMoveInfoBox with no move draws nothing") +end + +do + local mon = { species = "RATTATA", + stats = { attack = 10, defense = 9, specialAttack = 8, specialDefense = 8, + speed = 12 } } + calls = {} + spy("resolve"); spy("use"); spy("with") + BattleState.drawStatsBox({}, mon) + T.check(hits() > 0, "drawStatsBox's stat names/numbers reach the GbcPalette seam") +end + +do + calls = {} + spy("resolve"); spy("use"); spy("with") + BattleState.drawPanel({ battle = nil, tutorial = false }) + T.check(hits() > 0, "drawPanel's NO BATTLE fallback reaches the GbcPalette seam") +end + +T.finish() diff --git a/tests/engine/gen2_chrome_clear_palette_test.lua b/tests/engine/gen2_chrome_clear_palette_test.lua new file mode 100644 index 00000000..b270b624 --- /dev/null +++ b/tests/engine/gen2_chrome_clear_palette_test.lua @@ -0,0 +1,56 @@ +-- Chrome.clear() is the base background wipe ~30 opaque Gen 2 menu screens +-- start their frame with. It used to draw a flat literal white rectangle, +-- never touching GbcPalette, so every screen's background stayed white +-- regardless of a picked COLORS palette even though its boxes and text +-- already recoloured correctly. +-- +-- No real shader runs headless, so this can't check a rendered pixel, same +-- limitation gen2_textbox_palette_test notes: force GbcPalette.available() +-- true and assert on which seam gets called, not on pixels. +-- tests/drivers/gold_menu_shots.lua is where a real rendered screen gets +-- checked by eye. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local Chrome = require("src.ui.gen2.Chrome") + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +do + GbcPalette.available = function() return true end + GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + calls = {} + spy("resolve"); spy("use"); spy("with") + Chrome.clear() + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "Chrome.clear() reaches the GbcPalette seam when a shader is available") +end + +-- Unthemed / no-shader boot must still degrade to the plain literal white +-- wipe, byte-identical to before this fix, same guard Chrome.paletteBox +-- already takes. +do + GbcPalette.available = function() return false end + calls = {} + spy("resolve"); spy("use"); spy("with") + local ok = pcall(Chrome.clear) + T.check(ok, "Chrome.clear() does not error with no shader available") + T.eq((calls.resolve or 0) + (calls.use or 0) + (calls.with or 0), 0, + "Chrome.clear() does not touch GbcPalette when no shader is available") +end + +T.finish() diff --git a/tests/engine/gen2_chrome_flat_primitives_palette_test.lua b/tests/engine/gen2_chrome_flat_primitives_palette_test.lua new file mode 100644 index 00000000..48dbc998 --- /dev/null +++ b/tests/engine/gen2_chrome_flat_primitives_palette_test.lua @@ -0,0 +1,73 @@ +-- Chrome.print / Chrome.cursor / Chrome.printRight are the flat, +-- hardcoded-black primitives ~40 Gen 2 screens still call directly, so any +-- glyph through them rendered solid black regardless of COLORS. Made the +-- primitives themselves palette-aware instead of migrating 40 files. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local Chrome = require("src.ui.gen2.Chrome") + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +local function reset() + calls = {} + spy("resolve"); spy("use"); spy("useRaw"); spy("with") +end + +local function touched() + return (calls.resolve or 0) + (calls.use or 0) + (calls.useRaw or 0) + (calls.with or 0) +end + +do + GbcPalette.available = function() return true end + GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + + reset() + Chrome.print("HELLO", 1, 1) + T.check(touched() > 0, "Chrome.print() reaches the GbcPalette seam when a shader is available") + + reset() + Chrome.cursor(1, 1) + T.check(touched() > 0, "Chrome.cursor() reaches the GbcPalette seam when a shader is available") + + reset() + Chrome.printRight("42", 10, 1) + T.check(touched() > 0, "Chrome.printRight() reaches the GbcPalette seam when a shader is available") +end + +-- Unthemed / no-shader boot must still degrade to the plain literal black +-- glyph draw, byte-identical to before this fix. +do + GbcPalette.available = function() return false end + + reset() + local ok = pcall(Chrome.print, "HELLO", 1, 1) + T.check(ok, "Chrome.print() does not error with no shader available") + T.eq(touched(), 0, "Chrome.print() does not touch GbcPalette when no shader is available") + + reset() + ok = pcall(Chrome.cursor, 1, 1) + T.check(ok, "Chrome.cursor() does not error with no shader available") + T.eq(touched(), 0, "Chrome.cursor() does not touch GbcPalette when no shader is available") + + reset() + ok = pcall(Chrome.printRight, "42", 10, 1) + T.check(ok, "Chrome.printRight() does not error with no shader available") + T.eq(touched(), 0, "Chrome.printRight() does not touch GbcPalette when no shader is available") +end + +T.finish() diff --git a/tests/engine/gen2_chrome_list_palette_test.lua b/tests/engine/gen2_chrome_list_palette_test.lua new file mode 100644 index 00000000..948bfb73 --- /dev/null +++ b/tests/engine/gen2_chrome_list_palette_test.lua @@ -0,0 +1,64 @@ +-- Chrome.List:draw() backs nearly every Gen 2 menu's row text, but drew +-- each row through flat, hardcoded-black Chrome.print/cursor, so rows +-- stayed black-on-white even though the box behind them recoloured. Fixed +-- by giving List a `palette` option that routes rows through +-- printThrough/cursorThrough. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local Chrome = require("src.ui.gen2.Chrome") + +GbcPalette.available = function() return true end +GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +do + local list = Chrome.List.new({ items = { "POKEMON", "PACK", "QUIT" }, x = 2, y = 2 }) + calls = {} + spy("resolve"); spy("use"); spy("with") + list:draw() + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "Chrome.List:draw() reaches the GbcPalette seam by default") +end + +-- A caller that explicitly opts out (palette = false) keeps the old flat +-- black draw untouched by GbcPalette. +do + local list = Chrome.List.new({ items = { "A", "B" }, x = 2, y = 2, palette = false }) + calls = {} + spy("resolve"); spy("use"); spy("with") + list:draw() + T.eq((calls.resolve or 0) + (calls.use or 0) + (calls.with or 0), 0, + "Chrome.List:draw() with palette = false does not touch GbcPalette") +end + +-- The scroll-hint down arrow, drawn only when the list is longer than its +-- visible rows, must also go through the palette rather than a bare +-- Font.drawCode. +do + local items = {} + for i = 1, 5 do items[i] = "ITEM " .. i end + local list = Chrome.List.new({ items = items, x = 2, y = 2, rows = 2 }) + calls = {} + spy("resolve"); spy("use"); spy("with") + list:draw() + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "Chrome.List:draw()'s scroll-hint arrow reaches the GbcPalette seam") +end + +T.finish() diff --git a/tests/engine/gen2_color_cache_invalidation_test.lua b/tests/engine/gen2_color_cache_invalidation_test.lua new file mode 100644 index 00000000..c108bd74 --- /dev/null +++ b/tests/engine/gen2_color_cache_invalidation_test.lua @@ -0,0 +1,109 @@ +-- World's per-map colour bake keyed its cache on GbcPalette.mode alone, so +-- two different picked packs (both mode == "custom") kept showing +-- whichever was baked first until a full reload. +-- luajit tests/engine/gen2_color_cache_invalidation_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local GbcPalette = require("src.render.GbcPalette") +local BorderFill = require("src.world.gen2.BorderFill") +local World = require("src.world.gen2.World") + +GbcPalette.setMode("gbc") +GbcPalette.setCustomRamp(nil) + +-- ------- mapCacheKey: must depend on customRamp identity, not just mode + +do + local fakeSelf = { daytime = "DAY", flickerPhase = 1 } + local key1 = World.mapCacheKey(fakeSelf, "map1") + + GbcPalette.setCustomRamp( + { { 1, 1, 1 }, { 2, 2, 2 }, { 3, 3, 3 }, { 4, 4, 4 } }) + local key2 = World.mapCacheKey(fakeSelf, "map1") + + check(key1 ~= key2, + "BUG FIX: mapCacheKey differs once a custom ramp is active, even " .. + "though GbcPalette.mode ('gbc') never changed") + + GbcPalette.setCustomRamp( + { { 9, 9, 9 }, { 8, 8, 8 }, { 7, 7, 7 }, { 6, 6, 6 } }) + local key3 = World.mapCacheKey(fakeSelf, "map1") + check(key2 ~= key3, + "BUG FIX: mapCacheKey differs again for a second, different ramp " .. + "under the same mode") + + GbcPalette.setCustomRamp(nil) + eq(World.mapCacheKey(fakeSelf, "map1"), key1, + "clearing the ramp returns to the original no-ramp key") +end + +-- ------- refreshColorMode: must refetch on a customRamp-only change + +do + local fakeSelf = { daytime = "DAY", flickerPhase = 1 } + local calls = 0 + fakeSelf.imageFor = function(_, id) calls = calls + 1; return "img:" .. id end + fakeSelf.rebuildNeighbors = function() end + fakeSelf.map = { id = "map1" } + + World.refreshColorMode(fakeSelf) + eq(calls, 1, "first refreshColorMode call bakes/fetches once") + + World.refreshColorMode(fakeSelf) + eq(calls, 1, "a second call with nothing changed does not refetch") + + GbcPalette.setCustomRamp( + { { 5, 5, 5 }, { 4, 4, 4 }, { 3, 3, 3 }, { 2, 2, 2 } }) + World.refreshColorMode(fakeSelf) + eq(calls, 2, + "BUG FIX: a customRamp change alone (mode unchanged) still refetches") + + GbcPalette.setCustomRamp(nil) + World.refreshColorMode(fakeSelf) + eq(calls, 3, "clearing the ramp (mode still unchanged) refetches too") +end + +-- ------- World:borderImageFor: the void/border bake had the identical gap +-- in its own separately-inlined cache key (it doesn't call mapCacheKey). A +-- border baked while GEN 2 (no ramp) was active stayed cached forever, +-- since picking a pack never showed up in this key, only the mode did. + +do + local def = { id = "map1", tileset = "TILESET_JOHTO", borderBlock = 0 } + local fakeSelf = { + daytime = "DAY", flickerPhase = 1, + maps = { map1 = def }, + tilesets = { TILESET_JOHTO = { blocks = { [1] = {} }, tilesPerRow = 16 } }, + mapImages = {}, + borderWaterFrame = function() return nil end, + atlasFor = function() return nil end, + } + + GbcPalette.setCustomRamp(nil) + -- Seed the cache under exactly the key borderImageFor computes today for + -- "no ramp active", with a sentinel, proving the fix without touching + -- the real love.graphics bake pipeline. + local keyNoRamp = + BorderFill.cacheKey("map1|DAY|gbc|nil|1|fade|0|0") + fakeSelf.mapImages[keyNoRamp] = "SENTINEL-no-ramp" + eq(World.borderImageFor(fakeSelf, "map1"), "SENTINEL-no-ramp", + "sanity: the no-ramp key matches what borderImageFor builds today") + + GbcPalette.setCustomRamp( + { { 1, 1, 1 }, { 2, 2, 2 }, { 3, 3, 3 }, { 4, 4, 4 } }) + local got = World.borderImageFor(fakeSelf, "map1") + check(got ~= "SENTINEL-no-ramp", + "BUG FIX: borderImageFor must not reuse the no-ramp cached void " .. + "bake once a custom ramp is active") + + GbcPalette.setCustomRamp(nil) +end + +GbcPalette.setCustomRamp(nil) +GbcPalette.setMode("gbc") + +T.finish("gen2 color cache invalidation") diff --git a/tests/engine/gen2_options_menu_translation_test.lua b/tests/engine/gen2_options_menu_translation_test.lua index 2ce171c3..836e8ac9 100644 --- a/tests/engine/gen2_options_menu_translation_test.lua +++ b/tests/engine/gen2_options_menu_translation_test.lua @@ -54,13 +54,41 @@ local function rowIndex(rows, id) end end +-- TEXT SPEED and the other cart rows live on a group page now, so the +-- screen a row draws on is the page its group opens, not the top level. +local function pageFor(menu, id) + for _, row in ipairs(menu.view) do + if row.group then + row.activate(menu.game) + local page = menu.game.stack:top() + if rowIndex(page.view, id) then return page end + menu.game.stack:pop() + end + end +end + +local function fakeGame() + local stack = { items = {} } + function stack:push(s) self.items[#self.items + 1] = s; return s end + function stack:pop() self.items[#self.items] = nil end + function stack:top() return self.items[#self.items] end + return { stack = stack } +end + -- ---------------------------------------------- vanilla: no mod catalog do - local menu = OptionsMenu.new({}) + local menu = OptionsMenu.new(fakeGame()) drawn = {} menu:drawPanel() + T.eq(drawnAt(LABEL_X, 2 * 8), "SPEED", + "row 1 is the SPEED group with no mod loaded") + + local page = pageFor(menu, "textSpeed") + T.check(page ~= nil, "TEXT SPEED is on a group page") + drawn = {} + page:drawPanel() T.eq(drawnAt(LABEL_X, 2 * 8), "TEXT SPEED", - "row 1's label draws in English with no mod loaded") + "the page's first label draws in English") -- Save.DEFAULT_OPTIONS.textSpeed is "MID", the cart's own default. T.eq(drawnAt(VALUE_X, 3 * 8), "MID ", "and its cart-original display value too") @@ -73,13 +101,20 @@ do ["TEXT SPEED"] = "VITESSE TEXTE", ["MID "] = "MOY ", ["CONTROLS"] = "COMMANDES", - ["CANCEL"] = "ANNULER", + ["SPEED"] = "VITESSE", + ["BACK"] = "RETOUR", }, }) - local menu = OptionsMenu.new({}) + local menu = OptionsMenu.new(fakeGame()) drawn = {} menu:drawPanel() + T.eq(drawnAt(LABEL_X, 2 * 8), "VITESSE", + "a mod catalog reaches a group opener's label too") + + local page = pageFor(menu, "textSpeed") + drawn = {} + page:drawPanel() T.eq(drawnAt(LABEL_X, 2 * 8), "VITESSE TEXTE", "a mod catalog reaches a cart-original row's label") T.eq(drawnAt(VALUE_X, 3 * 8), "MOY ", @@ -87,7 +122,7 @@ do -- CONTROLS is the first port-added row; scroll to it so it lands in the -- VISIBLE_ROWS=7 window drawPanel actually draws. - local index = rowIndex(menu.rows, "controls") + local index = rowIndex(menu.view, "controls") T.check(index ~= nil, "CONTROLS is one of the rows") menu.index = index menu:ensureVisible() @@ -97,17 +132,17 @@ do T.eq(drawnAt(LABEL_X, (2 + (slot - 1) * 2) * 8), "COMMANDES", "and a port-added row's label is translated too") - -- CANCEL is the last row, built into ROWS like any other -- there is no + -- BACK is the last row, built into ROWS like any other -- there is no -- separate hook to fall through if this one row is missed. - local cancelMenu = OptionsMenu.new({}) - local cancelIndex = #cancelMenu.rows - T.check(cancelMenu.rows[cancelIndex].cancel, "the last row is CANCEL") + local cancelMenu = OptionsMenu.new(fakeGame()) + local cancelIndex = #cancelMenu.view + T.check(cancelMenu.view[cancelIndex].cancel, "the last row is BACK") cancelMenu.index = cancelIndex cancelMenu:ensureVisible() drawn = {} cancelMenu:drawPanel() local cancelSlot = cancelIndex - cancelMenu.scroll - T.eq(drawnAt(LABEL_X, (2 + (cancelSlot - 1) * 2) * 8), "ANNULER", + T.eq(drawnAt(LABEL_X, (2 + (cancelSlot - 1) * 2) * 8), "RETOUR", "CANCEL, the way out of the menu, is translated too") -- Module state is process-global (see tests/gen2_clock_test.lua's own diff --git a/tests/engine/gen2_pack_menu_item_list_palette_test.lua b/tests/engine/gen2_pack_menu_item_list_palette_test.lua new file mode 100644 index 00000000..fb300dab --- /dev/null +++ b/tests/engine/gen2_pack_menu_item_list_palette_test.lua @@ -0,0 +1,65 @@ +-- PackMenu:drawList and PackGfx:draw stayed flat white/black regardless of +-- COLORS, since PackGfx only repaints its left five columns and the +-- item-list columns kept their initial bare-rectangle fill. Fixed by +-- routing that fill through Chrome.paletteFill and PackMenu's prints +-- through printThrough/cursorThrough. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local PackGfx = require("src.ui.gen2.PackGfx") +local PackMenu = require("src.ui.gen2.PackMenu") + +GbcPalette.available = function() return true end +GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +do + -- An empty gfx.pack table (no header/background/pack-image paths): draw() + -- still runs its header/pattern loops, they just find nothing to blit, so + -- only the base fill itself is exercised. + local gfx = PackGfx.new({ pack = {} }) + calls = {} + spy("resolve"); spy("use"); spy("with") + gfx:draw("ITEMS") + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "PackGfx:draw()'s base fill reaches the GbcPalette seam") +end + +local function fakePackMenu() + return { + rows = { + { id = "POTION", name = "POTION", showCount = true, count = 1 }, + }, + scroll = 0, + index = 1, + switching = nil, + gfx = PackGfx.new(nil), + cursorAt = PackMenu.cursorAt, + total = function() return 2 end, -- 1 row + CANCEL + } +end + +do + calls = {} + spy("resolve"); spy("use"); spy("with") + PackMenu.drawList(fakePackMenu(), 5, 4) + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "PackMenu:drawList() reaches the GbcPalette seam for item labels") +end + +T.finish() diff --git a/tests/engine/gen2_palette_picker_test.lua b/tests/engine/gen2_palette_picker_test.lua new file mode 100644 index 00000000..1cdf7fac --- /dev/null +++ b/tests/engine/gen2_palette_picker_test.lua @@ -0,0 +1,275 @@ +-- Gen 2's answer to tests/engine/palette_test.lua: the same COLORS picker, +-- wired onto Gold's COLOR row and its own substitution seam +-- (src/render/GbcPalette.lua) instead of Gen 1's PaletteFX. +-- luajit tests/engine/gen2_palette_picker_test.lua +-- +-- Covers GbcPalette.customRamp/setCustomRamp (the seam every Gen 2 draw call +-- shares), that a stored palette beats CLASSIC's present pass the same way +-- it beats OG/OG INV/CLASSIC on Gen 1, and that the COLOR row opens the +-- picker while GBC/DMG/CLASSIC stay reachable inside it. + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local function hex(c) + return type(c) == "table" and ("%02x%02x%02x"):format(c[1], c[2], c[3]) or "?" +end + +local Palette = require("src.render.Palette") +local GbcPalette = require("src.render.GbcPalette") + +-- ------- GbcPalette seam: a custom ramp wins outright + +do + GbcPalette.setCustomRamp(nil) + GbcPalette.setMode("gbc") + + local cartColors = { { 9, 9, 9 }, { 8, 8, 8 }, { 7, 7, 7 }, { 6, 6, 6 } } + eq(GbcPalette.resolve(cartColors), cartColors, + "no custom ramp: GBC mode passes the cart's own colours through") + + local id = Palette.packId("GalleryWebApp Palettes", "Original GB") + local ramp = Palette.ramp(id) + GbcPalette.setCustomRamp(ramp) + + local out = GbcPalette.resolve(cartColors) + for i = 1, 4 do + eq(hex(out[i]), hex(ramp[i]), + "resolve[" .. i .. "] is the custom ramp, not the cart's own colours") + end + + -- it should win even over DMG, since DMG's whole effect lives at this seam + GbcPalette.setMode("dmg") + local out2 = GbcPalette.resolve(cartColors) + eq(hex(out2[1]), hex(ramp[1]), "a custom ramp beats DMG too") + + GbcPalette.setMode("gbc") + GbcPalette.setCustomRamp(nil) + eq(GbcPalette.resolve(cartColors), cartColors, + "clearing the ramp restores the cart's own colours") +end + +-- ------- CLASSIC is a present pass, same as Gen 1: it has to be dropped + +do + GbcPalette.setCustomRamp(nil) + GbcPalette.setMode("classic") + check(GbcPalette.presentColors() ~= nil, + "CLASSIC has a present pass before any palette is picked") + + local id = Palette.packId("BGB", "Red") + local ramp = Palette.ramp(id) + GbcPalette.setCustomRamp(ramp) + -- resolve() alone can't save CLASSIC: presentColors bypasses resolve + -- entirely, so the picker's opts have to drop the mode itself. Covered + -- below via the real row wiring. + GbcPalette.setMode("gbc") + GbcPalette.setCustomRamp(nil) +end + +-- ------- applyOptions: a saved palette re-applies on load, like Gen 1's + +do + GbcPalette.setCustomRamp(nil) + local id = Palette.packId("BGB", "Blue") + GbcPalette.applyOptions({ color = "dmg", palette = id }) + eq(GbcPalette.mode, "dmg", "applyOptions still sets the mode") + check(GbcPalette.customRamp ~= nil, "applyOptions applied the saved palette") + eq(hex(GbcPalette.customRamp[1]), hex(Palette.ramp(id)[1]), + "and it's the right ramp") + + GbcPalette.applyOptions({ color = "gbc", palette = "" }) + check(GbcPalette.customRamp == nil, + "applyOptions with no palette clears the custom ramp") +end + +-- ------- the COLOR row's real wiring: activate opens the picker + +local OptionsMenu = require("src.ui.gen2.OptionsMenu") + +local function rowNamed(rows, label) + for _, row in ipairs(rows) do + if row.label == label then return row end + end + return nil +end + +do + local colorRow = rowNamed(OptionsMenu.ROWS, "COLOR") + check(colorRow ~= nil, "COLOR is still a row") + check(colorRow.cycle == nil, "COLOR no longer cycles in place") + check(type(colorRow.activate) == "function", + "COLOR opens the picker instead") + + GbcPalette.setCustomRamp(nil) + GbcPalette.setMode("classic") + + local pushed + local game = { + options = { color = "classic", palette = "" }, + stack = { states = {}, + push = function(self, inst) pushed = inst end }, + persistOptions = function() end, + } + + colorRow.activate(game) + check(pushed ~= nil, "activate pushed a screen") + check(pushed.view == "root", "the pushed screen is a real PaletteScreen") + eq(game.options.color, "gbc", + "opening the picker while CLASSIC was active dropped it to GBC") + eq(GbcPalette.mode, "gbc", "and the live mode followed") + + -- the root offers the engine's own GBC/DMG/CLASSIC ladder alongside the + -- 699-pack folders and MONOCHROME: "the original options" still work + local sawModes = {} + for _, row in ipairs(pushed.rows) do + if row.mode then sawModes[row.mode] = true end + end + check(sawModes.gbc and sawModes.dmg and sawModes.classic, + "GBC, DMG and CLASSIC are all still reachable from the picker") + + local id = Palette.packId("BGB", "Blue") + pushed.set(id) + eq(game.options.palette, id, "storing a palette writes options.palette") + check(GbcPalette.customRamp ~= nil, "and GbcPalette.setCustomRamp actually ran") + eq(hex(GbcPalette.customRamp[1]), hex(Palette.ramp(id)[1]), "with the right ramp") + + pushed.set("") + check(GbcPalette.customRamp == nil, "storing empty clears the custom ramp") + eq(game.options.color, "gbc", + "clearing a palette lands back on GEN 2, not whatever mode was active") + eq(GbcPalette.mode, "gbc", "and the live mode followed") + + -- picking an engine mode through the picker still works and clears the + -- stored palette, exactly like Gen 1's picker + pushed.set(id) + pushed.setMode("dmg") + eq(game.options.color, "dmg", "setMode still writes options.color") + eq(GbcPalette.mode, "dmg", "and drives the live module") + + GbcPalette.setCustomRamp(nil) + GbcPalette.setMode("gbc") +end + +-- ------- GBC/GEN 2 relabel: GbcPalette.MODES keeps its id "gbc", but the +-- label the player sees for it moved to "GEN 2", freeing "GBC" up as +-- CUSTOM_MODE's own label, the state a stored palette actually lands on. + +do + eq(GbcPalette.MODE_LABELS.gbc, "GEN 2", + "GEN 2 is the engine's own unique-colour mode's display name now") + eq(GbcPalette.MODE_LABELS[GbcPalette.CUSTOM_MODE], "GBC", + "GBC is the label for a stored custom palette's state") + check(not (function() + for _, id in ipairs(GbcPalette.MODES) do + if id == GbcPalette.CUSTOM_MODE then return true end + end + return false + end)(), "CUSTOM_MODE is deliberately not one of the root ladder's own modes") +end + +-- ------- the bug this session's relabel fixes: picking a palette from a +-- non-GEN-2 mode used to strand the player there once the palette cleared, +-- because only CLASSIC ever got dropped back. Now ANY mode lands on +-- CUSTOM_MODE the moment a palette is picked, so clearing it always finds +-- its way back to GEN 2's own unique colours. + +do + local OptionsMenu = require("src.ui.gen2.OptionsMenu") + local colorRow = nil + for _, r in ipairs(OptionsMenu.ROWS) do + if r.key == "color" then colorRow = r end + end + check(colorRow ~= nil, "found the COLOR row") + + GbcPalette.setCustomRamp(nil) + GbcPalette.setMode("dmg") + + local pushed + local game = { + options = { color = "dmg", palette = "" }, + stack = { states = {}, push = function(self, inst) pushed = inst end }, + persistOptions = function() end, + } + colorRow.activate(game) + + -- opening the picker from DMG (not CLASSIC) doesn't touch the mode yet: + -- dropClassic only fires for CLASSIC, exactly as before. + eq(game.options.color, "dmg", "opening the picker from DMG leaves it alone") + + local id = Palette.packId("BGB", "Blue") + pushed.set(id) + eq(game.options.color, GbcPalette.CUSTOM_MODE, + "picking a palette from DMG now lands on CUSTOM_MODE, not DMG") + eq(GbcPalette.mode, GbcPalette.CUSTOM_MODE, "and the live mode followed") + + pushed.set("") + eq(game.options.color, "gbc", + "clearing it lands on GEN 2 (the bug: this used to stay on DMG, " .. + "with no selectable row left to get back to GEN 2's unique colours)") + eq(GbcPalette.mode, "gbc", "and the live mode followed") + + GbcPalette.setCustomRamp(nil) + GbcPalette.setMode("gbc") +end + +-- ------- TitleState's two baked art sets: GEN 2 and a picked custom +-- palette both want the colour set (there's no live recolour of this +-- screen's art either way); only DMG/CLASSIC want the grey set. + +do + local TitleState = require("src.ui.gen2.TitleState") + GbcPalette.setMode("gbc") + check(not TitleState.gray(), "GEN 2 shows the colour art") + GbcPalette.setMode(GbcPalette.CUSTOM_MODE) + check(not TitleState.gray(), "a picked custom palette also shows the colour art") + GbcPalette.setMode("dmg") + check(TitleState.gray(), "DMG shows the grey art") + GbcPalette.setMode("classic") + check(TitleState.gray(), "CLASSIC shows the grey art (then the green present pass)") + GbcPalette.setMode("gbc") +end + +-- ------- the "2" hotkey ladder (Game2:hotkey) and GbcPalette.cycle(): the +-- bug this session fixes. Unlike the picker's own row.mode branch (tested +-- above), this ladder used to change GbcPalette.mode/options.color without +-- touching customRamp or options.palette, so resolve() stayed locked onto +-- the old pack's colours no matter how many times "2" landed back on "gbc". + +do + local Game2 = require("src.core.Game2") + local cartColors = { { 9, 9, 9 }, { 8, 8, 8 }, { 7, 7, 7 }, { 6, 6, 6 } } + + GbcPalette.setCustomRamp(nil) + GbcPalette.setMode("gbc") + + local id = Palette.packId("BGB", "Blue") + local ramp = Palette.ramp(id) + local game = { + options = { color = GbcPalette.CUSTOM_MODE, palette = id }, + persistOptions = function() end, + } + GbcPalette.setMode(GbcPalette.CUSTOM_MODE) + GbcPalette.setCustomRamp(ramp) + + -- cycle() isn't in GbcPalette.MODES so a custom-mode start is treated as + -- index 1: three "2" presses walk dmg -> classic -> gbc. + for _ = 1, 3 do Game2.hotkey(game, "2") end + + eq(game.options.color, "gbc", "three presses land back on GEN 2's label") + eq(game.options.palette, "", + "BUG FIX: the hotkey ladder clears options.palette too, not just color") + check(GbcPalette.customRamp == nil, + "BUG FIX: GbcPalette.cycle() drops the live custom ramp") + eq(GbcPalette.resolve(cartColors), cartColors, + "BUG FIX: resolve() answers with the cart's own colours again, not " .. + "the stale custom ramp") + + GbcPalette.setCustomRamp(nil) + GbcPalette.setMode("gbc") +end + +T.finish("gen2 palette picker") diff --git a/tests/engine/gen2_pokegear_custom_ramp_film_test.lua b/tests/engine/gen2_pokegear_custom_ramp_film_test.lua new file mode 100644 index 00000000..b92dad7e --- /dev/null +++ b/tests/engine/gen2_pokegear_custom_ramp_film_test.lua @@ -0,0 +1,83 @@ +-- Follow-up to gen2_pokegear_native_colors_test.lua's "keep the gear +-- vanilla" fix: CLASSIC swept the native-coloured gear for free, so this +-- extends the same whole-panel tint to a picked custom COLORS ramp, with +-- Pokegear.CUSTOM_RAMP_FILM = false as an easy revert. +-- +-- No real shader/asset loads headless, so this isolates drawPanel()'s new +-- branch with a minimal fake self, same "stub the rest, assert the seam" +-- approach as the TrainerCard drawPanel() test. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local Pokegear = require("src.ui.gen2.Pokegear") + +GbcPalette.available = function() return true end + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +local function hits() + return (calls.resolve or 0) + (calls.use or 0) + (calls.useRaw or 0) + + (calls.with or 0) +end + +local function fakeGear() + return { + styled = function() return true end, + groundColor = function() return { 0, 0, 0 } end, + card = function() return { id = "clock" } end, + drawClock = function() end, + drawMap = function() end, + drawRadio = function() end, + drawPhone = function() end, + drawModeArrow = function() end, + } +end + +do + Pokegear.CUSTOM_RAMP_FILM = true + GbcPalette.setCustomRamp({ { 227, 249, 227 }, { 180, 200, 180 }, + { 90, 100, 90 }, { 0, 0, 0 } }) + calls = {} + spy("resolve"); spy("use"); spy("useRaw"); spy("with") + Pokegear.drawPanel(fakeGear()) + T.check(hits() > 0, + "with the film on and a custom ramp active, drawPanel() reaches GbcPalette") +end + +do + Pokegear.CUSTOM_RAMP_FILM = false + GbcPalette.setCustomRamp({ { 227, 249, 227 }, { 180, 200, 180 }, + { 90, 100, 90 }, { 0, 0, 0 } }) + calls = {} + spy("resolve"); spy("use"); spy("useRaw"); spy("with") + Pokegear.drawPanel(fakeGear()) + T.eq(hits(), 0, + "Pokegear.CUSTOM_RAMP_FILM = false is a one-line revert to fully native") +end + +do + Pokegear.CUSTOM_RAMP_FILM = true + GbcPalette.setCustomRamp(nil) + calls = {} + spy("resolve"); spy("use"); spy("useRaw"); spy("with") + Pokegear.drawPanel(fakeGear()) + T.eq(hits(), 0, + "with no custom ramp picked, the film never engages -- native GEN 2 stays untouched") +end + +Pokegear.CUSTOM_RAMP_FILM = true + +T.finish() diff --git a/tests/engine/gen2_pokegear_native_colors_test.lua b/tests/engine/gen2_pokegear_native_colors_test.lua new file mode 100644 index 00000000..3e5cf377 --- /dev/null +++ b/tests/engine/gen2_pokegear_native_colors_test.lua @@ -0,0 +1,66 @@ +-- The Pokegear's six hardware palette banks guarantee a highlighted cell +-- contrasts with its text, a guarantee GbcPalette.resolve() breaks by +-- collapsing every bank onto the same 4 shades. Product decision: the +-- Pokegear stays on native hardware colours always, via a `raw` path +-- (GbcPalette.withRaw, Chrome.*Through's `raw` param, TileSheet's `raw` +-- option) that calls useRaw directly and skips resolve. +-- +-- No real shader runs headless, so this asserts on which entry point gets +-- called rather than on rendered pixels. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local Chrome = require("src.ui.gen2.Chrome") +local TileSheet = require("src.ui.gen2.TileSheet") +local Pokegear = require("src.ui.gen2.Pokegear") + +GbcPalette.available = function() return true end +GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +local BANK_1 = { { 227, 249, 227 }, { 180, 200, 180 }, { 90, 100, 90 }, + { 0, 0, 0 } } + +do + calls = {} + spy("resolve"); spy("useRaw") + Pokegear.text({ pals = function() return { BANK_1 } end }, "SUNDAY", 6, 6) + T.eq(calls.resolve or 0, 0, + "Pokegear:text() never substitutes the active COLORS ramp") + T.check((calls.useRaw or 0) > 0, + "Pokegear:text() still shades its own native colours through the shader") +end + +do + local sheet = TileSheet.new({ palette = BANK_1, raw = true }) + -- No real image asset in the headless harness, so this exercises the + -- option's plumbing (raw stored, read by :draw) rather than a full + -- render; gen2_trainer_card_text_palette_test.lua and friends take the + -- same "assert the seam, not the pixel" approach one level up. + T.check(sheet.raw == true, "TileSheet:new stores the raw option") +end + +-- A sheet built without raw must still go through the normal substituting +-- seam, guarding the new option from silently becoming the default for +-- every other Gen 2 screen's tile art. +do + local sheet = TileSheet.new({ palette = BANK_1 }) + T.eq(sheet.raw, nil, "TileSheet:new defaults raw to unset") +end + +T.finish() diff --git a/tests/engine/gen2_startmenu_description_palette_test.lua b/tests/engine/gen2_startmenu_description_palette_test.lua new file mode 100644 index 00000000..023acf6e --- /dev/null +++ b/tests/engine/gen2_startmenu_description_palette_test.lua @@ -0,0 +1,66 @@ +-- StartMenu:draw drew the OPTION row's description tooltip, the "Change +-- settings" box under the list, with a raw love.graphics.rectangle at a +-- flat literal white, never touching GbcPalette. Every other box on this +-- screen already recoloured correctly with a picked COLORS palette, so +-- this one sub-region stood out as structurally white/black regardless. +-- +-- No real shader runs headless, so this can't check a rendered pixel, same +-- limitation every palette test in this directory notes: force +-- GbcPalette.available() true and assert on which seam gets called, not +-- on pixels. tests/drivers/custom_ramp_probe.lua is where a real rendered +-- screen gets checked by eye. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local StartMenu = require("src.ui.gen2.StartMenu") + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +-- Minimal fake: draw() only needs #self.items (box height), a list stub, +-- and a current item with a desc, the same shape start_menu.asm carries +-- for every row (see StartMenu.lua's ITEMS table). +local function fakeMenu() + return { + items = { { id = "option" } }, + list = { + draw = function() end, + current = function() return { desc = { "Change", "settings" } } end, + }, + phase = nil, + showDescription = true, + } +end + +do + GbcPalette.available = function() return true end + GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + calls = {} + spy("resolve"); spy("use"); spy("with") + StartMenu.draw(fakeMenu()) + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "StartMenu's description tooltip reaches the GbcPalette seam") +end + +do + GbcPalette.available = function() return false end + calls = {} + spy("resolve"); spy("use"); spy("with") + local ok = pcall(StartMenu.draw, fakeMenu()) + T.check(ok, "StartMenu:draw() does not error with no shader available") +end + +T.finish() diff --git a/tests/engine/gen2_textbox_palette_test.lua b/tests/engine/gen2_textbox_palette_test.lua new file mode 100644 index 00000000..8c458bf7 --- /dev/null +++ b/tests/engine/gen2_textbox_palette_test.lua @@ -0,0 +1,63 @@ +-- TextBox drew its box border and glyphs through Font.drawBox/drawCode +-- with a flat setColor, never touching GbcPalette, so dialogue text never +-- picked up a COLORS palette even though every other Chrome.lua-based +-- screen did. +-- +-- No real shader runs headless, so this asserts on which seam gets called +-- rather than checking a rendered pixel; tests/drivers/gold_menu_shots.lua +-- is where a real box gets checked by eye. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local TextBox = require("src.render.TextBox") + +GbcPalette.available = function() return true end +GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +local function newGame(generation) + return { + save = { player = { name = "RED" }, generation = generation, + version = generation == 2 and "gold" or "red", options = {} }, + stringBuffer = "", boxNumString = "", boxMonNicks = {}, + data = {}, + } +end + +do + calls = {} + spy("resolve"); spy("use"); spy("with") + local box = TextBox.new(newGame(2), "Hello world!", nil, {}) + box:draw() + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "Gen 2 dialogue box reaches the GbcPalette seam") +end + +-- Gen 1 recolours through a whole-frame zone remap upstream of this file +-- (PaletteFX), not per draw call, so TextBox must not start touching +-- GbcPalette for a Gen 1 game. +do + calls = {} + spy("resolve"); spy("use"); spy("with") + local box = TextBox.new(newGame(1), "Hello world!", nil, {}) + box:draw() + T.eq((calls.resolve or 0) + (calls.use or 0) + (calls.with or 0), 0, + "Gen 1 dialogue box is untouched, it never calls GbcPalette") +end + +T.finish() diff --git a/tests/engine/gen2_trainer_card_text_palette_test.lua b/tests/engine/gen2_trainer_card_text_palette_test.lua new file mode 100644 index 00000000..633a4ee7 --- /dev/null +++ b/tests/engine/gen2_trainer_card_text_palette_test.lua @@ -0,0 +1,69 @@ +-- TrainerCard had two palette gaps: its own text drew through flat +-- Chrome.print/cursor, fixed with a print/:cursor pair routed through +-- printThrough/cursorThrough via self:colorsAt; and drawPanel() painted +-- the screen with a flat white rectangle before anything else, leaving the +-- NAME/ID/MONEY/STATUS/BADGES interiors white, fixed by routing that fill +-- through Chrome.paletteFill. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local TrainerCard = require("src.ui.gen2.TrainerCard") + +GbcPalette.available = function() return true end +GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +local function fakeCard() + return { + colorsAt = function() return { { 255, 255, 255 }, { 200, 0, 200 }, + { 100, 0, 100 }, { 0, 0, 0 } } end, + } +end + +do + calls = {} + spy("resolve"); spy("use"); spy("with") + TrainerCard.print(fakeCard(), "NAME/", 2, 2) + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "TrainerCard:print() reaches the GbcPalette seam via self:colorsAt") +end + +do + calls = {} + spy("resolve"); spy("use"); spy("with") + TrainerCard.cursor(fakeCard(), 18, 15) + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "TrainerCard:cursor() reaches the GbcPalette seam via self:colorsAt") +end + +-- drawPanel() painted the whole screen with a flat white rectangle before +-- drawing the card, and self:frame only draws border tiles, so panel +-- interiors stayed white regardless of the picked palette. drawCard is +-- stubbed here to isolate that base fill from the print/cursor seam +-- covered above. +do + local fake = { styled = function() return true end, page = 1, + drawCard = function() end } + calls = {} + spy("resolve"); spy("use"); spy("with") + TrainerCard.drawPanel(fake) + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "TrainerCard:drawPanel()'s base fill reaches the GbcPalette seam") +end + +T.finish() diff --git a/tests/engine/gen2_unown_words_box_palette_test.lua b/tests/engine/gen2_unown_words_box_palette_test.lua new file mode 100644 index 00000000..5f6bb9ef --- /dev/null +++ b/tests/engine/gen2_unown_words_box_palette_test.lua @@ -0,0 +1,46 @@ +-- UnownWords:draw already routed its tile art through GbcPalette.with, but +-- drew the frame around it with a plain Font.drawBox, so the puzzle's own +-- glyphs picked up a selected COLORS palette while the box border stayed +-- white/black, the same shape of gap gen2_textbox_palette_test.lua found +-- in TextBox.lua. Fixed by routing the box through Chrome.paletteBox. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local UnownWords = require("src.world.gen2.UnownWords") + +GbcPalette.available = function() return true end +GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +-- data/events/unown_walls.asm:15 MenuHeaders_UnownWalls's ESCAPE row, the +-- same fixture tests/gen2_unown_words_test.lua uses for boxRect. +local ESCAPE_WALL = { x1 = 3, y1 = 4, x2 = 16, y2 = 9, + chars = { 0x08, 0x44, 0x04, 0x00, 0x2e, 0x08 } } + +do + calls = {} + spy("resolve"); spy("use"); spy("with") + -- no opts.world -> :tileset() returns nil -> draw() exits right after the + -- box, isolating the box's own palette seam from the tile-art body wrap + local words = UnownWords.new({}, { wall = ESCAPE_WALL }) + words:draw() + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "Unown wall box reaches the GbcPalette seam") +end + +T.finish() diff --git a/tests/engine/hostshell_curl_env_test.lua b/tests/engine/hostshell_curl_env_test.lua new file mode 100644 index 00000000..85fcd46d --- /dev/null +++ b/tests/engine/hostshell_curl_env_test.lua @@ -0,0 +1,76 @@ +-- Dual curl env selection for AppImage / Flatpak / host. +-- luajit tests/engine/hostshell_curl_env_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +-- Isolate getenv for the HostShell module load. +local env = { + APPIMAGE = "/tmp/fake.AppImage", + APPDIR = "/tmp/fake-appdir", + LD_LIBRARY_PATH = "/tmp/fake-appdir/lib", + LD_PRELOAD = "/tmp/steam_overlay.so", +} +local realGetenv = os.getenv +function os.getenv(k) + if env[k] ~= nil then return env[k] end + return realGetenv(k) +end + +-- Clear any prior HostShell package so resolveCurl memoization is fresh. +package.loaded["src.core.HostShell"] = nil +local HostShell = require("src.core.HostShell") + +eq(HostShell.curlEnvPrefix("host"), + "env -u LD_LIBRARY_PATH -u LD_PRELOAD ", + "host curl scrubs AppImage libs and Steam preload") +eq(HostShell.curlEnvPrefix("bundled"), + "env -u LD_PRELOAD ", + "bundled curl keeps LD_LIBRARY_PATH, still scrubs Steam preload") + +-- Without APPIMAGE, host prefix only scrubs preload when set. +env.APPIMAGE = nil +eq(HostShell.curlEnvPrefix("host"), + "env -u LD_PRELOAD ", + "non-AppImage host curl still scrubs LD_PRELOAD") + +-- Bundled path preferred when APPDIR curl exists. +env.APPIMAGE = "/tmp/fake.AppImage" +env.FLATPAK_ID = nil +local fakeBin = "/tmp/fake-appdir/usr/bin" +os.execute('mkdir -p "' .. fakeBin .. '"') +local curlPath = fakeBin .. "/curl" +local f = assert(io.open(curlPath, "wb")) +f:write("#!/bin/sh\necho curl\n") +f:close() +os.execute('chmod +x "' .. curlPath .. '"') + +package.loaded["src.core.HostShell"] = nil +-- Reset memo by reloading; resolveCurl caches in module locals. +HostShell = require("src.core.HostShell") +local path, kind = HostShell.resolveCurl() +eq(kind, "bundled", "APPDIR curl selected as bundled") +eq(path, curlPath, "APPDIR usr/bin/curl path used") + +-- Flatpak wins over APPDIR. +env.FLATPAK_ID = "com.theboisclub.gen1recomp" +os.execute('mkdir -p /tmp/fake-flatpak-app/bin') +-- We cannot create /app/bin without root; instead stub by temporarily +-- pointing resolveCurl via FLATPAK_ID alone when /app/bin/curl missing +-- falls through to APPDIR — document that Flatpak ships /app/bin/curl. +package.loaded["src.core.HostShell"] = nil +HostShell = require("src.core.HostShell") +path, kind = HostShell.resolveCurl() +-- Without a real /app/bin/curl, APPDIR still wins; that is fine for this +-- host-side unit test. Flatpak packaging installs /app/bin/curl. +check(kind == "bundled" or kind == "host", "resolveCurl returns a known kind") + +local diag = HostShell.curlDiagnostics() +check(type(diag) == "table", "curlDiagnostics returns a table") +check(diag.path ~= nil, "diagnostics include path") + +os.getenv = realGetenv +os.remove(curlPath) +print("ok") diff --git a/tests/engine/options_submenu_marquee_test.lua b/tests/engine/options_submenu_marquee_test.lua new file mode 100644 index 00000000..ce4b580e --- /dev/null +++ b/tests/engine/options_submenu_marquee_test.lua @@ -0,0 +1,152 @@ +-- The OPTION screen's grouped pages and its scrolling text. +-- +-- The flat row list outgrew the four-box viewport, so the battle, audio, +-- video and speed rows collapse into one opener each. Grouping happens after +-- the ui.options.rows hook and only builds self.view, so self.rows stays the +-- flat list a mod reads and edits. +-- +-- Text longer than its line used to run off the 160px panel with no clipping +-- (COLORS showed "1-A (Default) (B"); the highlighted row now scrolls instead. +-- luajit tests/engine/options_submenu_marquee_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local OptionsMenu = require("src.ui.OptionsMenu") +local Marquee = require("src.ui.Marquee") + +local function stubGame() + local stack = { items = {} } + function stack:push(s) self.items[#self.items + 1] = s; return s end + function stack:pop() self.items[#self.items] = nil end + function stack:top() return self.items[#self.items] end + return { + data = { rulesets = { gen1_faithful = { name = "GEN 1" } }, constants = {} }, + save = { options = {} }, + stack = stack, + modStatus = { available = {} }, + } +end + +local function ids(rows) + local out = {} + for i, row in ipairs(rows) do out[i] = row.id end + return table.concat(out, ",") +end + +local function find(rows, id) + for i, row in ipairs(rows) do if row.id == id then return row, i end end +end + +-- ------------------------------------------------------------- grouping + +local game = stubGame() +local menu = OptionsMenu.new(game) + +T.check(find(menu.rows, "battleBg"), "self.rows keeps the flat list for mods") +T.check(find(menu.rows, "musicVol"), "self.rows keeps the audio rows too") +for _, id in ipairs({ "battleBg", "speedBattle", "uiLayout", "tilt", "zoom", + "voidFill", "colors", "shaderfx", "shaderfx2", + "musicVol", "videoMode" }) do + T.check(not find(menu.view, id), id .. " moved onto a page") +end + +for _, spec in ipairs({ + { "group.battle", 6 }, { "group.audio", 3 }, { "group.video", 5 }, + { "group.speed", 4 }, { "group.graphics", 4 }, { "group.extras", 3 }, +}) do + local row = find(menu.view, spec[1]) + T.check(row, spec[1] .. " has an opener row") + T.eq(row.group, true, spec[1] .. " is marked as a group") +end + +-- Six battle rows on this build (PIKACHU VOL is Yellow-only, so AUDIO is 3). +T.eq(select(1, find(menu.view, "group.battle")).value(game), "6 OPTIONS", + "BATTLE OPTIONS counts its six rows") + +-- The top level is explicitly ordered, groups and singles interleaved. +T.eq(ids(menu.view):gsub("group%.", ""), + "speed,video,graphics,audio,performance,ruleset,battle,extras,mods," .. + "controls,dateFormat,timeFormat", + "the top level reads in the order ORDER names") +T.check(not find(menu.view, "textSpeed"), "TEXT SPEED moved onto the SPEED page") +T.check(#menu.view < #menu.rows, "the view is shorter than the flat list") + +-- A row in no group is untouched and keeps its order. +T.check(find(menu.view, "ruleset"), "RULESET stays on the top level") +T.check(find(menu.view, "performance"), "so does PERFORMANCE") +T.check(find(menu.view, "mods"), "and MODS") + +-- ------------------------------------------------------------- submenus + +local graphics = find(menu.view, "group.graphics") +graphics.activate(game) +T.eq(ids(game.stack:top().view), "colors,uiLetterbox,shaderfx,shaderfx2", + "GRAPHICS carries COLORS, UI LETTERBOX and both SHADER FX slots") +game.stack:pop() + +local extras = find(menu.view, "group.extras") +extras.activate(game) +T.eq(ids(game.stack:top().view), "tilt,zoom,voidFill", + "EXTRAS carries TILT, ZOOM and VOID FILL") +game.stack:pop() + +local video = find(menu.view, "group.video") +video.activate(game) +T.eq(ids(game.stack:top().view):match("^uiLayout"), "uiLayout", + "UI LAYOUT heads the VIDEO page") +game.stack:pop() + +local speed = find(menu.view, "group.speed") +speed.activate(game) +T.eq(ids(game.stack:top().view), + "textSpeed,speedOverworld,speedBattle,speedMenu", + "TEXT SPEED heads the SPEED page") +game.stack:pop() + +local battle, at = find(menu.view, "group.battle") +menu.index = at +battle.activate(game) +local sub = game.stack:top() +T.check(sub ~= menu, "opening a group pushes a screen") +T.eq(ids(sub.view), + "animations,battleStyle,battleLayout,battleFit,battleHud,battleBg", + "the page carries exactly its six battle rows, in order") +T.eq(sub.sub, true, "the page knows it is a submenu") +T.eq(sub.onCancel, nil, "BACK out of a page never fires the caller's close hook") + +-- focusRow reaches a grouped row by opening its page. +local game2 = stubGame() +local menu2 = OptionsMenu.new(game2) +local landed = menu2:focusRow("battleHud") +T.check(landed and landed ~= menu2, "focusRow opens the page a grouped row lives on") +T.eq(landed.view[landed.index].id, "battleHud", "and lands the cursor on it") +T.eq(menu2:focusRow("ruleset"), menu2, "an ungrouped row focuses in place") +T.eq(menu2.view[menu2.index].id, "ruleset", "on the row asked for") +T.eq(menu2:focusRow("nope"), nil, "an unknown id focuses nothing") + +-- ------------------------------------------------------------- marquee + +-- 16 characters fit beside a value's x=24 before the box frame at x=152; +-- the label's x=16 fits 17. +local long = "1-A (Default) (BGB)" +T.check(#long > 16, "the COLORS value really is too long for the line") + +-- 19 characters, 16 fit: three steps of overflow. Hold 1.0s, one char per +-- 0.25s, hold 1.0s on the tail, then repeat -- a 2.75s cycle. +T.eq(Marquee.at(long, 16, 0), long:sub(1, 16), "it holds at the start") +T.eq(Marquee.at(long, 16, 1.3), long:sub(2, 17), "then steps one char") +T.eq(Marquee.at(long, 16, 1.6), long:sub(3, 18), "then another") +T.eq(Marquee.at(long, 16, 2.4), long:sub(4, 19), "then holds on the tail") +T.eq(Marquee.at(long, 16, 2.8), long:sub(1, 16), "before wrapping back") +T.eq(Marquee.at(long, 16, 2.4):sub(-4), "BGB)", + "so the tail the panel used to clip becomes readable") +for _, at2 in ipairs({ 0, 1.3, 1.6, 2.4, 2.8 }) do + T.eq(#Marquee.at(long, 16, at2), 16, + "every frame fills the line exactly") +end + +T.eq(Marquee.at("SHORT", 16, 2.0), "SHORT", + "text that fits is never scrolled") + +T.finish("options submenu + marquee") diff --git a/tests/engine/palette_test.lua b/tests/engine/palette_test.lua new file mode 100644 index 00000000..82577ebf --- /dev/null +++ b/tests/engine/palette_test.lua @@ -0,0 +1,447 @@ +-- Tests the COLORS picker's 699-palette pack (src/render/Palette.lua) and +-- its substitution seam in src/render/PaletteFX.lua. ROM-free, since the +-- pack is committed data rather than ROM-imported. +-- luajit tests/engine/palette_test.lua +-- +-- Covers the ramp ordering flip in Palette.ramp() (the pack stores darkest +-- first, PaletteFX wants lightest first), the substitution seam itself (a +-- custom palette has to win outright and get a zone invented for it, same +-- as OG/OG INV/CLASSIC), and that BattleState/WideBattle's mono checks stay +-- in sync with that seam. + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local function hex(c) + return type(c) == "table" and ("%02x%02x%02x"):format(c[1], c[2], c[3]) or "?" +end + +local Palette = require("src.render.Palette") +local PaletteFX = require("src.render.PaletteFX") + +-- ------- the pack + +do + eq(#Palette.packs(), 32, "32 groups") + local total = 0 + for _, group in ipairs(Palette.packs()) do + total = total + #(group.palettes or {}) + end + eq(total, 699, "699 palettes") +end + +-- ------- the folders + +do + local seen, total = {}, 0 + for _, category in ipairs(Palette.categories()) do + for _, item in ipairs(category.palettes) do + total = total + 1 + seen[item.id] = category.key + end + end + eq(total, 699, "every palette is filed") + + local counted = 0 + for _ in pairs(seen) do counted = counted + 1 end + eq(counted, 699, "no palette is filed twice") + + -- the DMG ramp is the case HUE_ARC was tuned around + eq(seen[Palette.packId("GalleryWebApp Palettes", "Original GB")], + "single", "the DMG green is one colour") + eq(seen[Palette.packId("BGB", "Grey")], "grey", "BGB Grey is grey") + eq(seen[Palette.packId("GB Color (Unique Palettes)", "GBC Palette 119 (World)")], + "full", "a yellow and red cart palette is full colour") + -- the GBC boot palette for Red is white/pink/dark red/black: a red ramp + eq(seen[Palette.packId("GB Color (Unique Palettes)", + "GBC Palette 093 (USA, Europe)")], + "single", "a tinted grey ramp is one colour") + + local first = Palette.categories()[1].palettes[1] + check(type(first.id) == "string" and Palette.ramp(first.id) ~= nil, + "a bucket entry carries a resolvable id") + check(type(first.pack) == "string" and first.pack ~= "", + "a bucket entry remembers its source pack") + + local key, at = Palette.locate("p:BGB/Blue") + eq(key, "single", "locate answers the bucket") + eq(Palette.categories()[2].palettes[at].id, "p:BGB/Blue", + "and the tile") + check(Palette.locate("p:Nope/Nothing") == nil, "an unknown id is nowhere") +end + +-- ------- classifying a ramp + +do + local function cat(a, b, c, d) return Palette.categoryOf({ a, b, c, d }) end + eq(cat({ 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 }), + "grey", "four greys are greyscale") + eq(cat({ 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 }), + "single", "one hue at four strengths is a single colour") + eq(cat({ 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 }), + "single", "greys among one hue do not make it full colour") + eq(cat({ 255, 0, 0 }, { 0, 255, 0 }, { 0, 0, 255 }, { 0, 0, 0 }), + "full", "three hues is full colour") + eq(cat({ 255, 40, 20 }, { 255, 10, 40 }, { 128, 5, 20 }, { 40, 2, 6 }), + "single", "a ramp straddling 0 degrees is one colour") + check(Palette.categoryOf(nil) == nil, "nothing classifies as nothing") +end + +-- ------- hex decoding + +do + local c = Palette.unhex("0f380f30623077a1129bbc0f") + check(c and hex(c[1]) == "0f380f", "first colour is the string's first") + check(c and hex(c[4]) == "9bbc0f", "last colour is the string's last") + check(Palette.unhex("abc") == nil, "short string rejected") + check(Palette.unhex("zz380f30623077a1129bbc0f") == nil, "non-hex rejected") + check(Palette.unhex(nil) == nil, "nil rejected") +end + +-- ------- ordering +-- +-- #9BBC0F is the PAPER and #0F380F is the INK, so ramp()[1] must be pale. + +do + local id = Palette.packId("GalleryWebApp Palettes", "Original GB") + local ramp = Palette.ramp(id) + check(ramp ~= nil, "Original GB resolves") + eq(hex(ramp[1]), "9bbc0f", "ramp[1] is the lightest (paper)") + eq(hex(ramp[4]), "0f380f", "ramp[4] is the darkest (ink)") + + local sw = Palette.swatch(id) + eq(hex(sw[1]), "0f380f", "swatch[1] is the darkest") + eq(hex(sw[4]), "9bbc0f", "swatch[4] is the lightest") +end + +-- cross-check against PaletteFX.GBC_BG, the same palette hardcoded there +do + local ramp = Palette.ramp(Palette.packId( + "GB Color (Unique Palettes)", "GBC Palette 093 (USA, Europe)")) + for i = 1, 4 do + eq(hex(ramp[i]), hex(PaletteFX.GBC_BG[i]), + "pack Red ramp[" .. i .. "] agrees with PaletteFX.GBC_BG") + end +end + +-- ------- monochrome + +do + local id = Palette.monoId(0x9b, 0xbc, 0x0f) + eq(id, "m:9bbc0f", "id encodes as hex") + local ramp = Palette.ramp(id) + eq(hex(ramp[1]), "9bbc0f", "lightest is the chosen colour") + check(ramp[4][1] < ramp[1][1], "darkest is darker than lightest") + eq(Palette.label(id), "GREEN", "a known colour gets its name") + eq(Palette.label("m:123456"), "MONO", "an unknown one falls back") +end + +-- ------- ids + +do + check(Palette.label("") == nil, "empty id has no label") + eq(Palette.label("p:BGB/Blue"), "Blue", "pack label is the name") + check(Palette.ramp("") == nil, "empty id resolves to nothing") + check(Palette.ramp("p:Nope/Nothing") == nil, "missing palette resolves to nothing") + check(Palette.ramp("garbage") == nil, "malformed id resolves to nothing") +end + +-- ------- every palette in the pack parses + +do + local bad, checked = {}, 0 + for _, group in ipairs(Palette.packs()) do + for _, entry in ipairs(group.palettes or {}) do + local ramp = Palette.ramp(Palette.packId(group.name, entry[1])) + checked = checked + 1 + if not ramp then bad[#bad + 1] = group.name .. "/" .. tostring(entry[1]) end + end + end + eq(checked, 699, "checked every palette") + eq(#bad, 0, "all resolve (" .. table.concat(bad, ", ", 1, math.min(#bad, 3)) .. ")") +end + +-- ------- the picker's state machine +-- +-- update() touches no love API, so we can drive it with a stub game. + +local PaletteScreen = require("src.ui.PaletteScreen") + +do + local pressed = {} + + local function open(stored) + local state = { stored = stored or "", popped = 0, mode = "redpp" } + state.screen = PaletteScreen.new({ + input = { wasPressed = function(_, k) return pressed[k] == true end }, + stack = { states = {}, + pop = function() state.popped = state.popped + 1 end }, + }, { + palette = Palette, + get = function() return state.stored end, + set = function(v) state.stored = v end, + modes = { { "SGB", "gbc" }, { "ADVANCED", "redpp" } }, + getMode = function() return state.mode end, + setMode = function(v) state.mode = v end, + }) + return state + end + + local function press(state, key) + pressed = { [key] = true } + state.screen:update() + pressed = {} + end + + local fresh = open() + eq(fresh.screen.view, "root", "a fresh profile opens at the root") + eq(#fresh.screen.rows, 6, "four folders and two modes") + eq(fresh.screen.rows[4].label, "MONOCHROME") + check(fresh.screen.rows[4].folder, "MONOCHROME is the fourth folder") + eq(fresh.screen.rows[5].mode, "gbc") + check(not fresh.screen.rows[5].folder, "an engine mode is a row and not a folder") + + press(fresh, "a") + eq(fresh.screen.view, "tiles", "A on a folder opens its grid one level down") + eq(#fresh.screen.stackTrail, 1) + press(fresh, "b") + eq(fresh.screen.view, "root", "B steps back up to the root") + eq(fresh.popped, 0) + press(fresh, "b") + eq(fresh.popped, 1, "B at the root closes the screen") + + -- a stored selection should open straight into its grid + local stored = open("p:BGB/Blue") + eq(stored.screen.view, "tiles", "a stored palette opens in its folder") + eq(stored.screen.group.key, "single", "and in the RIGHT folder") + eq(stored.screen:idAt(stored.screen.index), "p:BGB/Blue", + "with the cursor on the stored tile") + eq(#stored.screen.stackTrail, 1, "the skipped level is on the trail") + press(stored, "b") + eq(stored.screen.view, "root", "B steps up instead of closing") + eq(stored.popped, 0) + eq(stored.screen.index, 2, "and lands on the folder it came from") + + -- an engine mode and a palette share the same slot + local modes = open("p:BGB/Blue") + press(modes, "b") + for _ = 1, 3 do press(modes, "down") end + eq(modes.screen.rows[modes.screen.index].mode, "gbc", "walked to the SGB row") + press(modes, "a") + eq(modes.stored, "", "picking an engine mode clears the palette") + eq(modes.mode, "gbc", "and sets the mode") + + local pick = open() + press(pick, "a") + press(pick, "right") + local wanted = pick.screen:idAt(pick.screen.index) + press(pick, "a") + eq(pick.stored, wanted, "A on a tile stores that tile's id") + check(wanted ~= nil) +end + +-- ------- the picker's REAL wiring: PaletteScreen.new(game) with no opts +-- +-- Everything above injects its own opts to keep update() engine-free, so +-- none of it would catch a bug in liveOpts/dropConflictingMode. This +-- builds the screen the way OptionsMenu actually does, with no opts at all. + +do + local game = { + input = { wasPressed = function() return false end }, + stack = { states = {}, pop = function() end }, + save = { options = { colors = "redpp" } }, + } + PaletteFX.mode = "redpp" + local screen = PaletteScreen.new(game) + check(screen.view == "root", "a live-opts screen opens at the root") + check(type(screen.get) == "function" and type(screen.set) == "function", + "liveOpts wired real get/set functions") + eq(game.save.options.colors, "gbc", + "opening the picker while ADVANCED was active dropped it to SGB " + .. "(dropConflictingMode ran)") + eq(PaletteFX.mode, "gbc", "and the live mode followed") + + local id = Palette.packId("BGB", "Blue") + screen.set(id) + eq(game.save.options.palette, id, "storing a palette writes save.options.palette") + eq(hex(PaletteFX.customRamp[1]), hex(Palette.ramp(id)[1]), + "and PaletteFX.setCustomRamp actually ran") + + screen.set("") + check(PaletteFX.customRamp == nil, "storing empty clears the custom ramp") + PaletteFX.mode = "gbc" +end + +-- ------- PaletteFX seam: a custom ramp wins outright + +do + PaletteFX.setCustomRamp(nil) + PaletteFX.mode = "gbc" + check(not PaletteFX.forcesRawGrays(), "no custom ramp: forcesRawGrays is false") + + local id = Palette.packId("GalleryWebApp Palettes", "Original GB") + local ramp = Palette.ramp(id) + PaletteFX.setCustomRamp(ramp) + check(PaletteFX.forcesRawGrays(), "a stored custom ramp: forcesRawGrays is true") + + local someZoneColors = { { 9, 9, 9 }, { 8, 8, 8 }, { 7, 7, 7 }, { 6, 6, 6 } } + local out = PaletteFX.effectiveColors(someZoneColors) + for i = 1, 4 do + eq(hex(out[i]), hex(ramp[i]), + "effectiveColors[" .. i .. "] is the custom ramp, not the zone's own colours") + end + + -- it should win even over a mode with its own fixed palette + PaletteFX.mode = "classic" + local out2 = PaletteFX.effectiveColors(someZoneColors) + eq(hex(out2[1]), hex(ramp[1]), "a custom ramp beats CLASSIC too") + PaletteFX.mode = "gbc" +end + +-- ------- picker immunity +-- +-- The picker's swatches are real RGB, so effectiveColors and ensureZones +-- both have to back off while it's on top or it would recolour its own +-- swatches. + +do + local Game = require("src.core.Game") + + Game.stack = nil + check(not PaletteFX.pickerActive(), "no stack: pickerActive is false") + + Game.stack = { states = { { screenId = "OptionsMenu" } } } + check(not PaletteFX.pickerActive(), "some other screen on top: pickerActive is false") + + Game.stack = { states = { { screenId = "OptionsMenu" }, + { screenId = "PaletteScreen" } } } + check(PaletteFX.pickerActive(), "PaletteScreen on top of the stack: pickerActive is true") + + local id = Palette.packId("BGB", "Red") + local ramp = Palette.ramp(id) + PaletteFX.setCustomRamp(ramp) + PaletteFX.mode = "gbc" + + local someZoneColors = { { 9, 9, 9 }, { 8, 8, 8 }, { 7, 7, 7 }, { 6, 6, 6 } } + local out = PaletteFX.effectiveColors(someZoneColors) + eq(hex(out[1]), hex(someZoneColors[1]), + "with the picker on top, effectiveColors does NOT substitute the custom ramp") + + check(PaletteFX.ensureZones(nil) == nil, + "with the picker on top, ensureZones invents nothing for the custom ramp") + + -- closing the picker should restore the substitution immediately + Game.stack = { states = { { screenId = "OptionsMenu" } } } + check(not PaletteFX.pickerActive(), "closing the picker: pickerActive is false again") + local out2 = PaletteFX.effectiveColors(someZoneColors) + eq(hex(out2[1]), hex(ramp[1]), "and the custom ramp substitutes again") + local invented = PaletteFX.ensureZones(nil) + check(invented and #invented == 1, "and ensureZones invents its zone again") + + Game.stack = nil + PaletteFX.setCustomRamp(nil) +end + +-- ------- PaletteFX seam: the invented zone, end to end +-- +-- A state with no SGB zones of its own gets one invented by ensureZones, +-- and effectiveColors then has to turn that zone's colours into the +-- chosen ramp. + +do + PaletteFX.setCustomRamp(nil) + check(PaletteFX.ensureZones(nil) == nil, + "no custom ramp, no mono mode: ensureZones invents nothing") + + local id = Palette.packId("BGB", "Red") + local ramp = Palette.ramp(id) + PaletteFX.setCustomRamp(ramp) + + local invented = PaletteFX.ensureZones(nil) + check(invented and #invented == 1, "a custom ramp invents one zone") + eq(invented[1].w, 160, "the invented zone is whole-screen (w)") + eq(invented[1].h, 144, "the invented zone is whole-screen (h)") + for i = 1, 4 do + eq(hex(invented[1].colors[i]), hex(PaletteFX.GRAYS[i]), + "the invented zone is plain DMG grays, not the ramp itself") + end + + -- run it back through effectiveColors, the way Renderer.sendColors would + local final = PaletteFX.effectiveColors(invented[1].colors) + for i = 1, 4 do + eq(hex(final[i]), hex(ramp[i]), + "the invented zone resolves to the custom ramp at send time") + end + + local realZones = { { colors = { { 1, 1, 1 } }, x = 0, y = 0, w = 40, h = 40 } } + check(PaletteFX.ensureZones(realZones) == realZones, + "a state with real zones is left alone") + + PaletteFX.setCustomRamp(nil) + check(not PaletteFX.forcesRawGrays(), "clearing the ramp clears forcesRawGrays") +end + +-- ------- the four sync points +-- +-- BattleState:picImage, BattleState:drawZonePass, WideBattle's monoMode and +-- PaletteFX.ensureZones all gate on the same "forced raw grays" condition. +-- This is a structural check (source-level, no ROM) that none of the four +-- got missed when forcesRawGrays was added. + +do + local function read(path) + local f = assert(io.open(path, "r")) + local src = f:read("*a") + f:close() + return src + end + + local battleSrc = read("src/battle/BattleState.lua") + local picImage = battleSrc:match("function BattleState:picImage%(img%)(.-)\nend") + check(picImage and picImage:find("forcesRawGrays", 1, true) ~= nil, + "picImage's mono check includes forcesRawGrays") + + local drawZonePass = battleSrc:match( + "function BattleState:drawZonePass%(src, sx, sy%)(.-)\nend") + check(drawZonePass and drawZonePass:find("forcesRawGrays", 1, true) ~= nil, + "drawZonePass's mono check includes forcesRawGrays") + + local wideSrc = read("src/battle/WideBattle.lua") + local monoMode = wideSrc:match("local function monoMode%(%)(.-)\nend") + check(monoMode and monoMode:find("forcesRawGrays", 1, true) ~= nil, + "WideBattle's monoMode includes forcesRawGrays") + + local fxSrc = read("src/render/PaletteFX.lua") + local ensureZones = fxSrc:match("function PaletteFX.ensureZones%(zones%)(.-)\nend") + check(ensureZones and ensureZones:find("forcesRawGrays", 1, true) ~= nil, + "ensureZones itself includes forcesRawGrays") +end + +-- ------- COLORS row wiring +-- +-- OptionsMenu's colors row should open the picker instead of cycling in place. + +do + local optSrc = (function() + local f = assert(io.open("src/ui/OptionsMenu.lua", "r")) + local s = f:read("*a") + f:close() + return s + end)() + -- bounded to the row itself, or the scan would run into TILT's own step + local block = optSrc:match('{ id = "colors",(.-)\n { id =') + check(block ~= nil, "colors row block found") + check(block:find("activate = function%(g%)") ~= nil, + "colors row has an activate") + check(block:find('Screens"%).push%(g, "PaletteScreen"%)', 1, false) ~= nil + or block:find('Screens").push(g, "PaletteScreen")', 1, true) ~= nil, + "colors row's activate pushes PaletteScreen") + check(block:find("step = function") == nil, + "colors row no longer has a step") +end + +T.finish("palette") diff --git a/tests/engine/portable_writable_probe_test.lua b/tests/engine/portable_writable_probe_test.lua new file mode 100644 index 00000000..761f2dbd --- /dev/null +++ b/tests/engine/portable_writable_probe_test.lua @@ -0,0 +1,65 @@ +-- Portable writability probe + Flatpak skip. +-- luajit tests/engine/portable_writable_probe_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local tmp = os.getenv("TMPDIR") or "/tmp" +local base = tmp .. "/gen1recomp-portable-probe-" .. tostring(os.time()) +os.execute('mkdir -p "' .. base .. '"') +local marker = assert(io.open(base .. "/portable.txt", "wb")) +marker:write("1\n") +marker:close() + +-- Minimal LOVE stubs so SaveData.gameFolders returns our base. +_G.love = { + filesystem = { + getSource = function() return base .. "/game" end, + getSourceBaseDirectory = function() return base end, + }, + system = { + getOS = function() return "Linux" end, + }, +} + +local env = {} +local realGetenv = os.getenv +function os.getenv(k) + if env[k] ~= nil then return env[k] end + return realGetenv(k) +end + +package.loaded["src.core.SaveData"] = nil +local SaveData = require("src.core.SaveData") + +check(SaveData.isPortable() == true, "writable portable parent activates portable mode") +eq(SaveData.portableBaseDir(), base, "portable base is the probed folder") + +SaveData._resetPortableCacheForTests() +env.FLATPAK_ID = "com.theboisclub.gen1recomp" +check(SaveData.isPortable() == false, "Flatpak ignores portable.txt") +eq(SaveData.portableBaseDir(), nil, "Flatpak has no portable base") + +-- Read-only parent: create a dir we cannot write (best-effort). +SaveData._resetPortableCacheForTests() +env.FLATPAK_ID = nil +local ro = base .. "-ro" +os.execute('mkdir -p "' .. ro .. '" && touch "' .. ro .. '/portable.txt" && chmod 555 "' .. ro .. '"') +_G.love.filesystem.getSourceBaseDirectory = function() return ro end +_G.love.filesystem.getSource = function() return ro .. "/game" end +SaveData._resetPortableCacheForTests() +-- chmod 555 may still allow owner write on some FS; accept either outcome +-- but must not throw. +local ok, portable = pcall(function() return SaveData.isPortable() end) +check(ok, "RO portable probe must not throw") +if portable then + check(SaveData.portableBaseDir() ~= nil, "if still writable, portable stays on") +else + eq(SaveData.portableBaseDir(), nil, "unwritable portable parent falls back") +end + +os.getenv = realGetenv +os.execute('chmod 755 "' .. ro .. '" 2>/dev/null; rm -rf "' .. base .. '" "' .. ro .. '"') +print("ok") diff --git a/tests/engine/sync_engine_test.lua b/tests/engine/sync_engine_test.lua index 22a79e9e..3ef6dab9 100644 --- a/tests/engine/sync_engine_test.lua +++ b/tests/engine/sync_engine_test.lua @@ -180,6 +180,58 @@ do T.eq(eng.phase, "idle", "and the sync ends idle") end +-- Identical playtime is not a fork. The Gold prompt the player saw offered +-- "GOLD - 8 badges - 3:46 - 9 seen" against "GOLD - 8 badges - 3:46 - 9 seen": +-- the same minute of the same playthrough, two saves that differ only in +-- their savedAt stamp. Nothing to choose between, so nothing to ask. +do + T.eq(SyncEngine.samePlaytime( + { summary = { timeText = "3:46" } }, + { summary = { timeText = "3:46" } }), true, + "the same H:MM is the same point in the playthrough") + T.eq(SyncEngine.samePlaytime( + { summary = { timeText = "3:46" } }, + { summary = { timeText = "3:47" } }), false, + "one minute apart is a real fork") + T.eq(SyncEngine.samePlaytime({ playTime = 13560 }, { playTime = 13599 }), + true, "Gen 1's seconds count compares to the minute, not the second") + T.eq(SyncEngine.samePlaytime({ playTime = 13560 }, { playTime = 13620 }), + false, "and a whole minute apart still forks") + -- Gen 2 stores playTime as a table, so meta.playTime is nil on a Gold + -- save and only timeText survives; a missing time must never read as a + -- match, or an unknowable conflict would be silently discarded. + T.eq(SyncEngine.samePlaytime({}, {}), false, + "two unknown playtimes are not a match") + T.eq(SyncEngine.samePlaytime({ summary = { timeText = "3:46" } }, {}), false, + "and neither is one unknown side") +end + +do + local state = linkedState() + SyncState.setRev(state, "red/abc", 7, 500) + local entry = saveEntry("red", "abc", 700, 650) + entry.meta.summary.timeText = "3:46" + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"red/abc":{"rev":9,"meta":{"savedAt":760,' .. + '"sessionStart":600,"summary":{"name":"ASH","timeText":"3:46"}}}}}' }, + ["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":10}' }, + }, { entry }, state) + + eng:syncNow() + pump(eng) + T.eq(#eng.conflicts, 0, "matching playtime raises no conflict") + T.eq(#eng.state.pendingConflicts, 0, "and leaves nothing pending") + T.neq(eng.phase, "conflict", "so the player is never prompted") + local put + for _, req in ipairs(transport.sent) do + if req.method == "PUT" then put = req end + end + T.check(put ~= nil, "this device's copy is pushed instead") + T.check(put and put.body:find('"force":true', 1, true) ~= nil, + "forced past the moved rev, since there is nothing to preserve") +end + local function conflictEngine() local state = linkedState() SyncState.setRev(state, "red/abc", 7, 500) diff --git a/tests/engine/ui_letterbox_test.lua b/tests/engine/ui_letterbox_test.lua new file mode 100644 index 00000000..9e813d1d --- /dev/null +++ b/tests/engine/ui_letterbox_test.lua @@ -0,0 +1,64 @@ +-- UI LETTERBOX (save.options.uiLetterbox): what fills the window around a +-- 160x144 screen. AUTO keeps what each screen was authored with, so the +-- default is byte-identical to the look before the option existed. +-- luajit tests/engine/ui_letterbox_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Letterbox = require("src.render.Letterbox") + +T.eq(require("src.core.SaveData").newGame().options.uiLetterbox, "auto", + "Gen 1 starts on AUTO") +T.eq(require("src.core.gen2.Save").DEFAULT_OPTIONS.uiLetterbox, "auto", + "so does Gen 2") + +-- AUTO hands back the colour the caller was authored with, untouched: the +-- Pokedex stays black, a menu stays white, the naming screen stays paper. +Letterbox.setMode("auto") +T.same({ Letterbox.fill(0, 0, 0) }, { 0, 0, 0 }, "AUTO keeps an authored black") +T.same({ Letterbox.fill(1, 1, 1) }, { 1, 1, 1 }, "AUTO keeps an authored white") +T.same({ Letterbox.fill(0.5, 0.25, 0.75) }, { 0.5, 0.25, 0.75 }, + "AUTO keeps an authored paper colour") + +-- PALETTE reads the caller's paper, because the two generations keep a picked +-- ramp in different modules. Gen 1 used to come back white here: anything that +-- sniffed for GbcPalette found it present on Gen 1 too and answered with Gen +-- 2's white default instead of PaletteFX's actual paper. +Letterbox.setMode("palette") +T.same({ Letterbox.fill(0, 0, 0, function() return 0.25, 0.5, 1 end) }, + { 0.25, 0.5, 1 }, "PALETTE takes the caller's paper") +T.same({ Letterbox.fill(0, 0, 0) }, { 0, 0, 0 }, + "with no paper reader it falls back to the authored colour, never to white") +T.same({ Letterbox.fill(1, 1, 1, function() return nil end) }, { 1, 1, 1 }, + "and a reader that cannot answer falls back too") + +Letterbox.setMode("black") +T.same({ Letterbox.fill(1, 1, 1) }, { 0, 0, 0 }, "BLACK overrides a white screen") +Letterbox.setMode("white") +T.same({ Letterbox.fill(0, 0, 0) }, { 1, 1, 1 }, "WHITE overrides a black screen") + +-- An unknown or absent value degrades to AUTO rather than to a colour, so a +-- save written before the option existed keeps its look. +T.eq(Letterbox.normalize(nil), "auto", "no stored value is AUTO") +T.eq(Letterbox.normalize("nonsense"), "auto", "and so is a value we cannot read") +Letterbox.setMode(nil) +T.same({ Letterbox.fill(0, 0, 0) }, { 0, 0, 0 }, "so an old save is unchanged") + +-- The ladder wraps both ways, like every other option row. +T.eq(Letterbox.cycle("auto", 1), "black", "right steps off AUTO") +T.eq(Letterbox.cycle("palette", 1), "auto", "and wraps at the end") +T.eq(Letterbox.cycle("auto", -1), "palette", "left wraps back") +for _, id in ipairs(Letterbox.MODES) do + T.check(Letterbox.label(id) ~= nil, id .. " has a label") +end +T.eq(Letterbox.label("uiLetterbox"), "AUTO", "an unknown id still labels") + +-- applyOptions is the seam both Game:applyOptions and Game2:applyOptions call. +Letterbox.applyOptions({ uiLetterbox = "white" }) +T.eq(Letterbox.mode, "white", "applyOptions installs the saved mode") +Letterbox.applyOptions({}) +T.eq(Letterbox.mode, "auto", "and an options table without one is AUTO") + +Letterbox.setMode("auto") +T.finish("ui letterbox") diff --git a/tests/engine/update_check_tests.lua b/tests/engine/update_check_tests.lua index 99c2516b..b24139e9 100644 --- a/tests/engine/update_check_tests.lua +++ b/tests/engine/update_check_tests.lua @@ -43,6 +43,10 @@ eq(Check.fullAssetName("1.4.2", "Android", "arm64"), "gen1recomp-1.4.2-android.apk", "Android full asset mapping") eq(Check.fullAssetName("1.4.2", "Linux", "aarch64"), "gen1recomp-1.4.2-linux-arm64.AppImage", "Linux ARM package mapping") +eq(Check.fullAssetName("1.4.2", "Linux", "x64"), + "gen1recomp-1.4.2-linux-x86_64.AppImage", "Linux x86_64 AppImage mapping") +eq(Check.fullAssetName("1.4.2", "Linux", "x64", "flatpak"), + "gen1recomp-1.4.2-linux.flatpak", "Linux Flatpak package mapping") eq(Check.fullAssetName("1.4.2", "iOS", "arm64"), "gen1recomp++-1.4.2-ios.ipa", "iOS package mapping") eq(Check.fullAssetName("not-a-version", "Android", "arm64"), nil, diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua index 8843026d..f82cf496 100644 --- a/tests/gen2_menus_test.lua +++ b/tests/gen2_menus_test.lua @@ -360,33 +360,51 @@ local options = OptionsMenu.new(optionsGame, { -- speed, display, SHADER FX + SHADER FX 2 (the second slot added alongside -- the dual-shader feature), video mode, screen position, the mobile-gated -- touch three (buildRows), MAX FPS, BATTLE BG and CANCEL. -check("twenty-seven rows", #OptionsMenu.ROWS, 27) +check("twenty-eight rows", #OptionsMenu.ROWS, 28) check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame") check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls") check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol") -check("last row is CANCEL", OptionsMenu.ROWS[#OptionsMenu.ROWS].cancel, true) -check("starts on TEXT SPEED", options:row().key, "textSpeed") -check("default text speed", options.options.textSpeed, "MID") +check("last row is BACK", OptionsMenu.ROWS[#OptionsMenu.ROWS].cancel, true) +-- PRINT is wGBPrinterBrightness and there is no Game Boy Printer here, so +-- buildRows hides it: the descriptor and the save key survive, the row does +-- not reach the screen. +local function hasRow(rows, key) + for _, row in ipairs(rows) do if row.key == key then return true end end + return false +end +check("PRINT is still a descriptor", hasRow(OptionsMenu.ROWS, "print"), true) +check("but never reaches the screen", hasRow(options.rows, "print"), false) +check("and the save keeps its value", + Save.defaultOptions().print ~= nil, true) +-- The rows are grouped into pages now, so the top level opens on the SPEED +-- group and TEXT SPEED is the first row of the page it opens. +check("starts on the SPEED group", options:row().id, "group.speed") +local speedPage = options:focusRow("textSpeed") +check("TEXT SPEED is on a page", speedPage ~= options, true) +check("and the cursor lands on it", speedPage:row().key, "textSpeed") +check("default text speed", speedPage.options.textSpeed, "MID") optionsInput:press("right") -options:update(0) +speedPage:update(0) check("right cycles forward", options.options.textSpeed, "SLOW") optionsInput:press("right") -options:update(0) +speedPage:update(0) check("right wraps", options.options.textSpeed, "FAST") optionsInput:press("left") -options:update(0) +speedPage:update(0) check("left wraps back", options.options.textSpeed, "SLOW") +check("a page edits the caller's own options table", + speedPage.options == options.options, true) -optionsInput:press("down") -options:update(0) -check("down moves a row", options:row().key, "battleScene") +local battlePage = options:focusRow("battleScene") +check("BATTLE SCENE is on the battle page", battlePage:row().key, "battleScene") optionsInput:press("right") -options:update(0) +battlePage:update(0) check("battle scene toggles off", options.options.battleScene, false) -- FRAME is 1-8 and wraps. -options.index = 7 -check("frame row", options:row().frame, true) +local framePage = options:focusRow("frame") +check("frame row", framePage:row().frame, true) +options = framePage options.options.frame = 8 optionsInput:press("right") options:update(0) @@ -406,10 +424,10 @@ local exiting = OptionsMenu.new(exitGame, { }) -- The screen's own rows, not ROWS: buildRows drops the touch three off a -- desktop, so the raw descriptor count overshoots CANCEL. -exiting.index = #exiting.rows +exiting.index = #exiting.view exitInput:press("a") exiting:update(0) -check("CANCEL leaves", savedOptions ~= nil, true) +check("BACK leaves", savedOptions ~= nil, true) -- --------------------------------------------------------- start menu @@ -934,16 +952,13 @@ local colorRow = select(2, rowNamed("COLOR")) check("COLOR is a row", colorRow ~= nil, true) check("and it defaults to the cart's own colour", Save.DEFAULT_OPTIONS.color, "gbc") -scrollOptions.options.color = "gbc" -scrollOptions:cycle(colorRow, 1) -check("right steps to DMG", scrollOptions.options.color, "dmg") -scrollOptions:cycle(colorRow, 1) -check("then CLASSIC", scrollOptions.options.color, "classic") -scrollOptions:cycle(colorRow, 1) -check("and wraps back to GBC", scrollOptions.options.color, "gbc") -scrollOptions:cycle(colorRow, -1) -check("left walks the ladder the other way", scrollOptions.options.color, - "classic") +-- COLOR no longer cycles in place (tests/engine/gen2_palette_picker_test.lua +-- covers the picker it opens instead, end to end); the ladder itself still +-- steps via the `2` hotkey (src/core/Game2.lua:hotkey), untouched here. +check("COLOR no longer cycles in place", colorRow.cycle, nil) +check("COLOR opens the picker instead", type(colorRow.activate), "function") +scrollOptions.options.color = "classic" +GbcPalette.setMode("classic") check("CLASSIC is the only mode with a present pass", GbcPalette.presentColors() ~= nil, true) GbcPalette.setMode("dmg") @@ -965,16 +980,16 @@ check("VOID FILL follows ZOOM", OptionsMenu.ROWS[zoomIndex + 1].label, "VOID FILL") check("and TILT follows VOID FILL", OptionsMenu.ROWS[zoomIndex + 2].label, "TILT") -check("SHADER FX follows COLOR follows TILT", OptionsMenu.ROWS[tiltIndex + 2].label, - "SHADER FX") -check("SHADER FX 2 follows SHADER FX", OptionsMenu.ROWS[tiltIndex + 3].label, - "SHADER FX 2") -check("VIDEO MODE follows SHADER FX 2", OptionsMenu.ROWS[tiltIndex + 4].label, - "VIDEO MODE") -check("and SCREEN POS follows it", OptionsMenu.ROWS[tiltIndex + 5].label, - "SCREEN POS") -check("and TOUCH PAD follows that", OptionsMenu.ROWS[tiltIndex + 6].label, - "TOUCH PAD") +-- By sequence rather than by offset, so inserting a row in the display block +-- moves the whole run instead of breaking six separate index assertions. +do + local run = { "TILT", "COLOR", "UI LETTERBOX", "SHADER FX", + "SHADER FX 2", "VIDEO MODE", "SCREEN POS", "TOUCH PAD" } + for at, want in ipairs(run) do + check("display block order: " .. want, + OptionsMenu.ROWS[tiltIndex + at - 1].label, want) + end +end local videoRow = select(2, rowNamed("VIDEO MODE")) check("VIDEO MODE is a row", videoRow ~= nil, true) @@ -2518,7 +2533,8 @@ local function modRowChecks() local og, oi = newGame(nil) local om = OptionsMenu.new(og, { options = Save.defaultOptions() }) check("gen2 OPTION takes the hook's row", om.rows[#om.rows].id, "modrow") - om.index = #om.rows + -- A hook row is in no group, so it stays on the top level. + check("and keeps it reachable there", om:focusRow("modrow"), om) oi:press("a") om:update(0) check("A on a mod row calls activate", fired, 1) diff --git a/tests/gen2_screen_layout_test.lua b/tests/gen2_screen_layout_test.lua index c5a42d89..c9f78ebc 100644 --- a/tests/gen2_screen_layout_test.lua +++ b/tests/gen2_screen_layout_test.lua @@ -146,8 +146,23 @@ Chrome.cursor = function(tx, ty) cursorAt = { x = tx, y = ty } end Chrome.clear = function() end Chrome.textbox = function() end -local options = OptionsMenu.new({}, { options = {} }) -options:drawPanel() +-- TEXT SPEED and FRAME sit on group pages now; the pages are the same +-- screen and draw at the same StringOptions coordinates, which is what this +-- pins. A stack, because opening a page pushes one. +local optStack = { items = {} } +function optStack:push(s) self.items[#self.items + 1] = s; return s end +function optStack:pop() self.items[#self.items] = nil end +function optStack:top() return self.items[#self.items] end + +local options = OptionsMenu.new({ stack = optStack }, { options = {} }) +options:focusRow("textSpeed"):drawPanel() +local framePage = options:focusRow("frame") +framePage.index = 1 +for i, row in ipairs(framePage:visible()) do + if row.frame then framePage.index = i end +end +framePage:ensureVisible() +framePage:drawPanel() Chrome.print, Chrome.cursor = realPrint, realCursor Chrome.clear, Chrome.textbox = realClear, realBox diff --git a/tests/mod_battle_tests.lua b/tests/mod_battle_tests.lua index 4c310756..6090ef92 100644 --- a/tests/mod_battle_tests.lua +++ b/tests/mod_battle_tests.lua @@ -293,9 +293,8 @@ do end }, stack = { pop = function() end } } local menu = OptionsMenu.new(game) - for i, row in ipairs(menu.rows) do - if row.id == "ruleset" then menu.index = i end - end + -- RULESET is in no group, so this focuses it on the top level. + check(menu:focusRow("ruleset") == menu, "RULESET focuses in place") local function press(key) pressed = { [key] = true } menu:update(1 / 60) diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 4cb7576b..2afd28d1 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -285,7 +285,7 @@ local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout", "battleFit", "battleHud", "battleBg", "uiLayout", "ruleset", "musicVol", "sfxVol", "musicFilter", "performance", "colors", - "tilt", "shaderfx", "shaderfx2", "zoom", "voidFill", + "tilt", "uiLetterbox", "shaderfx", "shaderfx2", "zoom", "voidFill", "videoMode", "faithfulRes", "screenPos", "fpsCap", "speedOverworld", "speedBattle", "speedMenu", "mods", "controls", "dateFormat", "timeFormat" } @@ -369,10 +369,10 @@ check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES") -- mirrors it one row down, opening the same shared screen on -- "secondary" instead. local ShaderFX = require("src.render.ShaderFX") -check(om.rows[16].id == "shaderfx", "row 16 is the SHADER FX row") -check(om.rows[16].value(om.game) == "OFF", "SHADER FX shows OFF with no presets") -check(om.rows[16].step == nil, "SHADER FX row has no step() any more") -om.rows[16].activate(om.game) +local sfx = orow(om, "shaderfx") +check(sfx.value(om.game) == "OFF", "SHADER FX shows OFF with no presets") +check(sfx.step == nil, "SHADER FX row has no step() any more") +sfx.activate(om.game) local sfxScreen = om.game.stack:top() check(sfxScreen and sfxScreen.title == "SHADER FX", "SHADER FX row.activate() pushes a ShaderFXScreen") @@ -383,9 +383,9 @@ sfxScreen.onChoose(sfxScreen.items[1]) check(ShaderFX.active("main") == false, "choosing OFF on an empty list stays a safe no-op") check(om.game.stack:top() == nil, "ShaderFXScreen pops itself after onChoose") -check(om.rows[17].id == "shaderfx2", "row 17 is the SHADER FX 2 row") -check(om.rows[17].value(om.game) == "OFF", "SHADER FX 2 shows OFF with no presets") -om.rows[17].activate(om.game) +local sfx2 = orow(om, "shaderfx2") +check(sfx2.value(om.game) == "OFF", "SHADER FX 2 shows OFF with no presets") +sfx2.activate(om.game) local sfx2Screen = om.game.stack:top() check(sfx2Screen and sfx2Screen.title == "SHADER FX 2", "SHADER FX 2 row.activate() pushes the shared ShaderFXScreen on the secondary slot") diff --git a/tests/shaderfx_bridge_packaging_test.lua b/tests/shaderfx_bridge_packaging_test.lua new file mode 100644 index 00000000..43801778 --- /dev/null +++ b/tests/shaderfx_bridge_packaging_test.lua @@ -0,0 +1,71 @@ +-- Content gate: every shipped artifact carries ShaderFX's librashader bridge. +-- Self-contained: luajit tests/shaderfx_bridge_packaging_test.lua + +local T = require("tests.harness") +local check = T.check + +local function read(path) + local f, err = io.open(path, "r") + if not f then error("cannot read " .. path .. ": " .. tostring(err)) end + local s = f:read("*a") + f:close() + return s +end + +local function mustContain(body, needle, label) + check(body:find(needle, 1, true) ~= nil, + label .. " must contain " .. string.format("%q", needle)) +end + +local build = read("scripts/build.sh") +local release = read(".github/workflows/release.yml") +local flatpak = read("scripts/build_flatpak.sh") +local manifest = read("flatpak/com.theboisclub.gen1recomp.yml") +local arm64 = read("scripts/build_linux_arm64.sh") +local arm64_pack = read("scripts/linux-arm64/build_appimage.sh") +local verify = read("scripts/linux-arm64/verify_appimage.sh") +local rg34 = read("build-rg34xxsp.sh") +local sbc = read("build-linux-arm-sbc.sh") + +for _, call in ipairs({ + 'bundle_shader_bridge "$out_app/Contents/MacOS" "liblibrashader_bridge.dylib" mac', + 'bundle_shader_bridge "$out_dir" "librashader_bridge.dll" win-x64', + 'bundle_shader_bridge "$appdir" "liblibrashader_bridge.so" linux-x64', +}) do + mustContain(build, call, "build.sh") +end +mustContain(build, '$DIST/native/$plat/$name', "build.sh staged lookup") +mustContain(build, 'SHADERFX_BRIDGE_REQUIRED', "build.sh hard-fail switch") +mustContain(build, '[ "$plat" = "$(shader_bridge_host_plat)" ]', "build.sh host guard") + +mustContain(release, "shaderfx-bridge:", "release.yml bridge job") +for _, plat in ipairs({ "win-x64", "mac", "linux-x64", "linux-arm64" }) do + mustContain(release, "plat: " .. plat, "release.yml matrix") +end +mustContain(release, "lipo -create", "release.yml universal macOS bridge") + +local requiredIn = select(2, release:gsub('SHADERFX_BRIDGE_REQUIRED: "1"', "")) +check(requiredIn >= 6, + 'release.yml must set SHADERFX_BRIDGE_REQUIRED on every shipping job, found ' .. requiredIn) +mustContain(release, "librashader_bridge.dll", "release.yml Windows zip assertion") + +mustContain(manifest, "# BRIDGE-BEGIN", "flatpak manifest markers") +mustContain(manifest, "/app/share/gen1recomp/liblibrashader_bridge.so", "flatpak install path") +mustContain(flatpak, "/# BRIDGE-BEGIN/,/# BRIDGE-END/d", "build_flatpak.sh strip") +mustContain(flatpak, "$BUILD_DIR/files/share/gen1recomp/$BRIDGE_LIB", "build_flatpak.sh verify") + +mustContain(arm64, "SHADERFX_BRIDGE_LINUX_ARM64", "build_linux_arm64.sh override") +mustContain(arm64_pack, '$APPDIR/liblibrashader_bridge.so', "build_appimage.sh AppDir root") +mustContain(arm64_pack, '${SHADER_BRIDGE[@]+"${SHADER_BRIDGE[@]}"}', "build_appimage.sh contract scan") +mustContain(verify, "7f454c46", "verify_appimage.sh discovers ELF objects") +mustContain(verify, 'ctypes.CDLL', "verify_appimage.sh dlopen smoke") +for _, scanned in ipairs({ 'ldd "${scan[@]}"', 'for f in "${scan[@]}"', 'objdump -T "${scan[@]}"' }) do + mustContain(verify, scanned, "verify_appimage.sh") +end + +for _, pair in ipairs({ { rg34, "build-rg34xxsp.sh" }, { sbc, "build-linux-arm-sbc.sh" } }) do + mustContain(pair[1], 'libs.aarch64/$BRIDGE_LIB', pair[2]) + mustContain(pair[1], "SHADERFX_BRIDGE_REQUIRED", pair[2]) +end + +T.finish("shaderfx_bridge_packaging_test") diff --git a/tools/shaderfx-bridge/target/release/liblibrashader_bridge.dylib b/tools/shaderfx-bridge/target/release/liblibrashader_bridge.dylib deleted file mode 100755 index a93711b2..00000000 Binary files a/tools/shaderfx-bridge/target/release/liblibrashader_bridge.dylib and /dev/null differ