mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-20 12:40:21 +02:00
Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
|
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
|
||||||
# IPA, a Nintendo Switch SD-ready zip (experimental), Xbox UWP, and the Anbernic
|
# IPA, a Nintendo Switch SD-ready zip (experimental), Xbox UWP, the Anbernic
|
||||||
# RG34XXSP (Stock OS 64-bit MOD / PortMaster) port, then publishes them as a
|
# RG34XXSP (Stock OS 64-bit MOD / PortMaster) and Linux ARM SBC PortMaster
|
||||||
|
# handheld ports on the self-hosted Mac runner, and publishes them as a
|
||||||
# GitHub Release.
|
# GitHub Release.
|
||||||
#
|
#
|
||||||
# Versioning:
|
# Versioning:
|
||||||
@@ -355,6 +356,20 @@ jobs:
|
|||||||
# runtime from PortMaster-GUI, so it needs no signing/notarization.
|
# runtime from PortMaster-GUI, so it needs no signing/notarization.
|
||||||
./build-rg34xxsp.sh --version "${{ needs.version.outputs.version }}"
|
./build-rg34xxsp.sh --version "${{ needs.version.outputs.version }}"
|
||||||
|
|
||||||
|
- name: Build Linux ARM SBC PortMaster port
|
||||||
|
env:
|
||||||
|
# The release workflow must package the commit being released. The
|
||||||
|
# script defaults to the latest published release for standalone
|
||||||
|
# builds, while this explicit local override keeps CI source-aligned.
|
||||||
|
GEN1RECOMP_SOURCE_DIR: ${{ github.workspace }}
|
||||||
|
GEN1RECOMP_RELEASE_TAG: v${{ needs.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Same aarch64 PortMaster-style pack for Linux ARM SBC PortMaster. The build
|
||||||
|
# keeps its own cache because the two scripts use different staging
|
||||||
|
# layouts and runtime package paths.
|
||||||
|
./build-linux-arm-sbc.sh --version "${{ needs.version.outputs.version }}"
|
||||||
|
|
||||||
- name: Notarize & staple macOS app
|
- name: Notarize & staple macOS app
|
||||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||||
run: |
|
run: |
|
||||||
@@ -449,6 +464,11 @@ jobs:
|
|||||||
[ -f "$rg34" ] || { echo "::error::$rg34 not found (expected from ./build-rg34xxsp.sh)"; exit 1; }
|
[ -f "$rg34" ] || { echo "::error::$rg34 not found (expected from ./build-rg34xxsp.sh)"; exit 1; }
|
||||||
cp "$rg34" "$outdir/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
cp "$rg34" "$outdir/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
||||||
|
|
||||||
|
# Linux ARM SBC PortMaster handheld port.
|
||||||
|
sbc="dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip"
|
||||||
|
[ -f "$sbc" ] || { echo "::error::$sbc not found (expected from ./build-linux-arm-sbc.sh)"; exit 1; }
|
||||||
|
cp "$sbc" "$outdir/gen1recomp-${v}-sbc-portmaster.zip"
|
||||||
|
|
||||||
# Platform-independent update payload, built alongside the desktop
|
# Platform-independent update payload, built alongside the desktop
|
||||||
# apps above (same game.love that gets fused into each of them).
|
# apps above (same game.love that gets fused into each of them).
|
||||||
love_file=".bazinga/work/game.love"
|
love_file=".bazinga/work/game.love"
|
||||||
@@ -564,6 +584,7 @@ jobs:
|
|||||||
"dist/release/gen1recomp-${v}-switch.zip"
|
"dist/release/gen1recomp-${v}-switch.zip"
|
||||||
"dist/release/gen1recomp-${v}-xbox-uwp.zip"
|
"dist/release/gen1recomp-${v}-xbox-uwp.zip"
|
||||||
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
||||||
|
"dist/release/gen1recomp-${v}-sbc-portmaster.zip"
|
||||||
"dist/release/gen1recomp-${v}.love"
|
"dist/release/gen1recomp-${v}.love"
|
||||||
"dist/release/sha256sums.txt"
|
"dist/release/sha256sums.txt"
|
||||||
)
|
)
|
||||||
|
|||||||
Executable
+366
@@ -0,0 +1,366 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build a PortMaster aarch64 port of gen1recomp for Linux ARM SBC handhelds.
|
||||||
|
# The package uses PortMaster control hooks and a self-contained LÖVE runtime,
|
||||||
|
# while keeping paths relative to the launcher for broad CFW compatibility.
|
||||||
|
#
|
||||||
|
# The launcher uses SHDIR-relative paths and bundles the LÖVE 11.5 aarch64
|
||||||
|
# runtime so the device does not need a separate runtime download on first launch.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./build-linux-arm-sbc.sh [--version X.Y.Z]
|
||||||
|
# GEN1RECOMP_SOURCE_DIR="$PWD" ./build-linux-arm-sbc.sh --version X.Y.Z
|
||||||
|
# ./build-linux-arm-sbc.sh --source /path/to/gen1recomp --version X.Y.Z
|
||||||
|
#
|
||||||
|
# Output:
|
||||||
|
# dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip
|
||||||
|
#
|
||||||
|
# Install on device:
|
||||||
|
# 1. Install PortMaster for the handheld firmware.
|
||||||
|
# 2. Unzip into the device's PortMaster ports folder so you have:
|
||||||
|
# Roms/Ports (PORTS)/gen1recomp-sbc.sh
|
||||||
|
# Roms/Ports (PORTS)/gen1recomp-sbc/...
|
||||||
|
# 3. Copy a legal US Red or Blue .gb into Roms/Ports (PORTS)/gen1recomp-sbc/lovegame/
|
||||||
|
# 4. Launch "gen1recomp-sbc" from the Ports list; press Choose ROM (scans that
|
||||||
|
# folder when zenity is missing).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
HERE="$ROOT/.bazinga"
|
||||||
|
CACHE="$HERE/cache/linux-arm-sbc"
|
||||||
|
WORK="$HERE/work/linux-arm-sbc"
|
||||||
|
DIST="$ROOT/dist/linux-arm-sbc"
|
||||||
|
|
||||||
|
APP_NAME="gen1recomp-sbc"
|
||||||
|
# Artifact suffix identifies this as the generic PortMaster SBC package.
|
||||||
|
# Release uploads stage it as gen1recomp-<ver>-sbc-portmaster.zip.
|
||||||
|
ARTIFACT_SUFFIX="portmaster"
|
||||||
|
PORT_DIR_NAME="gen1recomp-sbc"
|
||||||
|
LAUNCHER_NAME="gen1recomp-sbc.sh"
|
||||||
|
LOVE_VERSION="11.5"
|
||||||
|
# By default the pack is reproducible from the latest published GitHub release,
|
||||||
|
# not whatever happens to be in the caller's checkout. Development builds can
|
||||||
|
# point this at a local checkout with GEN1RECOMP_SOURCE_DIR=/path/to/repo.
|
||||||
|
SOURCE_DIR_OVERRIDE="${GEN1RECOMP_SOURCE_DIR:-}"
|
||||||
|
SOURCE_TAG_OVERRIDE="${GEN1RECOMP_RELEASE_TAG:-}"
|
||||||
|
VERSION="${GEN1RECOMP_VERSION:-}"
|
||||||
|
|
||||||
|
# Official PortMaster LÖVE 11.5 aarch64 runtime (small love stub + liblove).
|
||||||
|
PM_RUNTIME_BASE="https://raw.githubusercontent.com/PortsMaster/PortMaster-GUI/main/PortMaster/runtimes/love_${LOVE_VERSION}"
|
||||||
|
RELEASES_LATEST_URL="https://github.com/bryanthaboi/gen1recomp/releases/latest"
|
||||||
|
RELEASE_TARBALL_BASE="https://github.com/bryanthaboi/gen1recomp/archive/refs/tags"
|
||||||
|
|
||||||
|
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) [ $# -ge 2 ] || fail "--version needs X.Y.Z"; VERSION="$2"; shift ;;
|
||||||
|
--source) [ $# -ge 2 ] || fail "--source needs a directory"; SOURCE_DIR_OVERRIDE="$2"; shift ;;
|
||||||
|
--release-tag) [ $# -ge 2 ] || fail "--release-tag needs a tag"; SOURCE_TAG_OVERRIDE="$2"; shift ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '2,24p' "$0"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) fail "unknown argument: $1" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
command -v curl >/dev/null || fail "curl is required"
|
||||||
|
command -v zip >/dev/null || fail "zip is required"
|
||||||
|
command -v unzip >/dev/null || fail "unzip is required"
|
||||||
|
command -v tar >/dev/null || fail "tar is required"
|
||||||
|
|
||||||
|
mkdir -p "$CACHE" "$WORK" "$DIST"
|
||||||
|
|
||||||
|
download() {
|
||||||
|
local url="$1" dest="$2"
|
||||||
|
if [ -f "$dest" ] && [ -s "$dest" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
say "downloading $(basename "$dest")"
|
||||||
|
curl -fL --progress-bar "$url" -o "$dest.tmp" \
|
||||||
|
|| fail "download failed: $url"
|
||||||
|
mv "$dest.tmp" "$dest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- source + game tree
|
||||||
|
# Release builds use the latest published source archive. A local checkout is
|
||||||
|
# an explicit override for development and for CI's just-built release source.
|
||||||
|
if [ -n "$SOURCE_DIR_OVERRIDE" ]; then
|
||||||
|
SOURCE_DIR_OVERRIDE="$(cd "$SOURCE_DIR_OVERRIDE" 2>/dev/null && pwd)" \
|
||||||
|
|| fail "source directory does not exist: $SOURCE_DIR_OVERRIDE"
|
||||||
|
SOURCE_DIR="$SOURCE_DIR_OVERRIDE"
|
||||||
|
SOURCE_TAG="${SOURCE_TAG_OVERRIDE:-local}"
|
||||||
|
if [ "$SOURCE_TAG" != "local" ]; then
|
||||||
|
printf '%s' "$SOURCE_TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||||
|
|| fail "release tag must look like vX.Y.Z: $SOURCE_TAG"
|
||||||
|
fi
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
VERSION="$(git -C "$SOURCE_DIR" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [ -z "$SOURCE_TAG_OVERRIDE" ]; then
|
||||||
|
latest_location="$(curl -fsSI "$RELEASES_LATEST_URL" \
|
||||||
|
| awk 'tolower($1) == "location:" { print $2 }' | tail -1 | tr -d '\r')" \
|
||||||
|
|| fail "could not resolve latest published release"
|
||||||
|
SOURCE_TAG_OVERRIDE="${latest_location##*/}"
|
||||||
|
fi
|
||||||
|
printf '%s' "$SOURCE_TAG_OVERRIDE" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||||
|
|| fail "release tag must look like vX.Y.Z: $SOURCE_TAG_OVERRIDE"
|
||||||
|
SOURCE_TAG="$SOURCE_TAG_OVERRIDE"
|
||||||
|
SOURCE_ARCHIVE="$CACHE/gen1recomp-${SOURCE_TAG}.tar.gz"
|
||||||
|
download "$RELEASE_TARBALL_BASE/$SOURCE_TAG.tar.gz" "$SOURCE_ARCHIVE"
|
||||||
|
SOURCE_EXTRACT="$WORK/source-$SOURCE_TAG"
|
||||||
|
rm -rf "$SOURCE_EXTRACT"
|
||||||
|
mkdir -p "$SOURCE_EXTRACT"
|
||||||
|
tar -xzf "$SOURCE_ARCHIVE" -C "$SOURCE_EXTRACT"
|
||||||
|
SOURCE_DIR="$(find "$SOURCE_EXTRACT" -mindepth 1 -maxdepth 1 -type d -print -quit)"
|
||||||
|
[ -n "$SOURCE_DIR" ] || fail "release archive had no source directory"
|
||||||
|
if [ -z "$VERSION" ]; then VERSION="${SOURCE_TAG#v}"; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "staging lovegame/ from $SOURCE_TAG"
|
||||||
|
GAME_SRC="$WORK/lovegame"
|
||||||
|
rm -rf "$GAME_SRC"
|
||||||
|
mkdir -p "$GAME_SRC"
|
||||||
|
|
||||||
|
# Same payload as scripts/build.sh's game.love — never ship ROM-derived cache.
|
||||||
|
# tools/save-editor is part of that payload: the launcher's Edit button on a
|
||||||
|
# save row opens it in-process (main.lua).
|
||||||
|
(cd "$SOURCE_DIR" && zip -q -9 -r "$WORK/game-payload.zip" \
|
||||||
|
main.lua conf.lua src libs data assets tools/save-editor \
|
||||||
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
|
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||||
|
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||||
|
fail "payload unexpectedly contains generated ROM data"
|
||||||
|
fi
|
||||||
|
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
||||||
|
rm -f "$WORK/game-payload.zip"
|
||||||
|
|
||||||
|
# Stamp release version into the staged tree only (never the working tree).
|
||||||
|
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
say "stamping engine version $VERSION"
|
||||||
|
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
|
||||||
|
"$SOURCE_DIR/src/core/Version.lua" > "$GAME_SRC/src/core/Version.lua"
|
||||||
|
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
||||||
|
grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||||
|
"$GAME_SRC/src/core/Version.lua" \
|
||||||
|
|| fail "version stamp failed"
|
||||||
|
else
|
||||||
|
say "version '$VERSION' is not X.Y.Z — shipping default engine (no stamp)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Portable marker: saves + ROM cache live next to the game on the SD card.
|
||||||
|
: > "$GAME_SRC/portable.txt"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- love runtime
|
||||||
|
say "fetching LÖVE $LOVE_VERSION aarch64 runtime"
|
||||||
|
LOVE_BIN="$CACHE/love.aarch64"
|
||||||
|
LOVE_LIB="$CACHE/liblove-11.5.so"
|
||||||
|
LUAJIT_LIB="$CACHE/libluajit-5.1.so.2"
|
||||||
|
MODPLUG_LIB="$CACHE/libmodplug.so.1"
|
||||||
|
OGG_LIB="$CACHE/libogg.so.0"
|
||||||
|
|
||||||
|
download "$PM_RUNTIME_BASE/love.aarch64" "$LOVE_BIN"
|
||||||
|
download "$PM_RUNTIME_BASE/libs.aarch64/liblove-11.5.so" "$LOVE_LIB"
|
||||||
|
download "$PM_RUNTIME_BASE/libs.aarch64/libluajit-5.1.so.2" "$LUAJIT_LIB"
|
||||||
|
download "$PM_RUNTIME_BASE/libs.aarch64/libmodplug.so.1" "$MODPLUG_LIB"
|
||||||
|
download "$PM_RUNTIME_BASE/libs.aarch64/libogg.so.0" "$OGG_LIB"
|
||||||
|
|
||||||
|
# Sanity: love stub must be an aarch64 ELF.
|
||||||
|
file "$LOVE_BIN" | grep -qi 'aarch64\|ARM aarch64' \
|
||||||
|
|| fail "love.aarch64 does not look like an aarch64 ELF (got: $(file "$LOVE_BIN"))"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- port tree
|
||||||
|
say "assembling port package"
|
||||||
|
PORT_ROOT="$WORK/port"
|
||||||
|
rm -rf "$PORT_ROOT"
|
||||||
|
mkdir -p "$PORT_ROOT/$PORT_DIR_NAME/bin" \
|
||||||
|
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64" \
|
||||||
|
"$PORT_ROOT/$PORT_DIR_NAME/licenses" \
|
||||||
|
"$PORT_ROOT/$PORT_DIR_NAME/conf"
|
||||||
|
|
||||||
|
cp -R "$GAME_SRC" "$PORT_ROOT/$PORT_DIR_NAME/lovegame"
|
||||||
|
cp "$LOVE_BIN" "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||||
|
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/"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
(https://github.com/PortsMaster/PortMaster-GUI). LÖVE is zlib-licensed;
|
||||||
|
see https://love2d.org/ for full terms.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- launcher
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
export XDG_DATA_HOME="$CONFDIR"
|
||||||
|
export XDG_CONFIG_HOME="$CONFDIR"
|
||||||
|
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}"
|
||||||
|
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
|
||||||
|
# GLES is the common path on ARM SBC handhelds; firmware may override it.
|
||||||
|
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
|
||||||
|
|
||||||
|
$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
|
||||||
|
|
||||||
|
if [ -n "${GPTOKEYB:-}" ]; then
|
||||||
|
$GPTOKEYB "love.aarch64" &
|
||||||
|
fi
|
||||||
|
if type pm_platform_helper >/dev/null 2>&1; then
|
||||||
|
pm_platform_helper "$GAMEDIR/bin/love.aarch64"
|
||||||
|
fi
|
||||||
|
|
||||||
|
./bin/love.aarch64 "$GAMEDIR/lovegame"
|
||||||
|
|
||||||
|
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
|
||||||
|
EOF
|
||||||
|
chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- metadata
|
||||||
|
cat > "$PORT_ROOT/port.json" <<EOF
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"name": "gen1recomp-sbc.zip",
|
||||||
|
"items": [
|
||||||
|
"$LAUNCHER_NAME",
|
||||||
|
"$PORT_DIR_NAME"
|
||||||
|
],
|
||||||
|
"items_opt": null,
|
||||||
|
"attr": {
|
||||||
|
"title": "gen1recomp-sbc",
|
||||||
|
"desc": "Native LÖVE2D recreation of Pokemon Red and Blue. Supply your own legal US Red or Blue ROM.",
|
||||||
|
"source": "https://github.com/bryanthaboi/gen1recomp/releases/tag/$SOURCE_TAG",
|
||||||
|
"inst": "Requires a 64-bit Linux ARM handheld with PortMaster. Copy a canonical US Red or Blue .gb into gen1recomp-sbc/lovegame/, then launch and press Choose ROM.",
|
||||||
|
"genres": ["adventure", "rpg"],
|
||||||
|
"porter": ["gen1recomp-sbc"],
|
||||||
|
"image": {},
|
||||||
|
"rtr": true,
|
||||||
|
"runtime": null,
|
||||||
|
"reqs": [],
|
||||||
|
"arch": ["aarch64"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$PORT_ROOT/gameinfo.xml" <<EOF
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<gameList>
|
||||||
|
<game>
|
||||||
|
<path>./$LAUNCHER_NAME</path>
|
||||||
|
<name>gen1recomp-sbc</name>
|
||||||
|
<desc>Native LÖVE2D recreation of Pokemon Red and Blue. Requires your own legal US Red or Blue ROM.</desc>
|
||||||
|
<releasedate>20250101T000000</releasedate>
|
||||||
|
<developer>the bois club</developer>
|
||||||
|
<publisher>the bois club</publisher>
|
||||||
|
<genre>RPG</genre>
|
||||||
|
</game>
|
||||||
|
</gameList>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$PORT_ROOT/README.md" <<'EOF'
|
||||||
|
## gen1recomp-sbc (Linux ARM SBC / PortMaster)
|
||||||
|
|
||||||
|
Native LÖVE 11.5 aarch64 PortMaster port of gen1recomp for compatible Linux ARM SBC handhelds, including H700-class devices. This pack was built from source release **__SOURCE_TAG__**.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
1. Install PortMaster for your handheld firmware.
|
||||||
|
2. Unzip so `gen1recomp-sbc.sh` and the `gen1recomp-sbc/` folder are siblings in the device's PortMaster ports directory.
|
||||||
|
3. Copy a legal US Pokémon Red or Blue `.gb` into `gen1recomp-sbc/lovegame/`.
|
||||||
|
4. Refresh the launcher and launch **gen1recomp-sbc** from Ports.
|
||||||
|
|
||||||
|
### Controls
|
||||||
|
|
||||||
|
| Input | Action |
|
||||||
|
|--|--|
|
||||||
|
| D-pad | Move cursor |
|
||||||
|
| A | Click |
|
||||||
|
| L1 / R1 | Switch tabs |
|
||||||
|
| Start / Select | Play or choose ROM |
|
||||||
|
|
||||||
|
Controls use the normal PortMaster / SDL pad map. Device-specific power/suspend behavior is supplied by the firmware and PortMaster runtime.
|
||||||
|
|
||||||
|
### First run
|
||||||
|
|
||||||
|
Put the `.gb` in `lovegame/`, then press **Choose ROM**. After import, the ROM-derived cache and saves stay beside the game (`portable.txt`).
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
|
||||||
|
LÖVE runtime binaries from [PortMaster](https://portmaster.games/). PortMaster device support and runtime integration are maintained by the PortMaster team.
|
||||||
|
EOF
|
||||||
|
sed -i.bak "s/__SOURCE_TAG__/$SOURCE_TAG/g" "$PORT_ROOT/README.md"
|
||||||
|
rm -f "$PORT_ROOT/README.md.bak"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- zip
|
||||||
|
ZIP_OUT="$DIST/$APP_NAME-$ARTIFACT_SUFFIX.zip"
|
||||||
|
rm -f "$ZIP_OUT"
|
||||||
|
say "packing $ZIP_OUT"
|
||||||
|
(cd "$PORT_ROOT" && zip -q -9 -r "$ZIP_OUT" \
|
||||||
|
"$LAUNCHER_NAME" "$PORT_DIR_NAME" port.json gameinfo.xml README.md)
|
||||||
|
|
||||||
|
say "done."
|
||||||
|
say "artifact: $ZIP_OUT ($(du -h "$ZIP_OUT" | cut -f1))"
|
||||||
|
say "copy into the device PortMaster ports folder, then drop your .gb into gen1recomp-sbc/lovegame/"
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Linux ARM SBC Handhelds (PortMaster)
|
||||||
|
|
||||||
|
Download `gen1recomp-*-sbc-portmaster.zip` from the [Gen1Recomp releases](https://github.com/bryanthaboi/gen1recomp/releases). This build targets 64-bit Linux ARM handhelds with PortMaster, including compatible H700 devices.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
1. Unzip the release. It contains `gen1recomp-sbc.sh` and a `gen1recomp-sbc/` folder.
|
||||||
|
2. Copy both as siblings into your device's PortMaster ports directory, commonly `Roms/Ports (PORTS)/` or `Roms/PORTS/`.
|
||||||
|
3. Install PortMaster for your firmware and refresh the Ports list.
|
||||||
|
4. Copy your legally owned canonical US Red or Blue `.gb` file into `gen1recomp-sbc/lovegame/`.
|
||||||
|
5. Launch **gen1recomp-sbc** from Ports and choose the ROM.
|
||||||
|
|
||||||
|
The pack includes `portable.txt`, so saves and ROM-derived cache remain beside the game on the SD card. The build never ships ROM-derived bytes.
|
||||||
|
|
||||||
|
Canonical US cart SHA-1 values:
|
||||||
|
|
||||||
|
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||||
|
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Input | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| D-pad | Move cursor |
|
||||||
|
| A | Click / confirm |
|
||||||
|
| L1 / R1 | Switch tabs |
|
||||||
|
| Start / Select | Play or choose ROM |
|
||||||
|
|
||||||
|
In-game controls use the normal PortMaster/SDL mapping and can be rebound in **OPTIONS → CONTROLS**.
|
||||||
|
|
||||||
|
## Runtime and suspend
|
||||||
|
|
||||||
|
The package bundles PortMaster's LÖVE 11.5 aarch64 runtime. The launcher sources `control.txt`, calls `get_controls`, applies an optional CFW override, invokes `pm_platform_helper`, and calls `pm_finish` on exit. Paths are relative to the launcher, allowing different firmware mount points.
|
||||||
|
|
||||||
|
Suspend/resume uses the existing LÖVE focus/visibility lifecycle: input is reset on focus loss and the game resumes when the window becomes visible again. Exact power-button behavior remains firmware-dependent; hardware validation has been performed on the TrimUI Brick, not every SBC or H700 device.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Release workflows build this automatically. Standalone builds resolve the latest published Gen1Recomp release by default:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./build-linux-arm-sbc.sh --version 0.1.75
|
||||||
|
```
|
||||||
|
|
||||||
|
For development, package a local checkout explicitly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GEN1RECOMP_SOURCE_DIR="$PWD" ./build-linux-arm-sbc.sh --version 0.1.0
|
||||||
|
# or: ./build-linux-arm-sbc.sh --source "$PWD" --version 0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated `port.json` records the source release tag. `install-linux-arm-sbc.sh` is a macOS helper for copying a built pack to a mounted SD card.
|
||||||
|
|
||||||
|
PortMaster device support and runtime integration are maintained in the [PortMaster](https://github.com/PortsMaster/PortMaster-New) ecosystem.
|
||||||
+33
-3
@@ -340,6 +340,18 @@ failure as a failed first checkpoint rather than claiming restart safety.
|
|||||||
See RFC 0003, RFC 0004, RFC 0005, and RFC 0006 for exact contracts and error
|
See RFC 0003, RFC 0004, RFC 0005, and RFC 0006 for exact contracts and error
|
||||||
codes.
|
codes.
|
||||||
|
|
||||||
|
At that same settled supported wild/trainer decision boundary, a tool may claim
|
||||||
|
START through `battle.menu_auxiliary`. It receives `(next, game, context)`, where
|
||||||
|
`context` is the data-only `{ kind = "wild" }` or `{ kind = "trainer" }`; it
|
||||||
|
never receives the live battle controller. Return `true` to consume START after
|
||||||
|
opening source-owned UI, or call `next(game, context)` to allow lower-priority
|
||||||
|
handlers. With no handler, START remains inert. Ordinary encounters and the
|
||||||
|
validated built-in scripted battle origins described by RFC 0005 are eligible;
|
||||||
|
opaque scripts, link/Safari/ghost/demo battles, action queues,
|
||||||
|
animation/messages, forced choices, and every phase that cannot safely be
|
||||||
|
checkpointed remain excluded. Exceptions are contained by normal hook isolation
|
||||||
|
and fall through without advancing a turn.
|
||||||
|
|
||||||
## Developer console
|
## Developer console
|
||||||
|
|
||||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||||
@@ -442,9 +454,12 @@ identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
|
|||||||
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently
|
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently
|
||||||
control the battle text/menu layer and the HP/status panels. Both receive
|
control the battle text/menu layer and the HP/status panels. Both receive
|
||||||
`(next, state)` and default to `true`, so vanilla rendering is unchanged.
|
`(next, state)` and default to `true`, so vanilla rendering is unchanged.
|
||||||
Pushed text boxes also pass through `battle.bottom_ui_visible`; a wrapper that
|
Text boxes and YES/NO prompts pushed above a battle inherit a `false` result
|
||||||
only owns battle presentation should return `false` only for its active battle
|
for that battle, so hiding the bottom layer cannot leave their white backing
|
||||||
or text-box state.
|
behind under another overlay. Text boxes also pass through the hook as their
|
||||||
|
own state, preserving selective control outside a battle; a wrapper that only
|
||||||
|
owns battle presentation should return `false` only for its active battle or
|
||||||
|
text-box state.
|
||||||
|
|
||||||
`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call
|
`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call
|
||||||
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
|
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
|
||||||
@@ -495,6 +510,21 @@ Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call`
|
|||||||
already falls straight through to the vanilla function when no mod has
|
already falls straight through to the vanilla function when no mod has
|
||||||
wrapped the name, at negligible cost.
|
wrapped the name, at negligible cost.
|
||||||
|
|
||||||
|
## Detached Pokémon icon presentation
|
||||||
|
|
||||||
|
`mod.ui.PokemonIcon.draw(game, summary, x, y, opts)` draws the same party icon
|
||||||
|
the native Party menu would resolve without exposing a live Pokémon record or
|
||||||
|
the private Party menu. `summary` is the detached data-only shape
|
||||||
|
`{ species = string, hp = integer, maxHp = integer }`; `opts.selected` and
|
||||||
|
`opts.counter` optionally request the native selected-icon animation phase.
|
||||||
|
|
||||||
|
The engine retains icon ownership. Content registered through
|
||||||
|
`mod.content.icons`, species `icon` definitions, asset overrides, and the
|
||||||
|
public `pokemon.icon` hook therefore continue to compose. Invalid summaries
|
||||||
|
return `false, code, message` and draw nothing. The helper is presentation
|
||||||
|
only: it does not expose moves, status, checkpoint payloads, or mutable party
|
||||||
|
state.
|
||||||
|
|
||||||
## Shared date and time presentation
|
## Shared date and time presentation
|
||||||
|
|
||||||
The global Options menu owns `DATE FORMAT` (`DEVICE`, `DD-MM-YYYY`,
|
The global Options menu owns `DATE FORMAT` (`DEVICE`, `DD-MM-YYYY`,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# RFC 0007: Battle menu auxiliary actions
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Tool mods can inspect/capture a persistent checkpoint only at a settled
|
||||||
|
ordinary wild/trainer player-decision boundary. Before this proposal, that
|
||||||
|
boundary had no public semantic input/action seam: `BattleState` consumed the
|
||||||
|
command loop directly. A mod could reach it only through private battle/input
|
||||||
|
internals, which would be unsafe and incompatible with controller/touch input.
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
`mod.hooks:wrap("battle.menu_auxiliary", callback)` is called only when START
|
||||||
|
is pressed at the existing checkpoint-safe player-decision boundary. The
|
||||||
|
callback signature is:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
function callback(next, game, context)
|
||||||
|
-- context is { kind = "wild" } or { kind = "trainer" }
|
||||||
|
-- return true after claiming START, otherwise return next(game, context)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
The context is data-only. No live battle controller, input object, serializer,
|
||||||
|
or restoration primitive is exposed. A `true` result consumes START for that
|
||||||
|
fixed step without selecting a battle command. With no installed handler,
|
||||||
|
START is inert exactly as before. Hook priorities and error isolation are the
|
||||||
|
existing generic wrapper semantics: a throwing handler is skipped and cannot
|
||||||
|
advance battle state.
|
||||||
|
|
||||||
|
The engine reuses the same internal safety predicate as battle checkpoint
|
||||||
|
capture. Link, Safari, ghost/demo, unsupported origins, scripts, queues,
|
||||||
|
animations, messages, forced replacement/locked actions, and unsettled HP or
|
||||||
|
status presentation never invoke the hook.
|
||||||
|
|
||||||
|
## Compatibility and verification
|
||||||
|
|
||||||
|
The call is additive and no-op with no handler. ROM-free engine tests prove
|
||||||
|
wild/trainer delivery, cursor/turn preservation, and unsafe-phase refusal;
|
||||||
|
the mod-SDK fixture proves a loaded mod can consume the semantic action using
|
||||||
|
only its public hook facade. `gate_hooks` automatically includes the new call
|
||||||
|
site in no-mod parity coverage.
|
||||||
Executable
+93
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# After first boot of a compatible Linux ARM handheld (or when PortMaster is installed), reinsert the
|
||||||
|
# SD card and run this to install gen1recomp-sbc + Red/Blue ROMs into Roms/PORTS.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
STAGE="$ROOT/.bazinga/work/linux-arm-sbc-install"
|
||||||
|
DECPREP="${DECPREP:-$ROOT/../decprep}"
|
||||||
|
ZIP="$ROOT/dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip"
|
||||||
|
|
||||||
|
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
|
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Find a mounted handheld userdata volume with a ROMs or Apps directory.
|
||||||
|
find_roms_root() {
|
||||||
|
local v candidate
|
||||||
|
for v in /Volumes/*; do
|
||||||
|
[ -d "$v" ] || continue
|
||||||
|
# Prefer a volume that already has Roms/ or Apps/
|
||||||
|
if [ -d "$v/Roms" ] || [ -d "$v/roms" ] || [ -d "$v/PORTS" ] || [ -d "$v/ports" ] || [ -d "$v/Apps" ]; then
|
||||||
|
echo "$v"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# Fallback: common removable-volume labels
|
||||||
|
for v in /Volumes/SDCARD /Volumes/sdcard /Volumes/NO\ NAME /Volumes/ROMS; do
|
||||||
|
if [ -d "$v" ]; then
|
||||||
|
echo "$v"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
say "looking for handheld SD volume"
|
||||||
|
ROMS_ROOT="$(find_roms_root)" || fail "no SD volume mounted. boot the handheld once, power it off, reinsert the SD, then rerun."
|
||||||
|
|
||||||
|
say "using: $ROMS_ROOT"
|
||||||
|
# Resolve the device PortMaster ports directory
|
||||||
|
if [ -d "$ROMS_ROOT/Roms/PORTS" ]; then
|
||||||
|
PORTS="$ROMS_ROOT/Roms/PORTS"
|
||||||
|
elif [ -d "$ROMS_ROOT/roms/PORTS" ]; then
|
||||||
|
PORTS="$ROMS_ROOT/roms/PORTS"
|
||||||
|
elif [ -d "$ROMS_ROOT/Roms/ports" ]; then
|
||||||
|
PORTS="$ROMS_ROOT/Roms/ports"
|
||||||
|
elif [ -d "$ROMS_ROOT/PORTS" ]; then
|
||||||
|
PORTS="$ROMS_ROOT/PORTS"
|
||||||
|
else
|
||||||
|
mkdir -p "$ROMS_ROOT/Roms/PORTS"
|
||||||
|
PORTS="$ROMS_ROOT/Roms/PORTS"
|
||||||
|
fi
|
||||||
|
say "PORTS: $PORTS"
|
||||||
|
|
||||||
|
# Refresh staged payload
|
||||||
|
mkdir -p "$STAGE/PORTS"
|
||||||
|
if [ -f "$ZIP" ]; then
|
||||||
|
rm -rf "$STAGE/PORTS/gen1recomp-sbc.sh" "$STAGE/PORTS/gen1recomp-sbc" "$STAGE/PORTS/port.json" \
|
||||||
|
"$STAGE/PORTS/gameinfo.xml" "$STAGE/PORTS/README.md"
|
||||||
|
unzip -q -o "$ZIP" -d "$STAGE/PORTS"
|
||||||
|
else
|
||||||
|
fail "missing $ZIP — run ./build-linux-arm-sbc.sh first"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure ROMs are in lovegame (Choose ROM scans this folder on minimal images)
|
||||||
|
[ -f "$DECPREP/Pokemon - Red Version.gb" ] || fail "missing Red ROM in $DECPREP"
|
||||||
|
[ -f "$DECPREP/Pokemon - Blue Version.gb" ] || fail "missing Blue ROM in $DECPREP"
|
||||||
|
cp -f "$DECPREP/Pokemon - Red Version.gb" "$STAGE/PORTS/gen1recomp-sbc/lovegame/"
|
||||||
|
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$STAGE/PORTS/gen1recomp-sbc/lovegame/"
|
||||||
|
|
||||||
|
say "copying gen1recomp port"
|
||||||
|
rm -rf "$PORTS/gen1recomp-sbc" "$PORTS/gen1recomp-sbc.sh"
|
||||||
|
cp -R "$STAGE/PORTS/gen1recomp-sbc" "$PORTS/"
|
||||||
|
cp -f "$STAGE/PORTS/gen1recomp-sbc.sh" "$PORTS/"
|
||||||
|
cp -f "$STAGE/PORTS/port.json" "$PORTS/"
|
||||||
|
cp -f "$STAGE/PORTS/README.md" "$PORTS/"
|
||||||
|
chmod +x "$PORTS/gen1recomp-sbc.sh" "$PORTS/gen1recomp-sbc/bin/love.aarch64"
|
||||||
|
|
||||||
|
# Also drop carts in the stock GB folder for the emulator library
|
||||||
|
GB_DIR=""
|
||||||
|
for candidate in "$ROMS_ROOT/Roms/GB" "$ROMS_ROOT/roms/GB" "$ROMS_ROOT/Roms/gb"; do
|
||||||
|
if [ -d "$candidate" ]; then GB_DIR="$candidate"; break; fi
|
||||||
|
done
|
||||||
|
if [ -n "$GB_DIR" ]; then
|
||||||
|
say "copying .gb into $GB_DIR"
|
||||||
|
cp -f "$DECPREP/Pokemon - Red Version.gb" "$GB_DIR/"
|
||||||
|
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$GB_DIR/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sync
|
||||||
|
say "installed:"
|
||||||
|
ls -lh "$PORTS/gen1recomp-sbc.sh"
|
||||||
|
ls -lh "$PORTS/gen1recomp-sbc/lovegame/"*.gb
|
||||||
|
say "eject the SD, insert it in the handheld, open Ports → gen1recomp-sbc, Choose ROM."
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
-- Shared settled supported player-decision predicate. Checkpoint capture and
|
||||||
|
-- the public auxiliary action deliberately use this one engine-owned rule so
|
||||||
|
-- a tool cannot open at a phase that it could not subsequently checkpoint.
|
||||||
|
-- It exposes no controller; callers receive only the result/reason.
|
||||||
|
|
||||||
|
local BattleSafety = {}
|
||||||
|
|
||||||
|
local BATTLE_BUSY_FIELDS = {
|
||||||
|
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
||||||
|
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
||||||
|
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
||||||
|
}
|
||||||
|
|
||||||
|
local function nonempty(value)
|
||||||
|
return type(value) == "table" and next(value) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function running(runner)
|
||||||
|
return runner and runner.isRunning and runner:isRunning()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function scriptsBusy(overworld)
|
||||||
|
return running(overworld and overworld.runner)
|
||||||
|
or nonempty(overworld and overworld.parallelRunners)
|
||||||
|
or nonempty(overworld and overworld.pendingScripts)
|
||||||
|
or nonempty(overworld and overworld.parallelQueue)
|
||||||
|
or nonempty(overworld and overworld.scriptMoves)
|
||||||
|
end
|
||||||
|
|
||||||
|
function BattleSafety.inspect(game, battle)
|
||||||
|
if type(game) ~= "table" or type(game.save) ~= "table"
|
||||||
|
or type(game.save.version) ~= "string" then
|
||||||
|
return nil, "not_in_playthrough", "A checkpoint requires an identified active playthrough."
|
||||||
|
end
|
||||||
|
if type(battle) ~= "table" then
|
||||||
|
return nil, "not_battle", "No battle is active."
|
||||||
|
end
|
||||||
|
if battle.kind == "link" then
|
||||||
|
return nil, "link_battle_unsupported", "Network battles cannot be checkpointed."
|
||||||
|
end
|
||||||
|
if battle.safari or battle.ghost or battle.scopeReveal or battle.demo or battle.noCatch then
|
||||||
|
return nil, "battle_variant_unsupported",
|
||||||
|
"This battle variant does not have a checkpoint contract."
|
||||||
|
end
|
||||||
|
if battle.kind ~= "wild" and battle.kind ~= "trainer" then
|
||||||
|
return nil, "battle_variant_unsupported",
|
||||||
|
"This battle kind does not have a checkpoint contract."
|
||||||
|
end
|
||||||
|
local origin = battle.checkpointOrigin
|
||||||
|
local ordinaryOrigin = battle.kind == "wild" and "wild_encounter"
|
||||||
|
or "trainer_encounter"
|
||||||
|
local scriptedOrigin = type(origin) == "table"
|
||||||
|
and origin.kind == "script_battle"
|
||||||
|
if type(origin) ~= "table"
|
||||||
|
or (origin.kind ~= ordinaryOrigin and not scriptedOrigin) then
|
||||||
|
return nil, "battle_origin_unsupported",
|
||||||
|
"The battle completion path cannot be reconstructed safely."
|
||||||
|
end
|
||||||
|
local overworld = game.overworld or {}
|
||||||
|
local scriptedRunner = scriptedOrigin and (battle.checkpointScriptContinuation
|
||||||
|
or (overworld.runner
|
||||||
|
and overworld.runner.isCheckpointBattle
|
||||||
|
and overworld.runner:isCheckpointBattle(battle)))
|
||||||
|
local otherScriptWork = nonempty(overworld.parallelRunners)
|
||||||
|
or nonempty(overworld.pendingScripts) or nonempty(overworld.parallelQueue)
|
||||||
|
or nonempty(overworld.scriptMoves)
|
||||||
|
if (scriptedOrigin and (not scriptedRunner or otherScriptWork))
|
||||||
|
or (not scriptedOrigin and scriptsBusy(overworld)) then
|
||||||
|
return nil, "script_busy", "A suspended or queued script cannot be checkpointed."
|
||||||
|
end
|
||||||
|
if battle.phase ~= "menu" or nonempty(battle.queue) then
|
||||||
|
return nil, "battle_phase_busy",
|
||||||
|
"Wait for the player command menu before creating a checkpoint."
|
||||||
|
end
|
||||||
|
for _, field in ipairs(BATTLE_BUSY_FIELDS) do
|
||||||
|
if battle[field] ~= nil and battle[field] ~= false then
|
||||||
|
return nil, "battle_phase_busy", "Wait for the current battle action to finish."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not battle.player or not battle.enemy or not battle.player.mon
|
||||||
|
or battle.player.mon.hp <= 0
|
||||||
|
or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then
|
||||||
|
return nil, "battle_phase_busy",
|
||||||
|
"Wait for a supported player decision before creating a checkpoint."
|
||||||
|
end
|
||||||
|
for _, battler in ipairs({ battle.player, battle.enemy }) do
|
||||||
|
if not battler.mon or battler.shownHP ~= battler.mon.hp
|
||||||
|
or battler.shownStatus ~= battler.mon.status
|
||||||
|
or battler.drainFloor ~= nil or battler.drainHold ~= nil
|
||||||
|
or battler.faintQueued then
|
||||||
|
return nil, "battle_phase_busy",
|
||||||
|
"Wait for battle status and HP presentation to settle."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return BattleSafety
|
||||||
@@ -21,12 +21,14 @@ local MoveEffects = require("src.battle.MoveEffects")
|
|||||||
local Party = require("src.pokemon.Party")
|
local Party = require("src.pokemon.Party")
|
||||||
local Pokemon = require("src.pokemon.Pokemon")
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local BattleSafety = require("src.battle.BattleSafety")
|
||||||
local Screens = require("src.ui.Screens")
|
local Screens = require("src.ui.Screens")
|
||||||
local Status = require("src.battle.Status")
|
local Status = require("src.battle.Status")
|
||||||
local Timing = require("src.core.Timing")
|
local Timing = require("src.core.Timing")
|
||||||
local TrainerAI = require("src.battle.TrainerAI")
|
local TrainerAI = require("src.battle.TrainerAI")
|
||||||
local TurnOrder = require("src.battle.TurnOrder")
|
local TurnOrder = require("src.battle.TurnOrder")
|
||||||
local TypeChart = require("src.battle.TypeChart")
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
local UIVisibility = require("src.battle.UIVisibility")
|
||||||
local RomText = require("src.core.RomText")
|
local RomText = require("src.core.RomText")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
local WideBattle = require("src.battle.WideBattle")
|
local WideBattle = require("src.battle.WideBattle")
|
||||||
@@ -134,9 +136,7 @@ function BattleState:sgbPalettes()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function BattleState:bottomUIVisible()
|
function BattleState:bottomUIVisible()
|
||||||
if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end
|
return UIVisibility.bottomVisible(self, true)
|
||||||
return Runtime.call("battle.bottom_ui_visible", function() return true end,
|
|
||||||
self) ~= false
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function BattleState:statusHUDVisible()
|
function BattleState:statusHUDVisible()
|
||||||
@@ -1962,6 +1962,17 @@ function BattleState:update(dt)
|
|||||||
self:resolveTurn(locked)
|
self:resolveTurn(locked)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
-- START has no vanilla action at a settled supported player-decision
|
||||||
|
-- boundary. A tool mod may claim this semantic auxiliary action through
|
||||||
|
-- the public hook, receiving only game plus a data-only kind. The shared
|
||||||
|
-- safety predicate keeps every unsupported/forced/animated phase inert.
|
||||||
|
if input:wasPressed("start") and Runtime.wantsHook("battle.menu_auxiliary") then
|
||||||
|
local safe = BattleSafety.inspect(self.game, self)
|
||||||
|
if safe and Runtime.call("battle.menu_auxiliary", function() return false end,
|
||||||
|
self.game, { kind = self.kind }) == true then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
local col = (self.menuIndex - 1) % 2
|
local col = (self.menuIndex - 1) % 2
|
||||||
local row = math.floor((self.menuIndex - 1) / 2)
|
local row = math.floor((self.menuIndex - 1) / 2)
|
||||||
if input:wasPressed("left") then
|
if input:wasPressed("left") then
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- Shared visibility rules for battle-owned UI states. Text and choice
|
||||||
|
-- overlays live above BattleState on the state stack, but they are still part
|
||||||
|
-- of its bottom UI layer and must inherit that layer's visibility.
|
||||||
|
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
|
local UIVisibility = {}
|
||||||
|
|
||||||
|
local function enclosingBattle(state)
|
||||||
|
local stack = state and state.game and state.game.stack
|
||||||
|
local states = stack and stack.states
|
||||||
|
local found = false
|
||||||
|
for i = #(states or {}), 1, -1 do
|
||||||
|
local candidate = states[i]
|
||||||
|
if candidate == state then found = true end
|
||||||
|
if found and candidate and candidate.isBattle then return candidate end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- queryState keeps the existing TextBox contract: a mod may still decide
|
||||||
|
-- visibility for that individual box. ChoiceBox only inherits the enclosing
|
||||||
|
-- battle decision, so field YES/NO prompts never become battle-hook states.
|
||||||
|
function UIVisibility.bottomVisible(state, queryState)
|
||||||
|
if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end
|
||||||
|
local battle = enclosingBattle(state)
|
||||||
|
if battle and battle ~= state
|
||||||
|
and Runtime.call("battle.bottom_ui_visible",
|
||||||
|
function() return true end, battle) == false then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if queryState or battle == state then
|
||||||
|
return Runtime.call("battle.bottom_ui_visible",
|
||||||
|
function() return true end, state) ~= false
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return UIVisibility
|
||||||
+5
-67
@@ -7,6 +7,7 @@ local Version = require("src.core.Version")
|
|||||||
local BattleState = require("src.battle.BattleState")
|
local BattleState = require("src.battle.BattleState")
|
||||||
local BattleCheckpoint = require("src.core.BattleCheckpoint")
|
local BattleCheckpoint = require("src.core.BattleCheckpoint")
|
||||||
local ModRuntime = require("src.mods.Runtime")
|
local ModRuntime = require("src.mods.Runtime")
|
||||||
|
local BattleSafety = require("src.battle.BattleSafety")
|
||||||
|
|
||||||
local Checkpoint = {}
|
local Checkpoint = {}
|
||||||
|
|
||||||
@@ -36,72 +37,9 @@ local function scriptsBusy(ow)
|
|||||||
or nonempty(ow.scriptMoves)
|
or nonempty(ow.scriptMoves)
|
||||||
end
|
end
|
||||||
|
|
||||||
local BATTLE_BUSY_FIELDS = {
|
local function inspectBattle(game, battle)
|
||||||
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
local allowed, reason, message = BattleSafety.inspect(game, battle)
|
||||||
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
if not allowed then return refusal("battle", reason, message) end
|
||||||
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
|
||||||
}
|
|
||||||
|
|
||||||
local function inspectBattle(ow, battle)
|
|
||||||
if battle.kind == "link" then
|
|
||||||
return refusal("battle", "link_battle_unsupported",
|
|
||||||
"Network battles cannot be checkpointed.")
|
|
||||||
end
|
|
||||||
if battle.safari or battle.ghost or battle.scopeReveal or battle.demo
|
|
||||||
or battle.noCatch then
|
|
||||||
return refusal("battle", "battle_variant_unsupported",
|
|
||||||
"This battle variant does not have a checkpoint contract.")
|
|
||||||
end
|
|
||||||
if battle.kind ~= "wild" and battle.kind ~= "trainer" then
|
|
||||||
return refusal("battle", "battle_variant_unsupported",
|
|
||||||
"This battle kind does not have a checkpoint contract.")
|
|
||||||
end
|
|
||||||
local origin = battle.checkpointOrigin
|
|
||||||
local ordinaryOrigin = battle.kind == "wild" and "wild_encounter"
|
|
||||||
or "trainer_encounter"
|
|
||||||
local scriptedOrigin = type(origin) == "table"
|
|
||||||
and origin.kind == "script_battle"
|
|
||||||
if type(origin) ~= "table"
|
|
||||||
or (origin.kind ~= ordinaryOrigin and not scriptedOrigin) then
|
|
||||||
return refusal("battle", "battle_origin_unsupported",
|
|
||||||
"The battle completion path cannot be reconstructed safely.")
|
|
||||||
end
|
|
||||||
local scriptedRunner = scriptedOrigin and (battle.checkpointScriptContinuation
|
|
||||||
or (ow.runner
|
|
||||||
and ow.runner.isCheckpointBattle
|
|
||||||
and ow.runner:isCheckpointBattle(battle)))
|
|
||||||
local otherScriptWork = nonempty(ow.parallelRunners)
|
|
||||||
or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue)
|
|
||||||
or nonempty(ow.scriptMoves)
|
|
||||||
if (scriptedOrigin and (not scriptedRunner or otherScriptWork))
|
|
||||||
or (not scriptedOrigin and scriptsBusy(ow)) then
|
|
||||||
return refusal("battle", "script_busy",
|
|
||||||
"A suspended or queued script cannot be checkpointed.")
|
|
||||||
end
|
|
||||||
if battle.phase ~= "menu" or nonempty(battle.queue) then
|
|
||||||
return refusal("battle", "battle_phase_busy",
|
|
||||||
"Wait for the player command menu before creating a checkpoint.")
|
|
||||||
end
|
|
||||||
for _, field in ipairs(BATTLE_BUSY_FIELDS) do
|
|
||||||
if battle[field] ~= nil and battle[field] ~= false then
|
|
||||||
return refusal("battle", "battle_phase_busy",
|
|
||||||
"Wait for the current battle action to finish.")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if not battle.player or not battle.enemy or battle.player.mon.hp <= 0
|
|
||||||
or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then
|
|
||||||
return refusal("battle", "battle_phase_busy",
|
|
||||||
"Wait for an ordinary player decision before creating a checkpoint.")
|
|
||||||
end
|
|
||||||
for _, battler in ipairs({ battle.player, battle.enemy }) do
|
|
||||||
if battler.shownHP ~= battler.mon.hp
|
|
||||||
or battler.shownStatus ~= battler.mon.status
|
|
||||||
or battler.drainFloor ~= nil or battler.drainHold ~= nil
|
|
||||||
or battler.faintQueued then
|
|
||||||
return refusal("battle", "battle_phase_busy",
|
|
||||||
"Wait for battle status and HP presentation to settle.")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return { canCapture = true, canRestore = true, kind = "battle" }
|
return { canCapture = true, canRestore = true, kind = "battle" }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -120,7 +58,7 @@ function Checkpoint.inspect(game)
|
|||||||
end
|
end
|
||||||
local top = game.stack and game.stack.top and game.stack:top()
|
local top = game.stack and game.stack.top and game.stack:top()
|
||||||
if getmetatable(top) == BattleState then
|
if getmetatable(top) == BattleState then
|
||||||
return inspectBattle(ow, top)
|
return inspectBattle(game, top)
|
||||||
end
|
end
|
||||||
if top ~= ow then
|
if top ~= ow then
|
||||||
return refusal("overworld", "screen_busy",
|
return refusal("overworld", "screen_busy",
|
||||||
|
|||||||
+19
-6
@@ -159,14 +159,15 @@ function Game:makeTitleState()
|
|||||||
self:applyOptions(self.save.options)
|
self:applyOptions(self.save.options)
|
||||||
self.stack:push(OverworldState, self.save.player.map,
|
self.stack:push(OverworldState, self.save.player.map,
|
||||||
self.save.player.x, self.save.player.y,
|
self.save.player.x, self.save.player.y,
|
||||||
self.save.player.facing)
|
self.save.player.facing,
|
||||||
|
{ via = "boot", freshBoot = true })
|
||||||
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
|
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
|
||||||
function() end)
|
function() end)
|
||||||
end,
|
end,
|
||||||
onContinue = function()
|
onContinue = function()
|
||||||
local loaded, recovered = SaveData.load()
|
local loaded, recovered = SaveData.load()
|
||||||
if loaded then
|
if loaded then
|
||||||
self:restoreSave(loaded, recovered)
|
self:restoreSave(loaded, recovered, { freshBoot = true })
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
@@ -639,7 +640,12 @@ function Game:keypressed(key)
|
|||||||
return
|
return
|
||||||
elseif key == "f2" then
|
elseif key == "f2" then
|
||||||
local loaded, recovered = SaveData.load()
|
local loaded, recovered = SaveData.load()
|
||||||
if loaded then self:restoreSave(loaded, recovered) end
|
if loaded then
|
||||||
|
-- F2 jumps straight to the loaded save's map/position, with no
|
||||||
|
-- walking transition -- a hard state teleport like Continue, not a
|
||||||
|
-- smooth warp -- whether pressed at the title screen or mid-session.
|
||||||
|
self:restoreSave(loaded, recovered, { freshBoot = true })
|
||||||
|
end
|
||||||
return
|
return
|
||||||
elseif key == "-" then
|
elseif key == "-" then
|
||||||
self:zoomStep(-1)
|
self:zoomStep(-1)
|
||||||
@@ -1124,7 +1130,7 @@ function Game:applyOptions(opts)
|
|||||||
if gbcCleared then self:writeOptions() end
|
if gbcCleared then self:writeOptions() end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:restoreSave(loaded, recovered)
|
function Game:restoreSave(loaded, recovered, opts)
|
||||||
if ModRuntime.wants("save.loading") then
|
if ModRuntime.wants("save.loading") then
|
||||||
ModRuntime.emit("save.loading", { raw = loaded })
|
ModRuntime.emit("save.loading", { raw = loaded })
|
||||||
end
|
end
|
||||||
@@ -1157,8 +1163,12 @@ function Game:restoreSave(loaded, recovered)
|
|||||||
end
|
end
|
||||||
-- rebuild the state stack from the save
|
-- rebuild the state stack from the save
|
||||||
while self.stack:top() do self.stack:pop() end
|
while self.stack:top() do self.stack:pop() end
|
||||||
|
-- freshBoot threads through from the caller (onContinue and F2 both set
|
||||||
|
-- it); a future caller that doesn't ask for it keeps the ordinary
|
||||||
|
-- crossfade by default.
|
||||||
self.stack:push(self.overworld, loaded.player.map,
|
self.stack:push(self.overworld, loaded.player.map,
|
||||||
loaded.player.x, loaded.player.y, loaded.player.facing)
|
loaded.player.x, loaded.player.y, loaded.player.facing,
|
||||||
|
{ via = "boot", freshBoot = opts and opts.freshBoot })
|
||||||
self.saveReport = report
|
self.saveReport = report
|
||||||
if not SaveData.emptyReport(report) then
|
if not SaveData.emptyReport(report) then
|
||||||
-- the report screen is a Screens id so mods (or the ui milestone) own
|
-- the report screen is a Screens id so mods (or the ui milestone) own
|
||||||
@@ -1187,9 +1197,12 @@ function Game:restoreCheckpointSave(loaded)
|
|||||||
self.save = loaded
|
self.save = loaded
|
||||||
self:adoptSave(loaded)
|
self:adoptSave(loaded)
|
||||||
while self.stack:top() do self.stack:pop() end
|
while self.stack:top() do self.stack:pop() end
|
||||||
|
-- freshBoot unconditionally: Checkpoint.resume (src/core/Checkpoint.lua)
|
||||||
|
-- is this method's only caller, and it is itself gated to the title
|
||||||
|
-- session (isTitleSession).
|
||||||
self.stack:push(self.overworld, loaded.player.map,
|
self.stack:push(self.overworld, loaded.player.map,
|
||||||
loaded.player.x, loaded.player.y, loaded.player.facing,
|
loaded.player.x, loaded.player.y, loaded.player.facing,
|
||||||
{ via = "checkpoint", checkpoint = true })
|
{ via = "checkpoint", checkpoint = true, freshBoot = true })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Install a reconstructed battle without calling BattleState:enter(), whose
|
-- Install a reconstructed battle without calling BattleState:enter(), whose
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-- the text is exhausted and A is pressed, then calls onDone.
|
-- the text is exhausted and A is pressed, then calls onDone.
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local UIVisibility = require("src.battle.UIVisibility")
|
||||||
local Theme = require("src.ui.Theme")
|
local Theme = require("src.ui.Theme")
|
||||||
local Timing = require("src.core.Timing")
|
local Timing = require("src.core.Timing")
|
||||||
|
|
||||||
@@ -393,11 +393,7 @@ function TextBox:update(dt)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function TextBox:draw()
|
function TextBox:draw()
|
||||||
if Runtime.wantsHook("battle.bottom_ui_visible")
|
if not UIVisibility.bottomVisible(self, true) then return end
|
||||||
and Runtime.call("battle.bottom_ui_visible", function() return true end,
|
|
||||||
self) == false then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
-- The dialogue box belongs against the bottom of the screen, not floating
|
-- The dialogue box belongs against the bottom of the screen, not floating
|
||||||
-- in the middle of a zoomed-out letterbox. Declared per frame; the
|
-- in the middle of a zoomed-out letterbox. Declared per frame; the
|
||||||
-- renderer blits this region to the screen edge and the rest of the UI
|
-- renderer blits this region to the screen edge and the rest of the UI
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
-- YES/NO choice box (InitYesNoTextBoxParameters: above the text box, right).
|
-- YES/NO choice box (InitYesNoTextBoxParameters: above the text box, right).
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
|
local UIVisibility = require("src.battle.UIVisibility")
|
||||||
local Theme = require("src.ui.Theme")
|
local Theme = require("src.ui.Theme")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
local Timing = require("src.core.Timing")
|
local Timing = require("src.core.Timing")
|
||||||
@@ -67,6 +68,7 @@ function ChoiceBox:update(dt)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function ChoiceBox:draw()
|
function ChoiceBox:draw()
|
||||||
|
if not UIVisibility.bottomVisible(self, false) then return end
|
||||||
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
|
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
|
||||||
-- rides the same bottom anchor as the dialogue box it sits above, so the
|
-- rides the same bottom anchor as the dialogue box it sits above, so the
|
||||||
-- pair travels together (the anchor keeps each element's gap from the edge)
|
-- pair travels together (the anchor keeps each element's gap from the edge)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ local MODULES = {
|
|||||||
QuantityBox = "src.ui.QuantityBox",
|
QuantityBox = "src.ui.QuantityBox",
|
||||||
NamingScreen = "src.ui.NamingScreen",
|
NamingScreen = "src.ui.NamingScreen",
|
||||||
PicBox = "src.ui.PicBox",
|
PicBox = "src.ui.PicBox",
|
||||||
|
PokemonIcon = "src.ui.PokemonIcon",
|
||||||
TextBox = "src.render.TextBox",
|
TextBox = "src.render.TextBox",
|
||||||
Font = "src.render.Font",
|
Font = "src.render.Font",
|
||||||
Theme = "src.ui.Theme",
|
Theme = "src.ui.Theme",
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- Public read-only Pokemon icon presentation for detached summaries.
|
||||||
|
-- Resolution and rendering deliberately stay engine-owned: PartyMenu already
|
||||||
|
-- composes content icon registrations, per-species definitions, asset
|
||||||
|
-- overrides, and the pokemon.icon hook in one canonical path.
|
||||||
|
|
||||||
|
local PartyMenu = require("src.ui.PartyMenu")
|
||||||
|
|
||||||
|
local PokemonIcon = {}
|
||||||
|
|
||||||
|
local function finite(value)
|
||||||
|
return type(value) == "number" and value == value
|
||||||
|
and value ~= math.huge and value ~= -math.huge
|
||||||
|
end
|
||||||
|
|
||||||
|
local function integer(value, minimum)
|
||||||
|
return finite(value) and value % 1 == 0 and value >= minimum
|
||||||
|
end
|
||||||
|
|
||||||
|
function PokemonIcon.draw(game, summary, x, y, opts)
|
||||||
|
opts = type(opts) == "table" and opts or {}
|
||||||
|
if type(game) ~= "table" or type(summary) ~= "table"
|
||||||
|
or type(summary.species) ~= "string" or summary.species == ""
|
||||||
|
or not integer(summary.hp, 0) or not integer(summary.maxHp, 1)
|
||||||
|
or summary.hp > summary.maxHp or not finite(x) or not finite(y)
|
||||||
|
or (opts.selected ~= nil and type(opts.selected) ~= "boolean")
|
||||||
|
or (opts.counter ~= nil and not finite(opts.counter)) then
|
||||||
|
return false, "invalid_pokemon_preview",
|
||||||
|
"Pokemon icon presentation needs species and valid captured HP values."
|
||||||
|
end
|
||||||
|
local ok, message = pcall(PartyMenu.drawIcon, game, {
|
||||||
|
species = summary.species,
|
||||||
|
hp = summary.hp,
|
||||||
|
stats = { hp = summary.maxHp },
|
||||||
|
}, x, y, opts.selected == true, opts.counter or 0)
|
||||||
|
if not ok then
|
||||||
|
return false, "pokemon_icon_failed", tostring(message)
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return PokemonIcon
|
||||||
@@ -462,8 +462,18 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
|||||||
if not keepMusic then
|
if not keepMusic then
|
||||||
-- ..(home/overworld.asm ln 2346)
|
-- ..(home/overworld.asm ln 2346)
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
|
-- opts.freshBoot: switch instantly instead of cross-fading, like every
|
||||||
|
-- other map's PlayDefaultMusic on real hardware -- set only by
|
||||||
|
-- Game.lua's hard state teleports (onContinue, New Game, F2,
|
||||||
|
-- restoreCheckpointSave). Deliberately separate from opts.via ==
|
||||||
|
-- "boot" itself: dev tooling (src/dev/Console.lua's warp verb,
|
||||||
|
-- src/dev/HotReload.lua's reloadMap) reuses that same default for the
|
||||||
|
-- surf-restore/fresh-npc-pool branches above and must keep the
|
||||||
|
-- ordinary crossfade.
|
||||||
|
local fade = Music.MAP_FADE
|
||||||
|
if opts and opts.freshBoot then fade = nil end
|
||||||
Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing,
|
Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing,
|
||||||
Music.MAP_FADE)
|
fade)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- forced bike/surf tiles fire the moment the player is placed on the
|
-- forced bike/surf tiles fire the moment the player is placed on the
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
-- Public battle auxiliary actions are a narrow semantic entry point for tool
|
||||||
|
-- mods. They run only at the same settled ordinary decision boundary as a
|
||||||
|
-- battle checkpoint, consume no FIGHT/PKMN/ITEM/RUN action, and receive no
|
||||||
|
-- live BattleState object.
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local T = require("tests.harness").suite("battle menu auxiliary action")
|
||||||
|
local Fixtures = require("tests.modkit").fixtures
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local Hooks = require("src.mods.Hooks")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local StateStack = require("src.core.StateStack")
|
||||||
|
|
||||||
|
local Data = Fixtures.fresh()
|
||||||
|
|
||||||
|
local function makeGame(kind)
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.meta.playthroughId = "battle-menu-playthrough"
|
||||||
|
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
|
||||||
|
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
||||||
|
local overworld = {
|
||||||
|
map = { id = save.player.map },
|
||||||
|
player = { cellX = save.player.x, cellY = save.player.y, facing = save.player.facing },
|
||||||
|
runner = { isRunning = function() return false end },
|
||||||
|
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
|
||||||
|
}
|
||||||
|
local game = { data = Data, save = save, stack = stack }
|
||||||
|
game.input = { wasPressed = function(_, button) return button == "start" end }
|
||||||
|
game.overworld = overworld
|
||||||
|
stack.states[1] = overworld
|
||||||
|
local battle = kind == "trainer"
|
||||||
|
and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
|
||||||
|
or BattleState.newWild(game, "FIXMON_B", 12)
|
||||||
|
battle.phase, battle.queue = "menu", {}
|
||||||
|
battle.checkpointOrigin = kind == "trainer"
|
||||||
|
and { kind = "trainer_encounter", map = save.player.map, npcId = "TRAINER_1",
|
||||||
|
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1, event = "EVENT_BEAT_TRAINER_1" }
|
||||||
|
or { kind = "wild_encounter", map = save.player.map }
|
||||||
|
battle.onFinish = function() end
|
||||||
|
stack.states[2] = battle
|
||||||
|
return game, battle
|
||||||
|
end
|
||||||
|
|
||||||
|
local oldHooks = Runtime.hooks
|
||||||
|
local hooks = Hooks.new()
|
||||||
|
Runtime.hooks = hooks
|
||||||
|
|
||||||
|
local game, battle = makeGame("wild")
|
||||||
|
local calls = 0
|
||||||
|
hooks:wrap("battle.menu_auxiliary", function(nextFn, liveGame, context)
|
||||||
|
calls = calls + 1
|
||||||
|
T.check(liveGame == game, "auxiliary action receives the live game")
|
||||||
|
T.same(context, { kind = "wild" }, "auxiliary action receives only data-only battle context")
|
||||||
|
return true
|
||||||
|
end, 0, "tool_fixture")
|
||||||
|
|
||||||
|
local originalIndex = battle.menuIndex
|
||||||
|
battle:update(1 / 60)
|
||||||
|
T.eq(calls, 1, "START reaches the public auxiliary action at a wild decision")
|
||||||
|
T.eq(battle.phase, "menu", "handled auxiliary action does not advance the battle")
|
||||||
|
T.eq(battle.menuIndex, originalIndex, "handled auxiliary action preserves cursor")
|
||||||
|
T.eq(#battle.queue, 0, "handled auxiliary action does not enqueue a turn")
|
||||||
|
|
||||||
|
hooks:removeOwner("tool_fixture")
|
||||||
|
local trainerGame, trainer = makeGame("trainer")
|
||||||
|
local trainerCalls = 0
|
||||||
|
hooks:wrap("battle.menu_auxiliary", function(_, liveGame, context)
|
||||||
|
trainerCalls = trainerCalls + 1
|
||||||
|
T.check(liveGame == trainerGame, "trainer action receives its live game")
|
||||||
|
T.same(context, { kind = "trainer" }, "trainer context remains data-only")
|
||||||
|
return true
|
||||||
|
end, 0, "trainer_fixture")
|
||||||
|
trainer:update(1 / 60)
|
||||||
|
T.eq(trainerCalls, 1, "START reaches the public auxiliary action at a trainer decision")
|
||||||
|
hooks:removeOwner("trainer_fixture")
|
||||||
|
|
||||||
|
local scriptedGame, scripted = makeGame("trainer")
|
||||||
|
scripted.checkpointOrigin = { kind = "script_battle", scriptId = "STORY_TEST", pc = 4 }
|
||||||
|
scripted.checkpointScriptContinuation = { kind = "script_battle" }
|
||||||
|
local scriptedCalls = 0
|
||||||
|
hooks:wrap("battle.menu_auxiliary", function(_, liveGame, context)
|
||||||
|
scriptedCalls = scriptedCalls + 1
|
||||||
|
T.check(liveGame == scriptedGame,
|
||||||
|
"scripted action receives the live game without its runner")
|
||||||
|
T.same(context, { kind = "trainer" },
|
||||||
|
"supported scripted trainer context remains data-only")
|
||||||
|
return true
|
||||||
|
end, 0, "scripted_fixture")
|
||||||
|
scripted:update(1 / 60)
|
||||||
|
T.eq(scriptedCalls, 1,
|
||||||
|
"START reaches the public auxiliary action at a supported scripted decision")
|
||||||
|
hooks:removeOwner("scripted_fixture")
|
||||||
|
|
||||||
|
local unsafeGame, unsafe = makeGame("wild")
|
||||||
|
unsafe.phase = "messages"
|
||||||
|
local unsafeCalls = 0
|
||||||
|
hooks:wrap("battle.menu_auxiliary", function() unsafeCalls = unsafeCalls + 1 return true end,
|
||||||
|
0, "unsafe_fixture")
|
||||||
|
unsafe:update(1 / 60)
|
||||||
|
T.eq(unsafeCalls, 0, "messages never expose the auxiliary action")
|
||||||
|
hooks:removeOwner("unsafe_fixture")
|
||||||
|
|
||||||
|
Runtime.hooks = oldHooks
|
||||||
|
T.finish()
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
-- Regression test for the title-music-bleeds-into-the-map bug: Continue,
|
||||||
|
-- F2 quickload, and checkpoint-resume used to drop the player into the
|
||||||
|
-- overworld while the old song (the title screen's, or F2's previous
|
||||||
|
-- location) was still cross-fading in over Music.MAP_FADE's ~1.2s,
|
||||||
|
-- audibly wrong since the player already had control. See
|
||||||
|
-- OverworldState:setMap (src/world/OverworldController.lua) for the
|
||||||
|
-- opts.freshBoot mechanism this exercises, and Game.lua for where it's
|
||||||
|
-- set (onContinue, New Game, F2, restoreCheckpointSave) and where it's
|
||||||
|
-- deliberately not (dev tooling's reuse of opts.via == "boot").
|
||||||
|
--
|
||||||
|
-- (A)-(A4) and (C) call the real Game:restoreSave, Game:keypressed("f2"),
|
||||||
|
-- Game:restoreCheckpointSave and Console:exec("warp ...") -- SaveData.load
|
||||||
|
-- stubbed to skip the slot/persistence format -- so a dropped freshBoot at
|
||||||
|
-- any real call site fails this test, not just a hand-built opts table.
|
||||||
|
-- (D) simulates HotReload's { via = "boot" } shape instead of calling
|
||||||
|
-- through its local, unexported reloadMap.
|
||||||
|
--
|
||||||
|
-- ROM-free (fixture dataset -- FIX_TOWN/FIX_ROUTE, tests/fixture_data),
|
||||||
|
-- like tests/engine/warp_sprite_hidden_bug916.lua, so the CI headless
|
||||||
|
-- tier (no data/generated/) runs it.
|
||||||
|
-- luajit tests/engine/resume_boot_music_no_fade.lua
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local check = T.check
|
||||||
|
local eq = T.eq
|
||||||
|
|
||||||
|
local Source = {}
|
||||||
|
Source.__index = Source
|
||||||
|
function Source:play() self.playing = true end
|
||||||
|
function Source:stop() self.playing = false end
|
||||||
|
function Source:pause() self.playing = false end
|
||||||
|
function Source:isPlaying() return self.playing end
|
||||||
|
function Source:setLooping() end
|
||||||
|
function Source:setVolume(v) self.volume = v end
|
||||||
|
function Source:setPitch() end
|
||||||
|
function Source:setFilter() end
|
||||||
|
function Source:getDuration() return 1 end
|
||||||
|
|
||||||
|
local made = {} -- file -> the last source built for it
|
||||||
|
love.audio = {
|
||||||
|
newSource = function(file, mode)
|
||||||
|
made[file] = setmetatable({ file = file, mode = mode }, Source)
|
||||||
|
return made[file]
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
-- fixture patches that let the overworld boot and run headlessly (same
|
||||||
|
-- set tests/engine/warp_sprite_hidden_bug916.lua needs for the same reason)
|
||||||
|
Data.tilesets.FIX_OUT.tilesPerRow = 16
|
||||||
|
Data.field.flyWarps = Data.field.flyWarps or {}
|
||||||
|
Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" }
|
||||||
|
Data.field.waterTilesets = {}
|
||||||
|
Data.field.forcedMovement = { tiles = {} }
|
||||||
|
-- no data.audio in the fixture dataset either; synthesize just enough for
|
||||||
|
-- real Music.lua playback to run against the real FIX_TOWN/FIX_ROUTE maps
|
||||||
|
Data.audio = Data.audio or {}
|
||||||
|
Data.audio.songs = Data.audio.songs or {}
|
||||||
|
Data.audio.songs.Music_TitleScreen = { file = "title.wav" }
|
||||||
|
Data.audio.mapSongs = Data.audio.mapSongs or {}
|
||||||
|
Data.audio.mapSongs.FIX_TOWN = "Music_FixTown"
|
||||||
|
Data.audio.songs.Music_FixTown = { file = "town.wav" }
|
||||||
|
Data.audio.mapSongs.FIX_ROUTE = "Music_FixRoute"
|
||||||
|
Data.audio.songs.Music_FixRoute = { file = "route.wav" }
|
||||||
|
|
||||||
|
local Music = require("src.core.Music")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local Game = require("src.core.Game")
|
||||||
|
local StateStack = require("src.core.StateStack")
|
||||||
|
local OverworldState = require("src.world.OverworldController")
|
||||||
|
local Console = require("src.dev.Console")
|
||||||
|
|
||||||
|
Game.data = Data
|
||||||
|
Game.save = SaveData.newGame()
|
||||||
|
Game.save.player.name = "RED"
|
||||||
|
Game.save.player.map = "FIX_TOWN"
|
||||||
|
StateStack:init()
|
||||||
|
Game.stack = StateStack
|
||||||
|
Game.overworld = OverworldState -- set once at boot in the real game (Game.lua)
|
||||||
|
Game.input = {
|
||||||
|
isDown = function() return false end,
|
||||||
|
wasPressed = function() return false end,
|
||||||
|
step = function() end, state = {}, pressQueue = {},
|
||||||
|
}
|
||||||
|
Game.renderer = {
|
||||||
|
beginWorldPass = function() end, endWorldPass = function() end,
|
||||||
|
beginUIPass = function() end, endUIPass = function() end,
|
||||||
|
worldViewSize = function() return 160, 144 end,
|
||||||
|
setSGBZones = function() end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function playing()
|
||||||
|
for file, src in pairs(made) do
|
||||||
|
if src.playing then return file end
|
||||||
|
end
|
||||||
|
return "(silence)"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function finishFade()
|
||||||
|
for _ = 1, 7 * Music.MAP_FADE do Music.update(Data) end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- (A) The real Game:restoreSave, called the way onContinue calls it.
|
||||||
|
-- ===========================================================================
|
||||||
|
Music.play(Data, "Music_TitleScreen")
|
||||||
|
eq(playing(), "title.wav", "title screen music is playing before Continue")
|
||||||
|
|
||||||
|
local loaded = SaveData.newGame()
|
||||||
|
loaded.player.map = "FIX_TOWN"
|
||||||
|
Game:restoreSave(loaded, false, { freshBoot = true })
|
||||||
|
|
||||||
|
eq(playing(), "town.wav",
|
||||||
|
"Continue's real restoreSave(..., {freshBoot=true}) swaps at once")
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- (A2) The same real Game:restoreSave with no opts at all -- its own
|
||||||
|
-- default (e.g. for any future caller that doesn't ask for freshBoot) is
|
||||||
|
-- the safe, ordinary crossfade, not a silent hard-cut.
|
||||||
|
-- ===========================================================================
|
||||||
|
Music.play(Data, "Music_TitleScreen") -- stand-in for whatever was playing
|
||||||
|
local loaded2 = SaveData.newGame()
|
||||||
|
loaded2.player.map = "FIX_TOWN"
|
||||||
|
Game:restoreSave(loaded2, false)
|
||||||
|
|
||||||
|
eq(playing(), "title.wav",
|
||||||
|
"restoreSave(...) with no opts still fades, not an instant swap")
|
||||||
|
finishFade()
|
||||||
|
eq(playing(), "town.wav", "...landing on the loaded save's map song")
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- (A3) The real Game:keypressed("f2") handler, both ways it's reachable:
|
||||||
|
-- at the title screen and mid-session. SaveData.load is stubbed rather
|
||||||
|
-- than round-tripped through the in-memory love.filesystem, to isolate
|
||||||
|
-- this test from the slot/persistence format.
|
||||||
|
-- ===========================================================================
|
||||||
|
local realLoad = SaveData.load
|
||||||
|
local loaded3 = SaveData.newGame()
|
||||||
|
loaded3.player.map = "FIX_TOWN"
|
||||||
|
SaveData.load = function() return loaded3, false end
|
||||||
|
|
||||||
|
StateStack:init() -- no overworld on the stack: "at the title screen"
|
||||||
|
Music.play(Data, "Music_TitleScreen")
|
||||||
|
Game:keypressed("f2")
|
||||||
|
eq(playing(), "town.wav",
|
||||||
|
"F2 from the title screen (overworld not on the stack) swaps at once")
|
||||||
|
|
||||||
|
StateStack:init()
|
||||||
|
StateStack.states[1] = OverworldState -- overworld already active: mid-session
|
||||||
|
Music.play(Data, "Music_TitleScreen") -- stand-in for the session's own song
|
||||||
|
Game:keypressed("f2")
|
||||||
|
eq(playing(), "town.wav",
|
||||||
|
"F2 mid-session (a live overworld already on the stack) also swaps at once")
|
||||||
|
|
||||||
|
SaveData.load = realLoad
|
||||||
|
StateStack:init()
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- (A4) The real Game:restoreCheckpointSave, called the way Checkpoint.resume
|
||||||
|
-- (RFC 0006's mod.checkpoint:resume) calls it.
|
||||||
|
-- ===========================================================================
|
||||||
|
Music.play(Data, "Music_TitleScreen")
|
||||||
|
local checkpointSave = SaveData.newGame()
|
||||||
|
checkpointSave.player.map = "FIX_TOWN"
|
||||||
|
Game:restoreCheckpointSave(checkpointSave)
|
||||||
|
eq(playing(), "town.wav",
|
||||||
|
"a title-session checkpoint resume swaps at once, no lingering title music")
|
||||||
|
|
||||||
|
StateStack:init()
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- (B) An ordinary warp (e.g. walking into a house) is unaffected: it still
|
||||||
|
-- cross-fades like any other map-to-map transition.
|
||||||
|
-- ===========================================================================
|
||||||
|
OverworldState:setMap("FIX_ROUTE", 3, 3, "up", {})
|
||||||
|
eq(playing(), "town.wav",
|
||||||
|
"an ordinary warp still fades: the old song is still playing right after setMap")
|
||||||
|
finishFade()
|
||||||
|
eq(playing(), "route.wav",
|
||||||
|
"...and lands on the new map's song once the fade completes")
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- (C) The real dev console `warp` verb (src/dev/Console.lua VERBS.warp).
|
||||||
|
-- ===========================================================================
|
||||||
|
Music.play(Data, "Music_TitleScreen") -- re-arm a "stale" song to prove intent
|
||||||
|
Console.new(Game):exec("warp FIX_TOWN 5 5")
|
||||||
|
|
||||||
|
eq(playing(), "title.wav",
|
||||||
|
"Console's real `warp` verb still fades, like an ordinary warp")
|
||||||
|
finishFade()
|
||||||
|
eq(playing(), "town.wav", "...landing on the target map's song")
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- (D) src/dev/HotReload.lua's reloadMap opts shape, simulated (see header)
|
||||||
|
-- rather than called through: reloadMap is local/unexported, and
|
||||||
|
-- HotReload.run's full loader teardown is out of scope for this fix.
|
||||||
|
-- ===========================================================================
|
||||||
|
Music.play(Data, "Music_TitleScreen")
|
||||||
|
OverworldState:setMap("FIX_ROUTE", 3, 3, "up", { via = "boot" })
|
||||||
|
eq(playing(), "title.wav",
|
||||||
|
"HotReload's { via = \"boot\" } setMap still fades, not an instant swap")
|
||||||
|
finishFade()
|
||||||
|
eq(playing(), "route.wav", "...landing on the reloaded map's song")
|
||||||
|
|
||||||
|
T.finish("resume_boot_music_no_fade")
|
||||||
@@ -12,6 +12,7 @@ local Zoom = require("src.render.Zoom")
|
|||||||
local ListMenu = require("src.ui.ListMenu")
|
local ListMenu = require("src.ui.ListMenu")
|
||||||
local NamingScreen = require("src.ui.NamingScreen")
|
local NamingScreen = require("src.ui.NamingScreen")
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
local PartyMenu = require("src.ui.PartyMenu")
|
local PartyMenu = require("src.ui.PartyMenu")
|
||||||
local Player = require("src.world.Player")
|
local Player = require("src.world.Player")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
@@ -180,6 +181,23 @@ do
|
|||||||
text:draw()
|
text:draw()
|
||||||
check(seen == text, "pushed text boxes use the same visibility hook")
|
check(seen == text, "pushed text boxes use the same visibility hook")
|
||||||
unsub()
|
unsub()
|
||||||
|
|
||||||
|
local battle = setmetatable({ isBattle = true }, BattleState)
|
||||||
|
local game = { stack = { states = {} } }
|
||||||
|
text = setmetatable({ game = game }, TextBox)
|
||||||
|
local choice = setmetatable({ game = game }, ChoiceBox)
|
||||||
|
game.stack.states = { battle, text, choice }
|
||||||
|
local queried = {}
|
||||||
|
unsub = wrap("battle.bottom_ui_visible", function(_, state)
|
||||||
|
queried[#queried + 1] = state
|
||||||
|
return state ~= battle
|
||||||
|
end)
|
||||||
|
text:draw()
|
||||||
|
choice:draw()
|
||||||
|
check(queried[1] == battle and queried[2] == battle and #queried == 2,
|
||||||
|
"battle overlays inherit a hidden bottom layer without drawing backings")
|
||||||
|
unsub()
|
||||||
|
|
||||||
check(BattleState.bottomUIVisible({ phase = "moveSelect" }),
|
check(BattleState.bottomUIVisible({ phase = "moveSelect" }),
|
||||||
"battle bottom UI returns when the hook is removed")
|
"battle bottom UI returns when the hook is removed")
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,10 @@ local files = {
|
|||||||
'{"id":"probe","name":"probe","version":"1.0.0",'
|
'{"id":"probe","name":"probe","version":"1.0.0",'
|
||||||
.. '"entry":"main.lua","api":2,"profile":"content"}',
|
.. '"entry":"main.lua","api":2,"profile":"content"}',
|
||||||
["mods/probe/main.lua"] = [[
|
["mods/probe/main.lua"] = [[
|
||||||
return function(mod) _G.MOD_CHECKPOINTS = mod.checkpoints end
|
return function(mod)
|
||||||
|
_G.MOD_CHECKPOINTS = mod.checkpoints
|
||||||
|
_G.MOD_HOOKS = mod.hooks
|
||||||
|
end
|
||||||
]],
|
]],
|
||||||
}
|
}
|
||||||
local game, ow = makeGame()
|
local game, ow = makeGame()
|
||||||
@@ -433,9 +436,28 @@ if battleSnapshot then
|
|||||||
"public battle capture/restore/capture is a normalized differential roundtrip")
|
"public battle capture/restore/capture is a normalized differential roundtrip")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The mod receives the normal public hook facade, never BattleState. START
|
||||||
|
-- at the restored safe decision reaches its semantic auxiliary action without
|
||||||
|
-- selecting a native command.
|
||||||
|
local auxiliaryCalls = 0
|
||||||
|
_G.MOD_HOOKS:wrap("battle.menu_auxiliary", function(nextFn, liveGame, context)
|
||||||
|
auxiliaryCalls = auxiliaryCalls + 1
|
||||||
|
T.check(liveGame == battleGame, "public battle auxiliary action receives the game")
|
||||||
|
T.same(context, { kind = "wild" }, "public auxiliary context is data-only")
|
||||||
|
return true
|
||||||
|
end)
|
||||||
|
battleGame.input = { wasPressed = function(_, button) return button == "start" end }
|
||||||
|
local boundary = battleGame.stack:top()
|
||||||
|
local originalMenuIndex = boundary.menuIndex
|
||||||
|
boundary:update(1 / 60)
|
||||||
|
T.eq(auxiliaryCalls, 1, "public mod hook receives START at the checkpoint boundary")
|
||||||
|
T.eq(boundary.phase, "menu", "public auxiliary hook does not advance the turn")
|
||||||
|
T.eq(boundary.menuIndex, originalMenuIndex, "public auxiliary hook preserves cursor")
|
||||||
|
|
||||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||||
Runtime.currentMod = nil
|
Runtime.currentMod = nil
|
||||||
_G.MOD_CHECKPOINTS = nil
|
_G.MOD_CHECKPOINTS = nil
|
||||||
|
_G.MOD_HOOKS = nil
|
||||||
love.math.getRandomState = oldGetRandomState
|
love.math.getRandomState = oldGetRandomState
|
||||||
love.math.setRandomState = oldSetRandomState
|
love.math.setRandomState = oldSetRandomState
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
-- Public read-only Pokemon icon presentation delegates to the same resolver
|
||||||
|
-- PartyMenu uses, so content registrations and pokemon.icon hooks compose.
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local PartyMenu = require("src.ui.PartyMenu")
|
||||||
|
|
||||||
|
local FIXTURE = {
|
||||||
|
["mods/icon_probe/manifest.json"] = [[{
|
||||||
|
"id": "icon_probe",
|
||||||
|
"name": "Icon Probe",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"entry": "main.lua",
|
||||||
|
"api": 2
|
||||||
|
}]],
|
||||||
|
["mods/icon_probe/main.lua"] = [[
|
||||||
|
local mod = ...
|
||||||
|
mod.exports.icon = mod.ui.PokemonIcon
|
||||||
|
]],
|
||||||
|
}
|
||||||
|
|
||||||
|
local run = T.sdk.loadMods({ "mods/icon_probe" }, { fs = T.sdk.memfs(FIXTURE) })
|
||||||
|
T.eq(#run.errors, 0, "fixture mod loads cleanly")
|
||||||
|
local icon = run.loader.exports.icon_probe.icon
|
||||||
|
T.eq(type(icon), "table", "mod.ui exposes the PokemonIcon helper")
|
||||||
|
T.eq(type(icon.draw), "function", "PokemonIcon exposes a draw operation")
|
||||||
|
|
||||||
|
local original = PartyMenu.drawIcon
|
||||||
|
local call
|
||||||
|
PartyMenu.drawIcon = function(game, mon, x, y, selected, counter)
|
||||||
|
call = { game = game, mon = mon, x = x, y = y,
|
||||||
|
selected = selected, counter = counter }
|
||||||
|
end
|
||||||
|
|
||||||
|
local game = { data = {} }
|
||||||
|
local drawn, code = icon.draw(game, {
|
||||||
|
species = "PIKACHU", hp = 4, maxHp = 10,
|
||||||
|
}, 8, 16, { selected = true, counter = 7 })
|
||||||
|
T.eq(drawn, true, "valid detached Pokemon summary is drawable")
|
||||||
|
T.eq(code, nil, "valid summary has no rejection code")
|
||||||
|
T.check(call and call.game == game, "helper delegates with the live game")
|
||||||
|
T.eq(call.mon.species, "PIKACHU", "species reaches the shared party resolver")
|
||||||
|
T.eq(call.mon.hp, 4, "captured current HP reaches icon animation semantics")
|
||||||
|
T.eq(call.mon.stats.hp, 10, "captured maximum HP reaches icon animation semantics")
|
||||||
|
T.eq(call.selected, true, "selection state is presentation-only")
|
||||||
|
T.eq(call.counter, 7, "animation counter is presentation-only")
|
||||||
|
|
||||||
|
call = nil
|
||||||
|
local bad, badCode = icon.draw(game, {
|
||||||
|
species = "PIKACHU", hp = 11, maxHp = 10,
|
||||||
|
}, 0, 0)
|
||||||
|
T.eq(bad, false, "invalid detached summary fails closed")
|
||||||
|
T.eq(badCode, "invalid_pokemon_preview", "invalid summary has a stable error")
|
||||||
|
T.eq(call, nil, "invalid summary never reaches renderer internals")
|
||||||
|
|
||||||
|
PartyMenu.drawIcon = original
|
||||||
|
run.release()
|
||||||
|
|
||||||
|
T.finish("pokemon_icon")
|
||||||
Reference in New Issue
Block a user