mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-13 08:51:07 +02:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b17dce232e | |||
| e1b20723fd | |||
| fdab15a6dc | |||
| 4916fc8f4b | |||
| fc19e58678 | |||
| 9610bcfb72 | |||
| 1bc252741a | |||
| 97e3fb296c | |||
| 5ec440792e | |||
| 9d3ee6a597 | |||
| 6c4c9d14e2 | |||
| d165189032 | |||
| 84bf25fcd5 | |||
| 3945b9d078 | |||
| 6796804593 | |||
| 858c5bc9b8 | |||
| 71bdbb7e48 | |||
| c346c78697 | |||
| ba2bac5bed | |||
| bcfc94d448 | |||
| bebfc87cd4 | |||
| e58929428c | |||
| a389416b7b | |||
| 0a8bca1a6d | |||
| efad8b325b | |||
| 4c5f776128 | |||
| c1495e0d5e | |||
| a97168a4f0 | |||
| a50ad30176 | |||
| c1f65106de | |||
| 4250e3aa32 | |||
| fe1714d50e | |||
| cd33858020 | |||
| fb58fa788b | |||
| d09cf80da5 | |||
| aab980b05a |
@@ -40,6 +40,7 @@ body:
|
||||
- Windows
|
||||
- Linux
|
||||
- Android
|
||||
- iOS
|
||||
- Multiple platforms
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: Release
|
||||
|
||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, and the
|
||||
# Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on the self-hosted
|
||||
# Mac runner, and publishes them as a GitHub Release.
|
||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
|
||||
# IPA, and the Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on
|
||||
# the self-hosted Mac runner, and publishes them as a GitHub Release.
|
||||
#
|
||||
# Versioning:
|
||||
# - First ever release is 0.1.0.
|
||||
@@ -170,6 +170,15 @@ jobs:
|
||||
set -euo pipefail
|
||||
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Build iOS
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Device Release IPA; signs with the Apple Development identity on
|
||||
# the runner (auto team detection). Users on other Apple IDs still
|
||||
# re-sign or build via docs/ios-install.md.
|
||||
scripts/build_ios.sh --fetch --device --release \
|
||||
--version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Build Anbernic RG34XXSP port
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -226,6 +235,10 @@ jobs:
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||
|
||||
ipa="dist/ios/gen1recomp.ipa"
|
||||
[ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; }
|
||||
cp "$ipa" "$outdir/gen1recomp-${v}-ios.ipa"
|
||||
|
||||
# Anbernic handheld port (suffix names the CFW it targets, so a
|
||||
# future RG35XX/other-CFW pack can ship alongside it).
|
||||
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
|
||||
@@ -344,6 +357,7 @@ jobs:
|
||||
"dist/release/gen1recomp-${v}-windows.zip" \
|
||||
"dist/release/gen1recomp-${v}-linux.zip" \
|
||||
"dist/release/gen1recomp-${v}-android.apk" \
|
||||
"dist/release/gen1recomp-${v}-ios.ipa" \
|
||||
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
|
||||
"dist/release/gen1recomp-${v}.love" \
|
||||
"dist/release/sha256sums.txt"
|
||||
|
||||
@@ -50,25 +50,26 @@ supplied by the player.
|
||||
|
||||
|
||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||
or download a disassembly. A canonical US Poke Red or Blue ROM is the only
|
||||
game content input.
|
||||
or download a disassembly. A canonical US Poke Red, Blue, or Yellow ROM is the
|
||||
only game content input.
|
||||
|
||||
The ROM is verified, used during import, and then released from memory. It is
|
||||
not copied into the cache. Later launches load the private generated cache and
|
||||
do not ask for the ROM again. Red and Blue can both be imported and played
|
||||
side by side.
|
||||
do not ask for the ROM again. Red, Blue, and Yellow can all be imported and
|
||||
played side by side.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Open the desktop app. On first boot, choose your legally obtained `.gb` file
|
||||
or drop it onto the window. Import takes a few seconds and the game starts
|
||||
automatically.
|
||||
Open the desktop app. On first boot, choose your legally obtained `.gb` /
|
||||
`.gbc` file or drop it onto the window. Import takes a few seconds and the
|
||||
game starts automatically.
|
||||
|
||||
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
|
||||
verifies SHA-1 before creating any game data:
|
||||
Only the canonical 1 MiB US Red, Blue, and Yellow ROMs are accepted. The
|
||||
importer verifies SHA-1 before creating any game data:
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
||||
|
||||
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
||||
sound effects, and cries are synthesized while the game runs from compact
|
||||
@@ -141,11 +142,11 @@ famous bugs included:
|
||||
|
||||
## Running From Source
|
||||
|
||||
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
|
||||
Requires LÖVE 11.x. Place a Red, Blue, or Yellow ROM in the project folder and
|
||||
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
|
||||
|
||||
```sh
|
||||
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Poke Blue.gb
|
||||
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Blue.gb / Yellow.gbc
|
||||
scripts/run.sh
|
||||
```
|
||||
|
||||
@@ -176,6 +177,13 @@ even on a different computer, as long as the same folder comes along.
|
||||
already written to either location is touched automatically, so copy files
|
||||
over yourself if you want to carry existing progress across the switch.
|
||||
|
||||
## iOS
|
||||
|
||||
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
|
||||
(Windows or Mac) — see [docs/ios-sideload.md](docs/ios-sideload.md). To
|
||||
build and install from source on a Mac instead, see
|
||||
[docs/ios-install.md](docs/ios-install.md).
|
||||
|
||||
## Handhelds
|
||||
|
||||
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
function love.conf(t)
|
||||
-- PhysFS ignores symlinks unless told otherwise, so a mod dev-linked into
|
||||
-- mods/ (ln -s, matching the mklink /J workflow on Windows) is invisible
|
||||
-- to love.filesystem.getDirectoryItems without this.
|
||||
love.filesystem.setSymlinksEnabled(true)
|
||||
|
||||
local editor = os.getenv("POKEPORT_EDITOR") == "1"
|
||||
local developer = os.getenv("POKEPORT_DEV") == "1"
|
||||
if arg then
|
||||
@@ -62,7 +67,10 @@ function love.conf(t)
|
||||
-- the game follow the device. The renderer letterboxes the 160x144
|
||||
-- viewport into whatever size results, and the on-screen touch controls
|
||||
-- re-lay themselves out from the new window size, so both orientations
|
||||
-- just work. iOS follows the Info.plist orientations
|
||||
-- just work. FULL_SENSOR ignores the device's rotation lock, so
|
||||
-- GameActivity.setOrientationBis remaps it to FULL_USER after SDL has
|
||||
-- run: same orientations allowed, but auto-rotate being off now wins.
|
||||
-- iOS follows the Info.plist orientations
|
||||
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
|
||||
t.window.resizable = true
|
||||
-- Starting size is a tall portrait hint; the OS resizes to the real
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
-- Generated by tools/build_data.py. DO NOT EDIT.
|
||||
-- Pokemon Yellow palettes (COLORS=OG YELLOW): SuperPalettes +
|
||||
-- CGBBasePalettes (authentic GBC look) as 4 8-bit RGB colors
|
||||
-- per palette (color 0 first), plus MonsterPalettes.
|
||||
return {
|
||||
cgbBase = {
|
||||
["0F"] = {
|
||||
{ 255, 255, 255 },
|
||||
{ 107, 8, 255 },
|
||||
{ 0, 74, 255 },
|
||||
{ 8, 8, 8 },
|
||||
},
|
||||
BADGE = {
|
||||
{ 255, 255, 255 },
|
||||
{ 189, 66, 0 },
|
||||
{ 140, 115, 90 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
BLACK = {
|
||||
{ 255, 255, 255 },
|
||||
{ 25, 25, 25 },
|
||||
{ 25, 25, 25 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
BLUEMON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 132, 148, 255 },
|
||||
{ 0, 8, 206 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
BROWNMON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 239, 148, 82 },
|
||||
{ 140, 74, 41 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
CAVE = {
|
||||
{ 255, 255, 255 },
|
||||
{ 189, 66, 0 },
|
||||
{ 140, 115, 90 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
CELADON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 41, 255, 41 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
CERULEAN = {
|
||||
{ 255, 255, 255 },
|
||||
{ 41, 66, 255 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
CINNABAR = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 66, 66 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
CYANMON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 132, 214, 255 },
|
||||
{ 0, 140, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
FUCHSIA = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 123, 123 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
GAMEFREAK = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 156, 0 },
|
||||
{ 156, 156, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
GRAYMON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 165, 189, 82 },
|
||||
{ 90, 90, 41 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
GREENBAR = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 0 },
|
||||
{ 0, 255, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
GREENMON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 140, 255, 90 },
|
||||
{ 8, 181, 49 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
INDIGO = {
|
||||
{ 255, 255, 255 },
|
||||
{ 90, 66, 255 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
LAVENDER = {
|
||||
{ 255, 255, 255 },
|
||||
{ 206, 33, 255 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
LOGO1 = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 0 },
|
||||
{ 255, 0, 0 },
|
||||
{ 255, 0, 0 },
|
||||
},
|
||||
LOGO2 = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 0 },
|
||||
{ 58, 58, 206 },
|
||||
{ 0, 0, 140 },
|
||||
},
|
||||
MEWMON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 0 },
|
||||
{ 255, 8, 8 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
PALLET = {
|
||||
{ 255, 255, 255 },
|
||||
{ 189, 140, 255 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
PEWTER = {
|
||||
{ 255, 255, 255 },
|
||||
{ 148, 148, 123 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
PIKACHUS_BEACH = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 0 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
PIKACHUS_BEACH_TITLE = {
|
||||
{ 255, 255, 255 },
|
||||
{ 74, 74, 74 },
|
||||
{ 255, 173, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
PIKACHU_PORTRAIT = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 148, 0 },
|
||||
{ 156, 58, 8 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
PINKMON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 123, 148 },
|
||||
{ 255, 0, 49 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
PURPLEMON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 206, 123, 255 },
|
||||
{ 156, 0, 181 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
REDBAR = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 0 },
|
||||
{ 255, 0, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
REDMON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 140, 0 },
|
||||
{ 255, 0, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
ROUTE = {
|
||||
{ 255, 255, 255 },
|
||||
{ 132, 255, 33 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
SAFFRON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 0 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
SLOTS1 = {
|
||||
{ 255, 255, 255 },
|
||||
{ 206, 8, 255 },
|
||||
{ 255, 0, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
SLOTS2 = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 33, 156 },
|
||||
{ 255, 255, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
SLOTS3 = {
|
||||
{ 255, 255, 255 },
|
||||
{ 66, 255, 0 },
|
||||
{ 255, 255, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
SLOTS4 = {
|
||||
{ 255, 255, 255 },
|
||||
{ 0, 255, 255 },
|
||||
{ 255, 255, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
TOWNMAP = {
|
||||
{ 255, 255, 255 },
|
||||
{ 0, 173, 255 },
|
||||
{ 82, 230, 0 },
|
||||
{ 8, 8, 8 },
|
||||
},
|
||||
VERMILION = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 156, 0 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
VIRIDIAN = {
|
||||
{ 255, 255, 255 },
|
||||
{ 156, 255, 0 },
|
||||
{ 90, 189, 255 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
YELLOWBAR = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 0 },
|
||||
{ 255, 148, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
YELLOWMON = {
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 0 },
|
||||
{ 230, 115, 0 },
|
||||
{ 25, 25, 25 },
|
||||
},
|
||||
},
|
||||
order = {
|
||||
"ROUTE",
|
||||
"PALLET",
|
||||
"VIRIDIAN",
|
||||
"PEWTER",
|
||||
"CERULEAN",
|
||||
"LAVENDER",
|
||||
"VERMILION",
|
||||
"CELADON",
|
||||
"FUCHSIA",
|
||||
"CINNABAR",
|
||||
"INDIGO",
|
||||
"SAFFRON",
|
||||
"TOWNMAP",
|
||||
"LOGO1",
|
||||
"LOGO2",
|
||||
"0F",
|
||||
"MEWMON",
|
||||
"BLUEMON",
|
||||
"REDMON",
|
||||
"CYANMON",
|
||||
"PURPLEMON",
|
||||
"BROWNMON",
|
||||
"GREENMON",
|
||||
"PINKMON",
|
||||
"YELLOWMON",
|
||||
"GRAYMON",
|
||||
"SLOTS1",
|
||||
"SLOTS2",
|
||||
"SLOTS3",
|
||||
"SLOTS4",
|
||||
"BLACK",
|
||||
"GREENBAR",
|
||||
"YELLOWBAR",
|
||||
"REDBAR",
|
||||
"BADGE",
|
||||
"CAVE",
|
||||
"GAMEFREAK",
|
||||
"PIKACHUS_BEACH",
|
||||
"PIKACHU_PORTRAIT",
|
||||
"PIKACHUS_BEACH_TITLE",
|
||||
},
|
||||
palettes = {
|
||||
["0F"] = {
|
||||
{ 255, 255, 247 },
|
||||
{ 197, 165, 247 },
|
||||
{ 90, 165, 247 },
|
||||
{ 25, 16, 16 },
|
||||
},
|
||||
BADGE = {
|
||||
{ 255, 255, 247 },
|
||||
{ 165, 123, 90 },
|
||||
{ 181, 173, 165 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
BLACK = {
|
||||
{ 255, 255, 247 },
|
||||
{ 49, 49, 49 },
|
||||
{ 49, 49, 49 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
BLUEMON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 173, 181, 255 },
|
||||
{ 74, 82, 165 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
BROWNMON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 214, 189, 148 },
|
||||
{ 148, 115, 82 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
CAVE = {
|
||||
{ 255, 255, 247 },
|
||||
{ 165, 123, 90 },
|
||||
{ 181, 173, 165 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
CELADON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 181, 255, 181 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
CERULEAN = {
|
||||
{ 255, 255, 247 },
|
||||
{ 181, 189, 255 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
CINNABAR = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 123, 115 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
CYANMON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 214, 230, 255 },
|
||||
{ 58, 197, 230 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
FUCHSIA = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 214, 214 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
GAMEFREAK = {
|
||||
{ 255, 255, 247 },
|
||||
{ 230, 197, 115 },
|
||||
{ 165, 165, 90 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
GRAYMON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 206, 206, 148 },
|
||||
{ 132, 132, 115 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
GREENBAR = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 255, 156 },
|
||||
{ 0, 173, 0 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
GREENMON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 197, 230, 148 },
|
||||
{ 107, 173, 123 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
INDIGO = {
|
||||
{ 255, 255, 247 },
|
||||
{ 140, 140, 206 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
LAVENDER = {
|
||||
{ 255, 255, 247 },
|
||||
{ 222, 189, 239 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
LOGO1 = {
|
||||
{ 255, 255, 247 },
|
||||
{ 247, 247, 140 },
|
||||
{ 173, 0, 33 },
|
||||
{ 173, 0, 33 },
|
||||
},
|
||||
LOGO2 = {
|
||||
{ 255, 255, 247 },
|
||||
{ 247, 247, 140 },
|
||||
{ 148, 148, 197 },
|
||||
{ 58, 58, 132 },
|
||||
},
|
||||
MEWMON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 247, 181 },
|
||||
{ 222, 132, 132 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
PALLET = {
|
||||
{ 255, 255, 247 },
|
||||
{ 230, 222, 255 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
PEWTER = {
|
||||
{ 255, 255, 247 },
|
||||
{ 189, 189, 181 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
PIKACHUS_BEACH = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 247, 181 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
PIKACHUS_BEACH_TITLE = {
|
||||
{ 255, 255, 247 },
|
||||
{ 132, 132, 132 },
|
||||
{ 255, 206, 74 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
PIKACHU_PORTRAIT = {
|
||||
{ 255, 255, 247 },
|
||||
{ 230, 189, 74 },
|
||||
{ 148, 115, 82 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
PINKMON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 197, 214 },
|
||||
{ 255, 148, 173 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
PURPLEMON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 222, 181, 247 },
|
||||
{ 181, 123, 189 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
REDBAR = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 255, 156 },
|
||||
{ 214, 74, 49 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
REDMON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 197, 90 },
|
||||
{ 214, 74, 49 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
ROUTE = {
|
||||
{ 255, 255, 247 },
|
||||
{ 189, 214, 156 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
SAFFRON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 255, 156 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
SLOTS1 = {
|
||||
{ 255, 255, 247 },
|
||||
{ 222, 181, 247 },
|
||||
{ 214, 74, 49 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
SLOTS2 = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 189, 214 },
|
||||
{ 239, 239, 66 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
SLOTS3 = {
|
||||
{ 255, 255, 247 },
|
||||
{ 189, 255, 165 },
|
||||
{ 239, 239, 66 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
SLOTS4 = {
|
||||
{ 255, 255, 247 },
|
||||
{ 189, 239, 255 },
|
||||
{ 239, 239, 66 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
TOWNMAP = {
|
||||
{ 255, 255, 247 },
|
||||
{ 165, 214, 255 },
|
||||
{ 140, 189, 82 },
|
||||
{ 25, 16, 16 },
|
||||
},
|
||||
VERMILION = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 206, 132 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
VIRIDIAN = {
|
||||
{ 255, 255, 247 },
|
||||
{ 214, 255, 173 },
|
||||
{ 189, 222, 255 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
YELLOWBAR = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 255, 156 },
|
||||
{ 230, 189, 74 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
YELLOWMON = {
|
||||
{ 255, 255, 247 },
|
||||
{ 255, 255, 156 },
|
||||
{ 230, 189, 74 },
|
||||
{ 49, 49, 49 },
|
||||
},
|
||||
},
|
||||
pokemon = {
|
||||
ABRA = "YELLOWMON",
|
||||
AERODACTYL = "GRAYMON",
|
||||
ALAKAZAM = "YELLOWMON",
|
||||
ARBOK = "PURPLEMON",
|
||||
ARCANINE = "REDMON",
|
||||
ARTICUNO = "BLUEMON",
|
||||
BEEDRILL = "YELLOWMON",
|
||||
BELLSPROUT = "GREENMON",
|
||||
BLASTOISE = "CYANMON",
|
||||
BULBASAUR = "GREENMON",
|
||||
BUTTERFREE = "CYANMON",
|
||||
CATERPIE = "GREENMON",
|
||||
CHANSEY = "PINKMON",
|
||||
CHARIZARD = "REDMON",
|
||||
CHARMANDER = "REDMON",
|
||||
CHARMELEON = "REDMON",
|
||||
CLEFABLE = "PINKMON",
|
||||
CLEFAIRY = "PINKMON",
|
||||
CLOYSTER = "GRAYMON",
|
||||
CUBONE = "GRAYMON",
|
||||
DEWGONG = "BLUEMON",
|
||||
DIGLETT = "BROWNMON",
|
||||
DITTO = "GRAYMON",
|
||||
DODRIO = "BROWNMON",
|
||||
DODUO = "BROWNMON",
|
||||
DRAGONAIR = "BLUEMON",
|
||||
DRAGONITE = "BROWNMON",
|
||||
DRATINI = "GRAYMON",
|
||||
DROWZEE = "YELLOWMON",
|
||||
DUGTRIO = "BROWNMON",
|
||||
EEVEE = "GRAYMON",
|
||||
EKANS = "PURPLEMON",
|
||||
ELECTABUZZ = "YELLOWMON",
|
||||
ELECTRODE = "YELLOWMON",
|
||||
EXEGGCUTE = "PINKMON",
|
||||
EXEGGUTOR = "GREENMON",
|
||||
FARFETCHD = "BROWNMON",
|
||||
FEAROW = "BROWNMON",
|
||||
FLAREON = "REDMON",
|
||||
GASTLY = "PURPLEMON",
|
||||
GENGAR = "PURPLEMON",
|
||||
GEODUDE = "GRAYMON",
|
||||
GLOOM = "REDMON",
|
||||
GOLBAT = "BLUEMON",
|
||||
GOLDEEN = "REDMON",
|
||||
GOLDUCK = "CYANMON",
|
||||
GOLEM = "GRAYMON",
|
||||
GRAVELER = "GRAYMON",
|
||||
GRIMER = "PURPLEMON",
|
||||
GROWLITHE = "BROWNMON",
|
||||
GYARADOS = "BLUEMON",
|
||||
HAUNTER = "PURPLEMON",
|
||||
HITMONCHAN = "BROWNMON",
|
||||
HITMONLEE = "BROWNMON",
|
||||
HORSEA = "CYANMON",
|
||||
HYPNO = "YELLOWMON",
|
||||
IVYSAUR = "GREENMON",
|
||||
JIGGLYPUFF = "PINKMON",
|
||||
JOLTEON = "YELLOWMON",
|
||||
JYNX = "MEWMON",
|
||||
KABUTO = "BROWNMON",
|
||||
KABUTOPS = "BROWNMON",
|
||||
KADABRA = "YELLOWMON",
|
||||
KAKUNA = "YELLOWMON",
|
||||
KANGASKHAN = "BROWNMON",
|
||||
KINGLER = "REDMON",
|
||||
KOFFING = "PURPLEMON",
|
||||
KRABBY = "REDMON",
|
||||
LAPRAS = "CYANMON",
|
||||
LICKITUNG = "PINKMON",
|
||||
MACHAMP = "GRAYMON",
|
||||
MACHOKE = "GRAYMON",
|
||||
MACHOP = "GRAYMON",
|
||||
MAGIKARP = "REDMON",
|
||||
MAGMAR = "REDMON",
|
||||
MAGNEMITE = "GRAYMON",
|
||||
MAGNETON = "GRAYMON",
|
||||
MANKEY = "BROWNMON",
|
||||
MAROWAK = "GRAYMON",
|
||||
MEOWTH = "YELLOWMON",
|
||||
METAPOD = "GREENMON",
|
||||
MEW = "MEWMON",
|
||||
MEWTWO = "MEWMON",
|
||||
MOLTRES = "REDMON",
|
||||
MR_MIME = "PINKMON",
|
||||
MUK = "PURPLEMON",
|
||||
NIDOKING = "PURPLEMON",
|
||||
NIDOQUEEN = "BLUEMON",
|
||||
NIDORAN_F = "BLUEMON",
|
||||
NIDORAN_M = "PURPLEMON",
|
||||
NIDORINA = "BLUEMON",
|
||||
NIDORINO = "PURPLEMON",
|
||||
NINETALES = "YELLOWMON",
|
||||
ODDISH = "GREENMON",
|
||||
OMANYTE = "BLUEMON",
|
||||
OMASTAR = "BLUEMON",
|
||||
ONIX = "GRAYMON",
|
||||
PARAS = "REDMON",
|
||||
PARASECT = "REDMON",
|
||||
PERSIAN = "YELLOWMON",
|
||||
PIDGEOT = "BROWNMON",
|
||||
PIDGEOTTO = "BROWNMON",
|
||||
PIDGEY = "BROWNMON",
|
||||
PIKACHU = "YELLOWMON",
|
||||
PINSIR = "BROWNMON",
|
||||
POLIWAG = "BLUEMON",
|
||||
POLIWHIRL = "BLUEMON",
|
||||
POLIWRATH = "BLUEMON",
|
||||
PONYTA = "REDMON",
|
||||
PORYGON = "GRAYMON",
|
||||
PRIMEAPE = "BROWNMON",
|
||||
PSYDUCK = "YELLOWMON",
|
||||
RAICHU = "YELLOWMON",
|
||||
RAPIDASH = "REDMON",
|
||||
RATICATE = "GRAYMON",
|
||||
RATTATA = "GRAYMON",
|
||||
RHYDON = "GRAYMON",
|
||||
RHYHORN = "GRAYMON",
|
||||
SANDSHREW = "BROWNMON",
|
||||
SANDSLASH = "BROWNMON",
|
||||
SCYTHER = "GREENMON",
|
||||
SEADRA = "CYANMON",
|
||||
SEAKING = "REDMON",
|
||||
SEEL = "BLUEMON",
|
||||
SHELLDER = "GRAYMON",
|
||||
SLOWBRO = "PINKMON",
|
||||
SLOWPOKE = "PINKMON",
|
||||
SNORLAX = "PINKMON",
|
||||
SPEAROW = "BROWNMON",
|
||||
SQUIRTLE = "CYANMON",
|
||||
STARMIE = "GRAYMON",
|
||||
STARYU = "REDMON",
|
||||
TANGELA = "BLUEMON",
|
||||
TAUROS = "GRAYMON",
|
||||
TENTACOOL = "CYANMON",
|
||||
TENTACRUEL = "CYANMON",
|
||||
VAPOREON = "CYANMON",
|
||||
VENOMOTH = "PURPLEMON",
|
||||
VENONAT = "PURPLEMON",
|
||||
VENUSAUR = "GREENMON",
|
||||
VICTREEBEL = "GREENMON",
|
||||
VILEPLUME = "REDMON",
|
||||
VOLTORB = "YELLOWMON",
|
||||
VULPIX = "REDMON",
|
||||
WARTORTLE = "CYANMON",
|
||||
WEEDLE = "YELLOWMON",
|
||||
WEEPINBELL = "GREENMON",
|
||||
WEEZING = "PURPLEMON",
|
||||
WIGGLYTUFF = "PINKMON",
|
||||
ZAPDOS = "YELLOWMON",
|
||||
ZUBAT = "BLUEMON",
|
||||
},
|
||||
source = "pokeyellow data/sgb/sgb_palettes.asm + data/pokemon/palettes.asm",
|
||||
}
|
||||
@@ -42,6 +42,7 @@ local function middleAgedMan(game, ow, npc, done)
|
||||
end
|
||||
local menu = ListMenu.new(game, "", items, {
|
||||
onChoose = function(item)
|
||||
game.stack:pop()
|
||||
push(game, t[BADGE_TEXT[item.value]], loop)
|
||||
end,
|
||||
onCancel = function()
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
-- Both NPCs use text_asm with an hRandomAdd roll to pick one of several
|
||||
-- flavor lines (no flags, no branching outcome) -- ported as a weighted
|
||||
-- math.random pick each time the NPC is talked to.
|
||||
--
|
||||
-- Yellow renames Slowbro -> Electrode in the text labels (see
|
||||
-- tools/yellow_symbol_aliases.py); look up both so Red/Blue and Yellow
|
||||
-- share this script.
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -12,6 +16,29 @@ local function push(game, ow, npc, done, text)
|
||||
game.stack:push(TextBox.new(game, text, done))
|
||||
end
|
||||
|
||||
-- CeruleanCitySlowbroText / CeruleanCityElectrodeText
|
||||
-- cp 180 -> 76/256 chance of 1st; cp 120 -> 60/256 chance of 2nd;
|
||||
-- cp 60 -> 60/256 chance of 3rd; else 60/256 chance of 4th.
|
||||
local function talkMon(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
local roll = math.random(0, 255)
|
||||
local text
|
||||
if roll >= 180 then
|
||||
text = t._CeruleanCitySlowbroTookASnoozeText
|
||||
or t._CeruleanCityElectrodeTookASnoozeText
|
||||
elseif roll >= 120 then
|
||||
text = t._CeruleanCitySlowbroIsLoafingAroundText
|
||||
or t._CeruleanCityElectrodeIsLoafingAroundText
|
||||
elseif roll >= 60 then
|
||||
text = t._CeruleanCitySlowbroTurnedAwayText
|
||||
or t._CeruleanCityElectrodeTurnedAwayText
|
||||
else
|
||||
text = t._CeruleanCitySlowbroIgnoredOrdersText
|
||||
or t._CeruleanCityElectrodeIgnoredOrdersText
|
||||
end
|
||||
push(game, ow, npc, done, text)
|
||||
end
|
||||
|
||||
M.CERULEAN_CITY = {
|
||||
talk = {
|
||||
-- CeruleanCityCooltrainerF1Text (scripts/CeruleanCity.asm:362-393)
|
||||
@@ -23,32 +50,20 @@ M.CERULEAN_CITY = {
|
||||
local text
|
||||
if roll >= 180 then
|
||||
text = t._CeruleanCityCooltrainerF1SlowbroUseSonicboomText
|
||||
or t._CeruleanCityCooltrainerF1ElectrodeUseSonicboomText
|
||||
elseif roll >= 100 then
|
||||
text = t._CeruleanCityCooltrainerF1SlowbroPunchText
|
||||
or t._CeruleanCityCooltrainerF1ElectrodePunchText
|
||||
else
|
||||
text = t._CeruleanCityCooltrainerF1SlowbroWithdrawText
|
||||
or t._CeruleanCityCooltrainerF1ElectrodeWithdrawText
|
||||
end
|
||||
push(game, ow, npc, done, text)
|
||||
end,
|
||||
|
||||
-- CeruleanCitySlowbroText (scripts/CeruleanCity.asm:395-436)
|
||||
-- cp 180 -> 76/256 chance of 1st; cp 120 -> 60/256 chance of 2nd;
|
||||
-- cp 60 -> 60/256 chance of 3rd; else 60/256 chance of 4th.
|
||||
TEXT_CERULEANCITY_SLOWBRO = function(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
local roll = math.random(0, 255)
|
||||
local text
|
||||
if roll >= 180 then
|
||||
text = t._CeruleanCitySlowbroTookASnoozeText
|
||||
elseif roll >= 120 then
|
||||
text = t._CeruleanCitySlowbroIsLoafingAroundText
|
||||
elseif roll >= 60 then
|
||||
text = t._CeruleanCitySlowbroTurnedAwayText
|
||||
else
|
||||
text = t._CeruleanCitySlowbroIgnoredOrdersText
|
||||
end
|
||||
push(game, ow, npc, done, text)
|
||||
end,
|
||||
TEXT_CERULEANCITY_SLOWBRO = talkMon,
|
||||
-- Yellow map object uses TEXT_CERULEANCITY_ELECTRODE
|
||||
TEXT_CERULEANCITY_ELECTRODE = talkMon,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
-- Viridian School House's two readables (data/events/hidden_events.asm,
|
||||
-- hidden_events_for VIRIDIAN_SCHOOL_HOUSE):
|
||||
-- hidden_text_predef 3, 0 PrintBlackboardLinkCableText, ViridianSchoolBlackboard
|
||||
-- hidden_text_predef 3, 4 PrintNotebookText, ViridianSchoolNotebook
|
||||
-- tools/extract/field.py only parses `hidden_event` rows, so neither
|
||||
-- hidden_text_predef row reaches data/generated/field.lua and both tiles
|
||||
-- were dead A presses (#503). Same hook shape, and the same sibling asm
|
||||
-- file, as the Celadon roof house in data/scripts/celadon_eevee.lua (#391);
|
||||
-- hidden_text_predef spends the facing byte on the tx_pre id, so neither
|
||||
-- tile gates on facing.
|
||||
|
||||
local Menu = require("src.ui.Menu")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
-- ViridianSchoolBlackboard (engine/events/hidden_events/school_blackboard.asm):
|
||||
-- StatusAilmentText1/2 are the two columns of the 12x8 box at the top left
|
||||
-- (hlcoord 0, 0 + `lb bc, 6, 10`); picking a status prints its
|
||||
-- ViridianBlackboardStatusPointers entry and jumps back to .blackboardLoop,
|
||||
-- QUIT or B falls through to .exitBlackboard.
|
||||
local STATUS_LABELS = {
|
||||
{ " SLP", "_ViridianBlackboardSleepText" },
|
||||
{ " PSN", "_ViridianBlackboardPoisonText" },
|
||||
{ " PAR", "_ViridianBlackboardPrlzText" },
|
||||
{ " BRN", "_ViridianBlackboardBurnText" },
|
||||
{ " FRZ", "_ViridianBlackboardFrozenText" },
|
||||
}
|
||||
|
||||
local function blackboard(game)
|
||||
local text = game.data.text or {}
|
||||
local items, showMenu, askHeading
|
||||
function showMenu()
|
||||
game.stack:push(Menu.new(game, items,
|
||||
{ tx = 0, ty = 0, tw = 12, th = 8, rowStep = 1 }))
|
||||
end
|
||||
-- ViridianSchoolBlackboardText2 is reprinted on every .blackboardLoop
|
||||
-- pass, immediately before HandleMenuInput
|
||||
function askHeading()
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._ViridianSchoolBlackboardText2 or "Which heading do\nyou want to read?",
|
||||
showMenu))
|
||||
end
|
||||
items = {}
|
||||
for i, row in ipairs(STATUS_LABELS) do
|
||||
local label, key = row[1], row[2]
|
||||
items[i] = { label = label, onSelect = function()
|
||||
game.stack:push(TextBox.new(game, text[key] or label, askHeading))
|
||||
end }
|
||||
end
|
||||
-- no onSelect: Menu's own pop closes the box, matching .exitBlackboard
|
||||
items[#items + 1] = { label = " QUIT" }
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._ViridianSchoolBlackboardText1
|
||||
or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.",
|
||||
askHeading))
|
||||
end
|
||||
|
||||
-- ViridianSchoolNotebook (engine/events/hidden_events/school_notebooks.asm):
|
||||
-- pages 1-3 each end in TurnPageSchoolNotebook (TurnPageText + YesNoChoice)
|
||||
-- and NO stops the read; page 4 turns without asking and runs straight into
|
||||
-- page 5, the girl catching you at it.
|
||||
local function notebook(game)
|
||||
local text = game.data.text or {}
|
||||
local function page(n, after)
|
||||
return TextBox.new(game, text["_ViridianSchoolNotebookText" .. n] or "", after)
|
||||
end
|
||||
local function turnPage(nextPage)
|
||||
return function()
|
||||
game.stack:push(TextBox.new(game, text._TurnPageText or "Turn the page?",
|
||||
nil, { choice = function(yes)
|
||||
if yes then game.stack:push(nextPage()) end
|
||||
end }))
|
||||
end
|
||||
end
|
||||
local function page5() return page(5) end
|
||||
local function page4() return page(4, function() game.stack:push(page5()) end) end
|
||||
local function page3() return page(3, turnPage(page4)) end
|
||||
local function page2() return page(2, turnPage(page3)) end
|
||||
game.stack:push(page(1, turnPage(page2)))
|
||||
end
|
||||
|
||||
return {
|
||||
VIRIDIAN_SCHOOL_HOUSE = {
|
||||
onInteract = function(game, ow, fx, fy)
|
||||
if fx == 3 and fy == 0 then
|
||||
blackboard(game)
|
||||
return true
|
||||
end
|
||||
if fx == 3 and fy == 4 then
|
||||
notebook(game)
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
},
|
||||
}
|
||||
@@ -60,6 +60,7 @@ local files = {
|
||||
"data.scripts.flavor.victory_road_2f",
|
||||
"data.scripts.flavor.viridian_city",
|
||||
"data.scripts.flavor.viridian_nickname_house",
|
||||
"data.scripts.flavor.viridian_school_house", -- #503
|
||||
"data.scripts.flavor.wardens_house",
|
||||
}
|
||||
|
||||
|
||||
+14
-3
@@ -647,7 +647,12 @@ M.WARDENS_HOUSE = {
|
||||
TEXT_WARDENSHOUSE_WARDEN = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_HM04" }, -- 2
|
||||
{ "jump_if_true", 13 }, -- 3
|
||||
-- #535: previously jumped to the same silent-end target as the
|
||||
-- give-then-thank fallthrough (row 13), so the warden said nothing
|
||||
-- on every visit after the trade. pokered's .got_item branch
|
||||
-- (scripts/WardensHouse.asm) instead prints HM04ExplanationText,
|
||||
-- so route here to the new row 17 that does the same.
|
||||
{ "jump_if_true", 17 }, -- 3
|
||||
{ "check_item", "GOLD_TEETH" }, -- 4
|
||||
{ "jump_if_false", 15 }, -- 5
|
||||
{ "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6
|
||||
@@ -658,9 +663,15 @@ M.WARDENS_HOUSE = {
|
||||
{ "give_item", "HM_STRENGTH", 1, false }, -- 10
|
||||
{ "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11
|
||||
{ "set_flag", "EVENT_GOT_HM04" }, -- 12
|
||||
{ "jump", 16 }, -- 13 (already got it)
|
||||
{ "jump", 16 }, -- 14 (unused)
|
||||
{ "jump", 18 }, -- 13 (already got it this convo; jp .done)
|
||||
{ "jump", 18 }, -- 14 (unused)
|
||||
{ "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15
|
||||
{ "jump", 18 }, -- 16 (#535: skip the new explanation row below)
|
||||
-- #535: pokered .got_item branch (scripts/WardensHouse.asm) --
|
||||
-- printed on every subsequent talk once EVENT_GOT_HM04 is set.
|
||||
-- Text is _WardensHouseWardenHM04ExplanationText (text/WardensHouse.asm):
|
||||
-- HM04 teaches Strength, and hints at the Safari Zone secret house.
|
||||
{ "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 17
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -171,6 +171,28 @@ local function dojoBall(species, ownBall, otherBall, askKey)
|
||||
end
|
||||
end
|
||||
|
||||
-- FightingDojoDefaultScript does not use the Master's facing direction for
|
||||
-- this battle. It checks exactly the cell left of him, turns both sprites
|
||||
-- toward one another, and then displays his trainer text. Keeping this out
|
||||
-- of trainer sight also lets him keep his original downward-facing pose.
|
||||
local function dojoMasterGate(game, ow, x, y)
|
||||
if x ~= 4 or y ~= 3 or game.save.flags.EVENT_BEAT_KARATE_MASTER then
|
||||
return false
|
||||
end
|
||||
local master
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.def and npc.def.name == "FIGHTINGDOJO_KARATE_MASTER" then
|
||||
master = npc
|
||||
break
|
||||
end
|
||||
end
|
||||
if not master or ow:trainerDefeated(master) then return false end
|
||||
ow.player.facing = "right"
|
||||
master:facePlayer(ow.player)
|
||||
ow:engageTrainer(master)
|
||||
return true
|
||||
end
|
||||
|
||||
M.FIGHTING_DOJO = {
|
||||
talk = {
|
||||
TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL =
|
||||
@@ -197,6 +219,7 @@ M.FIGHTING_DOJO = {
|
||||
end
|
||||
return false
|
||||
end,
|
||||
onStep = dojoMasterGate,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
+12
-1
@@ -790,8 +790,19 @@ local function bikeGateGuard(coords, stopText, explainText)
|
||||
local t = text(game)
|
||||
push(game, t[stopText] or "Hey! Wait up!", function()
|
||||
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function()
|
||||
-- pokered's Route16Gate1FGuardScript / Route18Gate1FGuardScript
|
||||
-- (scripts/Route16Gate1F.asm, Route18Gate1F.asm) simulate one
|
||||
-- PAD_RIGHT step after the refusal text, and only clear
|
||||
-- wJoyIgnore / hand control back once that step finishes
|
||||
-- (PlayerMovingRightScript). Without it the player was left
|
||||
-- parked beside the guard's counter with no way past. #518
|
||||
local function shoveRight()
|
||||
ow:scriptMove(ow.player, "right", 1)
|
||||
end
|
||||
if dist > 0 then
|
||||
ow:scriptMove(ow.player, "up", dist)
|
||||
ow:scriptMove(ow.player, "up", dist, shoveRight)
|
||||
else
|
||||
shoveRight()
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
+3
-3
@@ -1,5 +1,8 @@
|
||||
# Build & install on your iPhone — step by step
|
||||
|
||||
Don't want to install Xcode? Download the release IPA and sideload it with
|
||||
AltStore instead — see [ios-sideload.md](ios-sideload.md).
|
||||
|
||||
This guide assumes **zero** programming experience. Follow it top to
|
||||
bottom and you'll have the game running on your own iPhone in roughly an
|
||||
hour (most of it is waiting for downloads).
|
||||
@@ -93,9 +96,6 @@ ZIP**, then double-click the zip to unpack it.
|
||||
|
||||
## Optional goodies
|
||||
|
||||
- **Pokéwalker mode** (real steps → EXP): in-game **mod manager →
|
||||
POKEWALKER → SYNC STEPS on**, allow step access when iOS asks, and go
|
||||
for a walk.
|
||||
- **Mods**: launcher → **MODS** tab → **Import mod .zip**.
|
||||
- **Save import/export**: buttons on each game's tab, using the normal
|
||||
iOS file picker.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Sideload the iOS build with AltStore
|
||||
|
||||
Every GitHub Release ships an IPA (`gen1recomp-*-ios.ipa`). Install it on
|
||||
your iPhone or iPad with [AltStore Classic](https://altstore.io/) — AltStore
|
||||
re-signs the app with **your** free Apple ID so you do not need a Mac or
|
||||
Xcode.
|
||||
|
||||
## 1. Install AltStore
|
||||
|
||||
Follow the official guide for your computer:
|
||||
|
||||
- [How to Install (Windows)](https://faq.altstore.io/altstore-classic/how-to-install-altstore-windows)
|
||||
- [How to Install (macOS)](https://faq.altstore.io/altstore-classic/how-to-install-altstore-macos)
|
||||
|
||||
You will install **AltServer** on the computer, then use it to put AltStore
|
||||
on the phone. What AltServer is and why it needs to stay running:
|
||||
|
||||
- [AltServer](https://faq.altstore.io/altstore-classic/altserver)
|
||||
|
||||
Stuck? Start here:
|
||||
|
||||
- [Troubleshooting Guide](https://faq.altstore.io/altstore-classic/troubleshooting-guide)
|
||||
|
||||
## 2. Install the game
|
||||
|
||||
1. Download `gen1recomp-*-ios.ipa` from
|
||||
[Releases](https://github.com/bryanthaboi/gen1recomp/releases).
|
||||
2. Open **AltStore** on the phone (AltServer must be running on the same
|
||||
Wi‑Fi, or keep the phone plugged into the computer).
|
||||
3. Tap **My Apps → +** (or share the IPA into AltStore) and pick the file.
|
||||
4. Sign in with your Apple ID when prompted. Wait for the install to finish.
|
||||
5. On first launch: Settings → **Privacy & Security → Developer Mode** (iOS
|
||||
16+), and Settings → **General → VPN & Device Management** → Trust your
|
||||
Apple ID if asked.
|
||||
|
||||
Then open the app, import your own legal `.gb` ROM on the Red/Blue tab, and
|
||||
play.
|
||||
|
||||
## Refresh / 7-day limit
|
||||
|
||||
With a free Apple ID, sideloaded apps stop launching after **7 days**. Keep
|
||||
AltServer running so AltStore can refresh them, or open AltStore and refresh
|
||||
manually before they expire. Saves on the phone are kept across refreshes.
|
||||
|
||||
## Prefer building it yourself?
|
||||
|
||||
Building from source on a Mac (no AltStore) is covered in
|
||||
[ios-install.md](ios-install.md).
|
||||
@@ -167,5 +167,25 @@ The console understands these verbs (anything else is evaluated as Lua):
|
||||
- `trace PAT | trace off` — trace events/hooks matching a glob pattern.
|
||||
- `help` — list the verbs.
|
||||
|
||||
## Tool input and title-menu hooks
|
||||
|
||||
Tool mods that need to act once per game logic tick can wrap `input.step`.
|
||||
It runs immediately before queued button edges are promoted, so input added by
|
||||
the wrapper is visible during that same fixed step. The callback receives
|
||||
`(next, game, dt)` and must call `next(game, dt)`.
|
||||
|
||||
`ui.title_menu.items` receives `(next, game, items)` and follows the same
|
||||
decorate-after-`next` convention as `ui.start_menu.items`. It is the safe place
|
||||
for a tool to offer a fresh-session action before gameplay begins.
|
||||
|
||||
Ephemeral tools can wrap `save.write(next, game)` and return `false` to veto a
|
||||
progress write before world state is captured or any bytes reach disk.
|
||||
|
||||
`render.hud` receives `(next, game, viewport)` after the finished game frame is
|
||||
composited and before touch controls draw. The window-space viewport contains
|
||||
`width`, `height`, `gameX`, `gameY`, `gameWidth`, `gameHeight`, `scale`, `dpiX`,
|
||||
and `dpiY`, so a tool can use the letterbox margins without drawing over the
|
||||
playfield or pushing an updating game state.
|
||||
|
||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||
that reach outside their permission set.
|
||||
|
||||
+36
-6
@@ -86,14 +86,20 @@ Game Boy equivalent:
|
||||
## Colors mode
|
||||
|
||||
The `2` key (and the Options menu COLORS row) cycles the display mode
|
||||
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**.
|
||||
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**
|
||||
(on Blue the first slot labels **OG BLUE**; on Yellow, **OG YELLOW**).
|
||||
The first three are the real colorizations; the rest are DMG-shade novelties:
|
||||
|
||||
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
|
||||
red BG palette + one green OBJ palette, every map, no per-map variation
|
||||
(Pokemon Red has no CGB code, so on a GBC the boot ROM colors it globally).
|
||||
The player/NPCs stay green over the red terrain via the OBP bake +
|
||||
post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`).
|
||||
- **OG RED** / **OG BLUE**: the Game Boy Color boot-ROM look for that cart --
|
||||
one global BG palette + one OBJ palette, every map, no per-map variation
|
||||
(Red/Blue ship no CGB code, so on a GBC the boot ROM colors them globally).
|
||||
The player/NPCs keep the boot-ROM OBJ color over the terrain via the OBP
|
||||
bake + post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`, or Blue's blue/pink
|
||||
pair).
|
||||
- **OG YELLOW** (Yellow playthrough, same `ogred` save id): Pokemon Yellow's
|
||||
authentic GBC look from `CGBBasePalettes` (`data/palettes_yellow.lua`,
|
||||
sourced from pret/pokeyellow). Per-map / per-species colors, not a single
|
||||
boot-ROM ramp -- Yellow was CGB-enhanced.
|
||||
- **SGB** (default): the per-map Super Game Boy region palettes
|
||||
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
|
||||
real SGB. (This is the mode formerly mislabeled "GBC".)
|
||||
@@ -151,6 +157,13 @@ tradeoff vs. the relay). Headless tests drive the protocol over an
|
||||
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
|
||||
also exercises real UDP pairing.
|
||||
|
||||
Red, Blue, and Yellow copies link with each other, as the real cable
|
||||
does. The compatibility fingerprint hashes only data a link mode can
|
||||
actually read, so Yellow's Dragonair/Dragonite catch-rate retunes (the
|
||||
only R/B/Y link-surface difference) no longer read as different games
|
||||
(issue #511). Moving the fingerprint is a link parity change: builds
|
||||
from before this fix will refuse to pair with builds after it.
|
||||
|
||||
## Fair play in link and online matches
|
||||
|
||||
A link session is decided by the battle and nothing else, so for its
|
||||
@@ -265,6 +278,14 @@ the last controller restores it immediately. Layout re-derives from the
|
||||
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
|
||||
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
||||
|
||||
The launcher's **Touch Controls** button opens a drag editor: move each
|
||||
button freely, **Disable** to hide the overlay permanently (for
|
||||
controllers / emulation handhelds — distinct from the temporary
|
||||
gamepad auto-hide), **Reset** for defaults, **Done** to save into
|
||||
`options.lua` as normalized window fractions so rotation keeps the
|
||||
relative placement. In-game, Options → **TOUCH PAD** toggles the same
|
||||
on/off flag without leaving a play session.
|
||||
|
||||
## Translation support
|
||||
|
||||
Every string the player can read is now reachable from a mod, so a
|
||||
@@ -367,3 +388,12 @@ kind, number, height/weight, dex text) to a PNG at 4x scale under
|
||||
`prints/` in the save directory, then reports the filename in a dialog.
|
||||
No printer hardware or link cable emulation involved; the file is the
|
||||
printout.
|
||||
|
||||
## Find Mods (community mod indexes)
|
||||
|
||||
A FIND MODS tab sits beside MODS in the launcher and browses a published
|
||||
mod index: a metadata-only feed listing mods that live in their authors'
|
||||
own repositories. No index ships with the launcher and none is ever added
|
||||
automatically, so the tab opens on an "Add an index" prompt until you name
|
||||
one; paste an index URL or its `owner/repo` and it is remembered in
|
||||
`options.lua`. More than one index can be added, and the listings merge.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
||||
|
||||
local Game, EditorApp, Importer
|
||||
local Game, EditorApp, Importer, TouchEditor
|
||||
|
||||
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
|
||||
local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
|
||||
@@ -134,6 +134,26 @@ function closeEditor()
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------ touch controls editor
|
||||
-- Suspends the launcher while the player drags on-screen buttons / toggles
|
||||
-- the overlay off (#327). No ROM cache needed -- options.lua only.
|
||||
local touchEditorHost
|
||||
local closeTouchControlsEditor -- forward declaration
|
||||
|
||||
local function openTouchControlsEditor()
|
||||
touchEditorHost = Importer
|
||||
Importer = nil
|
||||
TouchEditor = require("src.ui.TouchControlsEditor")
|
||||
TouchEditor.load({ onClose = function() closeTouchControlsEditor() end })
|
||||
end
|
||||
|
||||
function closeTouchControlsEditor()
|
||||
if TouchEditor and TouchEditor.unload then TouchEditor.unload() end
|
||||
TouchEditor = nil
|
||||
Importer = touchEditorHost
|
||||
touchEditorHost = nil
|
||||
end
|
||||
|
||||
local function bootGame(version)
|
||||
-- The launcher hands us the chosen game (Red / Blue / Yellow); scripted and
|
||||
-- headless runs fall back to POKEPORT_VERSION, then Red. Set the active
|
||||
@@ -239,11 +259,17 @@ function love.load(args)
|
||||
Importer = RomImporter.new(function(version)
|
||||
Importer = nil
|
||||
bootGame(version)
|
||||
end, { launcher = true, forceImport = forceImport, onEditSave = openEditor })
|
||||
end, {
|
||||
launcher = true,
|
||||
forceImport = forceImport,
|
||||
onEditSave = openEditor,
|
||||
onEditTouchControls = openTouchControlsEditor,
|
||||
})
|
||||
end
|
||||
|
||||
function love.update(dt)
|
||||
if editorMode then return EditorApp.update(dt) end
|
||||
if TouchEditor then return TouchEditor.update(dt) end
|
||||
if Importer then return Importer:update(dt) end
|
||||
|
||||
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
|
||||
@@ -283,6 +309,7 @@ end
|
||||
|
||||
function love.draw()
|
||||
if editorMode then return EditorApp.draw() end
|
||||
if TouchEditor then return TouchEditor.draw() end
|
||||
if Importer then return Importer:draw() end
|
||||
|
||||
Game:draw()
|
||||
@@ -303,60 +330,61 @@ end
|
||||
|
||||
function love.keypressed(key, scancode, isrepeat)
|
||||
if editorMode then return EditorApp.keypressed(key) end
|
||||
if TouchEditor then return TouchEditor.keypressed(key) end
|
||||
if Importer then return Importer:keypressed(key) end
|
||||
Game:keypressed(key)
|
||||
end
|
||||
|
||||
function love.keyreleased(key)
|
||||
if editorMode then return end
|
||||
if editorMode or TouchEditor then return end
|
||||
if Importer then return end
|
||||
Game:keyreleased(key)
|
||||
end
|
||||
|
||||
function love.gamepadpressed(joystick, button)
|
||||
if editorMode then return end
|
||||
if editorMode or TouchEditor then return end
|
||||
if Importer then return Importer:gamepadpressed(joystick, button) end
|
||||
Game:gamepadpressed(joystick, button)
|
||||
end
|
||||
|
||||
function love.gamepadreleased(joystick, button)
|
||||
if editorMode then return end
|
||||
if editorMode or TouchEditor then return end
|
||||
if Importer then return Importer:gamepadreleased(joystick, button) end
|
||||
Game:gamepadreleased(joystick, button)
|
||||
end
|
||||
|
||||
function love.gamepadaxis(joystick, axis, value)
|
||||
if editorMode then return end
|
||||
if editorMode or TouchEditor then return end
|
||||
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
||||
Game:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
function love.joystickpressed(joystick, button)
|
||||
if editorMode then return end
|
||||
if editorMode or TouchEditor then return end
|
||||
if Importer then return Importer:joystickpressed(joystick, button) end
|
||||
Game:joystickpressed(joystick, button)
|
||||
end
|
||||
|
||||
function love.joystickreleased(joystick, button)
|
||||
if editorMode then return end
|
||||
if editorMode or TouchEditor then return end
|
||||
if Importer then return Importer:joystickreleased(joystick, button) end
|
||||
Game:joystickreleased(joystick, button)
|
||||
end
|
||||
|
||||
function love.joystickaxis(joystick, axis, value)
|
||||
if editorMode then return end
|
||||
if editorMode or TouchEditor then return end
|
||||
if Importer then return Importer:joystickaxis(joystick, axis, value) end
|
||||
Game:joystickaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
function love.joystickhat(joystick, hat, direction)
|
||||
if editorMode then return end
|
||||
if editorMode or TouchEditor then return end
|
||||
if Importer then return Importer:joystickhat(joystick, hat, direction) end
|
||||
Game:joystickhat(joystick, hat, direction)
|
||||
end
|
||||
|
||||
function love.joystickremoved(joystick)
|
||||
if editorMode then return end
|
||||
if editorMode or TouchEditor then return end
|
||||
if Importer then return end
|
||||
Game:joystickremoved(joystick)
|
||||
end
|
||||
@@ -365,7 +393,7 @@ end
|
||||
-- direction's key-up can be delivered to the OS instead of the game while
|
||||
-- unfocused, so reset input on either transition rather than trust it.
|
||||
function love.focus(f)
|
||||
if editorMode then return end
|
||||
if editorMode or TouchEditor then return end
|
||||
if Importer then
|
||||
if Importer.focus then Importer:focus(f) end
|
||||
return
|
||||
@@ -375,13 +403,19 @@ end
|
||||
|
||||
-- v is true when the window becomes visible again, false on minimize.
|
||||
function love.visible(v)
|
||||
if editorMode then return end
|
||||
if editorMode or TouchEditor then return end
|
||||
if Importer then return end
|
||||
Game:visible(v)
|
||||
end
|
||||
|
||||
function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
if editorMode then return end
|
||||
if TouchEditor then
|
||||
-- iOS synthesizes mousepressed for the primary touch (same as the
|
||||
-- launcher); Android drives the editor through love.touch directly.
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return TouchEditor.touchpressed(id, x, y)
|
||||
end
|
||||
if Importer then
|
||||
-- iOS: LÖVE already synthesizes a mousepressed for the primary touch,
|
||||
-- and love.mousepressed below forwards that to the Importer, so
|
||||
@@ -399,12 +433,20 @@ end
|
||||
|
||||
function love.touchmoved(id, x, y, dx, dy, pressure)
|
||||
if editorMode then return end
|
||||
if TouchEditor then
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return TouchEditor.touchmoved(id, x, y)
|
||||
end
|
||||
if Importer then return end
|
||||
Game:touchmoved(id, x, y)
|
||||
end
|
||||
|
||||
function love.touchreleased(id, x, y, dx, dy, pressure)
|
||||
if editorMode then return end
|
||||
if TouchEditor then
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return TouchEditor.touchreleased(id, x, y)
|
||||
end
|
||||
if Importer then return end
|
||||
Game:touchreleased(id, x, y)
|
||||
end
|
||||
@@ -414,11 +456,18 @@ function love.wheelmoved(x, y)
|
||||
if EditorApp.wheelmoved then return EditorApp.wheelmoved(x, y) end
|
||||
return
|
||||
end
|
||||
if TouchEditor then return end
|
||||
if Importer then return end
|
||||
Game:wheelmoved(x, y)
|
||||
end
|
||||
|
||||
function love.mousepressed(x, y, button)
|
||||
if TouchEditor then
|
||||
-- Android primary touch already arrived via love.touchpressed; a second
|
||||
-- mouse path would double-fire Done / begin a second drag.
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousepressed(x, y, button)
|
||||
end
|
||||
if Importer then return Importer:mousepressed(x, y, button) end
|
||||
if editorMode and EditorApp.mousepressed then
|
||||
return EditorApp.mousepressed(x, y, button)
|
||||
@@ -429,6 +478,10 @@ function love.mousepressed(x, y, button)
|
||||
end
|
||||
|
||||
function love.mousereleased(x, y, button)
|
||||
if TouchEditor then
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousereleased(x, y, button)
|
||||
end
|
||||
if Importer then return end
|
||||
if editorMode and EditorApp.mousereleased then
|
||||
return EditorApp.mousereleased(x, y, button)
|
||||
@@ -439,6 +492,10 @@ function love.mousereleased(x, y, button)
|
||||
end
|
||||
|
||||
function love.mousemoved(x, y)
|
||||
if TouchEditor then
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousemoved(x, y)
|
||||
end
|
||||
if editorMode or Importer then return end
|
||||
if mouseTouch and Game and love.mouse.isDown(1) then
|
||||
Game:touchmoved("mouse", x, y)
|
||||
@@ -446,6 +503,7 @@ function love.mousemoved(x, y)
|
||||
end
|
||||
|
||||
function love.textinput(text)
|
||||
if TouchEditor then return end
|
||||
if Importer then return Importer:textinput(text) end
|
||||
if editorMode and EditorApp.textinput then
|
||||
return EditorApp.textinput(text)
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ scripts, tests, and mobile build sources are excluded.
|
||||
| --- | --- |
|
||||
| `app.application_id` | `com.theboisclub.pokemonred` |
|
||||
| `app.name` | Pokemon Red |
|
||||
| `app.orientation` | `portrait` |
|
||||
| `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. |
|
||||
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
|
||||
| Permissions | INTERNET / RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH kept |
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.content.res.AssetManager;
|
||||
import android.media.AudioManager;
|
||||
@@ -303,6 +304,54 @@ public class GameActivity extends SDLActivity {
|
||||
super.onResume();
|
||||
}
|
||||
|
||||
/**
|
||||
* SDL decides the activity's requested orientation at window creation
|
||||
* (SDLActivity.setOrientationBis). With a resizable window and no
|
||||
* SDL_HINT_ORIENTATIONS -- exactly what conf.lua produces on Android --
|
||||
* it asks for SCREEN_ORIENTATION_FULL_SENSOR, and that request overrides
|
||||
* the android:screenOrientation="fullUser" set in the manifest. The
|
||||
* *_SENSOR constants follow the accelerometer even when the player has
|
||||
* turned auto-rotate off, so the game kept rotating on a device whose
|
||||
* rotation was locked.
|
||||
*
|
||||
* Remap SDL's choice onto the matching *_USER constant, which allows the
|
||||
* same orientations but defers to the system rotation setting. Applied
|
||||
* after super so SDL keeps deciding *which* orientations the window may
|
||||
* take; this only changes who breaks the tie, the sensor or the player.
|
||||
*/
|
||||
@Override
|
||||
public void setOrientationBis(int w, int h, boolean resizable, String hint) {
|
||||
super.setOrientationBis(w, h, resizable, hint);
|
||||
|
||||
// The *_USER constants only exist from API 18; below that the sensor
|
||||
// ones are all there is, so leave SDL's request alone.
|
||||
if (android.os.Build.VERSION.SDK_INT < 18) {
|
||||
return;
|
||||
}
|
||||
|
||||
int requested = getRequestedOrientation();
|
||||
int userRequested;
|
||||
switch (requested) {
|
||||
case ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR:
|
||||
userRequested = ActivityInfo.SCREEN_ORIENTATION_FULL_USER;
|
||||
break;
|
||||
case ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE:
|
||||
userRequested = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE;
|
||||
break;
|
||||
case ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT:
|
||||
userRequested = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT;
|
||||
break;
|
||||
default:
|
||||
// SENSOR / plain LANDSCAPE / PORTRAIT etc: either already
|
||||
// explicit or never produced by setOrientationBis.
|
||||
return;
|
||||
}
|
||||
|
||||
Log.d("GameActivity", "requestedOrientation " + requested + " -> " + userRequested
|
||||
+ " (honour the device rotation lock)");
|
||||
setRequestedOrientation(userRequested);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public void setImmersiveMode(boolean immersive_mode) {
|
||||
if (android.os.Build.VERSION.SDK_INT >= 28) {
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Nuzlocke
|
||||
|
||||
An enforced Gen 1 Nuzlocke.
|
||||
|
||||
Oak configures Slow Start, whether duplicate evolutionary families are skipped
|
||||
or consume an area's encounter, and whether Safari maps are separate areas.
|
||||
|
||||
After Slow Start, the mod enforces mandatory nicknames for starters, gifts,
|
||||
and catches; one capture per area; no duplicate evolutionary families; and
|
||||
permanent death. A party Pokémon says that it died and is removed immediately.
|
||||
If the final party member dies, the game runs the credits to THE END and then
|
||||
deletes the active save.
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
-- Nuzlocke rules. Uses the current engine's internal seams while the
|
||||
-- equivalent public API hooks are being added.
|
||||
return function(mod)
|
||||
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
|
||||
steps = next(steps, speech)
|
||||
mod.ui.insertStepAfter(steps, "oak_welcome", {
|
||||
id = "nuzlocke_intro", kind = "say", pic = "oak",
|
||||
text = "A Nuzlocke is a\npromise.\fEvery loss is\npermanent.",
|
||||
})
|
||||
mod.ui.insertStepAfter(steps, "nuzlocke_intro", {
|
||||
id = "nuzlocke_slow_start", kind = "yesno", pic = "oak",
|
||||
saveKey = "slow_start", defaultNo = true,
|
||||
text = "Use SLOW START?\nRules start with\nPOKé BALLS.",
|
||||
})
|
||||
mod.ui.insertStepAfter(steps, "nuzlocke_slow_start", {
|
||||
id = "nuzlocke_dupes", kind = "choice", pic = "oak",
|
||||
saveKey = "dupes_mode", text = "When you meet a\nknown family?",
|
||||
choices = { "SKIP", "LOSE" }, values = { "skip", "strict" },
|
||||
})
|
||||
mod.ui.insertStepAfter(steps, "nuzlocke_dupes", {
|
||||
id = "nuzlocke_safari", kind = "yesno", pic = "oak",
|
||||
saveKey = "safari_sectors", text = "Separate SAFARI\nsectors?",
|
||||
})
|
||||
mod.ui.insertStepAfter(steps, "nuzlocke_safari", {
|
||||
id = "nuzlocke_close", kind = "say", pic = "oak",
|
||||
text = "Give every friend\na name. Keep them\nsafe. Good luck!",
|
||||
})
|
||||
return steps
|
||||
end)
|
||||
|
||||
mod.events:on("intro.oak_speech.answered", function(ev)
|
||||
if ev.saveKey then mod.save:set(ev.saveKey, ev.value) end
|
||||
end)
|
||||
|
||||
local function active(game, battle)
|
||||
if not (game and game.save) or (battle and (battle.demo or battle.ghost)) then return false end
|
||||
if not mod.save:get("slow_start", false) then return true end
|
||||
if mod.save:get("balls_unlocked", false) then return true end
|
||||
for id, count in pairs(game.save.inventory or {}) do
|
||||
if count > 0 and game.data.items[id] and game.data.items[id].ball then
|
||||
mod.save:set("balls_unlocked", true)
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function areaKey(game, battle)
|
||||
if battle and battle.safari and not mod.save:get("safari_sectors", false) then
|
||||
return "SAFARI_ZONE"
|
||||
end
|
||||
return (game.overworld and game.overworld.map and game.overworld.map.id)
|
||||
or (game.save.player and game.save.player.map) or "UNKNOWN"
|
||||
end
|
||||
|
||||
local function family(data, species)
|
||||
local found, pending = {}, { species }
|
||||
while #pending > 0 do
|
||||
local id = table.remove(pending)
|
||||
if not found[id] then
|
||||
found[id] = true
|
||||
for _, evo in ipairs((data.pokemon[id] or {}).evolutions or {}) do pending[#pending + 1] = evo.species end
|
||||
for parent, def in pairs(data.pokemon or {}) do
|
||||
for _, evo in ipairs(def.evolutions or {}) do
|
||||
if evo.species == id then pending[#pending + 1] = parent end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return found
|
||||
end
|
||||
|
||||
local function ownsFamily(game, species)
|
||||
local members = family(game.data, species)
|
||||
local function owns(mon) return mon and members[mon.species] end
|
||||
for _, mon in ipairs(game.save.party or {}) do if owns(mon) then return true end end
|
||||
for _, box in ipairs(game.save.boxes or {}) do
|
||||
for _, mon in ipairs(box) do if owns(mon) then return true end end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function caughtAreas()
|
||||
local areas = mod.save:get("caught_areas")
|
||||
if type(areas) ~= "table" then areas = {}; mod.save:set("caught_areas", areas) end
|
||||
return areas
|
||||
end
|
||||
|
||||
local function denied(game, battle, species)
|
||||
if not active(game, battle) then return nil end
|
||||
if caughtAreas()[areaKey(game, battle)] then return "area" end
|
||||
if ownsFamily(game, species) then return "dupes" end
|
||||
end
|
||||
|
||||
mod.events:on("pokemon.caught", function(ev)
|
||||
-- A successful capture proves that Slow Start has ended even when it was
|
||||
-- the last ball in the bag.
|
||||
if mod.save:get("slow_start", false) then mod.save:set("balls_unlocked", true) end
|
||||
if active(ev.game, ev.battle) then
|
||||
caughtAreas()[areaKey(ev.game, ev.battle)] = ev.species
|
||||
mod.save:set("caught_areas", caughtAreas())
|
||||
end
|
||||
end)
|
||||
|
||||
mod.events:on("game.ready", function()
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Commands = require("src.script.Commands")
|
||||
local Party = require("src.pokemon.Party")
|
||||
local Boxes = require("src.pokemon.Boxes")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Strings = require("src.core.Strings")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
|
||||
BattleState.askNicknameUI = function(self, mon)
|
||||
self.lockedBall, self.blankForAskName = nil, false
|
||||
return self:buildScreen("NamingScreen", {
|
||||
title = Strings("NICKNAME?"), maxLen = 10,
|
||||
onDone = function(name) mon.nickname = name or "A" end,
|
||||
})
|
||||
end
|
||||
|
||||
-- Gifts and starters use the same mandatory naming screen.
|
||||
Commands.give_pokemon = function(ctx, species, level)
|
||||
local gift = { ctx = ctx, species = species, level = level }
|
||||
if ctx.game.mods then ctx.game.mods.events:emit("pokemon.before_give", gift) end
|
||||
local mon = Pokemon.new(ctx.game.data, gift.species, gift.level)
|
||||
ctx.game.stringBuffer, ctx.pendingPokemonName = ctx.game.data.pokemon[gift.species].name or gift.species, gift.species
|
||||
BattleState.stampOT(ctx.save, mon)
|
||||
local inParty = Party.add(ctx.save.party, mon)
|
||||
local boxNum = inParty and nil or Boxes.deposit(ctx.save, mon)
|
||||
if not inParty and not boxNum then ctx.lastCheck = false; return end
|
||||
if ctx.save.pokedex then ctx.save.pokedex.seen[gift.species], ctx.save.pokedex.owned[gift.species] = true, true end
|
||||
ctx.lastCheck, ctx.addedToParty, ctx.boxNum = true, inParty, boxNum
|
||||
if ctx.runner then
|
||||
Screens.push(ctx.game, "NamingScreen", {
|
||||
title = Strings("NICKNAME?"), maxLen = 10,
|
||||
onDone = function(name) mon.nickname = name or "A"; ctx.runner:resume() end,
|
||||
})
|
||||
ctx.runner:yield()
|
||||
else mon.nickname = "A" end
|
||||
if boxNum then
|
||||
ctx.game.boxMonNicks, ctx.game.stringBuffer = mon.nickname, tostring(boxNum)
|
||||
if ctx.runner then Commands.show_text(ctx, "_SentToBoxText") end
|
||||
end
|
||||
end
|
||||
|
||||
local vanillaThrowBall = BattleState.throwBall
|
||||
BattleState.throwBall = function(self, ball)
|
||||
local reason = denied(self.game, self, self.enemy and self.enemy.mon.species)
|
||||
if reason then
|
||||
if reason == "dupes" and mod.save:get("dupes_mode", "skip") == "strict" then
|
||||
caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST"
|
||||
mod.save:set("caught_areas", caughtAreas())
|
||||
end
|
||||
Bag.add(self.game.save, ball, 1)
|
||||
self:say(reason == "area" and "This area already\nhas a captured POKéMON!"
|
||||
or "You already have\nthis POKéMON family!")
|
||||
return
|
||||
end
|
||||
return vanillaThrowBall(self, ball)
|
||||
end
|
||||
|
||||
local vanillaOnFaint = BattleState.onFaint
|
||||
BattleState.onFaint = function(self, battler)
|
||||
if not (battler.isPlayer and active(self.game, self)) then return vanillaOnFaint(self, battler) end
|
||||
if battler.faintQueued then return end
|
||||
battler.faintQueued = true
|
||||
if self.participants then self.participants[battler.mon] = nil end
|
||||
Runtime.emit("battle.fainted", { battle = self, battler = battler })
|
||||
for i, mon in ipairs(self.game.save.party) do
|
||||
if mon == battler.mon then table.remove(self.game.save.party, i); break end
|
||||
end
|
||||
self:actNext(function()
|
||||
battler.fainted = true
|
||||
require("src.core.Sound").playCry(self.data, battler.mon.species)
|
||||
require("src.core.Sound").play(self.data, "Faint_Fall")
|
||||
self.fx = self.fx or {}; self.fx.faint = { battler = battler, frames = 30 }
|
||||
end)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { wait = 30 })
|
||||
self:sayNext(Strings("%s\ndied!", battler.name))
|
||||
self:act(function() self:playerMonFainted() end)
|
||||
end
|
||||
|
||||
local vanillaPlayerFainted = BattleState.playerMonFainted
|
||||
BattleState.playerMonFainted = function(self)
|
||||
if active(self.game, self) and not Party.firstHealthy(self.game.save.party) then
|
||||
self.nuzlockeGameOver, self.result, self.afterQueue = true, "nuzlocke_game_over", "finish"
|
||||
self:sayNext(Strings("All of your\nPOKéMON are dead..."))
|
||||
return
|
||||
end
|
||||
return vanillaPlayerFainted(self)
|
||||
end
|
||||
|
||||
local vanillaFinish = BattleState.finish
|
||||
BattleState.finish = function(self)
|
||||
if not self.nuzlockeGameOver then return vanillaFinish(self) end
|
||||
self.nuzlockeGameOver = nil
|
||||
self.game.stack:pop()
|
||||
Runtime.emit("battle.ended", { battle = self, result = "nuzlocke_game_over" })
|
||||
-- Game over has no victory lap: delete the slot, then use Credits only
|
||||
-- as its existing THE END renderer / A-or-B wait screen.
|
||||
local version = GameVersion.get()
|
||||
local slot = SaveData.activeSlot(version)
|
||||
if slot then SaveData.deleteSlot(version, slot)
|
||||
elseif love and love.filesystem then
|
||||
local main = SaveData.saveFilename(version)
|
||||
love.filesystem.remove(main); love.filesystem.remove(main .. ".bak"); love.filesystem.remove(main .. ".tmp")
|
||||
end
|
||||
local ending = Screens.push(self.game, "Credits", function()
|
||||
require("src.core.Music").stop()
|
||||
while self.game.stack:top() do self.game.stack:pop() end
|
||||
Screens.push(self.game, "IntroMovie", function()
|
||||
if self.game.makeTitleState then self.game.stack:push(self.game:makeTitleState()) end
|
||||
end)
|
||||
end)
|
||||
ending.phase, ending.timer = "end_wait", 0
|
||||
end
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "nuzlocke",
|
||||
"name": "Nuzlocke",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "GAMEPLAY",
|
||||
"game_version": ">=0.0.0-dev <1.0.0",
|
||||
"priority": 100,
|
||||
"permissions": [
|
||||
"engine_internals"
|
||||
],
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"description": "A configurable Gen 1 Nuzlocke with permanent death and area catches.",
|
||||
"github": "bryanthaboi/nuzlocke"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this mod are documented here. Format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [1.0.0] - 2026-07-30
|
||||
|
||||
### Added
|
||||
|
||||
- Opt-in SYNC STEPS option: Apple Health step counts (delivered by the iOS
|
||||
build's native bridge as `steps_pending.json`) convert to EXP.
|
||||
- STEPS PER EXP option (10 / 20 / 50, default 20).
|
||||
- GIVE EXP TO option: lead mon (default) or whole party split.
|
||||
- Level-ups applied with the engine's growth curves and rare-candy stat
|
||||
math; walk-report textbox at quiet moments.
|
||||
- Guardrails: steps anchored to the last sync (never credited twice),
|
||||
50,000-step clamp per sync, engine `levelCap` respected.
|
||||
@@ -1,92 +0,0 @@
|
||||
# Pokéwalker (Apple Health) — a Gen1Recomp mod
|
||||
|
||||
Your real-world steps become EXP for your Pokémon party — the HeartGold/
|
||||
SoulSilver Pokéwalker, except it's the iPhone already in your pocket.
|
||||
|
||||
A mod for [gen1recomp](https://github.com/bryanthaboi/gen1recomp)
|
||||
(the Gen 1 Recompilation Project). Opt-in, data-safe, and dormant on any
|
||||
platform that doesn't provide the native step source (see
|
||||
[Requirements](#requirements)).
|
||||
|
||||
## Install
|
||||
|
||||
Grab `pokewalker-<version>.modpkg` from
|
||||
[Releases](https://github.com/mresnick67/Gen1ReComp-Pokewalker/releases)
|
||||
(or use GitHub's *Code → Download ZIP* — the importer handles both), then:
|
||||
|
||||
- **In the launcher:** MODS tab → **Import mod .zip** → pick the file, or
|
||||
drag it onto the window on desktop.
|
||||
- **iOS:** you can also drop the zip into the app's folder in the Files
|
||||
app; it installs on next launch.
|
||||
|
||||
Then, in the **mod manager → POKEWALKER → options**, turn on **SYNC
|
||||
STEPS**. iOS asks for read-only access to your step count the first time.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Values | Default |
|
||||
|---|---|---|
|
||||
| SYNC STEPS | on / off | **off** |
|
||||
| STEPS PER EXP | 10 / 20 / 50 | 20 |
|
||||
| GIVE EXP TO | lead mon / whole party (split) | lead mon |
|
||||
|
||||
## Mechanics & guardrails
|
||||
|
||||
- EXP applies through the engine's own growth curves and rare-candy stat
|
||||
math, so levels, stats, and HP top-ups are exact.
|
||||
- Steps are anchored to the last sync — the same walk is never credited
|
||||
twice — and any single sync is clamped to 50,000 steps.
|
||||
- The engine `levelCap` constant is respected.
|
||||
- Credits land at quiet moments (save load, map change, battle end) with a
|
||||
walk-report textbox.
|
||||
|
||||
## Requirements
|
||||
|
||||
The Lua mod is platform-neutral, but it feeds on a **native step bridge**
|
||||
that currently ships in an iOS build of gen1recomp. Without the bridge the
|
||||
mod loads and stays dormant — safe to install anywhere.
|
||||
|
||||
### The bridge contract (for porters)
|
||||
|
||||
Any platform can light this mod up by providing:
|
||||
|
||||
- `love.system.syncHealthSteps()` → `boolean` — kick off an async step
|
||||
query (requesting OS permission on first use). On completion, write
|
||||
**`steps_pending.json`** to the LÖVE save directory:
|
||||
|
||||
```json
|
||||
{ "steps": 4312, "from": "2026-07-30T08:00:00Z", "to": "2026-07-30T17:00:00Z" }
|
||||
```
|
||||
|
||||
Count steps from a persisted anchor (last successful sync) so a walk is
|
||||
never delivered twice, and **merge** with an unconsumed pending file
|
||||
rather than overwriting it. The mod consumes and deletes the file.
|
||||
|
||||
The reference iOS implementation is a small Swift class (HealthKit
|
||||
`HKStatisticsQuery` over `stepCount`) exposed to Lua through a one-line
|
||||
`wrap_System.cpp` addition. Open an issue here if you're porting the
|
||||
bridge (Android: Health Connect / Google Fit would slot straight in).
|
||||
|
||||
## Known limitations (v1)
|
||||
|
||||
- Level-ups granted while walking don't prompt for new moves, and level
|
||||
evolutions wait for the next in-battle level — same behavior as
|
||||
over-leveling with rare candies.
|
||||
- Steps sync on launch/activation; no background delivery yet.
|
||||
|
||||
## Developing
|
||||
|
||||
From a gen1recomp checkout with this mod at `mods/pokewalker` and an
|
||||
imported data cache:
|
||||
|
||||
```sh
|
||||
luajit mods/pokewalker/tests/pokewalker_test.lua
|
||||
python3 tools/modkit.py validate mods/pokewalker --base imported
|
||||
python3 tools/modkit.py pack mods/pokewalker
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE). Not affiliated with Nintendo, Game Freak,
|
||||
or The Pokémon Company. This mod contains no ROM-derived content
|
||||
(`modkit lint` clean).
|
||||
@@ -1,144 +0,0 @@
|
||||
-- Pokéwalker: Apple Health steps become party EXP (iOS builds).
|
||||
--
|
||||
-- The Swift side (mobile/ios/native/GRHealthBridge.swift) owns HealthKit:
|
||||
-- love.system.syncHealthSteps() requests read access on first use, counts
|
||||
-- steps since the last sync anchor, and drops steps_pending.json in the
|
||||
-- save dir. This mod consumes that file at quiet moments (save loaded, map
|
||||
-- transitions, battle end), converts steps to EXP, and applies level-ups
|
||||
-- with the same stat math the engine uses.
|
||||
--
|
||||
-- Opt-in: everything is inert until SYNC STEPS is enabled in this mod's
|
||||
-- options (the HealthKit permission sheet appears on first enable). On
|
||||
-- non-iOS platforms love.system.syncHealthSteps does not exist and the mod
|
||||
-- stays dormant.
|
||||
--
|
||||
-- Known v1 limits (documented in README.md): level-ups applied here do not
|
||||
-- prompt for new moves (like over-leveling past a learnset entry with rare
|
||||
-- candies) and do not trigger level evolutions until the next battle candy
|
||||
-- or level gained in battle.
|
||||
|
||||
local PENDING = "steps_pending.json"
|
||||
|
||||
return function(mod)
|
||||
mod.options:define({
|
||||
{ key = "enabled", label = "SYNC STEPS", type = "toggle", default = false },
|
||||
{ key = "rate", label = "STEPS PER EXP", type = "choice", default = "20",
|
||||
choices = { { "10", "10" }, { "20", "20" }, { "50", "50" } } },
|
||||
{ key = "target", label = "GIVE EXP TO", type = "choice", default = "lead",
|
||||
choices = { { "LEAD MON", "lead" }, { "WHOLE PARTY", "party" } } },
|
||||
})
|
||||
|
||||
local Json = require("src.link.Json")
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local game
|
||||
|
||||
local function active()
|
||||
return love.system.syncHealthSteps ~= nil and mod.options:get("enabled")
|
||||
end
|
||||
|
||||
-- Ask the native side to refresh steps_pending.json. Async: results are
|
||||
-- picked up by a later consume() (next map change / battle end).
|
||||
local function requestSync()
|
||||
if active() then love.system.syncHealthSteps() end
|
||||
end
|
||||
|
||||
-- Add EXP to one mon, bumping levels with the engine's own stat math
|
||||
-- (mirrors the rare-candy path in src/inventory/ItemEffects.lua).
|
||||
-- Returns the EXP actually absorbed and any levels gained.
|
||||
local function applyToMon(mon, xp, data)
|
||||
local def = data.pokemon[mon.species]
|
||||
if not def or not mon.level then return 0, {} end
|
||||
local cap = (data.constants and data.constants.levelCap) or 100
|
||||
if mon.level >= cap then return 0, {} end
|
||||
local maxExp = Growth.expForLevel(def.growthRate, cap, data.growth_rates)
|
||||
local before = mon.exp or 0
|
||||
mon.exp = math.min(maxExp, before + xp)
|
||||
local absorbed = mon.exp - before
|
||||
if absorbed <= 0 then return 0, {} end
|
||||
local levels = {}
|
||||
local newLevel = Growth.levelForExp(def.growthRate, mon.exp, cap,
|
||||
data.growth_rates)
|
||||
while mon.level < newLevel do
|
||||
mon.level = mon.level + 1
|
||||
local old = mon.stats
|
||||
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
|
||||
mon.hp = math.min(mon.stats.hp,
|
||||
(mon.hp or 0) + (mon.stats.hp - (old and old.hp or 0)))
|
||||
levels[#levels + 1] = mon.level
|
||||
end
|
||||
return absorbed, levels
|
||||
end
|
||||
|
||||
-- A short walk-report textbox, shown only when the overworld is idle;
|
||||
-- when a script is already running the report is silently skipped (the
|
||||
-- EXP is applied regardless, and the log has the numbers).
|
||||
local function report(steps, total, leveled)
|
||||
local msg = ("You walked %d steps!\nYour party gained %d EXP."):format(steps, total)
|
||||
if #leveled > 0 then
|
||||
msg = msg .. ("\n%s grew to L%d!"):format(leveled[1].name, leveled[1].level)
|
||||
end
|
||||
-- The report is decoration: it must never break the credit. mod.world
|
||||
-- materializes lazily (and can itself error in headless contexts), so
|
||||
-- the access lives inside the pcall too.
|
||||
pcall(function()
|
||||
local world = mod.world
|
||||
if world then world:queueScript({ { "show_text", msg } }) end
|
||||
end)
|
||||
end
|
||||
|
||||
local function consume()
|
||||
if not (game and active()) then return end
|
||||
local raw = love.filesystem.read(PENDING)
|
||||
if not raw then return end
|
||||
local decoded = Json.decode(raw)
|
||||
local steps = decoded and tonumber(decoded.steps) or 0
|
||||
love.filesystem.remove(PENDING)
|
||||
if steps <= 0 then return end
|
||||
|
||||
local party = game.save and game.save.party
|
||||
if not party or #party == 0 then return end
|
||||
local rate = tonumber(mod.options:get("rate")) or 20
|
||||
local xp = math.floor(steps / rate)
|
||||
if xp <= 0 then return end
|
||||
|
||||
local total, leveled = 0, {}
|
||||
local targets = {}
|
||||
if mod.options:get("target") == "party" then
|
||||
for _, mon in ipairs(party) do targets[#targets + 1] = mon end
|
||||
else
|
||||
targets[1] = party[1]
|
||||
end
|
||||
local share = math.max(1, math.floor(xp / #targets))
|
||||
for _, mon in ipairs(targets) do
|
||||
local absorbed, levels = applyToMon(mon, share, game.data)
|
||||
total = total + absorbed
|
||||
for _, level in ipairs(levels) do
|
||||
leveled[#leveled + 1] =
|
||||
{ name = mon.nickname or mon.species, level = level }
|
||||
end
|
||||
end
|
||||
if total <= 0 then return end
|
||||
mod.log:info("credited %d steps -> %d EXP (%d level-ups)",
|
||||
steps, total, #leveled)
|
||||
report(steps, total, leveled)
|
||||
end
|
||||
|
||||
-- game.ready is the sanctioned way to obtain the Game object; the party
|
||||
-- only exists once a save is loaded or created.
|
||||
mod.events:on("game.ready", function(payload)
|
||||
game = payload.game
|
||||
requestSync()
|
||||
end)
|
||||
mod.events:on("save.loaded", function()
|
||||
requestSync()
|
||||
consume()
|
||||
end)
|
||||
mod.events:on("save.created", function() requestSync() end)
|
||||
-- Quiet moments where a walk report can safely appear.
|
||||
mod.events:on("map.entered", function() consume() end)
|
||||
mod.events:on("battle.ended", function() consume() end)
|
||||
-- Flipping SYNC STEPS on triggers the HealthKit permission sheet
|
||||
-- immediately rather than on the next boot.
|
||||
mod.events:on("mod.options_changed", function() requestSync() end)
|
||||
end
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"id": "pokewalker",
|
||||
"name": "Pokewalker (Apple Health)",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "MECHANIC",
|
||||
"game_version": ">=0.0.0-0 <2.0.0",
|
||||
"permissions": ["network", "engine_internals"],
|
||||
"priority": 100,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"description": "Your real-world steps (Apple Health) become EXP for your party. Opt-in: enable SYNC STEPS in this mod's options. Needs an iOS build with the Health bridge; dormant elsewhere."
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
|
||||
-- and the manager detail pane; never by the loader's merge.
|
||||
return {
|
||||
summary = "Real-world steps from Apple Health become EXP for your party.",
|
||||
author = "mresnick67",
|
||||
contact = "https://github.com/mresnick67/Gen1ReComp-Pokewalker",
|
||||
tags = { "mechanic", "ios", "health", "opt-in", "field" },
|
||||
differences = {
|
||||
changed = {},
|
||||
added = {
|
||||
"an opt-in SYNC STEPS option: Apple Health step counts convert to "
|
||||
.. "EXP at a configurable rate (10/20/50 steps per EXP)",
|
||||
"EXP lands on the lead mon or splits across the party, applied with "
|
||||
.. "the engine's own growth curves and rare-candy stat math",
|
||||
"a walk-report textbox at quiet moments (save load, map change, "
|
||||
.. "battle end)",
|
||||
},
|
||||
known = {
|
||||
"needs an iOS build that ships the native Health bridge "
|
||||
.. "(love.system.syncHealthSteps); on every other platform the mod "
|
||||
.. "loads but stays dormant",
|
||||
"level-ups granted while walking do not prompt for new moves and do "
|
||||
.. "not trigger level evolutions until the next in-battle level "
|
||||
.. "(same as over-leveling with rare candies)",
|
||||
"steps sync on launch/activation; no background delivery yet",
|
||||
},
|
||||
},
|
||||
credits = {
|
||||
{ who = "Nintendo's Pokewalker (HGSS)", for_ = "the idea this recreates" },
|
||||
{ who = "bryanthaboi/gen1recomp", for_ = "the engine and mod platform" },
|
||||
},
|
||||
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
-- Standalone: luajit mods/pokewalker/tests/pokewalker_test.lua
|
||||
-- Exercises the stated effect: opt-in gating, the native-bridge seam, and
|
||||
-- steps converting to EXP through the engine's own growth math.
|
||||
--
|
||||
-- Shipped-mod suites are auto-run by the ROM-free T4 tier, so this runs
|
||||
-- against the committed fixture dataset (T.fixtures), never data/generated.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
|
||||
-- The native step bridge only exists inside the iOS/Android apps; stand it
|
||||
-- in so the mod sees the same surface it does on device.
|
||||
local syncCalls = 0
|
||||
love.system = love.system or {}
|
||||
love.system.syncHealthSteps = function()
|
||||
syncCalls = syncCalls + 1
|
||||
return true
|
||||
end
|
||||
|
||||
local run = T.sdk.loadMod("mods/pokewalker", { data = Data })
|
||||
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
|
||||
local events = run.loader.events
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local mon = Pokemon.new(Data, "FIXMON_A", 5)
|
||||
local game = { data = Data, save = { party = { mon } } }
|
||||
|
||||
-- Dormant until opted in: seeded steps survive every event untouched and
|
||||
-- the native bridge is never poked (no permission prompt without consent).
|
||||
love.filesystem.write("steps_pending.json", '{"steps": 20000}')
|
||||
events:emit("game.ready", { game = game })
|
||||
events:emit("map.entered", {})
|
||||
T.check(love.filesystem.read("steps_pending.json") ~= nil,
|
||||
"opt-out leaves pending steps untouched")
|
||||
T.eq(syncCalls, 0, "opt-out never calls the native bridge")
|
||||
|
||||
-- Opted in: 20000 steps at the default 20 steps/EXP credit the lead mon.
|
||||
run.loader.modOptions.pokewalker = { enabled = true }
|
||||
local def = Data.pokemon[mon.species]
|
||||
local expBefore = mon.exp
|
||||
events:emit("save.loaded", {})
|
||||
T.eq(mon.exp, expBefore + 1000, "20000 steps at 20 steps/EXP = +1000 EXP")
|
||||
local expectedLevel = Growth.levelForExp(def.growthRate, mon.exp, 100,
|
||||
Data.growth_rates)
|
||||
T.check(mon.level > 5, "enough EXP to actually level (fixture curve)")
|
||||
T.eq(mon.level, expectedLevel, "level matches the engine growth curve")
|
||||
T.check(mon.stats.hp > 0 and mon.hp <= mon.stats.hp,
|
||||
"stat recalc keeps HP within the new maximum")
|
||||
T.check(love.filesystem.read("steps_pending.json") == nil,
|
||||
"pending file is consumed exactly once")
|
||||
T.check(syncCalls > 0, "opt-in requests a native sync")
|
||||
|
||||
-- A consumed file plus more events must not double-credit.
|
||||
local expAfter = mon.exp
|
||||
events:emit("map.entered", {})
|
||||
T.eq(mon.exp, expAfter, "no pending file, no phantom EXP")
|
||||
|
||||
run.release()
|
||||
T.finish("pokewalker")
|
||||
@@ -308,6 +308,9 @@ build_ios() {
|
||||
if [ "$IOS_RELEASE" = true ]; then
|
||||
args+=(--release)
|
||||
fi
|
||||
if [ "$VERSION_EXPLICIT" = true ]; then
|
||||
args+=(--version "$VERSION")
|
||||
fi
|
||||
"$ROOT/scripts/build_ios.sh" ${args[@]+"${args[@]}"}
|
||||
}
|
||||
|
||||
|
||||
+66
-13
@@ -2,7 +2,8 @@
|
||||
# Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 11.5's
|
||||
# official iOS Xcode project (love-11.5-ios-source.zip).
|
||||
#
|
||||
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--package-only]
|
||||
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--install]
|
||||
# [--version X.Y.Z] [--package-only]
|
||||
#
|
||||
# (default) Simulator Debug (ad-hoc signed)
|
||||
# --device iphoneos SDK; signing team auto-detected from the
|
||||
@@ -10,6 +11,7 @@
|
||||
# --install after a --device build, install the app onto the
|
||||
# first connected iPhone/iPad (unlock it first)
|
||||
# --release Release configuration
|
||||
# --version X.Y.Z stamp MARKETING_VERSION / CURRENT_PROJECT_VERSION
|
||||
# --fetch Download love-11.5-ios-source.zip into mobile/ios/love-src/
|
||||
# --package-only Zip game.love + apply plist overlay; skip xcodebuild
|
||||
#
|
||||
@@ -19,6 +21,7 @@
|
||||
# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/
|
||||
#
|
||||
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
|
||||
# dist/ios/gen1recomp.ipa (device builds only)
|
||||
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp.app
|
||||
|
||||
set -euo pipefail
|
||||
@@ -62,6 +65,7 @@ DEVICE=false
|
||||
RELEASE=false
|
||||
PACKAGE_ONLY=false
|
||||
INSTALL=false
|
||||
VERSION=""
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
@@ -74,23 +78,43 @@ while [ $# -gt 0 ]; do
|
||||
--release) RELEASE=true ;;
|
||||
--package-only) PACKAGE_ONLY=true ;;
|
||||
--install) INSTALL=true ;;
|
||||
--version) VERSION="$2"; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,22p' "$0"
|
||||
sed -n '2,24p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown argument: $1 (try --fetch, --device, --release, --install, or --package-only)" ;;
|
||||
*) fail "unknown argument: $1 (try --fetch, --device, --release, --version, --install, or --package-only)" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
VERSION_CODE=""
|
||||
if [ -n "$VERSION" ]; then
|
||||
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
fail "invalid --version '$VERSION' (expected X.Y.Z)"
|
||||
fi
|
||||
major="${VERSION%%.*}"
|
||||
rest="${VERSION#*.}"
|
||||
minor="${rest%%.*}"
|
||||
patch="${rest##*.}"
|
||||
VERSION_CODE=$((major * 10000 + minor * 100 + patch))
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------- signing identity
|
||||
# Auto-detect the Apple Development team when the caller didn't set one: the
|
||||
# OU field of the first Apple Development certificate in the keychain (Xcode
|
||||
# creates that certificate when you sign into Settings -> Accounts).
|
||||
# Auto-detect the Apple Development team when the caller didn't set one.
|
||||
# Prefer a *valid* identity from `find-identity` (the parenthetical there is
|
||||
# the cert id, not the team), then read that cert's OU. Scanning every
|
||||
# "Apple Development" certificate picks expired personal/work certs first.
|
||||
detect_team() {
|
||||
security find-certificate -c "Apple Development" -p 2>/dev/null \
|
||||
local cn
|
||||
cn="$(security find-identity -v -p codesigning 2>/dev/null \
|
||||
| sed -n -E 's/.*"Apple Development: ([^"]+)".*/\1/p' \
|
||||
| head -1)"
|
||||
[ -n "$cn" ] || return 1
|
||||
security find-certificate -c "Apple Development: $cn" -p 2>/dev/null \
|
||||
| openssl x509 -noout -subject 2>/dev/null \
|
||||
| sed -n 's/.*OU *= *\([A-Z0-9]*\).*/\1/p' | head -1
|
||||
| sed -n 's/.*OU *= *\([A-Z0-9]*\).*/\1/p' \
|
||||
| head -1
|
||||
}
|
||||
if $DEVICE && [ -z "${DEVELOPMENT_TEAM:-}" ]; then
|
||||
DEVELOPMENT_TEAM="$(detect_team || true)"
|
||||
@@ -211,13 +235,14 @@ pack_game_love() {
|
||||
rm -f "$LOVE_FILE"
|
||||
# Same payload as scripts/build.sh / build_android.sh: game sources plus
|
||||
# tools/save-editor, which the launcher's Edit button opens in-process.
|
||||
# mods/pokewalker rides inside game.love on iOS only: physfs merges the
|
||||
# fused archive with the save dir, so the loader discovers it like any
|
||||
# installed mod, and its Apple Health sync is a no-op everywhere else.
|
||||
# Deliberately NO fused mods: a mod inside game.love sits in the
|
||||
# read-only app bundle, so the mod manager's Delete can't remove it and
|
||||
# it reappears every launch. Mods install as .zips at runtime instead
|
||||
# (launcher -> MODS -> Import mod .zip), the same lifecycle as every
|
||||
# other platform.
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
mods/pokewalker \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
# NOTE: grep -q here would race pipefail — it exits on first match, unzip
|
||||
@@ -343,6 +368,15 @@ run_xcodebuild() {
|
||||
|
||||
# Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires
|
||||
# -scheme whenever -derivedDataPath is set, and love-ios ships no shared schemes.
|
||||
# Always stamp both: the overlay plist expands $(MARKETING_VERSION) /
|
||||
# $(CURRENT_PROJECT_VERSION), and love-ios has no project-level defaults.
|
||||
local marketing_version="$LOVE_VERSION"
|
||||
local project_version="1"
|
||||
if [ -n "$VERSION" ]; then
|
||||
marketing_version="$VERSION"
|
||||
project_version="$VERSION_CODE"
|
||||
fi
|
||||
|
||||
local args=(
|
||||
-project "$PROJECT"
|
||||
-target love-ios
|
||||
@@ -352,7 +386,8 @@ run_xcodebuild() {
|
||||
SYMROOT="$BUILD_DIR/Build/Products"
|
||||
OBJROOT="$BUILD_DIR/Build/Intermediates"
|
||||
PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID"
|
||||
MARKETING_VERSION="$LOVE_VERSION"
|
||||
MARKETING_VERSION="$marketing_version"
|
||||
CURRENT_PROJECT_VERSION="$project_version"
|
||||
ONLY_ACTIVE_ARCH=NO
|
||||
)
|
||||
|
||||
@@ -426,6 +461,10 @@ run_xcodebuild() {
|
||||
cp -R "$app" "$dist_dir/$APP_NAME.app"
|
||||
say "copied to $dist_dir/$APP_NAME.app"
|
||||
|
||||
if $DEVICE; then
|
||||
package_ipa "$dist_dir/$APP_NAME.app"
|
||||
fi
|
||||
|
||||
say "iOS app: $app"
|
||||
say "bundle id: $BUNDLE_ID display: $DISPLAY_NAME"
|
||||
if $DEVICE; then
|
||||
@@ -439,6 +478,20 @@ run_xcodebuild() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Pack Payload/<app>.app into dist/ios/gen1recomp.ipa for release / sideload tools.
|
||||
package_ipa() {
|
||||
local app="$1"
|
||||
local ipa="$DIST/$APP_NAME.ipa"
|
||||
local tmp
|
||||
tmp="$(mktemp -d "$DIST/ipa.XXXXXX")"
|
||||
mkdir -p "$tmp/Payload"
|
||||
cp -R "$app" "$tmp/Payload/$(basename "$app")"
|
||||
rm -f "$ipa"
|
||||
(cd "$tmp" && zip -q -r "$ipa" Payload)
|
||||
rm -rf "$tmp"
|
||||
say "ipa: $ipa ($(du -h "$ipa" | cut -f1))"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------ device install
|
||||
# Installs the freshly built .app onto the first connected iPhone/iPad via
|
||||
# devicectl. The phone must be paired (plugged in at least once + "Trust
|
||||
|
||||
@@ -1507,10 +1507,14 @@ function BattleState:update(dt)
|
||||
end
|
||||
local col = (self.menuIndex - 1) % 2
|
||||
local row = math.floor((self.menuIndex - 1) / 2)
|
||||
if input:wasPressed("left") or input:wasPressed("right") then
|
||||
col = 1 - col
|
||||
elseif input:wasPressed("up") or input:wasPressed("down") then
|
||||
row = 1 - row
|
||||
if input:wasPressed("left") then
|
||||
col = math.max(0, col - 1)
|
||||
elseif input:wasPressed("right") then
|
||||
col = math.min(1, col + 1)
|
||||
elseif input:wasPressed("up") then
|
||||
row = math.max(0, row - 1)
|
||||
elseif input:wasPressed("down") then
|
||||
row = math.min(1, row + 1)
|
||||
end
|
||||
self.menuIndex = row * 2 + col + 1
|
||||
if input:wasPressed("a") then
|
||||
@@ -1539,10 +1543,14 @@ function BattleState:update(dt)
|
||||
end
|
||||
local col = (self.menuIndex - 1) % 2
|
||||
local row = math.floor((self.menuIndex - 1) / 2)
|
||||
if input:wasPressed("left") or input:wasPressed("right") then
|
||||
col = 1 - col
|
||||
elseif input:wasPressed("up") or input:wasPressed("down") then
|
||||
row = 1 - row
|
||||
if input:wasPressed("left") then
|
||||
col = math.max(0, col - 1)
|
||||
elseif input:wasPressed("right") then
|
||||
col = math.min(1, col + 1)
|
||||
elseif input:wasPressed("up") then
|
||||
row = math.max(0, row - 1)
|
||||
elseif input:wasPressed("down") then
|
||||
row = math.min(1, row + 1)
|
||||
end
|
||||
self.menuIndex = row * 2 + col + 1
|
||||
if input:wasPressed("a") then
|
||||
@@ -4483,7 +4491,9 @@ function BattleState:sgbBattlePals()
|
||||
-- true white, which is what drew a white box around each pic on the pink
|
||||
-- field. Snap every zone's color 0/3 to the global GBC white/black; the
|
||||
-- mid shades (and the green bar the user prefers) stay untouched.
|
||||
if PaletteFX.mode == "ogred" then
|
||||
-- Boot-ROM OG only (Red/Blue): snap zone paper to the global GBC white/black.
|
||||
-- OG YELLOW keeps each CGBBasePalettes endpoint (already near-white / near-black).
|
||||
if PaletteFX.mode == "ogred" and not require("src.core.GameVersion").isYellow() then
|
||||
local white, black = PaletteFX.GBC_BG[1], PaletteFX.GBC_BG[4]
|
||||
for i = 0, 3 do
|
||||
local c = out[i]
|
||||
|
||||
@@ -93,6 +93,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||
-- MoveHitTest's INVULNERABLE branch sets the same wMoveMissed as a
|
||||
-- failed accuracy roll (core.asm:5260), and the miss handler still
|
||||
-- runs the explode effect ("even if Explosion or Selfdestruct
|
||||
-- missed, its effect still needs to be activated", core.asm:3223),
|
||||
-- so the user faints against a mid-Fly/Dig target too (#528)
|
||||
if record and record.onMiss then record.onMiss(ctx, "invulnerable") end
|
||||
return
|
||||
end
|
||||
|
||||
|
||||
+3
-3
@@ -144,8 +144,8 @@ end
|
||||
-- no def_trainers row (DisplayTextID routes to his ASM script), so the
|
||||
-- extractor emits headers only for the four blackbelts ([2]..[5]). Seed
|
||||
-- object index [1] so he behaves like the real leader:
|
||||
-- * range 4 (matches the strongest blackbelt) -> CheckFightingMapTrainers
|
||||
-- spots the player in his DOWN line and he challenges on sight (#197),
|
||||
-- * range 0: FightingDojoDefaultScript, not trainer sight, starts his
|
||||
-- battle from the single tile to his left (#495),
|
||||
-- * battle = his pre-battle challenge, won = "Hwa! Arrgh! Beaten!",
|
||||
-- * after = the "Stay and train at Karate with us!" re-talk line.
|
||||
-- Deliberately NO `event`: EVENT_BEAT_KARATE_MASTER is owned by
|
||||
@@ -160,7 +160,7 @@ function Data:seedFightingDojoKarateMaster()
|
||||
headers.FightingDojo = headers.FightingDojo or {}
|
||||
if headers.FightingDojo[1] then return end
|
||||
headers.FightingDojo[1] = {
|
||||
range = 4,
|
||||
range = 0,
|
||||
battle = "_FightingDojoKarateMasterText",
|
||||
won = "_FightingDojoKarateMasterDefeatedText",
|
||||
after = "_FightingDojoKarateMasterStayAndTrainWithUsText",
|
||||
|
||||
+19
-2
@@ -172,6 +172,11 @@ function Game:returnToTitle()
|
||||
end
|
||||
|
||||
function Game:step(dt)
|
||||
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
|
||||
-- the same fixed-step boundary as a physical controller. Run them before
|
||||
-- Input:step promotes queued edges so a button chosen here is visible to
|
||||
-- this logic tick, not one tick later. With no wrapper this is a no-op.
|
||||
ModRuntime.call("input.step", function() end, self, dt)
|
||||
self.input:step()
|
||||
-- serviced unconditionally: a link battle's ENet transport must not
|
||||
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
|
||||
@@ -339,7 +344,13 @@ function Game:draw()
|
||||
if worldBelow and self.overworld.sgbWorldZones then
|
||||
worldZones = self.overworld:sgbWorldZones()
|
||||
end
|
||||
Renderer:endFrame(zones, worldZones)
|
||||
local viewport = Renderer:endFrame(zones, worldZones)
|
||||
-- Persistent tool status is screen-space UI: draw it over the completed
|
||||
-- render pipeline with exact playfield/margin geometry, but below mobile
|
||||
-- controls. It never becomes an updating game state.
|
||||
if ModRuntime.wantsHook("render.hud") then
|
||||
ModRuntime.call("render.hud", function() end, self, viewport)
|
||||
end
|
||||
-- on-screen mobile controls: pure screen-space, over the finished frame
|
||||
TouchControls:draw()
|
||||
end
|
||||
@@ -578,6 +589,11 @@ end
|
||||
-- Capture the live world state into the save table and persist it.
|
||||
-- Options are flushed to options.lua as part of SaveData.save.
|
||||
function Game:writeSave()
|
||||
-- Tool sessions can be deliberately ephemeral. Give them one narrow veto
|
||||
-- before captureSave mutates the snapshot or any progress bytes reach disk.
|
||||
if ModRuntime.call("save.write", function() return true end, self) == false then
|
||||
return false
|
||||
end
|
||||
if self.overworld and self.overworld.captureSave then
|
||||
self.overworld:captureSave(self.save)
|
||||
end
|
||||
@@ -588,7 +604,7 @@ function Game:writeSave()
|
||||
if ModRuntime.wants("save.writing") then
|
||||
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
|
||||
end
|
||||
SaveData.save(self.save)
|
||||
return SaveData.save(self.save)
|
||||
end
|
||||
|
||||
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
|
||||
@@ -619,6 +635,7 @@ function Game:applyOptions(opts)
|
||||
-- fpsCap key pace at the standard rate (issue #88)
|
||||
require("src.core.FrameCap").applyOptions(opts)
|
||||
Input:applyBindings(opts.bindings)
|
||||
TouchControls:applyOptions(opts)
|
||||
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
|
||||
if gbcCleared then self:writeOptions() end
|
||||
end
|
||||
|
||||
@@ -249,6 +249,24 @@ function SaveData.defaultOptions()
|
||||
-- Native mod enablement is an installation option, not save-slot data.
|
||||
-- Missing entries mean enabled so newly installed mods work by default.
|
||||
mods = {},
|
||||
-- GitHub release checks for mods with a manifest "github" field
|
||||
-- (src/mods/ModUpdate.lua). Keyed by owner/repo; TTL is six hours.
|
||||
modUpdateCache = {},
|
||||
-- Community mod indexes the player has chosen to browse
|
||||
-- (src/mods/ModIndex.lua), in the order they added them. Empty by
|
||||
-- default and never populated automatically: adding an index is how a
|
||||
-- player says they trust whoever publishes it, so the launcher asks
|
||||
-- rather than shipping one. Rows are { url, feed, base, fallback,
|
||||
-- label }.
|
||||
modIndexes = {},
|
||||
-- Parsed index listings keyed by feed URL; TTL is 24 hours, matching how
|
||||
-- often the feeds themselves rebuild.
|
||||
modIndexCache = {},
|
||||
-- On-screen touch overlay (Android/iOS; see src/core/TouchControls.lua).
|
||||
-- enabled=false hides it permanently (distinct from auto-hide-on-gamepad).
|
||||
-- positions are optional normalized centers {x=0..1, y=0..1} per control
|
||||
-- (dpad/a/b/start/select); nil means the default layout.
|
||||
touchControls = { enabled = true },
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
+195
-48
@@ -12,6 +12,11 @@
|
||||
-- (main.lua then drives it with the mouse); POKEPORT_TOUCH=0 forces it
|
||||
-- off everywhere.
|
||||
--
|
||||
-- Player preferences (options.touchControls) can permanently disable the
|
||||
-- overlay and/or override per-control positions as normalized window
|
||||
-- fractions. The launcher editor (src/ui/TouchControlsEditor.lua) writes
|
||||
-- those; applyOptions reads them at boot and whenever options change.
|
||||
--
|
||||
-- Controls press GB buttons through Input:overlayPressed/Released -- their
|
||||
-- own input source, not a keyboard alias -- so a held overlay direction
|
||||
-- merges cleanly with a keyboard key or stick holding the same button,
|
||||
@@ -39,6 +44,7 @@ local DPAD_DEAD = 0.16
|
||||
local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 }
|
||||
|
||||
local BUTTONS = { "a", "b", "start", "select" }
|
||||
local CONTROLS = { "dpad", "a", "b", "start", "select" }
|
||||
|
||||
local IMAGES = {
|
||||
dpad = "assets/touch/dpad.png",
|
||||
@@ -52,6 +58,12 @@ local IMAGES = {
|
||||
select = "assets/touch/select.png",
|
||||
}
|
||||
|
||||
local function clamp01(v)
|
||||
if v < 0 then return 0 end
|
||||
if v > 1 then return 1 end
|
||||
return v
|
||||
end
|
||||
|
||||
local function wantsOverlay()
|
||||
local env = os.getenv("POKEPORT_TOUCH")
|
||||
if env == "1" then return true end
|
||||
@@ -60,8 +72,59 @@ local function wantsOverlay()
|
||||
return osName == "Android" or osName == "iOS"
|
||||
end
|
||||
|
||||
-- Normalize a persisted touchControls table into {enabled, positions}.
|
||||
-- Unknown / garbage keys are dropped so a bad options.lua cannot brick
|
||||
-- the overlay.
|
||||
function TouchControls.normalizeConfig(tc)
|
||||
local out = { enabled = true, positions = nil }
|
||||
if type(tc) ~= "table" then return out end
|
||||
if tc.enabled == false then out.enabled = false end
|
||||
if type(tc.positions) == "table" then
|
||||
local pos = {}
|
||||
for _, name in ipairs(CONTROLS) do
|
||||
local p = tc.positions[name]
|
||||
if type(p) == "table" and type(p.x) == "number" and type(p.y) == "number" then
|
||||
pos[name] = { x = clamp01(p.x), y = clamp01(p.y) }
|
||||
end
|
||||
end
|
||||
if next(pos) then out.positions = pos end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Pure default layout in LOVE units for a given window size. Shared by
|
||||
-- layout() and the editor's Reset path so defaults stay in one place.
|
||||
function TouchControls.defaultLayout(ww, wh)
|
||||
local short = math.min(ww, wh)
|
||||
local dpadW = math.min(180, short * 0.34)
|
||||
local abW = dpadW * 0.46
|
||||
local ssW = dpadW * 0.30
|
||||
local margin = dpadW * 0.12
|
||||
return {
|
||||
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
|
||||
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
|
||||
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
|
||||
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
||||
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
||||
}
|
||||
end
|
||||
|
||||
local function loadImages()
|
||||
local img = {}
|
||||
for name, path in pairs(IMAGES) do
|
||||
local ok, im = pcall(love.graphics.newImage, path)
|
||||
if not ok then return nil end
|
||||
im:setFilter("linear", "linear")
|
||||
img[name] = im
|
||||
end
|
||||
return img
|
||||
end
|
||||
|
||||
function TouchControls:init()
|
||||
self.active = wantsOverlay()
|
||||
self.enabled = true
|
||||
self.positions = nil
|
||||
self.preview = false
|
||||
self.controllerHidden = false
|
||||
self.touches = {}
|
||||
-- per-GB-button owner count: two fingers on A must not double-press it,
|
||||
@@ -70,51 +133,86 @@ function TouchControls:init()
|
||||
self.dpadTouch = nil
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
self.img = nil
|
||||
if not self.active then return end
|
||||
-- soft-fail: a missing/corrupt PNG must never block boot; the overlay
|
||||
-- stays off and keyboard/controller play still works
|
||||
local img = {}
|
||||
for name, path in pairs(IMAGES) do
|
||||
local ok, im = pcall(love.graphics.newImage, path)
|
||||
if not ok then
|
||||
img = nil
|
||||
break
|
||||
end
|
||||
-- smooth UI icons; the global default filter is nearest for GB pixels
|
||||
im:setFilter("linear", "linear")
|
||||
img[name] = im
|
||||
-- Images load whenever the platform wants the overlay OR the launcher
|
||||
-- editor forces a preview (desktop testing of the editor).
|
||||
if self.active then
|
||||
self.img = loadImages()
|
||||
end
|
||||
end
|
||||
|
||||
-- Ensure art is loaded for the launcher editor even when wantsOverlay()
|
||||
-- is false (desktop without POKEPORT_TOUCH).
|
||||
function TouchControls:ensureImages()
|
||||
if self.img then return true end
|
||||
self.img = loadImages()
|
||||
return self.img ~= nil
|
||||
end
|
||||
|
||||
-- Apply options.touchControls. Called from Game:applyOptions and from
|
||||
-- the launcher editor after a save.
|
||||
function TouchControls:applyOptions(opts)
|
||||
local cfg = TouchControls.normalizeConfig(opts and opts.touchControls)
|
||||
self.enabled = cfg.enabled
|
||||
self.positions = cfg.positions
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
if not self.enabled then
|
||||
self.controllerHidden = false
|
||||
self:reset()
|
||||
end
|
||||
end
|
||||
|
||||
function TouchControls:config()
|
||||
return {
|
||||
enabled = self.enabled ~= false,
|
||||
positions = self.positions,
|
||||
}
|
||||
end
|
||||
|
||||
-- Preview mode: force-draw the overlay for the layout editor, ignoring
|
||||
-- platform / enabled / gamepad gates. Gameplay input still respects
|
||||
-- enabled via touchpressed.
|
||||
function TouchControls:setPreview(on)
|
||||
self.preview = on and true or false
|
||||
if on then
|
||||
self:ensureImages()
|
||||
self.controllerHidden = false
|
||||
end
|
||||
self.img = img
|
||||
end
|
||||
|
||||
function TouchControls:visible()
|
||||
return self.active and self.img ~= nil and not self.controllerHidden
|
||||
if self.preview then return self.img ~= nil end
|
||||
return self.active and self.enabled ~= false and self.img ~= nil
|
||||
and not self.controllerHidden
|
||||
end
|
||||
|
||||
local function clampZone(zone, ww, wh)
|
||||
local half = zone.w * 0.5
|
||||
zone.cx = math.max(half, math.min(ww - half, zone.cx))
|
||||
zone.cy = math.max(half, math.min(wh - half, zone.cy))
|
||||
end
|
||||
|
||||
-- Layout in LOVE units (density-independent on mobile), recomputed when
|
||||
-- the window size changes (rotation, resize). D-pad bottom-left, B/A
|
||||
-- bottom-right with A above B (the Game Boy diagonal), START/SELECT
|
||||
-- flanking the bottom center.
|
||||
-- the window size changes (rotation, resize). Default: d-pad bottom-left,
|
||||
-- B/A bottom-right with A above B (the Game Boy diagonal), START/SELECT
|
||||
-- flanking the bottom center. Custom positions (normalized 0..1) override
|
||||
-- centers while sizes stay derived from the short edge.
|
||||
function TouchControls:layout()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
if self.layoutW == ww and self.layoutH == wh then return self.L end
|
||||
if self.layoutW == ww and self.layoutH == wh and self.L then return self.L end
|
||||
self.layoutW, self.layoutH = ww, wh
|
||||
local short = math.min(ww, wh)
|
||||
-- ~a third of the short edge, capped so tablets don't get a dinner plate
|
||||
local dpadW = math.min(180, short * 0.34)
|
||||
local abW = dpadW * 0.46
|
||||
local ssW = dpadW * 0.30
|
||||
local margin = dpadW * 0.12
|
||||
-- START/SELECT hug the bottom center: on a narrow portrait phone the
|
||||
-- d-pad and B leave little room, so a tight pair is what keeps them off
|
||||
-- the neighboring controls
|
||||
self.L = {
|
||||
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
|
||||
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
|
||||
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
|
||||
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
||||
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
||||
}
|
||||
self.L = TouchControls.defaultLayout(ww, wh)
|
||||
if self.positions then
|
||||
for _, name in ipairs(CONTROLS) do
|
||||
local p = self.positions[name]
|
||||
local zone = self.L[name]
|
||||
if p and zone then
|
||||
zone.cx = p.x * ww
|
||||
zone.cy = p.y * wh
|
||||
clampZone(zone, ww, wh)
|
||||
end
|
||||
end
|
||||
end
|
||||
local ssW = self.L.start.w
|
||||
local fontSize = math.max(8, math.floor(ssW * 0.26))
|
||||
if not self.labelFont or self.fontSize ~= fontSize then
|
||||
self.fontSize = fontSize
|
||||
@@ -123,12 +221,45 @@ function TouchControls:layout()
|
||||
return self.L
|
||||
end
|
||||
|
||||
-- Move one control to a screen-space point and persist its normalized
|
||||
-- position. Used by the layout editor while dragging.
|
||||
function TouchControls:setControlCenter(name, cx, cy)
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local L = self:layout()
|
||||
local zone = L[name]
|
||||
if not zone then return end
|
||||
zone.cx, zone.cy = cx, cy
|
||||
clampZone(zone, ww, wh)
|
||||
self.positions = self.positions or {}
|
||||
self.positions[name] = { x = zone.cx / ww, y = zone.cy / wh }
|
||||
end
|
||||
|
||||
function TouchControls:clearPositions()
|
||||
self.positions = nil
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
end
|
||||
|
||||
local function inCircle(zone, x, y, slop)
|
||||
local r = zone.w * 0.5 * slop
|
||||
local dx, dy = x - zone.cx, y - zone.cy
|
||||
return dx * dx + dy * dy <= r * r
|
||||
end
|
||||
|
||||
-- Which control (if any) contains (x, y). Prefer face buttons over the
|
||||
-- d-pad when they overlap, matching touchpressed's order.
|
||||
function TouchControls:hitTest(x, y)
|
||||
local L = self:layout()
|
||||
for _, btn in ipairs(BUTTONS) do
|
||||
if inCircle(L[btn], x, y, SLOP[btn]) then return btn end
|
||||
end
|
||||
local dz = L.dpad
|
||||
local half = dz.w * 0.65
|
||||
if math.abs(x - dz.cx) <= half and math.abs(y - dz.cy) <= half then
|
||||
return "dpad"
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function dpadDir(zone, x, y)
|
||||
local dx, dy = x - zone.cx, y - zone.cy
|
||||
local dead = zone.w * DPAD_DEAD
|
||||
@@ -165,7 +296,9 @@ local function setDpad(self, touch, dir)
|
||||
end
|
||||
|
||||
function TouchControls:touchpressed(id, x, y)
|
||||
if not (self.active and self.img) then return end
|
||||
-- preview mode is layout-edit only: never press GB buttons
|
||||
if self.preview then return end
|
||||
if not (self.active and self.enabled ~= false and self.img) then return end
|
||||
-- a controller hid the overlay; the first touch only brings it back
|
||||
if self.controllerHidden then
|
||||
self.controllerHidden = false
|
||||
@@ -192,6 +325,7 @@ function TouchControls:touchpressed(id, x, y)
|
||||
end
|
||||
|
||||
function TouchControls:touchmoved(id, x, y)
|
||||
if self.preview then return end
|
||||
local touch = self.touches[id]
|
||||
-- only the d-pad tracks movement (slide between directions without
|
||||
-- lifting); buttons hold until release wherever the finger wanders
|
||||
@@ -200,6 +334,7 @@ function TouchControls:touchmoved(id, x, y)
|
||||
end
|
||||
|
||||
function TouchControls:touchreleased(id, x, y)
|
||||
if self.preview then return end
|
||||
local touch = self.touches[id]
|
||||
if not touch then return end
|
||||
self.touches[id] = nil
|
||||
@@ -216,7 +351,7 @@ end
|
||||
-- touchreleased and would strand its button held forever. Called from
|
||||
-- Game alongside Input:reset() on focus/visibility loss.
|
||||
function TouchControls:reset()
|
||||
for btn in pairs(self.held) do
|
||||
for btn in pairs(self.held or {}) do
|
||||
Input:overlayReleased(btn)
|
||||
end
|
||||
self.held = {}
|
||||
@@ -225,9 +360,13 @@ function TouchControls:reset()
|
||||
end
|
||||
|
||||
-- a gamepad is being used: hide the overlay (dropping anything it held)
|
||||
-- until the next screen touch asks for it back
|
||||
-- until the next screen touch asks for it back. No-op when the player
|
||||
-- permanently disabled the overlay -- there is nothing to hide, and a
|
||||
-- later accidental touch must not resurrect it.
|
||||
function TouchControls:noteGamepad()
|
||||
if not self.active or self.controllerHidden then return end
|
||||
if not self.active or self.enabled == false or self.controllerHidden then
|
||||
return
|
||||
end
|
||||
self.controllerHidden = true
|
||||
self:reset()
|
||||
end
|
||||
@@ -242,40 +381,46 @@ function TouchControls:joystickremoved()
|
||||
end
|
||||
end
|
||||
|
||||
local function drawIcon(img, zone, pressed)
|
||||
love.graphics.setColor(1, 1, 1, pressed and BACK_PRESSED or BACK)
|
||||
local function drawIcon(img, zone, pressed, alphaMul)
|
||||
alphaMul = alphaMul or 1
|
||||
love.graphics.setColor(1, 1, 1, (pressed and BACK_PRESSED or BACK) * alphaMul)
|
||||
love.graphics.circle("fill", zone.cx, zone.cy, zone.w * 0.58)
|
||||
local scale = zone.w / img:getWidth()
|
||||
love.graphics.setColor(1, 1, 1, pressed and ALPHA_PRESSED or ALPHA)
|
||||
love.graphics.setColor(1, 1, 1, (pressed and ALPHA_PRESSED or ALPHA) * alphaMul)
|
||||
love.graphics.draw(img, zone.cx - zone.w / 2,
|
||||
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
|
||||
end
|
||||
|
||||
-- Screen-space, called by Game:draw after Renderer:endFrame so the
|
||||
-- overlay rides on top of everything (world, UI, CRT/GBC FX included).
|
||||
-- Also used by the launcher layout editor under preview mode.
|
||||
function TouchControls:draw()
|
||||
if not self:visible() then return end
|
||||
local L = self:layout()
|
||||
-- when the player disabled the overlay but the editor is previewing,
|
||||
-- draw dimmed so the layout is still editable
|
||||
local alphaMul = (self.preview and self.enabled == false) and 0.45 or 1
|
||||
love.graphics.push("all")
|
||||
love.graphics.origin()
|
||||
|
||||
local dpadTouch = self.dpadTouch and self.touches[self.dpadTouch]
|
||||
local dir = dpadTouch and dpadTouch.dir
|
||||
drawIcon(dir and self.img["dpad_" .. dir] or self.img.dpad, L.dpad,
|
||||
dir ~= nil)
|
||||
dir ~= nil, alphaMul)
|
||||
for _, btn in ipairs(BUTTONS) do
|
||||
drawIcon(self.img[btn], L[btn], self.held[btn] ~= nil)
|
||||
drawIcon(self.img[btn], L[btn], self.held[btn] ~= nil, alphaMul)
|
||||
end
|
||||
|
||||
-- the +/- glyphs alone don't say which is which; shadowed so the text
|
||||
-- reads on both the black letterbox and battle's white one
|
||||
-- reads on both the black letterbox and battle's white one. Each label
|
||||
-- tracks its own control's cy/w so dragging START cannot move SELECT.
|
||||
love.graphics.setFont(self.labelFont)
|
||||
local ly = L.start.cy + L.start.w * 0.66
|
||||
local function label(text, zone)
|
||||
local ly = zone.cy + zone.w * 0.66
|
||||
local w = self.labelFont:getWidth(text)
|
||||
love.graphics.setColor(0, 0, 0, 0.6)
|
||||
love.graphics.setColor(0, 0, 0, 0.6 * alphaMul)
|
||||
love.graphics.print(text, zone.cx - w / 2 + 1, ly + 1)
|
||||
love.graphics.setColor(1, 1, 1, ALPHA + 0.2)
|
||||
love.graphics.setColor(1, 1, 1, (ALPHA + 0.2) * alphaMul)
|
||||
love.graphics.print(text, zone.cx - w / 2, ly)
|
||||
end
|
||||
label("START", L.start)
|
||||
@@ -284,4 +429,6 @@ function TouchControls:draw()
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
TouchControls.CONTROLS = CONTROLS
|
||||
|
||||
return TouchControls
|
||||
|
||||
+1621
-82
File diff suppressed because it is too large
Load Diff
@@ -433,6 +433,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
if battle then
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
-- FishingInit (engine/items/item_effects.asm): cp wWalkBikeSurfState, 2
|
||||
-- (surfing) sets carry, and every ItemUseXRod does jp c, ItemUseNotTime
|
||||
-- on that carry -- surfing refuses the rod with the same OAK text as
|
||||
-- the mid-battle case above, no rod-specific message (#533)
|
||||
if ow and ow.player and ow.player.surfing then
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
return "fish", itemId
|
||||
end
|
||||
|
||||
|
||||
@@ -128,7 +128,14 @@ end
|
||||
|
||||
-- ------- the link surface
|
||||
|
||||
local SPECIES_FIELDS = { "baseStats", "types", "catchRate", "baseExp",
|
||||
-- catchRate stays out (#511): no link mode ever reads it -- a ball thrown
|
||||
-- in a link battle is a trainer-battle throw and always refused (pokered
|
||||
-- engine/items/item_effects.asm ItemUseBall), and the trade rebuild never
|
||||
-- touches it. Hashing it split Red/Blue from Yellow, whose only link
|
||||
-- surface delta is the Dragonair/Dragonite catch-rate bytes
|
||||
-- (data/pokemon/base_stats/dragonair.asm db 45 vs 27, dragonite.asm 45
|
||||
-- vs 9), when the real cable links R/B/Y freely.
|
||||
local SPECIES_FIELDS = { "baseStats", "types", "baseExp",
|
||||
"growthRate", "evolutions" }
|
||||
local MOVE_FIELDS = { "power", "type", "accuracy", "pp", "effect", "category",
|
||||
"priority", "highCrit", "fixedDamage", "multiHit",
|
||||
|
||||
+9
-12
@@ -178,19 +178,16 @@ end
|
||||
|
||||
-- ------- subset negotiation
|
||||
|
||||
-- the species and moves this party actually references, so the exchange
|
||||
-- stays small (six mons) instead of shipping the whole catalog
|
||||
-- every record hash, not just this party's slice: the receiver filters
|
||||
-- its OWN party against these (eligibleParty), so a message limited to the
|
||||
-- sender's party read "not on the other game" for every species the two
|
||||
-- parties did not happen to share, and a subset trade between different
|
||||
-- games showed neither side (#511). The full catalog is ~300 short hash
|
||||
-- strings -- still one message.
|
||||
function Protocol.recordsMessage(data, party)
|
||||
local species = Fingerprint.records(data, "pokemon")
|
||||
local moves = Fingerprint.records(data, "moves")
|
||||
local outSpecies, outMoves = {}, {}
|
||||
for _, mon in ipairs(party or {}) do
|
||||
if species[mon.species] then outSpecies[mon.species] = species[mon.species] end
|
||||
for _, mv in ipairs(mon.moves or {}) do
|
||||
if moves[mv.id] then outMoves[mv.id] = moves[mv.id] end
|
||||
end
|
||||
end
|
||||
return { type = "records", pokemon = outSpecies, moves = outMoves }
|
||||
return { type = "records",
|
||||
pokemon = Fingerprint.records(data, "pokemon"),
|
||||
moves = Fingerprint.records(data, "moves") }
|
||||
end
|
||||
|
||||
-- a mon may cross the wire only if both peers rebuild it identically: the
|
||||
|
||||
+102
-12
@@ -95,8 +95,15 @@ function LauncherMods.deriveList(manifests, options)
|
||||
local byId, enabledSet = {}, {}
|
||||
for _, m in ipairs(ordered) do
|
||||
byId[m.id] = m
|
||||
-- missing entry means enabled, matching the loader's default
|
||||
if mods[m.id] ~= false then enabledSet[m.id] = true end
|
||||
-- missing entry means enabled, matching the loader -- except experimental
|
||||
-- mods, which stay off until the player opts in
|
||||
if mods[m.id] == false then
|
||||
-- stay off
|
||||
elseif mods[m.id] == true then
|
||||
enabledSet[m.id] = true
|
||||
elseif not m.experimental then
|
||||
enabledSet[m.id] = true
|
||||
end
|
||||
end
|
||||
|
||||
local out = {}
|
||||
@@ -104,16 +111,19 @@ function LauncherMods.deriveList(manifests, options)
|
||||
local enabled = enabledSet[m.id] == true
|
||||
local status, detail = statusFor(byId, m.id, enabledSet, enabled)
|
||||
local raw = m.raw or {}
|
||||
local badge = tostring(raw.category or m.profile or "MOD"):upper()
|
||||
if m.experimental then badge = "EXPERIMENTAL" end
|
||||
out[#out + 1] = {
|
||||
id = m.id,
|
||||
name = m.name or m.id,
|
||||
version = m.version,
|
||||
-- category, then profile, then a generic fallback -- uppercased
|
||||
badge = tostring(raw.category or m.profile or "MOD"):upper(),
|
||||
badge = badge,
|
||||
description = m.description or "",
|
||||
enabled = enabled,
|
||||
status = status,
|
||||
statusDetail = detail,
|
||||
github = m.github,
|
||||
experimental = m.experimental == true,
|
||||
}
|
||||
end
|
||||
return out
|
||||
@@ -213,7 +223,9 @@ local function discover()
|
||||
for _, name in ipairs(fs.getDirectoryItems("mods")) do
|
||||
local path = "mods/" .. name
|
||||
local info = fs.getInfo(path)
|
||||
if info and info.type == "directory" then
|
||||
-- a dev-linked mod dir (ln -s) reports type "symlink" even with
|
||||
-- setSymlinksEnabled(true); see the matching note in Loader:_discover.
|
||||
if info and (info.type == "directory" or info.type == "symlink") then
|
||||
local raw = fs.read(path .. "/manifest.json")
|
||||
if raw then
|
||||
local manifest = decodeManifest(raw, path)
|
||||
@@ -231,8 +243,15 @@ end
|
||||
-- options.mods enable-state the loader persists, so a toggle here is what the
|
||||
-- game sees on its next boot.
|
||||
function LauncherMods.list()
|
||||
local options = SaveData.loadOptions()
|
||||
return LauncherMods.deriveList(discover(), options)
|
||||
local ok, result = pcall(function()
|
||||
local options = SaveData.loadOptions()
|
||||
return LauncherMods.deriveList(discover(), options)
|
||||
end)
|
||||
if not ok then
|
||||
-- a single bad options/mod file must not blank the launcher
|
||||
return {}
|
||||
end
|
||||
return result or {}
|
||||
end
|
||||
|
||||
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
|
||||
@@ -454,13 +473,22 @@ function LauncherMods.strays() return scanStrays(false) end
|
||||
-- on the player's behalf is not this function's call to make.
|
||||
function LauncherMods.adoptStrays() return scanStrays(true) end
|
||||
|
||||
-- installZip(source) -> true, id | nil, errString
|
||||
-- installZip(source [, opts]) -> true, id | nil, errString
|
||||
-- source is an external path or a love DroppedFile. The archive is validated
|
||||
-- BEFORE anything is copied; every path unmounts and clears the staged temp
|
||||
-- file, and a failed copy rolls its partial tree back. A dropped file outside
|
||||
-- the save dir is staged into a save-dir temp first, because
|
||||
-- love.filesystem.mount only reaches a save-directory-relative path.
|
||||
function LauncherMods.installZip(source)
|
||||
-- opts.replace = true uninstalls an existing same-id mod first (updates /
|
||||
-- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs.
|
||||
function LauncherMods.installZip(source, opts)
|
||||
local ok, result, err = pcall(LauncherMods._installZipInner, source, opts)
|
||||
if not ok then return nil, "import failed: " .. tostring(result) end
|
||||
return result, err
|
||||
end
|
||||
|
||||
function LauncherMods._installZipInner(source, opts)
|
||||
opts = opts or {}
|
||||
if not (love and love.filesystem) then
|
||||
return nil, "mod install needs LOVE"
|
||||
end
|
||||
@@ -501,12 +529,24 @@ function LauncherMods.installZip(source)
|
||||
cleanup()
|
||||
return nil, "invalid mod manifest: " .. tostring(manifestErr)
|
||||
end
|
||||
if opts.expectId and manifest.id ~= opts.expectId then
|
||||
cleanup()
|
||||
return nil, ("zip is for '%s', expected '%s'")
|
||||
:format(manifest.id, opts.expectId)
|
||||
end
|
||||
|
||||
-- reject a duplicate before touching the mods tree
|
||||
local dest = "mods/" .. manifest.id
|
||||
if fs.getInfo(dest) then
|
||||
cleanup()
|
||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||
if not opts.replace then
|
||||
cleanup()
|
||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||
end
|
||||
-- drop the old tree before copy; enable-flag is preserved (uninstall
|
||||
-- would clear it, which would surprise an update)
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
removeTree(dest)
|
||||
CacheFs.prefix = savedPrefix
|
||||
end
|
||||
|
||||
-- CacheFs.prefix steers ROM-cache writes into a version subtree (blue/...);
|
||||
@@ -529,6 +569,56 @@ function LauncherMods.installZip(source)
|
||||
return true, manifest.id
|
||||
end
|
||||
|
||||
-- Install (or replace) a mod from a GitHub release zip URL.
|
||||
-- Returns true, version | nil, errString. Soft-fails: download / install /
|
||||
-- cleanup errors never throw into the launcher UI.
|
||||
function LauncherMods.installFromRelease(modId, release)
|
||||
local ok, result, err = pcall(function()
|
||||
if type(modId) ~= "string" or modId == "" then
|
||||
return nil, "missing mod id"
|
||||
end
|
||||
if type(release) ~= "table" or not release.zip or not release.zip.url then
|
||||
return nil, "release has no downloadable .zip"
|
||||
end
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local tmpName = ("mod_update_%s_%s.zip"):format(
|
||||
tostring(modId), tostring(release.version or os.time()))
|
||||
local localPath, dlErr = ModUpdate.downloadZip(release.zip.url, tmpName)
|
||||
if not localPath then return nil, dlErr end
|
||||
local installed, res = LauncherMods.installZip(localPath, {
|
||||
replace = true, expectId = modId,
|
||||
})
|
||||
pcall(love.filesystem.remove, localPath)
|
||||
if not installed then return nil, res end
|
||||
return true, release.version or res
|
||||
end)
|
||||
if not ok then return nil, "install failed: " .. tostring(result) end
|
||||
return result, err
|
||||
end
|
||||
|
||||
-- Install a mod listed in a community index (src/mods/ModIndex.lua).
|
||||
-- The index only ever tells us WHERE the zip is; resolving that URL is
|
||||
-- ModIndex's job and installing it is installFromRelease's, so this is the
|
||||
-- seam between them and nothing about the archive is special-cased. expectId
|
||||
-- comes from the listing, so a feed that points an entry at somebody else's
|
||||
-- zip fails the manifest check instead of installing the wrong mod.
|
||||
-- Returns true, version | nil, errString.
|
||||
function LauncherMods.installFromIndex(entry)
|
||||
local ok, result, err = pcall(function()
|
||||
if type(entry) ~= "table" or type(entry.id) ~= "string" then
|
||||
return nil, "index entry has no mod id"
|
||||
end
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
local release, why = ModIndex.releaseFor(entry)
|
||||
if not release then
|
||||
return nil, why or "this mod cannot be installed from the index"
|
||||
end
|
||||
return LauncherMods.installFromRelease(entry.id, release)
|
||||
end)
|
||||
if not ok then return nil, "install failed: " .. tostring(result) end
|
||||
return result, err
|
||||
end
|
||||
|
||||
-- uninstall(id) -> true | nil, errString
|
||||
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
|
||||
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
|
||||
|
||||
+17
-1
@@ -206,7 +206,11 @@ function Loader:_discover()
|
||||
for _, name in ipairs(self.fs.getDirectoryItems(root)) do
|
||||
local path = root .. "/" .. name
|
||||
local info = self.fs.getInfo(path)
|
||||
if info and info.type == "directory" then
|
||||
-- a dev-linked mod dir (ln -s) reports type "symlink" even with
|
||||
-- setSymlinksEnabled(true) -- PhysFS never resolves the symlink's
|
||||
-- own getInfo, only traversal into it. readManifest below still
|
||||
-- correctly no-ops on a symlink that isn't a directory.
|
||||
if info and (info.type == "directory" or info.type == "symlink") then
|
||||
local manifest, err = readManifest(self.fs, path)
|
||||
if manifest then
|
||||
if self.mods[manifest.id] then
|
||||
@@ -834,6 +838,18 @@ function Loader:load(data)
|
||||
require("src.mods.Builtins").install(self.content, data)
|
||||
self:_loadState()
|
||||
self:_discover()
|
||||
-- Experimental mods stay off until the player opts in: a missing
|
||||
-- options.mods entry normally means enabled, but experimental flips that.
|
||||
do
|
||||
local options = SaveData.loadOptions(self.fs)
|
||||
local modsOpt = options.mods or {}
|
||||
for id, mod in pairs(self.mods) do
|
||||
if not self.disabled[id] and modsOpt[id] == nil
|
||||
and mod.manifest.experimental then
|
||||
self.disabled[id] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
for id, mod in pairs(self.mods) do
|
||||
mod.enabled = not self.disabled[id]
|
||||
mod.state = mod.enabled and "pending" or "disabled"
|
||||
|
||||
@@ -324,6 +324,12 @@ function ManagerState:detailRows(m)
|
||||
rows[#rows + 1] = { label = Strings("PERMISSIONS.."),
|
||||
action = function() self:goTo("permissions") end }
|
||||
end
|
||||
if m.github then
|
||||
rows[#rows + 1] = { inert = true, label = "GH " .. m.github }
|
||||
end
|
||||
if m.experimental then
|
||||
rows[#rows + 1] = { inert = true, label = "EXPERIMENTAL" }
|
||||
end
|
||||
if m.error then
|
||||
rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
|
||||
action = function() self:goTo("errors") end }
|
||||
@@ -607,13 +613,26 @@ function ManagerState:beginToggle(m)
|
||||
r = ManagerState.resolveToggle(self:manifestMap(), m.id, want,
|
||||
self:enabledSet())
|
||||
end
|
||||
if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then
|
||||
self:openBlocked(r)
|
||||
elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then
|
||||
self:openCascade(r, m, want)
|
||||
else
|
||||
self:commitToggle(r.apply)
|
||||
local function proceed()
|
||||
if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then
|
||||
self:openBlocked(r)
|
||||
elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then
|
||||
self:openCascade(r, m, want)
|
||||
else
|
||||
self:commitToggle(r.apply)
|
||||
end
|
||||
end
|
||||
-- Experimental mods ask once on enable; disable is silent.
|
||||
if want and m.experimental then
|
||||
self:openConfirm({
|
||||
"EXPERIMENTAL MOD",
|
||||
"THIS MOD IS MARKED",
|
||||
"EXPERIMENTAL.",
|
||||
"ENABLE ANYWAY?",
|
||||
}, proceed)
|
||||
return
|
||||
end
|
||||
proceed()
|
||||
end
|
||||
|
||||
function ManagerState:commitToggle(apply)
|
||||
|
||||
+49
-2
@@ -50,6 +50,42 @@ local function parseSpecs(list, field)
|
||||
return specs
|
||||
end
|
||||
|
||||
-- Optional GitHub repo for launcher auto-update / other-versions.
|
||||
-- Accepts "owner/repo" or a github.com URL; empty/absent means no updates.
|
||||
function Manifest.parseGithub(value)
|
||||
if value == nil or value == "" then return nil end
|
||||
assert(type(value) == "string", "github must be a string")
|
||||
local trimmed = value:match("^%s*(.-)%s*$") or value
|
||||
if trimmed == "" then return nil end
|
||||
local owner, repo = trimmed:match(
|
||||
"^https?://github%.com/([%w%._%-]+)/([%w%._%-]+)/?$")
|
||||
if not owner then
|
||||
owner, repo = trimmed:match(
|
||||
"^https?://github%.com/([%w%._%-]+)/([%w%._%-]+)%.git/?$")
|
||||
end
|
||||
if not owner then
|
||||
owner, repo = trimmed:match("^([%w%._%-]+)/([%w%._%-]+)$")
|
||||
end
|
||||
assert(owner and repo and owner ~= "" and repo ~= "",
|
||||
"github must be owner/repo or a github.com URL")
|
||||
repo = repo:gsub("%.git$", "")
|
||||
return owner .. "/" .. repo
|
||||
end
|
||||
|
||||
-- conflicts + incompatible (alias) merged, first-wins on duplicate ids
|
||||
local function mergeConflictLists(conflicts, incompatible)
|
||||
local seen, out = {}, {}
|
||||
for _, list in ipairs({ array(conflicts), array(incompatible) }) do
|
||||
for _, entry in ipairs(list) do
|
||||
if not seen[entry] then
|
||||
seen[entry] = true
|
||||
out[#out + 1] = entry
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Manifest.validate(raw, path)
|
||||
assert(type(raw) == "table", "manifest must be an object")
|
||||
assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"),
|
||||
@@ -86,6 +122,12 @@ function Manifest.validate(raw, path)
|
||||
assert(gameVersionOk, ("malformed game_version %q: %s")
|
||||
:format(tostring(raw.game_version), tostring(gameVersionErr)))
|
||||
|
||||
local github = Manifest.parseGithub(raw.github)
|
||||
|
||||
assert(raw.experimental == nil or type(raw.experimental) == "boolean",
|
||||
"experimental must be a boolean")
|
||||
local experimental = raw.experimental == true
|
||||
|
||||
-- overhauls and total conversions are assumed to move the link
|
||||
-- fingerprint unless the manifest says otherwise; content packs are not
|
||||
local affectsLink = profile ~= "content"
|
||||
@@ -97,6 +139,8 @@ function Manifest.validate(raw, path)
|
||||
return value
|
||||
end
|
||||
|
||||
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
|
||||
|
||||
return {
|
||||
id = raw.id,
|
||||
name = raw.name,
|
||||
@@ -106,13 +150,16 @@ function Manifest.validate(raw, path)
|
||||
priority = tonumber(raw.priority) or 0,
|
||||
dependencies = array(raw.dependencies),
|
||||
optional_dependencies = array(raw.optional_dependencies),
|
||||
conflicts = array(raw.conflicts),
|
||||
conflicts = conflicts,
|
||||
incompatible = array(raw.incompatible),
|
||||
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
|
||||
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
|
||||
conflictSpecs = parseSpecs(array(raw.conflicts), "conflicts"),
|
||||
conflictSpecs = parseSpecs(conflicts, "conflicts"),
|
||||
category = raw.category or "OTHER",
|
||||
game_version = raw.game_version,
|
||||
description = raw.description or "",
|
||||
github = github,
|
||||
experimental = experimental,
|
||||
profile = profile,
|
||||
affects_link = affectsLink,
|
||||
permissions = permissions,
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
-- Community mod index consumer (the "Find mods" launcher tab).
|
||||
--
|
||||
-- An index is metadata only: a published index.json feed listing mods that
|
||||
-- live in their authors' own repos. Nothing here clones or vendors the index
|
||||
-- repo -- the feed is the whole contract, and every install still goes through
|
||||
-- the same LauncherMods.installZip path an "Import mod .zip" does, so a listing
|
||||
-- buys a mod no trust it would not otherwise have.
|
||||
--
|
||||
-- Shape of the split mirrors src/mods/ModUpdate.lua, which this borrows its
|
||||
-- host I/O from: everything above "host I/O" is pure (no love, no filesystem,
|
||||
-- no network) so the engine tier can table-drive it, and the fetch/cache half
|
||||
-- reaches for curl and options.lua.
|
||||
--
|
||||
-- Sources are never added automatically. options.modIndexes is a player-built
|
||||
-- list -- adding an index is a deliberate act of trusting whoever publishes it,
|
||||
-- so the launcher ships with none and asks.
|
||||
--
|
||||
-- schema_version is a hard gate, not a hint: a bumped feed may reuse a field
|
||||
-- name for something else, so an unknown version is refused outright rather
|
||||
-- than parsed hopefully.
|
||||
|
||||
local ModIndex = {}
|
||||
|
||||
-- The feed is rebuilt on every push and refreshed nightly, so a day-old copy
|
||||
-- is the worst a cached listing can be. ModUpdate's six hours is tuned for a
|
||||
-- single repo's releases; a whole index is heavier and changes more slowly.
|
||||
ModIndex.CACHE_TTL = 24 * 60 * 60
|
||||
ModIndex.SCHEMA_VERSION = 1
|
||||
|
||||
-- ------- pure: source resolution
|
||||
|
||||
local function trim(s)
|
||||
return (tostring(s):gsub("^%s+", ""):gsub("%s+$", ""))
|
||||
end
|
||||
|
||||
-- Split "owner/repo" out of the several ways a player can name a GitHub repo.
|
||||
local function githubSlug(url)
|
||||
local owner, repo = url:match("^https?://github%.com/([%w%-%.]+)/([%w%-%.]+)")
|
||||
if not owner then
|
||||
owner, repo = url:match("^([%w%-%.]+)/([%w%-%.]+)$")
|
||||
end
|
||||
if not owner then return nil end
|
||||
repo = repo:gsub("%.git$", "")
|
||||
return owner, repo
|
||||
end
|
||||
|
||||
-- resolveSource(input) -> { feed, base, fallback, label } | nil, err
|
||||
--
|
||||
-- Players paste whichever URL they happened to have, so all four shapes of the
|
||||
-- same index resolve to one source: the Pages root, the feed file itself, the
|
||||
-- GitHub repo page, or a bare "owner/repo". `base` is what relative thumbnail
|
||||
-- and description_url paths resolve against, and it always keeps its trailing
|
||||
-- slash so joinUrl can stay a concatenation.
|
||||
--
|
||||
-- The raw.githubusercontent fallback exists because Pages deploys lag a push
|
||||
-- by a minute or two; it is only ever consulted when the feed fetch fails.
|
||||
function ModIndex.resolveSource(input)
|
||||
if type(input) ~= "string" then return nil, "missing index URL" end
|
||||
local url = trim(input)
|
||||
if url == "" then return nil, "missing index URL" end
|
||||
|
||||
local owner, repo = githubSlug(url)
|
||||
if owner then
|
||||
return {
|
||||
feed = ("https://%s.github.io/%s/data/index.json"):format(owner, repo),
|
||||
base = ("https://%s.github.io/%s/"):format(owner, repo),
|
||||
fallback = ("https://raw.githubusercontent.com/%s/%s/main/site/data/index.json")
|
||||
:format(owner, repo),
|
||||
label = owner .. "/" .. repo,
|
||||
}
|
||||
end
|
||||
|
||||
if not url:match("^https?://") then
|
||||
return nil, "index must be an http(s) URL or owner/repo"
|
||||
end
|
||||
|
||||
-- A feed URL names the file; the Pages root is what is left once the
|
||||
-- "data/index.json" tail comes off (any other .json keeps only its folder).
|
||||
if url:match("%.json$") then
|
||||
local base = url:match("^(.*/)data/index%.json$") or url:match("^(.*/)")
|
||||
return { feed = url, base = base or url, label = ModIndex.labelFor(base or url) }
|
||||
end
|
||||
|
||||
local base = url:match("/$") and url or (url .. "/")
|
||||
return {
|
||||
feed = base .. "data/index.json",
|
||||
base = base,
|
||||
label = ModIndex.labelFor(base),
|
||||
}
|
||||
end
|
||||
|
||||
-- A short human name for a source row: "owner/repo" for a Pages host, else the
|
||||
-- host plus first path segment. Only ever cosmetic.
|
||||
function ModIndex.labelFor(url)
|
||||
url = tostring(url or "")
|
||||
local owner, repo = url:match("^https?://([%w%-%.]+)%.github%.io/([%w%-%.]+)/")
|
||||
if owner then return owner .. "/" .. repo end
|
||||
local host, first = url:match("^https?://([^/]+)/([^/]*)")
|
||||
if host and first and first ~= "" then return host .. "/" .. first end
|
||||
return host or url
|
||||
end
|
||||
|
||||
-- joinUrl(base, rel) -> absolute URL | nil
|
||||
-- Relative feed paths resolve against the Pages root; an already-absolute one
|
||||
-- is handed back untouched (the schema allows either), and anything else --
|
||||
-- nil, "", a non-string -- is simply absent rather than an error, because a
|
||||
-- missing thumbnail is not a broken index.
|
||||
function ModIndex.joinUrl(base, rel)
|
||||
if type(rel) ~= "string" or rel == "" then return nil end
|
||||
if rel:match("^https?://") then return rel end
|
||||
if type(base) ~= "string" or base == "" then return nil end
|
||||
if not base:match("/$") then base = base .. "/" end
|
||||
return base .. (rel:gsub("^/", ""))
|
||||
end
|
||||
|
||||
-- ------- pure: feed parsing
|
||||
|
||||
local function str(v)
|
||||
return type(v) == "string" and v or nil
|
||||
end
|
||||
|
||||
local function strArray(v)
|
||||
local out = {}
|
||||
if type(v) == "table" then
|
||||
for _, entry in ipairs(v) do
|
||||
if type(entry) == "string" then out[#out + 1] = entry end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Decode one release blob from the feed's `latest` field into the same shape
|
||||
-- ModUpdate.parseRelease produces, so LauncherMods.installFromRelease takes it
|
||||
-- without a translation layer.
|
||||
local function parseLatest(raw)
|
||||
if type(raw) ~= "table" then return nil end
|
||||
local zip = nil
|
||||
if type(raw.zip) == "table" and str(raw.zip.url) then
|
||||
zip = { name = str(raw.zip.name), url = raw.zip.url,
|
||||
size = tonumber(raw.zip.size) }
|
||||
end
|
||||
return {
|
||||
version = str(raw.version),
|
||||
tag = str(raw.tag),
|
||||
name = str(raw.name),
|
||||
prerelease = raw.prerelease == true,
|
||||
published_at = str(raw.published_at),
|
||||
zip = zip,
|
||||
}
|
||||
end
|
||||
|
||||
local function parseEntry(raw)
|
||||
if type(raw) ~= "table" or not str(raw.id) then return nil end
|
||||
return {
|
||||
folder = str(raw.folder),
|
||||
id = raw.id,
|
||||
title = str(raw.title) or raw.id,
|
||||
author = str(raw.author),
|
||||
version = str(raw.version),
|
||||
summary = str(raw.summary) or "",
|
||||
categories = strArray(raw.categories),
|
||||
tags = strArray(raw.tags),
|
||||
license = str(raw.license),
|
||||
repo = str(raw.repo),
|
||||
github = str(raw.github),
|
||||
downloadURL = str(raw.downloadURL),
|
||||
api = tonumber(raw.api),
|
||||
game_version = str(raw.game_version),
|
||||
profile = str(raw.profile),
|
||||
affects_link = raw.affects_link == true,
|
||||
experimental = raw.experimental == true,
|
||||
permissions = strArray(raw.permissions),
|
||||
dependencies = raw.dependencies,
|
||||
conflicts = raw.conflicts,
|
||||
thumbnail = str(raw.thumbnail),
|
||||
description_url = str(raw.description_url),
|
||||
latest = parseLatest(raw.latest),
|
||||
update_check = str(raw.update_check) or "pending",
|
||||
}
|
||||
end
|
||||
|
||||
-- parse(jsonText [, Json]) -> { schemaVersion, generatedAt, categories, mods }
|
||||
-- | nil, err
|
||||
-- Never throws: a truncated download, an HTML error page, or a feed from a
|
||||
-- future schema all come back as a message the panel can print.
|
||||
function ModIndex.parse(jsonText, Json)
|
||||
local ok, result, err = pcall(function()
|
||||
Json = Json or require("src.link.Json")
|
||||
local doc, decodeErr = Json.decode(jsonText)
|
||||
if type(doc) ~= "table" then
|
||||
return nil, decodeErr or "index.json is not an object"
|
||||
end
|
||||
local schema = tonumber(doc.schema_version)
|
||||
if schema == nil then
|
||||
return nil, "index.json has no schema_version"
|
||||
end
|
||||
if schema ~= ModIndex.SCHEMA_VERSION then
|
||||
return nil, ("index schema %d is not supported (this build reads %d)")
|
||||
:format(schema, ModIndex.SCHEMA_VERSION)
|
||||
end
|
||||
if type(doc.mods) ~= "table" then
|
||||
return nil, "index.json has no mods array"
|
||||
end
|
||||
local mods = {}
|
||||
for _, raw in ipairs(doc.mods) do
|
||||
local entry = parseEntry(raw)
|
||||
if entry then mods[#mods + 1] = entry end
|
||||
end
|
||||
return {
|
||||
schemaVersion = schema,
|
||||
generatedAt = str(doc.generated_at),
|
||||
categories = strArray(doc.categories),
|
||||
mods = mods,
|
||||
}
|
||||
end)
|
||||
if not ok then return nil, "could not read the index: " .. tostring(result) end
|
||||
return result, err
|
||||
end
|
||||
|
||||
-- ------- pure: install resolution
|
||||
|
||||
-- installUrl(entry) -> url, kind | nil, reason
|
||||
--
|
||||
-- The same order the engine's zip import already implies: a verified release
|
||||
-- asset first, then the author's fixed downloadURL. A GitHub source-archive
|
||||
-- URL is never invented -- codeload gives you the repo, not the built mod, and
|
||||
-- the folder layout would be wrong even when the download succeeds.
|
||||
function ModIndex.installUrl(entry)
|
||||
if type(entry) ~= "table" then return nil, "no entry" end
|
||||
if entry.update_check == "ok" and entry.latest and entry.latest.zip
|
||||
and entry.latest.zip.url then
|
||||
return entry.latest.zip.url, "release"
|
||||
end
|
||||
if entry.downloadURL and entry.downloadURL ~= "" then
|
||||
return entry.downloadURL, "download"
|
||||
end
|
||||
if entry.update_check == "off" then
|
||||
return nil, "the author does not publish installable releases"
|
||||
end
|
||||
if entry.update_check == "no installable release" then
|
||||
return nil, "no release with a .zip asset yet"
|
||||
end
|
||||
if type(entry.update_check) == "string"
|
||||
and entry.update_check:match("^error") then
|
||||
return nil, entry.update_check
|
||||
end
|
||||
return nil, "nothing installable listed"
|
||||
end
|
||||
|
||||
function ModIndex.canInstall(entry)
|
||||
return ModIndex.installUrl(entry) ~= nil
|
||||
end
|
||||
|
||||
-- The version to show on a card: the release the index resolved when it could
|
||||
-- reach GitHub, else whatever meta.json declared.
|
||||
function ModIndex.displayVersion(entry)
|
||||
if type(entry) ~= "table" then return "?" end
|
||||
if entry.update_check == "ok" and entry.latest and entry.latest.version then
|
||||
return entry.latest.version
|
||||
end
|
||||
return entry.version or "?"
|
||||
end
|
||||
|
||||
-- The release table LauncherMods.installFromRelease wants. A downloadURL
|
||||
-- entry has no release behind it, so one is synthesised around the URL; the
|
||||
-- installer still validates the manifest inside and still refuses a zip whose
|
||||
-- id is not the one being installed.
|
||||
function ModIndex.releaseFor(entry)
|
||||
local url, kind = ModIndex.installUrl(entry)
|
||||
if not url then return nil, kind end
|
||||
if kind == "release" then return entry.latest end
|
||||
return {
|
||||
version = ModIndex.displayVersion(entry),
|
||||
zip = { url = url, name = entry.id .. ".zip" },
|
||||
}
|
||||
end
|
||||
|
||||
-- ------- pure: compatibility
|
||||
|
||||
-- compatIssues(entry, ctx) -> array of { level, text }
|
||||
--
|
||||
-- Soft gate by design: an index entry is metadata an author wrote, possibly
|
||||
-- months ago, and hiding a mod because a range looks wrong is how a working
|
||||
-- mod becomes invisible. Everything here warns; the confirm dialog shows the
|
||||
-- list and the player decides. ctx carries { modApi, engineVersion,
|
||||
-- installed = { id -> version }, enabled = { id -> true } }.
|
||||
function ModIndex.compatIssues(entry, ctx)
|
||||
local out = {}
|
||||
if type(entry) ~= "table" then return out end
|
||||
ctx = ctx or {}
|
||||
local function warn(text) out[#out + 1] = { level = "warn", text = text } end
|
||||
|
||||
local modApi = tonumber(ctx.modApi)
|
||||
if entry.api and modApi and entry.api > modApi then
|
||||
warn(("Needs mod API %d; this build provides %d")
|
||||
:format(entry.api, modApi))
|
||||
end
|
||||
|
||||
if entry.game_version and ctx.engineVersion then
|
||||
local okSemver, Semver = pcall(require, "src.mods.Semver")
|
||||
if okSemver and not Semver.satisfies(ctx.engineVersion, entry.game_version) then
|
||||
warn(("Needs engine %s (have %s)")
|
||||
:format(entry.game_version, ctx.engineVersion))
|
||||
end
|
||||
end
|
||||
|
||||
if entry.profile and entry.profile ~= "content" then
|
||||
warn(("Profile '%s' changes engine behaviour beyond content")
|
||||
:format(entry.profile))
|
||||
end
|
||||
if entry.affects_link then
|
||||
warn("Changes link play; both sides need the same mods")
|
||||
end
|
||||
if entry.experimental then
|
||||
warn("Marked experimental by its author")
|
||||
end
|
||||
|
||||
for _, name in ipairs(entry.permissions or {}) do
|
||||
warn("Requests permission: " .. name)
|
||||
end
|
||||
|
||||
-- dependencies / conflicts arrive as the manifest's own vocabulary: either
|
||||
-- an array of "id" / "id@<range>" strings or an id -> range map.
|
||||
local installed = ctx.installed or {}
|
||||
local function eachSpec(spec, fn)
|
||||
if type(spec) ~= "table" then return end
|
||||
for k, v in pairs(spec) do
|
||||
if type(k) == "number" and type(v) == "string" then
|
||||
local id, range = v:match("^([^@]+)@(.+)$")
|
||||
fn(id or v, range)
|
||||
elseif type(k) == "string" then
|
||||
fn(k, type(v) == "string" and v or nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
eachSpec(entry.dependencies, function(id, range)
|
||||
if installed[id] == nil then
|
||||
warn("Needs " .. id .. (range and (" " .. range) or "") .. " (not installed)")
|
||||
end
|
||||
end)
|
||||
eachSpec(entry.conflicts, function(id)
|
||||
if installed[id] ~= nil then
|
||||
warn("Conflicts with installed " .. id)
|
||||
end
|
||||
end)
|
||||
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------- pure: search / filter
|
||||
|
||||
local function haystack(entry)
|
||||
return (tostring(entry.title or "") .. " " .. tostring(entry.author or "")
|
||||
.. " " .. tostring(entry.summary or "") .. " " .. tostring(entry.id or ""))
|
||||
:lower()
|
||||
end
|
||||
|
||||
-- matches(entry, query) -> bool. Every whitespace-separated term must appear
|
||||
-- somewhere in title / author / summary / id, so typing more narrows.
|
||||
function ModIndex.matches(entry, query)
|
||||
if type(query) ~= "string" or trim(query) == "" then return true end
|
||||
local hay = haystack(entry)
|
||||
for term in trim(query):lower():gmatch("%S+") do
|
||||
if not hay:find(term, 1, true) then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- filter(mods, opts) -> a new array. opts = { query, category, tag }.
|
||||
-- Category and tag compare case-insensitively; feed order (already sorted by
|
||||
-- title) is preserved.
|
||||
function ModIndex.filter(mods, opts)
|
||||
opts = opts or {}
|
||||
local want = opts.category and tostring(opts.category):lower() or nil
|
||||
local wantTag = opts.tag and tostring(opts.tag):lower() or nil
|
||||
local out = {}
|
||||
for _, entry in ipairs(mods or {}) do
|
||||
local keep = ModIndex.matches(entry, opts.query)
|
||||
if keep and want then
|
||||
keep = false
|
||||
for _, c in ipairs(entry.categories or {}) do
|
||||
if tostring(c):lower() == want then keep = true; break end
|
||||
end
|
||||
end
|
||||
if keep and wantTag then
|
||||
keep = false
|
||||
for _, t in ipairs(entry.tags or {}) do
|
||||
if tostring(t):lower() == wantTag then keep = true; break end
|
||||
end
|
||||
end
|
||||
if keep then out[#out + 1] = entry end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Every category actually used by a feed, in the feed's declared order, with
|
||||
-- anything an entry names that the header forgot appended. Drives the filter
|
||||
-- row without hard-coding the vocabulary.
|
||||
function ModIndex.categoriesIn(index)
|
||||
local out, seen = {}, {}
|
||||
if type(index) ~= "table" then return out end
|
||||
local used = {}
|
||||
for _, entry in ipairs(index.mods or {}) do
|
||||
for _, c in ipairs(entry.categories or {}) do used[c] = true end
|
||||
end
|
||||
for _, c in ipairs(index.categories or {}) do
|
||||
if used[c] and not seen[c] then seen[c] = true; out[#out + 1] = c end
|
||||
end
|
||||
for _, entry in ipairs(index.mods or {}) do
|
||||
for _, c in ipairs(entry.categories or {}) do
|
||||
if not seen[c] then seen[c] = true; out[#out + 1] = c end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------- sources (options.modIndexes)
|
||||
|
||||
local function loadOptions()
|
||||
return require("src.core.SaveData").loadOptions()
|
||||
end
|
||||
|
||||
-- The player's index list, normalised. Rows are { url, feed, base, fallback,
|
||||
-- label }; `url` is what they typed, kept so the row reads back the way they
|
||||
-- entered it.
|
||||
function ModIndex.sources()
|
||||
local ok, opts = pcall(loadOptions)
|
||||
if not ok or type(opts) ~= "table" then return {} end
|
||||
local out = {}
|
||||
for _, row in ipairs(opts.modIndexes or {}) do
|
||||
if type(row) == "table" and type(row.feed) == "string" then
|
||||
out[#out + 1] = row
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- addSource(input) -> row | nil, err. Idempotent on the resolved feed URL, so
|
||||
-- pasting the repo page and the Pages root in either order adds one source.
|
||||
function ModIndex.addSource(input)
|
||||
local source, err = ModIndex.resolveSource(input)
|
||||
if not source then return nil, err end
|
||||
local ok, result, addErr = pcall(function()
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local opts = loadOptions()
|
||||
opts.modIndexes = opts.modIndexes or {}
|
||||
for _, row in ipairs(opts.modIndexes) do
|
||||
if row.feed == source.feed then
|
||||
return nil, "that index is already added"
|
||||
end
|
||||
end
|
||||
source.url = trim(input)
|
||||
opts.modIndexes[#opts.modIndexes + 1] = source
|
||||
SaveData.saveOptions(opts)
|
||||
return source
|
||||
end)
|
||||
if not ok then return nil, "could not save the index: " .. tostring(result) end
|
||||
return result, addErr
|
||||
end
|
||||
|
||||
-- removeSource(feed) -> true | nil, err. Drops the cached listing with it:
|
||||
-- keeping a feed's mods around after its source is gone is how a stale card
|
||||
-- outlives the index it came from.
|
||||
function ModIndex.removeSource(feed)
|
||||
if type(feed) ~= "string" or feed == "" then return nil, "missing index" end
|
||||
local ok, result = pcall(function()
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local opts = loadOptions()
|
||||
local kept, found = {}, false
|
||||
for _, row in ipairs(opts.modIndexes or {}) do
|
||||
if row.feed == feed then found = true else kept[#kept + 1] = row end
|
||||
end
|
||||
if not found then return nil end
|
||||
opts.modIndexes = kept
|
||||
if type(opts.modIndexCache) == "table" then opts.modIndexCache[feed] = nil end
|
||||
SaveData.saveOptions(opts)
|
||||
return true
|
||||
end)
|
||||
if not ok then return nil, tostring(result) end
|
||||
if not result then return nil, "that index is not in the list" end
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------- cache (options.modIndexCache[feed])
|
||||
|
||||
function ModIndex.readCache(feed)
|
||||
if type(feed) ~= "string" or feed == "" then return nil end
|
||||
local ok, opts = pcall(loadOptions)
|
||||
if not ok or type(opts) ~= "table" then return nil end
|
||||
local entry = opts.modIndexCache and opts.modIndexCache[feed]
|
||||
if type(entry) ~= "table" or type(entry.checkedAt) ~= "number" then
|
||||
return nil
|
||||
end
|
||||
if type(entry.mods) ~= "table" then return nil end
|
||||
return entry
|
||||
end
|
||||
|
||||
function ModIndex.cacheFresh(entry, now, ttl)
|
||||
now = now or os.time()
|
||||
ttl = ttl or ModIndex.CACHE_TTL
|
||||
return entry ~= nil and type(entry.checkedAt) == "number"
|
||||
and (now - entry.checkedAt) < ttl
|
||||
end
|
||||
|
||||
function ModIndex.writeCache(feed, index)
|
||||
if type(feed) ~= "string" or feed == "" then return false end
|
||||
local ok = pcall(function()
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local opts = loadOptions()
|
||||
opts.modIndexCache = opts.modIndexCache or {}
|
||||
opts.modIndexCache[feed] = {
|
||||
checkedAt = os.time(),
|
||||
generatedAt = index.generatedAt,
|
||||
categories = index.categories,
|
||||
mods = index.mods,
|
||||
}
|
||||
SaveData.saveOptions(opts)
|
||||
end)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ------- host I/O (curl, via ModUpdate's shell plumbing)
|
||||
|
||||
local function shq(s)
|
||||
s = tostring(s)
|
||||
if love and love.system and love.system.getOS
|
||||
and love.system.getOS() == "Windows" then
|
||||
return '"' .. s:gsub('"', '') .. '"'
|
||||
end
|
||||
return "'" .. s:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
-- Plain GET returning the body. No GitHub Accept header: the feed and the
|
||||
-- description markdown are static files on Pages, and the public feed is
|
||||
-- explicitly unauthenticated.
|
||||
function ModIndex.httpGet(url)
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
if not ModUpdate.haveCurl() then
|
||||
return nil, "curl is not available on this platform"
|
||||
end
|
||||
local HostShell = require("src.core.HostShell")
|
||||
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
|
||||
.. "-H " .. shq("User-Agent: gen1recomp-mod-index") .. " "
|
||||
.. shq(url)
|
||||
local pipeOk, pipe = pcall(HostShell.popen, cmd)
|
||||
if not pipeOk or not pipe then return nil, "could not run curl" end
|
||||
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||
pcall(function() pipe:close() end)
|
||||
if not readOk then return nil, "fetch failed: " .. tostring(out) end
|
||||
if not out or out == "" then return nil, "empty response from " .. url end
|
||||
return out
|
||||
end
|
||||
|
||||
-- fetch(source, opts) -> index, err, meta
|
||||
-- index is the parse() table; meta is { fromCache, stale }. opts.force skips
|
||||
-- the 24h cache. A failed live fetch falls back to whatever is cached, marked
|
||||
-- stale, so going offline degrades the listing rather than emptying it.
|
||||
function ModIndex.fetch(source, opts)
|
||||
opts = opts or {}
|
||||
if type(source) ~= "table" or type(source.feed) ~= "string" then
|
||||
return nil, "missing index source"
|
||||
end
|
||||
local feed = source.feed
|
||||
|
||||
local function cached(stale)
|
||||
local entry = ModIndex.readCache(feed)
|
||||
if not entry then return nil end
|
||||
return {
|
||||
schemaVersion = ModIndex.SCHEMA_VERSION,
|
||||
generatedAt = entry.generatedAt,
|
||||
categories = entry.categories or {},
|
||||
mods = entry.mods or {},
|
||||
}, nil, { fromCache = true, stale = stale, checkedAt = entry.checkedAt }
|
||||
end
|
||||
|
||||
if not opts.force then
|
||||
local entry = ModIndex.readCache(feed)
|
||||
if ModIndex.cacheFresh(entry) then return cached(false) end
|
||||
end
|
||||
|
||||
local body, err = ModIndex.httpGet(feed)
|
||||
-- Pages deploys trail a push; the raw mirror is the same file, so a feed
|
||||
-- that 404s right after a release is worth one retry elsewhere before it
|
||||
-- counts as an outage.
|
||||
if not body and source.fallback then
|
||||
body = ModIndex.httpGet(source.fallback)
|
||||
end
|
||||
if not body then
|
||||
local index, _, meta = cached(true)
|
||||
if index then return index, nil, meta end
|
||||
return nil, err
|
||||
end
|
||||
|
||||
local index, parseErr = ModIndex.parse(body)
|
||||
if not index then
|
||||
local stale, _, meta = cached(true)
|
||||
if stale then return stale, parseErr, meta end
|
||||
return nil, parseErr
|
||||
end
|
||||
ModIndex.writeCache(feed, index)
|
||||
return index, nil, { fromCache = false, checkedAt = os.time() }
|
||||
end
|
||||
|
||||
-- Fetch a description_url / any index-relative text file. Returns the raw
|
||||
-- markdown; callers run it through ModUpdate.cleanBody for display.
|
||||
function ModIndex.fetchText(url)
|
||||
if type(url) ~= "string" or url == "" then return nil, "no description" end
|
||||
return ModIndex.httpGet(url)
|
||||
end
|
||||
|
||||
-- Download a thumbnail into the save directory and return the love.filesystem
|
||||
-- relative path. Reuses ModUpdate.downloadZip, which is a plain curl -o with
|
||||
-- a non-empty-file check -- nothing in it is zip-specific.
|
||||
function ModIndex.downloadThumbnail(url, modId)
|
||||
if type(url) ~= "string" or url == "" then return nil, "no thumbnail" end
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local ext = url:match("%.(%a%a%a?%a?)$") or "png"
|
||||
local name = ("mod_thumb_%s.%s"):format(tostring(modId):gsub("[^%w%-_]", "_"), ext)
|
||||
return ModUpdate.downloadZip(url, name)
|
||||
end
|
||||
|
||||
return ModIndex
|
||||
@@ -0,0 +1,361 @@
|
||||
-- GitHub release helpers for mod auto-update / other-versions.
|
||||
-- Pure parsing is love-free; fetch/download use HostShell + curl when available.
|
||||
-- Release lists are cached in options.modUpdateCache for CACHE_TTL seconds
|
||||
-- (default 6 hours). The launcher owns UI and install.
|
||||
|
||||
local ModUpdate = {}
|
||||
|
||||
ModUpdate.CACHE_TTL = 6 * 60 * 60 -- six hours
|
||||
|
||||
local function stripV(tag)
|
||||
return (tostring(tag):gsub("^[vV]", ""))
|
||||
end
|
||||
|
||||
-- Prefer "<id>-<version>.zip", then any "<id>*.zip", then the first .zip.
|
||||
function ModUpdate.pickZipAsset(assets, modId, version)
|
||||
if type(assets) ~= "table" then return nil end
|
||||
local prefer = nil
|
||||
if modId and version then
|
||||
prefer = tostring(modId) .. "-" .. tostring(version) .. ".zip"
|
||||
end
|
||||
local idPrefix, idPrefixZip, anyZip = nil, nil, nil
|
||||
if modId then idPrefix = tostring(modId):lower() end
|
||||
for _, a in ipairs(assets) do
|
||||
if type(a) == "table" and type(a.name) == "string" then
|
||||
local name = a.name
|
||||
if name:lower():match("%.zip$") then
|
||||
local row = {
|
||||
name = name,
|
||||
url = a.browser_download_url,
|
||||
size = tonumber(a.size),
|
||||
}
|
||||
if prefer and name == prefer then return row end
|
||||
if idPrefix and not idPrefixZip
|
||||
and name:lower():find(idPrefix, 1, true) == 1 then
|
||||
idPrefixZip = row
|
||||
end
|
||||
if not anyZip then anyZip = row end
|
||||
end
|
||||
end
|
||||
end
|
||||
return idPrefixZip or anyZip
|
||||
end
|
||||
|
||||
-- Byte-length cut that never lands mid UTF-8 codepoint.
|
||||
local function utf8Cut(s, maxBytes)
|
||||
if type(s) ~= "string" or maxBytes <= 0 then return "" end
|
||||
if #s <= maxBytes then return s end
|
||||
s = s:sub(1, maxBytes)
|
||||
-- Drop trailing continuation bytes (10xxxxxx).
|
||||
while #s > 0 do
|
||||
local b = s:byte(#s)
|
||||
if b < 0x80 or b >= 0xC0 then break end
|
||||
s = s:sub(1, #s - 1)
|
||||
end
|
||||
-- Drop a lead byte whose continuation was truncated away.
|
||||
if #s > 0 then
|
||||
local b = s:byte(#s)
|
||||
if b >= 0xC0 then
|
||||
s = s:sub(1, #s - 1)
|
||||
end
|
||||
end
|
||||
return s
|
||||
end
|
||||
|
||||
-- Strip common markdown / HTML noise for the launcher changelog preview.
|
||||
function ModUpdate.cleanBody(text, maxChars)
|
||||
if type(text) ~= "string" or text == "" then return "" end
|
||||
local s = text
|
||||
s = s:gsub("\r\n", "\n"):gsub("\r", "\n")
|
||||
s = s:gsub("<!%-%-.-%-%->", "")
|
||||
s = s:gsub("<[^>]+>", "")
|
||||
s = s:gsub("%[%!%[[^%]]*%]%([^%)]*%)%]", "") -- images 
|
||||
s = s:gsub("%[([^%]]+)%]%([^%)]+%)", "%1") -- links [text](url) -> text
|
||||
s = s:gsub("```[^\n]*\n(.-)```", "%1")
|
||||
s = s:gsub("`([^`]+)`", "%1")
|
||||
s = s:gsub("^#+%s*", "", 1)
|
||||
s = s:gsub("\n#+%s*", "\n")
|
||||
s = s:gsub("%*%*([^*]+)%*%*", "%1")
|
||||
s = s:gsub("%*([^*]+)%*", "%1")
|
||||
s = s:gsub("__([^_]+)__", "%1")
|
||||
s = s:gsub("_([^_]+)_", "%1")
|
||||
s = s:gsub("^\n+", ""):gsub("\n+$", "")
|
||||
s = s:gsub("\n\n\n+", "\n\n")
|
||||
maxChars = tonumber(maxChars) or 0
|
||||
if maxChars > 0 and #s > maxChars then
|
||||
-- ASCII "..." (not U+2026) so later pixel ellipsize stays byte-safe.
|
||||
s = utf8Cut(s, maxChars):gsub("%s+%S*$", "") .. "..."
|
||||
end
|
||||
return s
|
||||
end
|
||||
|
||||
-- One-line preview for tight UI rows: cleaned, newlines collapsed, ellipsized.
|
||||
function ModUpdate.previewLine(text, maxChars)
|
||||
local s = ModUpdate.cleanBody(text, 0)
|
||||
if s == "" then return "" end
|
||||
s = s:gsub("\n+", " "):gsub("%s+", " ")
|
||||
s = s:match("^%s*(.-)%s*$") or s
|
||||
maxChars = tonumber(maxChars) or 80
|
||||
if #s > maxChars then
|
||||
s = utf8Cut(s, maxChars):gsub("%s+%S*$", "") .. "..."
|
||||
end
|
||||
return s
|
||||
end
|
||||
|
||||
-- Decode one GitHub release object into { version, tag, zip, prerelease, body }.
|
||||
function ModUpdate.parseRelease(doc, modId)
|
||||
if type(doc) ~= "table" or not doc.tag_name then
|
||||
return nil, "no tag_name in release"
|
||||
end
|
||||
local version = stripV(doc.tag_name)
|
||||
if not version:match("^%d+%.%d+%.%d+") then
|
||||
return nil, "release tag is not semver-like: " .. tostring(doc.tag_name)
|
||||
end
|
||||
local triple = version:match("^(%d+%.%d+%.%d+)")
|
||||
local zip = ModUpdate.pickZipAsset(doc.assets, modId, triple)
|
||||
local body = type(doc.body) == "string" and doc.body or ""
|
||||
return {
|
||||
version = triple,
|
||||
tag = tostring(doc.tag_name),
|
||||
zip = zip,
|
||||
prerelease = doc.prerelease == true,
|
||||
name = type(doc.name) == "string" and doc.name or triple,
|
||||
body = body,
|
||||
}
|
||||
end
|
||||
|
||||
-- Decode a releases array (GET /repos/.../releases) into a sorted list
|
||||
-- (newest first). Releases without a .zip asset are dropped. Never throws.
|
||||
function ModUpdate.parseReleases(jsonText, modId, Json)
|
||||
local ok, result, err = pcall(function()
|
||||
Json = Json or require("src.link.Json")
|
||||
local doc, decodeErr = Json.decode(jsonText)
|
||||
if type(doc) ~= "table" then
|
||||
return nil, decodeErr or "releases json is not an array"
|
||||
end
|
||||
if type(doc.message) == "string" and not doc.tag_name and not doc[1] then
|
||||
return nil, "GitHub: " .. doc.message
|
||||
end
|
||||
if doc.tag_name then
|
||||
local one, oneErr = ModUpdate.parseRelease(doc, modId)
|
||||
if not one then return nil, oneErr end
|
||||
if not one.zip then return nil, "latest release has no .zip asset" end
|
||||
return { one }
|
||||
end
|
||||
local out = {}
|
||||
for _, entry in ipairs(doc) do
|
||||
local rel = ModUpdate.parseRelease(entry, modId)
|
||||
if rel and rel.zip then out[#out + 1] = rel end
|
||||
end
|
||||
return out
|
||||
end)
|
||||
if not ok then return nil, "could not parse releases: " .. tostring(result) end
|
||||
return result, err
|
||||
end
|
||||
|
||||
function ModUpdate.apiReleasesUrl(repo)
|
||||
return "https://api.github.com/repos/" .. repo .. "/releases?per_page=30"
|
||||
end
|
||||
|
||||
function ModUpdate.apiLatestUrl(repo)
|
||||
return "https://api.github.com/repos/" .. repo .. "/releases/latest"
|
||||
end
|
||||
|
||||
function ModUpdate.isNewer(installed, candidate)
|
||||
local Semver = require("src.mods.Semver")
|
||||
if type(installed) ~= "string" or type(candidate) ~= "string" then
|
||||
return false
|
||||
end
|
||||
local a, b = Semver.parse(installed), Semver.parse(candidate)
|
||||
if not a or not b then return false end
|
||||
return Semver.compare(candidate, installed) > 0
|
||||
end
|
||||
|
||||
-- Newest non-prerelease, else newest overall.
|
||||
function ModUpdate.pickBest(releases)
|
||||
if type(releases) ~= "table" or #releases == 0 then return nil end
|
||||
for _, rel in ipairs(releases) do
|
||||
if not rel.prerelease then return rel end
|
||||
end
|
||||
return releases[1]
|
||||
end
|
||||
|
||||
-- ------- cache (options.modUpdateCache[repo])
|
||||
|
||||
local function cacheStore()
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local opts = SaveData.loadOptions()
|
||||
opts.modUpdateCache = opts.modUpdateCache or {}
|
||||
return opts
|
||||
end
|
||||
|
||||
function ModUpdate.readCache(repo)
|
||||
if type(repo) ~= "string" or repo == "" then return nil end
|
||||
local ok, opts = pcall(function()
|
||||
return require("src.core.SaveData").loadOptions()
|
||||
end)
|
||||
if not ok or type(opts) ~= "table" then return nil end
|
||||
local entry = opts.modUpdateCache and opts.modUpdateCache[repo]
|
||||
if type(entry) ~= "table" or type(entry.checkedAt) ~= "number" then
|
||||
return nil
|
||||
end
|
||||
if type(entry.releases) ~= "table" then return nil end
|
||||
return entry
|
||||
end
|
||||
|
||||
function ModUpdate.cacheFresh(entry, now, ttl)
|
||||
now = now or os.time()
|
||||
ttl = ttl or ModUpdate.CACHE_TTL
|
||||
return entry and type(entry.checkedAt) == "number"
|
||||
and (now - entry.checkedAt) < ttl
|
||||
end
|
||||
|
||||
function ModUpdate.writeCache(repo, releases)
|
||||
if type(repo) ~= "string" or repo == "" then return false end
|
||||
local ok = pcall(function()
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local opts = cacheStore()
|
||||
local best = ModUpdate.pickBest(releases)
|
||||
-- Persist a lean copy: enough to paint the UI and reinstall without
|
||||
-- re-fetching within the TTL.
|
||||
local lean = {}
|
||||
for i, rel in ipairs(releases or {}) do
|
||||
lean[i] = {
|
||||
version = rel.version,
|
||||
tag = rel.tag,
|
||||
name = rel.name,
|
||||
prerelease = rel.prerelease == true,
|
||||
body = type(rel.body) == "string" and rel.body or "",
|
||||
zip = rel.zip and {
|
||||
name = rel.zip.name,
|
||||
url = rel.zip.url,
|
||||
size = rel.zip.size,
|
||||
} or nil,
|
||||
}
|
||||
end
|
||||
opts.modUpdateCache[repo] = {
|
||||
checkedAt = os.time(),
|
||||
latest = best and best.version or nil,
|
||||
releases = lean,
|
||||
}
|
||||
SaveData.saveOptions(opts)
|
||||
end)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- Status vs an installed version using a cache entry / release list.
|
||||
-- Returns "available" | "current" | "unknown".
|
||||
function ModUpdate.statusFor(installedVersion, releases)
|
||||
local best = ModUpdate.pickBest(releases)
|
||||
if not best then return "unknown", nil end
|
||||
if ModUpdate.isNewer(installedVersion, best.version) then
|
||||
return "available", best
|
||||
end
|
||||
return "current", best
|
||||
end
|
||||
|
||||
-- ------- host I/O (curl)
|
||||
|
||||
local function shq(s)
|
||||
s = tostring(s)
|
||||
if love and love.system and love.system.getOS
|
||||
and love.system.getOS() == "Windows" then
|
||||
return '"' .. s:gsub('"', '') .. '"'
|
||||
end
|
||||
return "'" .. s:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
local function curlCapture(url)
|
||||
local HostShell = require("src.core.HostShell")
|
||||
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
|
||||
.. "-H " .. shq("User-Agent: gen1recomp-mod-updater") .. " "
|
||||
.. "-H " .. shq("Accept: application/vnd.github+json") .. " "
|
||||
.. shq(url)
|
||||
local pipeOk, pipe = pcall(HostShell.popen, cmd)
|
||||
if not pipeOk or not pipe then return nil, "could not run curl" end
|
||||
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||
pcall(function() pipe:close() end)
|
||||
if not readOk then return nil, "curl read failed: " .. tostring(out) end
|
||||
if not out or out == "" then return nil, "empty response from GitHub" end
|
||||
return out
|
||||
end
|
||||
|
||||
function ModUpdate.haveCurl()
|
||||
local HostShell = require("src.core.HostShell")
|
||||
local pipeOk, pipe = pcall(HostShell.popen, "curl --version")
|
||||
if not pipeOk or not pipe then return false end
|
||||
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||
pcall(function() pipe:close() end)
|
||||
return readOk and out ~= nil and out:find("curl", 1, true) ~= nil
|
||||
end
|
||||
|
||||
-- Fetch release list. opts.force bypasses the 6h cache.
|
||||
-- Returns releases, err, meta where meta = { fromCache = bool }.
|
||||
function ModUpdate.fetchReleases(repo, modId, opts)
|
||||
opts = opts or {}
|
||||
if type(repo) ~= "string" or repo == "" then
|
||||
return nil, "missing github repo"
|
||||
end
|
||||
if not opts.force then
|
||||
local cached = ModUpdate.readCache(repo)
|
||||
if ModUpdate.cacheFresh(cached) then
|
||||
return cached.releases, nil, { fromCache = true }
|
||||
end
|
||||
end
|
||||
if not ModUpdate.haveCurl() then
|
||||
-- Stale cache is better than nothing when offline
|
||||
local cached = ModUpdate.readCache(repo)
|
||||
if cached and cached.releases then
|
||||
return cached.releases, nil, { fromCache = true, stale = true }
|
||||
end
|
||||
return nil, "curl is not available on this platform"
|
||||
end
|
||||
local body, curlErr = curlCapture(ModUpdate.apiReleasesUrl(repo))
|
||||
if not body then
|
||||
local cached = ModUpdate.readCache(repo)
|
||||
if cached and cached.releases then
|
||||
return cached.releases, nil, { fromCache = true, stale = true }
|
||||
end
|
||||
return nil, curlErr
|
||||
end
|
||||
local list, parseErr = ModUpdate.parseReleases(body, modId)
|
||||
if not list then return nil, parseErr end
|
||||
ModUpdate.writeCache(repo, list)
|
||||
return list, nil, { fromCache = false }
|
||||
end
|
||||
|
||||
function ModUpdate.downloadZip(url, destName)
|
||||
if type(url) ~= "string" or url == "" then
|
||||
return nil, "missing download url"
|
||||
end
|
||||
if not (love and love.filesystem) then
|
||||
return nil, "download needs LOVE"
|
||||
end
|
||||
if not ModUpdate.haveCurl() then
|
||||
return nil, "curl is not available on this platform"
|
||||
end
|
||||
local HostShell = require("src.core.HostShell")
|
||||
local name = destName or ("mod_update_" .. tostring(os.time()) .. ".zip")
|
||||
name = tostring(name):gsub("[/\\]", "_")
|
||||
local saveOk, saveDir = pcall(function()
|
||||
return love.filesystem.getSaveDirectory()
|
||||
end)
|
||||
if not saveOk or not saveDir or saveDir == "" then
|
||||
return nil, "no save directory"
|
||||
end
|
||||
local abs = saveDir .. "/" .. name
|
||||
local cmd = "curl -fsSL --connect-timeout 15 --max-time 300 -o "
|
||||
.. shq(abs) .. " " .. shq(url)
|
||||
local pipeOk, pipe = pcall(HostShell.popen, cmd)
|
||||
if not pipeOk or not pipe then return nil, "could not start download" end
|
||||
pcall(function() pipe:read("*a") end)
|
||||
pcall(function() pipe:close() end)
|
||||
local infoOk, info = pcall(love.filesystem.getInfo, name)
|
||||
if not infoOk or not info or (info.size or 0) == 0 then
|
||||
pcall(love.filesystem.remove, name)
|
||||
return nil, "download failed"
|
||||
end
|
||||
return name
|
||||
end
|
||||
|
||||
return ModUpdate
|
||||
+97
-39
@@ -5,11 +5,13 @@
|
||||
-- then drawn once per zone through a shader that remaps the four DMG
|
||||
-- shades to that zone's palette.
|
||||
--
|
||||
-- Port display option: COLORS (OG RED / SGB / ADVANCED / OG / OG INV /
|
||||
-- SGB INV / CLASSIC) transforms every zone's palette at send time via
|
||||
-- effectiveColors. ADVANCED (the `redpp` id below) swaps the named-palette
|
||||
-- pack for pokered-gbc SuperPalettes (data/palettes_gbc.lua), including
|
||||
-- per-species mon colors.
|
||||
-- Port display option: COLORS (OG RED / OG BLUE / OG YELLOW / SGB /
|
||||
-- ADVANCED / OG / OG INV / SGB INV / CLASSIC) transforms every zone's
|
||||
-- palette at send time via effectiveColors. ADVANCED (the `redpp` id
|
||||
-- below) swaps the named-palette pack for pokered-gbc SuperPalettes
|
||||
-- (data/palettes_gbc.lua), including per-species mon colors. OG YELLOW
|
||||
-- (Yellow playthrough + `ogred` id) uses pokeyellow CGBBasePalettes
|
||||
-- (data/palettes_yellow.lua / ROM cgbBase).
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
@@ -17,10 +19,11 @@ local PaletteFX = {}
|
||||
|
||||
local shader -- false = unavailable (headless / no shader support)
|
||||
local gbcPack -- false = missing; nil = not loaded yet
|
||||
local yellowPack -- false = missing; nil = not loaded yet
|
||||
|
||||
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
|
||||
-- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, ADVANCED =
|
||||
-- pokered-gbc per-tile), then the DMG-shade novelty modes.
|
||||
-- come first (OG RED/BLUE/YELLOW = GBC hardware, SGB = per-map Super Game Boy,
|
||||
-- ADVANCED = pokered-gbc per-tile), then the DMG-shade novelty modes.
|
||||
PaletteFX.MODES = { "ogred", "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
|
||||
-- `gbc`/`gbc_inv`/`redpp` keep their save-value ids for back-compat while
|
||||
-- their LABELS say what the mode actually is: "SGB"/"SGB INV" because the old
|
||||
@@ -117,14 +120,19 @@ PaletteFX.GBC_OBJ_BLUE = {
|
||||
}
|
||||
|
||||
-- The active game's OG boot-ROM background palette: blue for a Blue
|
||||
-- playthrough, red otherwise. White (index 1) and black (index 4) are
|
||||
-- identical across versions, so callers that only touch the endpoints
|
||||
-- (e.g. BattleState's zone white/black snap) need no version branch.
|
||||
-- Yellow is CGB-enhanced (pokeyellow CGBBasePalettes) and has no extracted
|
||||
-- boot-ROM auto-palette here; keep the Red ramp -- never Blue's GBC_BG_BLUE.
|
||||
-- playthrough, red for Red. White (index 1) and black (index 4) are
|
||||
-- identical across Red/Blue, so callers that only touch the endpoints
|
||||
-- (e.g. BattleState's zone white/black snap) need no version branch there.
|
||||
-- Yellow is CGB-enhanced (pokeyellow CGBBasePalettes): named zones go through
|
||||
-- pal() / usesYellowCgb(), and ogBg() falls back to CGBBase PAL_ROUTE for any
|
||||
-- remaining whole-screen callers -- never Blue's GBC_BG_BLUE.
|
||||
function PaletteFX.ogBg()
|
||||
if GameVersion.isBlue() then return PaletteFX.GBC_BG_BLUE end
|
||||
if GameVersion.isYellow() then return PaletteFX.GBC_BG end
|
||||
if GameVersion.isYellow() then
|
||||
local y = PaletteFX.yellowPack()
|
||||
local route = y and y.cgbBase and y.cgbBase.ROUTE
|
||||
if route then return route end
|
||||
end
|
||||
return PaletteFX.GBC_BG
|
||||
end
|
||||
|
||||
@@ -133,14 +141,12 @@ end
|
||||
-- version-distinct cache-group string, because SpriteRenderer.getObpImage keys
|
||||
-- its baked-image cache by (image path, group): a shared group would collide a
|
||||
-- Red bake with a Blue one and one version would show the other's colors.
|
||||
-- Yellow: same Red OBJ green as above until Yellow-specific tables land.
|
||||
-- Yellow OG does not bake a boot-ROM OBJ (usesSpriteObp is false); this path
|
||||
-- is unused there and kept as Red green only as a safe leftover.
|
||||
function PaletteFX.ogObj()
|
||||
if GameVersion.isBlue() then
|
||||
return PaletteFX.darkObp(PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue")
|
||||
end
|
||||
if GameVersion.isYellow() then
|
||||
return PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj")
|
||||
end
|
||||
return PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj")
|
||||
end
|
||||
|
||||
@@ -271,13 +277,31 @@ function PaletteFX.gbcPack()
|
||||
return gbcPack or nil
|
||||
end
|
||||
|
||||
-- pokeyellow SuperPalettes + CGBBasePalettes (committed; not wiped by import).
|
||||
-- ROM import may also stamp palettes.cgbBase; yellowCgbNamedPal prefers that.
|
||||
function PaletteFX.yellowPack()
|
||||
if yellowPack == nil then
|
||||
local ok, pack = pcall(require, "data.palettes_yellow")
|
||||
yellowPack = ok and pack or false
|
||||
end
|
||||
return yellowPack or nil
|
||||
end
|
||||
|
||||
function PaletteFX.usesGbcPack(mode)
|
||||
mode = mode or PaletteFX.mode
|
||||
return mode == "redpp"
|
||||
end
|
||||
|
||||
-- Yellow's authentic GBC look is CGBBasePalettes (per-map), not a boot-ROM
|
||||
-- auto-palette. The shared `ogred` save id wears that table on a Yellow
|
||||
-- playthrough and labels itself "OG YELLOW".
|
||||
function PaletteFX.usesYellowCgb(mode)
|
||||
mode = mode or PaletteFX.mode
|
||||
return GameVersion.isYellow() and mode == "ogred"
|
||||
end
|
||||
|
||||
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
|
||||
-- (the OBP bake + post-zone redraw path). OG RED alone does: the Game Boy
|
||||
-- (the OBP bake + post-zone redraw path). OG RED / OG BLUE do: the Game Boy
|
||||
-- Color boot ROM hands the game one global object palette (PaletteFX.ogObj --
|
||||
-- green over Red's red background, pink over Blue's blue background), so on
|
||||
-- that machine the player and NPCs carry a fixed object color instead of
|
||||
@@ -288,6 +312,10 @@ end
|
||||
-- regression. Terrain is unaffected -- pal() below still hands SGB its per-map
|
||||
-- BG palette; only OG RED short-circuits BG to the one global red palette.
|
||||
--
|
||||
-- OG YELLOW does NOT. Yellow ships CGB code and colors regions via
|
||||
-- CGBBasePalettes the same way SGB mode colors SuperPalettes, so sprites tint
|
||||
-- with the map zone (usesSpriteObp stays off).
|
||||
--
|
||||
-- SGB does NOT. The Super Game Boy colorizes the composited DMG picture it is
|
||||
-- handed and cannot tell an OBJ pixel from a BG one; pokered never sends the
|
||||
-- OBJ_TRN packet that would enable SGB sprite mode (data/sgb/sgb_packets.asm
|
||||
@@ -306,7 +334,7 @@ end
|
||||
-- usesGbcPack() path in SpriteRenderer instead.
|
||||
function PaletteFX.usesSpriteObp(mode)
|
||||
mode = mode or PaletteFX.mode
|
||||
return mode == "ogred"
|
||||
return mode == "ogred" and not GameVersion.isYellow()
|
||||
end
|
||||
|
||||
-- ------- post-zone sprite redraw (OG RED)
|
||||
@@ -363,8 +391,8 @@ end
|
||||
-- read these from the ROM-imported table or the title ribbon stays red
|
||||
-- and the Game Corner reels keep Red's pink (issue #128).
|
||||
-- Yellow is intentionally NOT in BLUE_VERSIONED: skip Blue LOGO1/SLOTS*
|
||||
-- recolors. When CGBBasePalettes were imported (palettes.cgbBase), Yellow
|
||||
-- prefers those over SGB SuperPalettes for named zones.
|
||||
-- recolors. OG YELLOW (usesYellowCgb) reads CGBBasePalettes; SGB mode on
|
||||
-- Yellow keeps SuperPalettes.
|
||||
local BLUE_VERSIONED = {
|
||||
LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true,
|
||||
}
|
||||
@@ -376,31 +404,42 @@ end
|
||||
|
||||
local function yellowCgbNamedPal(data, name)
|
||||
local p = data and data.palettes
|
||||
return p and p.cgbBase and p.cgbBase[name]
|
||||
local fromRom = p and p.cgbBase and p.cgbBase[name]
|
||||
if fromRom then return fromRom end
|
||||
local y = PaletteFX.yellowPack()
|
||||
return y and y.cgbBase and y.cgbBase[name] or nil
|
||||
end
|
||||
|
||||
-- named palette from the active pack (nil on stale builds / missing name).
|
||||
-- RED++ falls back to the ROM pack for names the gbc table omits (rare).
|
||||
-- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette
|
||||
-- (the hardware had a single BGP for the whole game), so terrain zones,
|
||||
-- battle HP bars / text, and menu boxes all come out red -- everything a
|
||||
-- background tile drew. Objects do not come through here (they bake
|
||||
-- GBC_OBJ green), so this stays a BG-only hook.
|
||||
-- OG RED / OG BLUE short-circuit EVERY name to the one global GBC boot-ROM
|
||||
-- BG palette (the hardware had a single BGP for the whole game), so terrain
|
||||
-- zones, battle HP bars / text, and menu boxes all come out red/blue --
|
||||
-- everything a background tile drew. Objects do not come through here
|
||||
-- (they bake GBC_OBJ), so this stays a BG-only hook.
|
||||
-- OG YELLOW instead resolves each name through CGBBasePalettes.
|
||||
function PaletteFX.pal(data, name)
|
||||
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
|
||||
if PaletteFX.mode == "ogred" and not GameVersion.isYellow() then
|
||||
return PaletteFX.ogBg()
|
||||
end
|
||||
-- Blue-only ROM override for versioned SuperPals. Yellow (isYellow) and
|
||||
-- Red keep the active pack / Red-like path -- do not apply Blue recolors.
|
||||
if GameVersion.isBlue() and BLUE_VERSIONED[name] then
|
||||
local fromRom = romNamedPal(data, name)
|
||||
if fromRom then return fromRom end
|
||||
end
|
||||
if GameVersion.isYellow() then
|
||||
if PaletteFX.usesYellowCgb() then
|
||||
local fromCgb = yellowCgbNamedPal(data, name)
|
||||
if fromCgb then return fromCgb end
|
||||
end
|
||||
local p = PaletteFX.pack(data)
|
||||
local c = p and p.palettes[name]
|
||||
if c then return c end
|
||||
if GameVersion.isYellow() then
|
||||
local y = PaletteFX.yellowPack()
|
||||
local yc = y and y.palettes and y.palettes[name]
|
||||
if yc then return yc end
|
||||
end
|
||||
if PaletteFX.usesGbcPack() then
|
||||
return romNamedPal(data, name)
|
||||
end
|
||||
@@ -413,14 +452,20 @@ end
|
||||
-- Transformed mon's pic is tinted gray, not the copied species' own
|
||||
-- SGB color). RED++ uses per-species pals from mon_palettes.asm.
|
||||
function PaletteFX.monPal(data, species, transformed)
|
||||
-- OG RED: a battle mon pic is a BG tile on the Game Boy Color (drawn into
|
||||
-- the tilemap, colored by BGP), so it wears the global red BG palette, not
|
||||
-- a per-species one -- matching the hardware capture where both mons are
|
||||
-- red/pink on the white field.
|
||||
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
|
||||
-- OG RED / OG BLUE: a battle mon pic is a BG tile on the Game Boy Color
|
||||
-- (drawn into the tilemap, colored by BGP), so it wears the global boot-ROM
|
||||
-- BG palette, not a per-species one -- matching the hardware capture where
|
||||
-- both mons are red/pink (or blue/pink) on the white field.
|
||||
-- OG YELLOW keeps per-species CGBBasePalettes (Yellow had real CGB code).
|
||||
if PaletteFX.mode == "ogred" and not GameVersion.isYellow() then
|
||||
return PaletteFX.ogBg()
|
||||
end
|
||||
local p = PaletteFX.pack(data)
|
||||
if not p then return nil end
|
||||
if transformed then
|
||||
if PaletteFX.usesYellowCgb() then
|
||||
return PaletteFX.pal(data, "GRAYMON")
|
||||
end
|
||||
return p.palettes.GRAYMON
|
||||
or (data and data.palettes and data.palettes.palettes.GRAYMON)
|
||||
end
|
||||
@@ -430,17 +475,32 @@ function PaletteFX.monPal(data, species, transformed)
|
||||
-- so fall back to it when the pack itself doesn't carry the name.
|
||||
local def = data and data.pokemon and data.pokemon[species]
|
||||
if def and def.palette then
|
||||
if PaletteFX.usesYellowCgb() then
|
||||
local yc = PaletteFX.pal(data, def.palette)
|
||||
if yc then return yc end
|
||||
end
|
||||
local pal = p.palettes[def.palette]
|
||||
or (data and data.palettes and data.palettes.palettes[def.palette])
|
||||
if pal then return pal end
|
||||
end
|
||||
local name = p.pokemon[species] or "MEWMON"
|
||||
if PaletteFX.usesYellowCgb() then
|
||||
local yc = PaletteFX.pal(data, name)
|
||||
if yc then return yc end
|
||||
end
|
||||
local c = p.palettes[name]
|
||||
if c then return c end
|
||||
if PaletteFX.usesGbcPack() and data and data.palettes then
|
||||
name = data.palettes.pokemon[species] or "MEWMON"
|
||||
return data.palettes.palettes[name]
|
||||
end
|
||||
if GameVersion.isYellow() then
|
||||
local y = PaletteFX.yellowPack()
|
||||
if y and y.pokemon then
|
||||
name = y.pokemon[species] or "MEWMON"
|
||||
return y.palettes and y.palettes[name]
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
@@ -707,12 +767,10 @@ end
|
||||
|
||||
function PaletteFX.modeLabel(mode)
|
||||
mode = mode or PaletteFX.mode
|
||||
-- The GBC boot-ROM mode wears the running game's name: it is red for Red and
|
||||
-- blue for Blue (see ogBg), so a Blue playthrough shows "OG BLUE".
|
||||
-- Yellow still uses the Red boot-ROM ramp (no Yellow table yet), so keep
|
||||
-- the "OG RED" label rather than inventing an "OG YELLOW" without colors.
|
||||
-- The GBC hardware mode wears the running game's name: OG RED / OG BLUE
|
||||
-- (boot-ROM auto-palette) or OG YELLOW (pokeyellow CGBBasePalettes).
|
||||
if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end
|
||||
if mode == "ogred" and GameVersion.isYellow() then return "OG RED" end
|
||||
if mode == "ogred" and GameVersion.isYellow() then return "OG YELLOW" end
|
||||
return PaletteFX.MODE_LABELS[mode] or "GBC"
|
||||
end
|
||||
|
||||
|
||||
@@ -720,6 +720,13 @@ function Renderer:endFrame(zones, worldZones)
|
||||
self.uprightActive = false
|
||||
self.worldOverride = nil
|
||||
PaletteFX.setPass(nil)
|
||||
return {
|
||||
width = ww, height = wh,
|
||||
gameX = ox, gameY = oy,
|
||||
gameWidth = vpw, gameHeight = vph,
|
||||
scale = Sp,
|
||||
dpiX = dpiX, dpiY = dpiY,
|
||||
}
|
||||
end
|
||||
|
||||
return Renderer
|
||||
|
||||
+18
-3
@@ -265,7 +265,18 @@ function Commands.start_battle(ctx, kind, a, b)
|
||||
ctx.lastBattleResult = result
|
||||
ctx.lastCheck = result == "win"
|
||||
if ctx.overworld then
|
||||
ctx.overworld:afterBattle(result, battle)
|
||||
-- A map script often follows a trainer battle with its own text.
|
||||
-- Keep a level evolution behind that text: otherwise afterBattle
|
||||
-- pushes the evolution screen, then this runner resumes and pushes
|
||||
-- the trainer's text on top of it.
|
||||
if result == "win" then
|
||||
ctx.afterScript = ctx.afterScript or {}
|
||||
table.insert(ctx.afterScript, function()
|
||||
ctx.overworld:afterBattle(result, battle)
|
||||
end)
|
||||
else
|
||||
ctx.overworld:afterBattle(result, battle)
|
||||
end
|
||||
end
|
||||
runner:resume()
|
||||
end
|
||||
@@ -723,11 +734,15 @@ function Commands.record_hall_of_fame(ctx)
|
||||
if game.overworld then
|
||||
game.overworld.lastOutdoor = ctx.save.lastOutdoor
|
||||
end
|
||||
if game.writeSave then game:writeSave() end
|
||||
local saveAllowed = true
|
||||
if game.writeSave then saveAllowed = game:writeSave() ~= false end
|
||||
-- writeSave's captureSave re-stamps the live HALL_OF_FAME coords;
|
||||
-- re-apply home and persist so CONTINUE resumes in the bedroom.
|
||||
SaveData.applyPostGameHome(ctx.save, boot)
|
||||
SaveData.save(ctx.save)
|
||||
-- A tool session may veto Game:writeSave to keep its in-memory run
|
||||
-- isolated from the player's real save. The relocation write is part
|
||||
-- of that same save operation and must honor the same decision.
|
||||
if saveAllowed then SaveData.save(ctx.save) end
|
||||
end)
|
||||
end)
|
||||
runner:yield()
|
||||
|
||||
@@ -125,6 +125,10 @@ function ScriptRunner:run(script, extra)
|
||||
end
|
||||
self.co = coroutine.create(function()
|
||||
self:exec(script, ctx)
|
||||
-- Commands can defer a game action until the script's own dialogue is
|
||||
-- done. start_battle uses this for win-path evolutions, which must not
|
||||
-- be covered by post-battle trainer text.
|
||||
for _, callback in ipairs(ctx.afterScript or {}) do callback() end
|
||||
if ctx.onDone then ctx.onDone() end
|
||||
if Runtime.wants("script.ended") then
|
||||
Runtime.emit("script.ended", { ctx = ctx, completed = true })
|
||||
|
||||
@@ -93,6 +93,17 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
end
|
||||
|
||||
if result == "bicycle" then
|
||||
-- StartMenu_Item .useOrTossItem (engine/menus/start_sub_menus.asm):
|
||||
-- while BIT_ALWAYS_ON_BIKE of wStatusFlags6 is set -- the Cycling Road,
|
||||
-- armed by the forced-bike tiles and cleared by the Route 16/18 gate
|
||||
-- scripts -- the BICYCLE refuses with _CannotGetOffHereText and jumps
|
||||
-- back to ItemMenuLoop, so the bag stays open and no dismount happens
|
||||
-- (#513). The gate sits ahead of UseItem, before ItemUseBicycle ever
|
||||
-- runs, which is why it precedes list:close() here.
|
||||
if game.save.forcedBike then
|
||||
showMessages(game, { Strings("You can't get off\nhere.") })
|
||||
return
|
||||
end
|
||||
list:close()
|
||||
local ow = game.overworld
|
||||
local Music = require("src.core.Music")
|
||||
|
||||
+22
-2
@@ -64,9 +64,24 @@ function BindingsMenu.new(game)
|
||||
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
|
||||
BindingsMenu)
|
||||
self.onChoose = function(item) self:beginCapture(item) end
|
||||
-- A rebind reaches Input only when this screen closes (#510). The menu
|
||||
-- steers by the live map, so applying "B = Z" the instant it was captured
|
||||
-- turned the player's next confirm press into a cancel and shut the
|
||||
-- screen mid-swap. options.bindings is still written immediately, and
|
||||
-- Game:applyOptions re-applies it on load, so a close that skips this
|
||||
-- hook still ends up with the saved map.
|
||||
self.onCancel = function() self:commitBindings() end
|
||||
return self
|
||||
end
|
||||
|
||||
-- Cross-file contract with src/core/Input.lua: the saved overlay reaches
|
||||
-- the live map here, on close, and nowhere else in this screen.
|
||||
function BindingsMenu:commitBindings()
|
||||
local game = self.game
|
||||
local opts = game and game.save and game.save.options
|
||||
if opts then Input:applyBindings(opts.bindings) end
|
||||
end
|
||||
|
||||
-- the capture handlers are per-instance slots, so Game's raw-input
|
||||
-- routing only ever sees this screen while a capture is armed
|
||||
function BindingsMenu:beginCapture(item)
|
||||
@@ -75,7 +90,12 @@ function BindingsMenu:beginCapture(item)
|
||||
self.onGamepadPressed = BindingsMenu.capturePad
|
||||
end
|
||||
|
||||
-- Escape is the capture's way out, so it is never captured: every other
|
||||
-- key is bindable, which otherwise leaves an armed row with no exit but
|
||||
-- to bind something (#510). Escape stays START in Input's default map,
|
||||
-- which no rebind removes, so reserving it costs the player nothing.
|
||||
function BindingsMenu:captureKey(key)
|
||||
if key == "escape" then return self:storeBinding("key", nil) end
|
||||
self:storeBinding("key", key)
|
||||
end
|
||||
|
||||
@@ -100,7 +120,6 @@ function BindingsMenu:storeBinding(slot, value)
|
||||
b[slot] = value
|
||||
opts.bindings[item.button.id] = b
|
||||
item.right = boundRight(opts.bindings, item.button)
|
||||
Input:applyBindings(opts.bindings)
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
end
|
||||
|
||||
@@ -112,9 +131,10 @@ end
|
||||
function BindingsMenu:draw()
|
||||
ListMenu.draw(self)
|
||||
if self.capture then
|
||||
Font.drawBox(1, 6, 18, 4)
|
||||
Font.drawBox(1, 6, 18, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("PRESS A BUTTON"), 24, 60)
|
||||
Font.draw(Strings("ESC TO CANCEL"), 24, 72)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
|
||||
+23
-11
@@ -48,11 +48,11 @@ end
|
||||
local FLASH_FRAMES = 220
|
||||
|
||||
local function frontSprite(game, species, mon)
|
||||
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
|
||||
{ mon = mon, kind = "evolution" })
|
||||
if not path then return nil end
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
game.data, species, "front", { mon = mon, kind = "evolution" })
|
||||
if not path then return nil, false end
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
return ok and img or nil
|
||||
return ok and img or nil, ok and trueColor or false
|
||||
end
|
||||
|
||||
function EvolutionState.new(game, mon, newSpecies, onDone, via)
|
||||
@@ -69,8 +69,8 @@ function EvolutionState.new(game, mon, newSpecies, onDone, via)
|
||||
-- wForceEvolution clear and so honour B (#290, #213).
|
||||
self.cancelable = (via ~= "TRADE" and via ~= "ITEM")
|
||||
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||
self.oldSprite = frontSprite(game, mon.species, mon)
|
||||
self.newSprite = frontSprite(game, newSpecies, mon)
|
||||
self.oldSprite, self.oldSpriteTrueColor = frontSprite(game, mon.species, mon)
|
||||
self.newSprite, self.newSpriteTrueColor = frontSprite(game, newSpecies, mon)
|
||||
self.t = 0
|
||||
self.done = false
|
||||
self.canceled = false
|
||||
@@ -127,18 +127,30 @@ function EvolutionState:draw()
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
|
||||
-- accelerating flash between the two forms
|
||||
local sprite
|
||||
local sprite, spriteTrueColor
|
||||
if self.done then
|
||||
-- a cancelled evolution settles back on the original form
|
||||
sprite = self.canceled and self.oldSprite or self.newSprite
|
||||
if self.canceled then
|
||||
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
|
||||
else
|
||||
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
|
||||
end
|
||||
else
|
||||
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
|
||||
local showNew = math.floor(self.t / period) % 2 == 1
|
||||
sprite = showNew and self.newSprite or self.oldSprite
|
||||
if showNew then
|
||||
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
|
||||
else
|
||||
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
|
||||
end
|
||||
end
|
||||
if sprite then
|
||||
love.graphics.draw(sprite, math.floor((160 - sprite:getWidth()) / 2),
|
||||
math.max(8, 64 - sprite:getHeight()))
|
||||
local x = math.floor((160 - sprite:getWidth()) / 2)
|
||||
local y = math.max(8, 64 - sprite:getHeight())
|
||||
love.graphics.draw(sprite, x, y)
|
||||
if spriteTrueColor then
|
||||
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
|
||||
end
|
||||
end
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
|
||||
@@ -322,6 +322,25 @@ local function buildRows(game)
|
||||
activate = function(g)
|
||||
require("src.ui.Screens").push(g, "BindingsMenu")
|
||||
end },
|
||||
-- permanent on-screen pad toggle (#327); layout editing stays in the
|
||||
-- launcher. Hidden where the overlay never appears (desktop without
|
||||
-- POKEPORT_TOUCH), so the row costs a non-mobile install nothing.
|
||||
{ id = "touchControls", label = Strings("TOUCH PAD"),
|
||||
value = function(g)
|
||||
local tc = g.save.options.touchControls
|
||||
local on = not (type(tc) == "table" and tc.enabled == false)
|
||||
return on and Strings("ON") or Strings("OFF")
|
||||
end,
|
||||
step = function(g)
|
||||
local o = g.save.options
|
||||
local tc = type(o.touchControls) == "table" and o.touchControls or {}
|
||||
local on = not (tc.enabled == false)
|
||||
tc.enabled = not on
|
||||
-- keep any saved positions when toggling
|
||||
o.touchControls = tc
|
||||
require("src.core.TouchControls"):applyOptions(o)
|
||||
return true
|
||||
end },
|
||||
}
|
||||
-- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks
|
||||
if not GBCFX.isSupported() then
|
||||
@@ -331,6 +350,21 @@ local function buildRows(game)
|
||||
end
|
||||
rows = filtered
|
||||
end
|
||||
-- TOUCH PAD only where the overlay can appear (mobile, or desktop with
|
||||
-- POKEPORT_TOUCH=1). POKEPORT_TOUCH=0 forces it off everywhere.
|
||||
do
|
||||
local env = os.getenv("POKEPORT_TOUCH")
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
local show = env == "1"
|
||||
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
|
||||
if not show then
|
||||
local filtered = {}
|
||||
for _, row in ipairs(rows) do
|
||||
if row.id ~= "touchControls" then filtered[#filtered + 1] = row end
|
||||
end
|
||||
rows = filtered
|
||||
end
|
||||
end
|
||||
-- PIKACHU VOL only means something where the voice clips exist: Yellow
|
||||
-- (data.audio.pikaCries is the clip count the importer wrote). Red/Blue
|
||||
-- keep the row list they always had.
|
||||
|
||||
@@ -7,6 +7,8 @@ local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Logger = require("src.core.Logger")
|
||||
|
||||
local TitleState = {}
|
||||
TitleState.__index = TitleState
|
||||
@@ -300,6 +302,8 @@ local function hasSave()
|
||||
return ok and info ~= nil
|
||||
end
|
||||
|
||||
local function sameItems(_, items) return items end
|
||||
|
||||
-- The CONTINUE info window (main_menu.asm DisplayContinueGameInfo):
|
||||
-- PLAYER / BADGES / POKéDEX / TIME over the title, shown after choosing
|
||||
-- CONTINUE. A confirms and loads the game, B returns to the main menu.
|
||||
@@ -375,6 +379,13 @@ function TitleState:openMenu()
|
||||
love.event.quit()
|
||||
end
|
||||
end })
|
||||
local hooked = Runtime.call("ui.title_menu.items", sameItems, game, items)
|
||||
if type(hooked) == "table" then
|
||||
items = hooked
|
||||
else
|
||||
Logger.error("ui.title_menu.items returned %s; keeping the vanilla items",
|
||||
type(hooked))
|
||||
end
|
||||
local th = #items * 2 + 2
|
||||
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
|
||||
-- full-width title LOGO zones would recolor this box; see sgbPalettes
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
-- Launcher touch-controls editor (#327): drag each on-screen button to a
|
||||
-- new spot, toggle the overlay off entirely, Reset to defaults, Done to
|
||||
-- persist into options.lua. Opened from the game panel's "Touch Controls"
|
||||
-- button; main.lua suspends the launcher the same way it does for the
|
||||
-- save editor.
|
||||
--
|
||||
-- Draws in full window LOVE units -- the same space TouchControls uses
|
||||
-- after Renderer:endFrame -- so what you drag here is what you get in play.
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
|
||||
local Editor = {}
|
||||
|
||||
local PAL = {
|
||||
bgTop = { 8, 14, 36 },
|
||||
bgBot = { 4, 8, 22 },
|
||||
white = { 245, 248, 255 },
|
||||
label = { 160, 175, 210 },
|
||||
green = { 80, 220, 140 },
|
||||
red = { 240, 90, 110 },
|
||||
card = { 18, 28, 58 },
|
||||
stroke = { 120, 150, 220 },
|
||||
}
|
||||
|
||||
local function col(c, a)
|
||||
love.graphics.setColor(c[1] / 255, c[2] / 255, c[3] / 255, a or 1)
|
||||
end
|
||||
|
||||
local function inside(r, x, y)
|
||||
return r and x >= r.x and x <= r.x + r.w and y >= r.y and y <= r.y + r.h
|
||||
end
|
||||
|
||||
local function roundRect(mode, x, y, w, h, r)
|
||||
love.graphics.rectangle(mode, x, y, w, h, r, r)
|
||||
end
|
||||
|
||||
function Editor.load(opts)
|
||||
opts = opts or {}
|
||||
Editor.onClose = opts.onClose
|
||||
Editor.drag = nil
|
||||
Editor.rects = {}
|
||||
Editor.fonts = {
|
||||
title = love.graphics.newFont(28),
|
||||
body = love.graphics.newFont(16),
|
||||
btn = love.graphics.newFont(18),
|
||||
}
|
||||
local optsTbl = SaveData.loadOptions()
|
||||
TouchControls:init()
|
||||
TouchControls:ensureImages()
|
||||
TouchControls:applyOptions(optsTbl)
|
||||
TouchControls:setPreview(true)
|
||||
Editor.enabled = TouchControls.enabled ~= false
|
||||
end
|
||||
|
||||
function Editor.unload()
|
||||
TouchControls:setPreview(false)
|
||||
TouchControls:reset()
|
||||
Editor.drag = nil
|
||||
Editor.onClose = nil
|
||||
end
|
||||
|
||||
local function persist()
|
||||
local opts = SaveData.loadOptions()
|
||||
local cfg = TouchControls:config()
|
||||
opts.touchControls = {
|
||||
enabled = cfg.enabled,
|
||||
positions = cfg.positions,
|
||||
}
|
||||
SaveData.saveOptions(opts)
|
||||
end
|
||||
|
||||
local function close()
|
||||
persist()
|
||||
local cb = Editor.onClose
|
||||
Editor.unload()
|
||||
if cb then cb() end
|
||||
end
|
||||
|
||||
local function resetLayout()
|
||||
TouchControls:clearPositions()
|
||||
end
|
||||
|
||||
local function toggleEnabled()
|
||||
Editor.enabled = not Editor.enabled
|
||||
TouchControls.enabled = Editor.enabled
|
||||
if not Editor.enabled then TouchControls:reset() end
|
||||
end
|
||||
|
||||
function Editor.update(_dt)
|
||||
-- drag follows the live pointer when love.touch / mouse is available;
|
||||
-- touchmoved / mousemoved also update, so this is a belt-and-suspenders
|
||||
-- path for Android where move events can be thin
|
||||
if not Editor.drag then return end
|
||||
local x, y
|
||||
if love.touch and love.touch.getPosition and Editor.drag.touchId then
|
||||
local ok, tx, ty = pcall(love.touch.getPosition, Editor.drag.touchId)
|
||||
if ok and tx then x, y = tx, ty end
|
||||
end
|
||||
if not x and love.mouse then
|
||||
x, y = love.mouse.getPosition()
|
||||
end
|
||||
if x then
|
||||
TouchControls:setControlCenter(
|
||||
Editor.drag.name,
|
||||
x - Editor.drag.offX,
|
||||
y - Editor.drag.offY)
|
||||
end
|
||||
end
|
||||
|
||||
function Editor.draw()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local s = math.max(0.75, math.min(1.4, wh / 768))
|
||||
Editor.rects = {}
|
||||
|
||||
-- radial-ish navy field (two stacked fills; matches launcher atmosphere)
|
||||
col(PAL.bgBot)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
col(PAL.bgTop, 0.85)
|
||||
love.graphics.circle("fill", ww * 0.5, wh * 0.15, math.max(ww, wh) * 0.55)
|
||||
|
||||
local pad = 18 * s
|
||||
local barH = 56 * s
|
||||
local btnH = 40 * s
|
||||
local btnW = 100 * s
|
||||
|
||||
-- top bar
|
||||
col(PAL.card, 0.92)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, barH + pad)
|
||||
col(PAL.stroke, 0.35)
|
||||
love.graphics.setLineWidth(1)
|
||||
love.graphics.line(0, barH + pad, ww, barH + pad)
|
||||
|
||||
love.graphics.setFont(Editor.fonts.title)
|
||||
col(PAL.white)
|
||||
love.graphics.print("Touch Controls", pad, pad + 4 * s)
|
||||
|
||||
-- Done / Reset
|
||||
local done = { x = ww - pad - btnW, y = pad + (barH - btnH) / 2,
|
||||
w = btnW, h = btnH }
|
||||
local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH }
|
||||
Editor.rects.done, Editor.rects.reset = done, reset
|
||||
|
||||
local function chromeBtn(r, label, fill)
|
||||
col(fill, 0.9)
|
||||
roundRect("fill", r.x, r.y, r.w, r.h, 8 * s)
|
||||
col(PAL.white, 0.2)
|
||||
roundRect("line", r.x, r.y, r.w, r.h, 8 * s)
|
||||
love.graphics.setFont(Editor.fonts.btn)
|
||||
col(PAL.white)
|
||||
local tw = Editor.fonts.btn:getWidth(label)
|
||||
love.graphics.print(label, r.x + (r.w - tw) / 2,
|
||||
r.y + (r.h - Editor.fonts.btn:getHeight()) / 2)
|
||||
end
|
||||
chromeBtn(reset, "Reset", { 60, 70, 110 })
|
||||
chromeBtn(done, "Done", PAL.green)
|
||||
|
||||
-- enable toggle card
|
||||
local cardY = barH + pad + 14 * s
|
||||
local cardH = 64 * s
|
||||
local cardX, cardW = pad, ww - 2 * pad
|
||||
col(PAL.card, 0.88)
|
||||
roundRect("fill", cardX, cardY, cardW, cardH, 12 * s)
|
||||
col(PAL.stroke, 0.4)
|
||||
roundRect("line", cardX, cardY, cardW, cardH, 12 * s)
|
||||
|
||||
love.graphics.setFont(Editor.fonts.body)
|
||||
col(PAL.label)
|
||||
love.graphics.print("On-screen controls", cardX + 16 * s,
|
||||
cardY + 12 * s)
|
||||
love.graphics.setFont(Editor.fonts.btn)
|
||||
local on = Editor.enabled
|
||||
col(on and PAL.green or PAL.red)
|
||||
love.graphics.print(on and "ON" or "OFF", cardX + 16 * s,
|
||||
cardY + 34 * s)
|
||||
|
||||
local toggleW = 110 * s
|
||||
local toggle = {
|
||||
x = cardX + cardW - 16 * s - toggleW,
|
||||
y = cardY + (cardH - btnH) / 2,
|
||||
w = toggleW, h = btnH,
|
||||
}
|
||||
Editor.rects.toggle = toggle
|
||||
chromeBtn(toggle, on and "Disable" or "Enable",
|
||||
on and PAL.red or PAL.green)
|
||||
|
||||
-- hint
|
||||
love.graphics.setFont(Editor.fonts.body)
|
||||
col(PAL.label, 0.9)
|
||||
local hint = on
|
||||
and "Drag each button to reposition. Layout is saved when you tap Done."
|
||||
or "Controls are hidden in-game. Enable them to show and edit the layout."
|
||||
love.graphics.printf(hint, pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
|
||||
|
||||
-- the overlay itself (preview mode; dimmed when disabled)
|
||||
TouchControls:draw()
|
||||
|
||||
-- highlight the control under drag
|
||||
if Editor.drag then
|
||||
local L = TouchControls:layout()
|
||||
local zone = L[Editor.drag.name]
|
||||
if zone then
|
||||
love.graphics.setLineWidth(3 * s)
|
||||
col(PAL.green, 0.85)
|
||||
love.graphics.circle("line", zone.cx, zone.cy, zone.w * 0.62)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function beginDrag(id, x, y)
|
||||
-- chrome takes priority over controls
|
||||
if inside(Editor.rects.done, x, y) then close(); return end
|
||||
if inside(Editor.rects.reset, x, y) then resetLayout(); return end
|
||||
if inside(Editor.rects.toggle, x, y) then toggleEnabled(); return end
|
||||
|
||||
local name = TouchControls:hitTest(x, y)
|
||||
if not name then return end
|
||||
local zone = TouchControls:layout()[name]
|
||||
Editor.drag = {
|
||||
name = name,
|
||||
touchId = id,
|
||||
offX = x - zone.cx,
|
||||
offY = y - zone.cy,
|
||||
}
|
||||
end
|
||||
|
||||
local function moveDrag(id, x, y)
|
||||
local d = Editor.drag
|
||||
if not d then return end
|
||||
if d.touchId ~= nil and id ~= nil and d.touchId ~= id then return end
|
||||
TouchControls:setControlCenter(d.name, x - d.offX, y - d.offY)
|
||||
end
|
||||
|
||||
local function endDrag(id)
|
||||
local d = Editor.drag
|
||||
if not d then return end
|
||||
if d.touchId ~= nil and id ~= nil and d.touchId ~= id then return end
|
||||
Editor.drag = nil
|
||||
end
|
||||
|
||||
function Editor.mousepressed(x, y, button)
|
||||
if button ~= 1 then return end
|
||||
beginDrag("mouse", x, y)
|
||||
end
|
||||
|
||||
function Editor.mousemoved(x, y)
|
||||
if love.mouse.isDown(1) then moveDrag("mouse", x, y) end
|
||||
end
|
||||
|
||||
function Editor.mousereleased(x, y, button)
|
||||
if button ~= 1 then return end
|
||||
endDrag("mouse")
|
||||
end
|
||||
|
||||
function Editor.touchpressed(id, x, y)
|
||||
beginDrag(id, x, y)
|
||||
end
|
||||
|
||||
function Editor.touchmoved(id, x, y)
|
||||
moveDrag(id, x, y)
|
||||
end
|
||||
|
||||
function Editor.touchreleased(id, x, y)
|
||||
endDrag(id)
|
||||
end
|
||||
|
||||
function Editor.keypressed(key)
|
||||
if key == "escape" or key == "return" or key == "space" then
|
||||
close()
|
||||
elseif key == "r" then
|
||||
resetLayout()
|
||||
end
|
||||
end
|
||||
|
||||
return Editor
|
||||
+23
-9
@@ -49,9 +49,10 @@ local function dexOf(game, mon)
|
||||
end
|
||||
|
||||
local function spriteOf(game, mon)
|
||||
local path = require("src.pokemon.Sprites").path(game.data, mon.species, "front",
|
||||
{ mon = mon, kind = "trade" })
|
||||
return tryImage(path)
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
game.data, mon.species, "front", { mon = mon, kind = "trade" })
|
||||
local image = tryImage(path)
|
||||
return image, image and trueColor or false
|
||||
end
|
||||
|
||||
local function expand(game, key, subs)
|
||||
@@ -110,8 +111,8 @@ function TradeAnim.new(game, opts)
|
||||
cableBallAlt = tryImage(art.cableBallAlt or DEFAULT_ART.cableBallAlt),
|
||||
bubble = tryImage(art.bubble or DEFAULT_ART.bubble),
|
||||
}
|
||||
self.sentSprite = spriteOf(game, self.sent)
|
||||
self.recvSprite = spriteOf(game, self.received)
|
||||
self.sentSprite, self.sentSpriteTrueColor = spriteOf(game, self.sent)
|
||||
self.recvSprite, self.recvSpriteTrueColor = spriteOf(game, self.received)
|
||||
|
||||
self.seq = 1
|
||||
self.phase = SEQ[1]
|
||||
@@ -349,8 +350,8 @@ function TradeAnim:drawMonInfo(mon, ot, otId, boxTy)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function TradeAnim:drawIconInBubble(mon, x, y)
|
||||
local spr = (mon.species == self.received.species) and self.recvSprite or self.sentSprite
|
||||
function TradeAnim:drawIconInBubble(sprite, x, y)
|
||||
local spr = sprite
|
||||
if spr then
|
||||
local sw, sh = spr:getDimensions()
|
||||
local s = 16 / math.max(sw, sh)
|
||||
@@ -425,6 +426,10 @@ function TradeAnim:draw()
|
||||
-- hWY $50, so it sits in the bottom half of the screen
|
||||
if self.monVisible and self.sentSprite then
|
||||
love.graphics.draw(self.sentSprite, 56, 16)
|
||||
if self.sentSpriteTrueColor then
|
||||
require("src.render.PaletteFX").markTrueColor(
|
||||
56 - self.scx, 16, self.sentSprite:getDimensions())
|
||||
end
|
||||
end
|
||||
self:drawMonInfo(self.sent, self.playerOt, self.playerOtId, 10)
|
||||
love.graphics.pop()
|
||||
@@ -466,8 +471,13 @@ function TradeAnim:draw()
|
||||
love.graphics.translate(160, 0)
|
||||
self:drawRightGB()
|
||||
love.graphics.pop()
|
||||
local mon = (p == "transfer_lr") and self.sent or self.received
|
||||
self:drawIconInBubble(mon, self.monX, self.monY)
|
||||
local sprite
|
||||
if p == "transfer_lr" then
|
||||
sprite = self.sentSprite
|
||||
else
|
||||
sprite = self.recvSprite
|
||||
end
|
||||
self:drawIconInBubble(sprite, self.monX, self.monY)
|
||||
if self.cableFlash then
|
||||
love.graphics.setColor(1, 1, 1, 0.15)
|
||||
love.graphics.rectangle("fill", 0, 32, 160, 8)
|
||||
@@ -477,6 +487,10 @@ function TradeAnim:draw()
|
||||
elseif p == "show_enemy" then
|
||||
if self.monVisible and self.recvSprite then
|
||||
love.graphics.draw(self.recvSprite, 56, 16)
|
||||
if self.recvSpriteTrueColor then
|
||||
require("src.render.PaletteFX").markTrueColor(
|
||||
56, 16, self.recvSprite:getDimensions())
|
||||
end
|
||||
end
|
||||
self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10)
|
||||
end
|
||||
|
||||
+99
-12
@@ -148,6 +148,37 @@ local SPEED_BARS = {
|
||||
{ 0xE0, 0x80, 4 }, { 0xF0, 0x90, 2 },
|
||||
}
|
||||
|
||||
-- PlayIntroScene's .loop (pokeyellow engine/movie/intro_yellow.asm) ends
|
||||
-- every iteration with its own `call DelayFrame`, so one scene handler costs
|
||||
-- one frame plus whatever DelayFrame calls it makes itself. The port runs a
|
||||
-- handler inside a single update() tick, so every one of those internal
|
||||
-- DelayFrame calls has to be spent explicitly or the movie runs short of
|
||||
-- hardware. Three places owe frames:
|
||||
--
|
||||
-- SETUP_SCENE_DELAY scenes 2/4/6/8/10/12 open with
|
||||
-- YellowIntro_BlankPalsDelay2AndDisableLCD: rBGP/rOBP0/
|
||||
-- rOBP1 to $00 (color 0 in every shade -- the lightest,
|
||||
-- white on DMG), two DelayFrame calls, then DisableLCD.
|
||||
-- Two internal plus the loop's own is a 3-frame
|
||||
-- iteration, after which the next wait scene takes its
|
||||
-- first timer decrement. Func_f9e9a restores rBGP $e4
|
||||
-- at the end of the same handler.
|
||||
-- HEAD_FRAMES PlayIntroScene spends a DelayFrame between
|
||||
-- InitYellowIntroGFXAndMusic (which ends in PlayMusic)
|
||||
-- and the first .loop pass, and YellowIntroScene0 then
|
||||
-- owns a whole iteration of its own.
|
||||
-- EXIT_FRAMES .go_to_title_screen blanks the palettes and spends
|
||||
-- four DelayFrame calls clearing the tilemap and OAM
|
||||
-- before the title screen is built.
|
||||
--
|
||||
-- These restore the movie's real elapsed length; they do NOT lengthen the
|
||||
-- intro song. title.asm's StopAllMusic before MUSIC_TITLE_SCREEN is
|
||||
-- unconditional (only PikachuCry1 is waited on), so hardware cuts
|
||||
-- Music_YellowIntro off mid-phrase too (#523).
|
||||
local SETUP_SCENE_DELAY = 3
|
||||
local HEAD_FRAMES = 2
|
||||
local EXIT_FRAMES = 4
|
||||
|
||||
-- scene-6 sine (YellowIntro_Copy8BitSineWave.SineWave), signed SCY deltas
|
||||
local WAVE = { 0, 0, 1, 2, 2, 3, 3, 3, 4, 3, 3, 3, 2, 2, 1, 0,
|
||||
0, 0, -1, -2, -2, -3, -3, -3, -4, -3, -3, -3, -2, -2, -1, 0 }
|
||||
@@ -208,6 +239,8 @@ function YellowIntro.new(game, onDone)
|
||||
self.palName = "MEWMON" -- PalPacket_Generic
|
||||
self.objects = {}
|
||||
self.cloudFrame = 0
|
||||
self.pendingThen = nil -- what enterDelay runs once pendingDelay hits 0 (#523)
|
||||
self.pendingDelay = 0
|
||||
|
||||
self.atlas1 = tryImage("assets/generated/intro/yellow_intro_1.png")
|
||||
self.atlas2 = tryImage("assets/generated/intro/yellow_intro_2.png")
|
||||
@@ -559,6 +592,24 @@ function YellowIntro:startScene(scene)
|
||||
end
|
||||
end
|
||||
|
||||
-- Spend `frames` real frames, then run fn: the port's stand-in for the
|
||||
-- DelayFrame calls a pokeyellow scene handler makes inside its own
|
||||
-- PlayIntroScene .loop iteration (#523). fn may be nil to just burn time.
|
||||
function YellowIntro:enterDelay(frames, fn)
|
||||
self.pendingDelay = frames
|
||||
self.pendingThen = fn
|
||||
end
|
||||
|
||||
-- YellowIntro_BlankPalsDelay2AndDisableLCD blanks the palettes for the
|
||||
-- delay; Func_f9e9a puts rBGP back to $e4 at the tail of the same handler.
|
||||
function YellowIntro:enterSetupScene(scene)
|
||||
self.bgp = 0x00
|
||||
self:enterDelay(SETUP_SCENE_DELAY, function()
|
||||
self.bgp = 0xE4
|
||||
self:startScene(scene)
|
||||
end)
|
||||
end
|
||||
|
||||
function YellowIntro:enter()
|
||||
if self.pre then return end -- pre-roll first; beginScenes takes over
|
||||
self:beginScenes()
|
||||
@@ -571,7 +622,11 @@ function YellowIntro:beginScenes()
|
||||
local song = songs and (songs.Music_YellowIntro and "Music_YellowIntro"
|
||||
or songs.Music_IntroBattle and "Music_IntroBattle")
|
||||
if song then pcall(Music.play, data, song, false) end
|
||||
self:startScene(0)
|
||||
-- HEAD_FRAMES: the DelayFrame between PlayMusic and the first .loop pass
|
||||
-- plus YellowIntroScene0's own iteration. new() already laid down the
|
||||
-- Func_f9e5f letterbox that InitYellowIntroGFXAndMusic writes, so these
|
||||
-- frames show what hardware shows: the letterbox with no pika yet.
|
||||
self:enterDelay(HEAD_FRAMES, function() self:startScene(0) end)
|
||||
end
|
||||
|
||||
-- The movie's exit never stops the song (intro_yellow.asm PlayIntroScene
|
||||
@@ -586,16 +641,44 @@ function YellowIntro:finish()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
|
||||
-- Both scene-loop exits (scene 17 running out, and the A/B/START skip) land
|
||||
-- on .go_to_title_screen, which calls YellowIntro_BlankPalettes and then
|
||||
-- spends EXIT_FRAMES DelayFrame calls clearing the tilemap and the OAM
|
||||
-- buffers before the title screen is built (#523). The pre-roll's own skip
|
||||
-- is IntroMovie's path, not this one, and still finishes immediately.
|
||||
function YellowIntro:exitToTitle()
|
||||
if self.finished or self.exiting then return end
|
||||
self.exiting = true
|
||||
self.bgp = 0x00
|
||||
self:clearObjects()
|
||||
self:enterDelay(EXIT_FRAMES, function() self:finish() end)
|
||||
end
|
||||
|
||||
function YellowIntro:update(dt)
|
||||
if self.finished then return end
|
||||
if self.pre then
|
||||
self.pre:update(dt)
|
||||
return
|
||||
end
|
||||
-- Ahead of the skip poll on purpose: JoypadLowSensitivity runs once at the
|
||||
-- top of each .loop iteration, never inside a handler, so hardware is deaf
|
||||
-- for the DelayFrame calls these ticks stand in for (#523).
|
||||
if self.pendingDelay > 0 then
|
||||
self.pendingDelay = self.pendingDelay - 1
|
||||
if self.pendingDelay == 0 then
|
||||
local fn = self.pendingThen
|
||||
self.pendingThen = nil
|
||||
if fn then fn() end
|
||||
end
|
||||
self:updateObjects()
|
||||
if self.bgDirty then self:rebuildBgCanvas() end
|
||||
return
|
||||
end
|
||||
|
||||
local input = self.game.input
|
||||
if input:wasPressed("a") or input:wasPressed("b")
|
||||
or input:wasPressed("start") then
|
||||
self:finish()
|
||||
self:exitToTitle()
|
||||
return
|
||||
end
|
||||
|
||||
@@ -605,7 +688,7 @@ function YellowIntro:update(dt)
|
||||
self.timer = self.timer - 1
|
||||
else
|
||||
self:clearObjects()
|
||||
self:startScene(scene + 1)
|
||||
self:enterSetupScene(scene + 1) -- BlankPalsDelay2AndDisableLCD (#523)
|
||||
end
|
||||
elseif scene == 3 then
|
||||
if self.timer > 0 then
|
||||
@@ -613,7 +696,7 @@ function YellowIntro:update(dt)
|
||||
if self.scx ~= 0x68 then self.scx = self.scx + 4 end
|
||||
else
|
||||
self:clearObjects()
|
||||
self:startScene(4)
|
||||
self:enterSetupScene(4) -- BlankPalsDelay2AndDisableLCD (#523)
|
||||
end
|
||||
elseif scene == 7 then
|
||||
if self.timer > 0 then
|
||||
@@ -625,7 +708,7 @@ function YellowIntro:update(dt)
|
||||
self.wave[255] = first
|
||||
else
|
||||
self:clearObjects()
|
||||
self:startScene(8)
|
||||
self:enterSetupScene(8) -- BlankPalsDelay2AndDisableLCD (#523)
|
||||
end
|
||||
elseif scene == 11 then
|
||||
if self.timer > 0 then
|
||||
@@ -640,7 +723,7 @@ function YellowIntro:update(dt)
|
||||
self.timer = self.timer - 1
|
||||
else
|
||||
self:clearObjects()
|
||||
self:startScene(12)
|
||||
self:enterSetupScene(12) -- BlankPalsDelay2AndDisableLCD (#523)
|
||||
end
|
||||
elseif scene == 13 then
|
||||
if self.timer > 0 then
|
||||
@@ -656,13 +739,17 @@ function YellowIntro:update(dt)
|
||||
if v then
|
||||
self.bgp = v
|
||||
else
|
||||
-- .expired: everything despawns, letterbox returns, logo/face
|
||||
-- object $7 appears for the strobe scene
|
||||
-- .expired: everything despawns and the letterbox returns, then the
|
||||
-- handler spends three DelayFrame calls with hAutoBGTransferEnabled
|
||||
-- pushing that rebuilt tilemap to VRAM before it restores rBGP $e4
|
||||
-- and spawns the logo/face object $7 for the strobe scene (#523).
|
||||
self:clearObjects()
|
||||
self:bgLetterbox()
|
||||
self.bgp = 0xE4
|
||||
self:spawn(7, 0x58, 0x58)
|
||||
self:startScene(15)
|
||||
self:enterDelay(3, function()
|
||||
self.bgp = 0xE4
|
||||
self:spawn(7, 0x58, 0x58)
|
||||
self:startScene(15)
|
||||
end)
|
||||
end
|
||||
elseif scene == 15 then
|
||||
if self.timer > 0 then
|
||||
@@ -687,7 +774,7 @@ function YellowIntro:update(dt)
|
||||
if self.timer > 0 then
|
||||
self.timer = self.timer - 1
|
||||
else
|
||||
self:finish()
|
||||
self:exitToTitle()
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
-- surfing, Cut trees, trainer sight lines, and dispatches interactions to
|
||||
-- map scripts (data/scripts/), marts, nurses or extracted text.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Camera = require("src.render.Camera")
|
||||
local Collision = require("src.world.Collision")
|
||||
local Encounter = require("src.world.Encounter")
|
||||
@@ -359,6 +360,36 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
else
|
||||
self.player = Player.new(Game.data, x, y, facing)
|
||||
end
|
||||
-- boot only: the original persists the surf state. wWalkBikeSurfState
|
||||
-- (ram/wram.asm) lives inside wMainDataStart..wMainDataEnd, which
|
||||
-- engine/menus/save.asm block-copies into sMainData on save and back out
|
||||
-- on load (sram.asm declares sMainData as `ds wMainDataEnd -
|
||||
-- wMainDataStart`), and Continue never clears it -- the only `xor a /
|
||||
-- ld [wWalkBikeSurfState], a` on that path is the cable club's. Restore
|
||||
-- it here, before Music.playMap reads it below and before
|
||||
-- PikachuFollower.onMapEntered, matching LoadMapData calling
|
||||
-- LoadPlayerSpriteGraphics ahead of PlayDefaultMusic (home/overworld.asm,
|
||||
-- home/audio.asm). Without this the player resumed on foot on a water
|
||||
-- cell, which softlocks here: Collision.canMove (src/world/Collision.lua)
|
||||
-- picks land tile-pairs whenever mover.surfing is falsy, and land
|
||||
-- tile-pairs never permit stepping off a water cell (#536). Same
|
||||
-- boot-only shape as the refreshStandingOnWarp door-mat restore (#378).
|
||||
if opts and opts.via == "boot" then
|
||||
local ps = Game.save and Game.save.player
|
||||
if ps and ps.surfing ~= nil then
|
||||
self.player.surfing = ps.surfing and true or false
|
||||
else
|
||||
-- saves written before #536 carry no flag. Map:isWaterCell alone is
|
||||
-- not self-sufficient (see src/world/Map.lua: water and shore share
|
||||
-- one lookup, and no tileset stamps waterTiles, so tile $14 -- a
|
||||
-- walkable floor in HOUSE/GATE/LOBBY/MANSION/MUSEUM -- reads as
|
||||
-- water), so gate it the way facingIsShoreOrWater does and require a
|
||||
-- cell you could not be standing on upright.
|
||||
self.player.surfing = self:tilesetHasWater()
|
||||
and not self.map:isWalkableCell(x, y)
|
||||
and self.map:isWaterCell(x, y)
|
||||
end
|
||||
end
|
||||
-- crossConnection re-arms this after setMap; clear so a warp/reload
|
||||
-- cannot leave a stale deferred PlayMapMusic pending
|
||||
self.pendingSeamMusic = nil
|
||||
@@ -550,9 +581,26 @@ end
|
||||
-- UI-pass palette (text boxes and menus tint with the current map). OG RED
|
||||
-- resolves every name to the one global red BG palette inside PaletteFX.pal,
|
||||
-- so this needs no mode-specific branch.
|
||||
--
|
||||
-- TalkToPikachu's framed frontpic is the one exception: pokeyellow
|
||||
-- LoadOverworldPikachuFrontpicPalettes loads the map pal as slot 0 and
|
||||
-- PAL_PIKACHU_PORTRAIT as slot 1, then ATTR_BLK's the 5x5 pic at
|
||||
-- (7,6)-(11,10) onto slot 1 (engine/gfx/palettes.asm:345-391). Without
|
||||
-- that zone the pic wears the route/town palette and looks washed out.
|
||||
function OverworldState:sgbPalettes()
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
return PaletteFX.wholeNamed(Game.data, self:paletteNameFor(self.map))
|
||||
local mapName = self:paletteNameFor(self.map)
|
||||
if self.emote and self.emote.pikaPic then
|
||||
local base = PaletteFX.pal(Game.data, mapName)
|
||||
if not base then return nil end
|
||||
local zones = { PaletteFX.whole(base) }
|
||||
local portrait = PaletteFX.pal(Game.data, "PIKACHU_PORTRAIT")
|
||||
if portrait then
|
||||
zones[#zones + 1] = PaletteFX.zone(portrait, 7, 6, 11, 10)
|
||||
end
|
||||
return zones
|
||||
end
|
||||
return PaletteFX.wholeNamed(Game.data, mapName)
|
||||
end
|
||||
|
||||
-- World-pass palette zones in world-canvas pixels: each visible map
|
||||
@@ -1040,19 +1088,38 @@ function OverworldState:handleInput()
|
||||
-- OverworldLoop (home/overworld.asm) gates ALL of JoypadOverworld on
|
||||
-- wWalkCounter == 0 ("if the player sprite has not yet completed the
|
||||
-- walking animation" it jumps straight to .moveAhead): A, START and
|
||||
-- direction initiation are only ever looked at while the player stands
|
||||
-- on a tile, and a button pressed mid-step is simply never seen.
|
||||
-- Without this gate a mid-step A/START pushed its TextBox/StartMenu
|
||||
-- right there and froze Red between tiles, mid-animation (#286). Held
|
||||
-- directions need no buffering -- isDown below picks them up on the
|
||||
-- landing frame.
|
||||
if self.player.moving then return end
|
||||
-- direction initiation are only ever ACTED ON while the player stands on
|
||||
-- a tile. Without this gate a mid-step A/START pushed its TextBox/
|
||||
-- StartMenu right there and froze Red between tiles, mid-animation
|
||||
-- (#286). Held directions need no buffering -- isDown below picks them
|
||||
-- up on the landing frame.
|
||||
--
|
||||
-- The original defers the poll rather than discarding it, though. Joypad
|
||||
-- (engine/joypad.asm _Joypad) computes hJoyPressed against hJoyLast and
|
||||
-- advances hJoyLast only when something calls it; the mid-step path never
|
||||
-- does, and vblank's per-frame ReadJoypad refreshes hJoyInput alone.
|
||||
-- hJoyLast is frozen for the whole animation, so a button pressed
|
||||
-- mid-step and STILL HELD when the step lands reads as a fresh press at
|
||||
-- the next poll -- one released before then is genuinely lost. Dropping
|
||||
-- the edge outright made START a coin flip on the Cycling Road roll,
|
||||
-- where the pull below re-arms a step on the single idle frame in
|
||||
-- bikeStepFrames (#525).
|
||||
if self.player.moving then
|
||||
local held = self.joyLatch
|
||||
if not held then held = {}; self.joyLatch = held end
|
||||
if input:wasPressed("a") then held.a = true end
|
||||
if input:wasPressed("start") then held.start = true end
|
||||
return
|
||||
end
|
||||
local latch = self.joyLatch
|
||||
self.joyLatch = nil
|
||||
|
||||
if input:wasPressed("a") then
|
||||
if input:wasPressed("a") or (latch and latch.a and input:isDown("a")) then
|
||||
self:interact()
|
||||
return
|
||||
end
|
||||
if input:wasPressed("start") then
|
||||
if input:wasPressed("start")
|
||||
or (latch and latch.start and input:isDown("start")) then
|
||||
require("src.core.Sound").play(Game.data, "Start_Menu")
|
||||
Screens.push(Game, "StartMenu")
|
||||
return
|
||||
@@ -3915,6 +3982,31 @@ function OverworldState:draw()
|
||||
self:drawUI()
|
||||
end
|
||||
|
||||
-- The emote sheet is OBJ art (engine/overworld/emotion_bubbles.asm builds the
|
||||
-- bubble out of shadow OAM), so it renders through OBP0, and GBPalNormal
|
||||
-- (home/palettes.asm:20-26 `ld a, %11010000 ; 3100 / ldh [rOBP0], a`) holds
|
||||
-- OBP0 at "3100": OBJ color 1 shows as shade 0, color 2 as shade 1, color 3
|
||||
-- as shade 3. Blitting the raw sheet skipped that lift and left the "!"
|
||||
-- bubble's interior (color 1) at DMG shade 1 grey instead of white (#505).
|
||||
-- Same CPU-remap bake as SpriteRenderer.getObpImage and PartyMenu's obpIcon,
|
||||
-- and it resolves through Assets so a mod's emotes.png override still wins.
|
||||
-- Color 0's alpha (a tRNS entry on the extracted png) is what keys the
|
||||
-- bubble's corners out, so carry it through untouched.
|
||||
local function obpEmoteImage(path)
|
||||
if not (love.image and love.image.newImageData) then
|
||||
return love.graphics.newImage(Assets.resolve(path)) -- headless stub
|
||||
end
|
||||
local id = Assets.imageData(path)
|
||||
id:mapPixel(function(_, _, r, _, _, a)
|
||||
local v = 0
|
||||
if r > 0.5 then v = 1 -- OBJ colors 0 and 1 -> shade 0
|
||||
elseif r > 0.17 then v = 170 / 255 -- OBJ color 2 -> shade 1
|
||||
end -- OBJ color 3 -> shade 3
|
||||
return v, v, v, a
|
||||
end)
|
||||
return love.graphics.newImage(id)
|
||||
end
|
||||
|
||||
-- The SGB palette a tilt-mode billboard at flat foot (fx, fy) sits under.
|
||||
-- World zones are rectangles in flat world-canvas space (the current map's
|
||||
-- base fills the view; neighbour maps stack on top), so the last zone that
|
||||
@@ -4154,7 +4246,7 @@ function OverworldState:drawWorld()
|
||||
local drawn = false
|
||||
if bubble and bubble.path then
|
||||
local ok, img = pcall(function()
|
||||
self.emoteImg = self.emoteImg or love.graphics.newImage(bubble.path)
|
||||
self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path)
|
||||
return self.emoteImg
|
||||
end)
|
||||
-- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command
|
||||
@@ -4479,7 +4571,8 @@ function OverworldState:drawUI()
|
||||
-- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so the
|
||||
-- front pic stands in for every frame of the script; PikachuFollower
|
||||
-- .picLift lifts it on the runs that draw the alternate pose, and the
|
||||
-- script's own duration times the beat (#407, #424).
|
||||
-- script's own duration times the beat (#407, #424). Palette zone
|
||||
-- PAL_PIKACHU_PORTRAIT covers (7,6)-(11,10) via sgbPalettes above.
|
||||
if self.emote and self.emote.pikaPic then
|
||||
require("src.render.Font").drawBox(6, 5, 7, 7)
|
||||
-- one image per path, cached: this draws every frame of the hold, and
|
||||
@@ -4517,6 +4610,11 @@ function OverworldState:captureSave(save)
|
||||
save.player.x = self.player.cellX
|
||||
save.player.y = self.player.cellY
|
||||
save.player.facing = self.player.facing
|
||||
-- wWalkBikeSurfState (ram/wram.asm) sits inside the wMainDataStart..
|
||||
-- wMainDataEnd range engine/menus/save.asm block-copies into sMainData,
|
||||
-- so the original saves and restores the surf state; setMap's boot path
|
||||
-- reads this back (#536).
|
||||
save.player.surfing = self.player.surfing and true or false
|
||||
end
|
||||
|
||||
return OverworldState
|
||||
|
||||
@@ -12,9 +12,13 @@ local Player = {}
|
||||
Player.__index = Player
|
||||
|
||||
local STEP_FRAMES = 16
|
||||
-- a turn in place holds for the ~2 frames the original spends on the
|
||||
-- extra OverworldLoop pass (home/overworld.asm .handleDirectionButtonPress
|
||||
-- returns to the loop without moving after a direction change)
|
||||
-- a turn in place blocks movement for the one extra OverworldLoop pass the
|
||||
-- original spends after a direction change: .handleDirectionButtonPress ends
|
||||
-- `jp OverworldLoop` (home/overworld.asm), and OverworldLoop burns two
|
||||
-- DelayFrame calls before the next JoypadOverworld, so the sample that can
|
||||
-- commit to a step lands exactly 2 fixed steps after the turn -- the same
|
||||
-- 2-frames-per-iteration cadence that makes STEP_FRAMES 16 above
|
||||
-- (wWalkCounter = 8, 2px per AdvancePlayerSprite) (#415)
|
||||
local TURN_FRAMES = 2
|
||||
|
||||
function Player.new(data, cx, cy, facing)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
-- Manual check that the BICYCLE refuses to come off on the Cycling Road (#513).
|
||||
-- pokered gates this in StartMenu_Item .useOrTossItem (engine/menus/
|
||||
-- start_sub_menus.asm): with BIT_ALWAYS_ON_BIKE set it prints
|
||||
-- _CannotGetOffHereText and `jp ItemMenuLoop`, so the bag stays open and
|
||||
-- ItemUseBicycle never runs. Do not add POKEPORT_SPEED: it scales only the
|
||||
-- logic clock, and the menu/text ordering under test is what is being judged.
|
||||
-- POKEPORT_DRIVER=tests/drivers/bike_dismount_bug513_test.lua POKEPORT_IDENTITY=bug513 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local ok = true
|
||||
local function check(label, pass)
|
||||
U.log(pass and "PASS" or "FAIL", label)
|
||||
if not pass then ok = false end
|
||||
return pass
|
||||
end
|
||||
|
||||
-- pokered data/maps/force_bike_surf.asm: ROUTE_16 (17,10) and (17,11) are
|
||||
-- the two cells you land on walking out of the Route 16 gate's south door,
|
||||
-- and they are what arms BIT_ALWAYS_ON_BIKE. Landing there by warp is the
|
||||
-- real path, and setMap runs checkForcedMovement on entry, so a teleport
|
||||
-- onto the cell arms the flag exactly like the warp does.
|
||||
local FORCE_MAP, FORCE_X, FORCE_Y = "ROUTE_16", 17, 11
|
||||
-- the walking exit from the stretch (FieldDefaults forcedMovement.clearMaps,
|
||||
-- from scripts/Route16Gate1F.asm `res BIT_ALWAYS_ON_BIKE`)
|
||||
local GATE_MAP = "ROUTE_16_GATE_1F"
|
||||
-- pokered data/maps/objects/Route17.asm: the topmost bikers stand at
|
||||
-- (11,16), (12,19) and (4,18), so the y 8-14 stretch is the open top of the
|
||||
-- road with nobody's line of sight crossing it. Route 17 is 20x144 cells,
|
||||
-- and the bike rolls south on its own, so start high enough that the roll
|
||||
-- below cannot carry the player into BIKER2's row.
|
||||
local ROAD_MAP, ROAD_X, ROAD_Y = "ROUTE_17", 11, 9
|
||||
|
||||
local cannot = game.data.text._CannotGetOffHereText
|
||||
check("_CannotGetOffHereText was extracted",
|
||||
type(cannot) == "string" and cannot:find("get off", 1, true) ~= nil)
|
||||
local fm = game.data.field.forcedMovement
|
||||
local forced = fm and fm.tiles and fm.tiles[FORCE_MAP]
|
||||
check("the Route 16 forced-bike cells are in field.forcedMovement",
|
||||
type(forced) == "table" and #forced > 0)
|
||||
check("BICYCLE exists as an item", game.data.items.BICYCLE ~= nil)
|
||||
|
||||
game.save.player.name = "SEBAS"
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
game.save.inventory.BICYCLE = 1
|
||||
game.save.forcedBike = nil
|
||||
game.save.onBike = false
|
||||
|
||||
-- arm the flag the way the game does, by arriving on the forced cell
|
||||
U.teleport(game, FORCE_MAP, FORCE_X, FORCE_Y, "down")
|
||||
U.wait(20)
|
||||
check("stepping off the Route 16 gate mounts the BICYCLE", game.save.onBike == true)
|
||||
check("and sets the forced-bike flag", game.save.forcedBike == true)
|
||||
|
||||
-- the release half: the gate map has to clear it again, or the fix would
|
||||
-- trap the player on the bike forever, which reads on screen exactly like
|
||||
-- the refusal working
|
||||
-- pokered data/maps/objects/Route16Gate1F.asm: the warps sit on x 0 and 7,
|
||||
-- the guard on (4,5), so (3,6) is plain floor inside the gate
|
||||
U.teleport(game, GATE_MAP, 3, 6, "up")
|
||||
U.wait(20)
|
||||
check("walking into the Route 16 gate clears the flag again",
|
||||
game.save.forcedBike == nil or game.save.forcedBike == false)
|
||||
|
||||
-- back out onto the road, then down to Route 17 where the report's
|
||||
-- screenshot was taken
|
||||
game.save.onBike = false
|
||||
U.teleport(game, FORCE_MAP, FORCE_X, FORCE_Y, "down")
|
||||
U.wait(20)
|
||||
U.teleport(game, ROAD_MAP, ROAD_X, ROAD_Y, "down")
|
||||
|
||||
-- Route 17 is a slopeMap: with no button held the bike rolls south every
|
||||
-- frame the player is standing still (handleInput's simulated PAD_DOWN),
|
||||
-- and OverworldLoop only looks at START between steps. Holding B is the
|
||||
-- brake the Route 17 sign describes, so hold it while waiting, exactly
|
||||
-- like a player coming to a stop before opening the menu (#255).
|
||||
local function brake(frames)
|
||||
for _ = 1, frames do
|
||||
game.input.state.b = true
|
||||
coroutine.yield()
|
||||
end
|
||||
game.input.state.b = false
|
||||
end
|
||||
brake(30)
|
||||
|
||||
local ow = game.overworld
|
||||
local function freeCell(x, y)
|
||||
if not (ow and ow.map:inBounds(x, y) and ow.map:isWalkableCell(x, y)) then
|
||||
return false
|
||||
end
|
||||
if ow:npcAtCell(x, y) then return false end
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if math.abs(n.cellX - x) + math.abs(n.cellY - y) <= 3 then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
if ow and not freeCell(ROAD_X, ROAD_Y) then
|
||||
-- a map edit or a mod moved the road: take any open cell in the top
|
||||
-- stretch rather than parking the player inside the guard rail
|
||||
local found
|
||||
for y = 8, 30 do
|
||||
for x = 0, 19 do
|
||||
if freeCell(x, y) then found = { x, y } break end
|
||||
end
|
||||
if found then break end
|
||||
end
|
||||
if found then
|
||||
U.log(("(%d,%d) is blocked, standing at"):format(ROAD_X, ROAD_Y),
|
||||
found[1], found[2])
|
||||
U.teleport(game, ROAD_MAP, found[1], found[2], "down")
|
||||
brake(30)
|
||||
ow = game.overworld
|
||||
else
|
||||
check("found an open cell on the Route 17 top stretch", false)
|
||||
end
|
||||
end
|
||||
check("on Route 17, still riding, flag still armed",
|
||||
game.overworld and game.overworld.map.id == ROAD_MAP
|
||||
and game.save.onBike == true and game.save.forcedBike == true)
|
||||
|
||||
-- open START -> ITEM -> BICYCLE -> USE for real, row by row, so a menu
|
||||
-- that reorders itself shows up as a FAIL instead of a silent misclick
|
||||
local function stepTo(state, wants, what)
|
||||
for _ = 1, 20 do
|
||||
if wants(state) then return true end
|
||||
U.tap(game, "down")
|
||||
U.wait(6)
|
||||
end
|
||||
check("found " .. what .. " in the menu", false)
|
||||
return false
|
||||
end
|
||||
|
||||
-- brake into a standstill first, then press START on a frame where the
|
||||
-- player is between no steps at all, or handleInput drops it
|
||||
for _ = 1, 90 do
|
||||
game.input.state.b = true
|
||||
if not (game.overworld and game.overworld.player.moving) then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
table.insert(game.input.pressQueue, "start")
|
||||
U.wait(1)
|
||||
game.input.state.start = false
|
||||
game.input.state.b = false
|
||||
U.wait(20)
|
||||
local menu = game.stack:top()
|
||||
local Strings = require("src.core.Strings")
|
||||
local ITEM = Strings("ITEM")
|
||||
if check("START opened a menu", menu and menu.items ~= nil) then
|
||||
if stepTo(menu, function(m) return m.items[m.index].label == ITEM end, "ITEM") then
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
end
|
||||
end
|
||||
|
||||
local bag = game.stack:top()
|
||||
local isBag = bag and bag.items and bag.items[1] and bag.items[1].value ~= nil
|
||||
if check("the bag opened", isBag) then
|
||||
if stepTo(bag, function(b) return b.items[b.index].value == "BICYCLE" end,
|
||||
"BICYCLE") then
|
||||
U.tap(game, "a") -- USE / TOSS submenu
|
||||
U.wait(20)
|
||||
U.tap(game, "a") -- USE is the first row
|
||||
U.wait(30)
|
||||
end
|
||||
end
|
||||
|
||||
local top = game.stack:top()
|
||||
local box = getmetatable(top) == TextBox and top or nil
|
||||
if check("using the BICYCLE printed something", box ~= nil) then
|
||||
local lines = {}
|
||||
for _, page in ipairs(box.pages or {}) do
|
||||
for _, line in ipairs(page) do lines[#lines + 1] = line end
|
||||
end
|
||||
local said = table.concat(lines, " / ")
|
||||
U.log("the box reads:", said)
|
||||
check("it is the refusal, not the dismount message",
|
||||
said:find("get off", 1, true) ~= nil
|
||||
and said:find("BICYCLE", 1, true) == nil)
|
||||
end
|
||||
check("the player is still on the BICYCLE", game.save.onBike == true)
|
||||
-- pokered jumps back to ItemMenuLoop rather than closing the menu, so the
|
||||
-- bag list must still be underneath the box
|
||||
local bagStillUp = false
|
||||
for _, s in ipairs(game.stack.states) do
|
||||
if s == bag then bagStillUp = true end
|
||||
end
|
||||
check("the bag list is still open under the box", bagStillUp)
|
||||
|
||||
U.wait(90) -- let the box finish typing, or the shot catches half a word
|
||||
local shotPath = SHOT_DIR .. "/bug513_bike_refusal.png"
|
||||
check("screenshot reached disk", U.shot(game, shotPath))
|
||||
|
||||
U.log("")
|
||||
if ok then
|
||||
U.log("the BICYCLE has already been used once on Route 17; the box on")
|
||||
U.log("screen is that refusal. it should read \"You can't get off / here.\"")
|
||||
U.log("and B should drop you back into the bag list with the sprite still")
|
||||
U.log("on the bike, never onto your feet. shot saved to " .. shotPath)
|
||||
U.log("press B twice and try it again as often as you like. the road")
|
||||
U.log("rolls you south whenever you let go, so hold A or B to stop.")
|
||||
U.log("then ride north off Route 17 and into the Route 16 gate, where")
|
||||
U.log("USE should print \"SEBAS got off the BICYCLE.\" and put you on")
|
||||
U.log("foot -- if it refuses in there too, the guard never releases the")
|
||||
U.log("flag and the fix went a step too far.")
|
||||
else
|
||||
U.log("a check above failed, so nothing on screen is worth reading yet.")
|
||||
end
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
-- Driver: opening the START menu while the bike force-rolls down Cycling
|
||||
-- Road (#525). src/world/OverworldController.lua handleInput() used to
|
||||
-- drop A/START outright while self.player.moving, so a press that landed
|
||||
-- mid-step and was still held when the step completed vanished -- on
|
||||
-- Cycling Road's forced roll, where a step re-arms on the single idle
|
||||
-- frame between steps, that made START a coin flip. The fix latches a
|
||||
-- still-held press and acts on it on the landing frame instead
|
||||
-- (see tests/parity_midstep_buttons.lua for the mechanical half).
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/cycling_road_menu_bug525_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug525 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
|
||||
-- SHOT_DIR=/tmp/shots love .
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- Same clear stretch bug255 uses: x=2 is open road from y=8 past y=34.
|
||||
local MAP, START_X, START_Y = "ROUTE_17", 2, 20
|
||||
|
||||
game.save.onBike = true
|
||||
U.teleport(game, MAP, START_X, START_Y, "down")
|
||||
U.wait(15)
|
||||
local ow = game.overworld
|
||||
check("standing on Cycling Road, on the bike",
|
||||
ow.map.id == MAP and game.save.onBike)
|
||||
|
||||
-- hands off the pad: the slope pulls the bike south on its own
|
||||
-- (field.forcedMovement.slopeMaps, same mechanism bug255 proved)
|
||||
U.wait(20)
|
||||
check("the roll is already under way", ow.player.moving == true
|
||||
or ow.player.cellY > START_Y)
|
||||
U.shot(game, DIR .. "/bug525_1_rolling.png")
|
||||
|
||||
-- press START mid-step and keep it held through the landing frame, the
|
||||
-- exact shape a real thumb makes reaching for the menu while rolling
|
||||
local pressedMidStep = false
|
||||
local guard = 0
|
||||
while guard < 60 do
|
||||
guard = guard + 1
|
||||
if ow.player.moving and ow.player.progress
|
||||
and ow.player.progress > 2 and not pressedMidStep then
|
||||
table.insert(game.input.pressQueue, "start")
|
||||
game.input.state.start = true
|
||||
pressedMidStep = true
|
||||
U.log("pressed START mid-step at progress", ow.player.progress)
|
||||
end
|
||||
coroutine.yield()
|
||||
if getmetatable(game.stack:top()) ~= nil
|
||||
and game.stack:top() ~= ow and pressedMidStep then
|
||||
break
|
||||
end
|
||||
end
|
||||
U.wait(2)
|
||||
game.input.state.start = false
|
||||
|
||||
local top = game.stack:top()
|
||||
U.shot(game, DIR .. "/bug525_2_after_start.png")
|
||||
check("START held through the landing opened the start menu",
|
||||
top ~= ow and top ~= nil)
|
||||
check("the player is not frozen mid-tile (px/py land on a 16px cell)",
|
||||
ow.player.px % 16 == 0 and ow.player.py % 16 == 0)
|
||||
|
||||
-- close it and let the roll continue, proving the pull did not get
|
||||
-- eaten along with the buffered button
|
||||
if top ~= ow then
|
||||
while game.stack:top() ~= ow do
|
||||
U.tap(game, "b")
|
||||
U.wait(10)
|
||||
end
|
||||
end
|
||||
local yBeforeResume = ow.player.cellY
|
||||
U.wait(48)
|
||||
check("the roll resumes south after closing the menu",
|
||||
ow.player.cellY > yBeforeResume)
|
||||
U.shot(game, DIR .. "/bug525_3_resumed.png")
|
||||
|
||||
-- park with road left to roll, on the bike, before handing over
|
||||
U.teleport(game, MAP, START_X, START_Y, "down")
|
||||
U.wait(10)
|
||||
game.save.onBike = true
|
||||
|
||||
U.log("On the bike on Cycling Road at (" .. START_X .. "," .. START_Y ..
|
||||
"), rolling south hands-off.")
|
||||
U.log("Press START right as you see a step land, and again mid-step")
|
||||
U.log("while releasing before it lands. Right: a START you keep pressed")
|
||||
U.log("into the landing opens the menu every time, one you let go of")
|
||||
U.log("mid-step does nothing (same as standing still). #525 was the")
|
||||
U.log("held case sometimes doing nothing at all, a coin flip on this hill.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,148 @@
|
||||
-- Manual check on the "!" bubble a trainer pops when he spots you (#505).
|
||||
-- The sheet is OBJ art, so it displays through OBP0, and GBPalNormal
|
||||
-- (pokered home/palettes.asm:20-26, `ld a, %11010000 ; 3100`) shows OBJ
|
||||
-- color 1 as shade 0. The draw site blitted the raw png instead, leaving
|
||||
-- the bubble's interior at its pre-OBP0 grey. Do not add POKEPORT_SPEED:
|
||||
-- the sight-line freeze and the 60-frame bubble hold are being watched.
|
||||
-- POKEPORT_DRIVER=tests/drivers/emote_bubble_bug505_test.lua POKEPORT_IDENTITY=bug505 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Assets = require("src.render.Assets")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local failed = 0
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
if not ok then failed = failed + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
-- extraction, asset resolution and a renamed crop all look like the bug on
|
||||
-- screen (grey bubble, or no bubble at all), so separate them here first
|
||||
local bubble = game.data.field and game.data.field.emotionBubbles
|
||||
check("field data carries the emote sheet",
|
||||
type(bubble) == "table" and type(bubble.path) == "string")
|
||||
local crop = bubble and bubble.bubbles and bubble.bubbles[1]
|
||||
check("crop 1 is EXCLAMATION_BUBBLE 16x16 at the sheet origin",
|
||||
crop ~= nil and crop.name == "EXCLAMATION_BUBBLE"
|
||||
and crop.w == 16 and crop.h == 16 and crop.x == 0 and crop.y == 0)
|
||||
|
||||
-- the source art is the pre-OBP0 art and is supposed to look grey: the
|
||||
-- body is OBJ color 1, which the extractor writes as DMG shade 1 (170),
|
||||
-- and OBJ color 0 (the corners) carries the sheet's tRNS alpha. If this
|
||||
-- ever reads white the extractor changed and the bake below is moot.
|
||||
local BODY_X, BODY_Y = 4, 5 -- inside the outline, left of the "!" stem
|
||||
local EDGE_X, EDGE_Y = 1, 4 -- the black outline column
|
||||
if bubble and bubble.path then
|
||||
local ok, src = pcall(Assets.imageData, bubble.path)
|
||||
check("the sheet resolves and decodes: " .. bubble.path, ok and src ~= nil)
|
||||
if ok and src then
|
||||
local r = select(1, src:getPixel(BODY_X, BODY_Y))
|
||||
local _, _, _, ca = src:getPixel(0, 0)
|
||||
U.log(("source art body pixel %.3f, corner alpha %.3f"):format(r, ca))
|
||||
check("source body is the shade 1 grey the bug showed on screen",
|
||||
math.abs(r - 170 / 255) < 0.04)
|
||||
check("source corner is keyed out by tRNS", ca < 0.5)
|
||||
end
|
||||
end
|
||||
|
||||
-- pokered data/maps/objects/Route3.asm:22 -- the Bug Catcher stands at
|
||||
-- (10, 6) with range RIGHT, so his line runs east along row 6 and (12, 6)
|
||||
-- is the detection tile. Start one cell outside it and walk in.
|
||||
local MAP, TRAINER = "ROUTE_3", "ROUTE3_YOUNGSTER1"
|
||||
local stand = { x = 13, y = 6, facing = "left", walk = "left" }
|
||||
|
||||
U.teleport(game, MAP, stand.x, stand.y, stand.facing)
|
||||
local ow = game.overworld
|
||||
local npc
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == TRAINER then npc = n end
|
||||
end
|
||||
check("the Bug Catcher loaded on " .. MAP, npc ~= nil)
|
||||
|
||||
-- a map edit that moves or re-aims him would park us facing empty grass,
|
||||
-- so re-derive the approach from where he actually is and which way he
|
||||
-- looks: stand three cells down his line and walk back along it
|
||||
if npc and (npc.cellX ~= 10 or npc.cellY ~= 6 or npc.facing ~= "right") then
|
||||
local away = { up = { 0, -1 }, down = { 0, 1 },
|
||||
left = { -1, 0 }, right = { 1, 0 } }
|
||||
local back = { up = "down", down = "up", left = "right", right = "left" }
|
||||
local d = away[npc.facing] or away.right
|
||||
local cx, cy = npc.cellX + d[1] * 3, npc.cellY + d[2] * 3
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log("he is not at (10, 6) facing right any more, approaching from",
|
||||
cx, cy)
|
||||
stand = { x = cx, y = cy, facing = back[npc.facing] or "left",
|
||||
walk = back[npc.facing] or "left" }
|
||||
U.teleport(game, MAP, stand.x, stand.y, stand.facing)
|
||||
ow = game.overworld
|
||||
end
|
||||
end
|
||||
|
||||
-- walk into the line one frame at a time and stop the moment the bubble
|
||||
-- is up, so the shot lands well inside the 60-frame hold
|
||||
local spotted = false
|
||||
for _ = 1, 90 do
|
||||
U.hold(game, stand.walk, 1)
|
||||
if ow.emote and ow.emote.npc and ow.emote.bubble ~= false then
|
||||
spotted = true
|
||||
break
|
||||
end
|
||||
end
|
||||
check("walking in triggered the bubble", spotted)
|
||||
U.log("player at", ow.player.cellX, ow.player.cellY,
|
||||
"bubble frames left:", ow.emote and ow.emote.frames)
|
||||
|
||||
local shotPath = DIR .. "/bug505_emote.png"
|
||||
local wrote = U.shot(game, shotPath)
|
||||
check("the window rendered and the screenshot reached disk", wrote)
|
||||
|
||||
-- what the human sees is one 16x16 blit of ow.emoteImg, so read that
|
||||
-- image back rather than hunting pixels in the framebuffer: draw it to a
|
||||
-- scratch canvas and sample it. Canvas readback outside love.draw is not
|
||||
-- guaranteed on every driver, hence the pcall.
|
||||
local img = ow.emoteImg
|
||||
check("the draw site built its emote image", img ~= nil)
|
||||
if img then
|
||||
local ok, body, edge, corner = pcall(function()
|
||||
local canvas = love.graphics.newCanvas(img:getDimensions())
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(0, 0, 0, 0)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, 0, 0)
|
||||
love.graphics.setCanvas()
|
||||
local id = canvas:newImageData()
|
||||
local b = select(1, id:getPixel(BODY_X, BODY_Y))
|
||||
local e = select(1, id:getPixel(EDGE_X, EDGE_Y))
|
||||
local _, _, _, a = id:getPixel(0, 0)
|
||||
return b, e, a
|
||||
end)
|
||||
if ok then
|
||||
U.log(("baked body %.3f, outline %.3f, corner alpha %.3f")
|
||||
:format(body, edge, corner))
|
||||
check("the bubble body came out white, not the 170 grey of #505",
|
||||
body > 0.9)
|
||||
check("the outline is still black", edge < 0.1)
|
||||
check("the corners are still keyed out", corner < 0.5)
|
||||
else
|
||||
U.log("could not read the baked image back:", tostring(body))
|
||||
U.log("judge the colour off the screenshot alone")
|
||||
end
|
||||
end
|
||||
|
||||
if failed > 0 then
|
||||
U.log(failed, "check(s) failed above; fix those before eyeballing anything")
|
||||
end
|
||||
|
||||
-- put him back on the board so the moment can be replayed by hand
|
||||
U.wait(20)
|
||||
U.teleport(game, MAP, stand.x, stand.y, stand.facing)
|
||||
U.log("the bubble has already been triggered once and saved to " .. shotPath)
|
||||
U.log("its interior should read pure white with a black outline and the")
|
||||
U.log("grass showing through the corners; #505 was a flat mid grey inside.")
|
||||
U.log("hold " .. stand.walk .. " to walk into his line and pop it again.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,56 @@
|
||||
-- Driver: inspect a full-color Pokémon while it evolves (#494). The sprite
|
||||
-- is normally protected from the ADVANCED palette pass by its trueColor flag.
|
||||
-- EvolutionState used the image but dropped that flag, so the full-color pic
|
||||
-- was recolored as if it were a four-shade SGB sprite.
|
||||
--
|
||||
-- Run:
|
||||
-- POKEPORT_DRIVER=tests/drivers/evolution_true_color_bug494_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug494 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Evolution = require("src.pokemon.Evolution")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.colors = "redpp"
|
||||
PaletteFX.setMode("redpp")
|
||||
|
||||
-- The stock artwork is a four-shade source image. Set the same runtime
|
||||
-- contract a full-color sprite mod uses so this driver reaches the palette
|
||||
-- boundary that #494 exposed, without turning the visual result into a test.
|
||||
local pikachu = game.data.pokemon.PIKACHU
|
||||
local raichu = game.data.pokemon.RAICHU
|
||||
check("PIKACHU and RAICHU data are available", pikachu ~= nil and raichu ~= nil)
|
||||
if not (pikachu and raichu) then return end
|
||||
pikachu.trueColor = true
|
||||
raichu.trueColor = true
|
||||
check("ADVANCED color mode is active", PaletteFX.mode == "redpp")
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(10)
|
||||
check("the overworld fixture is ready", game.overworld ~= nil)
|
||||
|
||||
local mon = Pokemon.new(game.data, "PIKACHU", 20)
|
||||
game.save.party = { mon }
|
||||
Evolution.evolve(game, mon, "RAICHU")
|
||||
U.wait(12)
|
||||
|
||||
local top = game.stack:top()
|
||||
check("the evolution screen opened", top and top.screenId == "EvolutionState")
|
||||
U.log("Issue #494: true-color sprites during evolutions and trades")
|
||||
U.log("Watch this PIKACHU evolve into RAICHU in ADVANCED colors.")
|
||||
U.log("Right: both sprites keep their source colors during the flash and")
|
||||
U.log("the congratulations screen. Wrong: either sprite is recolored by")
|
||||
U.log("the surrounding four-shade palette. Press B during the flash to")
|
||||
U.log("also inspect the cancelled PIKACHU screen.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
-- Driver: Fighting Dojo Karate Master bundle (#197).
|
||||
-- Six sub-bugs live in FIGHTING_DOJO (scripts/FightingDojo.asm):
|
||||
-- BUG1 no aggro -- the master has no trainer header so range=0
|
||||
-- BUG1 gate -- the master stops the player on the tile to his left
|
||||
-- BUG2 no speech -- no won text + no prize dialogue after the win
|
||||
-- BUG3 wrong re-talk -- shows the pre-battle challenge, not the after line
|
||||
-- BUG4 (verify) -- the ball ask() is the Gen1 descriptor, not a dex entry
|
||||
@@ -112,21 +112,23 @@ return function(game)
|
||||
------------------------------------------------------------------
|
||||
local hdr = game.data:trainerHeader("FightingDojo", 1)
|
||||
check(hdr ~= nil, "BUG1/2/3: Karate Master trainer header (index 1) exists")
|
||||
check(hdr and (hdr.range or 0) > 0, "BUG1: master has a sight range")
|
||||
check(hdr and (hdr.range or 0) == 0,
|
||||
"BUG1: master relies on the exact-tile gate, not trainer sight")
|
||||
check(hdr and hdr.won ~= nil, "BUG2: master has a won (defeat) text")
|
||||
check(hdr and hdr.after ~= nil, "BUG3: master has an after (re-talk) text")
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- BUG1: sight aggro. Stand directly below the master (5,3 faces DOWN,
|
||||
-- range 4) with the four blackbelts pre-cleared so only he can engage.
|
||||
-- BUG1: exact gate. Start just north of the tile to the Master's left,
|
||||
-- then step down once. The four blackbelts are cleared so only he reacts.
|
||||
------------------------------------------------------------------
|
||||
local ow = resetDojo(5, 4, "up", {})
|
||||
local ow = resetDojo(4, 2, "down", {})
|
||||
U.shot(game, DIR .. "/dojo_1_before.png")
|
||||
U.wait(20) -- the idle sight scan runs every frame
|
||||
local engaged = ow.engaging or (ow.emote ~= nil)
|
||||
U.log("aggro engaging:", tostring(ow.engaging), "emote:", tostring(ow.emote ~= nil))
|
||||
U.tap(game, "down")
|
||||
U.wait(30)
|
||||
local engaged = topIsTextBox()
|
||||
U.log("gate dialogue open:", tostring(engaged))
|
||||
U.shot(game, DIR .. "/dojo_2_aggro.png")
|
||||
check(engaged, "BUG1: Karate Master aggros on sight")
|
||||
check(engaged, "BUG1: Karate Master stops the player at his left")
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- BUG3: talk to the already-beaten master -> "Stay and train..." and
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Driver: Fighting Dojo Karate Master gate (#495).
|
||||
--
|
||||
-- Run:
|
||||
-- POKEPORT_DRIVER=tests/drivers/fighting_dojo_gate_bug495_test.lua \
|
||||
-- POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
--
|
||||
-- The scene starts one tile north of the only trigger tile. The reported
|
||||
-- behaviour is an interaction timing and position question for a human to
|
||||
-- observe, not an assertion this driver can decide.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
U.teleport(game, "FIGHTING_DOJO", 4, 2, "down")
|
||||
local ow = game.stack:top()
|
||||
local master
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.def and npc.def.name == "FIGHTINGDOJO_KARATE_MASTER" then
|
||||
master = npc
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
-- Fixture checks only: the room, player, and Master must be in the layout
|
||||
-- that makes the reported tile meaningful.
|
||||
check("Fighting Dojo is loaded", ow.map.id == "FIGHTING_DOJO")
|
||||
check("player starts north of the trigger tile",
|
||||
ow.player.cellX == 4 and ow.player.cellY == 2)
|
||||
check("Karate Master is at (5,3)",
|
||||
master and master.cellX == 5 and master.cellY == 3)
|
||||
|
||||
U.log("Issue #495: Karate Master gate")
|
||||
U.log("Do this: press Down once, onto the tile left of the Master.")
|
||||
U.log("Right: he turns left and starts his challenge on that tile only.")
|
||||
U.log("Wrong: nothing happens here, while standing below him triggers a battle.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,107 @@
|
||||
-- Driver: Fighting Dojo Karate Master does not leave his post (#502),
|
||||
-- the opposite-direction report against the same script #495 fixed
|
||||
-- (data/scripts/story4.lua dojoMasterGate / M.FIGHTING_DOJO.onStep).
|
||||
--
|
||||
-- Before the fix his trainer header used range=4/DOWN sight aggro
|
||||
-- (src/core/Data.lua seedFightingDojoKarateMaster), so walking up his
|
||||
-- column from below put him in CheckFightingMapTrainers' generic path:
|
||||
-- the NPC walks toward the player before the pre-battle text, straight
|
||||
-- through FIGHTINGDOJO_BLACKBELT3/4's tiles at (5,5)/(5,7) -- the
|
||||
-- "merges with a pupil" glitch, and it fired regardless of whether the
|
||||
-- pupils in front of him had been beaten yet ("forces the fight early").
|
||||
-- The fix sets range=0 and gates him on the single tile at his left
|
||||
-- (4,3) instead (onStep = dojoMasterGate): he only ever turns to face
|
||||
-- the player there, never steps.
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/fighting_dojo_master_bug502_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug502 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function masterIn(ow)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == "FIGHTINGDOJO_KARATE_MASTER" then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- No pupil beaten, master not beaten: the exact state the report says
|
||||
-- triggered the early merge (walking up before clearing the pupils).
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_BEAT_KARATE_MASTER = nil
|
||||
game.save.defeatedTrainers = {}
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- Direction 1 (#502): stand in the master's own sight column, below
|
||||
-- him, with his southern pupils (BLACKBELT3 at (5,5), BLACKBELT4 at
|
||||
-- (5,7)) still in the way -- exactly the "walk towards Karate Master"
|
||||
-- report. The old range=4/DOWN header put any of these cells in his
|
||||
-- sight; his first step toward the player collided with BLACKBELT3's
|
||||
-- tile. Collision blocks a real walk onto an NPC's cell, so probe by
|
||||
-- teleport (a scripted approach cannot walk through them either) and
|
||||
-- watch several fixed steps for the master moving or engaging at all.
|
||||
------------------------------------------------------------------
|
||||
local ow, master
|
||||
local function probe(px, py, label)
|
||||
U.teleport(game, "FIGHTING_DOJO", px, py, "up")
|
||||
U.wait(10)
|
||||
ow = game.stack:top()
|
||||
master = masterIn(ow)
|
||||
U.shot(game, DIR .. "/bug502_1_" .. label .. "_before.png")
|
||||
local moved, engaged = false, false
|
||||
for _ = 1, 40 do
|
||||
U.wait(1)
|
||||
if master.cellX ~= 5 or master.cellY ~= 3 then moved = true end
|
||||
if getmetatable(game.stack:top()) == TextBox then engaged = true; break end
|
||||
end
|
||||
U.shot(game, DIR .. "/bug502_1_" .. label .. "_after.png")
|
||||
check("Fighting Dojo is loaded (" .. label .. ")", ow.map.id == "FIGHTING_DOJO")
|
||||
check("Karate Master stayed at (5,3), did not walk toward (" .. label .. ")",
|
||||
not moved)
|
||||
check("standing at " .. label .. " started no battle on its own",
|
||||
not engaged)
|
||||
return moved or engaged
|
||||
end
|
||||
|
||||
-- (5,4): directly below the master, one tile from BLACKBELT3 (5,5).
|
||||
-- (5,6): the gap between BLACKBELT3 and BLACKBELT4, still in the old
|
||||
-- sight range and still boxed in by pupils on both sides.
|
||||
local bad1 = probe(5, 4, "below_master")
|
||||
local bad2 = probe(5, 6, "between_pupils")
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- Direction 2 (#495, same script): the ONLY tile that should start his
|
||||
-- fight is one step left of him. Confirm the gate still works so the
|
||||
-- fix didn't just make him inert in both directions.
|
||||
------------------------------------------------------------------
|
||||
U.teleport(game, "FIGHTING_DOJO", 4, 2, "down")
|
||||
U.wait(10)
|
||||
ow = game.stack:top()
|
||||
master = masterIn(ow)
|
||||
U.shot(game, DIR .. "/bug502_3_gate_before.png")
|
||||
U.tap(game, "down")
|
||||
U.wait(30)
|
||||
local gateOpened = getmetatable(game.stack:top()) == TextBox
|
||||
U.shot(game, DIR .. "/bug502_4_gate_after.png")
|
||||
check("stepping onto (4,3) still starts the Master's challenge", gateOpened)
|
||||
check("he turned to face the player instead of walking to them",
|
||||
master.cellX == 5 and master.cellY == 3 and master.facing == "left")
|
||||
|
||||
U.log("Compare bug502_1/2: the Master's sprite should sit in the same")
|
||||
U.log("spot in both, never sliding down onto BLACKBELT3 or BLACKBELT4.")
|
||||
U.log("bug502_4 should show his pre-battle text box, with him still on")
|
||||
U.log("(5,3), just turned to face left -- that is the correct trigger,")
|
||||
U.log("distinct from the column walk that used to glitch him south.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Driver: a quick directional tap should turn the player in place, not
|
||||
-- also take a step (#415). Player:tryMove (src/world/Player.lua) only
|
||||
-- steps once turnTimer has counted down; TURN_FRAMES/turnFrames set how
|
||||
-- many fixed steps a facing change blocks movement for before a held
|
||||
-- direction is allowed to commit to a step. This is an input-timing feel
|
||||
-- bug: no assertion here can tell a quick tap from a held one the way a
|
||||
-- human thumb can, so this hands the pad over rather than scripting taps
|
||||
-- at a fixed, unrealistic frame count.
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/turn_in_place_bug415_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug415 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- REDS_HOUSE_2F: the player's own bedroom, the default new-game spawn
|
||||
-- (src/core/SaveData.lua bedroom fallback). Small enough that an
|
||||
-- accidental step is obvious -- a stray step toward the stairs at
|
||||
-- (7,1)/(7,2) or into the desk/bed furniture is immediately visible.
|
||||
U.teleport(game, "REDS_HOUSE_2F", 5, 6, "down")
|
||||
U.wait(10)
|
||||
local ow = game.stack:top()
|
||||
check("Red's bedroom is loaded", ow.map.id == "REDS_HOUSE_2F")
|
||||
check("player starts facing down", ow.player.facing == "down")
|
||||
|
||||
U.log("Standing in the middle of the bedroom, facing down.")
|
||||
U.log("Tap Up once, quickly, then tap Right once, quickly.")
|
||||
U.log("Right: each tap only turns you to face that way -- your feet")
|
||||
U.log("stay on the same tile both times. Wrong (#415): a quick tap")
|
||||
U.log("still slides you one tile in the new direction, same as holding.")
|
||||
U.log("Now hold Up for a beat: turning into a real step, then a walk,")
|
||||
U.log("should feel identical to before -- no extra pause was added there.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
-- Manual check that Yellow's bank $1f music headers, wave table and cry
|
||||
-- data read from the right ROM addresses (#522). Before the fix every
|
||||
-- header past Music_YellowIntro (a 3-channel header where Red's
|
||||
-- Music_IntroBattle was 4) was read 3 bytes off, so Viridian Forest's
|
||||
-- Music_Dungeon2 lost 3 of its 4 channels and its tempo command; every
|
||||
-- song's channel 3 sampled engine code instead of a wave table.
|
||||
-- POKEPORT_DRIVER=tests/drivers/yellow_audio_bug522_test.lua POKEPORT_IDENTITY=bug522 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local ChipSynth = require("src.core.ChipSynth")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local audio = game.data.audio or {}
|
||||
local header = audio.musicHeaders and audio.musicHeaders.Music_Dungeon2
|
||||
|
||||
-- pokeyellow.sym: Music_Dungeon2 = 1f:42a9 (bank 31, address 17065)
|
||||
check("Music_Dungeon2 header reads pokeyellow.sym's address",
|
||||
header and header.address == 17065 and header.bank == 31)
|
||||
|
||||
-- pokeyellow.sym: CryData = 0e:5462 (bank 14, address 21602)
|
||||
local cry = audio.cryData
|
||||
check("cryData reads pokeyellow.sym's CryData",
|
||||
cry and cry.address == 21602 and cry.bank == 14)
|
||||
|
||||
-- pokeyellow only has one wave table, Audio1_WavePointers.wave0 = 02:5a26
|
||||
local waves = audio.waveBanks or {}
|
||||
local waveOk = true
|
||||
for _, engine in ipairs({ "1", "2", "3" }) do
|
||||
local w = waves[engine]
|
||||
if not (w and w.address == 23078 and w.bank == 2) then waveOk = false end
|
||||
end
|
||||
check("all three waveBanks point at Audio1_WavePointers.wave0", waveOk)
|
||||
|
||||
-- Rebuild the engine exactly as ChipAudio does and count the channels a
|
||||
-- misread header would drop to 1 (see headerChannels, ChipSynth.lua).
|
||||
local engine, engErr
|
||||
if header then
|
||||
local ok
|
||||
ok, engine = pcall(ChipSynth.newEngine, game.data, header)
|
||||
if not ok then engErr = engine; engine = nil end
|
||||
end
|
||||
check("Music_Dungeon2 engine builds" .. (engErr and (": " .. tostring(engErr)) or ""),
|
||||
engine ~= nil)
|
||||
check("Music_Dungeon2 has all 4 channels, not the misread header's 1",
|
||||
engine and #engine.channels == 4)
|
||||
|
||||
-- Ch1 opens with the tempo command (dungeon2.asm "Music_Dungeon2_Ch1::
|
||||
-- tempo 144"); a header pointed at the wrong row drops it and the engine
|
||||
-- is left at the 0x100 default, ~1.78x slower.
|
||||
if engine and engine.channels[1] then
|
||||
engine.channels[1]:nextEvent()
|
||||
end
|
||||
check("Ch1's tempo command set engine.tempo to 144, not the 0x100 default",
|
||||
engine and engine.tempo == 144)
|
||||
|
||||
-- pokered data/maps/objects/ViridianForest.asm: the south gate warps land
|
||||
-- around (16-18, 47); one step north of that is inside the forest proper.
|
||||
local MAP = "VIRIDIAN_FOREST"
|
||||
local STAND = { x = 16, y = 46, facing = "up" }
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
|
||||
local ow = game.overworld
|
||||
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
local function nearestWalkable()
|
||||
for dy = -3, 3 do
|
||||
for dx = -3, 3 do
|
||||
local cx, cy = STAND.x + dx, STAND.y + dy
|
||||
if ow.map:isWalkableCell(cx, cy) then return cx, cy end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local cx, cy = nearestWalkable()
|
||||
if cx then
|
||||
U.log("start cell blocked, standing at", cx, cy, "instead")
|
||||
U.teleport(game, MAP, cx, cy, "up")
|
||||
end
|
||||
end
|
||||
|
||||
if game.save and game.save.options and game.save.options.musicVol == 0 then
|
||||
U.log("FAIL musicVol is 0 -- turn it up, this run is silent by design")
|
||||
end
|
||||
|
||||
U.log("Standing in Viridian Forest; Music_Dungeon2 is playing now.")
|
||||
U.log("Right: four voices at a brisk clip -- lead melody, a counter line")
|
||||
U.log("underneath it, a soft rounded bass, and a hi-hat tick on top.")
|
||||
U.log("Wrong (the bug): one thin lonely voice at a noticeably slower,")
|
||||
U.log("draggy tempo, with no bass or hi-hat at all.")
|
||||
U.log("Also listen to that bass note's timbre -- it should be a soft")
|
||||
U.log("rounded triangle-ish tone, not a harsh buzzy rasp (waveBanks).")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Manual check that the Yellow intro now runs long enough for
|
||||
-- Music_YellowIntro's descending run and long final note to be audible
|
||||
-- before title.asm's unconditional StopAllMusic swaps in the title theme
|
||||
-- (#523). Never taps a button: Game:load() already pushed YellowIntro,
|
||||
-- so this driver only watches it play out at real, unaccelerated speed.
|
||||
-- POKEPORT_SPEED must stay unset -- Music runs on its own real-time 60Hz
|
||||
-- accumulator (Game:update), decoupled from the fast-forwardable logic
|
||||
-- clock, so speeding up the driver would desync the exact ordering under
|
||||
-- test here instead of just playing it back faster.
|
||||
-- POKEPORT_DRIVER=tests/drivers/yellow_intro_bug523_test.lua POKEPORT_IDENTITY=bug523 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Music = require("src.core.Music")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
check("booted into a state (Yellow intro, per bootScreens)",
|
||||
game.stack:top() ~= nil)
|
||||
|
||||
if game.save and game.save.options and game.save.options.musicVol == 0 then
|
||||
U.log("FAIL musicVol is 0 -- turn it up, this run is silent by design")
|
||||
end
|
||||
|
||||
local sawIntroSong = false
|
||||
local sawTitleSong = false
|
||||
|
||||
-- Music.lua keeps the current song in a module-local `state` table with
|
||||
-- no public getter, so watch the one seam that is public: wrap Music.play
|
||||
-- itself for the life of this driver and log what it's handed. Both the
|
||||
-- YellowIntro:beginScenes() pcall and title.asm's StopAllMusic-then-
|
||||
-- PlaySound land here.
|
||||
local frame = 0
|
||||
local originalPlay = Music.play
|
||||
Music.play = function(data, song, loop, ctx)
|
||||
if song and song:find("Intro") and not sawIntroSong then
|
||||
sawIntroSong = true
|
||||
U.log("Music_YellowIntro started")
|
||||
end
|
||||
if song == "Music_TitleScreen" and not sawTitleSong then
|
||||
sawTitleSong = true
|
||||
U.log("Music_TitleScreen started")
|
||||
end
|
||||
return originalPlay(data, song, loop, ctx)
|
||||
end
|
||||
|
||||
-- 40s ceiling counted from driver start, not from the intro song: roughly 500 frames of
|
||||
-- boot and bootScreens run before Music_YellowIntro begins, and the title swap lands
|
||||
-- around frame 1680 (28s), so a 20s ceiling expired before the moment under test (#523).
|
||||
while frame < 40 * 60 and not sawTitleSong do
|
||||
frame = frame + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
|
||||
check("Music_YellowIntro started", sawIntroSong)
|
||||
check("the movie reached the title screen and Music_TitleScreen took over",
|
||||
sawTitleSong)
|
||||
|
||||
U.log("Listen for Music_YellowIntro's ending: a short descending run of")
|
||||
U.log("notes (F#, F, F#...F#, E, D#, C#) followed by one long low note,")
|
||||
U.log("right before the title theme cuts in. Before #523 the swap landed")
|
||||
U.log("early and those descending notes never played -- the track just")
|
||||
U.log("stopped mid-phrase. The swap itself is still an abrupt cut by")
|
||||
U.log("design (pokeyellow's title.asm stops the intro song unconditionally,")
|
||||
U.log("it never waits for it to finish); what changed is how much of the")
|
||||
U.log("track plays before that cut lands.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -121,6 +121,7 @@ local DEBT = {
|
||||
["event:pokemon.evolved"] = "M7",
|
||||
["event:pokemon.level_up"] = "M7",
|
||||
["event:pokemon.move_learned"] = "M7",
|
||||
["event:save.loaded"] = "M11",
|
||||
["event:save.loading"] = "M11",
|
||||
["event:save.writing"] = "M11",
|
||||
["event:trade.completed"] = "M12",
|
||||
|
||||
@@ -60,6 +60,25 @@ do
|
||||
eq(m.aaa.statusDetail, "Ready", "ok detail reads Ready")
|
||||
end
|
||||
|
||||
-- ------- experimental defaults to disabled; github surfaces on the row
|
||||
|
||||
do
|
||||
local manifests = {
|
||||
mf({ id = "lab", name = "Lab", version = "1.2.0", entry = "m.lua",
|
||||
experimental = true, github = "acme/lab" }),
|
||||
mf({ id = "lab_on", name = "Lab On", version = "1.0.0", entry = "m.lua",
|
||||
experimental = true }),
|
||||
}
|
||||
local m = byId(LauncherMods.deriveList(manifests, {
|
||||
mods = { lab_on = true },
|
||||
}))
|
||||
check(not m.lab.enabled, "experimental with no options entry stays off")
|
||||
check(m.lab_on.enabled, "experimental can still be explicitly enabled")
|
||||
eq(m.lab.badge, "EXPERIMENTAL", "experimental badge overrides category")
|
||||
eq(m.lab.github, "acme/lab", "github is exposed on the panel row")
|
||||
check(m.lab.experimental, "experimental flag is exposed on the panel row")
|
||||
end
|
||||
|
||||
-- ------- conflict: only when this mod is enabled and the other is too
|
||||
|
||||
do
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
-- Pure coverage for src/mods/ModIndex.lua: the community mod index consumer
|
||||
-- (source resolution, feed parsing, install-URL precedence, compatibility
|
||||
-- warnings, search). Nothing here touches the network -- every fetch path in
|
||||
-- ModIndex funnels through parse()/installUrl(), which are what the launcher
|
||||
-- actually depends on being right.
|
||||
-- luajit tests/engine/mod_index_tests.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
local Json = require("src.link.Json")
|
||||
|
||||
-- ------- source resolution: four ways to name one index
|
||||
|
||||
do
|
||||
local expectFeed =
|
||||
"https://bryanthaboi.github.io/gen1recomp-mod-index/data/index.json"
|
||||
local expectBase = "https://bryanthaboi.github.io/gen1recomp-mod-index/"
|
||||
|
||||
local fromRepo = ModIndex.resolveSource("bryanthaboi/gen1recomp-mod-index")
|
||||
eq(fromRepo.feed, expectFeed, "owner/repo resolves to the Pages feed")
|
||||
eq(fromRepo.base, expectBase, "owner/repo resolves the Pages base")
|
||||
check(fromRepo.fallback:find("raw.githubusercontent.com", 1, true) ~= nil,
|
||||
"owner/repo carries the raw fallback")
|
||||
|
||||
local fromUrl =
|
||||
ModIndex.resolveSource("https://github.com/bryanthaboi/gen1recomp-mod-index")
|
||||
eq(fromUrl.feed, expectFeed, "a github repo URL resolves the same feed")
|
||||
|
||||
local fromPages = ModIndex.resolveSource(expectBase)
|
||||
eq(fromPages.feed, expectFeed, "the Pages root resolves the same feed")
|
||||
eq(fromPages.base, expectBase, "the Pages root is its own base")
|
||||
|
||||
local fromFeed = ModIndex.resolveSource(expectFeed)
|
||||
eq(fromFeed.feed, expectFeed, "the feed URL is taken as-is")
|
||||
eq(fromFeed.base, expectBase, "the feed URL yields the Pages base")
|
||||
|
||||
-- a root without its trailing slash must not produce "...indexdata/index.json"
|
||||
local noSlash =
|
||||
ModIndex.resolveSource("https://bryanthaboi.github.io/gen1recomp-mod-index")
|
||||
eq(noSlash.feed, expectFeed, "a Pages root without a trailing slash still works")
|
||||
|
||||
local bad, err = ModIndex.resolveSource("not a url")
|
||||
check(bad == nil and err ~= nil, "garbage input soft-fails")
|
||||
bad, err = ModIndex.resolveSource(nil)
|
||||
check(bad == nil and err ~= nil, "nil input soft-fails")
|
||||
end
|
||||
|
||||
do
|
||||
local base = "https://bryanthaboi.github.io/gen1recomp-mod-index/"
|
||||
eq(ModIndex.joinUrl(base, "data/mods/bryanthaboi@nuzlocke/thumbnail.png"),
|
||||
base .. "data/mods/bryanthaboi@nuzlocke/thumbnail.png",
|
||||
"relative asset paths resolve against the Pages base")
|
||||
eq(ModIndex.joinUrl(base, "https://elsewhere/x.png"), "https://elsewhere/x.png",
|
||||
"an absolute asset URL is left alone")
|
||||
check(ModIndex.joinUrl(base, nil) == nil, "a nil thumbnail is absent, not an error")
|
||||
check(ModIndex.joinUrl(nil, "x.png") == nil, "no base means no asset URL")
|
||||
end
|
||||
|
||||
-- ------- feed parsing
|
||||
|
||||
local function feed(mods, overrides)
|
||||
local doc = { schema_version = 1, generated_at = "2026-07-31T15:21:36.687Z",
|
||||
count = #mods, categories = { "GAMEPLAY", "ART" }, mods = mods }
|
||||
for k, v in pairs(overrides or {}) do doc[k] = v end
|
||||
return Json.encode(doc)
|
||||
end
|
||||
|
||||
local NUZLOCKE = {
|
||||
folder = "bryanthaboi@nuzlocke",
|
||||
id = "nuzlocke",
|
||||
title = "Nuzlocke",
|
||||
author = "bryanthaboi",
|
||||
summary = "An enforced Gen 1 Nuzlocke: one catch per area.",
|
||||
version = "1.0.1",
|
||||
categories = { "GAMEPLAY" },
|
||||
tags = { "nuzlocke", "challenge" },
|
||||
repo = "https://github.com/bryanthaboi/nuzlocke",
|
||||
github = "bryanthaboi/nuzlocke",
|
||||
api = 2,
|
||||
game_version = ">=0.0.0-dev <1.0.0",
|
||||
profile = "content",
|
||||
permissions = { "engine_internals" },
|
||||
thumbnail = "data/mods/bryanthaboi@nuzlocke/thumbnail.png",
|
||||
description_url = "data/mods/bryanthaboi@nuzlocke/description.md",
|
||||
latest = {
|
||||
version = "1.0.1", tag = "v1.0.1", name = "1.0.1", prerelease = false,
|
||||
published_at = "2026-07-31T14:17:23Z",
|
||||
zip = {
|
||||
name = "nuzlocke-1.0.1.zip",
|
||||
url = "https://github.com/bryanthaboi/nuzlocke/releases/download/v1.0.1/nuzlocke-1.0.1.zip",
|
||||
size = 4396,
|
||||
},
|
||||
},
|
||||
update_check = "ok",
|
||||
}
|
||||
|
||||
do
|
||||
local index, err = ModIndex.parse(feed({ NUZLOCKE }))
|
||||
check(index ~= nil, "the published feed shape parses: " .. tostring(err))
|
||||
eq(index.schemaVersion, 1, "schema_version is carried through")
|
||||
eq(#index.mods, 1, "one mod")
|
||||
local m = index.mods[1]
|
||||
eq(m.id, "nuzlocke", "id")
|
||||
eq(m.title, "Nuzlocke", "title")
|
||||
eq(m.latest.zip.url,
|
||||
"https://github.com/bryanthaboi/nuzlocke/releases/download/v1.0.1/nuzlocke-1.0.1.zip",
|
||||
"the release asset URL survives parsing")
|
||||
eq(m.permissions[1], "engine_internals", "permissions are kept")
|
||||
eq(m.update_check, "ok", "update_check is kept")
|
||||
end
|
||||
|
||||
-- schema_version is a contract, not a hint: an unknown one is refused rather
|
||||
-- than parsed on the assumption the fields still mean what they used to.
|
||||
do
|
||||
local index, err = ModIndex.parse(feed({ NUZLOCKE }, { schema_version = 2 }))
|
||||
check(index == nil and tostring(err):find("schema", 1, true) ~= nil,
|
||||
"a future schema is refused")
|
||||
index, err = ModIndex.parse(Json.encode({ mods = { NUZLOCKE } }))
|
||||
check(index == nil and err ~= nil, "a feed with no schema_version is refused")
|
||||
index, err = ModIndex.parse("<!DOCTYPE html><html>404</html>")
|
||||
check(index == nil and err ~= nil, "an HTML error page soft-fails")
|
||||
index, err = ModIndex.parse('{"schema_version":1}')
|
||||
check(index == nil and err ~= nil, "a feed with no mods array soft-fails")
|
||||
end
|
||||
|
||||
-- ------- install URL precedence
|
||||
|
||||
do
|
||||
local url, kind = ModIndex.installUrl(NUZLOCKE)
|
||||
eq(kind, "release", "an ok update_check installs from the release asset")
|
||||
eq(url, NUZLOCKE.latest.zip.url, "and uses that asset's URL")
|
||||
eq(ModIndex.displayVersion(NUZLOCKE), "1.0.1",
|
||||
"an ok entry shows the resolved release version")
|
||||
end
|
||||
|
||||
do
|
||||
-- no github: the author's fixed zip is the only route
|
||||
local entry = { id = "static", version = "2.0.0", update_check = "off",
|
||||
downloadURL = "https://example.test/static-2.0.0.zip" }
|
||||
local url, kind = ModIndex.installUrl(entry)
|
||||
eq(kind, "download", "downloadURL is used when there is no release")
|
||||
eq(url, "https://example.test/static-2.0.0.zip", "and it is used verbatim")
|
||||
eq(ModIndex.displayVersion(entry), "2.0.0",
|
||||
"a non-ok entry falls back to its declared version")
|
||||
end
|
||||
|
||||
do
|
||||
-- a stale `latest` behind a failed check must not be installed: the zip URL
|
||||
-- may point at a release that has since been deleted or replaced
|
||||
local entry = { id = "flaky", version = "1.0.0",
|
||||
update_check = "error: rate limited",
|
||||
latest = { version = "9.9.9", zip = { url = "https://x/stale.zip" } } }
|
||||
local url, why = ModIndex.installUrl(entry)
|
||||
check(url == nil, "a failed update_check does not install its stale release")
|
||||
check(tostring(why):find("rate limited", 1, true) ~= nil,
|
||||
"and the failure reason is surfaced")
|
||||
eq(ModIndex.displayVersion(entry), "1.0.0",
|
||||
"a failed check shows the entry's own version, not the stale release")
|
||||
|
||||
entry.downloadURL = "https://example.test/flaky.zip"
|
||||
local url2, kind = ModIndex.installUrl(entry)
|
||||
eq(kind, "download", "downloadURL still rescues a failed check")
|
||||
eq(url2, "https://example.test/flaky.zip", "with the author's URL")
|
||||
end
|
||||
|
||||
do
|
||||
local entry = { id = "listing-only", update_check = "no installable release" }
|
||||
local url, why = ModIndex.installUrl(entry)
|
||||
check(url == nil and why ~= nil, "an entry with no zip anywhere is not installable")
|
||||
check(not ModIndex.canInstall(entry), "canInstall agrees")
|
||||
-- but it is still a listing: the panel shows it so a broken upstream is
|
||||
-- visible rather than silently missing
|
||||
check(ModIndex.matches(entry, nil), "and it still matches an empty search")
|
||||
end
|
||||
|
||||
do
|
||||
local release = ModIndex.releaseFor(NUZLOCKE)
|
||||
eq(release.zip.url, NUZLOCKE.latest.zip.url,
|
||||
"releaseFor hands installFromRelease the real release")
|
||||
local synth = ModIndex.releaseFor({ id = "static", version = "2.0.0",
|
||||
update_check = "off", downloadURL = "https://example.test/s.zip" })
|
||||
eq(synth.zip.url, "https://example.test/s.zip",
|
||||
"a downloadURL entry gets a synthesised release")
|
||||
eq(synth.version, "2.0.0", "carrying its declared version")
|
||||
end
|
||||
|
||||
-- ------- compatibility: warns, never blocks
|
||||
|
||||
do
|
||||
local issues = ModIndex.compatIssues(NUZLOCKE, {
|
||||
modApi = 2, engineVersion = "0.0.0-dev", installed = {},
|
||||
})
|
||||
-- engine_internals is a declared permission, so there is always one line
|
||||
local text = ""
|
||||
for _, i in ipairs(issues) do text = text .. i.text .. "\n" end
|
||||
check(text:find("engine_internals", 1, true) ~= nil,
|
||||
"a declared permission is surfaced before install")
|
||||
check(text:find("mod API", 1, true) == nil,
|
||||
"an api the engine provides raises nothing")
|
||||
end
|
||||
|
||||
do
|
||||
local entry = { id = "future", api = 99, experimental = true,
|
||||
profile = "total_conversion", affects_link = true,
|
||||
permissions = {}, update_check = "off" }
|
||||
local issues = ModIndex.compatIssues(entry, {
|
||||
modApi = 2, engineVersion = "0.0.0-dev", installed = {},
|
||||
})
|
||||
local text = ""
|
||||
for _, i in ipairs(issues) do text = text .. i.text .. "\n" end
|
||||
check(text:find("mod API 99", 1, true) ~= nil, "too-new api warns")
|
||||
check(text:find("experimental", 1, true) ~= nil, "experimental warns")
|
||||
check(text:find("total_conversion", 1, true) ~= nil, "a non-content profile warns")
|
||||
check(text:find("link play", 1, true) ~= nil, "affects_link warns")
|
||||
-- the entry is still installable: incompatibility is a warning, not a gate
|
||||
check(ModIndex.installUrl(entry) == nil or true, "warnings do not gate install")
|
||||
end
|
||||
|
||||
do
|
||||
-- dependencies / conflicts in both manifest spellings
|
||||
local arrayForm = { id = "needy", dependencies = { "base@>=1.0.0", "other" },
|
||||
conflicts = { "rival" } }
|
||||
local issues = ModIndex.compatIssues(arrayForm, { installed = { rival = "1.0.0" } })
|
||||
local text = ""
|
||||
for _, i in ipairs(issues) do text = text .. i.text .. "\n" end
|
||||
check(text:find("Needs base", 1, true) ~= nil, "a missing dependency warns")
|
||||
check(text:find(">=1.0.0", 1, true) ~= nil, "with its range")
|
||||
check(text:find("Needs other", 1, true) ~= nil, "a rangeless dependency warns")
|
||||
check(text:find("Conflicts with installed rival", 1, true) ~= nil,
|
||||
"an installed conflict warns")
|
||||
|
||||
local mapForm = { id = "needy2", dependencies = { base = ">=1.0.0" } }
|
||||
local issues2 = ModIndex.compatIssues(mapForm, { installed = { base = "1.2.0" } })
|
||||
eq(#issues2, 0, "an installed dependency raises nothing")
|
||||
end
|
||||
|
||||
-- ------- search / filter
|
||||
|
||||
do
|
||||
local mods = {
|
||||
{ id = "nuzlocke", title = "Nuzlocke", author = "bryanthaboi",
|
||||
summary = "one catch per area", categories = { "GAMEPLAY" },
|
||||
tags = { "challenge" } },
|
||||
{ id = "palettes", title = "True Colour", author = "someone",
|
||||
summary = "richer SGB palettes", categories = { "ART" }, tags = {} },
|
||||
}
|
||||
eq(#ModIndex.filter(mods, {}), 2, "no filter keeps everything")
|
||||
eq(#ModIndex.filter(mods, { query = "nuz" }), 1, "search matches a title prefix")
|
||||
eq(ModIndex.filter(mods, { query = "colour" })[1].id, "palettes",
|
||||
"search matches the title")
|
||||
eq(ModIndex.filter(mods, { query = "bryanthaboi" })[1].id, "nuzlocke",
|
||||
"search matches the author")
|
||||
eq(ModIndex.filter(mods, { query = "SGB" })[1].id, "palettes",
|
||||
"search matches the summary and ignores case")
|
||||
-- every term must hit, so typing more narrows rather than widens
|
||||
eq(#ModIndex.filter(mods, { query = "nuzlocke palettes" }), 0,
|
||||
"terms are ANDed")
|
||||
eq(ModIndex.filter(mods, { category = "ART" })[1].id, "palettes",
|
||||
"category filters")
|
||||
eq(#ModIndex.filter(mods, { category = "AUDIO" }), 0,
|
||||
"an unused category filters everything out")
|
||||
eq(ModIndex.filter(mods, { tag = "challenge" })[1].id, "nuzlocke",
|
||||
"tag filters")
|
||||
end
|
||||
|
||||
do
|
||||
local index = ModIndex.parse(feed({ NUZLOCKE }))
|
||||
local cats = ModIndex.categoriesIn(index)
|
||||
eq(#cats, 1, "only categories an entry actually uses are offered")
|
||||
eq(cats[1], "GAMEPLAY", "and they keep the feed's declared order")
|
||||
end
|
||||
|
||||
print("ok mod_index_tests")
|
||||
@@ -0,0 +1,111 @@
|
||||
-- Pure coverage for src/mods/ModUpdate.lua (zip picking, release parse, isNewer).
|
||||
-- luajit tests/engine/mod_update_tests.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local Json = require("src.link.Json")
|
||||
|
||||
do
|
||||
local assets = {
|
||||
{ name = "readme.txt", browser_download_url = "https://x/r" },
|
||||
{ name = "other-1.0.0.zip", browser_download_url = "https://x/o", size = 10 },
|
||||
{ name = "coolmod-1.2.0.zip", browser_download_url = "https://x/c", size = 20 },
|
||||
}
|
||||
local pick = ModUpdate.pickZipAsset(assets, "coolmod", "1.2.0")
|
||||
eq(pick.name, "coolmod-1.2.0.zip", "prefers id-version.zip")
|
||||
eq(pick.url, "https://x/c", "returns the download URL")
|
||||
end
|
||||
|
||||
do
|
||||
local assets = {
|
||||
{ name = "payload.zip", browser_download_url = "https://x/p" },
|
||||
}
|
||||
local pick = ModUpdate.pickZipAsset(assets, "coolmod", "1.0.0")
|
||||
eq(pick.name, "payload.zip", "falls back to the first .zip")
|
||||
end
|
||||
|
||||
do
|
||||
local body = Json.encode({
|
||||
{
|
||||
tag_name = "v1.2.0",
|
||||
name = "1.2.0",
|
||||
prerelease = false,
|
||||
assets = {
|
||||
{ name = "demo-1.2.0.zip", browser_download_url = "https://x/d.zip",
|
||||
size = 99 },
|
||||
},
|
||||
},
|
||||
{
|
||||
tag_name = "v1.1.0",
|
||||
assets = {
|
||||
{ name = "demo-1.1.0.zip", browser_download_url = "https://x/old.zip" },
|
||||
},
|
||||
},
|
||||
{
|
||||
tag_name = "notes-only",
|
||||
assets = {},
|
||||
},
|
||||
})
|
||||
local list = ModUpdate.parseReleases(body, "demo")
|
||||
eq(#list, 2, "releases without a zip are dropped")
|
||||
eq(list[1].version, "1.2.0", "keeps GitHub array order")
|
||||
eq(list[1].zip.url, "https://x/d.zip", "zip url is preserved")
|
||||
end
|
||||
|
||||
check(ModUpdate.isNewer("1.0.0", "1.0.1"), "patch bump is newer")
|
||||
check(not ModUpdate.isNewer("1.2.0", "1.1.9"), "older candidate is not newer")
|
||||
check(not ModUpdate.isNewer("1.0.0", "1.0.0"), "same version is not newer")
|
||||
|
||||
eq(ModUpdate.apiLatestUrl("acme/mod"),
|
||||
"https://api.github.com/repos/acme/mod/releases/latest",
|
||||
"latest API URL")
|
||||
|
||||
-- soft-fail paths: garbage input must never throw
|
||||
do
|
||||
local list, err = ModUpdate.parseReleases("{{{not json", "demo")
|
||||
check(list == nil and err ~= nil, "bad json returns nil, err")
|
||||
list, err = ModUpdate.parseReleases('{"message":"Not Found"}', "demo")
|
||||
check(list == nil and tostring(err):find("Not Found", 1, true),
|
||||
"GitHub error payload surfaces the message")
|
||||
list, err = ModUpdate.fetchReleases("", "demo")
|
||||
check(list == nil and err ~= nil, "empty repo soft-fails")
|
||||
local path, dlErr = ModUpdate.downloadZip("", "x.zip")
|
||||
check(path == nil and dlErr ~= nil, "empty url soft-fails")
|
||||
end
|
||||
|
||||
do
|
||||
local body = Json.encode({
|
||||
tag_name = "v2.0.0",
|
||||
body = "## Changes\n\n- **fixed** the [thing](http://x)\n\n<!-- note -->",
|
||||
assets = {
|
||||
{ name = "demo-2.0.0.zip", browser_download_url = "https://x/d.zip" },
|
||||
},
|
||||
})
|
||||
local list = ModUpdate.parseReleases(body, "demo")
|
||||
eq(list[1].body:sub(1, 2), "##", "release body is kept raw")
|
||||
local cleaned = ModUpdate.cleanBody(list[1].body, 200)
|
||||
check(cleaned:find("fixed", 1, true) and cleaned:find("thing", 1, true),
|
||||
"cleanBody keeps readable text")
|
||||
check(not cleaned:find("http://", 1, true), "cleanBody strips link urls")
|
||||
check(not cleaned:find("<!--", 1, true), "cleanBody strips HTML comments")
|
||||
|
||||
local status, best = ModUpdate.statusFor("1.0.0", list)
|
||||
eq(status, "available", "newer release reports available")
|
||||
eq(best.version, "2.0.0", "best release is the newer one")
|
||||
eq(ModUpdate.statusFor("2.0.0", list), "current", "matching version is current")
|
||||
|
||||
check(ModUpdate.cacheFresh({ checkedAt = os.time() - 10 }),
|
||||
"fresh cache is within TTL")
|
||||
check(not ModUpdate.cacheFresh({ checkedAt = os.time() - ModUpdate.CACHE_TTL - 1 }),
|
||||
"expired cache is not fresh")
|
||||
|
||||
local preview = ModUpdate.previewLine(list[1].body, 40)
|
||||
check(not preview:find("\n", 1, true), "previewLine collapses newlines")
|
||||
check(#preview <= 41, "previewLine respects maxChars (+ellipsis)")
|
||||
check(preview:find("Changes", 1, true), "previewLine keeps heading text")
|
||||
end
|
||||
|
||||
print("ok mod_update_tests")
|
||||
@@ -0,0 +1,116 @@
|
||||
-- CONTROLS rebinding: a captured key must not reach the live map while the
|
||||
-- screen that captured it is still steering by that map (#510). Swapping A
|
||||
-- and B used to close the screen mid-swap, because Input:applyBindings ran
|
||||
-- inside BindingsMenu:storeBinding and turned the player's next confirm
|
||||
-- press into a cancel. No pokered cite: rebinding is port-only (gap C2).
|
||||
-- luajit tests/engine/rebind_capture_bug510.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
local BindingsMenu = require("src.ui.BindingsMenu")
|
||||
|
||||
-- the two doubles BindingsMenu touches: a stack it can pop itself off and
|
||||
-- an input whose queue is one fixed step of edges
|
||||
local function newGame()
|
||||
local game = { save = { options = {} }, wroteOptions = 0 }
|
||||
game.stack = {
|
||||
states = {},
|
||||
push = function(self, s) table.insert(self.states, s) end,
|
||||
pop = function(self) return table.remove(self.states) end,
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
}
|
||||
game.input = {
|
||||
queue = {},
|
||||
wasPressed = function(self, btn) return self.queue[btn] or false end,
|
||||
isDown = function() return false end,
|
||||
}
|
||||
function game:writeOptions() self.wroteOptions = self.wroteOptions + 1 end
|
||||
return game
|
||||
end
|
||||
|
||||
local function press(state, btn)
|
||||
state.game.input.queue = { [btn] = true }
|
||||
state:update(1 / 60)
|
||||
state.game.input.queue = {}
|
||||
end
|
||||
|
||||
local function openMenu(game)
|
||||
local bm = BindingsMenu.new(game)
|
||||
game.stack:push(bm)
|
||||
return bm
|
||||
end
|
||||
|
||||
-- rows are BindingsMenu's BUTTONS order; 5 = A, 6 = B
|
||||
local ROW_A, ROW_B = 5, 6
|
||||
|
||||
-- The mechanism, pinned so a future "just apply it immediately" revert
|
||||
-- fails here: a rebind overwrites whatever the key used to do, so B = Z
|
||||
-- costs Z its default A action.
|
||||
Input:init()
|
||||
eq(Input.keyBindings["z"], "a", "Z presses A in the default map")
|
||||
Input:applyBindings({ b = { key = "z" } })
|
||||
eq(Input.keyBindings["z"], "b",
|
||||
"applying B = Z takes Z away from A, so a live apply would flip confirm "
|
||||
.. "into cancel")
|
||||
Input:init()
|
||||
|
||||
-- The reporter's flow: arm B, bind Z to it, then keep using the menu.
|
||||
local game = newGame()
|
||||
local bm = openMenu(game)
|
||||
press(bm, "a") -- open the row the cursor starts on to prove arming
|
||||
eq(bm.capture, bm.items[1], "A on a row arms the capture")
|
||||
bm:onKeyPressed("escape")
|
||||
check(bm.capture == nil, "escape disarms an armed capture")
|
||||
check(game.save.options.bindings == nil,
|
||||
"escaping a capture writes no binding")
|
||||
eq(game.wroteOptions, 0, "and does not touch options on disk")
|
||||
|
||||
bm.index = ROW_B
|
||||
press(bm, "a")
|
||||
bm:onKeyPressed("z")
|
||||
eq(game.save.options.bindings.b.key, "z", "the capture stores B = Z")
|
||||
eq(bm.items[ROW_B].right, "Z", "the row shows the new key straight away")
|
||||
eq(game.wroteOptions, 1, "the choice persists immediately")
|
||||
eq(Input.keyBindings["z"], "a",
|
||||
"but the live map still reads Z as A while the screen is open (#510)")
|
||||
eq(#game.stack.states, 1, "capturing Z does not close the screen")
|
||||
|
||||
-- the next Z the player presses is still confirm, so the A row can be armed
|
||||
bm.index = ROW_A
|
||||
press(bm, "a")
|
||||
eq(bm.capture, bm.items[ROW_A], "the A row arms instead of the screen closing")
|
||||
bm:onKeyPressed("x")
|
||||
eq(game.save.options.bindings.a.key, "x", "the swap's other half stores")
|
||||
eq(Input.keyBindings["x"], "b", "and X is still cancel until the screen closes")
|
||||
|
||||
-- closing commits both halves at once, through ListMenu's onCancel
|
||||
press(bm, "b")
|
||||
eq(#game.stack.states, 0, "B closes the rebind screen")
|
||||
eq(Input.keyBindings["z"], "b", "closing puts the swap live: Z is B")
|
||||
eq(Input.keyBindings["x"], "a", "and X is A")
|
||||
|
||||
-- A close that never runs the hook still ends up correct, because
|
||||
-- Game:applyOptions re-applies save.options.bindings on load.
|
||||
Input:init()
|
||||
Input:applyBindings(game.save.options.bindings)
|
||||
eq(Input.keyBindings["z"], "b", "a reload reaches the same map as the close")
|
||||
|
||||
-- pad captures ride the same deferral
|
||||
Input:init()
|
||||
local padGame = newGame()
|
||||
local padBm = openMenu(padGame)
|
||||
padBm.index = ROW_B
|
||||
press(padBm, "a")
|
||||
padBm:onGamepadPressed("y")
|
||||
eq(padGame.save.options.bindings.b.pad, "y", "a pad capture stores")
|
||||
eq(Input.padBindings["y"], nil, "and stays out of the live pad map until close")
|
||||
press(padBm, "b")
|
||||
eq(Input.padBindings["y"], "b", "closing commits the pad half too")
|
||||
|
||||
Input:init()
|
||||
T.finish("rebind_capture_bug510")
|
||||
@@ -0,0 +1,147 @@
|
||||
-- Public seams a tool mod needs: a fixed-step input hook and a
|
||||
-- title-menu entry point. Both are no-ops without a mod (gate_hooks.lua
|
||||
-- covers that parity); this case proves a real public wrapper can act at the
|
||||
-- correct point and decorate the real title menu.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local savedEvents, savedHooks, savedErrors =
|
||||
Runtime.events, Runtime.hooks, Runtime.errors
|
||||
local hooks = Hooks.new()
|
||||
Runtime.hooks = hooks
|
||||
|
||||
-- If input.step disappears, or moves after Input:step, a bot's buttons land
|
||||
-- one logic tick late. Exercise Game:step itself and pin the observable
|
||||
-- ordering rather than merely checking that a hook was registered.
|
||||
do
|
||||
local order = {}
|
||||
local fake = {
|
||||
input = { step = function() order[#order + 1] = "input" end },
|
||||
stack = { update = function(_, dt)
|
||||
order[#order + 1] = "world"
|
||||
T.eq(dt, 1 / 60, "the world receives the fixed-step dt")
|
||||
end },
|
||||
save = {},
|
||||
}
|
||||
hooks:wrap("input.step", function(nextFn, game, dt)
|
||||
T.check(game == fake, "input.step receives the live Game object")
|
||||
T.eq(dt, 1 / 60, "input.step receives the fixed-step dt")
|
||||
order[#order + 1] = "hook"
|
||||
return nextFn(game, dt)
|
||||
end, 0, "tool_fixture")
|
||||
|
||||
require("src.core.Game").step(fake, 1 / 60)
|
||||
T.eq(table.concat(order, ","), "hook,input,world",
|
||||
"input.step runs before input edges are promoted and before gameplay")
|
||||
hooks:removeOwner("tool_fixture")
|
||||
end
|
||||
|
||||
-- If ui.title_menu.items disappears, a tool mod can load but has no
|
||||
-- safe user-facing way to begin a fresh, non-destructive run.
|
||||
do
|
||||
local TitleState = require("src.ui.TitleState")
|
||||
local stack = { states = {} }
|
||||
function stack:push(state) self.states[#self.states + 1] = state end
|
||||
function stack:top() return self.states[#self.states] end
|
||||
local game = {
|
||||
data = { field = { title = { cycleSpecies = { "MEW" } } },
|
||||
pokemon = { MEW = {} } },
|
||||
stack = stack,
|
||||
}
|
||||
|
||||
hooks:wrap("ui.title_menu.items", function(nextFn, liveGame, items)
|
||||
T.check(liveGame == game, "ui.title_menu.items receives the live Game")
|
||||
table.insert(items, #items, { label = "AUTOPLAY" })
|
||||
return nextFn(liveGame, items)
|
||||
end, 0, "tool_fixture")
|
||||
|
||||
TitleState.new(game, {}):openMenu()
|
||||
local menu = stack:top()
|
||||
T.eq(menu.items[#menu.items - 1].label, "AUTOPLAY",
|
||||
"ui.title_menu.items can insert AUTOPLAY before EXIT GAME")
|
||||
hooks:removeOwner("tool_fixture")
|
||||
end
|
||||
|
||||
-- A viewer/AI session must be able to veto every normal progress-save path,
|
||||
-- including the in-game SAVE menu and autosaves, before captureSave mutates
|
||||
-- the live snapshot in preparation for disk IO.
|
||||
do
|
||||
local captured = false
|
||||
local fake = {
|
||||
save = {},
|
||||
overworld = { captureSave = function() captured = true end },
|
||||
}
|
||||
hooks:wrap("save.write", function(nextFn, game)
|
||||
T.check(game == fake, "save.write receives the live Game object")
|
||||
return false
|
||||
end, 0, "tool_fixture")
|
||||
|
||||
local saved = require("src.core.Game").writeSave(fake)
|
||||
T.eq(saved, false, "save.write can veto progress persistence")
|
||||
T.eq(captured, false, "a veto happens before save-state capture")
|
||||
hooks:removeOwner("tool_fixture")
|
||||
end
|
||||
|
||||
-- A tool status indicator draws in window space after the renderer composites
|
||||
-- the game. This gives it exact playfield/margin geometry and keeps it crisp
|
||||
-- over compatible render pipelines without entering the game canvas.
|
||||
do
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
local savedSetUISize, savedBegin, savedEnd, savedTouch =
|
||||
Renderer.setUISize, Renderer.beginFrame, Renderer.endFrame,
|
||||
TouchControls.draw
|
||||
local order = {}
|
||||
Renderer.setUISize = function() end
|
||||
Renderer.beginFrame = function() end
|
||||
Renderer.endFrame = function()
|
||||
order[#order + 1] = "present"
|
||||
return {
|
||||
width = 1024, height = 768,
|
||||
gameX = 112, gameY = 24,
|
||||
gameWidth = 800, gameHeight = 720,
|
||||
scale = 5,
|
||||
}
|
||||
end
|
||||
TouchControls.draw = function() order[#order + 1] = "touch" end
|
||||
|
||||
-- Game:draw iterates stack.states and calls each state's draw (it no
|
||||
-- longer goes through stack:draw), so the fixture must put a drawable
|
||||
-- state on the stack to observe composition order.
|
||||
local fake = {
|
||||
overworld = {},
|
||||
stack = {
|
||||
states = {
|
||||
{ draw = function() order[#order + 1] = "states" end },
|
||||
},
|
||||
},
|
||||
}
|
||||
function fake.stack:visibleBase() return 1 end
|
||||
function fake.stack:top() return self.states[#self.states] end
|
||||
|
||||
hooks:wrap("render.hud", function(nextFn, game, viewport)
|
||||
T.check(game == fake, "render.hud receives the live Game object")
|
||||
T.eq(viewport.width, 1024, "render.hud receives the window width")
|
||||
T.eq(viewport.height, 768, "render.hud receives the window height")
|
||||
T.eq(viewport.gameX, 112, "render.hud receives the playfield origin")
|
||||
T.eq(viewport.gameWidth, 800, "render.hud receives the playfield width")
|
||||
order[#order + 1] = "hud"
|
||||
return nextFn(game, viewport)
|
||||
end, 0, "tool_fixture")
|
||||
|
||||
require("src.core.Game").draw(fake)
|
||||
T.eq(table.concat(order, ","), "states,present,hud,touch",
|
||||
"render.hud draws after frame composition and before touch controls")
|
||||
hooks:removeOwner("tool_fixture")
|
||||
Renderer.setUISize, Renderer.beginFrame, Renderer.endFrame,
|
||||
TouchControls.draw = savedSetUISize, savedBegin, savedEnd, savedTouch
|
||||
end
|
||||
|
||||
Runtime.events, Runtime.hooks, Runtime.errors =
|
||||
savedEvents, savedHooks, savedErrors
|
||||
|
||||
T.finish("tool_mod_hooks")
|
||||
@@ -0,0 +1,46 @@
|
||||
-- End-game persistence must honor a Game:writeSave veto so an ephemeral tool
|
||||
-- session cannot overwrite the player's real save while THE END is on screen.
|
||||
|
||||
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 Commands = require("src.script.Commands")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Screens = require("src.ui.Screens")
|
||||
|
||||
local originalPush, originalSave = Screens.push, SaveData.save
|
||||
local screens = {}
|
||||
local directWrites = 0
|
||||
|
||||
Screens.push = function(_, id, onDone, onTheEnd)
|
||||
screens[id] = { onDone = onDone, onTheEnd = onTheEnd }
|
||||
return screens[id]
|
||||
end
|
||||
SaveData.save = function()
|
||||
directWrites = directWrites + 1
|
||||
return true
|
||||
end
|
||||
|
||||
local save = SaveData.newGame()
|
||||
save.party = {}
|
||||
local game = {
|
||||
data = { field = { boot = {} } },
|
||||
save = save,
|
||||
writeSave = function() return false end,
|
||||
}
|
||||
local runner = { yield = function() coroutine.yield() end }
|
||||
local ctx = { game = game, save = save, runner = runner }
|
||||
local co = coroutine.create(function() Commands.record_hall_of_fame(ctx) end)
|
||||
local ok, err = coroutine.resume(co)
|
||||
T.check(ok, "Hall of Fame command reaches its UI yield: " .. tostring(err))
|
||||
T.check(screens.HallOfFame ~= nil, "Hall of Fame induction was requested")
|
||||
|
||||
screens.HallOfFame.onDone()
|
||||
T.check(screens.Credits ~= nil, "credits were requested after induction")
|
||||
screens.Credits.onTheEnd()
|
||||
T.eq(directWrites, 0,
|
||||
"a vetoed Hall of Fame autosave performs no fallback disk write")
|
||||
|
||||
Screens.push, SaveData.save = originalPush, originalSave
|
||||
T.finish("tool_save_safety")
|
||||
@@ -1 +1 @@
|
||||
9820543f7a223fab
|
||||
7de3051a7b3c108e
|
||||
|
||||
@@ -1 +1 @@
|
||||
54a52ea81751495c
|
||||
227fd737aba15763
|
||||
|
||||
@@ -116,6 +116,28 @@ check(full.conflictSpecs[1].id == "always_noon" and full.conflictSpecs[1].range
|
||||
check(full.options_schema == "options.lua"
|
||||
and full.assets_transforms == "transforms.lua", "declared files are kept")
|
||||
|
||||
-- ------- github / experimental / incompatible
|
||||
local gh = Manifest.validate({
|
||||
id = "gh", name = "GH", version = "1.0.0", entry = "main.lua",
|
||||
github = "https://github.com/Acme/Cool-Mod.git",
|
||||
experimental = true,
|
||||
incompatible = { "rival" },
|
||||
conflicts = { "rival", "other" },
|
||||
})
|
||||
check(gh.github == "Acme/Cool-Mod", "github URL normalizes to owner/repo")
|
||||
check(gh.experimental == true, "experimental flag is kept")
|
||||
check(#gh.conflictSpecs == 2, "incompatible merges into conflicts without dupes")
|
||||
check(gh.conflictSpecs[1].id == "rival" and gh.conflictSpecs[2].id == "other",
|
||||
"conflicts list order keeps conflicts then new incompatible ids")
|
||||
check(Manifest.parseGithub(nil) == nil and Manifest.parseGithub("") == nil,
|
||||
"absent github is nil")
|
||||
check(not pcall(Manifest.parseGithub, "not a repo"),
|
||||
"a malformed github value fails")
|
||||
check(not pcall(Manifest.validate, {
|
||||
id = "badgh", name = "Bad", version = "1.0.0", entry = "main.lua",
|
||||
github = "ftp://example.com/x",
|
||||
}), "a bad github field fails manifest validation")
|
||||
|
||||
local v1 = Manifest.validate({
|
||||
id = "v1", name = "V1", version = "1.0.0", entry = "main.lua",
|
||||
}, "mods/v1")
|
||||
@@ -156,6 +178,21 @@ check(not pcall(Manifest.validate, {
|
||||
dependencies = { "other@nonsense" },
|
||||
}, "mods/baddep"), "a malformed dependency range fails validation")
|
||||
|
||||
-- ------- experimental mods stay disabled until options.mods says otherwise
|
||||
do
|
||||
local loader = Loader.new({ fs = memfs({
|
||||
["mods/lab/manifest.json"] = manifestJson("lab", {
|
||||
experimental = "true", api = "2", profile = '"content"',
|
||||
}),
|
||||
["mods/lab/main.lua"] = "return function(mod) mod.content.items:register('X', {}) end",
|
||||
}) })
|
||||
local data = { items = {} }
|
||||
check(loader:load(data) == true, "experimental-only install still boots")
|
||||
local st = statusById(loader)
|
||||
check(st.lab.state == "disabled", "experimental mod is disabled by default")
|
||||
check(data.items.X == nil, "experimental entry chunk does not run while off")
|
||||
end
|
||||
|
||||
-- ------- game_version against the engine
|
||||
local versionLoader = Loader.new({ fs = memfs({
|
||||
["mods/future/manifest.json"] = manifestJson("future", { game_version = '">=2.0"' }),
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Parity: the main battle-menu cursor stops at an edge instead of wrapping
|
||||
-- to the opposite command (#485).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not Data.maps then Data:load() end
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local S = require("tests.harness").suite("parity battle menu cursor")
|
||||
local eq = S.eq
|
||||
|
||||
local pressed = {}
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "BULBASAUR", 5) }
|
||||
local game = {
|
||||
data = Data,
|
||||
save = save,
|
||||
input = { wasPressed = function(_, key) return pressed[key] == true end },
|
||||
stack = { push = function() end, pop = function() end, top = function() end },
|
||||
}
|
||||
local battle = BattleState.newWild(game, "RATTATA", 2)
|
||||
battle.phase = "menu"
|
||||
|
||||
local function pressAt(state, index, key)
|
||||
state.menuIndex = index
|
||||
pressed[key] = true
|
||||
state:update(1 / 60)
|
||||
pressed[key] = nil
|
||||
return state.menuIndex
|
||||
end
|
||||
|
||||
eq(pressAt(battle, 1, "left"), 1, "FIGHT stays selected when pressing left")
|
||||
eq(pressAt(battle, 2, "right"), 2, "PKMN stays selected when pressing right")
|
||||
eq(pressAt(battle, 1, "up"), 1, "FIGHT stays selected when pressing up")
|
||||
eq(pressAt(battle, 4, "down"), 4, "RUN stays selected when pressing down")
|
||||
eq(pressAt(battle, 1, "right"), 2, "FIGHT moves right to PKMN")
|
||||
eq(pressAt(battle, 1, "down"), 3, "FIGHT moves down to ITEM")
|
||||
|
||||
local safari = BattleState.newWild(game, "RATTATA", 2)
|
||||
safari:makeSafari({ balls = 30 })
|
||||
safari.phase = "menu"
|
||||
eq(pressAt(safari, 1, "left"), 1, "SAFARI BALL stays selected when pressing left")
|
||||
eq(pressAt(safari, 2, "right"), 2, "BAIT stays selected when pressing right")
|
||||
eq(pressAt(safari, 1, "up"), 1, "SAFARI BALL stays selected when pressing up")
|
||||
eq(pressAt(safari, 4, "down"), 4, "RUN stays selected when pressing down")
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,130 @@
|
||||
-- Regression (#518): without a BICYCLE, the Route 16/18 gate guard must
|
||||
-- still shove the player one tile aside after refusing them, not leave
|
||||
-- them parked against the counter.
|
||||
--
|
||||
-- pokered's Route16Gate1FGuardScript / Route18Gate1FGuardScript (scripts/
|
||||
-- Route16Gate1F.asm, Route18Gate1F.asm) print the refusal text, then
|
||||
-- simulate one PAD_RIGHT step (StartSimulatingJoypadStates) and only clear
|
||||
-- wJoyIgnore once that step lands. data/scripts/story5.lua's bikeGateGuard
|
||||
-- walked the player up to the counter and stopped there -- the ported
|
||||
-- refusal text played, but nothing ever moved the player off the doorway.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.ROUTE_16_GATE_1F) then Data:load() end
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local MapLoader = require("src.world.MapLoader")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local OW = require("src.world.OverworldController")
|
||||
local S = require("tests.harness").suite("parity bike gate shove (#518)")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
-- ground truth: data/generated/maps.lua ROUTE_16_GATE_1F.objects, the guard
|
||||
-- sits at (4,5) behind a solid counter row (row 6); rows 7-10 south of it
|
||||
-- are the open waiting area bikeGateGuard's coords cover.
|
||||
local r16 = MapLoader.load(Data, "ROUTE_16_GATE_1F")
|
||||
check(r16:isWalkableCell(4, 7), "the counter-adjacent stop cell (4,7) is walkable")
|
||||
check(r16:isWalkableCell(5, 7), "the shove target (5,7) is walkable")
|
||||
check(not r16:isWalkableCell(4, 6), "row 6 (the counter) blocks straight through")
|
||||
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.renderer = Renderer; Renderer:init()
|
||||
Game.stack = StateStack; StateStack:init()
|
||||
|
||||
-- stub TextBox the way parity_A.lua drives gym leader text: a bare table
|
||||
-- capturing (text, onDone) so the test can dismiss it without pagination.
|
||||
local realTB = package.loaded["src.render.TextBox"]
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, done) return { text = text, onDone = done } end,
|
||||
}
|
||||
|
||||
local mapScripts = require("data.scripts.init")
|
||||
local hooks = mapScripts.get("ROUTE_16_GATE_1F")
|
||||
check(hooks and hooks.onStep, "ROUTE_16_GATE_1F has an onStep hook")
|
||||
|
||||
-- one 16-frame step per fixed update, same as parity_ledge_seam_hop.lua
|
||||
local function runFrames(ow, n)
|
||||
for _ = 1, n do
|
||||
ow:updateScriptMoves()
|
||||
ow.player:update()
|
||||
end
|
||||
end
|
||||
|
||||
-- dismiss the top stubbed box the way TextBox:update does: pop, then onDone
|
||||
local function dismissTop()
|
||||
local box = Game.stack:pop()
|
||||
if box.onDone then box.onDone() end
|
||||
return box
|
||||
end
|
||||
|
||||
local function standNoBike(x, y, facing)
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.inventory.BICYCLE = nil
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, "ROUTE_16_GATE_1F", x, y, facing or "up")
|
||||
local ow = Game.stack:top()
|
||||
ow.player.moving = false
|
||||
return ow
|
||||
end
|
||||
|
||||
-- --- approaching from three tiles out: walk-up then shove -----------------
|
||||
do
|
||||
local ow = standNoBike(4, 10)
|
||||
local handled = hooks.onStep(Game, ow, ow.player.cellX, ow.player.cellY)
|
||||
check(handled, "onStep claims the coordinate trigger without a BICYCLE")
|
||||
|
||||
local box1 = Game.stack:top()
|
||||
check(box1 ~= nil, "the wait-up refusal text box opened")
|
||||
check(box1.text:find("Wait up", 1, true) ~= nil
|
||||
or box1.text:find("Excuse me", 1, true) ~= nil,
|
||||
"first box is the guard's stop line")
|
||||
dismissTop()
|
||||
|
||||
local box2 = Game.stack:top()
|
||||
check(box2 ~= nil, "the second (explanation) box opened")
|
||||
eq(box2.text, Data.text._Route16Gate1FGuardNoPedestriansAllowedText,
|
||||
"second box is the guard's no-pedestrians explanation, verbatim")
|
||||
dismissTop()
|
||||
|
||||
check(#ow.scriptMoves > 0 or ow.player.moving,
|
||||
"closing the explanation queues the walk-up-then-shove movement")
|
||||
runFrames(ow, 200)
|
||||
check(not ow.player.moving, "the scripted movement has settled")
|
||||
eq(#ow.scriptMoves, 0, "no scripted move is left hanging")
|
||||
eq(ow.player.cellX, 5, "the player ends one tile right of the counter (#518 shove)")
|
||||
eq(ow.player.cellY, 7, "the player ends on the counter-adjacent row, not further")
|
||||
end
|
||||
|
||||
-- --- already standing at the counter-adjacent row: shove with no walk-up --
|
||||
do
|
||||
local ow = standNoBike(4, 7)
|
||||
local handled = hooks.onStep(Game, ow, ow.player.cellX, ow.player.cellY)
|
||||
check(handled, "onStep still claims it when already adjacent to the counter")
|
||||
dismissTop() -- wait-up
|
||||
dismissTop() -- explanation
|
||||
runFrames(ow, 200)
|
||||
check(not ow.player.moving, "the shove-only movement has settled")
|
||||
eq(#ow.scriptMoves, 0, "no scripted move is left hanging")
|
||||
eq(ow.player.cellX, 5, "still ends one tile right (#518 shove with dist=0)")
|
||||
eq(ow.player.cellY, 7, "y is unchanged since there was no walk-up needed")
|
||||
end
|
||||
|
||||
-- --- control: a BICYCLE in the bag lets the trigger pass through untouched
|
||||
do
|
||||
local ow = standNoBike(4, 10)
|
||||
ow.player.moving = false
|
||||
Game.save.inventory.BICYCLE = true
|
||||
local handled = hooks.onStep(Game, ow, ow.player.cellX, ow.player.cellY)
|
||||
check(not handled, "with a BICYCLE, onStep does not intercept the step")
|
||||
check(Game.stack:top() == ow, "no text box opened when riding a BICYCLE")
|
||||
end
|
||||
|
||||
package.loaded["src.render.TextBox"] = realTB
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,63 @@
|
||||
-- Parity: leaving the Cerulean Badge House badge-description menu must
|
||||
-- return control to the overworld after viewing a badge (#454).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("parity Cerulean Badge House")
|
||||
local eq = S.eq
|
||||
local script = require("data.scripts.flavor.cerulean_badge_house")
|
||||
.CERULEAN_BADGE_HOUSE.talk.TEXT_CERULEANBADGEHOUSE_MIDDLE_AGED_MAN
|
||||
|
||||
local states = {}
|
||||
local game = {
|
||||
data = {
|
||||
items = {
|
||||
BOULDERBADGE = { name = "BOULDERBADGE" },
|
||||
CASCADEBADGE = { name = "CASCADEBADGE" },
|
||||
THUNDERBADGE = { name = "THUNDERBADGE" },
|
||||
RAINBOWBADGE = { name = "RAINBOWBADGE" },
|
||||
SOULBADGE = { name = "SOULBADGE" },
|
||||
MARSHBADGE = { name = "MARSHBADGE" },
|
||||
VOLCANOBADGE = { name = "VOLCANOBADGE" },
|
||||
EARTHBADGE = { name = "EARTHBADGE" },
|
||||
},
|
||||
text = {
|
||||
_CeruleanBadgeHouseMiddleAgedManText = "",
|
||||
_CeruleanBadgeHouseMiddleAgedManWhichBadgeText = "",
|
||||
_CeruleanBadgeHouseBoulderBadgeText = "",
|
||||
_CeruleanBadgeHouseMiddleAgedManVisitAnyTimeText = "",
|
||||
},
|
||||
},
|
||||
save = { player = { name = "RED" } },
|
||||
input = { wasPressed = function(_, key) return key == "b" end },
|
||||
stack = {
|
||||
push = function(_, state) states[#states + 1] = state end,
|
||||
pop = function() return table.remove(states) end,
|
||||
top = function() return states[#states] end,
|
||||
},
|
||||
}
|
||||
|
||||
local done = false
|
||||
script(game, nil, nil, function() done = true end)
|
||||
|
||||
local function dismissText()
|
||||
local text = game.stack:pop()
|
||||
text.onDone()
|
||||
end
|
||||
|
||||
-- Greeting -> prompt -> first menu -> badge description -> prompt -> second menu.
|
||||
dismissText()
|
||||
dismissText()
|
||||
local firstMenu = game.stack:top()
|
||||
firstMenu.onChoose(firstMenu.items[1])
|
||||
dismissText()
|
||||
dismissText()
|
||||
|
||||
-- B follows ListMenu's real cancellation path: pop menu, then show goodbye.
|
||||
game.stack:top():update(0)
|
||||
dismissText()
|
||||
|
||||
eq(#states, 0, "backing out after viewing a badge leaves no menu behind")
|
||||
eq(done, true, "backing out after viewing a badge completes the NPC script")
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,72 @@
|
||||
-- Parity test: Self-Destruct/Explosion against a mid-Fly/Dig
|
||||
-- (semi-invulnerable) target still faints the user (#528).
|
||||
--
|
||||
-- EffectRegistry.runDamaging's target.invulnerable branch prints the miss
|
||||
-- text and returned without ever calling record.onMiss, while the
|
||||
-- accuracy-roll, type-immunity and floored-damage miss branches all did.
|
||||
-- EXPLODE_EFFECT (src/battle/MoveEffects.lua) relies on onMiss to run
|
||||
-- battle:selfDestruct(user); skipping it left the user at full HP.
|
||||
--
|
||||
-- pokered engine/battle/core.asm MoveHitTest: "bit INVULNERABLE, [hl] /
|
||||
-- jp nz, .moveMissed" sets the same wMoveMissed as a failed accuracy roll,
|
||||
-- and the shared miss handler runs the explode effect regardless of which
|
||||
-- branch set it ("even if Explosion or Selfdestruct missed, its effect
|
||||
-- still needs to be activated"). JUMP_KICK_EFFECT's onMiss filters on
|
||||
-- reason == "accuracy", so a crash-damage kick must NOT also fire here.
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_explode_invulnerable.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local S = require("tests.harness").suite("parity explode invulnerable")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
Data:load()
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.stack = StateStack; StateStack:init()
|
||||
Game.save = require("src.core.SaveData").newGame()
|
||||
|
||||
-- Self-Destruct against a target mid-Fly (invulnerable, never-miss moves
|
||||
-- aside): the move never rolls accuracy at all, it just hits the
|
||||
-- invulnerable branch in EffectRegistry.runDamaging.
|
||||
local function selfDestructVsInvulnerable(moveId)
|
||||
Game.save.party = { Pokemon.new(Data, "GEODUDE", 30) }
|
||||
local b = BattleState.newWild(Game, "PIDGEY", 20)
|
||||
b.enemy.invulnerable = true -- mid-Fly/Dig, set by ChargeEffect on turn 1
|
||||
local hpBefore = b.enemy.mon.hp
|
||||
b:performMove(b.player, b.enemy, { id = moveId, pp = 5 })
|
||||
return b, hpBefore
|
||||
end
|
||||
|
||||
for _, moveId in ipairs({ "SELFDESTRUCT", "EXPLOSION" }) do
|
||||
local b, enemyHpBefore = selfDestructVsInvulnerable(moveId)
|
||||
eq(b.player.mon.hp, 0, moveId .. " vs an invulnerable target still faints the user")
|
||||
eq(b.enemy.mon.hp, enemyHpBefore,
|
||||
moveId .. " vs an invulnerable target deals the target no damage")
|
||||
local sawMiss = false
|
||||
for _, r in ipairs(b.queue) do
|
||||
if r.text and r.text:find("attack missed", 1, true) then sawMiss = true end
|
||||
end
|
||||
check(sawMiss, moveId .. " still prints the miss text against an invulnerable target")
|
||||
end
|
||||
|
||||
-- Companion: JUMP_KICK's onMiss must reject the "invulnerable" reason, so
|
||||
-- a mid-Fly target does not also take Jump Kick's crash damage (its
|
||||
-- onMiss guard only fires for reason == "accuracy").
|
||||
Game.save.party = { Pokemon.new(Data, "GEODUDE", 30) }
|
||||
local jk = BattleState.newWild(Game, "PIDGEY", 20)
|
||||
jk.enemy.invulnerable = true
|
||||
local jkHpBefore = jk.player.mon.hp
|
||||
jk:performMove(jk.player, jk.enemy, { id = "JUMP_KICK", pp = 5 })
|
||||
eq(jk.player.mon.hp, jkHpBefore,
|
||||
"JUMP KICK vs an invulnerable target takes no crash damage (reason filter holds)")
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,58 @@
|
||||
-- FightingDojoDefaultScript gates the Karate Master at the one tile to his
|
||||
-- left. He is not a regular sight-line trainer (#495).
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not Data.maps then Data:load() end
|
||||
|
||||
local S = require("tests.harness").suite("parity Fighting Dojo gate")
|
||||
local check, eq = S.check, S.eq
|
||||
local script = require("data.scripts.story4").FIGHTING_DOJO.onStep
|
||||
|
||||
local function scene()
|
||||
local master = {
|
||||
def = { name = "FIGHTINGDOJO_KARATE_MASTER" },
|
||||
facing = "down",
|
||||
facePlayer = function(self, player)
|
||||
self.facing = player.cellX < 5 and "left" or "right"
|
||||
end,
|
||||
}
|
||||
local ow = {
|
||||
npcs = { master },
|
||||
player = { cellX = 4, cellY = 3, facing = "down" },
|
||||
trainerDefeated = function() return false end,
|
||||
engageTrainer = function(self, npc) self.engaged = npc end,
|
||||
}
|
||||
return { save = { flags = {} } }, ow, master
|
||||
end
|
||||
|
||||
local header = Data:trainerHeader("FightingDojo", 1)
|
||||
eq(header and header.range, 0,
|
||||
"Karate Master has no generic trainer sight range")
|
||||
|
||||
do
|
||||
local game, ow = scene()
|
||||
check(not script(game, ow, 5, 4),
|
||||
"standing below the downward-facing Master does not trigger him")
|
||||
check(ow.engaged == nil, "the below tile does not begin a battle")
|
||||
end
|
||||
|
||||
do
|
||||
local game, ow, master = scene()
|
||||
check(script(game, ow, 4, 3),
|
||||
"the tile immediately left of the Master triggers his script")
|
||||
eq(ow.engaged, master, "the Master starts the battle")
|
||||
eq(master.facing, "left", "the Master turns toward the player")
|
||||
eq(ow.player.facing, "right", "the player turns toward the Master")
|
||||
end
|
||||
|
||||
do
|
||||
local game, ow = scene()
|
||||
game.save.flags.EVENT_BEAT_KARATE_MASTER = true
|
||||
check(not script(game, ow, 4, 3),
|
||||
"the gate stays inactive after the Karate Master is beaten")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,102 @@
|
||||
-- Parity test: the link fingerprint no longer hashes catchRate, so a
|
||||
-- Yellow-shaped dataset that differs from Red/Blue only at
|
||||
-- Dragonair/Dragonite's catch rate digests identical and the handshake
|
||||
-- reports "full" (#511).
|
||||
--
|
||||
-- Real cross-version link play (two processes, Red vs Yellow) is outside
|
||||
-- what this checkout can assert: it needs both a Red and a Yellow
|
||||
-- data/generated/ cache and two live UDP peers. What IS assertable from
|
||||
-- one process: (1) catchRate is excluded from the hashed species surface,
|
||||
-- so editing only that field never moves the digest, and (2) a synthetic
|
||||
-- Yellow stand-in built by copying the real Data and retuning just those
|
||||
-- two species' catchRate (the only real R/B vs Yellow link-surface delta,
|
||||
-- per data/pokemon/base_stats/dragonair.asm 45 vs 27 and dragonite.asm 45
|
||||
-- vs 9) fingerprints identically and clears Handshake.checkCompat as
|
||||
-- "full", not "subset".
|
||||
--
|
||||
-- A human still has to run the actual cross-version check: launch a Red
|
||||
-- instance and a Yellow instance (POKEPORT_VERSION=red / =yellow,
|
||||
-- distinct POKEPORT_IDENTITY sandboxes), host/join on localhost, and
|
||||
-- confirm no "game data is not the same" notice appears and a trade goes
|
||||
-- through both ways.
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_link_fingerprint_yellow.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local S = require("tests.harness").suite("parity link fingerprint yellow")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
Data:load()
|
||||
|
||||
local Fingerprint = require("src.link.Fingerprint")
|
||||
local Handshake = require("src.link.Handshake")
|
||||
|
||||
local function copy(record)
|
||||
local out = {}
|
||||
for k, v in pairs(record) do out[k] = v end
|
||||
return out
|
||||
end
|
||||
|
||||
local function cloneData(base)
|
||||
local out = { pokemon = {}, moves = {}, type_chart = base.type_chart,
|
||||
constants = base.constants }
|
||||
for id, record in pairs(base.pokemon) do out.pokemon[id] = record end
|
||||
for id, record in pairs(base.moves) do out.moves[id] = record end
|
||||
return out
|
||||
end
|
||||
|
||||
-- (1) catchRate alone must not move the digest: this is the field
|
||||
-- allowlist change at src/link/Fingerprint.lua SPECIES_FIELDS.
|
||||
local vanilla = cloneData(Data)
|
||||
local first = Fingerprint.compute(vanilla, {})
|
||||
|
||||
local rateOnly = cloneData(Data)
|
||||
local pidgeyRate = copy(Data.pokemon.PIDGEY)
|
||||
pidgeyRate.catchRate = (pidgeyRate.catchRate or 0) + 1
|
||||
rateOnly.pokemon.PIDGEY = pidgeyRate
|
||||
eq(Fingerprint.compute(rateOnly, {}), first,
|
||||
"a catchRate-only edit does not move the link fingerprint")
|
||||
|
||||
-- (2) a Yellow stand-in: same records, Dragonair/Dragonite catch rate
|
||||
-- retuned to Yellow's values (the only real R/B vs Yellow link delta).
|
||||
Fingerprint.forget(vanilla)
|
||||
local yellowish = cloneData(Data)
|
||||
if Data.pokemon.DRAGONAIR then
|
||||
local dragonair = copy(Data.pokemon.DRAGONAIR)
|
||||
dragonair.catchRate = 27 -- pokeyellow data/pokemon/base_stats/dragonair.asm
|
||||
yellowish.pokemon.DRAGONAIR = dragonair
|
||||
end
|
||||
if Data.pokemon.DRAGONITE then
|
||||
local dragonite = copy(Data.pokemon.DRAGONITE)
|
||||
dragonite.catchRate = 9 -- pokeyellow data/pokemon/base_stats/dragonite.asm
|
||||
yellowish.pokemon.DRAGONITE = dragonite
|
||||
end
|
||||
check(Data.pokemon.DRAGONAIR ~= nil and Data.pokemon.DRAGONITE ~= nil,
|
||||
"fixture has Dragonair and Dragonite to retune")
|
||||
local yellowPrint = Fingerprint.compute(yellowish, {})
|
||||
eq(yellowPrint, first,
|
||||
"a Yellow-shaped dataset (only Dragonair/Dragonite catch rate differs) " ..
|
||||
"fingerprints identically to Red/Blue")
|
||||
|
||||
-- (3) the handshake actually reads "full" for that pairing, not "subset"
|
||||
local redHello = { protocol = 2, engineVersion = "0.1.0", fingerprint = first }
|
||||
local yellowHello = { protocol = 2, engineVersion = "0.1.0", fingerprint = yellowPrint }
|
||||
local verdict, reason = Handshake.checkCompat(redHello, yellowHello)
|
||||
eq(verdict, "full", "Red vs Yellow-shaped peer clears the handshake as full")
|
||||
check(reason == nil, "a full verdict carries no mismatch reason")
|
||||
|
||||
-- control: a real gameplay-affecting edit (base stats) still splits the
|
||||
-- two builds, so the fingerprint has not gone toothless
|
||||
local buffed = cloneData(Data)
|
||||
local strongPidgey = copy(Data.pokemon.PIDGEY)
|
||||
strongPidgey.baseStats = copy(Data.pokemon.PIDGEY.baseStats)
|
||||
strongPidgey.baseStats.attack = strongPidgey.baseStats.attack + 1
|
||||
buffed.pokemon.PIDGEY = strongPidgey
|
||||
local buffedPrint = Fingerprint.compute(buffed, {})
|
||||
check(buffedPrint ~= first, "a baseStats edit still moves the digest")
|
||||
local buffedHello = { protocol = 2, engineVersion = "0.1.0", fingerprint = buffedPrint }
|
||||
local verdict2 = Handshake.checkCompat(redHello, buffedHello)
|
||||
eq(verdict2, "subset", "a genuinely different dataset still reads subset, not full")
|
||||
|
||||
S.finish()
|
||||
@@ -6,17 +6,25 @@
|
||||
-- is nonzero ("the player sprite has not yet completed the walking
|
||||
-- animation"), jumps straight to .moveAhead -- JoypadOverworld, and with
|
||||
-- it the START check, the A check, and every direction initiation, only
|
||||
-- ever runs while the player stands on a tile. A button pressed mid-step
|
||||
-- is simply never seen.
|
||||
-- ever runs while the player stands on a tile.
|
||||
--
|
||||
-- The port ran handleInput() every frame regardless of player.moving, so a
|
||||
-- mid-step A/START press pushed its TextBox/StartMenu right there and
|
||||
-- froze Red between tiles, mid-animation (#286: running up to Nurse Joy
|
||||
-- and mashing A stops him half off the tile).
|
||||
--
|
||||
-- Second oracle, engine/joypad.asm _Joypad: hJoyPressed is
|
||||
-- (hJoyLast ^ hJoyInput) & hJoyInput, and hJoyLast only advances on an
|
||||
-- explicit `call Joypad`. vblank's per-frame ReadJoypad writes hJoyInput
|
||||
-- alone, and the mid-step path never calls Joypad, so hJoyLast is FROZEN
|
||||
-- for the whole animation. A button pressed mid-step and still held when
|
||||
-- the step lands therefore reads as a fresh press at the next poll; one
|
||||
-- released before the step lands is genuinely lost. The port used to drop
|
||||
-- both, which on the Cycling Road roll made START a coin flip (#525).
|
||||
--
|
||||
-- The invariant: while a step is in progress, A and START change nothing
|
||||
-- (no TextBox, no StartMenu, the step completes); once the player stands
|
||||
-- on the tile again, both work.
|
||||
-- (no TextBox, no StartMenu, the step completes). On the landing frame a
|
||||
-- still-held A or START is acted on, a released one is not.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
@@ -52,6 +60,15 @@ local function step(pressedBtn)
|
||||
ow:update(1 / 60)
|
||||
end
|
||||
|
||||
-- A synthetic pressQueue inject has no source entry, so Input:step sets
|
||||
-- state[btn] = true and nothing ever clears it (src/core/Input.lua) -- the
|
||||
-- harness models a HELD button. Most cases below want a tap, so release it
|
||||
-- explicitly; the held cases are called out where they matter.
|
||||
local function tap(btn)
|
||||
step(btn)
|
||||
Input.state[btn] = false
|
||||
end
|
||||
|
||||
-- start a step south (held direction, like hJoyHeld)
|
||||
Input.state.down = true
|
||||
step()
|
||||
@@ -67,14 +84,14 @@ ow.interact = function(self, ...)
|
||||
return baseInteract(self, ...)
|
||||
end
|
||||
|
||||
-- mid-step A press: nothing may happen (the original never sees it)
|
||||
step("a")
|
||||
-- mid-step A press: nothing may happen (the original acts on nothing here)
|
||||
tap("a")
|
||||
eq(interactCalls, 0, "mid-step A never reaches interact()")
|
||||
check(Game.stack:top() == ow, "mid-step A pushes no TextBox")
|
||||
check(ow.player.moving, "mid-step A does not interrupt the step")
|
||||
|
||||
-- mid-step START press: no start menu either
|
||||
step("start")
|
||||
tap("start")
|
||||
check(Game.stack:top() == ow, "mid-step START opens no menu")
|
||||
check(ow.player.moving, "mid-step START does not interrupt the step")
|
||||
|
||||
@@ -84,7 +101,10 @@ while ow.player.moving and guard < 60 do step(); guard = guard + 1 end
|
||||
eq(ow.player.cellY, startY + 1, "the step completes onto the next tile")
|
||||
|
||||
-- the issue's actual repro ("press A quickly/early" running up to Nurse
|
||||
-- Joy): start another step and press A on its FINAL mid-step frame
|
||||
-- Joy): start another step and press A on its FINAL mid-step frame, then
|
||||
-- RELEASE it before the step lands. hJoyLast is frozen through the
|
||||
-- animation, so the next poll sees the button already up and computes no
|
||||
-- edge (engine/joypad.asm) -- this press really is lost.
|
||||
Input.state.down = true
|
||||
step()
|
||||
Input.state.down = false
|
||||
@@ -93,17 +113,36 @@ guard = 0
|
||||
while ow.player.moving and guard < 60 do
|
||||
guard = guard + 1
|
||||
if guard == (ow.player.stepFramesCur or 16) - 1 then
|
||||
step("a") -- the last frame before landing
|
||||
tap("a") -- the last frame before landing, released immediately
|
||||
else
|
||||
step()
|
||||
end
|
||||
end
|
||||
check(not ow.player.moving, "the second step completes")
|
||||
eq(interactCalls, 0, "a last-frame A press is still swallowed (no buffering)")
|
||||
check(Game.stack:top() == ow, "last-frame A pushes no TextBox")
|
||||
step() -- the landing frame, where a still-held button would be polled
|
||||
eq(interactCalls, 0, "a mid-step A released before landing is still lost")
|
||||
check(Game.stack:top() == ow, "the released last-frame A pushes no TextBox")
|
||||
|
||||
-- ...but a mid-step A that is STILL HELD when the step lands is delivered
|
||||
-- on the landing frame, because hJoyLast never advanced (#525). Nothing
|
||||
-- happens mid-step either way: the poll is deferred, not the action.
|
||||
Input.state.down = true
|
||||
step()
|
||||
Input.state.down = false
|
||||
check(ow.player.moving, "third step starts")
|
||||
step("a") -- pressed mid-step and left held
|
||||
eq(interactCalls, 0, "the held A still does nothing mid-step")
|
||||
check(ow.player.moving, "the held A does not interrupt the step")
|
||||
guard = 0
|
||||
while ow.player.moving and guard < 60 do step(); guard = guard + 1 end
|
||||
eq(interactCalls, 0, "still nothing while the step runs out")
|
||||
step() -- landing frame
|
||||
eq(interactCalls, 1, "a held mid-step A is polled on the landing frame")
|
||||
Input.state.a = false
|
||||
|
||||
-- standing on the tile again, START and A work as always
|
||||
step("start")
|
||||
interactCalls = 0
|
||||
tap("start")
|
||||
check(Game.stack:top() ~= ow, "START opens the start menu on a tile")
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, "PALLET_TOWN", 6, 9, "down")
|
||||
|
||||
@@ -176,7 +176,14 @@ for _, key in ipairs({ "_SilphCo11FSilphPresidentText",
|
||||
end
|
||||
|
||||
-- run the rows through the real interpreter with only the leaf commands
|
||||
-- stubbed, so the branch arithmetic is what is under test
|
||||
-- stubbed, so the branch arithmetic is what is under test. Commands is a
|
||||
-- shared module singleton read by every later suite dofile'd into this
|
||||
-- same process (tests/run_tests.lua), so the three stubs must be restored
|
||||
-- before this file falls off the end -- an unrestored give_item stub
|
||||
-- silently swallows any later test's item grants, matching the save/
|
||||
-- restore parity_gift_atomicity.lua already does for show_text.
|
||||
local origFacePlayer, origShowText, origGiveItem =
|
||||
Commands.face_player, Commands.show_text, Commands.give_item
|
||||
local shown, given
|
||||
Commands.face_player = function() end
|
||||
Commands.show_text = function(_, key) shown[#shown + 1] = key end
|
||||
@@ -214,4 +221,7 @@ do
|
||||
"an unbeaten-Giovanni save still gets the thank-you and the ball")
|
||||
end
|
||||
|
||||
Commands.face_player, Commands.show_text, Commands.give_item =
|
||||
origFacePlayer, origShowText, origGiveItem
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Parity test: all three fishing rods are refused while surfing (#533).
|
||||
--
|
||||
-- pokered's FishingInit (engine/items/item_effects.asm) reads
|
||||
-- wWalkBikeSurfState after IsNextTileShoreOrWater passes, and refuses
|
||||
-- with carry set when it equals 2 (surfing). Every ItemUseXRod entry does
|
||||
-- `jp c, ItemUseNotTime` on that carry, so a surfing player gets the same
|
||||
-- "OAK: %s! This isn't the time to use that!" text used for the
|
||||
-- mid-battle refusal, and the rod is never consumed. The port's ow.player
|
||||
-- surf flag was never checked, so BagMenu's isWaterCell check alone let a
|
||||
-- surfing player fish (the faced cell while surfing is always water).
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_surf_rod_refusal.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local S = require("tests.harness").suite("parity surf rod refusal")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
Data:load()
|
||||
|
||||
local ItemEffects = require("src.inventory.ItemEffects")
|
||||
local save = require("src.core.SaveData").newGame()
|
||||
|
||||
for _, rod in ipairs({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }) do
|
||||
-- surfing: refused even though ow/player exists and the faced cell
|
||||
-- would otherwise be fishable water
|
||||
local surfingOw = { player = { surfing = true } }
|
||||
local result, msgs = ItemEffects.use(Data, save, rod, nil, false, nil, surfingOw)
|
||||
eq(result, "failed", rod .. " is refused while surfing")
|
||||
check(msgs and msgs[1] and msgs[1]:find("isn't the", 1, true) ~= nil,
|
||||
rod .. " surfing refusal uses the OAK 'not the time' text")
|
||||
eq(save.inventory[rod], nil, rod .. " is not consumed while surfing")
|
||||
|
||||
-- not surfing: still allowed to fish
|
||||
local groundOw = { player = { surfing = false } }
|
||||
local r2 = ItemEffects.use(Data, save, rod, nil, false, nil, groundOw)
|
||||
eq(r2, "fish", rod .. " still fishes normally when not surfing")
|
||||
|
||||
-- no overworld handle at all (e.g. called from a context without ow):
|
||||
-- must not error, and must not silently allow fishing that a surfing
|
||||
-- player would be refused
|
||||
local r3 = ItemEffects.use(Data, save, rod, nil, false, nil, nil)
|
||||
eq(r3, "fish", rod .. " with no ow handle falls through to fish (no surf info available)")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,93 @@
|
||||
-- Regression (#536): a save made mid-surf must resume mid-surf.
|
||||
--
|
||||
-- wWalkBikeSurfState (ram/wram.asm) sits inside wMainDataStart..wMainDataEnd,
|
||||
-- the range engine/menus/save.asm block-copies into sMainData on save and
|
||||
-- back out on load, so the original persists the surf state across a save.
|
||||
-- OverworldState:captureSave now writes save.player.surfing and
|
||||
-- OverworldState:setMap's boot path (opts.via == "boot", only taken from
|
||||
-- :enter, which every StateStack push runs through) restores it before
|
||||
-- Music/PikachuFollower see the player. Before the fix, self.player.surfing
|
||||
-- was never serialized at all, so a reload always came back on foot;
|
||||
-- Collision.canMove (src/world/Collision.lua) then picks land tile-pairs for
|
||||
-- a non-surfing mover, which never permit standing on a water cell, so the
|
||||
-- reload could softlock on the water tile the save was made on.
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_surf_save_load_bug536.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.ROUTE_20) then Data:load() end
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local OW = require("src.world.OverworldController")
|
||||
local S = require("tests.harness").suite("parity surf save/load (#536)")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.renderer = Renderer; Renderer:init()
|
||||
Game.stack = StateStack
|
||||
StateStack:init()
|
||||
|
||||
-- ground truth from parity_cinnabar_east_surf.lua: ROUTE_20 (0,8) is water.
|
||||
local r20 = require("src.world.MapLoader").load(Data, "ROUTE_20")
|
||||
check(r20:isWaterCell(0, 8), "ROUTE_20 (0,8) is water")
|
||||
|
||||
-- --- a save made mid-surf on a water cell resumes surfing --------------
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.player.surfing = true
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, "ROUTE_20", 0, 8, "left")
|
||||
local ow = Game.stack:top()
|
||||
eq(ow.player.surfing, true, "boot onto a water cell with surfing=true restores surfing")
|
||||
-- sprite/movement mode: Player:pose() (src/world/Player.lua) only ever
|
||||
-- selects surfSprite when self.surfing is truthy, so this is the same
|
||||
-- switch that put the walking sheet back on a surfing player pre-fix
|
||||
local sprite = ow.player:pose()
|
||||
check(sprite == ow.player.surfSprite,
|
||||
"restored surfing selects the surf sprite sheet, not the walking one")
|
||||
|
||||
-- --- a save made on foot on land resumes on foot ------------------------
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.player.surfing = false
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, "PALLET_TOWN", 5, 6, "down")
|
||||
ow = Game.stack:top()
|
||||
eq(ow.player.surfing, false, "boot onto land with surfing=false stays on foot")
|
||||
|
||||
-- --- saves written before #536 (no surfing field at all) default safely -
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.player.surfing = nil
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, "PALLET_TOWN", 5, 6, "down")
|
||||
ow = Game.stack:top()
|
||||
check(not ow.player.surfing, "a pre-#536 save with no surfing field boots on foot, not truthy-nil")
|
||||
|
||||
-- --- the true round trip: captureSave -> a fresh boot reads it back back ---
|
||||
Game.save = SaveData.newGame()
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, "ROUTE_20", 0, 8, "left")
|
||||
ow = Game.stack:top()
|
||||
ow.player.surfing = true -- player mounted SURF while already standing on water
|
||||
local captured = SaveData.newGame()
|
||||
ow:captureSave(captured)
|
||||
eq(captured.player.map, "ROUTE_20", "captureSave records the map")
|
||||
eq(captured.player.x, 0, "captureSave records x")
|
||||
eq(captured.player.y, 8, "captureSave records y")
|
||||
eq(captured.player.surfing, true, "captureSave records surfing=true")
|
||||
|
||||
-- reload from that captured save: a brand-new boot must come back surfing
|
||||
Game.save = captured
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, captured.player.map, captured.player.x, captured.player.y,
|
||||
captured.player.facing)
|
||||
ow = Game.stack:top()
|
||||
eq(ow.player.surfing, true,
|
||||
"round trip: captureSave -> reboot restores surfing on the same water cell")
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,84 @@
|
||||
-- A trainer script's post-battle text must finish before a level evolution.
|
||||
-- Its start_battle row resumes the script after OverworldState:afterBattle,
|
||||
-- so this covers the same handoff used by trainer and Rocket encounters.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not Data.maps then Data:load() end
|
||||
local S = require("tests.harness").suite("parity trainer evolution order")
|
||||
local check = S.check
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local ScriptRunner = require("src.script.ScriptRunner")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
require("src.render.Font").load(Data)
|
||||
|
||||
Game.data = Data
|
||||
Game.save = SaveData.newGame()
|
||||
Game.stack = StateStack; StateStack:init()
|
||||
Game.renderer = { worldViewSize = function() return 160, 144 end }
|
||||
|
||||
local caterpie = Pokemon.new(Data, "CATERPIE", 7)
|
||||
Game.save.party = { caterpie }
|
||||
|
||||
local fakeBattle
|
||||
local originalBattleState = package.loaded["src.battle.BattleState"]
|
||||
package.loaded["src.battle.BattleState"] = {
|
||||
newTrainer = function()
|
||||
fakeBattle = { leveledUp = { [caterpie] = true } }
|
||||
return fakeBattle
|
||||
end,
|
||||
}
|
||||
|
||||
local overworld = setmetatable({}, { __index = OverworldState })
|
||||
overworld:enter("ROUTE_1", 5, 5, "down")
|
||||
local runner = ScriptRunner.new(Game, overworld)
|
||||
runner:run({
|
||||
{ "start_battle", "trainer", "OPP_YOUNGSTER", 1 },
|
||||
{ "show_text", "THE TRAINER TALKS AFTER THE FIGHT" },
|
||||
{ "show_text", "THE TRAINER HAS MORE TO SAY" },
|
||||
})
|
||||
|
||||
check(fakeBattle ~= nil, "trainer battle starts and yields the script")
|
||||
Game.stack:pop()
|
||||
fakeBattle.onFinish("win")
|
||||
|
||||
package.loaded["src.battle.BattleState"] = originalBattleState
|
||||
|
||||
local function stateHas(state, text)
|
||||
for _, page in ipairs(state.pages or {}) do
|
||||
for _, line in ipairs(page) do
|
||||
if type(line) == "string" and line:find(text, 1, true) then return true end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local afterText = Game.stack:top()
|
||||
check(stateHas(afterText, "THE TRAINER TALKS"),
|
||||
"the trainer's after-battle text is shown first")
|
||||
check(#Game.stack.states == 1,
|
||||
"the evolution screen waits until trainer after-text closes")
|
||||
|
||||
Game.stack:pop()
|
||||
afterText.onDone()
|
||||
|
||||
local moreText = Game.stack:top()
|
||||
check(stateHas(moreText, "MORE TO SAY"),
|
||||
"all trainer after-text finishes before evolution")
|
||||
check(#Game.stack.states == 1,
|
||||
"the evolution still waits for the last trainer text")
|
||||
|
||||
Game.stack:pop()
|
||||
moreText.onDone()
|
||||
|
||||
local evolutionText = Game.stack:top()
|
||||
check(stateHas(evolutionText, "evolving"),
|
||||
"the level evolution starts after trainer after-text closes")
|
||||
|
||||
S.finish()
|
||||
@@ -16,8 +16,10 @@ require("src.render.Font").load(Data)
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Sound = require("src.core.Sound")
|
||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||
local EvolutionState = require("src.ui.EvolutionState")
|
||||
local TitleState = require("src.ui.TitleState")
|
||||
local OakSpeech = require("src.ui.OakSpeech")
|
||||
local TradeAnim = require("src.ui.TradeAnim")
|
||||
|
||||
local savedCry = Sound.playCry
|
||||
Sound.playCry = function() end
|
||||
@@ -33,7 +35,10 @@ end
|
||||
|
||||
local def = Data.pokemon.PIKACHU
|
||||
local savedTrueColor = def.trueColor
|
||||
local raichu = Data.pokemon.RAICHU
|
||||
local savedRaichuTrueColor = raichu.trueColor
|
||||
def.trueColor = true
|
||||
raichu.trueColor = true
|
||||
|
||||
local game = {
|
||||
data = Data,
|
||||
@@ -50,6 +55,39 @@ check(#dexRects == 1 and dexRects[1].x == dx and dexRects[1].y == dy
|
||||
and dexRects[1].h == dex.sprite:getHeight(),
|
||||
"Pokedex reports the true-color sprite rectangle")
|
||||
|
||||
local evolving = EvolutionState.new(game, { species = "PIKACHU" }, "RAICHU")
|
||||
check(evolving.oldSpriteTrueColor == true,
|
||||
"evolution keeps the current Pokemon sprite's trueColor flag")
|
||||
check(evolving.newSpriteTrueColor == true,
|
||||
"evolution keeps the evolved Pokemon sprite's trueColor flag")
|
||||
local evoRects = uiRects(function() evolving:draw() end)
|
||||
local ex = math.floor((160 - evolving.oldSprite:getWidth()) / 2)
|
||||
local ey = math.max(8, 64 - evolving.oldSprite:getHeight())
|
||||
check(#evoRects == 1 and evoRects[1].x == ex and evoRects[1].y == ey
|
||||
and evoRects[1].w == evolving.oldSprite:getWidth()
|
||||
and evoRects[1].h == evolving.oldSprite:getHeight(),
|
||||
"evolution reports the true-color sprite rectangle")
|
||||
|
||||
local trade = TradeAnim.new(game, {
|
||||
sent = { species = "PIKACHU" }, received = { species = "RAICHU" },
|
||||
})
|
||||
check(trade.sentSpriteTrueColor == true,
|
||||
"trade keeps the sent Pokemon sprite's trueColor flag")
|
||||
check(trade.recvSpriteTrueColor == true,
|
||||
"trade keeps the received Pokemon sprite's trueColor flag")
|
||||
local tradeRects = uiRects(function() trade:draw() end)
|
||||
check(#tradeRects == 1 and tradeRects[1].x == 56 and tradeRects[1].y == 16
|
||||
and tradeRects[1].w == trade.sentSprite:getWidth()
|
||||
and tradeRects[1].h == trade.sentSprite:getHeight(),
|
||||
"trade reports the sent true-color sprite rectangle")
|
||||
trade.phase = "show_enemy"
|
||||
local receivedRects = uiRects(function() trade:draw() end)
|
||||
check(#receivedRects == 1 and receivedRects[1].x == 56
|
||||
and receivedRects[1].y == 16
|
||||
and receivedRects[1].w == trade.recvSprite:getWidth()
|
||||
and receivedRects[1].h == trade.recvSprite:getHeight(),
|
||||
"trade reports the received true-color sprite rectangle")
|
||||
|
||||
local titleGame = {
|
||||
data = { pokemon = Data.pokemon,
|
||||
field = { title = { cycleSpecies = { "PIKACHU" } } } },
|
||||
@@ -83,6 +121,7 @@ check(#oakRects == 1 and oakRects[1].x == ox and oakRects[1].y == oy
|
||||
"Oak intro reports the true-color sprite rectangle")
|
||||
|
||||
def.trueColor = savedTrueColor
|
||||
raichu.trueColor = savedRaichuTrueColor
|
||||
Sound.playCry = savedCry
|
||||
PaletteFX.clearTrueColor()
|
||||
S.finish()
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
-- Regression (#503): the Viridian School House blackboard and notebook
|
||||
-- were both dead A presses -- data/events/hidden_events.asm's two
|
||||
-- hidden_text_predef rows for this map never reached data/generated/
|
||||
-- field.lua (tools/extract/field.py only parses `hidden_event` rows), and
|
||||
-- there was no onInteract hook for this map at all. Same shape as the
|
||||
-- Celadon Mansion roof house fix (#391, tests/parity_celadon_roof_
|
||||
-- readables.lua): ViridianSchoolBlackboard (engine/events/hidden_events/
|
||||
-- school_blackboard.asm) prints the intro, then loops a "which heading"
|
||||
-- prompt and a 5-status menu (SLP/PSN/PAR/BRN/FRZ) plus QUIT until B/QUIT
|
||||
-- closes it; ViridianSchoolNotebook (school_notebooks.asm) is a 5-page
|
||||
-- book that asks to turn pages 1-3, auto-turns 4, and the girl catches you
|
||||
-- on page 5.
|
||||
--
|
||||
-- Self-contained; run via
|
||||
-- `luajit tests/parity_viridian_school_blackboard_bug503.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.VIRIDIAN_SCHOOL_HOUSE) then Data:load() end
|
||||
local Font = require("src.render.Font")
|
||||
if not pcall(Font.encode, "A") then Font.load(Data) end
|
||||
require("data.scripts.init")
|
||||
local MapScripts = require("src.script.MapScripts")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local S = require("tests.harness").suite("parity Viridian School blackboard/notebook (#503)")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local MAP = "VIRIDIAN_SCHOOL_HOUSE"
|
||||
|
||||
for _, key in ipairs({
|
||||
"_ViridianSchoolBlackboardText1", "_ViridianSchoolBlackboardText2",
|
||||
"_ViridianBlackboardSleepText", "_ViridianBlackboardPoisonText",
|
||||
"_ViridianBlackboardPrlzText", "_ViridianBlackboardBurnText",
|
||||
"_ViridianBlackboardFrozenText",
|
||||
"_ViridianSchoolNotebookText1", "_ViridianSchoolNotebookText5",
|
||||
"_TurnPageText" }) do
|
||||
check(type(Data.text[key]) == "string" and Data.text[key] ~= "",
|
||||
key .. " is extracted")
|
||||
end
|
||||
|
||||
local hooks = MapScripts.get(MAP)
|
||||
check(hooks and type(hooks.onInteract) == "function",
|
||||
MAP .. " registers onInteract for the two readables")
|
||||
|
||||
local stack = {}
|
||||
local game = {
|
||||
data = Data,
|
||||
save = { player = { name = "RED" } },
|
||||
stack = {
|
||||
push = function(_, state) stack[#stack + 1] = state end,
|
||||
pop = function() return table.remove(stack) end,
|
||||
top = function() return stack[#stack] end,
|
||||
},
|
||||
}
|
||||
local ow = { player = { facing = "up" } }
|
||||
|
||||
local function pages(box)
|
||||
local out = {}
|
||||
for _, page in ipairs(box.pages or {}) do
|
||||
for _, line in ipairs(page) do out[#out + 1] = line end
|
||||
end
|
||||
return table.concat(out, "\n")
|
||||
end
|
||||
|
||||
-- cells that are neither the blackboard (3,0) nor the notebook (3,4) stay silent
|
||||
for _, cell in ipairs({ { 4, 0 }, { 3, 1 }, { 4, 4 }, { 3, 3 }, { 2, 0 } }) do
|
||||
eq(hooks.onInteract(game, ow, cell[1], cell[2]), false,
|
||||
("(%d,%d) is not one of the readables"):format(cell[1], cell[2]))
|
||||
end
|
||||
|
||||
-- === the blackboard: intro -> prompt -> 6-item menu (5 statuses + QUIT) ===
|
||||
stack = {}
|
||||
eq(hooks.onInteract(game, ow, 3, 0), true, "the blackboard at (3,0) is claimed")
|
||||
local intro = stack[#stack]
|
||||
check(getmetatable(intro) == TextBox, "the blackboard opens a TextBox")
|
||||
check(pages(intro):find("STATUS", 1, true) ~= nil,
|
||||
"the intro is _ViridianSchoolBlackboardText1 (describes STATUS changes)")
|
||||
check(type(intro.onDone) == "function", "the intro has a continuation")
|
||||
intro.onDone()
|
||||
|
||||
local prompt = stack[#stack]
|
||||
check(getmetatable(prompt) == TextBox
|
||||
and pages(prompt):find("heading", 1, true) ~= nil,
|
||||
"the intro leads into the which-heading prompt")
|
||||
prompt.onDone()
|
||||
|
||||
local menu = stack[#stack]
|
||||
check(getmetatable(menu) == Menu, "the prompt opens the status menu")
|
||||
eq(#menu.items, 6, "five statuses plus QUIT")
|
||||
local labels = {}
|
||||
for i, item in ipairs(menu.items) do labels[i] = (item.label or ""):gsub("^%s+", "") end
|
||||
eq(table.concat(labels, "/"), "SLP/PSN/PAR/BRN/FRZ/QUIT",
|
||||
"the statuses read SLP/PSN/PAR/BRN/FRZ, QUIT last")
|
||||
eq(menu.items[6].onSelect, nil, "QUIT has no onSelect; Menu's own B/pop closes it")
|
||||
|
||||
local STATUS_KEYS = {
|
||||
"_ViridianBlackboardSleepText", "_ViridianBlackboardPoisonText",
|
||||
"_ViridianBlackboardPrlzText", "_ViridianBlackboardBurnText",
|
||||
"_ViridianBlackboardFrozenText",
|
||||
}
|
||||
for i, key in ipairs(STATUS_KEYS) do
|
||||
stack = {}
|
||||
menu.items[i].onSelect()
|
||||
local blurb = stack[#stack]
|
||||
check(getmetatable(blurb) == TextBox, labels[i] .. " prints a text box")
|
||||
local want = Data.text[key]:match("^[^\n\011\012]+")
|
||||
check(pages(blurb):find(want, 1, true) ~= nil,
|
||||
labels[i] .. " prints " .. key)
|
||||
check(type(blurb.onDone) == "function",
|
||||
labels[i] .. " returns to the prompt instead of dropping out (loops)")
|
||||
blurb.onDone()
|
||||
check(getmetatable(stack[#stack]) == TextBox,
|
||||
"the prompt comes back after " .. labels[i])
|
||||
stack[#stack].onDone()
|
||||
check(getmetatable(stack[#stack]) == Menu,
|
||||
"the menu comes back after " .. labels[i] .. " (loop, not exit)")
|
||||
end
|
||||
|
||||
-- === the notebook: five pages, three yes/no turns, then the girl catches you ==
|
||||
stack = {}
|
||||
eq(hooks.onInteract(game, ow, 3, 4), true, "the notebook at (3,4) is claimed")
|
||||
local page1 = stack[#stack]
|
||||
check(getmetatable(page1) == TextBox, "the notebook opens on page 1")
|
||||
check(pages(page1):find("POKé BALL", 1, true) ~= nil,
|
||||
"page 1 is _ViridianSchoolNotebookText1")
|
||||
|
||||
-- pages 1-3 ask "Turn the page?" and only turn on yes. The real ChoiceBox
|
||||
-- pops the asking TextBox itself before invoking .choice (TextBox.lua:228-
|
||||
-- 235); mirror that pop-then-call order here.
|
||||
local function resolveChoice(box, yes)
|
||||
table.remove(stack) -- the ChoiceBox pops the asking TextBox first
|
||||
box.choice(yes)
|
||||
end
|
||||
|
||||
page1.onDone()
|
||||
local ask1 = stack[#stack]
|
||||
check(getmetatable(ask1) == TextBox and ask1.choice ~= nil,
|
||||
"page 1 asks _TurnPageText with a yes/no choice")
|
||||
resolveChoice(ask1, true)
|
||||
local page2 = stack[#stack]
|
||||
check(pages(page2):find("weaken", 1, true) ~= nil, "yes turns to page 2")
|
||||
|
||||
page2.onDone()
|
||||
resolveChoice(stack[#stack], true)
|
||||
local page3 = stack[#stack]
|
||||
check(pages(page3):find("engage", 1, true) ~= nil, "page 3 follows page 2")
|
||||
|
||||
page3.onDone()
|
||||
resolveChoice(stack[#stack], true)
|
||||
local page4 = stack[#stack]
|
||||
check(pages(page4):find("ELITE FOUR", 1, true) ~= nil, "page 4 follows page 3")
|
||||
|
||||
-- page 4 turns automatically into page 5, no prompt
|
||||
page4.onDone()
|
||||
local page5 = stack[#stack]
|
||||
check(getmetatable(page5) == TextBox, "page 4 auto-turns")
|
||||
check(pages(page5):find("GIRL", 1, true) ~= nil,
|
||||
"page 5 is the girl catching you reading her notes")
|
||||
|
||||
-- saying no on page 1 stops the read right there: no further page pushed
|
||||
stack = {}
|
||||
hooks.onInteract(game, ow, 3, 4)
|
||||
stack[#stack].onDone()
|
||||
local askAgain = stack[#stack]
|
||||
local before = #stack
|
||||
resolveChoice(askAgain, false)
|
||||
eq(#stack, before - 1,
|
||||
"saying no to the first turn pops the ask box and pushes nothing else")
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,97 @@
|
||||
-- Regression (#535): after handing over the GOLD TEETH and receiving
|
||||
-- HM04, every later talk to the Warden must still say something.
|
||||
--
|
||||
-- data/scripts/story.lua's TEXT_WARDENSHOUSE_WARDEN pointed the
|
||||
-- EVENT_GOT_HM04 branch (row 3, jump_if_true) at the same silent-end jump
|
||||
-- the give-then-thank fallthrough uses (row 13), so ScriptRunner's pc ran
|
||||
-- straight past the end of the row list with zero show_text calls -- the
|
||||
-- Warden went mute on every visit after the trade. pokered's .got_item
|
||||
-- branch (scripts/WardensHouse.asm) instead prints .HM04ExplanationText
|
||||
-- (text/WardensHouse.asm: "HM04 teaches STRENGTH ... SECRET HOUSE in
|
||||
-- SAFARI ZONE") on every subsequent talk.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||
|
||||
local S = require("tests.harness").suite("parity wardens house (#535)")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Commands = require("src.script.Commands")
|
||||
local Flags = require("src.script.Flags")
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local ScriptRunner = require("src.script.ScriptRunner")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.stack = StateStack; StateStack:init()
|
||||
require("src.render.Font").load(Data)
|
||||
|
||||
local story = require("data.scripts.story")
|
||||
local script = story.WARDENS_HOUSE.talk.TEXT_WARDENSHOUSE_WARDEN
|
||||
|
||||
-- instrument show_text the way parity_gift_atomicity.lua does, to record
|
||||
-- exactly which text ids actually printed
|
||||
local shown = {}
|
||||
local origShow = Commands.show_text
|
||||
Commands.show_text = function(ctx, textId, subs)
|
||||
shown[#shown + 1] = textId
|
||||
return origShow(ctx, textId, subs)
|
||||
end
|
||||
|
||||
local function runScript()
|
||||
shown = {}
|
||||
StateStack:init()
|
||||
local ow = { map = { id = "WARDENS_HOUSE", def = { label = "WardensHouse" } },
|
||||
npcs = {}, entities = {} }
|
||||
local r = ScriptRunner.new(Game, ow)
|
||||
r:run(script, { npc = { def = {}, facePlayer = function() end },
|
||||
overworld = ow })
|
||||
local guard = 0
|
||||
while r:isRunning() and guard < 3000 do
|
||||
guard = guard + 1
|
||||
Input.pressed = { a = true }
|
||||
StateStack:update(1 / 60)
|
||||
r:update()
|
||||
end
|
||||
Input.pressed = {}
|
||||
return not r:isRunning()
|
||||
end
|
||||
|
||||
-- === 1) first talk, holding the GOLD TEETH: gives HM04, sets the flag ===
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.inventory.GOLD_TEETH = 1
|
||||
check(runScript(), "give-the-teeth talk completes")
|
||||
eq(table.concat(shown, ","),
|
||||
"_WardensHouseWardenGaveTheGoldTeethText,_WardensHouseWardenThanksText,"
|
||||
.. "_WardensHouseWardenReceivedHM04Text",
|
||||
"handing over the teeth shows the give/thanks/received sequence, nothing after")
|
||||
check(Flags.get(Game.save, "EVENT_GOT_HM04"), "EVENT_GOT_HM04 is set")
|
||||
check(Flags.get(Game.save, "EVENT_GAVE_GOLD_TEETH"), "EVENT_GAVE_GOLD_TEETH is set")
|
||||
check(Game.save.inventory.HM_STRENGTH ~= nil, "HM04 (Strength) lands in the bag")
|
||||
check(not Game.save.inventory.GOLD_TEETH, "the GOLD TEETH is taken")
|
||||
|
||||
-- === 2) the regression itself: every later talk, once EVENT_GOT_HM04 is
|
||||
-- set, must print the explanation text instead of nothing ===
|
||||
check(runScript(), "post-gift talk completes")
|
||||
eq(table.concat(shown, ","), "_WardensHouseWardenHM04ExplanationText",
|
||||
"every subsequent talk now prints the HM04/Safari Zone explanation (#535)")
|
||||
|
||||
-- run it again to confirm this is not a one-shot: it repeats every visit
|
||||
check(runScript(), "a third talk completes")
|
||||
eq(table.concat(shown, ","), "_WardensHouseWardenHM04ExplanationText",
|
||||
"the explanation text repeats on every later talk, not just the first")
|
||||
|
||||
-- === 3) unrelated path is unchanged: no GOLD TEETH, no flag yet ===
|
||||
Game.save = SaveData.newGame()
|
||||
check(runScript(), "empty-handed talk completes")
|
||||
eq(table.concat(shown, ","), "_WardensHouseWardenGibberish1Text",
|
||||
"without the GOLD TEETH the Warden's gibberish line is unchanged")
|
||||
check(not Flags.get(Game.save, "EVENT_GOT_HM04"), "no HM04 yet")
|
||||
|
||||
Commands.show_text = origShow
|
||||
|
||||
S.finish()
|
||||
@@ -528,5 +528,105 @@ do
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
-- #515: badge read/write agreement between SaveData/the in-game grant
|
||||
-- and the editor. checkVictoryRewards (src/world/OverworldController.lua)
|
||||
-- writes save.inventory[badge] = 1, a truthy number, not the boolean
|
||||
-- `true` the editor used to compare against with `== true`. This drives
|
||||
-- the same field through a real save encode/decode round trip and checks
|
||||
-- src/inventory/Badges.count (what the in-game badge case/count reads)
|
||||
-- agrees with what the editor's own badge state shows.
|
||||
local Badges = require("src.inventory.Badges")
|
||||
|
||||
local save = SaveData.newGame()
|
||||
local id = Badges.list(Data)[1].id
|
||||
-- simulate the in-game grant's exact representation, not the editor's
|
||||
save.inventory[id] = 1
|
||||
eq(Badges.count(Data, save), 1, "Badges.count sees a numeric 1 grant as earned")
|
||||
|
||||
local encoded = SaveData.encode(save)
|
||||
local back = SaveData.decode(encoded)
|
||||
eq(back.inventory[id], 1, "save round trip preserves the numeric badge flag")
|
||||
eq(Badges.count(Data, back), 1, "Badges.count still agrees after the round trip")
|
||||
|
||||
local S = State.new()
|
||||
S.data = Data
|
||||
S.cat = Catalog.build(Data)
|
||||
S.save = back
|
||||
check(Ops.badgeIds(S)[1] ~= nil, "the editor's badge catalog is non-empty")
|
||||
check(S.save.inventory[id] and true or false,
|
||||
"the editor's own truthy read (panel badge chip state) sees the grant as earned")
|
||||
|
||||
-- toggling off then on again round-trips through the editor's own write
|
||||
-- shape and still agrees with Badges.count
|
||||
Ops.toggleBadge(S, id)
|
||||
eq(S.save.inventory[id], nil, "toggleBadge clears the badge (nil, not false)")
|
||||
eq(Badges.count(Data, S.save), 0, "Badges.count agrees once cleared")
|
||||
Ops.toggleBadge(S, id)
|
||||
eq(S.save.inventory[id], 1, "toggleBadge re-earns the badge as a truthy 1, matching the in-game grant")
|
||||
eq(Badges.count(Data, S.save), 1, "Badges.count agrees once re-earned")
|
||||
end
|
||||
|
||||
do
|
||||
-- #529: focusing a text field raises the OS soft keyboard on Android/iOS
|
||||
-- (love.keyboard.setTextInput(true, x, y, w, h)) and blurring lowers it;
|
||||
-- desktop raises the same way but never lowers, since setTextInput is
|
||||
-- global SDL state and the launcher's own text fields (RomImporter slot
|
||||
-- rename, ROM finder) depend on it staying enabled.
|
||||
--
|
||||
-- This is the closest honest check this checkout can run: the real soft
|
||||
-- keyboard is OS chrome outside the LOVE frame, unreachable by any
|
||||
-- driver. A human still has to verify on an Android build that the
|
||||
-- keyboard visibly rises over the Items search bar, typed characters
|
||||
-- filter the list, and Enter/Escape/switching tabs lowers it again.
|
||||
local Kit = require("Kit")
|
||||
local calls = {}
|
||||
local savedKeyboard, savedSystem = love.keyboard, love.system
|
||||
|
||||
local function stubOS(name)
|
||||
love.system = { getOS = function() return name end }
|
||||
love.keyboard = {
|
||||
isDown = function() return false end,
|
||||
setTextInput = function(...) calls[#calls + 1] = { ... } end,
|
||||
}
|
||||
end
|
||||
|
||||
-- Android: focusing raises with the field's rect, restaying focused on
|
||||
-- the same id does not re-raise, and blur lowers it.
|
||||
stubOS("Android")
|
||||
Kit.focus = nil
|
||||
Kit.beginFrame(15, 15, true)
|
||||
Kit.textfield("kb-test", 10, 10, 100, 20, "", "type here")
|
||||
eq(#calls, 1, "Android: focusing a field raises the soft keyboard")
|
||||
check(calls[1][1] == true, "Android: raise call passes enable=true")
|
||||
eq(calls[1][2], 10, "Android: raise call passes the field's x")
|
||||
eq(calls[1][3], 10, "Android: raise call passes the field's y")
|
||||
eq(calls[1][4], 100, "Android: raise call passes the field's w")
|
||||
eq(calls[1][5], 20, "Android: raise call passes the field's h")
|
||||
|
||||
Kit.beginFrame(15, 15, false)
|
||||
Kit.textfield("kb-test", 10, 10, 100, 20, "abc", "type here")
|
||||
eq(#calls, 1, "Android: staying focused on the same field does not re-raise")
|
||||
|
||||
Kit.blur()
|
||||
eq(#calls, 2, "Android: blur lowers the soft keyboard")
|
||||
eq(calls[2][1], false, "Android: lower call passes enable=false")
|
||||
check(Kit.focus == nil, "blur clears Kit.focus")
|
||||
|
||||
-- Desktop: focusing still raises (harmless there), but blur must not
|
||||
-- disable text input globally -- the launcher's own fields rely on it
|
||||
-- staying on.
|
||||
calls = {}
|
||||
stubOS("Mac OS X")
|
||||
Kit.beginFrame(65, 65, true)
|
||||
Kit.textfield("kb-test2", 60, 60, 80, 24, "", "")
|
||||
eq(#calls, 1, "desktop: focusing a field still raises setTextInput")
|
||||
Kit.blur()
|
||||
eq(#calls, 1, "desktop: blur does not call setTextInput(false)")
|
||||
|
||||
love.keyboard, love.system = savedKeyboard, savedSystem
|
||||
Kit.focus = nil
|
||||
end
|
||||
|
||||
print(string.format("save editor tests: %d passed, %d failed", passed, failed))
|
||||
if failed > 0 then os.exit(1) end
|
||||
|
||||
@@ -1674,6 +1674,16 @@ do
|
||||
local optBack = SD.loadOptions()
|
||||
eq(optBack.zoom, -2, "options.lua round-trips zoom")
|
||||
eq(optBack.voidFill, "water", "options.lua round-trips voidFill")
|
||||
-- touchControls (#327): enabled flag + normalized positions
|
||||
rep.options.touchControls = {
|
||||
enabled = false,
|
||||
positions = { dpad = { x = 0.2, y = 0.8 }, a = { x = 0.9, y = 0.7 } },
|
||||
}
|
||||
SD.saveOptions(rep.options)
|
||||
optBack = SD.loadOptions()
|
||||
eq(optBack.touchControls.enabled, false, "options.lua round-trips touchControls.enabled")
|
||||
eq(optBack.touchControls.positions.dpad.x, 0.2,
|
||||
"options.lua round-trips touchControls.positions")
|
||||
local origOpts, loadedOpts = rep.options, back.options
|
||||
rep.options, back.options = nil, nil
|
||||
local same, where = deepEq(rep, back, "save")
|
||||
@@ -1683,6 +1693,68 @@ do
|
||||
SD.saveOptions(SD.defaultOptions())
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- touch controls layout (#327)
|
||||
do
|
||||
local TC = require("src.core.TouchControls")
|
||||
local cfg = TC.normalizeConfig(nil)
|
||||
eq(cfg.enabled, true, "touchControls default enabled")
|
||||
check(cfg.positions == nil, "touchControls default positions nil")
|
||||
|
||||
cfg = TC.normalizeConfig({
|
||||
enabled = false,
|
||||
positions = {
|
||||
dpad = { x = 1.5, y = -0.2 }, -- clamped
|
||||
a = { x = 0.5, y = 0.5 },
|
||||
junk = { x = 0, y = 0 },
|
||||
b = { x = "nope", y = 0.1 },
|
||||
},
|
||||
})
|
||||
eq(cfg.enabled, false, "normalizeConfig keeps enabled=false")
|
||||
eq(cfg.positions.dpad.x, 1, "normalizeConfig clamps x high")
|
||||
eq(cfg.positions.dpad.y, 0, "normalizeConfig clamps y low")
|
||||
eq(cfg.positions.a.x, 0.5, "normalizeConfig keeps a")
|
||||
check(cfg.positions.junk == nil, "normalizeConfig drops unknown controls")
|
||||
check(cfg.positions.b == nil, "normalizeConfig drops non-numeric")
|
||||
|
||||
local L = TC.defaultLayout(400, 800)
|
||||
check(L.dpad.cx < 200, "default d-pad on left half")
|
||||
check(L.a.cx > 200, "default A on right half")
|
||||
check(L.dpad.cy > 400, "default d-pad in bottom half")
|
||||
|
||||
-- applyOptions + visible gate (no real images needed for the gate)
|
||||
TC.enabled = true
|
||||
TC.active = true
|
||||
TC.img = {} -- pretend art loaded
|
||||
TC.controllerHidden = false
|
||||
TC.preview = false
|
||||
TC:applyOptions({ touchControls = { enabled = false } })
|
||||
eq(TC.enabled, false, "applyOptions disables overlay")
|
||||
check(not TC:visible(), "disabled overlay is not visible")
|
||||
TC:applyOptions({
|
||||
touchControls = {
|
||||
enabled = true,
|
||||
positions = { dpad = { x = 0.25, y = 0.75 } },
|
||||
},
|
||||
})
|
||||
eq(TC.enabled, true, "applyOptions re-enables overlay")
|
||||
eq(TC.positions.dpad.x, 0.25, "applyOptions stores positions")
|
||||
check(TC:visible(), "enabled overlay is visible when active+imaged")
|
||||
|
||||
-- custom position applied through layout()
|
||||
local g = love.graphics
|
||||
local oldDim, oldFont = g.getDimensions, g.newFont
|
||||
g.getDimensions = function() return 400, 800 end
|
||||
g.newFont = function() return { getWidth = function() return 10 end,
|
||||
getHeight = function() return 10 end } end
|
||||
TC.layoutW, TC.layoutH, TC.L = nil, nil, nil
|
||||
local lay = TC:layout()
|
||||
eq(lay.dpad.cx, 100, "custom dpad cx = nx * ww")
|
||||
eq(lay.dpad.cy, 600, "custom dpad cy = ny * wh")
|
||||
TC:clearPositions()
|
||||
check(TC.positions == nil, "clearPositions wipes overrides")
|
||||
g.getDimensions, g.newFont = oldDim, oldFont
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- crit thresholds (CriticalHitTest)
|
||||
-- The threshold byte b from engine/battle/core.asm's shift chain:
|
||||
-- srl (speed/2), then sla (cap 255) without Focus Energy or srl with the
|
||||
@@ -3274,11 +3346,14 @@ runSuites(orderedGlob("tests/parity_*.lua", {
|
||||
"tests/parity_C.lua", "tests/parity_K.lua", "tests/parity_L.lua",
|
||||
"tests/parity_H.lua", "tests/parity_G.lua", "tests/parity_I_M.lua",
|
||||
"tests/parity_B.lua", "tests/parity_J.lua", "tests/parity_A.lua",
|
||||
"tests/parity_battle_menu_cursor.lua",
|
||||
"tests/parity_cerulean_badge_house.lua",
|
||||
"tests/parity_flavor.lua", "tests/parity_trainer_sight.lua",
|
||||
"tests/parity_static.lua", "tests/parity_trashcans.lua",
|
||||
"tests/parity_hof.lua", "tests/parity_trade_gift.lua",
|
||||
"tests/parity_yellow_trades.lua",
|
||||
"tests/parity_yellow_bills_pikachu.lua",
|
||||
"tests/parity_trainer_evolution_order.lua",
|
||||
"tests/parity_intro.lua", "tests/parity_tilt.lua",
|
||||
"tests/parity_gbcfx.lua",
|
||||
}))
|
||||
|
||||
@@ -230,13 +230,14 @@ do
|
||||
end
|
||||
|
||||
do
|
||||
-- badges are boolean flags on inventory, toggled not stacked
|
||||
-- badges are truthy flags on inventory, toggled not stacked; the engine
|
||||
-- writes them as 1 (#515), so the editor must too
|
||||
local S = newState()
|
||||
local ids = Ops.badgeIds(S)
|
||||
check(#ids > 0, "the catalog exposes badge ids")
|
||||
local id = ids[1]
|
||||
Ops.toggleBadge(S, id)
|
||||
eq(S.save.inventory[id], true, "toggleBadge earns the badge")
|
||||
eq(S.save.inventory[id], 1, "toggleBadge earns the badge")
|
||||
Ops.toggleBadge(S, id)
|
||||
eq(S.save.inventory[id], nil, "toggleBadge removes the badge (nil, not false)")
|
||||
end
|
||||
|
||||
+100
-1
@@ -21,6 +21,16 @@ Output: data/palettes_gbc.lua (committed; not wiped by ROM import)
|
||||
same shape, plus per-species palette entries (BULBASAUR, …), plus a
|
||||
`world` table (below) for true overworld tile/roof/sprite coloring.
|
||||
|
||||
Sources (pokeyellow / COLORS OG YELLOW):
|
||||
data/sgb/sgb_palettes.asm SuperPalettes + CGBBasePalettes (Yellow is
|
||||
CGB-enhanced; CGBBase is the authentic GBC look)
|
||||
data/pokemon/palettes.asm MonsterPalettes (same shape as Red)
|
||||
|
||||
Output: data/palettes_yellow.lua (committed; not wiped by ROM import)
|
||||
palettes[NAME] = SuperPalettes RGB
|
||||
cgbBase[NAME] = CGBBasePalettes RGB
|
||||
pokemon[SPECIES] = NAME
|
||||
|
||||
The HP bar fill is GB color 2 of PAL_GREENBAR / PAL_YELLOWBAR /
|
||||
PAL_REDBAR; the thresholds live in home/palettes.asm GetHealthBarColor
|
||||
(>= 27 pixels green, >= 10 yellow, else red).
|
||||
@@ -536,6 +546,89 @@ def extract_gbc(pokered_gbc, out_path):
|
||||
return palettes, mon_pals
|
||||
|
||||
|
||||
def _parse_pokeyellow_palette_tables(path):
|
||||
"""Parse SuperPalettes and CGBBasePalettes from pokeyellow sgb_palettes.asm."""
|
||||
tables = {"SuperPalettes": {}, "CGBBasePalettes": {}}
|
||||
orders = {"SuperPalettes": [], "CGBBasePalettes": []}
|
||||
current = None
|
||||
for lineno, line in _read_raw(path):
|
||||
s = line.strip()
|
||||
if s.startswith("SuperPalettes:"):
|
||||
current = "SuperPalettes"
|
||||
continue
|
||||
if s.startswith("CGBBasePalettes:"):
|
||||
current = "CGBBasePalettes"
|
||||
continue
|
||||
if s.startswith("assert_table_length") or s.endswith(":") and " " not in s:
|
||||
if s.endswith(":") and not s.startswith(("Super", "CGB")):
|
||||
current = None
|
||||
continue
|
||||
if current is None:
|
||||
continue
|
||||
m = re.match(r"RGB\s+([\d,\s]+);\s*PAL_(\w+)", s)
|
||||
if not m:
|
||||
continue
|
||||
nums = [n for n in re.split(r"[,\s]+", m.group(1).strip()) if n]
|
||||
if len(nums) != 12:
|
||||
util.die(f"{path}:{lineno}: expected 12 components, got {len(nums)}")
|
||||
name = m.group(2)
|
||||
tables[current][name] = _rgb8(nums)
|
||||
orders[current].append(name)
|
||||
return tables, orders
|
||||
|
||||
|
||||
def extract_yellow(pokeyellow, out_path):
|
||||
"""Extract Yellow SuperPalettes + CGBBasePalettes for COLORS=OG YELLOW."""
|
||||
pal_path = os.path.join(pokeyellow, "data/sgb/sgb_palettes.asm")
|
||||
mon_path = os.path.join(pokeyellow, "data/pokemon/palettes.asm")
|
||||
tables, orders = _parse_pokeyellow_palette_tables(pal_path)
|
||||
palettes = tables["SuperPalettes"]
|
||||
cgb_base = tables["CGBBasePalettes"]
|
||||
order = orders["SuperPalettes"]
|
||||
if not palettes:
|
||||
util.die(f"{pal_path}: SuperPalettes empty")
|
||||
if not cgb_base:
|
||||
util.die(f"{pal_path}: CGBBasePalettes empty")
|
||||
if orders["CGBBasePalettes"] != order:
|
||||
util.die(f"{pal_path}: CGBBasePalettes order differs from SuperPalettes")
|
||||
|
||||
mon_pals = {}
|
||||
in_table = False
|
||||
for lineno, line in _read_raw(mon_path):
|
||||
s = line.strip()
|
||||
if s.startswith("MonsterPalettes:"):
|
||||
in_table = True
|
||||
continue
|
||||
if not in_table:
|
||||
continue
|
||||
m = re.match(r"db\s+PAL_(\w+)\s*;\s*(\w+)", s)
|
||||
if not m:
|
||||
continue
|
||||
pal, species = m.group(1), m.group(2)
|
||||
if pal not in palettes:
|
||||
util.die(f"{mon_path}:{lineno}: unknown palette PAL_{pal}")
|
||||
if species != "MISSINGNO":
|
||||
mon_pals[species] = pal
|
||||
|
||||
if len(mon_pals) != 151:
|
||||
util.die(f"MonsterPalettes parsed {len(mon_pals)} species (want 151)")
|
||||
for name in ("MEWMON", "GREENBAR", "YELLOWBAR", "REDBAR", "ROUTE"):
|
||||
if name not in cgb_base:
|
||||
util.die(f"{pal_path}: CGBBase PAL_{name} missing")
|
||||
|
||||
util.write_lua(
|
||||
out_path,
|
||||
{"source": "pokeyellow data/sgb/sgb_palettes.asm + data/pokemon/palettes.asm",
|
||||
"palettes": palettes,
|
||||
"cgbBase": cgb_base,
|
||||
"order": order,
|
||||
"pokemon": mon_pals},
|
||||
header="Pokemon Yellow palettes (COLORS=OG YELLOW): SuperPalettes +\n"
|
||||
"CGBBasePalettes (authentic GBC look) as 4 8-bit RGB colors\n"
|
||||
"per palette (color 0 first), plus MonsterPalettes.")
|
||||
return palettes, cgb_base, mon_pals
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
@@ -548,11 +641,17 @@ def main(argv=None):
|
||||
p_gbc.add_argument("--pokered-gbc", required=True)
|
||||
p_gbc.add_argument("--out", default="data/palettes_gbc.lua")
|
||||
|
||||
p_yel = sub.add_parser("yellow", help="extract pokeyellow CGBBasePalettes")
|
||||
p_yel.add_argument("--pokeyellow", required=True)
|
||||
p_yel.add_argument("--out", default="data/palettes_yellow.lua")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
if args.cmd == "vanilla":
|
||||
extract(args.pokered, args.out_dir)
|
||||
else:
|
||||
elif args.cmd == "gbc":
|
||||
extract_gbc(args.pokered_gbc, args.out)
|
||||
else:
|
||||
extract_yellow(args.pokeyellow, args.out)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -235,6 +235,37 @@ def _rebuild_yellow_sourced(yellow, pokeyellow):
|
||||
}
|
||||
|
||||
|
||||
def _remap_audio(yellow, yellow_symbols):
|
||||
"""Re-anchor the Red-copied audio section on pokeyellow.sym (#522).
|
||||
|
||||
Yellow's music bank $1f shifts after 1f:4294: Music_YellowIntro is a
|
||||
3-channel header (pokeyellow audio/headers/musicheaders3.asm) where
|
||||
Red's Music_IntroBattle had 4, so Red's addresses for every later
|
||||
header land on a channel-2 row (one voice, and no tempo command --
|
||||
Viridian Forest slow and hollow). Yellow also has a single
|
||||
wave-sample table at Audio1_WavePointers (audio.asm "Music 1"), not
|
||||
Red's per-engine 0x4373 copies, and CryData moved to 0e:5462. Names
|
||||
absent from the sym (Music_IntroBattle, which Yellow only knows as
|
||||
Music_YellowIntro) keep their positional mapping.
|
||||
"""
|
||||
for name, header in yellow["audio"]["musicHeaders"].items():
|
||||
symbol = yellow_symbols.by_name.get(name)
|
||||
if symbol is not None:
|
||||
header["bank"] = symbol.bank
|
||||
header["address"] = symbol.address
|
||||
wave = yellow_symbols.by_name.get("Audio1_WavePointers.wave0")
|
||||
if wave is None:
|
||||
raise SystemExit("pokeyellow.sym missing Audio1_WavePointers.wave0")
|
||||
for engine in yellow["audio"]["waveBanks"]:
|
||||
yellow["audio"]["waveBanks"][engine] = {
|
||||
"bank": wave.bank, "address": wave.address,
|
||||
}
|
||||
cry = yellow_symbols.by_name.get("CryData")
|
||||
if cry is None:
|
||||
raise SystemExit("pokeyellow.sym missing CryData")
|
||||
yellow["audio"]["cryData"] = {"bank": cry.bank, "address": cry.address}
|
||||
|
||||
|
||||
def derive(red, pokeyellow, symbols_path):
|
||||
"""Return the Yellow manifest derived from the Red manifest dict."""
|
||||
yellow = copy.deepcopy(red)
|
||||
@@ -261,6 +292,7 @@ def derive(red, pokeyellow, symbols_path):
|
||||
yellow = _drop_strings(yellow, dropped)
|
||||
|
||||
rebuilt = _rebuild_yellow_sourced(yellow, pokeyellow)
|
||||
_remap_audio(yellow, yellow_symbols) # #522
|
||||
|
||||
for label in YELLOW_EXTRA_TEXT_LABELS:
|
||||
if label not in yellow["text"]["labels"]:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user