This commit is contained in:
bryanthaboi
2026-08-13 05:30:35 -04:00
21 changed files with 1264 additions and 89 deletions
+23 -2
View File
@@ -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"
) )
+366
View File
@@ -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/"
+54
View File
@@ -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
View File
@@ -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.
+93
View File
@@ -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."
+98
View File
@@ -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
+14 -3
View File
@@ -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
+39
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -6
View File
@@ -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
+2
View File
@@ -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)
+1
View File
@@ -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",
+41
View File
@@ -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
+11 -1
View File
@@ -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
+108
View File
@@ -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()
+208
View File
@@ -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")
+18
View File
@@ -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")
+23 -1
View File
@@ -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
+60
View File
@@ -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")