Compare commits

..

36 Commits

Author SHA1 Message Date
bryanthaboi b17dce232e Merge pull request #547 from bryanthaboi/dev
bug squashing and what not
2026-07-31 20:21:37 -04:00
bryanthaboi e1b20723fd Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-07-31 20:20:38 -04:00
bryanthaboi fdab15a6dc CLOSES #503, CLOSES #511, CLOSES #515, CLOSES #518, CLOSES #522, CLOSES #523, CLOSES #525, CLOSES #528, CLOSES #529, CLOSES #533, CLOSES #535, CLOSES #536 2026-07-31 20:20:37 -04:00
bryanthaboi 4916fc8f4b Merge pull request #532 from TitaniteScale/symlink-mod-support
Recognize symlinked mod dirs on Linux (mods/ dev workflow)
2026-07-31 15:22:48 -04:00
Jake Eaker fc19e58678 added symlink support for mods 2026-07-31 13:47:53 -04:00
bryanthaboi 9610bcfb72 Merge pull request #527 from bryanthaboi/dev
bug fixes and new features
2026-07-31 12:04:18 -04:00
bryanthaboi 1bc252741a rom finder 2026-07-31 11:58:21 -04:00
bryanthaboi 97e3fb296c Update tool_mod_hooks.lua 2026-07-31 10:45:12 -04:00
bryanthaboi 5ec440792e Merge pull request #347 from kevindjacobson/agent/tool-mod-hooks
Add lifecycle and HUD hooks for tool mods
2026-07-31 10:29:04 -04:00
bryanthaboi 9d3ee6a597 mod manager now supports auto updates 2026-07-31 10:19:51 -04:00
bryanthaboi 6c4c9d14e2 Merge pull request #520 from johnjohto/fix-true-color-evolution-trade-494
Fix true-color evolution and trade sprites
2026-07-31 10:18:44 -04:00
bryanthaboi d165189032 Merge pull request #521 from johnjohto/fix-fighting-dojo-master-gate-495
Fix Karate Master gate
2026-07-31 10:08:02 -04:00
johnjohto 84bf25fcd5 Fix Karate Master gate 2026-07-31 09:47:53 -04:00
bryanthaboi 3945b9d078 on screen controller editing CLOSES #327 2026-07-31 09:23:38 -04:00
bryanthaboi 6796804593 CLOSES #505, CLOSES #510, CLOSES #513 2026-07-31 09:08:56 -04:00
johnjohto 858c5bc9b8 Fix true-color evolution and trade sprites 2026-07-31 09:05:19 -04:00
bryanthaboi 71bdbb7e48 CLOSES #519 2026-07-31 08:27:29 -04:00
bryanthaboi c346c78697 Merge pull request #499 from johnjohto/fix-battle-menu-cursor
Fix battle menu cursor edges
2026-07-31 08:14:55 -04:00
bryanthaboi ba2bac5bed Merge branch 'dev' into fix-battle-menu-cursor 2026-07-31 08:14:08 -04:00
bryanthaboi bcfc94d448 Merge pull request #366 from pmarcus93/android-respect-rotation-lock
Respect the device rotation lock on Android
2026-07-31 08:08:19 -04:00
bryanthaboi bebfc87cd4 yellow palette / pikachu yellow 2026-07-31 08:07:23 -04:00
bryanthaboi e58929428c Merge pull request #498 from johnjohto/fix-cerulean-badge-house-exit 2026-07-30 18:24:26 -04:00
johnjohto a389416b7b Fix battle menu cursor edges
Battle commands toggled directly between rows and columns. An outward press therefore moved the cursor to the opposite command instead of leaving it at the edge.
2026-07-30 17:53:53 -04:00
johnjohto 0a8bca1a6d Fix Cerulean badge menu exit
The selected badge list stayed on the stack while its description opened. Cancelling the next list only closed that newer menu, leaving the NPC conversation open.
2026-07-30 17:49:02 -04:00
bryanthaboi efad8b325b Merge pull request #490 from bryanthaboi/dev
ios and some bugs
2026-07-30 16:12:24 -04:00
bryanthaboi 4c5f776128 Update README.md 2026-07-30 16:04:34 -04:00
bryanthaboi c1495e0d5e ios 2026-07-30 16:03:35 -04:00
bryanthaboi a97168a4f0 Update ios-install.md 2026-07-30 15:53:49 -04:00
bryanthaboi a50ad30176 Merge pull request #464 from mresnick67/pokewalker-0.2.0-followup
iOS: stop fusing mods; Pokewalker moves to its own repo
2026-07-30 15:51:55 -04:00
bryanthaboi c1f65106de Merge pull request #457 from johnjohto/fix-trainer-evolution-order
Delay evolution until trainer text ends
2026-07-30 14:56:24 -04:00
johnjohto 4250e3aa32 Delay evolution until trainer text ends 2026-07-30 14:03:36 -04:00
Myles Resnick fe1714d50e iOS: stop fusing mods; Pokewalker moves to its own repo
Follow-up to #452, from dogfooding the merged build on a real iPhone.

A mod fused into game.love sits in the read-only app bundle: the mod
manager's Delete can't remove it and it reappears every launch, and an
imported newer version can never fully replace it. iOS now packs no
mods, matching every other platform - mods install as .zips at runtime
with a real install/upgrade/delete lifecycle.

With nothing fusing it, mods/pokewalker moves out of this tree to its
canonical home, https://github.com/mresnick67/Gen1ReComp-Pokewalker
(already at 0.2.0 there with move learning and a paged credit dialog;
in-tree 0.1.0 was stale). docs/ios-install.md points at it. The
event:save.loaded coverage-debt entry returns to gate_meta_coverage:
its coverage came from the mod's test suite and leaves with it.

Local reproduction of the ci workflow on this branch: all tiers, mod
lint, fingerprint + meta-coverage gates green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 11:44:16 -04:00
Marcus Pereira cd33858020 Respect the device rotation lock on Android
The manifest asks for android:screenOrientation="fullUser", but SDL
overrides it at window creation: SDLActivity.setOrientationBis, given a
resizable window and no SDL_HINT_ORIENTATIONS -- which is what conf.lua
produces on Android -- requests SCREEN_ORIENTATION_FULL_SENSOR. The
*_SENSOR constants follow the accelerometer even when the player has
turned auto-rotate off, so the game rotated anyway on a locked device.

Override setOrientationBis in GameActivity to remap SDL's request onto
the matching *_USER constant after super has run. The same orientations
stay allowed and SDL keeps deciding which ones those are; only the
tie-break changes, from the sensor to the system rotation setting.
2026-07-28 23:22:35 -03:00
Kevin Jacobson fb58fa788b Expose window margins to tool HUDs 2026-07-28 14:09:46 -07:00
Kevin Jacobson d09cf80da5 Add persistent HUD hook for tool mods 2026-07-28 13:58:42 -07:00
Kevin Jacobson aab980b05a Add lifecycle hooks for tool mods 2026-07-28 13:33:53 -07:00
107 changed files with 8723 additions and 756 deletions
+1
View File
@@ -40,6 +40,7 @@ body:
- Windows
- Linux
- Android
- iOS
- Multiple platforms
validations:
required: true
+17 -3
View File
@@ -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"
+19 -11
View File
@@ -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
+9 -1
View File
@@ -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
+686
View File
@@ -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()
+33 -18
View File
@@ -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,
},
}
+1
View File
@@ -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
View File
@@ -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
},
},
}
+23
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+48
View File
@@ -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
WiFi, 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).
+20
View File
@@ -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
View File
@@ -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.
+71 -13
View File
@@ -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
View File
@@ -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) {
+1 -1
View File
@@ -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>
+13
View File
@@ -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.
+224
View File
@@ -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
+19
View File
@@ -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"
}
-17
View File
@@ -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.
-92
View File
@@ -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).
-144
View File
@@ -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
-16
View File
@@ -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."
}
-33
View File
@@ -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 },
}
-61
View File
@@ -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")
+3
View File
@@ -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
View File
@@ -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
+19 -9
View File
@@ -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]
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
+18
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -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
+8 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+25 -6
View File
@@ -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
View File
@@ -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,
+623
View File
@@ -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
+361
View File
@@ -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 ![alt](url)
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
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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()
+4
View File
@@ -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 })
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+34
View File
@@ -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.
+11
View File
@@ -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
+275
View File
@@ -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
View File
@@ -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
View File
@@ -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
+110 -12
View File
@@ -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
+7 -3
View File
@@ -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)
+215
View File
@@ -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
+148
View File
@@ -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
+11 -9
View File
@@ -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
+1
View File
@@ -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",
+19
View File
@@ -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
+276
View File
@@ -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")
+111
View File
@@ -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")
+116
View File
@@ -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")
+147
View File
@@ -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")
+46
View File
@@ -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
View File
@@ -1 +1 @@
9820543f7a223fab
7de3051a7b3c108e
+1 -1
View File
@@ -1 +1 @@
54a52ea81751495c
227fd737aba15763
+37
View File
@@ -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"' }),
+49
View File
@@ -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()
+130
View File
@@ -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()
+63
View File
@@ -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()
+72
View File
@@ -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()
+58
View File
@@ -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()
+102
View File
@@ -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()
+51 -12
View File
@@ -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")
+11 -1
View File
@@ -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()
+46
View File
@@ -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()
+93
View File
@@ -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()
+84
View File
@@ -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()
+39
View File
@@ -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()
+97
View File
@@ -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()
+100
View File
@@ -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
+75
View File
@@ -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",
}))
+3 -2
View File
@@ -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
View File
@@ -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
+32
View File
@@ -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