Compare commits

...

48 Commits

Author SHA1 Message Date
github-actions 833388c235 chore(ios): update app-repo.json [skip ci] 2026-08-13 06:22:43 -04:00
bryanthaboi 2ac527ae3c Merge pull request #1222 from bryanthaboi/dev
bug fixing and friends
2026-08-13 06:14:29 -04:00
bryanthaboi df35193ad3 Merge pull request #1204 from castdrian/ios-improvements
feat(ios): improve branding and fix haptic feedback
2026-08-13 06:06:31 -04:00
bryanthaboi 9b6365c9cc Merge pull request #1202 from HimioneGranger/upstream-android-host-extension
Android: add optional native host lifecycle hooks
2026-08-13 06:06:24 -04:00
bryanthaboi 285a98533a Merge pull request #1201 from HimioneGranger/upstream-payload-host-contract
feat(update): gate payloads by native host family
2026-08-13 06:06:18 -04:00
bryanthaboi f952edf77a Merge pull request #1200 from HimioneGranger/upstream-host-display-api
feat(host): add optional display lifecycle backend
2026-08-13 06:06:12 -04:00
bryanthaboi ae8360b2ae Merge pull request #1199 from HimioneGranger/upstream-quest-focus-api
fix(launcher): route focus input through mod dialogs
2026-08-13 06:06:05 -04:00
bryanthaboi 52e36ad7e4 fix testsZ 2026-08-13 06:05:27 -04:00
bryanthaboi 62b9f04191 Merge pull request #1209 from MaxTomahawk/fix/checkpoint-engine-version-restore
fix(mods): allow checkpoint restore across engine versions
2026-08-13 05:35:05 -04:00
bryanthaboi 1010d0584a Merge pull request #1210 from Yukitty/patch-hopFrames
fix(OverworldController): ledge jump arc to match player stepFrames
2026-08-13 05:33:02 -04:00
bryanthaboi 6864173f35 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-13 05:30:35 -04:00
bryanthaboi e9d431b3ff CLOSES #1091, CLOSES #1097, CLOSES #1100, CLOSES #1104, CLOSES #1135, CLOSES #1155, CLOSES #1157, CLOSES #1158, CLOSES #1159, CLOSES #1160, CLOSES #1167, CLOSES #1208 2026-08-13 05:30:33 -04:00
Yukita Mayako f138827917 fix(OverworldController): ledge jump arc to match player stepFrames 2026-08-12 21:23:35 -04:00
Max a68c47e7e1 fix(mods): allow checkpoint restore across engine versions 2026-08-13 02:28:57 +02:00
Adrian Castro d648990a81 feat(ios): brand app and tune haptic feedback 2026-08-13 02:19:45 +02:00
Codex Agent ee00728e9e android: add optional native host lifecycle hooks 2026-08-12 17:41:26 -05:00
Codex Agent 0aab11b690 feat(update): gate payloads by native host family 2026-08-12 17:23:25 -05:00
Codex Agent c2d9af693d feat(host): add optional display lifecycle backend 2026-08-12 17:07:32 -05:00
Codex Agent 1642113d1f fix(launcher): route focus input through mod dialogs 2026-08-12 16:48:29 -05:00
github-actions d64627e062 chore(ios): update app-repo.json [skip ci] 2026-08-12 17:16:13 -04:00
bryanthaboi 5fea0b05a4 Merge pull request #1198 from bryanthaboi/dev
bugs and slugs
2026-08-12 17:07:13 -04:00
bryanthaboi c3136bf8f7 Merge pull request #1034 from ShaneMcGovernIE/find-mods-release-stats
Add generic Linux ARM SBC PortMaster build
2026-08-12 16:57:46 -04:00
bryanthaboi 517170f1c8 Merge pull request #1068 from anxiousintrovert/agent/fix-disabled-battle-ui-background
Fix hidden battle UI overlay backgrounds
2026-08-12 16:56:46 -04:00
bryanthaboi e5bebea437 Merge pull request #1077 from MaxTomahawk/feat/battle-menu-auxiliary
feat(mods): add battle menu auxiliary action
2026-08-12 16:56:04 -04:00
bryanthaboi 3320eacc26 Merge pull request #1079 from MaxTomahawk/feat/mod-pokemon-icon
feat(mods): expose canonical Pokémon icon presentation
2026-08-12 16:55:55 -04:00
bryanthaboi b40ff167e7 Merge pull request #1170 from thibautbus/fix/resume-boot-music
Fix lingering title music after Resume Game
2026-08-12 16:55:32 -04:00
bryanthaboi f4497b4dbb Delete KANTO-CONTINUE.md 2026-08-12 16:52:38 -04:00
bryanthaboi b9684ef162 Merge branch 'main' into dev 2026-08-12 16:51:44 -04:00
bryanthaboi f394f56395 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-12 16:51:29 -04:00
bryanthaboi 6731937841 Pulled every open Gold bug (bodies, comments, screenshots/videos), matched them against the port and ../pokegold, then split them so each fixer owns a disjoint file set. Duplicates collapsed: #1164 and #1188 are the same Radio Tower stair softlock. #1150 and #1190 are the same Gold rebind freeze. Enhancements (#1161, #1130, #1112, #1100) were left out. Battle 1 src/battle/gen2/*, src/ui/gen2/BattleState.lua #1168 multi-hit always 2 #1180 DV / stats #1185 learn-move text order after a trainer fight #1152 no "Use next POKéMON?" / run after a wild faint Overworld 2 src/world/gen2/*, trade anim, hidden items #1184 Kurt blocking Slowpoke Well #1164 / #1188 Radio Tower gentleman on the stairs #1173 Rock Smash rocks walk left each visit #1172 last item name used Rock Smash #1167 Ilex Forest hidden items #1165 trade movie + ledge hop still missing (#1121 / #1126) UI 3 naming, party, mart, pokegear #1166 grey nickname screen #1162 TM/HM missing ABLE / NOT ABLE #1169 underground herb lady whites out the map #1151 Pokegear black player icon + extra A/B on the phone card Platform 4 Gold options, save slots, pack, mod tab #1150 / #1190 controls freeze #1178 no vibration row #1177 cannot move Gold touch controls #1107 SAVE with no launcher slot #1145 Gold mod tab still lists Gen 1-only mods #1192 RGXX missing tools/rom_manifest_gold.json Drivers only for the progress / data-loss ones (faint-run, multi-hit, Kurt, Radio Tower, Rock Smash shift, herb shop, save slot). No new unit tests. New comments are pokegold file.asm:line only. A verifier agent will get this same list once the four finish and check that the diffs actually fix the reports. Verifier checked the batch against pokegold. Almost everything landed. I closed the two leftovers that were still real bugs: #1185: the active mon no longer reprints GrewToLevel (engine/battle/core.asm:7044). Bench mons still print it, then the stats box, then the learn-move line. #1180: the party list recalcs stats on open, same as Summary. Gold boot now applies options.touchControls / haptics from the Gold options block, not the shared Red pad. # Verdict 1168 multi-hit FIXED 1180 DVs / stats FIXED 1185 learn-move order FIXED (was partial) 1152 run after faint FIXED 1184 Kurt FIXED 1164 / 1188 Radio Tower FIXED 1173 rocks slide FIXED 1172 Rock Smash name FIXED 1167 Ilex hidden items SKIP-OK (already in the Gold cache) 1165 trade + ledge FIXED 1166 nickname color FIXED 1162 TM ABLE / NOT ABLE FIXED 1169 herb shop white-out FIXED 1151 Pokegear FIXED 1150 / 1190 rebind freeze FIXED 1178 vibration FIXED 1177 touch layout FIXED 1107 save slot FIXED 1145 mod tab filter FIXED 1192 RGXX Gold manifest FIXED Nothing is committed. Drivers, from the repo root: POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/multihit_bug1168_test.lua love . Fury Attack should hit 2-5 times. Always twice is still broken. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/use_next_mon_bug1152_test.lua love . After the lead faints: "Use next POKéMON?" YES opens the party. NO/B tries to run. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/kurt_well_bug1184_test.lua love . Kurt at the well entrance (16, 14), not on the inner path. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/radiotower_softlock_bug1164_test.lua love . Gentleman in the 5F office (3, 6). Stairs at (12, 0) clear. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/rocksmash_shift_bug1173_test.lua love . Route 40 rocks stay put. A slide one cell left is the old bug. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/herbshop_bug1169_test.lua love . Herb shop intro over the Underground map, not a white screen. POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-bug1107 POKEPORT_DRIVER=tests/drivers/gold_save_slot_bug1107_test.lua love . Look for PASS and a saves/gold/slot*.lua path. CONTINUE should see it. Worth a hand check with no driver: nickname color, a TM for ABLE/NOT ABLE, Pokegear phone B-to-close, OPTION > CONTROLS, a ledge hop, a trade movie, and on a phone OPTION > TOUCH LAYOUT / VIBRATION. give me CLOSES #93939, in a comma separated list each saying CLOSES before each issue number that is fixed CLOSES #1107, CLOSES #1145, CLOSES #1150, CLOSES #1151, CLOSES #1152, CLOSES #1162, CLOSES #1164, CLOSES #1165, CLOSES #1166, CLOSES #1168, CLOSES #1169, CLOSES #1172, CLOSES #1173, CLOSES #1177, CLOSES #1178, CLOSES #1180, CLOSES #1184, CLOSES #1185, CLOSES #1188, CLOSES #1190, CLOSES #1192 2026-08-12 16:51:27 -04:00
bryanthaboi b2819d04e0 Merge pull request #1191 from AverageConsumer/codex/gen2-screen-render-visible
fix(mods): honor screen visibility in Gold rendering
2026-08-12 16:10:43 -04:00
AverageConsumer 52fac8ced6 Honor hidden screen states in Gold rendering 2026-08-12 20:59:35 +02:00
thibautbus 1176a269e5 Add unit tests 2026-08-12 11:38:17 +02:00
thibautbus 96af652d3b Fix lingering title music after Resume Game
Continue dropped the player into the overworld with the title
screen's song still cross-fading into the map theme over ~1.2s
(Music.MAP_FADE), audibly wrong since the player already has control.
New Game never showed this because OakSpeech's own unfaded
Music.play/playMap masks it before the player is ever placed in the
overworld. The same bug was also reachable through F2 quickload
(pressed at the title screen, or mid-session -- F2 always jumps
straight to the loaded save's map/position with no walking
transition, so it needs the same instant swap as Continue rather than
an ordinary warp's crossfade either way) and through the
checkpoint-resume mod API (RFC 0006's mod.checkpoint:resume).

OverworldState:setMap now takes an opts.freshBoot flag: when set, the
map's music swaps in at once instead of cross-fading, like every
other map's PlayDefaultMusic. It is set by every real hard state
teleport -- TitleState's onContinue, New Game's push, F2 quickload,
and Game:restoreCheckpointSave (whose only caller is itself
title-gated) -- and deliberately kept separate from the pre-existing
opts.via == "boot" default, which dev tooling (the console's `warp`
verb, hot reload's map rebuild) also reuses for unrelated reasons and
must keep its ordinary crossfade.
2026-08-12 11:25:07 +02:00
MaxTomahawk 29a2b9a123 Merge remote-tracking branch 'origin/dev' into feat/mod-pokemon-icon
# Conflicts:
#	docs/modding.md
2026-08-12 09:46:44 +02:00
MaxTomahawk 238af263d6 Merge remote-tracking branch 'origin/dev' into feat/battle-menu-auxiliary
# Conflicts:
#	src/core/Checkpoint.lua
2026-08-12 09:46:00 +02:00
github-actions dbd8abe14c chore(ios): update app-repo.json [skip ci] 2026-08-11 21:44:16 -04:00
bryanthaboi 04490c9b9a Merge pull request #1147 from bryanthaboi/dev
bunch of bugs and addl api stuff
2026-08-11 21:34:58 -04:00
Shane McGovern a0279f5be2 Merge branch 'dev' into find-mods-release-stats
Resolve conflicts with the dev rewrite:

- LauncherView.lua / RomImporter.lua / ModUpdate.lua /
  docs/new-features.md: dev already carries the Find Mods stats &
  sort work (in evolved async form), so take dev's versions. The
  auto-merge's duplicate blocking _findStats is dropped.
- release.yml: keep dev's split version/love-payload/linux-arm64/
  xbox-uwp job structure and re-wire the Linux ARM SBC PortMaster
  step to needs.version.outputs.version (steps.ver no longer exists).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-11 11:30:15 +01:00
MaxTomahawk ccb5358aad feat: expose public Pokemon icon presentation 2026-08-11 11:16:58 +02:00
anxiousintrovert d21a0edffc Fix hidden battle UI overlay backgrounds 2026-08-10 20:03:33 -05:00
MaxTomahawk 59725c0ead feat(mods): add battle menu auxiliary action 2026-08-10 23:32:38 +02:00
Shane McGovern 9f54734f62 Add generic Linux ARM SBC PortMaster build
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-10 00:19:52 +01:00
Shane McGovern eec396e388 Add the MODS tab sort options to the Find Mods tab 2026-08-04 15:11:55 +01:00
Shane McGovern 1ef0d8c2c0 Retry Find Mods stats after failed repo fetches
A failed repo fetch (hourly GitHub API rate limit, transient network error)
was memoized as resolved, so a rate-limited first visit left those rows
empty for the whole session. Failures now schedule a 60s retry; a 404 is
still permanent so a renamed or vanished repo is fetched once.
2026-08-04 13:39:36 +01:00
Shane McGovern 0f24105686 Fix Find Mods crash: require ModUpdate in the find panel
buildFindPanel called ModUpdate.statsLine without a local require --
only buildModsPanel had one -- so opening the tab indexed a nil global.
2026-08-04 13:37:48 +01:00
Shane McGovern d35b9e5c3e Fix crash opening the Find Mods tab: rename the stats cache field
The resolver stored results in self._findStats, which collides with the
method of the same name: self._findStats resolves through the metatable to
the function, so the or {} guard never fired and indexing it crashed the
launcher the moment the panel built. State now lives in _findStatsCache.
2026-08-04 13:36:30 +01:00
Shane McGovern ee5c168983 Resolve Find Mods stats from each mod's GitHub repo when the feed lacks them
A FIND MODS row now shows download/date stats even when its feed publishes
none: the row fetches the mod's own GitHub releases through the same
cached ModUpdate.fetchReleases the MODS tab uses (six-hour options cache,
so an installed mod's repo is instant). Feed-published stats still win
when present; otherwise one repo is fetched per frame -- the thumbnail
budget pattern -- so opening the tab never stalls for the whole listing.
ModUpdate.statsForReleases is the shared resolver.
2026-08-04 13:34:36 +01:00
107 changed files with 4633 additions and 898 deletions
+2 -2
View File
@@ -94,8 +94,8 @@ jobs:
if: github.repository == 'bryanthaboi/gen1recomp'
uses: actions/upload-artifact@v7
with:
name: gen1recomp-ios-ipa
path: dist/ios/gen1recomp.ipa
name: gen1recomp++-ios-ipa
path: dist/ios/gen1recomp++.ipa
if-no-files-found: error
retention-days: 7
- name: clean up signing keychain
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
run: |
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp-ios-ipa") | .id')"
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp++-ios-ipa") | .id')"
[ -n "$artifact_id" ] || exit 0
head_owner="${HEAD_REPOSITORY%%/*}"
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
@@ -44,7 +44,7 @@ jobs:
uses: thollander/actions-comment-pull-request@v3
with:
message: |
[gen1recomp.ipa](${{ steps.artifact.outputs.artifact_url }})
[gen1recomp++.ipa](${{ steps.artifact.outputs.artifact_url }})
**Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }})
**Build Time**: `${{ steps.build-info.outputs.time }}`
+35 -13
View File
@@ -1,8 +1,9 @@
name: Release
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
# IPA, a Nintendo Switch SD-ready zip (experimental), Xbox UWP, and the Anbernic
# RG34XXSP (Stock OS 64-bit MOD / PortMaster) port, then publishes them as a
# IPA, a Nintendo Switch SD-ready zip (experimental), Xbox UWP, the Anbernic
# RG34XXSP (Stock OS 64-bit MOD / PortMaster) and Linux ARM SBC PortMaster
# handheld ports on the self-hosted Mac runner, and publishes them as a
# GitHub Release.
#
# Versioning:
@@ -355,6 +356,20 @@ jobs:
# runtime from PortMaster-GUI, so it needs no signing/notarization.
./build-rg34xxsp.sh --version "${{ needs.version.outputs.version }}"
- name: Build Linux ARM SBC PortMaster port
env:
# The release workflow must package the commit being released. The
# script defaults to the latest published release for standalone
# builds, while this explicit local override keeps CI source-aligned.
GEN1RECOMP_SOURCE_DIR: ${{ github.workspace }}
GEN1RECOMP_RELEASE_TAG: v${{ needs.version.outputs.version }}
run: |
set -euo pipefail
# Same aarch64 PortMaster-style pack for Linux ARM SBC PortMaster. The build
# keeps its own cache because the two scripts use different staging
# layouts and runtime package paths.
./build-linux-arm-sbc.sh --version "${{ needs.version.outputs.version }}"
- name: Notarize & staple macOS app
if: github.repository == 'bryanthaboi/gen1recomp'
run: |
@@ -429,9 +444,9 @@ 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"
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"
cp "$ipa" "$outdir/gen1recomp++-${v}-ios.ipa"
swzip="dist/switch/gen1recomp-${v}-switch.zip"
[ -f "$swzip" ] || { echo "::error::$swzip not found (expected from scripts/build_switch.sh --fused → pack_sd_zip.sh)"; exit 1; }
@@ -449,6 +464,11 @@ jobs:
[ -f "$rg34" ] || { echo "::error::$rg34 not found (expected from ./build-rg34xxsp.sh)"; exit 1; }
cp "$rg34" "$outdir/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
# Linux ARM SBC PortMaster handheld port.
sbc="dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip"
[ -f "$sbc" ] || { echo "::error::$sbc not found (expected from ./build-linux-arm-sbc.sh)"; exit 1; }
cp "$sbc" "$outdir/gen1recomp-${v}-sbc-portmaster.zip"
# Platform-independent update payload, built alongside the desktop
# apps above (same game.love that gets fused into each of them).
love_file=".bazinga/work/game.love"
@@ -560,10 +580,11 @@ jobs:
"dist/release/gen1recomp-${v}-linux.zip"
"dist/release/gen1recomp-${v}-linux-arm64.AppImage"
"dist/release/gen1recomp-${v}-android.apk"
"dist/release/gen1recomp-${v}-ios.ipa"
"dist/release/gen1recomp++-${v}-ios.ipa"
"dist/release/gen1recomp-${v}-switch.zip"
"dist/release/gen1recomp-${v}-xbox-uwp.zip"
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
"dist/release/gen1recomp-${v}-sbc-portmaster.zip"
"dist/release/gen1recomp-${v}.love"
"dist/release/sha256sums.txt"
)
@@ -581,14 +602,15 @@ jobs:
run: |
set -euo pipefail
v="${{ needs.version.outputs.version }}"
ipa="dist/release/gen1recomp-${v}-ios.ipa"
ipa="dist/release/gen1recomp++-${v}-ios.ipa"
app_repo="mobile/ios/app-repo.json"
[ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; }
[ -f "$app_repo" ] || { echo "::error::$app_repo not found"; exit 1; }
date="$(date -u +"%Y-%m-%d")"
size="$(wc -c < "$ipa" | tr -d '[:space:]')"
download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${v}/gen1recomp-${v}-ios.ipa"
download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${v}/gen1recomp++-${v}-ios.ipa"
bundle_id="com.theboisclub.gen1recompplusplus"
localized_description="Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke"
release_notes="$(GH_TOKEN="${{ github.token }}" gh release view "v${v}" --json body --jq '.body // ""' 2>/dev/null || true)"
if [ -n "$release_notes" ]; then
@@ -602,15 +624,15 @@ jobs:
--argjson size "$size" \
'{version: $version, date: $date, size: $size, downloadURL: $download_url, localizedDescription: $localized_description}')"
if jq -e --arg version "$v" \
'any(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions[]?; .version == $version)' \
if jq -e --arg bundle_id "$bundle_id" --arg version "$v" \
'any(.apps[] | select(.bundleIdentifier == $bundle_id).versions[]?; .version == $version)' \
"$app_repo" >/dev/null; then
jq --arg version "$v" --argjson entry "$entry" \
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= map(if .version == $version then $entry else . end)' \
jq --arg bundle_id "$bundle_id" --arg version "$v" --argjson entry "$entry" \
'(.apps[] | select(.bundleIdentifier == $bundle_id).versions) |= map(if .version == $version then $entry else . end)' \
"$app_repo" > "$app_repo.tmp"
else
jq --argjson entry "$entry" \
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= [$entry] + .' \
jq --arg bundle_id "$bundle_id" --argjson entry "$entry" \
'(.apps[] | select(.bundleIdentifier == $bundle_id).versions) |= [$entry] + .' \
"$app_repo" > "$app_repo.tmp"
fi
mv "$app_repo.tmp" "$app_repo"
-457
View File
@@ -1,457 +0,0 @@
# KANTO-CONTINUE: executing the never-run half of Gold (sections 19-32)
Deferred from the 2026-08-09 fix campaign by request. This doc is self-contained: the
audit documents it was distilled from are being retired, so everything a future session
needs is here.
## What this is
The Johto main line (walkthrough sections 00-18) is proven: the route bot beats Champion
Lance with every row passing. Kanto (sections 19-32) is extracted and wired but has
NEVER been executed by any bot row, driver, or human. Every entry below reads clean in
static review (cache + code + cart source were cross-checked adversarially); the risk is
purely runtime. The job: make each beat actually run, fix what breaks, and leave
executable evidence (bot rows or assertion drivers) behind.
## State after the 2026-08-09 campaign (what changed under you)
- ~80 defects fixed across the port; 91/91 gen2 suites, engine 128/128, T3 all green.
- The post-game handoff WORKS now: post-E4 credits end at the title screen, and
CONTINUE consumes the post-game spawn (HallOfFame.consumePostGameSpawn): after Lance
you continue in NEW_BARK_TOWN, after Red at SILVER_CAVE_OUTSIDE (23,20). Section 19
is reachable by normal play for the first time.
- The Snorlax wake chain is fully functional (engine-flag bridge unlocks the Pokegear
and EXPN card; the tuned radio song persists after closing the gear; SnorlaxAwake
fires; proven live by tests/drivers/gold_radio_persist.lua).
- Landed systems Kanto beats depend on: phone random/outgoing calls, field item use
(Escape Rope/Dig via save.backupWarp), Repel, swarms, Rock Smash encounters,
waterfall current tiles, roamer scatter on CONTINUE, trap volatiles + FORCESHINY
battle types (beast catching in section 32 is now mechanically possible), badge
boosts, held items, specialty balls (Lure Ball needs battleType 'fish', already
stamped by updateFishing).
- NPC HOUR WINDOWS are now enforced (they were not when the Johto route was authored).
Any Kanto row that talks to a time-gated NPC must pin the clock (POKEPORT_GOLD_HOUR /
POKEPORT_GOLD_DAY) or handle absence. Only 11 hour-gated objects exist on the whole
cart: CELADON_GAME_CORNER 6/7, GOLDENROD_GAME_CORNER 4/5, MOUNT_MOON_GIFT_SHOP 1-4,
and the three Moms in PLAYERS_HOUSE_1F; Celadon and Mt Moon are the Kanto exposures.
- The game clock now anchors on every new game (cart InitClock semantics), so time of
day no longer follows the host wall clock in driver runs; POKEPORT_GOLD_HOUR pins
the anchored base too. Runs are reproducible across day/night; a pre-existing flake
class (night runs catching HOOTHOOT where day runs catch PIDGEY, shifting grind
levels) died with it.
- Battles hit harder both ways since the cart MIN_DAMAGE floor landed; grind budgets
tuned for the old numbers may need a nudge.
## Known bot weaknesses to expect (verified pre-existing, not campaign regressions)
- Pathfinding pocket: travel into ILEX_FOREST / SLOWPOKE_WELL bounces (probe
CHERRYGROVE_CITY>ILEX_FOREST reports no route), and route row 16.54 gives up the
walk to VICTORY_ROAD_GATE with the identical TELEPORT signature in every logged run.
Kanto routing should waypoint doors explicitly per the ops recipes above.
- Optional buy rows 04.5d and 18.g4/18.g5 fail whenever the wallet is empty at that
point in the run; they are budgeting gaps in the route, not engine bugs.
- Grind row 08.g (Route 41 water) has almost no margin; with the clock now anchored
its inputs are stable, but a low-level POLIWAG catch can still stretch it.
## How to run things
Identity/cache: gold-dev (~/Library/Application Support/LOVE/gold-dev/gold), rebuilt
2026-08-09 with swarm/rock/roam tables. Checkpoints gold-ckpt-NN.lua exist per section;
gold-ckpt-18-pristine.lua is the post-E4 seed. ROM: ../decprep/Pokemon - Gold
Version.gbc. Cart source: ../pokegold (cite it in any hand-ported code).
Headless love needs a pty on macOS; wrap every run:
perl -e 'alarm 900; exec @ARGV' python3 -c "import pty; pty.spawn(['love','.'])"
Full bot run: POKEPORT_IDENTITY=<id> POKEPORT_GAME=gold POKEPORT_SPEED=200 \
POKEPORT_GOLD_CKPT=1 POKEPORT_GOLD_STALL=20000 POKEPORT_GOLD_LOG=/tmp/b.log \
POKEPORT_DRIVER=tests/drivers/gold_bot.lua love .
Resume a section: POKEPORT_GOLD_RESUME=NN (same identity, prior CKPT=1 run).
Travel probe: POKEPORT_GOLD_PROBE="MAP_A>MAP_B" with tests/drivers/gold_travel_probe.lua.
Map graph: luajit tools/goldwalk/mapgraph.lua path|map|reach|audit; regenerate
tests/drivers/gold/map_regions.lua with `mapgraph.lua graph >` after ANY extractor map
change, then re-check route region indices.
Route validation (before any run): luajit tests/gold_route_validate_test.lua and
luajit tests/gold_flag_names_test.lua.
Suites: GOLD_CACHE="$HOME/Library/Application Support/LOVE/gold-dev/gold" luajit
tests/gen2_<name>_test.lua; tiers run_engine/run_modkit/run_tests.
Reimport after extractor/manifest changes: python3 tools/make_gold_manifest.py, then
POKEPORT_IMPORT_TRACE=1 POKEPORT_IDENTITY=gold-dev POKEPORT_IMPORT_ROM="../decprep/\
Pokemon - Gold Version.gbc" POKEPORT_IMPORT_ONLY=1 POKEPORT_FORCE_IMPORT=1 \
POKEPORT_GAME=gold love .
## Recommended shape of the work
1. Extend tests/drivers/gold/route.lua with sections 19-32 (the row format, ops and
evidence rules are documented in the file head and bot.lua). Seed runs from
gold-ckpt-18-pristine plus the now-working post-credits CONTINUE. `expect` flags are
the only oracles; never make a row load-bearing on a soft-failing map.
2. Highest-risk multi-map scripted chains first: S.S. Aqua voyage, Machine Part chain,
PASS/Magnet Train chain. Each deserves an assertion driver even if bot rows also
cover it.
3. Time-gated beats (S.S. Aqua sailing days, Indigo rival Mon/Wed, Dragon's Den
Tue/Thu, Clefairy Monday night, TM03 night gate) need POKEPORT_GOLD_DAY/HOUR pins.
4. Johto stragglers are folded in below (lighthouse descent, TM08 flake, and the
optional gift beats); they ride the same route-extension pass.
5. Section 32 (beasts) last: it needs long roaming play; trap volatiles and FORCESHINY
landed this campaign, so Mean Look + Heavy/Fast Ball plans are now viable.
## Also deferred (deliberate stubs, do not re-flag)
Time Capsule trading, Trainer House CAL2/Mystery Gift visitor, Silver-version import,
Cable Club link rooms, and Game Boy Printer output are deliberate product-level stubs,
not campaign work.
## The beats, in walkthrough order
### Runtime coverage ends at the Hall of Fame: no bot row, driver, or human has run any Kanto beat (sections 19-32) or the section 14 Tin Tower/Whirl Islands content
walkthrough section(s) 19,20,21,22,23,24,27,28,29,30,31,14 | missing
One root cause: tests/drivers/gold/route.lua (1264 lines) ends at row 18.20 (settle HALL_OF_FAME, expect EVENT_BEAT_ELITE_FOUR); the campaign covers asm-walk sections 00-18 only and none of the 24/36 gold_* drivers reaches any Kanto map, so sections 19-32 have zero execution proof. Extend route.lua into sections 19-32 and/or add targeted drivers. Per section pair: (s19/20) next rows are the Elm ticket (needs the post-credits spawn fix or a fly detour from Indigo), the ship crossing as far as the docking events, the Surge fight, and a Saffron Gym pad-chain assertion. (s21/22) All 24 route/gym trainers resolve in cache with correct parties: Route 9 six (Edna, Sid, Dean, Sidney, Tim, Heidi); Route 25 seven plus Kevin, whose Nugget-then-battle script 50:488d is hand-shaped (a full bag aborts before the battle, cart behavior); Cerulean's three swimmers; Route 10 South's Jim and Robert (Robert's Quagsire holds a BERRY via TRAINERTYPE_ITEM); Route 8's five; Celadon Gym's five including the twins' shared beat flag. Sight-line engagement is Trainers.sees (proven by the Johto bot); prize money base x level x 4 is Prize.lua; Erika's class item extracts as HYPER_POTION (the walkthrough's 'three Full Restores' is a FAQ error, not a port gap); Gold-only wilds (Mankey line Route 9, Growlithe Routes 7/8) come from the ROM version branch; Route 8's PRZCureBerry fruit tree is a fruittree op (Vm.lua:1218) into Apricorns.lua daily reset. One Kanto smoke-run converts the whole block. (s23/24) Nothing touches Routes 11-19, Fuchsia City/gym, Vermilion Snorlax, Diglett's Cave, Route 2, Pewter; the report's three blocker findings (START menu row, Pokegear card flags, radio persistence) are exactly the UI-plumbing breaks data audits miss: a gold_snorlax_wake.lua that grants the gear and EXPN card the way the game does (setflag 4, setflag 0, setflag 3 through the VM, not by poking save fields), tunes the radio, closes the gear, and talks to the Snorlax would currently fail at three separate steps; gold_cycling_road.lua and gold_fuchsia_gym.lua are the other two cheap proofs (gold_menu_shots.lua:72-73 only ever hand-seeded save fields). (s27/28) Drive first: (1) Cinnabar Blue talk -> disappear -> clearevent 1910 -> Viridian Gym populated -> Blue beaten -> ENGINE_EARTHBADGE set -> VAR_BADGES answers 16 (a break anywhere makes the 16th badge unobtainable and the Mt. Silver gate unpassable); cache chain exists: CINNABAR_ISLAND obj 1 SPRITE_BLUE at (9,6) scriptKey 4e:4985 eventFlag 1909; VIRIDIAN_GYM objs 1-2 gated on 1910; (2) the ROUTE_20 MAPCALLBACK_NEWMAP 4e:4cfa setevent 215 / ROUTE_19 MAPCALLBACK_TILES 4e:4f09 changeblock unseal pair (regression walls off Route 19). Driver: surf Pallet -> Route 21 -> Cinnabar -> talk to Blue -> Route 20 -> beat Blaine -> fly to Viridian -> beat Blue. (s29/30) No rows for Kanto OAKS_LAB, ROUTE_22, ROUTE_28, SILVER_CAVE_OUTSIDE, or the Union Cave B2F Lapras revisit (route.lua's only Union Cave rows are 03.29/04.g); risk concentrates on composites never run together: the 16-badge Oak visit with ProfOaksPCBoot text chain, the double Cut approach to the Route 28 west strip, surf-to-talk on the drifting SWIM_WANDER Lapras (Lake of Rage Gyarados proves the shape via gold_lake_probe.lua), and the daily-flag consume-and-respawn cycle across a save reload; a targeted gold_lapras_probe driver (clockDay=5, walk in, assert object present, catch, walk out, re-enter, assert gone) plus route extension converts inspected to proven. (s31) All six Silver Cave maps decode (warps, item balls flags 1689-1693, hidden items, SILVER_CAVE_OUTSIDE flypoint callback, PALETTE_DARK on Room 1, grass/water tables including Room 2 water); Flash lifts PALETTE_DARK via Palettes.isDarkness + World flashUsed; Surf/Waterfall live in FieldMoves with the badge table; gen2 ledges landed via Permissions.ledgeFacings / World:tryLedgeJump (the asm-walk's 'ledge hop missing' row is stale, GOLD-NEXT-RUN records the fix); the flypoint is FieldMoves.lua:386 flag 75; but no driver has entered Silver Cave, used Waterfall on Room 2's two bands, hopped Room 1's Escape Rope ledge, or talked to Red (closest: gold_halloffame_shots, gold_hm07_probe which proved Waterfall only at Tohjo Falls). (s14) The bot covers sections 10,11,12,13,16,17,18 and never section 14 (Ho-Oh is optional); untested: the 4F-9F ladder/hop mazes (depend on the recently added ledge-hop and one-way-wall support), the Ecruteak entrance sage scene retarget from Morty's setmapscene, the 1F sage unmasking off EVENT_TEAM_ROCKET_DISBANDED (set on the Gold arm of the Radio Tower boss script), the roof save/reset loop, and the entire Gold-side Whirl Islands dive; needs one probe driver carrying a RAINBOW_WING save up the tower and one diving to the Lugia chamber.
*How to prove it:* Extend tests/drivers/gold/route.lua past row 18.20 into sections 19-32, and/or write the named targeted drivers: gold_snorlax_wake.lua, gold_cycling_road.lua, gold_fuchsia_gym.lua, a gold_lapras_probe (clockDay=5 present/absent cycle), a Cinnabar-to-Viridian 16th-badge driver, a Route 20/19 unseal driver, a Silver Cave/Red driver, a Tin Tower RAINBOW_WING probe, and a Whirl Islands Lugia dive probe.
*Notes:* Merged from the parent heading plus six 'Also filed as' sub-findings (all one cause). Doc's own ratings: parent missing/minor (s19,20, already in ledger); s21,22 unverified/minor; s23,24 unverified/minor; s27,28 missing/major; s29,30 unverified/minor; s31 unverified/minor; s14 unverified/minor. The adversarial re-check ran on the s27/28 sub-claim, tried to refute and could not; its one correction: tests/drivers/gold/flag_names.lua and map_regions.lua DO name the Kanto maps (map_regions even carries CINNABAR/SEAFOAM/VIRIDIAN region graphs, so bot travel there is data-ready) but they are lookup tables, not executed coverage. 'Implemented statically, executed by nobody' matches the project's Roamers/Events:restore failure mode. GOLD-WALK-HANDOFF sections 1 and 4 already document the Johto-only scope.
### Olivine Port pier and Fast Ship boarding gate: the only road into Kanto, fully extracted, never executed
walkthrough section(s) 19 | unverified
Coord event at (7,15) (cache 5b:407d) does the full cart sequence: temporary-event bails, first-time bypass on event 48, readvar VAR_WEEKDAY branch, yesorno, checkitem S_S_TICKET (item 68), setevent 1, 7-step applymovement, sjump into gangway script 5b:401e which plays SFX, disappears/appears the sailor, runs special 46 = FadeOutToWhite (specialOrder[46+1], implemented Specials.lua:998), clears the eight eastbound EVENT_BEAT_* rematch flags on repeat trips, sets FAST_SHIP_1F scene 1, and warps to (25,1). Port sprite toggles for the post-Hall-of-Fame sailor (events 1847/1848) are armed by the HOF script. All data extracted, every op has a proven Johto call site; matched op-for-op against pokegold maps/OlivinePort.asm. Needs one live run or driver.
*How to prove it:* A driver or bot section that reaches OLIVINE_PORT post-HOF with the S_S_TICKET, boards, and asserts the warp to FAST_SHIP_1F (25,1) with scene 1 set. No gold_* driver currently reaches OLIVINE_PORT post-HOF.
*Notes:* Doc rating: unverified/blocker, new. If this chain fails, the walkthrough stops here (Kanto unreachable).
### S.S. Aqua maiden voyage: trainer gauntlet, lazy sailor, granddaughter, Metal Coat, docking; extracted end to end, never run
walkthrough section(s) 19 | unverified
Verified in cache, never executed: FastShip1F enter scene 5b:48a8 sdefer 5b:48ad (SFX_BOAT, earthquake 30, blackoutmod to cabin map, clearevent 49, scene 2 on first trip); grandpa bump coord events at (24,6)/(25,6); maiden-trip trainer objects behind flag 1849 with parties matching data/trainers/parties.asm exactly (NOLAND Sandslash31/Golem33, LYLE Koffing28/Flareon31/Koffing28, COLIN Delibird32 holding BERRY, MEG&PEG, FRITZ Mr.Mime/Magmar/Machoke, JEFF 2x Raticate32, DEBRA Seaking33, STANLY Machop31/Machoke33/Psyduck26, all in trainers.lua); B1F blocking-sailor coord scripts 5b:5e0c/5b:5e21 plus shared talk script 5b:5e37 clearing event 1837 to spawn the lazy sailor; FastShipLazySailorScript 5b:4d68 (playmusic, loadtrainer SAILOR 9, reloadmap, special 27 = HealParty, setmapscene B1F NOOP, readvar VAR_FACING walk-out); bed script 5b:521b (HealParty, fades, ReloadSpritesNoPalettes, RestartMapMusic, dock check on events 49/50/48 so sleeping cannot dock the maiden voyage); granddaughter payoff 5b:55c6 (fade, disappear/moveobject/appear teleport, showemote, sjump 5b:5633 = Metal Coat verbosegiveitem plus EVENT_FAST_SHIP_HAS_ARRIVED/FOUND_GIRL); door guard 5b:48d2 with arrived arm 5b:48ec (scall facing arm, setmapscene VERMILION_PORT scene 1, warp to VERMILION_PORT 7,17). Specials 27/46/47/49/50/60 resolve to HealParty/FadeOutToWhite/FadeOutToBlack/FadeInFromBlack/ReloadSpritesNoPalettes/RestartMapMusic, all implemented. Nothing in the chain touches a stubbed special, callasm, or VAR. Matched against pokegold maps/FastShip*.asm.
*How to prove it:* A bot section or driver for the full crossing (board, fight or dodge the gauntlet, sleep check, granddaughter Metal Coat, dock at VERMILION_PORT 7,17).
*Notes:* Doc rating: unverified/blocker, new. If any link breaks, arrival in Kanto is impossible.
### Machine Part quest chain (Power Plant manager, Cerulean Gym grunt scene, Route 24 grunt, part return, TM07) never executed
walkthrough section(s) 21,22 | unverified
The spine of sections 21-22: manager talk sets EVENT_MET_MANAGER_AT_POWER_PLANT 202, clears EVENT_CERULEAN_GYM_ROCKET 1901 and EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM 251, arms CERULEAN_GYM scene 1 and the POWER_PLANT guard phone-call scene; grunt runs out of the gym (sets 203, clears 1900/1902, arms ROUTE_25 scene 1); beat the Route 24 grunt (GRUNTM 31, L30 Golbat); return the part for TM07 Zap Cannon plus EVENT_RESTORED_POWER_TO_KANTO 205 and the Saffron station population flip (clears 1906). Statically verified: cache holds full bytecode for PowerPlantManager 54:4dbd with both branches (54:4deb takeitem MACHINE_PART / 54:4e04 TM give guarded by event 223), gym scene script 54:4332 sdefer 54:4336 with all 34 rows, Route 24 grunt script 50:4407; initial_events.lua seeds flags 251/1900/1901/1902/1903/1906/1907 at new game exactly as InitializeEventsScript does (pokegold engine/events/std_scripts.asm:546-550); World.lua:607 applies the seed while EVENT_INITIALIZED_EVENTS is clear. Engine deps all have call sites: World:trySceneScript (World.lua:5369, called at :6893), setmapscene/setscene (Vm.lua:274-284), specials RestartMapMusic/FadeOutToBlack/FadeInFromBlack/ReloadSpritesNoPalettes/FadeOutMusic (Specials.lua:999-1072). Missing only run-time proof: route bot covers sections 00-18, no Kanto driver exists.
*How to prove it:* Route extension or a driver walking manager -> gym grunt scene -> Route 24 grunt battle -> part return, asserting events 202/203/205 set and 1900/1901/1902/251/1906 cleared and TM07 in bag.
*Notes:* Doc rating: unverified/blocker, new.
### Route 25 Misty date scene, Cerulean gym population, and Misty fight never executed (and Misty gives no TM, contrary to walkthrough)
walkthrough section(s) 21 | unverified
The date cutscene at Route 25 cells (42,6)/(42,7) is what clears EVENT_TRAINERS_IN_CERULEAN_GYM 1903 and populates the gym with Misty, three swimmers, and the guide; without it Misty never appears and the Cascade Badge is unreachable. Cache has both coord events with sceneId=1 (scripts 18305/18369) and full scripts 50:4781/50:47c1 (34-row cutscene: heart emote, boyfriend flees, Misty approach movement, clearevent 1903, setscene 0, special 60=RestartMapMusic), armed by gym grunt script 54:4336 row 27 (setmapscene group=7 map=16 scene=1). Engine deps: World:tryCoordScript (World.lua:5006) filters on the map scene id; mapScenes persist on the save (GOLD-INDEX tier note on World:loadPlayerData). CeruleanGymMistyScript 54:438a is in the cache; MISTY trainer 1 party (L42 Golduck, L42 Quagsire, L44 Lapras, L47 Starmie with full movesets) extracts correctly; baseMoney 25 so the 4700G payout follows from Prize.reward.
*How to prove it:* Driver: arm the scene via the gym grunt, trigger the date coord event at (42,6)/(42,7), assert 1903 cleared and the gym populated, then fight Misty and assert the Cascade Badge and 4700G.
*Notes:* Doc rating: unverified/blocker, new. Correction embedded in the doc: Misty gives NO TM on the Gold cart, only the badge plus retroactive swimmer beat flags; the walkthrough and the lead's 'TM from Misty' are wrong about the cart, nothing is missing in the port there.
### PASS quest: Copycat lost item and Fan Club doll chain fully extracted, never run
walkthrough section(s) 22 | unverified
Copycat (COPYCATS_HOUSE_2F object 1, variable sprite slot 251) talk after the machine part return sets EVENT_MET_COPYCAT_FOUND_OUT_ABOUT_LOST_ITEM 206; Fan Club Clefairy guy (POKEMON_FAN_CLUB object 3, script 59:437b) then gives LOST_ITEM 130 and disappears the doll (flag 1908, correctly NOT in the initial seed; doc evidence line also cites POKEMON_FAN_CLUB object 5 flag 1908); returning it (Copycat branch 61:528b: takeitem 130, setevent 208, clearevent 1907 to re-show her shelf doll, then PASS 134 via 61:529d) yields the PASS. All bytecode in cache (61:5235/61:5270/61:528b); the mimicry gag's variablesprite slot 11 plus special 93 resolves to LoadUsedSpritesGFX (specialOrder[94], Specials.lua:1033); the Copycat's variable sprite slot is seeded through World.initialSprites/findInitialSprites (World.lua:1530, the fix GOLD-INDEX credits for the Copycat existing at all). Flag 1907 is in the initial seed so the shelf doll is hidden until the return; 1906 is seeded so the Saffron station stays empty until power is restored.
*How to prove it:* Driver: after machine-part return, talk to Copycat (assert 206), get LOST_ITEM at the Fan Club (assert doll flag 1908 set), return it (assert 208 set, 1907 cleared, PASS 134 in bag).
*Notes:* Doc rating: unverified/major, new. Correction embedded: the walkthrough's 'Rail Pass then Magnet Train Pass' is one item (PASS) on the cart, not a port gap. Merge-with hint: the s20 entry 'Copycat lost doll quest chain toward the rail PASS' (copycat-doll-pass-s20) describes the same quest from the section-20 audit with slightly different event ids cited (210/201 gate checks there vs 206/208 here).
### Magnet Train ride wired end to end; arrival behavior needs a human eye, never ridden since
walkthrough section(s) 22 | unverified
SaffronMagnetTrainStationOfficerScript (cache 61:4bc2/61:4bd0, rows 11-14) checks EVENT_RESTORED_POWER_TO_KANTO 205 then checkitem PASS 134, does setval 1, special id=35 which resolves to MagnetTrain (specialOrder[36]), then warpcheck and newloadmap 249 (MAPSETUP_TRAIN). All implemented: H.MagnetTrain (Specials.lua:2119) reads scriptVar for direction and blocks on World:magnetTrain (World.lua:2393) which pushes Gen2MagnetTrainRide; Vm handles warpcheck (:1135) and newloadmap (:1177); MAPSETUP_TRAIN defined at World.lua:175. Gating events (205 set by the Power Plant manager, PASS from the Copycat) are this section's other findings.
*How to prove it:* WHATS-NEXT item 8: a human rides both ways and presses a direction on arrival; the conversation must start on the first step because south is the only legal step out of the doorway cell. Nothing has run this since.
*Notes:* Doc rating: unverified/major, already named in the ledger.
### Silver Wing in Gold: Pewter City gramps script extracted, but Kanto (and the Whirl Islands dive) never run
walkthrough section(s) 14 | unverified
For a Gold cart, Lugia needs the SILVER_WING carried in the bag (WhirlIslandLugiaChamberLugiaCallback does checkevent EVENT_FOUGHT_LUGIA then checkitem SILVER_WING), and in Gold the wing comes from the Pewter City gramps (pokegold maps/PewterCity.asm PewterCityGrampsScript: checkver, Silver arm branches away, Gold falls through to verbosegiveitem SILVER_WING + setevent). Verified in cache: PEWTER_CITY object 3 (SPRITE_GRAMPS at 29,17) scriptKey 4d:583e = faceplayer / opentext / checkver / iftrue 4d:585c / checkevent 121 / verbosegiveitem item 71 / setevent 121 / closetext / end, all generic opcodes. The Lugia chamber map, its SPRITE_LUGIA object (scriptKey 47:41a0, eventFlag 1853), and its MAPCALLBACK_OBJECTS callback (47:418c) are in the cache using only implemented machinery. Route bot has no section 14/25 rows (grep 'id = "1[0-9]' shows 10,11,12,13,16,17,18). One mechanical gap on the dive path: the waterfall forced-down item, reported separately in the doc.
*How to prove it:* Driver or bot rows: reach Pewter in Gold, talk to the gramps (assert SILVER_WING item 71 given, event 121 set), then the full Whirl Islands dive to the Lugia chamber.
*Notes:* Doc rating: unverified/major, new. The waterfall forced-down item on the dive path is a separate finding elsewhere in the doc, not part of this entry.
### Vermilion Port arrival bookkeeping, Vermilion flypoint, and Kanto music never executed
walkthrough section(s) 19 | unverified
Arrival scene (VERMILION_PORT scene 1, sdefer 5b:450e) extracted intact: one step up, appear the gangway sailor, setscene 0, end-of-voyage flags (1841/1840 hide grandpa and granddaughter for good, 1849 retires the maiden-trip trainers, clear 1843 to reveal the Olivine passage Pokefan, set 48 EVENT_FAST_SHIP_FIRST_TIME), and blackoutmod VERMILION_CITY. ENGINE_FLYPOINT_VERMILION comes from MAPCALLBACK_NEWMAP on both VERMILION_PORT and VERMILION_CITY (setflag 57); the port's fly table carries the matching row (FieldMoves.lua:392) and World:runMapCallback has a live call site (World.lua:5659 plus the MAPCALLBACK_OBJECTS call in setMap). Fly deliberately stays region-locked exactly like the cart (FieldMoves.flyPoints, FieldMoves.lua:442: Kanto page only after SPAWN_INDIGO, no cross-region fly, verified against pokegold engine/pokegear/pokegear.asm .KantoFlyMap). Kanto music extracted: audio.lua songs has 92 entries, musicOrder includes Music_VermilionCity and Music_ViridianCity (musicOrder[63]=Music_VermilionCity), mapSongs carries all 362 map rows.
*How to prove it:* Driver: dock at Vermilion, assert flags 1841/1840/1849/48 set and 1843 cleared, flypoint flag 57 set on map entry, and Vermilion/Viridian city music playing.
*Notes:* Doc rating: unverified/major, new.
### Pokemon Fan Club chairman Rare Candy give never run
walkthrough section(s) 19 | unverified
Chairman script 59:4340: checkevent 212 (already heard), checkevent 211 (bag-full retry), yesorno where answering No gives nothing (cart behavior), two promptbutton speech pages, verbosegiveitem item 32 = RARE_CANDY, setevent 212. The cart quirk that nothing ever sets the bag-full retry event 211 is faithfully carried in the extracted bytecode. Same map also holds the Clefairy doll object (flag 1908) and Clefairy Guy script 59:437b that the later Copycat quest reads. yesorno, verbosegiveitem, and multi-page writetext all have proven Johto call sites. Matched against pokegold maps/PokemonFanClub.asm; RARE_CANDY present in items.lua.
*How to prove it:* Covered by any Kanto smoke-run that talks to the chairman and asserts RARE_CANDY given and event 212 set.
*Notes:* Doc rating: unverified/minor, new.
### Lt. Surge gym: trainers, Thunder Badge, AI item use fully extracted, never fought
walkthrough section(s) 19 | unverified
Surge script 59:4bfc: checkflag 36 re-fight guard, loadtrainer class 19 member 1, winlosstext, reloadmapafterbattle (whose loss-abort semantics were fixed during the Johto campaign), retroactive setevent of the three gym trainers, setflag 36 = ENGINE_THUNDERBADGE which routes to save.player.kantoBadges.THUNDER. Parties in trainers.lua match data/trainers/parties.asm exactly: Surge is RAICHU44/ELECTRODE40/MAGNETON40/ELECTRODE40/ELECTABUZZ46 with full TRAINERTYPE_MOVES movesets; Vincent, Horton, Gregory all match (GUITARIST 2/JUGGLER 3/GENTLEMAN 3). Trainer AI item use implemented: Ai.ITEM_ORDER/Ai.chooseItem (Ai.lua:1368-1400) dispatched from Battle.lua:1790 (AI_SwitchOrTryItem) off the extracted class attributes. The gym has no puzzle in Gen 2, matching the walkthrough's power-outage line.
*How to prove it:* Driver or route rows: fight the gym trainers and Surge, assert ENGINE_THUNDERBADGE (flag 36) set into save.player.kantoBadges.THUNDER and the AI HYPER_POTION use path exercised.
*Notes:* Doc rating: unverified/major, new. Correction embedded: the walkthrough's claim that Surge holds a Full Restore is a FAQ error; pokegold data/trainers/attributes.asm line 114 (block 113-117) gives the Lt Surge class HYPER_POTION and the cache carries exactly that (LT_SURGE items=HYPER_POTION, baseMoney 25).
### Route 6 Underground Path blocker and Saffron gate guard extracted, never walked
walkthrough section(s) 20 | unverified
The walkthrough's 'large man who blocks the entire entrance' is the ROUTE6_POKEFAN_M object at (17,4) whose event flag EVENT_ROUTE_5_6_POKEFAN_M_BLOCKS_UNDERGROUND_PATH hides him only when SET (set by the Power Plant script in a later section); the port's object-visibility rule 'flag set hides object' is implemented and documented at Events.lua:1-2, and his script is a bare jumptextfaceplayer about the Power Plant. The Route 6 Saffron gate guard at (0,4) stands off the walking lane and is dialogue only (branches on EVENT_RETURNED_MACHINE_PART). ROUTE_6 grass and water encounter tables (Abra/Magnemite slots) are present in encounters.lua. Nothing gated on engine work; it has just never been walked.
*How to prove it:* Covered by any Kanto walk through Route 6 before and after the Power Plant script sets the blocker flag.
*Notes:* Doc rating: unverified/minor, new.
### Mr. Psychic's TM29 Psychic give extracted verbatim, Kanto walk to the door unproven
walkthrough section(s) 20 | unverified
Cache 61:4b15: checkevent 227, writetext, promptbutton, verbosegiveitem item 221 = TM_PSYCHIC_M, iffalse full-bag fall-through WITHOUT setting the flag (cart behavior, re-talk after making room), setevent 227. TM_PSYCHIC_M is in items.lua with pocket TM_HM and price 2000; the four-pocket bag and TM teaching were exercised in the Johto run (TM08 ROCK SMASH teach passed on merit per GOLD-WALK-HANDOFF). Matched against pokegold maps/MrPsychicsHouse.asm.
*How to prove it:* Any Kanto run that walks to the house, talks, and asserts TM29 given and event 227 set (plus the full-bag retry arm).
*Notes:* Doc rating: unverified/minor, new.
### Silph Co. 1F Up-Grade give extracted, never run
walkthrough section(s) 20 | unverified
Cache 61:4f81: same shape as Mr. Psychic, verbosegiveitem item 172 = UP_GRADE guarded by event 222 with the full-bag fall-through. The officer at (13,1) has event flag -1 so he never moves, and SILPH_CO_1F has only the two street-door warps: there is no upstairs in Gen 2, so the 'man blocking the stairway' never opens, matching the cart (verified against pokegold maps/SilphCo1F.asm). Generic mechanisms only. UP_GRADE present in items.lua.
*How to prove it:* Any Kanto run that enters Silph Co. 1F and asserts UP_GRADE given behind event 222.
*Notes:* Doc rating: unverified/minor, new.
### Saffron Gym teleporter maze (30 same-map warp pads) never exercised; same-map warp chains untested anywhere
walkthrough section(s) 20 | unverified
The gym's whole puzzle is 30 warp pads (cache SAFFRON_GYM warps 3-32) that all target SAFFRON_GYM itself; the only route to Sabrina is entrance pad (11,15) -> ... -> (1,5) -> (11,9). The engine mechanism exists and reads correct: pads are COLL_WARP_PANEL 0x7c, an immediate warp (Permissions.lua:311); World:takeWarp (World.lua:6022-6040) handles a destination equal to the current map through setMap; warpCooldown keyed on the landing cell (World.lua:6135 set, 6217-6222, 6240 suppression check) stops the landing pad from re-firing until the player steps off, exactly what a pad-to-pad maze needs. But the asm-walk's own port-coverage table flags that no driver has ever exercised a same-map warp chain, and no Kanto run exists. If this breaks, Sabrina is unreachable and the 10th badge (and eventually Red's gate) is lost.
*How to prove it:* Targeted driver: enter the gym, ride pad (11,15), assert arrival at (19,17), then the full 5-hop chain to (11,9).
*Notes:* Doc rating: unverified/major, new.
### Sabrina battle and Marsh Badge fully extracted, never fought
walkthrough section(s) 20 | unverified
Sabrina script (cache 61:40cf): checkflag 39 guard, loadtrainer class 35 member 1, reloadmapafterbattle, force-sets the four gym trainer flags (so the trainers are skippable, as the walkthrough implies), setflag 39 = ENGINE_MARSHBADGE routed to save.player.kantoBadges.MARSH. Party matches the cart exactly (trainers.lua SABRINA 1: ESPEON46 Sand-Attack/Quick Attack/Swift/Psychic, MR__MIME46 Barrier/Reflect/Baton Pass/Psychic, ALAKAZAM48 Recover/Future Sight/Psychic/Reflect; class item HYPER_POTION, base reward 25, AI attribute words extracted; attributes.asm:209-213). The four trainers (Rebecca, Franklin, Doris, Jared; MEDIUM 6,7 / PSYCHIC_T 2,11; parties verified including the cart's L35 third Exeggcute that the FAQ gets wrong) are OBJECTTYPE_TRAINER with SPINRANDOM movement, which Npc.lua supports, and sight/battle handoff is the proven Trainers.lua path. Prize money base x last-level x4 is implemented in Prize.lua (matches the walkthrough's 4800G).
*How to prove it:* Driver through the warp maze to Sabrina; assert ENGINE_MARSHBADGE (flag 39) into save.player.kantoBadges.MARSH, the four trainer flags force-set, and the 4800G payout.
*Notes:* Doc rating: unverified/major, new. Corrections embedded: the L35 third Exeggcute is cart-correct (the FAQ is wrong); Sabrina's class item is HYPER_POTION.
### Copycat lost doll quest chain toward the rail PASS (section-20 filing): all pieces present and generic, never run
walkthrough section(s) 20 | unverified
Spans this section's maps (Copycat's house is Saffron warp 8, the doll source is the Vermilion Fan Club) though the walkthrough text defers it to after the Power Plant (EVENT_RETURNED_MACHINE_PART gates the dialogue arms). Copycat's 2F object uses variable sprite 251 (SPRITE_COPYCAT), and the new-game variablesprite seeding that used to leave Copycat despawned was fixed during the Johto campaign (tests/gen2_variable_sprites_test.lua, GOLD-WALK-HANDOFF section 5); her script (cache 61:5235) is extracted bytecode whose only special, LoadUsedSpritesGFX, is implemented (Specials.lua:1033); the Fan Club Clefairy Guy (59:437b) checks events 210/201 and gives LOST_ITEM; the doll object (flag 1907) and the PASS handover (verbosegiveitem PASS, setevent EVENT_GOT_PASS_FROM_COPYCAT per pokegold maps/CopycatsHouse2F.asm:61-73) are ordinary ops; PASS is a KEY_ITEM in items.lua. Nothing engine-shaped is missing.
*How to prove it:* Same driver as the s22 PASS quest entry: full Copycat/Fan Club round trip asserting LOST_ITEM, doll flags, and PASS.
*Notes:* Doc rating: unverified/minor, new, named in the leads. Merge-with hint: same quest chain as pass-quest-copycat-fanclub (s22); kept separate because the doc filed them as distinct headings from two section audits. This filing cites Clefairy Guy gate events 210/201 and doll flag 1907; the s22 filing cites 206/208 and doll-disappear flag 1908.
### Celadon side items: TM03 Curse night gate, Leftovers trash can, hidden PP Up (plus Berserk Gene, Route 9 Ether, Route 25 Potion) never run
walkthrough section(s) 22 | unverified
TM03 Curse: roof house pharmacist script 5e:5083 in cache with checktime mask 4 (NITE) at row 7; the Vm checktime opcode (Vm.lua:788) maps NITE to bit 4 off World:timeOfDayId, so the night-only gate should behave; the give branch 5e:509a is guarded by EVENT_GOT_TM03_CURSE 218. Leftovers: CeladonCafeTrashcan bg event at (7,1) key 5e:648c present, plain giveitem bytecode with the flag only set on success so a full pack retries, exactly the cart. Hidden PP Up at Celadon (37,21): BGEVENT_ITEM row present, handled by HiddenItems (fixed PP_UP; the walkthrough's 'random PP Up' is wrong on the cart too). Berserk Gene at Cerulean (2,12), hidden Ether Route 9 (10,5), and hidden Potion Route 25 (4,5) likewise extract. All generic and statically sound, none ever run.
*How to prove it:* Kanto run collecting each: night visit for TM03 (checktime NITE), cafe trash can Leftovers with full-pack retry, hidden-item picks via HiddenItems.
*Notes:* Doc rating: unverified/minor, new. Correction embedded: the PP Up is fixed, not random, on the cart.
### Mt. Moon Square Clefairy dance (Monday night) + hidden Moon Stone + Rock Smash rock: all dependencies present, sequence never executed
walkthrough section(s) 26 | unverified
Coord event at (7,11) scene 0 runs 5b:676a, whose 45 rows extract completely: checkflag 87 (ENGINE_MT_MOON_SQUARE_CLEFAIRY), readvar var 11 (VAR_WEEKDAY, real in the port) ifnotequal 1 (MONDAY), checktime NITE, then the appear/follow/applymovement/cry/showemote dance, clearevent 236 (the hidden Moon Stone flag) and setflag 87. Both callbacks extracted (type 5 NEWMAP 5b:6763 re-hides the stone on entry; type 2 OBJECTS 5b:6767 hides the rock). The rock object (SPRITE_ROCK at 7,7, scriptKey 5b:67ee = jumpstd SmashRockScript) resolves to the real SmashRock/AskRockSmash bodies GOLD-INDEX.md:377-382 documents; follow/stopfollow are implemented (Vm.lua:1009,1026). Flag semantics match the cart: ENGINE_MT_MOON_SQUARE_CLEFAIRY lives in wDailyFlags2 (pokegold constants/engine_flags.asm:105-106) and the port clears id 87 in Apricorns.DAILY_ENGINE_FLAGS, so the dance is once per day, i.e. once per Monday night. Unproven in-game: (a) whether the appeared rock correctly blocks/receives the A press on the Moon Stone tile at (7,7) and Rock Smash then exposes the hidden item in the same visit, and (b) the full follow-chain animation. The stubbed RockMonEncounter (CallAsm STUB_ROWS) does NOT affect this rock: MOUNT_MOON_SQUARE is not in the cart's RockMonMaps (pokegold data/wild/treemon_maps.asm:43-48), so no encounter roll is owed here.
*How to prove it:* Driver: force clockDay=MONDAY clockHour=NITE, walk to (7,11), watch the dance, smash the rock, take the Moon Stone.
*Notes:* Doc rating: unverified/minor, new.
### Rival re-encounters after Mt Moon (Indigo Plateau Mon/Wed rematch, Dragon's Den Tue/Thu cameo) never run
walkthrough section(s) 26 | unverified
Both beats are pure extracted content keyed on EVENT_BEAT_RIVAL_IN_MT_MOON (set by the implemented Mt Moon script) and VAR_WEEKDAY (real in the port; World:readVar VAR_WEEKDAY at World.lua:1289): the cache carries INDIGO_PLATEAU_POKECENTER_1F's NEWMAP callback 5a:48b6 (23 rows of setmapscene/clearevent resets) plus its two coord events, and DRAGONS_DEN_B1F's callback 47:44e1 (checkevent 793 -> disappear object 4). The daily rematch flag ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT (id 92) is in Apricorns.DAILY_ENGINE_FLAGS so it clears on the daily rollover. The rematch parties RIVAL2 members 4-6 (L45-50 with CROBAT) are in trainers.lua. Nothing looks missing, but the coord-event choreography in the Pokecenter (rival walks in as you leave) is the kind of scene worth a driver before trusting; filed unverified rather than implemented for that reason.
*How to prove it:* Driver: set EVENT_BEAT_RIVAL_IN_MT_MOON via the Mt Moon fight, force weekday Monday/Wednesday, enter the Indigo lobby and assert the rematch triggers; force Tuesday/Thursday and assert the Dragon's Den object 4 state.
*Notes:* Doc rating: unverified/minor, new. Merge-with hint: overlaps indigo-lobby-rival-rematch (s18), the same Indigo Plateau rematch filed from the section-18 audit; that filing cites World:weekday at World.lua:1247 and scriptKeys 5a:48ff/5a:4940.
### Indigo lobby rival rematch (Monday/Wednesday, RIVAL2, post Mt. Moon) armed on tiles the walkthrough crosses, never run
walkthrough section(s) 18 | unverified
Not in the walkthrough text but armed on the lobby tiles it crosses: coord events at (16,4)/(17,4) run PlateauRivalBattle1/2 (cache scriptKeys 5a:48ff/5a:4940), which fight the RIVAL2 second-tier party when EVENT_BEAT_RIVAL_IN_MT_MOON is set, ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT is clear, and VAR_WEEKDAY is Monday or Wednesday (pokegold maps/IndigoPlateauPokecenter1F.asm). Every dependency exists: coord events and scripts extracted, VAR_WEEKDAY answers the real weekday (World:weekday, World.lua:1247), the daily engine flag is registered for midnight reset (Apricorns.lua:88, id 92 ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT), and RIVAL2 parties 4-6 (SNEASEL/45, CROBAT/48, MAGNETON/45, GENGAR/46, ALAKAZAM/46, starter/50) are in the trainers cache. But the gate flag EVENT_BEAT_RIVAL_IN_MT_MOON is Kanto content no run has ever set.
*How to prove it:* Should be picked up when section 26 is audited/run: same driver as rival-rematch-mtmoon, entering the lobby on Monday/Wednesday with the Mt Moon flag set and asserting the coord-event battle fires.
*Notes:* Doc rating: unverified/minor, new. Merge-with hint: same beat as rival-rematch-mtmoon (s26); kept separate because the doc filed both headings. Line-cite difference between filings: World.lua:1247 (weekday) here vs World.lua:1289 (readVar VAR_WEEKDAY) there.
### Lighthouse descent and swim to Cianwood (route rows 08.50/08.51) never walked; collision data matches the cart, blame points at the bot's region graph
walkthrough section(s) 08 | unverified
The route bot has never completed the descent from Jasmine to Route 40, so the Surf crossing, all fourteen Route 40/41 swimmers, and Cianwood arrival are engine-unwalked (runs reach section 08+ by teleport; run 28 still failed rows 08.50/08.51). The handoff narrows it to the 3F seven-cell pocket: warps 8/9 at (8,3)/(9,3) 'do not take' when stood on. Verified in the current cache: OLIVINE_LIGHTHOUSE_3F (8,3)/(9,3) decode to COLL_FLOOR 0x00, and pokegold data/tilesets/lighthouse_collision.asm block $27 is FLOOR,FLOOR,FLOOR,FLOOR, so those two warp rows are one-way landing anchors for the 4F pits at (8,3)/(9,3) (which decode to COLL_PIT 0x60) and never fire on the cart either. The pocket's real exit is warp 3, the staircase at (9,5), which decodes to 0x72 and does fire ((13,3) also 0x72). Fix shape: correct the bot's region graph, which treats landing-anchor warp coordinates as exits (the exact trap GOLD-WALK-HANDOFF.md:593 documents), not the engine; a human following the walkthrough (drop beside Connie, fight Terrell, take the Ether, head up the stairs) uses the (9,5) staircase and should be fine.
*How to prove it:* Something must walk 6F down to CIANWOOD_CITY in the engine (bot rows 08.50/08.51 passing, or a driver doing the descent and the Route 40/41 Surf crossing); until then the second half of section 08 stays a beat-completeness check rather than a proven path.
*Notes:* Doc rating: unverified/major, already named in the ledger. The doc's own re-check corrected the suspicion of an engine defect: the cache collision matches the cart exactly, so the failing rows indict the bot's region graph, not the engine or the warp data.
### TM08 Rock Smash give (route row 06.21) flaky across runs; ledger contradicts itself and the failure is unresolved
walkthrough section(s) 06 | unverified
The Route 36 fisher gives TM08 after EVENT_FOUGHT_SUDOWOODO (Route36RockSmashGuyScript, generic extracted bytecode with verbosegiveitem guarded by iffalse .NoRoomForTM). The ledger contradicts itself across runs: GOLD-WALK-HANDOFF section 8 says 'Everything before it now passes on merit: Sudowoodo, TM08, the ROCK SMASH teach', but run 28's failure table lists '06.21 ROUTE_36 EVENT_GOT_TM08_ROCK_SMASH not set' while the Sudowoodo row itself passed. Plausible causes: full TM pocket taking the iffalse arm, or the bot failing to stand adjacent. The script path is generic and the same give shape works everywhere else, so this is most likely route flake, but it is unresolved in the latest continuous run and TM08 gates all Rock Smash content downstream (Burned Tower rocks, Route 40/Cianwood rock items).
*How to prove it:* Re-run route row 06.21 in a continuous run and assert EVENT_GOT_TM08_ROCK_SMASH gets set; instrument for TM-pocket fullness and adjacency to distinguish the two suspected causes.
*Notes:* Doc rating: unverified/minor, already named in the ledger. Not given severity unverified-risk because the problem is a reproduced-once run failure (a live flake), not purely never-executed content.
### New Bark rival push scene (talk to rival by the lab) unproven; low risk, zero coverage
walkthrough section(s) 00 | unverified
Walkthrough: 'If you talk to him, he'll push you out of the way.' NewBarkTownRivalScript is optional, repeatable (sets no flag) and uses follow PLAYER, applymovement with turn_head/step/fix_facing/jump_step, and SFX_TACKLE. Every opcode involved is implemented (follow/stopfollow in the VM, jump_step in Movement.lua which the ledge fixes exercised), and the sibling teacher-drag scene has a dedicated driver (tests/drivers/gold_teacher_scene.lua), but no driver or test touches this specific scene and the route bot never talks to optional NPCs. Cache maps.lua NEW_BARK_TOWN objects include the rival with a scriptKey and eventFlag.
*How to prove it:* A short driver that talks to the rival by the lab and observes the push movement chain, mirroring gold_teacher_scene.lua.
*Notes:* Doc rating: unverified/minor, new.
### Kiyo battle and Tyrogue gift (Mount Mortar B1F): fully wired, never exercised by any driver or bot run
walkthrough section(s) 15 | unverified
The game's only Tyrogue. Cache scripts['46:5eec'] holds all 29 rows of MountMortarB1FKiyoScript (checkevent 97 / 1193, winlosstext, loadtrainer class BLACKBELT_T member 6, startbattle, reloadmapafterbattle, setevent 1193, readvar var=1 VAR_PARTYCOUNT, ifequal 6, givepoke species 236 level 10, setevent 97); trainers cache has BLACKBELT_T[6] KIYO = L34 HITMONLEE + L34 HITMONCHAN (KIYO object at 13,4 with scriptKey 46:5eec); readvar VAR_PARTYCOUNT is a real read (World.lua:1271); givepoke is implemented at Vm.lua:443. The full dependency chain has never run: Surf across the 1F Outside lake, the in-cave Waterfall climb to warp 4 at (17,5), the 2F/1F-inside/B1F warp descent, the battle, and the gift. The waterfall climb mechanic itself is bot-verified only at Tohjo Falls; route.lua contains no MORTAR/KIYO/DARK_CAVE rows.
*How to prove it:* A Mount Mortar driver: Surf the outside lake, Waterfall to warp 4 (17,5), descend to B1F, beat Kiyo, assert Tyrogue (species 236, L10) given with a party of <6 and events 97/1193 set.
*Notes:* Doc rating: unverified/major, new. Correction embedded: GOLD-INDEX's claim that only VAR_WEEKDAY and VAR_FACING answer readvar is STALE; World.lua:1265-1330 now answers essentially every VAR_*.
### Blackglasses pharmacist in Dark Cave Blackthorn entrance extracted exactly, never run (same for TM13 Snore and Revive balls)
walkthrough section(s) 15 | unverified
The script is fully extracted (cache scripts['47:436c'], 13 rows matching pokegold maps/DarkCaveBlackthornEntrance.asm exactly: checkevent / verbosegiveitem / iffalse pack-full arm / setevent), the spinning NPC object (SPRITE_PHARMACIST) is in the cache at (7,3) with that scriptKey, and verbosegiveitem is implemented in the VM. Reaching him also needs Flash plus Surf inside the cave, both implemented. The same unverified state covers the TM13 Snore and Revive item balls beside him (item ball arm is implemented and tested). No driver enters Dark Cave and the bot route bypasses it (grep DARK_CAVE in route.lua is empty).
*How to prove it:* A Dark Cave driver: Flash + Surf to the Blackthorn entrance, talk to the pharmacist (assert Blackglasses given), pick up TM13 Snore and the Revives.
*Notes:* Doc rating: unverified/minor, new.
### Master Ball from Elm after eighth badge: route rows exist but optional, no run log confirms the event ever set
walkthrough section(s) 16 | unverified
ProfElmScript's ElmCheckMasterBall gate is checkflag ENGINE_RISINGBADGE then verbosegiveitem MASTER_BALL then setevent EVENT_GOT_MASTER_BALL_FROM_ELM (pokegold maps/ElmsLab.asm:57-61). All generic extracted bytecode; checkflag over ENGINE_* badges works since the badge-store fix (GOLD-WALK-HANDOFF.md:502, badges routed into save.player.badges). The route bot has rows 16.24/16.24b for this beat but both are marked optional = true, and no run log or handoff note confirms EVENT_GOT_MASTER_BALL_FROM_ELM was ever actually set in a live run (no MASTER hits in GOLD-NEXT-RUN.md run logs). A full bag silently skips the setevent (verbosegiveitem iffalse), same as the cart.
*How to prove it:* Make route rows 16.24/16.24b non-optional (or run them) and assert EVENT_GOT_MASTER_BALL_FROM_ELM set with bag space available.
*Notes:* Doc rating: unverified/major, new.
### Everstone from Elm by showing a self-hatched Togepi: pieces individually proven, full chain never exercised end to end
walkthrough section(s) 16 | unverified
The gating special FindPartyMonThatSpeciesYourTrainerID is a real handler with the own-OT check (Specials.lua:1134), and the phone-call prerequisite is NOT required: pokegold maps/ElmsLab.asm ElmCheckTogepiEgg -> EVENT_TOGEPI_HATCHED -> ElmEggHatchedScript re-runs the same species scan, so a hatched Togepi in the party is sufficient (maps/ElmsLab.asm:62-95, engine/phone/scripts/elm.asm:46). Egg hatching has a driver (tests/drivers/gold_egg_hatch.lua) and PC withdraw exists (BoxMenu.lua / PcMenu.lua). The full chain (hatch with your OT, withdraw, talk, EVENT_SHOWED_TOGEPI_TO_ELM, verbosegiveitem EVERSTONE) has never run end to end. The .egghatched arm of ElmPhoneCalleeScript (sets EVENT_TOLD_ELM_ABOUT_TOGEPI_OVER_THE_PHONE) lives in the bank $41 callee scripts and only matters for the alternate entry path. No route row exists for the Everstone.
*How to prove it:* A driver chaining gold_egg_hatch-style hatching (own OT), PC withdraw, and the Elm talk, asserting EVENT_SHOWED_TOGEPI_TO_ELM and EVERSTONE in bag.
*Notes:* Doc rating: unverified/minor, new.
### TM37 Sandstorm happiness gate at the Route 27 house never exercised; gate is first-slot happiness >= 150
walkthrough section(s) 16 | unverified
The granny's script is extracted (cache scripts['60:6352'], 9 rows: checkevent, special id 88, ifgreater, sjump into the loyal/disloyal arms) and special id 88 resolves through constants.specialOrder with the VM's order[id+1] rule (Vm.lua:1773) to specialOrder[89] = GetFirstPokemonHappiness, a real handler at Specials.lua:1506 reading the first non-egg party member. The cart gate is happiness >= 150 on the FIRST party slot (ifgreater 150-1), not 'one of your Pokemon'. Happiness accrues via Happiness.lua stepped from StepEvents. No driver or bot row enters ROUTE_27_SANDSTORM_HOUSE.
*How to prove it:* Driver: enter ROUTE_27_SANDSTORM_HOUSE with a first-slot mon at happiness >= 150 and assert TM37 given; also assert the disloyal arm below 150.
*Notes:* Doc rating: unverified/minor, new.
### Route 27 whirlpool island (Bird Keeper Jose, TM22 Solarbeam, Rare Candy) and Tohjo Falls Moon Stone never run; whirlpool mechanic only proven on Route 41
walkthrough section(s) 16 | unverified
The whirlpool mechanic is implemented and driver-verified, but on Route 41, not here: tests/drivers/gold_water_moves.lua:105-124 clears a real whirlpool block with GLACIERBADGE gating and asserts the block replacement. The Route 27 island content is all in the cache (TM_SOLARBEAM ball at (60,12) item id 213 with event flag, Rare Candy ball at (53,12), BIRD_KEEPER[14] JOSE = L35 FARFETCH_D with phone contact rows; maps.ROUTE_27 objects rows 6-8) and the item ball arm is implemented, but the bot route crosses Route 27 only via Tohjo Falls and never surfs south past Gilbert (route rows 16.42-16.43 skip the south water), so this specific whirlpool, the Jose fight, and the TM22 pickup have never run. Same for the Tohjo Falls MOON_STONE ball at (2,6) (maps.TOHJO_FALLS object 1), which the walkthrough itself never mentions.
*How to prove it:* Driver or route rows: surf south past Gilbert, clear the Route 27 whirlpool, fight Jose, collect TM22 and the Rare Candy; separately pick up the Tohjo Falls Moon Stone.
*Notes:* Doc rating: unverified/minor, new.
### Route 26 heal house free heal extracted with all specials real, never entered
walkthrough section(s) 16 | unverified
The teacher's script is extracted (cache scripts['60:60e6'], 17 rows) and every special id in it resolves via order[id+1] to a real handler: 47 FadeOutToBlack, 50 ReloadSpritesNoPalettes, 27 HealParty, 49 FadeInFromBlack, 60 RestartMapMusic (Specials.lua:450, :999, :1025, and the fade-in/restart pair at :1059). Unconditional and repeatable like the cart. No driver or bot row ever enters ROUTE_26_HEAL_HOUSE; the bot route's Route 26 leg is a single travel row straight to the gate.
*How to prove it:* Driver: enter ROUTE_26_HEAL_HOUSE with a damaged party, talk, assert full heal and that the scene is repeatable.
*Notes:* Doc rating: unverified/minor, new.
### Victory Road item pickups (TM26 Earthquake pit, Full Restore shelf, Max Revive, Full Heal, X Special, two hidden items) never exercised
walkthrough section(s) 17 | unverified
The walkthrough collects five Poke Ball items (TM26 Earthquake via the one-way pit at (0,11), Full Restore on the ledge shelf via the (17,19) ladder, Max Revive and Full Heal in the entrance region, X Special on the HOP_LEFT column) plus two hidden items (Max Potion at (3,29), Full Heal at (3,65)). All pieces exist: cache maps.VICTORY_ROAD carries 6 objects (5 with itemball item/qty payloads), 2 bgEvents with hiddenItem tables, 10 warps, 2 coord events; World:interact consumes obj.itemball (World.lua:5483-5490, an earlier bot-found fix); HiddenItems handles BGEVENT_ITEM; Permissions models the COLL_PIT one-way warp, the six ladders, ledge hops (isLedge/ledgeFacings, Gold's own direction order where $a0 is HOP_RIGHT), and COLL_UP_WALL side walls (Permissions.lua:170-260 LEDGE_FACINGS/SIDE_BLOCKS/NEIGHBOR_ARM), with a test section at tests/gen2_world_test.lua:2936. But nothing has walked these routes: the bot's section 17 is only travel/walk rows 17.2, 17.11, 17.15 (no pickup rows), so the pit drop, the two HOP exits from the TM pocket, the HOP_DOWN off the shelf, and the X Special ball sitting ON a HOP_LEFT tile (the one placement the asm-walk flags as needing hardware confirmation) are all unproven end to end.
*How to prove it:* Driver or route rows through Victory Road collecting all five balls and both hidden items, specifically exercising the (0,11) pit drop, the (17,19) ladder shelf with HOP_DOWN exit, the two HOP exits from the TM pocket, and interaction with the X Special ball on its HOP_LEFT tile.
*Notes:* Doc rating: unverified/minor, new.
+1 -1
View File
@@ -242,7 +242,7 @@ in [docs/linux-arm64-build.md](docs/linux-arm64-build.md).
## iOS
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
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).
+366
View File
@@ -0,0 +1,366 @@
#!/usr/bin/env bash
# Build a PortMaster aarch64 port of gen1recomp for Linux ARM SBC handhelds.
# The package uses PortMaster control hooks and a self-contained LÖVE runtime,
# while keeping paths relative to the launcher for broad CFW compatibility.
#
# The launcher uses SHDIR-relative paths and bundles the LÖVE 11.5 aarch64
# runtime so the device does not need a separate runtime download on first launch.
#
# Usage:
# ./build-linux-arm-sbc.sh [--version X.Y.Z]
# GEN1RECOMP_SOURCE_DIR="$PWD" ./build-linux-arm-sbc.sh --version X.Y.Z
# ./build-linux-arm-sbc.sh --source /path/to/gen1recomp --version X.Y.Z
#
# Output:
# dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip
#
# Install on device:
# 1. Install PortMaster for the handheld firmware.
# 2. Unzip into the device's PortMaster ports folder so you have:
# Roms/Ports (PORTS)/gen1recomp-sbc.sh
# Roms/Ports (PORTS)/gen1recomp-sbc/...
# 3. Copy a legal US Red or Blue .gb into Roms/Ports (PORTS)/gen1recomp-sbc/lovegame/
# 4. Launch "gen1recomp-sbc" from the Ports list; press Choose ROM (scans that
# folder when zenity is missing).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
HERE="$ROOT/.bazinga"
CACHE="$HERE/cache/linux-arm-sbc"
WORK="$HERE/work/linux-arm-sbc"
DIST="$ROOT/dist/linux-arm-sbc"
APP_NAME="gen1recomp-sbc"
# Artifact suffix identifies this as the generic PortMaster SBC package.
# Release uploads stage it as gen1recomp-<ver>-sbc-portmaster.zip.
ARTIFACT_SUFFIX="portmaster"
PORT_DIR_NAME="gen1recomp-sbc"
LAUNCHER_NAME="gen1recomp-sbc.sh"
LOVE_VERSION="11.5"
# By default the pack is reproducible from the latest published GitHub release,
# not whatever happens to be in the caller's checkout. Development builds can
# point this at a local checkout with GEN1RECOMP_SOURCE_DIR=/path/to/repo.
SOURCE_DIR_OVERRIDE="${GEN1RECOMP_SOURCE_DIR:-}"
SOURCE_TAG_OVERRIDE="${GEN1RECOMP_RELEASE_TAG:-}"
VERSION="${GEN1RECOMP_VERSION:-}"
# Official PortMaster LÖVE 11.5 aarch64 runtime (small love stub + liblove).
PM_RUNTIME_BASE="https://raw.githubusercontent.com/PortsMaster/PortMaster-GUI/main/PortMaster/runtimes/love_${LOVE_VERSION}"
RELEASES_LATEST_URL="https://github.com/bryanthaboi/gen1recomp/releases/latest"
RELEASE_TARBALL_BASE="https://github.com/bryanthaboi/gen1recomp/archive/refs/tags"
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--version) [ $# -ge 2 ] || fail "--version needs X.Y.Z"; VERSION="$2"; shift ;;
--source) [ $# -ge 2 ] || fail "--source needs a directory"; SOURCE_DIR_OVERRIDE="$2"; shift ;;
--release-tag) [ $# -ge 2 ] || fail "--release-tag needs a tag"; SOURCE_TAG_OVERRIDE="$2"; shift ;;
-h|--help)
sed -n '2,24p' "$0"
exit 0
;;
*) fail "unknown argument: $1" ;;
esac
shift
done
command -v curl >/dev/null || fail "curl is required"
command -v zip >/dev/null || fail "zip is required"
command -v unzip >/dev/null || fail "unzip is required"
command -v tar >/dev/null || fail "tar is required"
mkdir -p "$CACHE" "$WORK" "$DIST"
download() {
local url="$1" dest="$2"
if [ -f "$dest" ] && [ -s "$dest" ]; then
return 0
fi
say "downloading $(basename "$dest")"
curl -fL --progress-bar "$url" -o "$dest.tmp" \
|| fail "download failed: $url"
mv "$dest.tmp" "$dest"
}
# --------------------------------------------------------------- source + game tree
# Release builds use the latest published source archive. A local checkout is
# an explicit override for development and for CI's just-built release source.
if [ -n "$SOURCE_DIR_OVERRIDE" ]; then
SOURCE_DIR_OVERRIDE="$(cd "$SOURCE_DIR_OVERRIDE" 2>/dev/null && pwd)" \
|| fail "source directory does not exist: $SOURCE_DIR_OVERRIDE"
SOURCE_DIR="$SOURCE_DIR_OVERRIDE"
SOURCE_TAG="${SOURCE_TAG_OVERRIDE:-local}"
if [ "$SOURCE_TAG" != "local" ]; then
printf '%s' "$SOURCE_TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|| fail "release tag must look like vX.Y.Z: $SOURCE_TAG"
fi
if [ -z "$VERSION" ]; then
VERSION="$(git -C "$SOURCE_DIR" rev-parse --short HEAD 2>/dev/null || echo dev)"
fi
else
if [ -z "$SOURCE_TAG_OVERRIDE" ]; then
latest_location="$(curl -fsSI "$RELEASES_LATEST_URL" \
| awk 'tolower($1) == "location:" { print $2 }' | tail -1 | tr -d '\r')" \
|| fail "could not resolve latest published release"
SOURCE_TAG_OVERRIDE="${latest_location##*/}"
fi
printf '%s' "$SOURCE_TAG_OVERRIDE" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|| fail "release tag must look like vX.Y.Z: $SOURCE_TAG_OVERRIDE"
SOURCE_TAG="$SOURCE_TAG_OVERRIDE"
SOURCE_ARCHIVE="$CACHE/gen1recomp-${SOURCE_TAG}.tar.gz"
download "$RELEASE_TARBALL_BASE/$SOURCE_TAG.tar.gz" "$SOURCE_ARCHIVE"
SOURCE_EXTRACT="$WORK/source-$SOURCE_TAG"
rm -rf "$SOURCE_EXTRACT"
mkdir -p "$SOURCE_EXTRACT"
tar -xzf "$SOURCE_ARCHIVE" -C "$SOURCE_EXTRACT"
SOURCE_DIR="$(find "$SOURCE_EXTRACT" -mindepth 1 -maxdepth 1 -type d -print -quit)"
[ -n "$SOURCE_DIR" ] || fail "release archive had no source directory"
if [ -z "$VERSION" ]; then VERSION="${SOURCE_TAG#v}"; fi
fi
say "staging lovegame/ from $SOURCE_TAG"
GAME_SRC="$WORK/lovegame"
rm -rf "$GAME_SRC"
mkdir -p "$GAME_SRC"
# Same payload as scripts/build.sh's game.love — never ship ROM-derived cache.
# tools/save-editor is part of that payload: the launcher's Edit button on a
# save row opens it in-process (main.lua).
(cd "$SOURCE_DIR" && zip -q -9 -r "$WORK/game-payload.zip" \
main.lua conf.lua src libs data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$WORK/game-payload.zip" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "payload unexpectedly contains generated ROM data"
fi
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
rm -f "$WORK/game-payload.zip"
# Stamp release version into the staged tree only (never the working tree).
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
say "stamping engine version $VERSION"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$SOURCE_DIR/src/core/Version.lua" > "$GAME_SRC/src/core/Version.lua"
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
"$GAME_SRC/src/core/Version.lua" \
|| fail "version stamp failed"
else
say "version '$VERSION' is not X.Y.Z — shipping default engine (no stamp)"
fi
# Portable marker: saves + ROM cache live next to the game on the SD card.
: > "$GAME_SRC/portable.txt"
# --------------------------------------------------------------- love runtime
say "fetching LÖVE $LOVE_VERSION aarch64 runtime"
LOVE_BIN="$CACHE/love.aarch64"
LOVE_LIB="$CACHE/liblove-11.5.so"
LUAJIT_LIB="$CACHE/libluajit-5.1.so.2"
MODPLUG_LIB="$CACHE/libmodplug.so.1"
OGG_LIB="$CACHE/libogg.so.0"
download "$PM_RUNTIME_BASE/love.aarch64" "$LOVE_BIN"
download "$PM_RUNTIME_BASE/libs.aarch64/liblove-11.5.so" "$LOVE_LIB"
download "$PM_RUNTIME_BASE/libs.aarch64/libluajit-5.1.so.2" "$LUAJIT_LIB"
download "$PM_RUNTIME_BASE/libs.aarch64/libmodplug.so.1" "$MODPLUG_LIB"
download "$PM_RUNTIME_BASE/libs.aarch64/libogg.so.0" "$OGG_LIB"
# Sanity: love stub must be an aarch64 ELF.
file "$LOVE_BIN" | grep -qi 'aarch64\|ARM aarch64' \
|| fail "love.aarch64 does not look like an aarch64 ELF (got: $(file "$LOVE_BIN"))"
# --------------------------------------------------------------- port tree
say "assembling port package"
PORT_ROOT="$WORK/port"
rm -rf "$PORT_ROOT"
mkdir -p "$PORT_ROOT/$PORT_DIR_NAME/bin" \
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64" \
"$PORT_ROOT/$PORT_DIR_NAME/licenses" \
"$PORT_ROOT/$PORT_DIR_NAME/conf"
cp -R "$GAME_SRC" "$PORT_ROOT/$PORT_DIR_NAME/lovegame"
cp "$LOVE_BIN" "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/"
# Drop a short license pointer for the bundled LÖVE bits.
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
(https://github.com/PortsMaster/PortMaster-GUI). LÖVE is zlib-licensed;
see https://love2d.org/ for full terms.
EOF
# --------------------------------------------------------------- launcher
# Resolve the game directory from the launcher so this works with both
# PortMaster-managed ports directories.
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
#!/bin/bash
# gen1recomp-sbc — Linux ARM SBC / PortMaster launcher
# Uses SHDIR-relative paths so firmware-specific mount points do not matter.
export HOME="${HOME:-/root}"
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
SHDIR="$(cd "$(dirname "$0")" && pwd)"
if [ -d "/mnt/SDCARD/Apps/PortMaster/PortMaster/" ]; then
controlfolder="/mnt/SDCARD/Apps/PortMaster/PortMaster"
elif [ -d "/mnt/SDCARD/Roms/ports/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Roms/ports/PortMaster"
elif [ -d "/mnt/SDCARD/Data/PortMaster/" ]; then
controlfolder="/mnt/SDCARD/Data/PortMaster"
elif [ -d "$SHDIR/PortMaster" ]; then
controlfolder="$SHDIR/PortMaster"
elif [ -d "/opt/system/Tools/PortMaster/" ]; then
controlfolder="/opt/system/Tools/PortMaster"
elif [ -d "/opt/tools/PortMaster/" ]; then
controlfolder="/opt/tools/PortMaster"
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
controlfolder="$XDG_DATA_HOME/PortMaster"
elif [ -d "/roms/ports/PortMaster" ]; then
controlfolder="/roms/ports/PortMaster"
else
controlfolder="/mnt/SDCARD/Roms/PORTS/PortMaster"
fi
if [ ! -f "$controlfolder/control.txt" ]; then
echo "PortMaster control.txt not found under $controlfolder" >&2
exit 1
fi
# shellcheck disable=SC1090
source "$controlfolder/control.txt"
get_controls
if [ -n "${CFW_NAME:-}" ] && [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ]; then
# shellcheck disable=SC1090
source "${controlfolder}/mod_${CFW_NAME}.txt"
fi
GAMEDIR="$SHDIR/gen1recomp-sbc"
CONFDIR="$GAMEDIR/conf"
mkdir -p "$CONFDIR"
cd "$GAMEDIR" || exit 1
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
export XDG_DATA_HOME="$CONFDIR"
export XDG_CONFIG_HOME="$CONFDIR"
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}"
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
# GLES is the common path on ARM SBC handhelds; firmware may override it.
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
if [ -n "${GPTOKEYB:-}" ]; then
$GPTOKEYB "love.aarch64" &
fi
if type pm_platform_helper >/dev/null 2>&1; then
pm_platform_helper "$GAMEDIR/bin/love.aarch64"
fi
./bin/love.aarch64 "$GAMEDIR/lovegame"
if type pm_finish >/dev/null 2>&1; then
pm_finish
else
if [ -n "${ESUDO:-}" ]; then
$ESUDO kill -9 $(pidof gptokeyb) 2>/dev/null || true
else
kill -9 $(pidof gptokeyb) 2>/dev/null || true
fi
fi
EOF
chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
# --------------------------------------------------------------- metadata
cat > "$PORT_ROOT/port.json" <<EOF
{
"version": 2,
"name": "gen1recomp-sbc.zip",
"items": [
"$LAUNCHER_NAME",
"$PORT_DIR_NAME"
],
"items_opt": null,
"attr": {
"title": "gen1recomp-sbc",
"desc": "Native LÖVE2D recreation of Pokemon Red and Blue. Supply your own legal US Red or Blue ROM.",
"source": "https://github.com/bryanthaboi/gen1recomp/releases/tag/$SOURCE_TAG",
"inst": "Requires a 64-bit Linux ARM handheld with PortMaster. Copy a canonical US Red or Blue .gb into gen1recomp-sbc/lovegame/, then launch and press Choose ROM.",
"genres": ["adventure", "rpg"],
"porter": ["gen1recomp-sbc"],
"image": {},
"rtr": true,
"runtime": null,
"reqs": [],
"arch": ["aarch64"]
}
}
EOF
cat > "$PORT_ROOT/gameinfo.xml" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<gameList>
<game>
<path>./$LAUNCHER_NAME</path>
<name>gen1recomp-sbc</name>
<desc>Native LÖVE2D recreation of Pokemon Red and Blue. Requires your own legal US Red or Blue ROM.</desc>
<releasedate>20250101T000000</releasedate>
<developer>the bois club</developer>
<publisher>the bois club</publisher>
<genre>RPG</genre>
</game>
</gameList>
EOF
cat > "$PORT_ROOT/README.md" <<'EOF'
## gen1recomp-sbc (Linux ARM SBC / PortMaster)
Native LÖVE 11.5 aarch64 PortMaster port of gen1recomp for compatible Linux ARM SBC handhelds, including H700-class devices. This pack was built from source release **__SOURCE_TAG__**.
### Install
1. Install PortMaster for your handheld firmware.
2. Unzip so `gen1recomp-sbc.sh` and the `gen1recomp-sbc/` folder are siblings in the device's PortMaster ports directory.
3. Copy a legal US Pokémon Red or Blue `.gb` into `gen1recomp-sbc/lovegame/`.
4. Refresh the launcher and launch **gen1recomp-sbc** from Ports.
### Controls
| Input | Action |
|--|--|
| D-pad | Move cursor |
| A | Click |
| L1 / R1 | Switch tabs |
| Start / Select | Play or choose ROM |
Controls use the normal PortMaster / SDL pad map. Device-specific power/suspend behavior is supplied by the firmware and PortMaster runtime.
### First run
Put the `.gb` in `lovegame/`, then press **Choose ROM**. After import, the ROM-derived cache and saves stay beside the game (`portable.txt`).
### Thanks
LÖVE runtime binaries from [PortMaster](https://portmaster.games/). PortMaster device support and runtime integration are maintained by the PortMaster team.
EOF
sed -i.bak "s/__SOURCE_TAG__/$SOURCE_TAG/g" "$PORT_ROOT/README.md"
rm -f "$PORT_ROOT/README.md.bak"
# --------------------------------------------------------------- zip
ZIP_OUT="$DIST/$APP_NAME-$ARTIFACT_SUFFIX.zip"
rm -f "$ZIP_OUT"
say "packing $ZIP_OUT"
(cd "$PORT_ROOT" && zip -q -9 -r "$ZIP_OUT" \
"$LAUNCHER_NAME" "$PORT_DIR_NAME" port.json gameinfo.xml README.md)
say "done."
say "artifact: $ZIP_OUT ($(du -h "$ZIP_OUT" | cut -f1))"
say "copy into the device PortMaster ports folder, then drop your .gb into gen1recomp-sbc/lovegame/"
+7 -4
View File
@@ -91,11 +91,14 @@ mkdir -p "$GAME_SRC"
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
main.lua conf.lua src libs data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$WORK/game-payload.zip" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "payload unexpectedly contains generated ROM data"
fi
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
printf '%s\n' "$payload_list" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' \
&& fail "payload unexpectedly contains generated ROM data"
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|| fail "payload is missing tools/rom_manifest_gold.json"
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
rm -f "$WORK/game-payload.zip"
+2 -2
View File
@@ -1,6 +1,6 @@
# Sideload the iOS build with AltStore
Every GitHub Release ships an IPA (`gen1recomp-*-ios.ipa`). Install it on
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.
@@ -23,7 +23,7 @@ Stuck? Start here:
## 2. Install the game
1. Download `gen1recomp-*-ios.ipa` from
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).
+54
View File
@@ -0,0 +1,54 @@
# Linux ARM SBC Handhelds (PortMaster)
Download `gen1recomp-*-sbc-portmaster.zip` from the [Gen1Recomp releases](https://github.com/bryanthaboi/gen1recomp/releases). This build targets 64-bit Linux ARM handhelds with PortMaster, including compatible H700 devices.
## Install
1. Unzip the release. It contains `gen1recomp-sbc.sh` and a `gen1recomp-sbc/` folder.
2. Copy both as siblings into your device's PortMaster ports directory, commonly `Roms/Ports (PORTS)/` or `Roms/PORTS/`.
3. Install PortMaster for your firmware and refresh the Ports list.
4. Copy your legally owned canonical US Red or Blue `.gb` file into `gen1recomp-sbc/lovegame/`.
5. Launch **gen1recomp-sbc** from Ports and choose the ROM.
The pack includes `portable.txt`, so saves and ROM-derived cache remain beside the game on the SD card. The build never ships ROM-derived bytes.
Canonical US cart SHA-1 values:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
## Controls
| Input | Action |
| --- | --- |
| D-pad | Move cursor |
| A | Click / confirm |
| L1 / R1 | Switch tabs |
| Start / Select | Play or choose ROM |
In-game controls use the normal PortMaster/SDL mapping and can be rebound in **OPTIONS → CONTROLS**.
## Runtime and suspend
The package bundles PortMaster's LÖVE 11.5 aarch64 runtime. The launcher sources `control.txt`, calls `get_controls`, applies an optional CFW override, invokes `pm_platform_helper`, and calls `pm_finish` on exit. Paths are relative to the launcher, allowing different firmware mount points.
Suspend/resume uses the existing LÖVE focus/visibility lifecycle: input is reset on focus loss and the game resumes when the window becomes visible again. Exact power-button behavior remains firmware-dependent; hardware validation has been performed on the TrimUI Brick, not every SBC or H700 device.
## Building
Release workflows build this automatically. Standalone builds resolve the latest published Gen1Recomp release by default:
```sh
./build-linux-arm-sbc.sh --version 0.1.75
```
For development, package a local checkout explicitly:
```sh
GEN1RECOMP_SOURCE_DIR="$PWD" ./build-linux-arm-sbc.sh --version 0.1.0
# or: ./build-linux-arm-sbc.sh --source "$PWD" --version 0.1.0
```
The generated `port.json` records the source release tag. `install-linux-arm-sbc.sh` is a macOS helper for copying a built pack to a mounted SD card.
PortMaster device support and runtime integration are maintained in the [PortMaster](https://github.com/PortsMaster/PortMaster-New) ecosystem.
+33 -3
View File
@@ -340,6 +340,18 @@ failure as a failed first checkpoint rather than claiming restart safety.
See RFC 0003, RFC 0004, RFC 0005, and RFC 0006 for exact contracts and error
codes.
At that same settled supported wild/trainer decision boundary, a tool may claim
START through `battle.menu_auxiliary`. It receives `(next, game, context)`, where
`context` is the data-only `{ kind = "wild" }` or `{ kind = "trainer" }`; it
never receives the live battle controller. Return `true` to consume START after
opening source-owned UI, or call `next(game, context)` to allow lower-priority
handlers. With no handler, START remains inert. Ordinary encounters and the
validated built-in scripted battle origins described by RFC 0005 are eligible;
opaque scripts, link/Safari/ghost/demo battles, action queues,
animation/messages, forced choices, and every phase that cannot safely be
checkpointed remain excluded. Exceptions are contained by normal hook isolation
and fall through without advancing a turn.
## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload
@@ -442,9 +454,12 @@ identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently
control the battle text/menu layer and the HP/status panels. Both receive
`(next, state)` and default to `true`, so vanilla rendering is unchanged.
Pushed text boxes also pass through `battle.bottom_ui_visible`; a wrapper that
only owns battle presentation should return `false` only for its active battle
or text-box state.
Text boxes and YES/NO prompts pushed above a battle inherit a `false` result
for that battle, so hiding the bottom layer cannot leave their white backing
behind under another overlay. Text boxes also pass through the hook as their
own state, preserving selective control outside a battle; a wrapper that only
owns battle presentation should return `false` only for its active battle or
text-box state.
`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
@@ -495,6 +510,21 @@ Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call`
already falls straight through to the vanilla function when no mod has
wrapped the name, at negligible cost.
## Detached Pokémon icon presentation
`mod.ui.PokemonIcon.draw(game, summary, x, y, opts)` draws the same party icon
the native Party menu would resolve without exposing a live Pokémon record or
the private Party menu. `summary` is the detached data-only shape
`{ species = string, hp = integer, maxHp = integer }`; `opts.selected` and
`opts.counter` optionally request the native selected-icon animation phase.
The engine retains icon ownership. Content registered through
`mod.content.icons`, species `icon` definitions, asset overrides, and the
public `pokemon.icon` hook therefore continue to compose. Invalid summaries
return `false, code, message` and draw nothing. The helper is presentation
only: it does not expose moves, status, checkpoint payloads, or mutable party
state.
## Shared date and time presentation
The global Options menu owns `DATE FORMAT` (`DEVICE`, `DD-MM-YYYY`,
+1 -3
View File
@@ -27,8 +27,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered. Port extras beyond the cartridge:
* **Separate Gold save file** beside the Gen 1 ones
* **COLOR, zoom, tilt, GBC FX, and quick save/load** on the same keys as Gen 1
* **COLOR, zoom, tilt, GBC FX, and quick save/load**
* **UI that stays fixed while the overworld zooms**
* **Border-block surrounds** for maps smaller than the screen
* **Gold-specific launcher options**
@@ -39,5 +38,4 @@ A fourth game the launcher can import and play, built from pret/pokegold the sam
* **Followers** for mods, plus Gen 2-only registries and hooks
* **On-screen touch pad** and controller SELECT for registered items
Actual approximations, and missing original behavior are documented separately in `docs/known-differences.md`.
@@ -0,0 +1,46 @@
# RFC 0007: Battle menu auxiliary actions
## Status
Proposed.
## Problem
Tool mods can inspect/capture a persistent checkpoint only at a settled
ordinary wild/trainer player-decision boundary. Before this proposal, that
boundary had no public semantic input/action seam: `BattleState` consumed the
command loop directly. A mod could reach it only through private battle/input
internals, which would be unsafe and incompatible with controller/touch input.
## Contract
`mod.hooks:wrap("battle.menu_auxiliary", callback)` is called only when START
is pressed at the existing checkpoint-safe player-decision boundary. The
callback signature is:
```lua
function callback(next, game, context)
-- context is { kind = "wild" } or { kind = "trainer" }
-- return true after claiming START, otherwise return next(game, context)
end
```
The context is data-only. No live battle controller, input object, serializer,
or restoration primitive is exposed. A `true` result consumes START for that
fixed step without selecting a battle command. With no installed handler,
START is inert exactly as before. Hook priorities and error isolation are the
existing generic wrapper semantics: a throwing handler is skipped and cannot
advance battle state.
The engine reuses the same internal safety predicate as battle checkpoint
capture. Link, Safari, ghost/demo, unsupported origins, scripts, queues,
animations, messages, forced replacement/locked actions, and unsettled HP or
status presentation never invoke the hook.
## Compatibility and verification
The call is additive and no-op with no handler. ROM-free engine tests prove
wild/trainer delivery, cursor/turn preservation, and unsafe-phase refusal;
the mod-SDK fixture proves a loaded mod can consume the semantic action using
only its public hook facade. `gate_hooks` automatically includes the new call
site in no-mod parity coverage.
+12 -1
View File
@@ -26,7 +26,7 @@ JSON parsing, and sha256 verification run on a background `love.thread`
## Version.lua fields
`src/core/Version.lua` carries three fields the updater reads directly (the
`src/core/Version.lua` carries four fields the updater reads directly (the
existing `modApi`, `linkProtocol`, `saveFormat`, and `cache` fields are
untouched):
@@ -37,6 +37,11 @@ untouched):
as a valid payload to chainload).
- `shell` - the native-shell contract this build's fused executable
implements.
- `payloadHost` - the native host family an in-place payload targets. Ordinary
LÖVE packages use `"love"`. A specialized native package uses a distinct,
stable identifier and accepts only payloads carrying that same identifier.
A missing field defaults to `"love"`, preserving compatibility with payloads
released before this field existed.
- `minShell` - the lowest shell contract required to *run* this payload.
Bump `minShell` only when a payload needs something the currently-shipped
@@ -49,6 +54,12 @@ rather than deleting it, in case a future shell upgrade can run it, and
installer instead. Do not bump `minShell` for an ordinary Lua/data release;
that is exactly the case the updater exists to avoid a reinstall for.
Change `payloadHost` only when the packaged Lua depends on a different native
host family. This is separate from `minShell`: the host name answers *which*
native integration the payload targets, while the shell number answers *which
revision* of that integration it requires. A mismatched-host payload is never
mounted or deleted as stale; the launcher directs the player to a full package.
## Release assets
Each tagged release `vX.Y.Z` carries the existing per-platform archives
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# After first boot of a compatible Linux ARM handheld (or when PortMaster is installed), reinsert the
# SD card and run this to install gen1recomp-sbc + Red/Blue ROMs into Roms/PORTS.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
STAGE="$ROOT/.bazinga/work/linux-arm-sbc-install"
DECPREP="${DECPREP:-$ROOT/../decprep}"
ZIP="$ROOT/dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip"
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
# Find a mounted handheld userdata volume with a ROMs or Apps directory.
find_roms_root() {
local v candidate
for v in /Volumes/*; do
[ -d "$v" ] || continue
# Prefer a volume that already has Roms/ or Apps/
if [ -d "$v/Roms" ] || [ -d "$v/roms" ] || [ -d "$v/PORTS" ] || [ -d "$v/ports" ] || [ -d "$v/Apps" ]; then
echo "$v"
return 0
fi
done
# Fallback: common removable-volume labels
for v in /Volumes/SDCARD /Volumes/sdcard /Volumes/NO\ NAME /Volumes/ROMS; do
if [ -d "$v" ]; then
echo "$v"
return 0
fi
done
return 1
}
say "looking for handheld SD volume"
ROMS_ROOT="$(find_roms_root)" || fail "no SD volume mounted. boot the handheld once, power it off, reinsert the SD, then rerun."
say "using: $ROMS_ROOT"
# Resolve the device PortMaster ports directory
if [ -d "$ROMS_ROOT/Roms/PORTS" ]; then
PORTS="$ROMS_ROOT/Roms/PORTS"
elif [ -d "$ROMS_ROOT/roms/PORTS" ]; then
PORTS="$ROMS_ROOT/roms/PORTS"
elif [ -d "$ROMS_ROOT/Roms/ports" ]; then
PORTS="$ROMS_ROOT/Roms/ports"
elif [ -d "$ROMS_ROOT/PORTS" ]; then
PORTS="$ROMS_ROOT/PORTS"
else
mkdir -p "$ROMS_ROOT/Roms/PORTS"
PORTS="$ROMS_ROOT/Roms/PORTS"
fi
say "PORTS: $PORTS"
# Refresh staged payload
mkdir -p "$STAGE/PORTS"
if [ -f "$ZIP" ]; then
rm -rf "$STAGE/PORTS/gen1recomp-sbc.sh" "$STAGE/PORTS/gen1recomp-sbc" "$STAGE/PORTS/port.json" \
"$STAGE/PORTS/gameinfo.xml" "$STAGE/PORTS/README.md"
unzip -q -o "$ZIP" -d "$STAGE/PORTS"
else
fail "missing $ZIP — run ./build-linux-arm-sbc.sh first"
fi
# Ensure ROMs are in lovegame (Choose ROM scans this folder on minimal images)
[ -f "$DECPREP/Pokemon - Red Version.gb" ] || fail "missing Red ROM in $DECPREP"
[ -f "$DECPREP/Pokemon - Blue Version.gb" ] || fail "missing Blue ROM in $DECPREP"
cp -f "$DECPREP/Pokemon - Red Version.gb" "$STAGE/PORTS/gen1recomp-sbc/lovegame/"
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$STAGE/PORTS/gen1recomp-sbc/lovegame/"
say "copying gen1recomp port"
rm -rf "$PORTS/gen1recomp-sbc" "$PORTS/gen1recomp-sbc.sh"
cp -R "$STAGE/PORTS/gen1recomp-sbc" "$PORTS/"
cp -f "$STAGE/PORTS/gen1recomp-sbc.sh" "$PORTS/"
cp -f "$STAGE/PORTS/port.json" "$PORTS/"
cp -f "$STAGE/PORTS/README.md" "$PORTS/"
chmod +x "$PORTS/gen1recomp-sbc.sh" "$PORTS/gen1recomp-sbc/bin/love.aarch64"
# Also drop carts in the stock GB folder for the emulator library
GB_DIR=""
for candidate in "$ROMS_ROOT/Roms/GB" "$ROMS_ROOT/roms/GB" "$ROMS_ROOT/Roms/gb"; do
if [ -d "$candidate" ]; then GB_DIR="$candidate"; break; fi
done
if [ -n "$GB_DIR" ]; then
say "copying .gb into $GB_DIR"
cp -f "$DECPREP/Pokemon - Red Version.gb" "$GB_DIR/"
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$GB_DIR/"
fi
sync
say "installed:"
ls -lh "$PORTS/gen1recomp-sbc.sh"
ls -lh "$PORTS/gen1recomp-sbc/lovegame/"*.gb
say "eject the SD, insert it in the handheld, open Ports → gen1recomp-sbc, Choose ROM."
+29 -5
View File
@@ -14,6 +14,7 @@ local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
local LaunchOptions = require("src.core.LaunchOptions")
local NxDisplay = require("src.core.NxDisplay")
local PlatformHooks = require("src.core.PlatformHooks")
local HostDisplay = require("src.core.HostDisplay")
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
do
@@ -197,14 +198,19 @@ end
local touchEditorHost
local closeTouchControlsEditor -- forward declaration
local function openTouchControlsEditor()
-- `version` is the launcher tab the gear was opened on, and it decides which
-- option block the layout lands in (src/ui/TouchControlsEditor.lua persist).
local function openTouchControlsEditor(version)
touchEditorHost = Importer
if Importer and Importer.prepareOverlayHandoff then
Importer:prepareOverlayHandoff()
end
Importer = nil
TouchEditor = require("src.ui.TouchControlsEditor")
TouchEditor.load({ onClose = function() closeTouchControlsEditor() end })
TouchEditor.load({
version = version,
onClose = function() closeTouchControlsEditor() end,
})
end
function closeTouchControlsEditor()
@@ -425,6 +431,7 @@ function love.load(args)
end
function love.update(dt)
HostDisplay.update(dt)
SwitchDiagnostics.maybeFlush(false)
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
NxDisplay.sync()
@@ -472,11 +479,27 @@ function love.update(dt)
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
if editorMode then
HostDisplay.beginFrame("editor", EditorApp)
local result = EditorApp.draw()
HostDisplay.endFrame("editor", EditorApp)
return result
end
if TouchEditor then
HostDisplay.beginFrame("touch_editor", TouchEditor)
local result = TouchEditor.draw()
HostDisplay.endFrame("touch_editor", TouchEditor)
return result
end
if Importer then
HostDisplay.beginFrame("launcher", Importer)
local result = Importer:draw()
HostDisplay.endFrame("launcher", Importer)
return result
end
if not Game then return end
HostDisplay.beginFrame("game", Game)
Game:draw()
-- frame capture requested by a driver
if Game.capturePath then
@@ -491,6 +514,7 @@ function love.draw()
end
end)
end
HostDisplay.endFrame("game", Game)
end
function love.keypressed(key, scancode, isrepeat)
@@ -143,16 +143,36 @@ public class GameActivity extends SDLActivity {
private static native void nativeSetDefaultStreamValues(int sampleRate, int framesPerBurst);
/**
* Native libraries required by an optional Android host extension.
*
* Subclasses supplied by another product flavor may override this method.
* The libraries are loaded after LÖVE's dependencies and before liblove;
* liblove must remain last because SDL treats the final entry as the main
* shared object.
*/
protected String[] getHostLibraries() {
return new String[0];
}
@Override
protected String[] getLibraries() {
return new String[] {
"c++_shared",
"mpg123",
"openal",
"love",
};
String[] hostLibraries = getHostLibraries();
String[] libraries = new String[hostLibraries.length + 4];
libraries[0] = "c++_shared";
libraries[1] = "mpg123";
libraries[2] = "openal";
System.arraycopy(hostLibraries, 0, libraries, 3, hostLibraries.length);
libraries[libraries.length - 1] = "love";
return libraries;
}
protected void onHostCreateBeforeSDL(Bundle savedInstanceState) {}
protected void onHostCreateAfterSDL(Bundle savedInstanceState) {}
protected void onHostResume() {}
protected void onHostPause() {}
protected void onHostDestroy() {}
@Override
protected String getMainSharedObject() {
String[] libs = getLibraries();
@@ -192,7 +212,9 @@ public class GameActivity extends SDLActivity {
intent.setData(null);
}
onHostCreateBeforeSDL(savedInstanceState);
super.onCreate(savedInstanceState);
onHostCreateAfterSDL(savedInstanceState);
if (savedInstanceState != null) {
// Restore the in-flight SAF destinations, so a pick that returns to
// a recreated activity still lands under the basename it asked for.
@@ -341,6 +363,7 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel();
}
onHostDestroy();
super.onDestroy();
}
@@ -351,12 +374,14 @@ public class GameActivity extends SDLActivity {
vibrator.cancel();
}
teardownSecondaryDisplay();
onHostPause();
super.onPause();
}
@Override
public void onResume() {
super.onResume();
onHostResume();
setupSecondaryDisplay();
}
+9 -9
View File
@@ -7,7 +7,7 @@ an iOS app with LÖVE 12.0.
The app uses the public iOS Documents directory as its LÖVE save directory.
There is no `pokemon-love2d` subdirectory and the app does not create a
README file there. When browsing `On My iPhone > gen1recomp` in Files, the
README file there. When browsing `On My iPhone > gen1recomp++` in Files, the
directory contains the app's runtime data directly, including:
- installed mods and downloaded ROMs
@@ -46,7 +46,7 @@ scripts/build_ios.sh --device --release --install
Device builds require a paired, unlocked device and a valid Apple signing
identity. Set `DEVELOPMENT_TEAM` or `CODE_SIGN_IDENTITY` when automatic
signing cannot select the intended account. Add `--ipa` to create
`dist/ios/gen1recomp.ipa`.
`dist/ios/gen1recomp++.ipa`.
The script verifies the final app before packaging it:
@@ -76,10 +76,10 @@ option.
Simulator and device app bundles are copied to:
```text
dist/ios/Debug-iphonesimulator/gen1recomp.app
dist/ios/Release-iphonesimulator/gen1recomp.app
dist/ios/Debug-iphoneos/gen1recomp.app
dist/ios/Release-iphoneos/gen1recomp.app
dist/ios/Debug-iphonesimulator/gen1recomp++.app
dist/ios/Release-iphonesimulator/gen1recomp++.app
dist/ios/Debug-iphoneos/gen1recomp++.app
dist/ios/Release-iphoneos/gen1recomp++.app
```
The intermediate Xcode products are under `mobile/ios/build/`. Both locations
@@ -94,9 +94,9 @@ saves; those are created at runtime in Documents.
| Field | Default |
| --- | --- |
| Display name | `gen1recomp` |
| Product name | `gen1recomp` |
| Bundle identifier | `com.theboisclub.gen1recomp` |
| Display name | `gen1recomp++` |
| Product name | `gen1recomp++` |
| Bundle identifier | `com.theboisclub.gen1recompplusplus` |
| Save directory | Public `Documents` root |
| Orientation | Portrait |
+25 -4
View File
@@ -1,17 +1,38 @@
{
"name": "gen1recomp App Repo",
"identifier": "com.theboisclub.gen1recomp.repo",
"name": "gen1recomp++ App Repo",
"identifier": "com.theboisclub.gen1recompplusplus.repo",
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png",
"apps": [
{
"name": "gen1recomp",
"bundleIdentifier": "com.theboisclub.gen1recomp",
"name": "gen1recomp++",
"bundleIdentifier": "com.theboisclub.gen1recompplusplus",
"developerName": "bryanthaboi",
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png",
"localizedDescription": "Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke",
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.1.81",
"date": "2026-08-13",
"size": 9886854,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.81/gen1recomp++-0.1.81-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1091 Encounter Rate is tied to Game Speed\n- #1097 Phase through walls exiting a cave\n- #1100 Allow for control edit and vibration toggle on gold\n- #1104 wrong warp\n- #1135 update for iphone - haptic vibrations\n- #1155 Magnitude always rolls a 4\n- #1157 Sonic Boom doesn't deal set damage\n- #1158 Unable to view opponent's next Pokemon\n- #1159 Dig animation is bugged\n- #1160 64x64 battle sprite followup for mods\n- #1167 [Gold] items in ilex forest missing\n- #1208 Gold Save game not showing properly in LAUNCHER after having multiple saves\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian\n- @MaxTomahawk\n- @Yukitty\n- Codex Agent"
},
{
"version": "0.1.80",
"date": "2026-08-12",
"size": 10964568,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.80/gen1recomp-0.1.80-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1100 Allow for control edit and vibration toggle on gold\n- #1107 [Gold] When playing without a save slot in the launcher, no save will be created at all when saving ingame\n- #1112 [Gold] In the shop when buying items and pressing \"down\", it should not default to x99\n- #1121 [Gold] NPC trading (probably player too?) animation broken / missing things\n- #1126 [Gold] Jumping off ledge animation missing\n- #1130 A separate program for Gen2 (Gen2 Recomp)\n- #1145 Gen1recomp internal update - Mod didn't filter for Gen1 and Gen2 when click on individual tab\n- #1150 Control in Gold freeze when change\n- #1151 [Gold] Following up of #1127\n- #1152 [Gold] You cannot try to run away from wild battle after one of your Pokemon gets fainted.\n- #1161 window/borderless & resolution selection for gen2\n- #1162 [gold] learning TM/HM doesn't show if possible or not\n- #1164 Goldenrod Tower Softlock\n- #1165 [Gold] NPC trading behavior and ledge jumping missing still there despite fix\n- #1166 [Gold] nickname screen grey\n- #1167 [Gold] items in ilex forest missing\n- #1168 [Gold] multi-hit attacks only hits twice\n- #1169 [Gold] the sales lady in goldenrod underground\n- #1172 [Gold] {last item u found/got} used ROCK SMASH\n- #1173 [Gold] ROCK SMASH rocks keep slide left\n- #1177 [Gold] Touch controls position\n- #1178 No Vibration Setting for Gold\n- #1180 [Gold] something feels off with the DV calculation\n- #1184 [Gold] Kurt is in the wrong spot, blocking you from progressing\n- #1185 [Gold] Text messages is in the wrong order when a Pokemon learns a new skill after a trainer fight\n- #1188 GoldenRod tower softlock\n- #1190 POKEMON GOLD GEN1RECOMP++\n- #1192 cant import gold on RGXX build\n\n## Contributors\n\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi\n- @MaxTomahawk\n- @ShaneMcGovernIE\n- @thibautbus"
},
{
"version": "0.1.79",
"date": "2026-08-12",
"size": 10956333,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.79/gen1recomp-0.1.79-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1090 With TILT Option enabled, screen does not correctly render beyond what would be visible with TILT Disabled.\n- #1093 Gen 2 Gold Beta: jingle doesn't play when receiving Elm's phone number\n- #1094 Gen 2 Gold Beta: no battle SFX when animations are Off\n- #1095 Gen 2 Gold Beta: player sprite has white box outline that cuts off Pokémon sprites\n- #1098 [Gold] Can't change controls (no menu for it and it doesn't use the one set in RBY)\n- #1102 [Gold] Shop ui off because of a border\n- #1105 [Gold] Game speed doesn't apply to the intro\n- #1106 [Gold] Games slots don't respect portable.txt\n- #1108 [Gold] Launcher doesn't show any information about the save slot(s)\n- #1109 [Options] text speed doesn't work at all.\n- #1110 [Gold] When catching a Pokemon, the ball disappears and the text doesn't wait for you to progress\n- #1111 [Gold] nurse joy doesn't look left for healing\n- #1113 [Gold] Poke center -> Bills PC -> Deposit Pokemon, screen off\n- #1114 [Gold] Can't release Pokemon, menu doesn't appear\n- #1117 [Gold/Options] sound doesn't change\n- #1118 [Gold] Berrys animation missing and speedup.\n- #1121 [Gold] NPC trading (probably player too?) animation broken / missing things\n- #1122 [Gold] Level up UI.\n- #1123 [Gold] Character stops \"inside door\" when leaving buildings instead of moving one tile down\n- #1124 [Gold] When \"turning\", no animation is played\n- #1126 [Gold] Jumping off ledge animation missing\n- #1127 [Gold] Very minor Pokegear issues.\n- #1128 [Gold] When calling mom, the yes/no dialogue background color doesn't fit\n- #1131 [Gold] Pokegear map bug(s)\n- #1132 [gold] pokedex bugs\n- #1134 Unable to Validate/Pack mod calling Gold's overworld sprite\n- #1137 64x64 sprite support broken for mods on Gen 2\n- #1141 Teleport doesn't end wild battle\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @dburton95\n- @dlloa\n- @MaxTomahawk\n- @swuff-star\n- @TheRealSolidusSnake\n- @thibautbus"
},
{
"version": "0.1.78",
"date": "2026-08-11",
+2 -2
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>gen1recomp</string>
<string>gen1recomp++</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
@@ -32,7 +32,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>gen1recomp</string>
<string>gen1recomp++</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
+52 -2
View File
@@ -29,6 +29,9 @@ WRAP_SYSTEM = LOVE_SRC / "src" / "modules" / "system" / "wrap_System.cpp"
PBXPROJ = LOVE_SRC / "platform" / "xcode" / "love.xcodeproj" / "project.pbxproj"
APPLE_MM = LOVE_SRC / "src" / "common" / "apple.mm"
FILESYSTEM_CPP = LOVE_SRC / "src" / "modules" / "filesystem" / "physfs" / "Filesystem.cpp"
IOS_MM = LOVE_SRC / "src" / "common" / "ios.mm"
IOS_H = LOVE_SRC / "src" / "common" / "ios.h"
SYSTEM_CPP = LOVE_SRC / "src" / "modules" / "system" / "System.cpp"
ENTITLEMENTS_SRC = IOS_DIR / "overlays" / "love-ios.entitlements"
NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRBootstrap.m")
@@ -120,7 +123,7 @@ int w_pickFileKinds(lua_State *L)
typedef const char *(*GRUTF8)(id, SEL);
const char *bytes = ((GRUTF8)objc_msgSend)(kinds,
sel_registerName("UTF8String"));
if (bytes == nullptr || bytes[0] == '\0')
if (bytes == nullptr || bytes[0] == '\\0')
{
lua_pushnil(L);
return 1;
@@ -358,6 +361,50 @@ def patch_public_documents():
print("patch_love_src: iOS save directory routed to Documents root")
def patch_ios_haptics():
text = pristine(IOS_MM, ("UIImpactFeedbackGenerator",))
original = """void vibrate()
{
@autoreleasepool
{
AudioServicesPlaySystemSound(kSystemSoundID_Vibrate);
}
}
"""
replacement = """void vibrate(double seconds)
{
@autoreleasepool
{
UIImpactFeedbackStyle style = UIImpactFeedbackStyleLight;
if (seconds >= 0.035)
style = UIImpactFeedbackStyleHeavy;
else if (seconds >= 0.02)
style = UIImpactFeedbackStyleMedium;
UIImpactFeedbackGenerator *generator = [[UIImpactFeedbackGenerator alloc]
initWithStyle:style];
[generator prepare];
[generator impactOccurred];
}
}
"""
if original not in text:
fail(f"iOS haptic anchor not found in {IOS_MM}")
IOS_MM.write_text(text.replace(original, replacement, 1))
header = pristine(IOS_H, ("void vibrate(double seconds);",))
header_original = "void vibrate();"
if header_original not in header:
fail(f"iOS haptic declaration not found in {IOS_H}")
IOS_H.write_text(header.replace(header_original, "void vibrate(double seconds);", 1))
system = pristine(SYSTEM_CPP, ("love::ios::vibrate(seconds)",))
system_original = "love::ios::vibrate();"
if system_original not in system:
fail(f"iOS haptic call site not found in {SYSTEM_CPP}")
SYSTEM_CPP.write_text(system.replace(system_original, "love::ios::vibrate(seconds);", 1))
print("patch_love_src: iOS haptics use Taptic Engine impact presets")
def patch_pbxproj():
text = pristine(PBXPROJ)
@@ -408,7 +455,9 @@ def patch_pbxproj():
fail(f"build configuration {config_id} not found")
settings = (
"\t\t\t\tSWIFT_VERSION = 5.0;\n"
"\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n"
"\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 15.0;\n"
"\t\t\t\tPRODUCT_NAME = \"gen1recomp++\";\n"
"\t\t\t\tEXECUTABLE_NAME = \"gen1recomp++\";\n"
'\t\t\t\tCODE_SIGN_ENTITLEMENTS = "ios/native/love-ios.entitlements";\n'
)
text = text[: m.end()] + settings + text[m.end():]
@@ -422,6 +471,7 @@ def main():
fail("love-src/ missing; run scripts/build_ios.sh --fetch first")
copy_native_files()
patch_public_documents()
patch_ios_haptics()
patch_wrap_system()
patch_pbxproj()
+1 -1
View File
@@ -13,7 +13,7 @@
# dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage)
# dist/android/debug/*.apk (full gradle output stays under
# mobile/android/app/build/outputs/apk/embedNoRecord/)
# dist/ios/<Config>-<sdk>/gen1recomp.app (full xcodebuild output stays
# dist/ios/<Config>-<sdk>/gen1recomp++.app (full xcodebuild output stays
# under mobile/ios/build/Build/Products/)
set -euo pipefail
+51 -3
View File
@@ -27,10 +27,9 @@ APPLICATION_ID="com.theboisclub.pokemonred"
LOVE_ANDROID_VERSION="11.5a"
NDK_VERSION="25.2.9519653"
YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
# A fresh source checkout normally supplies this through Git. This URL is
# deliberately only a last resort for incomplete source exports: the manifest
# contains extraction metadata, never a ROM or extracted game data.
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_gold.json}"
VERSION=""
PACKAGE_ONLY=false
@@ -130,6 +129,54 @@ ensure_yellow_manifest() {
fail "Yellow import manifest is unavailable. Git recovery failed and could not download $YELLOW_MANIFEST_URL"
}
gold_manifest_is_valid() {
local path="$1"
python3 - "$path" <<'PY'
import json, pathlib, sys
try:
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
except (OSError, ValueError):
raise SystemExit(1)
raise SystemExit(0 if manifest.get("romSha1") ==
"d8b8a3600a465308c9953dfa04f0081c05bdcb94" else 1)
PY
}
ensure_gold_manifest() {
local manifest="$ROOT/$GOLD_MANIFEST_RELATIVE"
local staged
staged="$(mktemp)"
if gold_manifest_is_valid "$manifest"; then
rm -f "$staged"
return
fi
warn "Gold import manifest is missing or invalid; recovering it before packaging"
if git -C "$ROOT" show "HEAD:$GOLD_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \
&& gold_manifest_is_valid "$staged"; then
mkdir -p "$(dirname "$manifest")"
mv "$staged" "$manifest"
say "restored Gold import manifest from this checkout's Git data"
return
fi
if command -v curl >/dev/null 2>&1 \
&& curl --fail --location --retry 2 --connect-timeout 15 \
--output "$staged" "$GOLD_MANIFEST_URL" \
&& gold_manifest_is_valid "$staged"; then
mkdir -p "$(dirname "$manifest")"
mv "$staged" "$manifest"
say "downloaded Gold import manifest from the project repository"
return
fi
rm -f "$staged"
fail "Gold import manifest is unavailable. Git recovery failed and could not download $GOLD_MANIFEST_URL"
}
# --------------------------------------------------------------- branding
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
# Manifest still gets permission trims. Re-applied every build so refreshing
@@ -194,6 +241,7 @@ PY
pack_game_love() {
say "packing game.love for love-android embed flavor"
ensure_yellow_manifest
ensure_gold_manifest
mkdir -p "$EMBED_ASSETS"
rm -f "$LOVE_FILE"
# tools/save-editor ships with the app: the launcher's Edit button on a save
+43 -25
View File
@@ -20,9 +20,9 @@
# - macOS + Xcode (xcodebuild)
# - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md)
#
# 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
# 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
@@ -39,8 +39,9 @@ RESOURCES_DIR="$XCODE_DIR/ios/resources"
LOVE_FILE="$RESOURCES_DIR/game.love"
LIBS_DIR="$XCODE_DIR/ios/libraries"
APP_NAME="gen1recomp"
DISPLAY_NAME="gen1recomp"
APP_NAME="gen1recomp++"
DISPLAY_NAME="gen1recomp++"
PRODUCT_NAME="gen1recomp++"
# Bundle ID resolution, most specific wins:
# 1. GEN1_BUNDLE_ID env var
# 2. mobile/ios/bundle_id.local (one line, gitignored — pins YOUR install
@@ -50,7 +51,7 @@ DISPLAY_NAME="gen1recomp"
# capabilities like HealthKit are involved), so a per-team default
# lets anyone build without colliding with someone else's app
# 4. simulator: the project default (no App ID registration involved)
BUNDLE_ID="${GEN1_BUNDLE_ID:-com.theboisclub.gen1recomp}"
BUNDLE_ID="${GEN1_BUNDLE_ID:-}"
if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then
BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")"
fi
@@ -138,11 +139,7 @@ if $DEVICE && [ -z "${DEVELOPMENT_TEAM:-}" ]; then
fi
fi
if [ -z "$BUNDLE_ID" ]; then
if $DEVICE; then
BUNDLE_ID="com.gen1recomp.t$(printf '%s' "$DEVELOPMENT_TEAM" | tr '[:upper:]' '[:lower:]')"
else
BUNDLE_ID="com.theboisclub.pokemonred"
fi
BUNDLE_ID="com.theboisclub.gen1recompplusplus"
fi
# --------------------------------------------------------------- host checks
@@ -226,13 +223,26 @@ verify_documents_overlay() {
}
apply_ios_icon() {
local source="$ROOT/assets/logo/gen1recomp_cover.png"
local source="$ROOT/assets/logo/logo.png"
local target="$XCODE_DIR/Images.xcassets/iOS AppIcon.appiconset"
[ -f "$source" ] || fail "missing iOS icon source: $source"
[ -d "$target" ] || fail "missing iOS app icon set: $target"
local icon="$BUILD_DIR/gen1recomp-ios-icon.png"
mkdir -p "$BUILD_DIR"
if command -v magick >/dev/null 2>&1; then
magick -size 1024x1024 xc:black \
\( "$source" -resize 900x900 \) -gravity center -composite \
-alpha off "$icon" || fail "could not create iOS app icon: $source"
else
local scaled="$BUILD_DIR/gen1recomp-logo.png"
sips -Z 900 "$source" --out "$scaled" >/dev/null \
|| fail "could not resize iOS app icon source: $source"
sips -p 1024 1024 --padColor 000000 "$scaled" --out "$icon" >/dev/null \
|| fail "could not center iOS app icon source: $source"
fi
local entry name size
while IFS=: read -r name size; do
sips -z "$size" "$size" "$source" --out "$target/$name" >/dev/null
sips -z "$size" "$size" "$icon" --out "$target/$name" >/dev/null
done <<'EOF'
icon-1024pt@1x.png:1024
icon-29pt@1x.png:29
@@ -647,6 +657,7 @@ run_xcodebuild() {
CURRENT_PROJECT_VERSION="$project_version"
INFOPLIST_KEY_UIFileSharingEnabled=YES
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace=YES
IPHONEOS_DEPLOYMENT_TARGET=15.0
ONLY_ACTIVE_ARCH=NO
DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES
)
@@ -666,6 +677,9 @@ run_xcodebuild() {
if [ -n "${CODE_SIGN_IDENTITY:-}" ]; then
args+=(CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY")
fi
if [ "${GEN1_DISABLE_HEALTHKIT:-0}" = "1" ]; then
args+=(CODE_SIGN_ENTITLEMENTS=)
fi
fi
if ! xcodebuild -showsdks 2>/dev/null | grep -q "$sdk"; then
@@ -708,18 +722,22 @@ run_xcodebuild() {
fi
local products="$BUILD_DIR/Build/Products/${config}-${sdk}"
local app="$products/$APP_NAME.app"
if [ ! -d "$app" ]; then
# PRODUCT_NAME override can still leave love.app on older projects
if [ -d "$products/love.app" ]; then
app="$products/$APP_NAME.app"
mv "$products/love.app" "$app"
warn "renamed love.app to $APP_NAME.app"
else
warn "xcodebuild finished but no .app under $products"
find "$BUILD_DIR/Build/Products" -name '*.app' 2>/dev/null | head -20 || true
return 0
local app=""
local candidate
for candidate in "$products/$PRODUCT_NAME.app" "$products/$APP_NAME.app" "$products/love.app"; do
if [ -d "$candidate" ]; then
app="$candidate"
break
fi
done
if [ -z "$app" ]; then
warn "xcodebuild finished but no .app under $products"
find "$BUILD_DIR/Build/Products" -name '*.app' 2>/dev/null | head -20 || true
return 0
fi
if [ "$app" != "$products/$APP_NAME.app" ]; then
mv "$app" "$products/$APP_NAME.app"
app="$products/$APP_NAME.app"
fi
verify_documents_configuration "$app"
@@ -760,7 +778,7 @@ run_xcodebuild() {
fi
}
# Pack Payload/<app>.app into dist/ios/gen1recomp.ipa for release / sideload tools.
# Pack Payload/<app>.app into dist/ios/gen1recomp++.ipa for release / sideload tools.
package_ipa() {
local app="$1"
local ipa="$DIST/$APP_NAME.ipa"
+3
View File
@@ -357,6 +357,9 @@ cp -R "$jit_share/jit" "$APPDIR/share/$LUAJIT_SHARE_DIR/"
# --------------------------------------------------------------- branding
cp "$IN/game.love" "$APPDIR/game.love"
unzip -Z1 "$APPDIR/game.love" > "$WORK/love-listing.txt"
grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \
|| fail "game.love is missing tools/rom_manifest_gold.json"
# The .desktop's Icon= resolves against the AppDir root by basename, and
# .DirIcon is what appimaged and file-manager thumbnailers read.
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png"
@@ -167,5 +167,7 @@ trap 'rm -rf "$temp_dir"' EXIT
unzip -p "$temp_dir/game.love" src/core/Version.lua \
| grep -Eq 'engine[[:space:]]*=[[:space:]]*"1\.2\.3"' \
|| fail "shared payload version was not stamped"
grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \
|| fail "shared payload is missing tools/rom_manifest_gold.json"
say "Linux arm64 self-test passed"
+98
View File
@@ -0,0 +1,98 @@
-- Shared settled supported player-decision predicate. Checkpoint capture and
-- the public auxiliary action deliberately use this one engine-owned rule so
-- a tool cannot open at a phase that it could not subsequently checkpoint.
-- It exposes no controller; callers receive only the result/reason.
local BattleSafety = {}
local BATTLE_BUSY_FIELDS = {
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
}
local function nonempty(value)
return type(value) == "table" and next(value) ~= nil
end
local function running(runner)
return runner and runner.isRunning and runner:isRunning()
end
local function scriptsBusy(overworld)
return running(overworld and overworld.runner)
or nonempty(overworld and overworld.parallelRunners)
or nonempty(overworld and overworld.pendingScripts)
or nonempty(overworld and overworld.parallelQueue)
or nonempty(overworld and overworld.scriptMoves)
end
function BattleSafety.inspect(game, battle)
if type(game) ~= "table" or type(game.save) ~= "table"
or type(game.save.version) ~= "string" then
return nil, "not_in_playthrough", "A checkpoint requires an identified active playthrough."
end
if type(battle) ~= "table" then
return nil, "not_battle", "No battle is active."
end
if battle.kind == "link" then
return nil, "link_battle_unsupported", "Network battles cannot be checkpointed."
end
if battle.safari or battle.ghost or battle.scopeReveal or battle.demo or battle.noCatch then
return nil, "battle_variant_unsupported",
"This battle variant does not have a checkpoint contract."
end
if battle.kind ~= "wild" and battle.kind ~= "trainer" then
return nil, "battle_variant_unsupported",
"This battle kind does not have a checkpoint contract."
end
local origin = battle.checkpointOrigin
local ordinaryOrigin = battle.kind == "wild" and "wild_encounter"
or "trainer_encounter"
local scriptedOrigin = type(origin) == "table"
and origin.kind == "script_battle"
if type(origin) ~= "table"
or (origin.kind ~= ordinaryOrigin and not scriptedOrigin) then
return nil, "battle_origin_unsupported",
"The battle completion path cannot be reconstructed safely."
end
local overworld = game.overworld or {}
local scriptedRunner = scriptedOrigin and (battle.checkpointScriptContinuation
or (overworld.runner
and overworld.runner.isCheckpointBattle
and overworld.runner:isCheckpointBattle(battle)))
local otherScriptWork = nonempty(overworld.parallelRunners)
or nonempty(overworld.pendingScripts) or nonempty(overworld.parallelQueue)
or nonempty(overworld.scriptMoves)
if (scriptedOrigin and (not scriptedRunner or otherScriptWork))
or (not scriptedOrigin and scriptsBusy(overworld)) then
return nil, "script_busy", "A suspended or queued script cannot be checkpointed."
end
if battle.phase ~= "menu" or nonempty(battle.queue) then
return nil, "battle_phase_busy",
"Wait for the player command menu before creating a checkpoint."
end
for _, field in ipairs(BATTLE_BUSY_FIELDS) do
if battle[field] ~= nil and battle[field] ~= false then
return nil, "battle_phase_busy", "Wait for the current battle action to finish."
end
end
if not battle.player or not battle.enemy or not battle.player.mon
or battle.player.mon.hp <= 0
or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then
return nil, "battle_phase_busy",
"Wait for a supported player decision before creating a checkpoint."
end
for _, battler in ipairs({ battle.player, battle.enemy }) do
if not battler.mon or battler.shownHP ~= battler.mon.hp
or battler.shownStatus ~= battler.mon.status
or battler.drainFloor ~= nil or battler.drainHold ~= nil
or battler.faintQueued then
return nil, "battle_phase_busy",
"Wait for battle status and HP presentation to settle."
end
end
return true
end
return BattleSafety
+14 -3
View File
@@ -21,12 +21,14 @@ local MoveEffects = require("src.battle.MoveEffects")
local Party = require("src.pokemon.Party")
local Pokemon = require("src.pokemon.Pokemon")
local Runtime = require("src.mods.Runtime")
local BattleSafety = require("src.battle.BattleSafety")
local Screens = require("src.ui.Screens")
local Status = require("src.battle.Status")
local Timing = require("src.core.Timing")
local TrainerAI = require("src.battle.TrainerAI")
local TurnOrder = require("src.battle.TurnOrder")
local TypeChart = require("src.battle.TypeChart")
local UIVisibility = require("src.battle.UIVisibility")
local RomText = require("src.core.RomText")
local Strings = require("src.core.Strings")
local WideBattle = require("src.battle.WideBattle")
@@ -134,9 +136,7 @@ function BattleState:sgbPalettes()
end
function BattleState:bottomUIVisible()
if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end
return Runtime.call("battle.bottom_ui_visible", function() return true end,
self) ~= false
return UIVisibility.bottomVisible(self, true)
end
function BattleState:statusHUDVisible()
@@ -1962,6 +1962,17 @@ function BattleState:update(dt)
self:resolveTurn(locked)
return
end
-- START has no vanilla action at a settled supported player-decision
-- boundary. A tool mod may claim this semantic auxiliary action through
-- the public hook, receiving only game plus a data-only kind. The shared
-- safety predicate keeps every unsupported/forced/animated phase inert.
if input:wasPressed("start") and Runtime.wantsHook("battle.menu_auxiliary") then
local safe = BattleSafety.inspect(self.game, self)
if safe and Runtime.call("battle.menu_auxiliary", function() return false end,
self.game, { kind = self.kind }) == true then
return
end
end
local col = (self.menuIndex - 1) % 2
local row = math.floor((self.menuIndex - 1) / 2)
if input:wasPressed("left") then
+39
View File
@@ -0,0 +1,39 @@
-- Shared visibility rules for battle-owned UI states. Text and choice
-- overlays live above BattleState on the state stack, but they are still part
-- of its bottom UI layer and must inherit that layer's visibility.
local Runtime = require("src.mods.Runtime")
local UIVisibility = {}
local function enclosingBattle(state)
local stack = state and state.game and state.game.stack
local states = stack and stack.states
local found = false
for i = #(states or {}), 1, -1 do
local candidate = states[i]
if candidate == state then found = true end
if found and candidate and candidate.isBattle then return candidate end
end
return nil
end
-- queryState keeps the existing TextBox contract: a mod may still decide
-- visibility for that individual box. ChoiceBox only inherits the enclosing
-- battle decision, so field YES/NO prompts never become battle-hook states.
function UIVisibility.bottomVisible(state, queryState)
if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end
local battle = enclosingBattle(state)
if battle and battle ~= state
and Runtime.call("battle.bottom_ui_visible",
function() return true end, battle) == false then
return false
end
if queryState or battle == state then
return Runtime.call("battle.bottom_ui_visible",
function() return true end, state) ~= false
end
return true
end
return UIVisibility
+32 -7
View File
@@ -217,7 +217,7 @@ function Battle.new(opts)
opts = opts or {}
local self = setmetatable({}, Battle)
self.data = opts.data or {}
self.random = opts.random
self.random = opts.random or function(n) return rand(nil, n) end
self.party = opts.party or {}
self.trainer = opts.trainer
self.save = opts.save
@@ -271,6 +271,13 @@ function Battle.new(opts)
self.enemy = self.enemyParty[self.enemyIndex]
end
for _, mon in ipairs(self.party) do
Mon.refreshStats(mon, self.data)
end
for _, mon in ipairs(self.enemyParty or {}) do
Mon.refreshStats(mon, self.data)
end
-- Battle RAM opens empty on both sides: NewBattleMonStatus and
-- NewEnemyMonStatus run at the first send-out of every battle.
self:clearAllVolatiles()
@@ -1597,9 +1604,25 @@ function Battle:useMove(attacker, defender, moveId)
return
end
-- Damage that skips the formula entirely.
local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random)
-- Damage that skips the formula entirely. The move's own power goes with
-- it: EFFECT_STATIC_DAMAGE's arm of BattleCommand_ConstantDamage reads
-- BATTLE_VARS_MOVE_POWER as the damage (effect_commands.asm:3157-3161).
local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random,
def.power)
if fixed then
-- The constant-damage effect list carries `resettypematchup` instead of
-- `stab`, and that command misses the move outright when the matchup byte
-- is 0 (effect_commands.asm:1480-1493) -- an immune target is the one
-- thing that stops SONIC BOOM, NIGHT SHADE or SUPER FANG.
local defenderTypes = (self:speciesDef(defender) or {}).types
or defender.types
local matchups = self.data.type_chart and self.data.type_chart.matchups
if Damage.typeMultiplier(def.type, defenderTypes, matchups) == 0 then
self:markMissed()
self:emit({ kind = "message",
text = "It doesn't affect " .. self:monName(defender) .. "..." })
return
end
self:dealDamage(attacker, defender, fixed, { move = def, moveId = moveId })
return
end
@@ -1655,7 +1678,7 @@ function Battle:useMove(attacker, defender, moveId)
state.rolloutLock = nil
end
local hits = Effects.hitCount(def.effect, self.random)
local hits = Effects.hitCount(def.effect, self:roller())
local landed = 0
for hit = 1, hits do
if (defender.hp or 0) <= 0 then break end
@@ -3178,7 +3201,8 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved)
-- ChangeHappiness is outside the level loop.
Happiness.change(mon, "GAINLEVEL")
self:emit({ kind = "level", index = index, level = mon.level,
text = self:monName(mon) .. " grew to level " .. mon.level .. "!" })
text = self:monName(mon) .. " grew to level " .. mon.level .. "!",
sfx = "Sfx_DexFanfare5079", waitSfx = true })
for _, moveId in ipairs(result.learned) do
local ok, reason, entry = Mon.learnMove(mon, moveId, self.data)
local moveDef = self:moveDef(moveId)
@@ -3644,7 +3668,7 @@ end
-- Running: Gen 2's odds (engine/battle/core.asm TryToRunAwayFromBattle) are
-- based on the speed ratio and how many times you have tried this battle.
-- Trainers never let you run.
function Battle:tryRun()
function Battle:tryRun(pSpd)
-- .cant_escape and .cant_run_from_trainer leave wBattlePlayerAction alone,
-- which is what BattleMenu_Run reads to decide whether the turn was spent
-- (engine/battle/core.asm:5035); only .cant_escape_2, the failed roll at the
@@ -3676,7 +3700,8 @@ function Battle:tryRun()
return false
end
self.runAttempts = (self.runAttempts or 0) + 1
if self:runRoll(self:effectiveSpeed(self.player),
-- engine/battle/core.asm:2614
if self:runRoll(pSpd or self:effectiveSpeed(self.player),
self:effectiveSpeed(self.enemy)) then
self:emit({ kind = "run", text = "Got away safely!" })
self:endBattle("run")
+27 -12
View File
@@ -100,21 +100,28 @@ end
-- are 1/8 each, which is what the `and 3` on a 0-3 roll plus the two-step
-- fallthrough in .DetermineNumberOfHits produces.
function Effects.multiHitCount(random)
local roll = random and random(4) or 0
if roll < 2 then return roll + 2 end
-- Hits 4 and 5 take a second roll, so each ends up half as likely.
local second = random and random(2) or 0
return second + 4
local function roll(n)
if random then return random(n) end
if love and love.math and love.math.random then
return love.math.random(n) - 1
end
return math.random(n) - 1
end
-- engine/battle/effect_commands.asm:5228
local first = roll(4)
if first < 2 then return first + 2 end
return roll(4) + 2
end
Effects.HIT_COUNTS = {
EFFECT_DOUBLE_HIT = 2,
EFFECT_POISON_MULTI_HIT = 2,
-- Triple Kick stops early if a hit misses; Battle rolls that per hit.
EFFECT_TRIPLE_KICK = 3,
}
function Effects.hitCount(effect, random)
if effect == "EFFECT_MULTI_HIT" or effect == "EFFECT_POISON_MULTI_HIT" then
if effect == "EFFECT_MULTI_HIT" then
return Effects.multiHitCount(random)
end
return Effects.HIT_COUNTS[effect] or 1
@@ -172,9 +179,9 @@ end
-- --------------------------------------------------------------- fixed damage
-- BattleCommand_LevelDamage / SuperFang / Psywave, all of which skip the
-- damage formula entirely.
function Effects.fixedDamage(effect, attacker, defender, random)
-- BattleCommand_ConstantDamage (engine/battle/effect_commands.asm:3131-3205),
-- one command for the whole SuperFang / Psywave / StaticDamage list.
function Effects.fixedDamage(effect, attacker, defender, random, power)
if effect == "EFFECT_LEVEL_DAMAGE" then
return math.max(1, attacker.level or 1)
end
@@ -182,9 +189,17 @@ function Effects.fixedDamage(effect, attacker, defender, random)
return math.max(1, math.floor((defender.hp or 1) / 2))
end
if effect == "EFFECT_PSYWAVE" then
-- 1..(level * 1.5), rerolled until it is in range; one roll is enough here.
local ceiling = math.max(1, math.floor((attacker.level or 1) * 3 / 2))
return math.max(1, (random and random(ceiling) or 0) + 1)
-- .psywave rerolls until the byte is nonzero AND below level * 1.5, so the
-- top of the range is that ceiling minus one (effect_commands.asm:3163).
local ceiling = math.max(2, math.floor((attacker.level or 1) * 3 / 2))
return math.max(1, (random and random(ceiling - 1) or 0) + 1)
end
-- SONIC BOOM and DRAGON RAGE share EFFECT_STATIC_DAMAGE, whose arm reads
-- BATTLE_VARS_MOVE_POWER straight into the damage word: their stored power
-- (20 and 40) IS the damage, never a formula input
-- (effect_commands.asm:3157-3161).
if effect == "EFFECT_STATIC_DAMAGE" then
return math.max(1, math.floor(power or 0))
end
return nil
end
+26 -3
View File
@@ -68,7 +68,16 @@ function Mon.stats(baseStats, dvs, level, statExp)
baseStats = baseStats or {}
dvs = dvs or {}
statExp = statExp or {}
local hpDv = dvs.hp or Mon.hpDV(dvs)
-- engine/pokemon/move_mon.asm:1540
local specialDv = dvs.special
if specialDv == nil then
specialDv = dvs.specialAttack or dvs.specialDefense
end
-- engine/pokemon/move_mon.asm:1496
local hpDv = Mon.hpDV({
attack = dvs.attack, defense = dvs.defense,
speed = dvs.speed, special = specialDv,
})
local hp = math.floor((((baseStats.hp or 1) * 2 + hpDv * 2
+ math.floor(math.sqrt(statExp.hp or 0) / 4)) * level) / 100)
+ level + 10
@@ -82,13 +91,27 @@ function Mon.stats(baseStats, dvs, level, statExp)
-- box_struct ends them at SpcExp), so SpA and SpD grow together. The
-- per-stat keys are still read as a fallback for a record written before
-- the shared word existed.
specialAttack = statValue(baseStats.specialAttack, dvs.special, level,
specialAttack = statValue(baseStats.specialAttack, specialDv, level,
statExp.special or statExp.specialAttack),
specialDefense = statValue(baseStats.specialDefense, dvs.special, level,
specialDefense = statValue(baseStats.specialDefense, specialDv, level,
statExp.special or statExp.specialDefense),
}
end
function Mon.refreshStats(mon, data)
if type(mon) ~= "table" then return mon end
local def = data and data.pokemon and data.pokemon[mon.species]
if not (def and def.baseStats) then return mon end
-- engine/pokemon/move_mon.asm:1402
local stats = Mon.stats(def.baseStats, mon.dvs, mon.level or 1, mon.statExp)
mon.stats = stats
mon.maxHp = stats.hp
if mon.hp == nil or mon.hp > stats.hp then
mon.hp = stats.hp
end
return mon
end
-- The five stat exp words, in struct order. There is no sixth: see Mon.stats.
Mon.STAT_EXP_ORDER = { "hp", "attack", "defense", "speed", "special" }
+17 -67
View File
@@ -7,6 +7,7 @@ local Version = require("src.core.Version")
local BattleState = require("src.battle.BattleState")
local BattleCheckpoint = require("src.core.BattleCheckpoint")
local ModRuntime = require("src.mods.Runtime")
local BattleSafety = require("src.battle.BattleSafety")
local Checkpoint = {}
@@ -36,72 +37,9 @@ local function scriptsBusy(ow)
or nonempty(ow.scriptMoves)
end
local BATTLE_BUSY_FIELDS = {
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
}
local function inspectBattle(ow, battle)
if battle.kind == "link" then
return refusal("battle", "link_battle_unsupported",
"Network battles cannot be checkpointed.")
end
if battle.safari or battle.ghost or battle.scopeReveal or battle.demo
or battle.noCatch then
return refusal("battle", "battle_variant_unsupported",
"This battle variant does not have a checkpoint contract.")
end
if battle.kind ~= "wild" and battle.kind ~= "trainer" then
return refusal("battle", "battle_variant_unsupported",
"This battle kind does not have a checkpoint contract.")
end
local origin = battle.checkpointOrigin
local ordinaryOrigin = battle.kind == "wild" and "wild_encounter"
or "trainer_encounter"
local scriptedOrigin = type(origin) == "table"
and origin.kind == "script_battle"
if type(origin) ~= "table"
or (origin.kind ~= ordinaryOrigin and not scriptedOrigin) then
return refusal("battle", "battle_origin_unsupported",
"The battle completion path cannot be reconstructed safely.")
end
local scriptedRunner = scriptedOrigin and (battle.checkpointScriptContinuation
or (ow.runner
and ow.runner.isCheckpointBattle
and ow.runner:isCheckpointBattle(battle)))
local otherScriptWork = nonempty(ow.parallelRunners)
or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue)
or nonempty(ow.scriptMoves)
if (scriptedOrigin and (not scriptedRunner or otherScriptWork))
or (not scriptedOrigin and scriptsBusy(ow)) then
return refusal("battle", "script_busy",
"A suspended or queued script cannot be checkpointed.")
end
if battle.phase ~= "menu" or nonempty(battle.queue) then
return refusal("battle", "battle_phase_busy",
"Wait for the player command menu before creating a checkpoint.")
end
for _, field in ipairs(BATTLE_BUSY_FIELDS) do
if battle[field] ~= nil and battle[field] ~= false then
return refusal("battle", "battle_phase_busy",
"Wait for the current battle action to finish.")
end
end
if not battle.player or not battle.enemy or battle.player.mon.hp <= 0
or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then
return refusal("battle", "battle_phase_busy",
"Wait for an ordinary player decision before creating a checkpoint.")
end
for _, battler in ipairs({ battle.player, battle.enemy }) do
if battler.shownHP ~= battler.mon.hp
or battler.shownStatus ~= battler.mon.status
or battler.drainFloor ~= nil or battler.drainHold ~= nil
or battler.faintQueued then
return refusal("battle", "battle_phase_busy",
"Wait for battle status and HP presentation to settle.")
end
end
local function inspectBattle(game, battle)
local allowed, reason, message = BattleSafety.inspect(game, battle)
if not allowed then return refusal("battle", reason, message) end
return { canCapture = true, canRestore = true, kind = "battle" }
end
@@ -120,7 +58,7 @@ function Checkpoint.inspect(game)
end
local top = game.stack and game.stack.top and game.stack:top()
if getmetatable(top) == BattleState then
return inspectBattle(ow, top)
return inspectBattle(game, top)
end
if top ~= ow then
return refusal("overworld", "screen_busy",
@@ -375,6 +313,16 @@ local function equalData(a, b)
return okA and okB and encodedA == encodedB
end
local function normalizeVerificationMetadata(actual, expected)
if type(actual) == "table" and type(actual.identity) == "table"
and type(expected) == "table" and type(expected.identity) == "table" then
-- RFC 0004 treats engineVersion as compatibility metadata, not runtime
-- state. A fresh recapture stamps the running engine, so normalize only
-- that metadata before differential verification.
actual.identity.engineVersion = expected.identity.engineVersion
end
end
local function firstDifference(a, b, path)
path = path or "$"
if type(a) ~= type(b) then return path .. " (type)" end
@@ -456,6 +404,7 @@ function Checkpoint.restore(game, checkpoint)
if ok then
local restored, verifyCode = Checkpoint.capture(game)
if restored and validated.rng == nil then restored.rng = nil end
normalizeVerificationMetadata(restored, validated)
if restored and equalData(restored, validated) then
emitRestored(game, validated)
return true
@@ -536,6 +485,7 @@ function Checkpoint.resume(game, checkpoint)
if ok then
local restored, verifyCode = Checkpoint.capture(game)
if restored and validated.rng == nil then restored.rng = nil end
normalizeVerificationMetadata(restored, validated)
if restored and equalData(restored, validated) then
emitRestored(game, validated)
return true
+19 -6
View File
@@ -159,14 +159,15 @@ function Game:makeTitleState()
self:applyOptions(self.save.options)
self.stack:push(OverworldState, self.save.player.map,
self.save.player.x, self.save.player.y,
self.save.player.facing)
self.save.player.facing,
{ via = "boot", freshBoot = true })
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
function() end)
end,
onContinue = function()
local loaded, recovered = SaveData.load()
if loaded then
self:restoreSave(loaded, recovered)
self:restoreSave(loaded, recovered, { freshBoot = true })
end
end,
})
@@ -639,7 +640,12 @@ function Game:keypressed(key)
return
elseif key == "f2" then
local loaded, recovered = SaveData.load()
if loaded then self:restoreSave(loaded, recovered) end
if loaded then
-- F2 jumps straight to the loaded save's map/position, with no
-- walking transition -- a hard state teleport like Continue, not a
-- smooth warp -- whether pressed at the title screen or mid-session.
self:restoreSave(loaded, recovered, { freshBoot = true })
end
return
elseif key == "-" then
self:zoomStep(-1)
@@ -1124,7 +1130,7 @@ function Game:applyOptions(opts)
if gbcCleared then self:writeOptions() end
end
function Game:restoreSave(loaded, recovered)
function Game:restoreSave(loaded, recovered, opts)
if ModRuntime.wants("save.loading") then
ModRuntime.emit("save.loading", { raw = loaded })
end
@@ -1157,8 +1163,12 @@ function Game:restoreSave(loaded, recovered)
end
-- rebuild the state stack from the save
while self.stack:top() do self.stack:pop() end
-- freshBoot threads through from the caller (onContinue and F2 both set
-- it); a future caller that doesn't ask for it keeps the ordinary
-- crossfade by default.
self.stack:push(self.overworld, loaded.player.map,
loaded.player.x, loaded.player.y, loaded.player.facing)
loaded.player.x, loaded.player.y, loaded.player.facing,
{ via = "boot", freshBoot = opts and opts.freshBoot })
self.saveReport = report
if not SaveData.emptyReport(report) then
-- the report screen is a Screens id so mods (or the ui milestone) own
@@ -1187,9 +1197,12 @@ function Game:restoreCheckpointSave(loaded)
self.save = loaded
self:adoptSave(loaded)
while self.stack:top() do self.stack:pop() end
-- freshBoot unconditionally: Checkpoint.resume (src/core/Checkpoint.lua)
-- is this method's only caller, and it is itself gated to the title
-- session (isTitleSession).
self.stack:push(self.overworld, loaded.player.map,
loaded.player.x, loaded.player.y, loaded.player.facing,
{ via = "checkpoint", checkpoint = true })
{ via = "checkpoint", checkpoint = true, freshBoot = true })
end
-- Install a reconstructed battle without calling BattleState:enter(), whose
+17 -11
View File
@@ -109,6 +109,12 @@ local function makeStack()
return StateStack
end
local function visibleBaseState(stack)
if not stack then return nil end
local state = stack.states[stack:visibleBase()]
return stack:renderVisible(state) and state or nil
end
local function loadGenerated(path)
local chunk = love.filesystem.load(path)
if not chunk then return nil end
@@ -1441,8 +1447,7 @@ end
-- them but the Pokegear, whose paper is RGB 28,31,20. Nil means white, which
-- is what Font.drawBox does by default.
function Game2:textboxPaper()
local base = self.stack and self.stack.states
and self.stack.states[self.stack:visibleBase()]
local base = visibleBaseState(self.stack)
if base and base.paperColor then return base:paperColor() end
return nil
end
@@ -1455,10 +1460,11 @@ function Game2:drawScene(w, h)
if self:inFillBoot() then
local top = self.stack:top()
local base = self.stack.states[self.stack:visibleBase()]
local base = visibleBaseState(self.stack)
-- Title (and friends) paint sky/clouds edge-to-edge; Oak speech and
-- name pick paint a paper-white surround via drawWidescreen.
local wide = (top and top.drawsWidescreen and top:drawsWidescreen()
local wide = (self.stack:renderVisible(top)
and top.drawsWidescreen and top:drawsWidescreen()
and top.drawWidescreen) and top
or (base and base.drawsWidescreen and base:drawsWidescreen()
and base.drawWidescreen and base)
@@ -1507,8 +1513,9 @@ function Game2:drawScene(w, h)
-- PARTY, PACK, #DEX, PC, DAY-CARE, MAILBOX and TRADE screens whenever a
-- TextBox goes up over them.
local top = self.stack:top()
local base = self.stack.states[self.stack:visibleBase()]
local wide = (top and top.drawsWidescreen and top:drawsWidescreen()
local base = visibleBaseState(self.stack)
local wide = (self.stack:renderVisible(top)
and top.drawsWidescreen and top:drawsWidescreen()
and top.drawWidescreen) and top
or (base and base.drawsWidescreen and base:drawsWidescreen()
and base.drawWidescreen and base)
@@ -1906,11 +1913,10 @@ function Game2:applyOptions()
require("src.render.Pipelines").applyOptions(options)
-- src/core/Game.lua:1121 mirrors this call for Gen 1
Input:applyBindings(options.bindings)
-- options.touchControls (the launcher editor's per-orientation layouts) and
-- options.haptics, the same two keys Gen 1 hands over here
-- (src/core/Game.lua:1073). One options.lua serves both games, so the pad a
-- player laid out for Red is already the pad Gold draws.
TouchControls:applyOptions(options)
TouchControls:applyOptions({
touchControls = options.touchControls,
haptics = options.haptics,
})
local GBCFX = require("src.render.GBCFX")
if GBCFX.applyOptions(options) and self.save then
-- applyOptions returns true when it had to clear an unsupported level.
+41
View File
@@ -0,0 +1,41 @@
-- Optional native-host display lifecycle.
--
-- The engine always owns simulation and drawing. A packaged host may install
-- one backend to observe per-frame updates and prepare/finalize a render target
-- around an otherwise unchanged draw. With no backend installed every method
-- is a no-op, which is the normal desktop and mobile path.
--
-- Backend methods are optional:
-- backend:update(dt)
-- backend:beginFrame(kind, subject)
-- backend:endFrame(kind, subject)
--
-- `kind` is "editor", "touch_editor", "launcher", or "game". `subject` is
-- the object whose existing draw method runs between beginFrame and endFrame.
local HostDisplay = {}
local backend
function HostDisplay.setBackend(value)
if value ~= nil and type(value) ~= "table" then
error("host display backend must be a table or nil", 2)
end
backend = value
end
function HostDisplay.update(dt)
local fn = backend and backend.update
if fn then return fn(backend, dt) end
end
function HostDisplay.beginFrame(kind, subject)
local fn = backend and backend.beginFrame
if fn then return fn(backend, kind, subject) end
end
function HostDisplay.endFrame(kind, subject)
local fn = backend and backend.endFrame
if fn then return fn(backend, kind, subject) end
end
return HostDisplay
+34
View File
@@ -119,6 +119,34 @@ function Input:reset()
self.stickAxis = { x = 0, y = 0 }
self.stickDir = nil
self.hatDirs = {}
self.captureArmed = false
self.captureEvents = nil
end
function Input:armCapture()
self.captureArmed = true
self.captureEvents = {}
end
function Input:disarmCapture()
self.captureArmed = false
self.captureEvents = nil
end
function Input:takeCaptureEvents()
local ev = self.captureEvents
self.captureEvents = self.captureArmed and {} or nil
return ev
end
local function noteCapture(self, kind, phase, value)
if not self.captureArmed then return end
local ev = self.captureEvents
if not ev then
ev = {}
self.captureEvents = ev
end
ev[#ev + 1] = { kind = kind, phase = phase, value = value }
end
-- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the
@@ -154,6 +182,7 @@ local function release(self, btn, source)
end
function Input:keypressed(key)
noteCapture(self, "key", "pressed", key)
local btn = self.keyBindings[key]
if btn then
press(self, btn, "key:" .. key)
@@ -161,6 +190,7 @@ function Input:keypressed(key)
end
function Input:keyreleased(key)
noteCapture(self, "key", "released", key)
local btn = self.keyBindings[key]
if btn then
release(self, btn, "key:" .. key)
@@ -221,6 +251,7 @@ function Input:sourceRelease(btn, source)
end
function Input:gamepadpressed(joystick, button)
noteCapture(self, "pad", "pressed", button)
local btn = self.padBindings[button]
if btn then
press(self, btn, "pad:" .. button)
@@ -228,6 +259,7 @@ function Input:gamepadpressed(joystick, button)
end
function Input:gamepadreleased(joystick, button)
noteCapture(self, "pad", "released", button)
local btn = self.padBindings[button]
if btn then
release(self, btn, "pad:" .. button)
@@ -249,12 +281,14 @@ end
function Input:joystickpressed(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
noteCapture(self, "joy", "pressed", button)
local btn = self.joyBindings[button]
if btn then press(self, btn, "joy:" .. button) end
end
function Input:joystickreleased(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
noteCapture(self, "joy", "released", button)
local btn = self.joyBindings[button]
if btn then release(self, btn, "joy:" .. button) end
end
+31 -8
View File
@@ -335,11 +335,11 @@ function SaveData.defaultOptions()
-- TouchControls.normalizeConfig folds it into both orientations on load.
touchControls = { enabled = true },
-- Haptic feedback level for on-screen pad presses (#806):
-- off | light | medium | heavy, mapped to a love.system.vibrate
-- duration in src/core/TouchControls.lua. LIGHT by default, like the
-- overlay itself defaulting on, so an options.lua predating this key
-- gets the tick without going looking for the row. Inert wherever the
-- overlay never appears (desktop) or LOVE has no vibrator.
-- off | light | normal | strong, mapped to a love.system.vibrate duration
-- in src/core/TouchControls.lua. LIGHT by default, so an options.lua
-- predating this key gets the tick without going looking for the row.
-- Inert wherever the overlay never appears (desktop) or LOVE has no
-- vibrator.
haptics = "light",
-- Shared UI/mod timestamp presentation. DEVICE follows the process time
-- locale where the platform exposes one; otherwise DateTime falls back to
@@ -818,9 +818,19 @@ end
function SaveData.slotSummary(save)
if type(save) ~= "table" then return nil, nil end
local name = save.player and save.player.name or nil
-- A Gen 2 slot carries no badge items and fills pokedex.caught, so both
-- counts come off wJohtoBadges/wPokedexCaught (engine/menus/intro_menu.asm:461).
local vinfo = type(save.version) == "string" and GameVersion.info(save.version)
local gen2 = save.generation == 2 or (vinfo and vinfo.generation == 2) or false
local dexCount = 0
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
dexCount = dexCount + 1
if gen2 then
for _, has in pairs((save.pokedex and save.pokedex.caught) or {}) do
if has then dexCount = dexCount + 1 end
end
else
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
dexCount = dexCount + 1
end
end
-- playTime is a plain seconds count in a Gen 1 save but a
-- { hours, minutes, seconds, frames } table in a Gen 2 (Gold) save, matching
@@ -838,8 +848,21 @@ function SaveData.slotSummary(save)
end
local timeText = ("%d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60)
local badges
if gen2 then
-- Continue_DisplayBadgeCount walks TWO bytes, Johto then Kanto
-- (engine/menus/intro_menu.asm:461-469).
badges = 0
local p = save.player or {}
for _, has in pairs(p.badges or {}) do if has then badges = badges + 1 end end
for _, has in pairs(p.kantoBadges or {}) do
if has then badges = badges + 1 end
end
else
badges = Badges.count(nil, save)
end
return name, {
badges = Badges.count(nil, save),
badges = badges,
timeText = timeText,
dexCount = dexCount,
}
+13 -5
View File
@@ -105,17 +105,21 @@ end
-- would be dropped on every editor save.
-- love.system.vibrate takes a duration and nothing else, so "intensity" is a
-- duration preset: Android runs the platform vibrator for exactly that long,
-- while iOS ignores the duration and fires the fixed system vibration, so
-- there the three levels all read as simply on.
TouchControls.HAPTICS = { "off", "light", "medium", "heavy" }
-- while iOS maps each duration to a matching Taptic Engine impact.
TouchControls.HAPTICS = { "off", "light", "normal", "strong" }
TouchControls.HAPTIC_DEFAULT = "light"
local HAPTIC_SECONDS = { off = 0, light = 0.012, medium = 0.025, heavy = 0.045 }
local HAPTIC_SECONDS = {
off = 0, light = 0.012, normal = 0.025, strong = 0.045,
}
local HAPTIC_LABELS = {
off = "OFF", light = "LIGHT", medium = "MEDIUM", heavy = "HEAVY",
off = "OFF", light = "LIGHT", normal = "NORMAL", strong = "STRONG",
}
function TouchControls.normalizeHaptics(level)
if level == "physical" then return "light" end
if level == "medium" then return "normal" end
if level == "heavy" then return "strong" end
if HAPTIC_SECONDS[level] then return level end
return TouchControls.HAPTIC_DEFAULT
end
@@ -211,6 +215,10 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale)
local abW = dpadW * 0.46
local ssW = dpadW * 0.30
local margin = dpadW * 0.12
local ok, GameVersion = pcall(require, "src.core.GameVersion")
if ok and GameVersion.isGold and GameVersion.isGold() then
margin = math.max(margin, math.min(ww * 0.10, 72))
end
return {
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
+3
View File
@@ -8,6 +8,9 @@ local Version = {
-- "-dev" placeholder; CI stamps the real X.Y.Z into
-- the packed game.love only, never the working tree.
shell = 1, -- native-shell contract this build implements
payloadHost = "love", -- native host family for in-place Lua payloads.
-- A payload must name the same family; this prevents
-- mounting code packaged for a different native host.
minShell = 1, -- lowest shell contract that can RUN this payload.
-- Bump only when a payload needs a newer native
-- binary (e.g. a LOVE version bump); an older shell
+14
View File
@@ -275,6 +275,8 @@ Save.DEFAULT_OPTIONS = {
musicVol = 7, -- 0-7, like the GB's NR50 master volume
sfxVol = 7, -- 0-7
musicFilter = 0, -- low-pass steps, 0 = off
haptics = "light",
touchControls = { enabled = true },
}
function Save.defaultOptions()
@@ -719,6 +721,13 @@ end
-- copy is the witness that survives a crash mid-replace.
function Save.save(save)
if type(save) ~= "table" then return false, "no save" end
if (save.version or "gold") == "gold" then
local ok, SaveData = pcall(require, "src.core.SaveData")
if ok and SaveData.activeSlot and not SaveData.activeSlot("gold") then
local id = SaveData.createSlot and SaveData.createSlot("gold")
if id and SaveData.setActiveSlot then SaveData.setActiveSlot("gold", id) end
end
end
local main, backup, tmp = saveNames(save.version)
local f = fs()
if not f then return false, "no filesystem" end
@@ -755,9 +764,14 @@ end
function Save.summary(save)
if type(save) ~= "table" then return nil end
local badges = 0
-- Continue_DisplayBadgeCount counts TWO bytes, wJohtoBadges then wKantoBadges
-- (engine/menus/intro_menu.asm:461-469).
for _, has in pairs(save.player and save.player.badges or {}) do
if has then badges = badges + 1 end
end
for _, has in pairs(save.player and save.player.kantoBadges or {}) do
if has then badges = badges + 1 end
end
local caught = 0
for _, has in pairs(save.pokedex and save.pokedex.caught or {}) do
if has then caught = caught + 1 end
+67 -56
View File
@@ -62,6 +62,68 @@ local FILTERS = { "OFF", "1X", "2X", "3X" }
-- The core rows. Helper modules are required lazily under pcall: they are
-- pure label/cycle tables, but the launcher must never die because a render
-- module grew a dependency on live game data.
-- TOUCH PAD, VIBRATION and the layout editor, shared by both row sets.
--
-- Gold reads these out of its own `gold` block (src/core/gen2/Save.lua:297),
-- so `opts` is whichever table the gear is editing and the rows never have to
-- know which game they belong to. #1100 / #1135: the Gold gear carried none
-- of them, so a phone player could turn the pad and the buzz off in Red and
-- had no way to reach either in Gold.
local function addTouchRows(rows, add, opts, hooks)
-- TOUCH PAD only where the overlay can appear, mirroring OptionsMenu's
-- gate (mobile, or desktop forced by POKEPORT_TOUCH=1).
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 show then
add(Strings("TOUCH PAD"),
function()
local tc = opts.touchControls
local on = not (type(tc) == "table" and tc.enabled == false)
return on and Strings("ON") or Strings("OFF")
end,
function()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
tc.enabled = tc.enabled == false
opts.touchControls = tc
return true
end)
-- VIBRATION sits with it (#806): same gate, same subsystem. Stepping
-- the row buzzes once at the level being selected.
local okTC, TC = pcall(require, "src.core.TouchControls")
if okTC then
add(Strings("VIBRATION"),
function() return Strings(TC.hapticLabel(opts.haptics)) end,
function(dir)
opts.haptics = TC.cycleHaptics(opts.haptics, dir)
TC.buzz(opts.haptics)
return true
end)
end
end
-- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a
-- button on the game panel, once per game -- but the overlay layout is
-- global (options.touchControls.layouts), so three tabs offered three
-- buttons that edited the same thing while crowding the column that has to
-- hold Play. It belongs with the other control rows, behind the gear.
-- The host owns the editor screen, so the row only fires when a hook was
-- supplied (the standalone save editor opens this model with none).
if hooks and hooks.editTouchControls then
rows[#rows + 1] = {
label = Strings("TOUCH CONTROLS"),
actionLabel = Strings("Edit"),
action = function()
hooks.editTouchControls()
-- The editor replaces the whole screen: nothing left to persist here
-- beyond what the caller already saved on the way out.
return false
end,
}
end
end
local function coreRows(opts, hooks)
local rows = {}
local function add(label, value, step)
@@ -236,60 +298,7 @@ local function coreRows(opts, hooks)
end)
end
-- TOUCH PAD only where the overlay can appear, mirroring OptionsMenu's
-- gate (mobile, or desktop forced by POKEPORT_TOUCH=1).
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 show then
add(Strings("TOUCH PAD"),
function()
local tc = opts.touchControls
local on = not (type(tc) == "table" and tc.enabled == false)
return on and Strings("ON") or Strings("OFF")
end,
function()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
tc.enabled = tc.enabled == false
opts.touchControls = tc
return true
end)
-- VIBRATION sits with it (#806): same gate, same subsystem. Stepping
-- the row buzzes once at the level being selected.
local okTC, TC = pcall(require, "src.core.TouchControls")
if okTC then
add(Strings("VIBRATION"),
function() return Strings(TC.hapticLabel(opts.haptics)) end,
function(dir)
opts.haptics = TC.cycleHaptics(opts.haptics, dir)
TC.buzz(opts.haptics)
return true
end)
end
end
end
-- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a
-- button on the game panel, once per game -- but the overlay layout is
-- global (options.touchControls.layouts), so three tabs offered three
-- buttons that edited the same thing while crowding the column that has to
-- hold Play. It belongs with the other control rows, behind the gear.
-- The host owns the editor screen, so the row only fires when a hook was
-- supplied (the standalone save editor opens this model with none).
if hooks and hooks.editTouchControls then
rows[#rows + 1] = {
label = Strings("TOUCH CONTROLS"),
actionLabel = Strings("Edit"),
action = function()
hooks.editTouchControls()
-- The editor replaces the whole screen: nothing left to persist here
-- beyond what the caller already saved on the way out.
return false
end,
}
end
addTouchRows(rows, add, opts, hooks)
-- RESET REBINDS, directly under the touch-pad row. Rebinds are additive
-- (src/core/Input.lua:applyBindings layers options.bindings over the
@@ -462,7 +471,7 @@ end
-- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync.
local GEN2_KEY = "gold"
local function gen2Rows(opts)
local function gen2Rows(opts, hooks)
local rows = {}
local function add(label, value, step)
rows[#rows + 1] = { label = label, value = value, step = step }
@@ -551,6 +560,8 @@ local function gen2Rows(opts)
end)
end
addTouchRows(rows, add, opts, hooks)
return rows
end
@@ -574,7 +585,7 @@ function LauncherSettings.open(hooks, version)
opts[GEN2_KEY] = block
end
sections = {
{ title = Strings("OPTIONS"), rows = gen2Rows(block) },
{ title = Strings("OPTIONS"), rows = gen2Rows(block, hooks) },
}
else
sections = {
+52 -3
View File
@@ -1370,6 +1370,10 @@ function RomImporter.new(onComplete, opts)
end
end
if GameVersion.VERSIONS[self.tab] then
self.modScope = self.tab
end
return self
end
@@ -2627,6 +2631,9 @@ function RomImporter:_switchTab(id)
self.tab = id
self._findSearchFocus = false
self:_disarmTextInput()
if GameVersion.VERSIONS[id] then
self:_setModScope(id)
end
end
function RomImporter:_toggleFindSearchFocus()
@@ -2645,11 +2652,14 @@ function RomImporter:_openSettings()
-- hook rather than reaching for main.lua's handler itself. Closing the
-- settings panel FIRST persists the pending edits (_closeSettings saves)
-- and leaves no modal behind the editor to return to.
-- The tab rides along: the editor persists the layout into that game's own
-- option block, and Gold's is not the flat Gen 1 one (#1100).
local hooks = {}
if self.onEditTouchControls then
local version = self.tab
hooks.editTouchControls = function()
self:_closeSettings()
self.onEditTouchControls()
self.onEditTouchControls(version)
end
end
-- The tab the gear was opened on decides the row set: Gold reads a
@@ -2734,6 +2744,13 @@ function RomImporter:keypressed(key)
end
if self._modConfirm or self._modVersions or self._modReleaseNotes
or self._findDetails then
-- Focus navigation belongs to the visible modal as well as the launcher
-- beneath it. Route arrows and an already-armed confirm before this guard
-- returns; unarmed Enter still falls through to the modal guard. Keep this
-- inside the modal branch so text fields retain exclusive keyboard input.
if self._flex and require("src.import.LauncherView").keypressed(self, key) then
return
end
if key == "escape" then
if self._findDetails then
self._findDetails = nil
@@ -2937,6 +2954,13 @@ function RomImporter:_refreshMods()
end
end
self.mods = LauncherMods.list(self.modScope) or {}
if self.modScope then
local kept = {}
for _, m in ipairs(self.mods) do
if m.targetsHere ~= false then kept[#kept + 1] = m end
end
self.mods = kept
end
self:_syncModUpdateInfo(false)
end
@@ -3586,7 +3610,7 @@ function RomImporter:_findRows()
-- index, query, or category actually changed.
local c = self._findRowsCache
if c and c.src == all and c.query == self.findQuery
and c.category == self.findCategory then
and c.category == self.findCategory and c.scope == self.modScope then
return c.rows
end
local ModIndex = require("src.mods.ModIndex")
@@ -3594,8 +3618,33 @@ function RomImporter:_findRows()
query = self.findQuery,
category = self.findCategory,
})
if self.modScope then
local gen = GameVersion.generation(self.modScope)
local kept = {}
for _, entry in ipairs(rows) do
local has1, has2 = false, false
local function note(s)
s = tostring(s or ""):lower()
if s == "gen1" or s == "gen 1" or s == "red" or s == "blue"
or s == "yellow" then
has1 = true
end
if s == "gen2" or s == "gen 2" or s == "gold" then
has2 = true
end
end
for _, cat in ipairs(entry.categories or {}) do note(cat) end
for _, tag in ipairs(entry.tags or {}) do note(tag) end
if (not has1 and not has2)
or (gen == 2 and has2)
or (gen ~= 2 and has1) then
kept[#kept + 1] = entry
end
end
rows = kept
end
self._findRowsCache = { src = all, query = self.findQuery,
category = self.findCategory, rows = rows }
category = self.findCategory, scope = self.modScope, rows = rows }
return rows
end
+2 -6
View File
@@ -7,7 +7,7 @@
-- the text is exhausted and A is pressed, then calls onDone.
local Font = require("src.render.Font")
local Runtime = require("src.mods.Runtime")
local UIVisibility = require("src.battle.UIVisibility")
local Theme = require("src.ui.Theme")
local Timing = require("src.core.Timing")
@@ -393,11 +393,7 @@ function TextBox:update(dt)
end
function TextBox:draw()
if Runtime.wantsHook("battle.bottom_ui_visible")
and Runtime.call("battle.bottom_ui_visible", function() return true end,
self) == false then
return
end
if not UIVisibility.bottomVisible(self, true) then return end
-- The dialogue box belongs against the bottom of the screen, not floating
-- in the middle of a zoomed-out letterbox. Declared per frame; the
-- renderer blits this region to the screen edge and the rest of the UI
+7 -1
View File
@@ -456,7 +456,13 @@ end
-- AskRockSmashScript reads it with `ifequal 1, .no`. Transcribing it the
-- obvious way round refuses the move for every party that has it.
function H.HasRockSmash(ctx)
return call(ctx, "partyMoveUser", "ROCK_SMASH") and 0 or 1
local mon = call(ctx, "partyMoveUser", "ROCK_SMASH")
if mon then
-- engine/events/overworld.asm:1339
ctx.curPartyMon = mon
return 0
end
return 1
end
-- PutTheRodAway: ClearBox over the text window, then wPlayerAction back to
+41 -4
View File
@@ -127,6 +127,7 @@ function BindingsMenu:beginCapture(item)
self.onKeyReleased = BindingsMenu.captureKeyRelease
self.onGamepadReleased = BindingsMenu.capturePadRelease
self.onJoystickReleased = BindingsMenu.captureJoyRelease
if Input.armCapture then Input:armCapture() end
end
function BindingsMenu:endCapture()
@@ -138,6 +139,7 @@ function BindingsMenu:endCapture()
self.onKeyReleased = nil
self.onGamepadReleased = nil
self.onJoystickReleased = nil
if Input.disarmCapture then Input:disarmCapture() end
end
-- Escape is the capture's way out, so it is never captured: every other
@@ -232,7 +234,11 @@ function BindingsMenu:storeBinding(slot, value)
b[slot] = value
opts.bindings[item.button.id] = b
item.right = boundRight(opts.bindings, item.button)
if game.writeOptions then game:writeOptions() end
if game.writeOptions then
game:writeOptions()
elseif game.persistOptions then
game:persistOptions()
end
end
-- SELECT: forget one row's rebind and fall back to the defaults. #510's
@@ -246,7 +252,11 @@ function BindingsMenu:clearBinding(item)
end
opts.bindings[item.button.id] = nil
item.right = boundRight(opts.bindings, item.button)
if game.writeOptions then game:writeOptions() end
if game.writeOptions then
game:writeOptions()
elseif game.persistOptions then
game:persistOptions()
end
end
-- START: confirm, then drop the whole overlay (#589). The footer doubles
@@ -264,12 +274,39 @@ function BindingsMenu:confirmReset()
for _, it in ipairs(self.items) do
it.right = boundRight(nil, it.button)
end
if game.writeOptions then game:writeOptions() end
if game.writeOptions then
game:writeOptions()
elseif game.persistOptions then
game:persistOptions()
end
end, { defaultNo = true }))
end
function BindingsMenu:drainCapture()
local events = Input.takeCaptureEvents and Input:takeCaptureEvents()
if not events then return end
for i = 1, #events do
local ev = events[i]
if ev.phase == "pressed" then
if ev.kind == "key" then self:captureKey(ev.value)
elseif ev.kind == "pad" then self:capturePad(ev.value)
elseif ev.kind == "joy" then self:captureJoy(ev.value)
end
else
if ev.kind == "key" then self:captureKeyRelease(ev.value)
elseif ev.kind == "pad" then self:capturePadRelease(ev.value)
elseif ev.kind == "joy" then self:captureJoyRelease(ev.value)
end
end
if not self.capture then return end
end
end
function BindingsMenu:update(dt)
if self.capture then return end -- the raw capture owns the input
if self.capture then
self:drainCapture()
return
end
if self.game.input:wasPressed("start") then
return self:confirmReset()
end
+2
View File
@@ -1,6 +1,7 @@
-- YES/NO choice box (InitYesNoTextBoxParameters: above the text box, right).
local Font = require("src.render.Font")
local UIVisibility = require("src.battle.UIVisibility")
local Theme = require("src.ui.Theme")
local Strings = require("src.core.Strings")
local Timing = require("src.core.Timing")
@@ -67,6 +68,7 @@ function ChoiceBox:update(dt)
end
function ChoiceBox:draw()
if not UIVisibility.bottomVisible(self, false) then return end
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
-- rides the same bottom anchor as the dialogue box it sits above, so the
-- pair travels together (the anchor keeps each element's gap from the edge)
+1
View File
@@ -12,6 +12,7 @@ local MODULES = {
QuantityBox = "src.ui.QuantityBox",
NamingScreen = "src.ui.NamingScreen",
PicBox = "src.ui.PicBox",
PokemonIcon = "src.ui.PokemonIcon",
TextBox = "src.render.TextBox",
Font = "src.render.Font",
Theme = "src.ui.Theme",
+4 -1
View File
@@ -266,7 +266,10 @@ function OakSpeech.new(game, onDone)
-- RedSprite: the walking sprite the pic shrinks into (frame 0 =
-- standing, facing down)
local playerSprites = (game.data.field and game.data.field.playerSprites) or {}
local red = game.data.sprites and game.data.sprites[playerSprites.walk or "SPRITE_RED"] or game.data.sprites.SPRITE_RED
-- The fallback has to read the same guarded table: reaching for
-- game.data.sprites.SPRITE_RED after the `and` already found it nil threw.
local sprites = game.data.sprites or {}
local red = sprites[playerSprites.walk or "SPRITE_RED"] or sprites.SPRITE_RED
self.walkSheet = tryImage(red and red.image)
return self
end
+2 -2
View File
@@ -501,7 +501,7 @@ local function buildRows(game)
return true
end },
-- Haptic feedback for on-screen pad presses (#806): OFF / LIGHT /
-- MEDIUM / HEAVY, where the intensity is a vibration duration --
-- NORMAL / STRONG, where the intensity is a vibration duration --
-- love.system.vibrate takes nothing else. Hidden with TOUCH PAD below,
-- since the only thing that buzzes is a virtual button press.
{ id = "haptics", label = Strings("VIBRATION"),
@@ -515,7 +515,7 @@ local function buildRows(game)
o.haptics = TC.cycleHaptics(o.haptics, dir)
TC:applyOptions(o)
-- sample the level being selected: stepping the row is the only way
-- to compare LIGHT against HEAVY without leaving the menu
-- to compare LIGHT against STRONG without leaving the menu
TC.buzz(o.haptics)
return true
end },
+41
View File
@@ -0,0 +1,41 @@
-- Public read-only Pokemon icon presentation for detached summaries.
-- Resolution and rendering deliberately stay engine-owned: PartyMenu already
-- composes content icon registrations, per-species definitions, asset
-- overrides, and the pokemon.icon hook in one canonical path.
local PartyMenu = require("src.ui.PartyMenu")
local PokemonIcon = {}
local function finite(value)
return type(value) == "number" and value == value
and value ~= math.huge and value ~= -math.huge
end
local function integer(value, minimum)
return finite(value) and value % 1 == 0 and value >= minimum
end
function PokemonIcon.draw(game, summary, x, y, opts)
opts = type(opts) == "table" and opts or {}
if type(game) ~= "table" or type(summary) ~= "table"
or type(summary.species) ~= "string" or summary.species == ""
or not integer(summary.hp, 0) or not integer(summary.maxHp, 1)
or summary.hp > summary.maxHp or not finite(x) or not finite(y)
or (opts.selected ~= nil and type(opts.selected) ~= "boolean")
or (opts.counter ~= nil and not finite(opts.counter)) then
return false, "invalid_pokemon_preview",
"Pokemon icon presentation needs species and valid captured HP values."
end
local ok, message = pcall(PartyMenu.drawIcon, game, {
species = summary.species,
hp = summary.hp,
stats = { hp = summary.maxHp },
}, x, y, opts.selected == true, opts.counter or 0)
if not ok then
return false, "pokemon_icon_failed", tostring(message)
end
return true
end
return PokemonIcon
+94 -7
View File
@@ -49,17 +49,29 @@ end
function Editor.load(opts)
opts = opts or {}
Editor.onClose = opts.onClose
Editor.version = opts.version
Editor.hostPoll = opts.hostPoll == true
Editor.drag = nil
Editor.rects = {}
Editor._hostMouse = false
Editor._hostTouches = nil
Editor.fonts = {
title = love.graphics.newFont(28),
body = love.graphics.newFont(16),
btn = love.graphics.newFont(18),
}
local optsTbl = SaveData.loadOptions()
local applied = optsTbl
if opts.version == "gold" then
local gold = type(optsTbl.gold) == "table" and optsTbl.gold or {}
applied = {
touchControls = gold.touchControls,
haptics = gold.haptics or optsTbl.haptics,
}
end
TouchControls:init()
TouchControls:ensureImages()
TouchControls:applyOptions(optsTbl)
TouchControls:applyOptions(applied)
TouchControls:setPreview(true)
Editor.enabled = TouchControls.enabled ~= false
PadCursor.reset()
@@ -71,17 +83,24 @@ function Editor.unload()
PadCursor.reset()
Editor.drag = nil
Editor.onClose = nil
Editor.version = nil
Editor._hostMouse = false
Editor._hostTouches = nil
end
local function persist()
local opts = SaveData.loadOptions()
local cfg = TouchControls:config()
-- replaces the whole table, so a pre-#633 top-level positions key is
-- dropped once the player saves under the new shape
opts.touchControls = {
local block = {
enabled = cfg.enabled,
layouts = cfg.layouts,
}
if Editor.version == "gold" then
opts.gold = type(opts.gold) == "table" and opts.gold or {}
opts.gold.touchControls = block
else
opts.touchControls = block
end
SaveData.saveOptions(opts)
end
@@ -104,9 +123,7 @@ end
function Editor.update(dt)
PadCursor.update(dt or 0)
-- drag follows the live pointer when love.touch / mouse / pad is available;
-- touchmoved / mousemoved also update, so this is a belt-and-suspenders
-- path for Android where move events can be thin
if Editor.hostPoll then Editor.pollHostPointers() end
if not Editor.drag then return end
local x, y
if Editor.drag.touchId == "pad" then
@@ -297,6 +314,45 @@ local function endDrag(id)
Editor.drag = nil
end
function Editor.pollHostPointers()
local down = love.mouse and love.mouse.isDown and love.mouse.isDown(1)
if down then
local x, y = love.mouse.getPosition()
if not Editor._hostMouse then
Editor._hostMouse = true
beginDrag("mouse", x, y)
else
moveDrag("mouse", x, y)
end
elseif Editor._hostMouse then
Editor._hostMouse = false
endDrag("mouse")
end
if not (love.touch and love.touch.getTouches and love.touch.getPosition) then
return
end
Editor._hostTouches = Editor._hostTouches or {}
local seen = {}
for _, id in ipairs(love.touch.getTouches()) do
seen[id] = true
local ok, tx, ty = pcall(love.touch.getPosition, id)
if ok and tx then
if not Editor._hostTouches[id] then
Editor._hostTouches[id] = true
beginDrag(id, tx, ty)
else
moveDrag(id, tx, ty)
end
end
end
for id in pairs(Editor._hostTouches) do
if not seen[id] then
Editor._hostTouches[id] = nil
endDrag(id)
end
end
end
function Editor.mousepressed(x, y, button)
if button ~= 1 then return end
-- Finger / mouse tap yields the Joy-Con pointer so the click lands where
@@ -391,4 +447,35 @@ function Editor.keypressed(key)
end
end
function Editor.new(game)
local state = { game = game, isOpaque = true }
Editor.hostPoll = true
Editor.load({
version = "gold",
hostPoll = true,
onClose = function()
Editor.hostPoll = false
if game.options then
game.options.touchControls = TouchControls:config()
end
if game.stack and game.stack:top() == state then
game.stack:pop()
end
if game.applyOptions then game:applyOptions() end
end,
})
function state:wantsFillScale() return true end
function state:drawsWidescreen() return true end
function state:update(dt)
Editor.update(dt)
local input = self.game and self.game.input
if input and input:wasPressed("b") then close() end
end
function state:draw() end
function state:drawWidescreen(_w, _h)
Editor.draw()
end
return state
end
return Editor
+132 -15
View File
@@ -92,6 +92,8 @@ local FAINT_SLIDE_FRAMES_PER_ROW = 2
-- (engine/battle/core.asm:3439-3466, data/text/battle.asm:241-249).
local TEXT_NO_WILL_TO_FIGHT = "There's no will to battle!"
local TEXT_EGG_CANT_BATTLE = "An EGG can't battle!"
-- data/text/battle.asm:207
local TEXT_USE_NEXT_MON = "Use next POKéMON?"
-- BattleText_TheMoveIsDisabled / BattleText_TheresNoPPLeftForThisMove
-- (data/text/battle.asm:315-322).
@@ -104,6 +106,10 @@ local TEXT_ASK_FORGET_SLOT = Strings.source("Which move should\nbe forgotten?")
local TEXT_CANT_FORGET_HM = Strings.source("HM moves can't be\nforgotten now.")
local TEXT_STOP_LEARNING = Strings.source("Stop learning\n%s?")
-- BattleText_EnemyIsAboutToUseWillPlayerChangeMon (data/text/battle.asm:222-231).
local TEXT_ENEMY_ABOUT_TO_USE = Strings.source(
"%s\nis about to use\v%s.\fWill %s\nchange POKéMON?")
-- _AskForgetMoveText, all three paragraphs (data/text/common_3.asm:141-165).
local TEXT_ASK_FORGET_MOVE = Strings.source(
"%s is\ntrying to learn\v%s.\fBut %s\ncan't learn more\vthan four moves."
@@ -546,6 +552,14 @@ BattleState.PLAYER_PIC_TILES = 6
-- box's own bottom centre.
local PIC_RESIZE_TILES = { [0] = 6, [1] = 4, [2] = 2, [3] = 7, [4] = 5, [5] = 3 }
-- SUBSTATUS_UNDERGROUND / SUBSTATUS_FLYING, which BattleCommand_Charge sets on
-- FLY and DIG only (engine/battle/effect_commands.asm:5478-5485); the port
-- carries both as the volatile `vanished` flag.
function BattleState.isVanished(mon)
local volatiles = mon and mon.volatile
return (volatiles and volatiles.vanished) and true or false
end
function BattleState:drawPic(mon, back)
-- During the intro slide the player-side pic belongs to presentSlide's
-- backpic overlay, not to the baked bands (see BattleAnimView).
@@ -569,6 +583,13 @@ function BattleState:drawPic(mon, back)
-- The box is empty either because the animation running right now has
-- cleared it, or because the last one ENDED with it cleared (picHidden).
if (anim and anim.hidden) or self.picHidden[side] then return end
-- Mid FLY / DIG the box is empty: DisappearUser ClearBoxes it
-- (engine/battle/misc.asm:1-13), AppearUserRaiseSub puts it back on the
-- stored attack (engine/battle/effect_commands.asm:2113-2117).
if not (trainerBack or enemyTrainer) and BattleState.isVanished(mon)
and not (self.vanishAnim and self.vanishAnim == self.anim) then
return
end
local G = love.graphics
local w, h = image:getDimensions()
local px, py
@@ -580,10 +601,12 @@ function BattleState:drawPic(mon, back)
py = BattleState.PLAYER_PIC_TILE_Y * 8 + (box - h)
boxTiles = BattleState.PLAYER_PIC_TILES
else
-- Bottom-aligned and horizontally centred inside the 7x7 box.
-- PadFrontpic pads a short pic and never a long one
-- (engine/gfx/load_pics.asm:342-386), so an oversized mod pic pins to the
-- box's own corner at hlcoord 12, 0 rather than to a negative offset.
local box = BattleState.ENEMY_PIC_TILES * 8
px = BattleState.ENEMY_PIC_TILE_X * 8 + math.floor((box - w) / 2)
py = BattleState.ENEMY_PIC_TILE_Y * 8 + (box - h)
px = BattleState.ENEMY_PIC_TILE_X * 8 + math.max(0, math.floor((box - w) / 2))
py = BattleState.ENEMY_PIC_TILE_Y * 8 + math.max(0, box - h)
boxTiles = BattleState.ENEMY_PIC_TILES
end
-- One tile per two frames to the right, SlideBattlePicOut's own step.
@@ -901,11 +924,12 @@ function BattleState:startAnim(key, opts)
param = opts.param or 0,
sfxOrder = audio.sfxOrder,
ballPalette = opts.ballPalette,
-- BGEffect_CheckFlyDigStatus reads wPlayerSubStatus3 / wEnemySubStatus3
-- (engine/battle_anims/bg_effects.asm:2838-2851); the port keeps that bit
-- on the mon's volatile table, not on the mon itself.
flying = {
player = self.battle and self.battle.player
and self.battle.player.vanished or false,
enemy = self.battle and self.battle.enemy
and self.battle.enemy.vanished or false,
player = BattleState.isVanished(self.battle and self.battle.player),
enemy = BattleState.isVanished(self.battle and self.battle.enemy),
},
hooks = {
-- anim_sound (engine/battle_anims/anim_commands.asm:1105) calls
@@ -1146,11 +1170,11 @@ function BattleState:advanceQueue()
-- pokegold engine/battle/core.asm:7057-7069: every mon that leveled
-- gets the stats box, not just the mon currently on the field.
self.pendingStatsMon = mon
-- BattleText_StringBuffer1GrewToLevel ends in text_end (battle.asm:336-343),
-- and the active mon never even prints it (core.asm:7044-7056 jumps to the
-- stats box). Either way there is no PromptButton before the stats box.
-- engine/battle/core.asm:7044
if mon and mon == battle.player then
event.text = nil
event.sfx = nil
event.waitSfx = nil
if self.shownHp then
self.shownHp.player = mon.hp or 0
if self.hpAnim and self.hpAnim.side == "player" then
@@ -1270,6 +1294,14 @@ function BattleState:advanceQueue()
return self:askNickname(event.mon)
end
if event.kind == "choose-switch" then
-- engine/battle/core.asm:2590
if self.battle and self.battle.wild then
self.nextMonIndex = 1
self.phase = "ask-next-mon"
self.message = TEXT_USE_NEXT_MON
self.messageTimer = 0
return
end
-- A fainted lead: force a switch before anything else runs.
self.phase = "forced-switch"
self.message = "Choose a POKéMON."
@@ -1355,6 +1387,13 @@ function BattleState:advanceQueue()
end
end
end
-- BattleCommand_Charge runs LoadMoveAnim BEFORE DisappearUser
-- (engine/battle/effect_commands.asm:5459-5470), so FLY / DIG still draw
-- the take-off or the burrow on the turn the substatus goes up; the box
-- only empties once THIS animation is done with.
if BattleState.isVanished(self:activeMon(event.side)) then
self.vanishAnim = self.anim
end
elseif event.kind == "damage" and event.side then
-- ANIM_x_DAMAGE is the MOVE's after-anim (effect_commands.asm:1963-1972),
-- so only a move hit gets it; `animMove` is HandleWrap's (core.asm:1198-1203).
@@ -1854,6 +1893,20 @@ function BattleState:update(_dt)
return
end
-- The prompt's earlier pages: PlaceYesNoBox only follows the LAST one
-- (engine/battle/core.asm:3302-3305), so the mon is named and read first.
if self.phase == "shift-intro" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0
end
return
end
self:nextPage()
if not self.messagePages then self.phase = "ask-shift" end
return
end
-- OfferSwitch's YesNoBox: YES opens PickSwitchMonInBattle, NO (and B) falls
-- straight through to the enemy's send-out (engine/battle/core.asm:3305-3310).
if self.phase == "ask-shift" then
@@ -1876,6 +1929,36 @@ function BattleState:update(_dt)
return
end
-- engine/battle/core.asm:2590
if self.phase == "ask-next-mon" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0
end
return
end
if input:wasPressed("up") or input:wasPressed("down") then
self.nextMonIndex = self.nextMonIndex == 1 and 2 or 1
elseif input:wasPressed("b") then
return self:answerUseNextMon(false)
elseif input:wasPressed("a") then
return self:answerUseNextMon(self.nextMonIndex == 1)
end
return
end
if self.phase == "cant-escape-then-switch" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0
end
return
end
self.message = "Choose a POKéMON."
self.phase = "forced-switch"
return
end
if self.phase == "refuse-shift" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
@@ -2424,13 +2507,38 @@ end
-- (engine/battle/core.asm:3298-3304, data/text/battle.asm:222-231).
function BattleState:offerShiftSwitch(mon)
self.shiftIndex = 1
self.phase = "ask-shift"
local trainer = (self.battle.trainer and self.battle.trainer.name) or "Foe"
local player = (self.save and self.save.player and self.save.player.name)
or "GOLD"
self.message = trainer .. " is about to use " .. self:name(mon)
.. ". Will " .. player .. " change POKéMON?"
-- The `para` splits this in two (data/text/battle.asm:222-231): the incoming
-- mon is NAMED on its own page, and only the second carries the yes/no box.
self:showPages(Strings(TEXT_ENEMY_ABOUT_TO_USE, trainer, self:name(mon), player))
self.phase = self.messagePages and "shift-intro" or "ask-shift"
end
function BattleState:answerUseNextMon(yes)
if yes then
self.phase = "forced-switch"
self.message = "Choose a POKéMON."
return
end
local battle = self.battle
if not battle then
self.phase = "forced-switch"
return
end
local lead = battle.party and battle.party[1]
local pSpd = (lead and lead.stats and lead.stats.speed) or 0
-- engine/battle/core.asm:2614
if battle:tryRun(pSpd) then
self:pushAll(battle:takeEvents())
self.phase = "resolving"
return self:advanceQueue()
end
battle:takeEvents()
self.message = "Can't escape!"
self.messageTimer = MESSAGE_FRAMES
self.phase = "cant-escape-then-switch"
end
-- SetUpBattlePartyMenu + PickSwitchMonInBattle (core.asm:3307-3308), which is
@@ -3173,15 +3281,18 @@ function BattleState:drawPanel()
-- stands.
local asking = self.phase == "ask-nickname" or self.phase == "ask-forget"
or self.phase == "stop-learning" or self.phase == "ask-shift"
or self.phase == "ask-next-mon"
if asking and (self.messageTimer or 0) <= 0 then
-- OfferSwitch calls PlaceYesNoBox with `lb bc, 1, 7`, so its box is at
-- (1,7) instead (engine/battle/core.asm:3303, home/menu.asm:392-410).
local left = self.phase == "ask-shift" and 1 or 14
local left = (self.phase == "ask-shift" or self.phase == "ask-next-mon")
and 1 or 14
Chrome.box(left, 7, 6, 5)
Chrome.print("YES", left + 2, 8)
Chrome.print("NO", left + 2, 10)
local index = self.phase == "ask-nickname" and self.nicknameIndex
or self.phase == "ask-shift" and self.shiftIndex
or self.phase == "ask-next-mon" and self.nextMonIndex
or self.forgetChoice
Chrome.cursor(left + 1, index == 1 and 8 or 10)
end
@@ -3201,7 +3312,13 @@ local STATS_BOX_ROWS = {
-- pokegold engine/battle/core.asm:7060-7066 (box at hlcoord 9,0, stats at 11,y).
function BattleState:drawStatsBox(mon)
local stats = mon and mon.stats
if not mon then return end
local stats = mon.stats
local data = self.game and self.game.data
local def = data and data.pokemon and data.pokemon[mon.species]
if def and def.baseStats then
stats = Mon.stats(def.baseStats, mon.dvs, mon.level, mon.statExp)
end
if not stats then return end
Chrome.textbox(9, 0, 9, 10)
for i, row in ipairs(STATS_BOX_ROWS) do
+4 -22
View File
@@ -75,7 +75,8 @@ local SFX_TRANSACTION = "Sfx_Transaction"
local MartMenu = {}
MartMenu.__index = MartMenu
MartMenu.isOpaque = true
-- engine/items/mart.asm:54
MartMenu.isOpaque = false
-- constants/mart_constants.asm. The `pokemart` macro emits this as one byte
-- ahead of the word mart id, and MartTypeDialogs is indexed by it.
@@ -330,9 +331,6 @@ local function extractedText(text, base, labels)
return out
end
function MartMenu:wantsFillScale() return true end
function MartMenu:drawsWidescreen() return true end
-- data/generated/marts.lua is what the ROM extractor will write out of `Marts`
-- (data/items/marts.asm): `lists` is a 1-based array in MART_* order, each
-- entry an array of item ids, and `bargain` is BargainShopData's own
@@ -896,7 +894,8 @@ local function printPriceOpaque(amount, ty)
end
function MartMenu:drawBuyList()
-- pokegold engine/menus/scrolling_menu.asm _InitScrollingMenu: no border for the buy list
-- engine/items/mart.asm:542
Chrome.box(LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H)
for row = 1, VISIBLE_ROWS do
local i = row + self.scroll
local ty = LIST_Y + (row - 1) * LIST_SPACING
@@ -953,18 +952,14 @@ end
function MartMenu:drawUnder()
local phase = self.phase
if phase == "top" then
Chrome.clear()
self:drawTopMenu()
self:drawTextBox(self.topLines)
elseif phase == "buy" or phase == "buyQuantity" then
Chrome.clear()
self:drawMoneyBox()
self:drawBuyList()
self:drawDescription()
elseif phase == "sell" or phase == "sellQuantity" then
if self.pack then self.pack:drawPanel() end
else
Chrome.clear()
end
end
@@ -1003,17 +998,4 @@ function MartMenu:draw()
self:drawPanel()
end
function MartMenu:drawWidescreen(winW, winH)
local G = love.graphics
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(math.floor((winW - 160 * scale) / 2),
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale)
self:drawPanel()
G.pop()
end
return MartMenu
+55 -21
View File
@@ -119,7 +119,13 @@ function NamingScreen.new(game, opts)
-- The header icon is an OBJ on the cart, so it wears a real palette; without
-- one it would draw in raw DMG shades next to a colored world.
self.iconColors = opts.iconColors
self.gfx = opts.menuGfx
local data = game and game.data or {}
self.gfx = opts.menuGfx or data.gen2MenuGfx
if self.gfx and self.gfx.naming then self.gfx = self.gfx.naming end
-- engine/menus/naming_screen.asm:47
-- engine/gfx/cgb_layouts.asm:488
local diploma = data.gen2Diploma
self.palette = diploma and diploma.palettes and diploma.palettes[1]
self.tiles = {}
if self.gfx then
for _, key in ipairs({ "border", "middleLine", "underLine", "cursor" }) do
@@ -315,6 +321,10 @@ function NamingScreen:update(_dt)
end
end
function NamingScreen:paperColor()
return GbcPalette.color(self.palette, 1)
end
-- The backdrop: one patterned tile repeated over the whole screen. Without
-- menu_gfx.lua (older cache) fall back to a flat mid gray, which keeps the
-- cleared panels readable.
@@ -322,17 +332,25 @@ function NamingScreen:drawBackdrop()
local G = love.graphics
local tile = self.tiles.border
if not tile then
G.setColor(0.62, 0.62, 0.62, 1)
local paper = self:paperColor()
G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
G.rectangle("fill", 0, 0, 160, 144)
G.setColor(1, 1, 1, 1)
return
end
G.setColor(1, 1, 1, 1)
for ty = 0, Chrome.SCREEN_H - 1 do
for tx = 0, Chrome.SCREEN_W - 1 do
G.draw(tile, tx * 8, ty * 8)
local function blit()
G.setColor(1, 1, 1, 1)
for ty = 0, Chrome.SCREEN_H - 1 do
for tx = 0, Chrome.SCREEN_W - 1 do
G.draw(tile, tx * 8, ty * 8)
end
end
end
if self.palette and GbcPalette.available() then
GbcPalette.with(self.palette, blit)
else
blit()
end
end
-- The cursor (data/sprite_anims/oam.asm .OAMData_TextEntryCursor and
@@ -410,7 +428,8 @@ end
function NamingScreen:clearPanel(tx, ty, tw, th)
local G = love.graphics
G.setColor(1, 1, 1, 1)
local paper = self:paperColor()
G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
G.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8)
G.setColor(0, 0, 0, 1)
end
@@ -472,12 +491,13 @@ function NamingScreen:drawPanel()
G.draw(self.iconImage, quad, 16, 16)
end
end
local pal = self.palette
if self.monName then
-- Nickname header is two lines: "<MON>'S" then "NICKNAME?".
Chrome.print(self.monName .. "'S", 5, 2)
Chrome.print("NICKNAME?", 5, 4)
Chrome.printThrough(self.monName .. "'S", 5, 2, pal)
Chrome.printThrough("NICKNAME?", 5, 4, pal)
else
Chrome.print(self.prompt, 5, 2)
Chrome.printThrough(self.prompt, 5, 2, pal)
end
self:drawEntry(5, self.isBox and 4 or 6)
@@ -489,17 +509,22 @@ function NamingScreen:drawPanel()
for col = 0, 8 do
local ch = line[col + 1]
if ch and ch ~= " " and ch ~= "" then
Chrome.print(ch, 2 + col * 2, keyboardTop + row * 2)
Chrome.printThrough(ch, 2 + col * 2, keyboardTop + row * 2, pal)
end
end
end
local labels = self.lower and BOTTOM_LOWER_LABELS or BOTTOM_UPPER_LABELS
local bottomY = keyboardTop + bottom * 2
for i, label in ipairs(labels) do
Chrome.print(label, BOTTOM_LABEL_TX[i], bottomY)
Chrome.printThrough(label, BOTTOM_LABEL_TX[i], bottomY, pal)
end
self:drawCursorBox(self:cursorTile())
local function cursor() self:drawCursorBox(self:cursorTile()) end
if pal and GbcPalette.available() then
GbcPalette.with(pal, cursor)
else
cursor()
end
G.setColor(1, 1, 1, 1)
end
@@ -511,23 +536,32 @@ function NamingScreen:drawWidescreen(winW, winH)
local G = love.graphics
-- The naming screen's own patterned backdrop is the surround: extend it to
-- the window edges so a widescreen boot has no black pillarbox.
self:drawBackdrop()
local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
G.setColor(0.62, 0.62, 0.62, 1)
local paper = self:paperColor()
G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
G.rectangle("fill", 0, 0, winW, winH)
G.setColor(1, 1, 1, 1)
if self.tiles.border then
local tilesX = math.ceil(winW / (8 * scale))
local tilesY = math.ceil(winH / (8 * scale))
G.push()
G.scale(scale, scale)
for ty = 0, tilesY do
for tx = 0, tilesX do
G.draw(self.tiles.border, tx * 8, ty * 8)
local tile = self.tiles.border
local function blit()
G.setColor(1, 1, 1, 1)
G.push()
G.scale(scale, scale)
for ty = 0, tilesY do
for tx = 0, tilesX do
G.draw(tile, tx * 8, ty * 8)
end
end
G.pop()
end
if self.palette and GbcPalette.available() then
GbcPalette.with(self.palette, blit)
else
blit()
end
G.pop()
end
G.push()
G.translate(ox, oy)
+42 -4
View File
@@ -172,6 +172,34 @@ local ROWS = {
text = function(options)
return require("src.render.GBCFX").levelLabel(options.gbcfx or 0)
end },
{ id = "touchControls", label = "TOUCH PAD", port = true,
text = function(options)
local tc = options.touchControls
local on = not (type(tc) == "table" and tc.enabled == false)
return on and "ON" or "OFF"
end,
cycle = function(options, _delta, game)
local tc = type(options.touchControls) == "table" and options.touchControls or {}
tc.enabled = tc.enabled == false
options.touchControls = tc
require("src.core.TouchControls"):applyOptions(options)
if game and game.persistOptions then game:persistOptions() end
end },
{ id = "touchLayout", label = "TOUCH LAYOUT", port = true,
activate = function(game)
game.stack:push(require("src.ui.TouchControlsEditor").new(game))
end },
{ id = "haptics", label = "VIBRATION", port = true,
text = function(options)
return require("src.core.TouchControls").hapticLabel(options.haptics)
end,
cycle = function(options, delta, game)
local TC = require("src.core.TouchControls")
options.haptics = TC.cycleHaptics(options.haptics, delta)
TC:applyOptions(options)
TC.buzz(options.haptics)
if game and game.persistOptions then game:persistOptions() end
end },
{ label = "CANCEL", cancel = true },
}
@@ -196,11 +224,18 @@ local function sameRows(_, rows) return rows end
-- COLOR) simply appear in the list the hook receives.
local function buildRows()
local rows = {}
local env = os.getenv("POKEPORT_TOUCH")
local osName = love.system and love.system.getOS and love.system.getOS()
local showTouch = env == "1"
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
for i, row in ipairs(ROWS) do
local copy = {}
for key, value in pairs(row) do copy[key] = value end
copy.id = copy.id or copy.key or (copy.cancel and "cancel") or nil
rows[i] = copy
if showTouch or (row.id ~= "touchControls" and row.id ~= "touchLayout"
and row.id ~= "haptics") then
local copy = {}
for key, value in pairs(row) do copy[key] = value end
copy.id = copy.id or copy.key or (copy.cancel and "cancel") or nil
rows[#rows + 1] = copy
end
end
return rows
end
@@ -291,6 +326,9 @@ end
function OptionsMenu:leave_()
if self.onDone then self.onDone(self.options) end
if self.game and self.game.stack and self.game.stack:top() == self then
self.game.stack:pop()
end
end
function OptionsMenu:update(_dt)
+66
View File
@@ -354,6 +354,12 @@ function PackMenu:useSelected()
end
return
end
-- engine/items/tmhm.asm:73
local def = self.items and self.items[row.id]
if def and def.teaches then
self:openTeachParty(row)
return
end
-- UseItem's jumptable runs off ITEMATTR's field-menu nibble, and the first
-- four entries are all .Oak -- an X ATTACK or a POKé DOLL used from the
-- field PACK prints OakThisIsntTheTimeText and goes nowhere. Only the
@@ -586,6 +592,66 @@ function PackMenu:giveToSlot(slot, row)
held:giveItem(row.id)
end
-- engine/items/tmhm.asm:73
function PackMenu:openTeachParty(row)
local game = self.game
local party = (self.save and self.save.party) or {}
if #party == 0 then
self.message = NO_POKEMON
return
end
if not (game and game.stack) then return end
if not pcall(Screens.get, game, "Gen2PartyMenu") then return end
local def = self.items and self.items[row.id]
local moveId = def and def.teaches
local moves = game.data and game.data.moves
local moveDef = moves and moves[moveId]
local moveName = (moveDef and moveDef.name) or moveId
self.staleRows = true
Screens.push(game, "Gen2PartyMenu", {
save = self.save,
prompt = "teach",
tmhm = { move = moveId },
onCancel = function()
game.stack:pop()
self:rebuild()
end,
onChoose = function(_slot, mon)
game.stack:pop()
local species = game.data and game.data.pokemon
and game.data.pokemon[mon.species]
local allowed = false
for _, id in ipairs((species and species.tmhm) or {}) do
if id == moveId then allowed = true end
end
if not allowed then
if game.say then
game:say(("%s can't learn %s!"):format(
mon.nickname or mon.species or "?", moveName))
end
return
end
for _, move in ipairs(mon.moves or {}) do
if move.id == moveId then
if game.say then
game:say(("%s already knows %s!"):format(
mon.nickname or mon.species or "?", moveName))
end
return
end
end
if not game.learnMoveOn then return end
game:learnMoveOn(mon, moveId, function(learned)
if not learned then return end
if tostring(row.id):sub(1, 3) == "HM_" then return end
require("src.core.gen2.Happiness").change(mon, "LEARNMOVE")
if game.consumeItem then game:consumeItem(row.id) end
self:rebuild()
end)
end,
})
end
function PackMenu:update(_dt)
local input = self.game and self.game.input
if not input then return end
+37 -3
View File
@@ -22,6 +22,7 @@ local GbcPalette = require("src.render.GbcPalette")
local HpBar = require("src.battle.gen2.HpBar")
local Logger = require("src.core.Logger")
local Mail = require("src.core.gen2.Mail")
local Mon = require("src.battle.gen2.Mon")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
@@ -90,11 +91,20 @@ function PartyMenu.new(game, opts)
self.save = save
self.party = opts.party or (save and save.party) or {}
local data = game and game.data or {}
-- engine/pokemon/move_mon.asm:1402
for i = 1, #self.party do
Mon.refreshStats(self.party[i], data)
end
self.icons = opts.icons or data.gen2Icons
self.palettes = opts.palettes or data.gen2Palettes
self.pokemon = opts.pokemon or data.pokemon
self.prompt = PartyMenu.PROMPTS[opts.prompt or "choose"] or opts.prompt
or PartyMenu.PROMPTS.choose
-- engine/pokemon/party_menu.asm:297
self.tmhm = opts.tmhm
if self.tmhm and (opts.prompt == nil or opts.prompt == "teach") then
self.prompt = PartyMenu.PROMPTS.teach
end
self.onChoose = opts.onChoose
self.onCancel = opts.onCancel
self.moves = opts.moves or data.moves
@@ -469,7 +479,14 @@ function PartyMenu:update(_dt)
elseif self.wantsSubmenu or self.wantsBattleSubmenu then
self:openSubmenu()
elseif self.onChoose then
self.onChoose(self.index, self.party[self.index])
local mon = self.party[self.index]
if self.tmhm and mon and mon.isEgg then
-- engine/items/tmhm.asm:104
local world = self.game and self.game.world
if world and world.playSfxNamed then world:playSfxNamed("Sfx_Wrong") end
return
end
self.onChoose(self.index, mon)
end
elseif input:wasPressed("b") then
self:storeCursor()
@@ -674,6 +691,18 @@ function PartyMenu.rowFor(mon)
}
end
-- engine/pokemon/party_menu.asm:331
function PartyMenu:tmhmAble(mon)
if not mon or mon.isEgg then return nil end
local move = self.tmhm and self.tmhm.move
if not move then return nil end
local species = self.pokemon and self.pokemon[mon.species]
for _, id in ipairs((species and species.tmhm) or {}) do
if id == move then return "ABLE" end
end
return "NOT ABLE"
end
-- WritePartyMenuTilemap, jumptable entry by jumptable entry. Every coordinate
-- below is the hlcoord the matching PARTYMENUQUALITY_* routine uses, and each
-- steps 2 * SCREEN_WIDTH per mon:
@@ -712,10 +741,15 @@ function PartyMenu:drawPanel()
self:drawIcon(mon, self:iconX(i), 4 + (i - 1) * 16 + self:iconBob(i))
local row = PartyMenu.rowFor(mon)
Chrome.print(row.name, 3, nameY)
if row.hp then Chrome.print(row.hp, 13, nameY) end
if self.tmhm then
local able = self:tmhmAble(mon)
if able then Chrome.print(able, 12, dataY) end
else
if row.hp then Chrome.print(row.hp, 13, nameY) end
if row.hp then self:drawHpBar(mon, 11, dataY) end
end
if row.status then Chrome.print(row.status, 5, dataY) end
if row.level then Chrome.print(row.level, 8, dataY) end
if row.hp then self:drawHpBar(mon, 11, dataY) end
end
-- .end does `dec hl` twice from the row past the last nickname, so CANCEL
+27 -9
View File
@@ -1084,16 +1084,20 @@ function Pokegear:update(_dt)
-- answers -1 rather than backing out to the strip.
if self.fly then return self:updateFlyMap(input) end
if self.mode == "strip" then
if input:wasPressed("left") then
self.cardIndex = self.cardIndex > 1 and self.cardIndex - 1 or #self.cards
elseif input:wasPressed("right") then
self.cardIndex = self.cardIndex < #self.cards and self.cardIndex + 1 or 1
elseif input:wasPressed("a") then
self.mode = "card"
elseif input:wasPressed("b") then
if self.onClose then self.onClose() end
local stripCard = self:card()
if not (stripCard and stripCard.id == "phone") then
if input:wasPressed("left") then
self.cardIndex = self.cardIndex > 1 and self.cardIndex - 1 or #self.cards
elseif input:wasPressed("right") then
self.cardIndex = self.cardIndex < #self.cards and self.cardIndex + 1 or 1
elseif input:wasPressed("a") then
self.mode = "card"
elseif input:wasPressed("b") then
if self.onClose then self.onClose() end
end
return
end
return
self.mode = "card"
end
-- Inside a card.
local card = self:card()
@@ -1105,6 +1109,19 @@ function Pokegear:update(_dt)
-- submenu is waiting for.
local phoneBusy = card and card.id == "phone"
and (self.call ~= nil or self.phoneSubmenu ~= nil)
-- engine/pokegear/pokegear.asm:799
if card and card.id == "phone" and not phoneBusy then
if input:wasPressed("b") then
if self.onClose then self.onClose() end
return
elseif input:wasPressed("left") then
self:switchCard("map", "clock")
return
elseif input:wasPressed("right") then
self:switchCard("radio")
return
end
end
if not phoneBusy and input:wasPressed("b") then
self.mode = "strip"
self:stopRadio()
@@ -2057,6 +2074,7 @@ function Pokegear:drawPlayerIcon(x, y)
local beat = math.floor((self.iconTimer or 0) / 8)
-- .OAMData_RedWalk (data/sprite_anims/oam.asm:314-319) hangs its four tiles
-- at -8,-8; camY of -4 undoes the world's sprite lift.
love.graphics.setColor(1, 1, 1, 1)
self.playerIcon:draw(x - 8, y - 8, 0, -4, "down",
beat % 2, beat == 3)
return true
+6
View File
@@ -261,6 +261,8 @@ function SummaryMenu.new(game, opts)
math.min(opts.index or 1, math.max(1, #self.party)))
self.mon = self.party[self.index]
end
-- engine/pokemon/move_mon.asm:1402
Mon.refreshStats(self.mon, data)
self.page = opts.page or PINK_PAGE
-- ManagePokemonMoves opens straight onto MoveScreenLoop's screen; SELECT off
-- the green page reaches the same view with the stats pages still behind it.
@@ -678,6 +680,8 @@ function SummaryMenu:switchMon(delta)
if next_ < 1 or next_ > #self.party then return false end
self.index = next_
self.mon = self.party[next_]
-- engine/pokemon/move_mon.asm:1402
Mon.refreshStats(self.mon, self.game and self.game.data)
self.moveIndex = 1
self:playCry()
return true
@@ -694,6 +698,8 @@ function SummaryMenu:switchMonPastEggs(delta)
if next_ < 1 or next_ > #self.party then return false end
self.index = next_
self.mon = self.party[next_]
-- engine/pokemon/move_mon.asm:1402
Mon.refreshStats(self.mon, self.game and self.game.data)
self.moveIndex = 1
self:playCry()
return true
+30 -3
View File
@@ -45,6 +45,7 @@
local Anim = require("src.core.gen2.TradeAnim")
local Assets = require("src.render.Assets")
local Chrome = require("src.ui.gen2.Chrome")
local Font = require("src.render.Font")
local GbcPalette = require("src.render.GbcPalette")
local Music = require("src.core.Music")
local Palettes = require("src.world.gen2.Palettes")
@@ -202,6 +203,15 @@ local TEMPLATE_ROWS = {
{ row = 6, text = "<ID>№." },
}
-- gfx/sgb/predef.pal:29
local function scale5(value) return math.floor(value * 255 / 31 + 0.5) end
local TRADE_TUBE_PAL = {
{ scale5(31), scale5(31), scale5(31) },
{ scale5(18), scale5(20), scale5(27) },
{ scale5(11), scale5(15), scale5(23) },
{ 0, 0, 0 },
}
--------------------------------------------------------------------------
-- Construction
--------------------------------------------------------------------------
@@ -367,8 +377,22 @@ end
-- reading through.
-- `index` is a GB shade, 0 (white) to 3 (black); GbcPalette.color counts from
-- 1, as the palettes themselves do.
function TradeAnimView:bgColors()
local id = (self.beat or {}).id or ""
local pan = id:find("_pan_", 1, true)
if not pan then
return Palettes.textColors(self.palettes)
end
local colors = TRADE_TUBE_PAL
-- engine/movie/trade_animation.asm:1271
if math.floor((self.frame or 0) / 8) % 2 == 1 then
colors = { colors[1], colors[3], colors[2], colors[4] }
end
return colors
end
function TradeAnimView:shade(index)
local colors = Palettes.textColors(self.palettes)
local colors = self:bgColors()
local rgb = GbcPalette.color(colors, index + 1)
or ({ { 255, 255, 255 }, { 168, 168, 168 }, { 96, 96, 96 },
{ 0, 0, 0 } })[index + 1]
@@ -384,7 +408,7 @@ end
-- and GbcPalette maps them. A driver with no shader draws the greys, which
-- is the DMG ramp and not a black frame.
function TradeAnimView:through(body)
local colors = Palettes.textColors(self.palettes)
local colors = self:bgColors()
love.graphics.setColor(1, 1, 1, 1)
if colors and GbcPalette.available() then
GbcPalette.with(colors, body)
@@ -543,7 +567,7 @@ function TradeAnimView:drawStats(record, offset)
G.setColor(1, 1, 1, 1)
G.rectangle("fill", (PANEL_X + 1) * 8, PANEL_Y * 8, 9 * 8, 8)
for _, row in ipairs(TEMPLATE_ROWS) do
Chrome.print(row.text, PANEL_X + 1, row.row)
Chrome.print(Strings(row.text), PANEL_X + 1, row.row)
end
Chrome.print(Chrome.number(record.dex or 0, 3, true), PANEL_X + 7, 0)
Chrome.print(record.name, PANEL_X + 1, 2)
@@ -767,6 +791,8 @@ end
function TradeAnimView:drawPanel()
local id = (self.beat or {}).id
local t = self.offset or 0
-- engine/movie/trade_animation.asm:151
local wasBattle = Font.useBattleExtra(true)
Chrome.clear()
if GIVE_BEATS[id] then
@@ -794,6 +820,7 @@ function TradeAnimView:drawPanel()
end
end
love.graphics.setColor(1, 1, 1, 1)
Font.useBattleExtra(wasBattle)
end
function TradeAnimView:drawTubeBeat(id, t)
+48 -13
View File
@@ -80,7 +80,8 @@ local function purgeBundledModules()
end
end
-- Boot.probePayload(rel) -> { engine = string, minShell = number } | nil, err
-- Boot.probePayload(rel)
-- -> { engine = string, minShell = number, payloadHost = string } | nil, err
--
-- Mount the archive at rel (a save-directory-relative path) on an isolated
-- mountpoint, read its src/core/Version.lua by executing the source with
@@ -104,24 +105,50 @@ function Boot.probePayload(rel)
if type(v) ~= "table" or type(v.engine) ~= "string" then
return nil, "payload has no usable Version table"
end
return { engine = v.engine, minShell = tonumber(v.minShell) or 1 }
return {
engine = v.engine,
minShell = tonumber(v.minShell) or 1,
payloadHost = type(v.payloadHost) == "string" and v.payloadHost or "love",
}
end
-- Boot.select(candidates, bundledEngine, bundledShell) -> chosen | nil, toDelete
-- Pure host gate shared by boot selection and the download worker. Missing
-- payloadHost fields mean "love" so payloads made before this contract remain
-- compatible with ordinary LOVE packages.
function Boot.canHost(info, bundledShell, bundledPayloadHost)
if type(info) ~= "table" then return false end
local payloadHost = type(info.payloadHost) == "string"
and info.payloadHost or "love"
local host = type(bundledPayloadHost) == "string"
and bundledPayloadHost or "love"
return payloadHost == host and (tonumber(info.minShell) or 1)
<= (tonumber(bundledShell) or 1)
end
local function samePayloadHost(info, bundledPayloadHost)
local payloadHost = type(info.payloadHost) == "string"
and info.payloadHost or "love"
local host = type(bundledPayloadHost) == "string"
and bundledPayloadHost or "love"
return payloadHost == host
end
-- Boot.select(candidates, bundledEngine, bundledShell, bundledPayloadHost)
-- -> chosen | nil, toDelete
--
-- Pure (no love.*): decide which payload to run and which to delete.
-- candidates is a list of { name = , engine = , minShell = }.
-- candidates is a list of { name = , engine = , minShell = , payloadHost = }.
-- * chosen: the highest engine that is STRICTLY newer than bundledEngine and
-- whose minShell <= bundledShell (a payload the running shell can host).
-- whose payloadHost matches and minShell <= bundledShell.
-- * toDelete: stale payloads -- engine <= bundled (old or the same as what we
-- already ship), or superseded by the chosen one (not newer than chosen).
-- A payload newer than the chosen one but unrunnable here (minShell too
-- high) is kept: a future shell upgrade may be able to run it.
function Boot.select(candidates, bundledEngine, bundledShell)
-- A newer incompatible payload is kept: a matching host or future shell
-- may be able to run it.
function Boot.select(candidates, bundledEngine, bundledShell, bundledPayloadHost)
local chosen
for _, c in ipairs(candidates) do
local newer = Semver.compare(c.engine, bundledEngine) > 0
local runnable = (c.minShell or 1) <= bundledShell
local runnable = Boot.canHost(c, bundledShell, bundledPayloadHost)
if newer and runnable then
if not chosen or Semver.compare(c.engine, chosen.engine) > 0 then
chosen = c
@@ -132,9 +159,15 @@ function Boot.select(candidates, bundledEngine, bundledShell)
local toDelete = {}
for _, c in ipairs(candidates) do
if not (chosen and c.name == chosen.name) then
local stale = Semver.compare(c.engine, bundledEngine) <= 0
if chosen and Semver.compare(c.engine, chosen.engine) <= 0 then
stale = true
local stale = false
-- Never clean up another host family's payloads. A shared save directory
-- may be opened by multiple native packages, and only the matching host
-- can decide whether one of its own archives is stale.
if samePayloadHost(c, bundledPayloadHost) then
stale = Semver.compare(c.engine, bundledEngine) <= 0
if chosen and Semver.compare(c.engine, chosen.engine) <= 0 then
stale = true
end
end
if stale then toDelete[#toDelete + 1] = c.name end
end
@@ -223,6 +256,7 @@ local function runInner(args)
name = entry,
engine = info.engine,
minShell = info.minShell,
payloadHost = info.payloadHost,
}
end
end
@@ -230,7 +264,8 @@ local function runInner(args)
end
local Version = require("src.core.Version")
local chosen, toDelete = Boot.select(candidates, Version.engine, Version.shell)
local chosen, toDelete = Boot.select(candidates, Version.engine,
Version.shell, Version.payloadHost)
for _, victim in ipairs(toDelete) do
love.filesystem.remove(PAYLOAD_DIR .. "/" .. victim)
+3 -2
View File
@@ -153,8 +153,9 @@ local function gatePasses(rel)
local info = Boot.probePayload(rel)
if not info then return true end
local shell = (Version and Version.shell) or 1
if info.minShell and info.minShell > shell then return false end
return true
local payloadHost = (Version and Version.payloadHost) or "love"
if Boot.canHost then return Boot.canHost(info, shell, payloadHost) end
return not (info.minShell and info.minShell > shell)
end
-- ---------------------------------------------------------------------------
+29 -6
View File
@@ -462,8 +462,18 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
if not keepMusic then
-- ..(home/overworld.asm ln 2346)
local Music = require("src.core.Music")
-- opts.freshBoot: switch instantly instead of cross-fading, like every
-- other map's PlayDefaultMusic on real hardware -- set only by
-- Game.lua's hard state teleports (onContinue, New Game, F2,
-- restoreCheckpointSave). Deliberately separate from opts.via ==
-- "boot" itself: dev tooling (src/dev/Console.lua's warp verb,
-- src/dev/HotReload.lua's reloadMap) reuses that same default for the
-- surf-restore/fresh-npc-pool branches above and must keep the
-- ordinary crossfade.
local fade = Music.MAP_FADE
if opts and opts.freshBoot then fade = nil end
Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing,
Music.MAP_FADE)
fade)
end
-- forced bike/surf tiles fire the moment the player is placed on the
@@ -1387,14 +1397,16 @@ function OverworldState:checkLedgeHop(dir)
return false
end
require("src.core.Sound").play(Game.data, "Ledge")
p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic)
local hop = (p.stepFramesCur or p.stepFrames or 16) * 2
p.hopFrames, p.hopTotal = hop, hop -- jump arc (cosmetic)
self:scriptMove(p, dir, 1, function() self:checkEdgeExit(dir) end)
return true
end
if not Collision.occupied(self.entities, lx, ly, p)
and self.map:isWalkableCell(lx, ly) then
require("src.core.Sound").play(Game.data, "Ledge")
p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic)
local hop = (p.stepFramesCur or p.stepFrames or 16) * 2
p.hopFrames, p.hopTotal = hop, hop -- jump arc (cosmetic)
self:scriptMove(p, dir, 2)
return true
end
@@ -3505,10 +3517,21 @@ end
function OverworldState:onStepComplete()
local p = self.player
local suppressWildEncounter = self.wildEncounterGraceSteps > 0
if suppressWildEncounter then
self.wildEncounterGraceSteps = self.wildEncounterGraceSteps - 1
-- Defaulted: a state built without the constructor (a mod harness, a test
-- fixture) reaches this before :234 ever ran, and nil > 0 threw the step.
local grace = self.wildEncounterGraceSteps or 0
if grace > 0 then
self.wildEncounterGraceSteps = grace - 1
end
-- TryDoWildEncounter's first guard is `ld a, [wNPCMovementScriptPointerTable
-- Num] / and a / ret nz` (engine/battle/wild_encounters.asm:3-9): a step the
-- player did not take never rolls, which is why Oak's escort walks to the lab
-- through Pallet's grass without being jumped.
local runner = self.runner
local scripted = (runner and runner.isRunning and runner:isRunning())
or #(self.scriptMoves or {}) > 0
or self.engaging or self.emote or self.teleportOut
local suppressWildEncounter = grace > 0 or scripted and true or false
self.todSteps = (self.todSteps or 0) + 1
-- UpdatePikachuHappinessAndMood rides the step counter (poison.asm)
require("src.world.PikachuFollower").onStep(Game.save)
+34
View File
@@ -442,6 +442,27 @@ function NPC:updateTreeShake()
return true
end
function NPC:scriptRockSmash(frames)
-- engine/overworld/map_objects.asm:1462
self.rockSmash = {
frame = 0,
frames = frames or 10,
}
self.frozen = true
return true
end
function NPC:updateRockSmash()
local st = self.rockSmash
if not st then return false end
st.frame = st.frame + 1
if st.frame >= st.frames then
self.rockSmash = nil
return false
end
return true
end
-- `passable` is the follower's escape (src/world/gen2/Follower.lua), the same
-- name and meaning src/world/Collision.lua:20 gives it under Gen 1.
local function occupied(entities, tx, ty, self)
@@ -514,6 +535,10 @@ function NPC:update(map, entities)
self:updateTreeShake()
return
end
if self.rockSmash then
self:updateRockSmash()
return
end
-- NPC_CHANGE_FACING (src/world/NPC.lua:71): one walk cycle in place, no
-- translation. Above the moving arm because it has no targetX to reach,
-- and the arm below would assign cellX = nil a frame later.
@@ -726,6 +751,15 @@ function NPC:draw(ox, oy, scale)
self.sprite:draw(
self.px, self.py + yOffset, 0, 0,
facing, 0, false, false, q == 3)
elseif self.rockSmash then
-- engine/overworld/map_objects.asm:1462
if (self.rockSmash.frame % 2) == 0 then
G.pop()
return
end
self.sprite:draw(
self.px, self.py + yOffset, 0, 0,
self.facing, self:walkPhase(), self.stepFlip)
else
self.sprite:draw(
self.px, self.py + yOffset, 0, 0,
+21 -3
View File
@@ -17,6 +17,12 @@ local TURN_FRAMES = 4
-- at the walking rate, which is what stops a bike step flickering the legs.
Player.STEP_FRAMES = STEP_FRAMES
-- engine/overworld/map_objects.asm:1815
local JUMP_Y = {
-4, -6, -8, -10, -11, -12, -12, -12,
-11, -10, -9, -8, -6, -4, 0, 0,
}
function Player.new(cx, cy, facing, spriteDef)
local self = setmetatable({
cellX = cx, cellY = cy,
@@ -170,9 +176,11 @@ function Player:update()
self.px = self.cellX * 16 + dx * adv
self.py = self.cellY * 16 + dy * adv
if self.jumping then
-- pokegold engine/overworld/map_objects.asm: UpdateJumpPosition's
-- y_offsets table peaks at -12.
self.py = self.py - math.floor(12 * math.sin(math.pi * self.progress / frames))
-- engine/overworld/map_objects.asm:1815
local idx = math.floor((self.progress - 1) / 2) + 1
if idx < 1 then idx = 1 end
if idx > #JUMP_Y then idx = #JUMP_Y end
self.spriteYOffset = JUMP_Y[idx]
end
if self.progress >= frames then
self.cellX, self.cellY = self.targetX, self.targetY
@@ -180,6 +188,7 @@ function Player:update()
self.px, self.py = self.cellX * 16, self.cellY * 16
self.moving = false
self.jumping = nil
self.spriteYOffset = 0
self.stepFlip = not self.stepFlip
return true
end
@@ -193,6 +202,15 @@ function Player:draw(ox, oy, scale)
-- standing on. StepFunction_GotBite's `xor 1` rod bob and the fly take-off
-- lift both ride this one byte.
local yOffset = self.spriteYOffset or 0
if self.jumping then
-- engine/overworld/map_objects.asm:1995
local gx = ox + self.px * scale
local gy = oy + self.py * scale
local s = 16 * scale
G.setColor(0, 0, 0, 0.4)
G.ellipse("fill", gx + s * 0.5, gy + s * 0.85, s * 0.35, s * 0.12)
G.setColor(1, 1, 1, 1)
end
if self.sprite then
G.push()
G.translate(ox, oy)
+62 -4
View File
@@ -621,6 +621,8 @@ function World.new(game)
-- which is why a cut tree is back the next time you walk in. Restoring
-- these at the top of setMap is that refill.
blockEdits = {},
-- engine/overworld/map_setup.asm:78
objectSpawns = {},
-- A field move that is mid-flow (the used-X text, then its effect).
fieldMove = nil,
-- ---- state the script VM owns ------------------------------------------
@@ -1819,6 +1821,15 @@ function World:moveObject(objectId, cellX, cellY)
local def = self.map and self.map.def
local obj = def and def.objects and def.objects[index]
if not (obj and cellX and cellY) then return end
local mapId = self.map and self.map.id
local key = obj.index or index
if mapId then
self.objectSpawns = self.objectSpawns or {}
self.objectSpawns[mapId] = self.objectSpawns[mapId] or {}
if not self.objectSpawns[mapId][key] then
self.objectSpawns[mapId][key] = { obj.x, obj.y }
end
end
obj.x, obj.y = cellX, cellY
local npc = self:objectEntity(objectId)
if npc and npc ~= self.player then
@@ -3715,6 +3726,16 @@ function World:updateMovement()
while st.i <= #st.bytes do
local b = st.bytes[st.i]
st.i = st.i + 1
-- engine/overworld/movement.asm:163
if b == 0x57 then
local duration = st.bytes[st.i] or 0
st.i = st.i + 1
if ent.scriptRockSmash then
ent:scriptRockSmash(duration)
end
st.sleep = duration
return
end
local act = Movement.decodeByte(b)
if act.kind == "end" then
-- SLIDING_F is an object flag, not a stream one, so a stream that never
@@ -3925,7 +3946,8 @@ function World:rollEncounter(kind, terrain, tables, vanilla)
local ctx = {
mapId = map and map.id,
terrain = terrain,
rng = love.math.random,
-- Same guard World:rockRandom uses: a headless suite has no love global.
rng = (love and love.math and love.math.random) or math.random,
kind = kind,
daytime = self.daytime,
environment = map and map.def and map.def.environment,
@@ -5237,6 +5259,38 @@ function World:restoreBlocks()
return any
end
function World:restoreObjectSpawns()
-- engine/overworld/map_setup.asm:78
local spawns = self.objectSpawns
if not spawns then return end
for mapId, byIndex in pairs(spawns) do
local def = self.maps and self.maps[mapId]
local objects = def and def.objects
if objects then
for key, xy in pairs(byIndex) do
local obj
for _, row in ipairs(objects) do
if (row.index or 0) == key then obj = row break end
end
if obj then
obj.x, obj.y = xy[1], xy[2]
end
local npc = self.npcPool
and self.npcPool[string.format("%s_obj_%d", mapId, key)]
if npc then
npc.cellX, npc.cellY = xy[1], xy[2]
npc.px, npc.py = xy[1] * 16, xy[2] * 16
npc.homeX, npc.homeY = xy[1], xy[2]
npc.moving = false
npc.progress = 0
npc.targetX, npc.targetY = nil, nil
end
end
end
spawns[mapId] = nil
end
end
-- Drop the loaded map's baked canvases and bake again. Same shape as what
-- pollTimeOfDay does when the clock rolls the palette over; a block edit
-- invalidates the bake for the same reason a palette change does. A world with
@@ -5360,8 +5414,10 @@ end
-- picked, and TextBox reads it back off game.stringBuffer.
function World:setNickname(mon)
if not self.game then return end
self.game.stringBuffer =
(mon and (mon.nickname or mon.name or mon.species)) or ""
local name = (mon and (mon.nickname or mon.name or mon.species)) or ""
self.game.stringBuffer = name
-- engine/events/overworld.asm:1339
if self.vm then self.vm.stringBuffer = name end
end
function World:playMonCry(mon)
@@ -8304,6 +8360,7 @@ function World:setMap(mapId, cx, cy, facing, opts)
-- and WHIRLPOOL swapped out goes back: a cut tree is standing again the next
-- time the map is loaded, and this has to happen before Map.new reads them.
self:restoreBlocks()
self:restoreObjectSpawns()
-- HandleNewMap (home/map.asm:216-228) runs ResetMapBufferEventFlags before
-- anything else that touches state: event flags 0-7
-- (EVENT_TEMPORARY_UNTIL_MAP_RELOAD) die on every map load, which is what
@@ -8784,7 +8841,8 @@ function World:tryLedgeJump(dir)
-- ShakeGrass (engine/overworld/movement.asm:741-770).
p.inGrass, p.grassShake = false, nil
p.progress = 0
p.stepFrames = Player.STEP_FRAMES
-- engine/overworld/map_objects.asm:1163
p.stepFrames = Player.STEP_FRAMES * 2
self:playSfxNamed("Sfx_JumpOverLedge", SFX_JUMP_OVER_LEDGE)
return true
end
+80
View File
@@ -0,0 +1,80 @@
-- #1091: does GAME SPEED change the wild encounter rate per STEP?
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1091_test.lua love .
local Encounter = require("src.battle.gen2.Encounter")
local Player = require("src.world.gen2.Player")
local Mon = require("src.battle.gen2.Mon")
return function(game)
local rolls, hits, steps = 0, 0, 0
local realTriggers = Encounter.triggers
Encounter.triggers = function(rate, random)
rolls = rolls + 1
if realTriggers(rate, random) then hits = hits + 1 end
return false
end
local World = require("src.world.gen2.World")
local ticks = 0
local realWorldStep = World.step
World.step = function(self)
ticks = ticks + 1
return realWorldStep(self)
end
local realUpdate = Player.update
Player.update = function(self)
local landed = realUpdate(self)
if landed then steps = steps + 1 end
return landed
end
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function clearDirs()
for _, d in ipairs({ "up", "down", "left", "right" }) do
game.input.state[d] = false
end
end
wait(30)
local world = game.world
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
local function measure(speed, yields)
world:setMap("ROUTE_29", 44, 12, "right")
wait(20)
world.wildCooldown = 0
game.speedOverride = speed
rolls, hits, steps, ticks = 0, 0, 0, 0
local dir = "right"
for i = 1, yields do
if i % 8 == 0 then dir = (dir == "right") and "left" or "right" end
clearDirs()
game.input.state[dir] = true
table.insert(game.input.pressQueue, dir)
coroutine.yield()
end
clearDirs()
game.speedOverride = 1
wait(5)
return rolls, hits, steps, ticks
end
local r1, h1, s1, t1 = measure(1, 900)
local r2, h2, s2, t2 = measure(200, 60)
print(("[driver] 1X frames=%d cells=%d rolls=%d hits=%d rolls/cell=%.3f")
:format(t1, s1, r1, h1, s1 > 0 and r1 / s1 or 0))
print(("[driver] 200X frames=%d cells=%d rolls=%d hits=%d rolls/cell=%.3f")
:format(t2, s2, r2, h2, s2 > 0 and r2 / s2 or 0))
Encounter.triggers = realTriggers
Player.update = realUpdate
World.step = realWorldStep
local rate1 = s1 > 0 and r1 / s1 or 0
local rate2 = s2 > 0 and r2 / s2 or 0
assert(s1 > 20 and s2 > 20, "not enough walked cells to judge")
assert(math.abs(rate1 - rate2) < 0.05,
("rolls per cell changed with GAME SPEED: %.3f vs %.3f"):format(rate1, rate2))
print("[driver] PASS encounter rolls are one per walked cell at every speed")
end
+79
View File
@@ -0,0 +1,79 @@
-- #1097 (and the absorbed #1116): walking out of Dark Cave and pressing UP
-- puts the player inside the mountain on Route 31.
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1097_test.lua love .
local Mon = require("src.battle.gen2.Mon")
return function(game)
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function clearDirs()
for _, d in ipairs({ "up", "down", "left", "right" }) do
game.input.state[d] = false
end
end
local function hold(dir, frames, trace)
local last
for _ = 1, frames do
clearDirs()
game.input.state[dir] = true
table.insert(game.input.pressQueue, dir)
coroutine.yield()
local w = game.world
local at = ("%s (%d,%d)"):format(w.map.id, w.player.cellX, w.player.cellY)
if trace and at ~= last then print(" " .. at); last = at end
end
clearDirs()
end
wait(30)
local world = game.world
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
-- Route31CheckMomCallCallback (maps/Route31.asm:14) fires on every NEWMAP
-- arrival until the errand is over; set its event so the walk is not spent
-- behind a phone call. tests/drivers/gold/flag_names.lua:1132.
if world.events then world.events:set(64, true) end
-- maps/DarkCaveVioletEntrance.asm warp 1 is (3,15) -> ROUTE_31 warp 3.
world:setMap("DARK_CAVE_VIOLET_ENTRANCE", 3, 14, "down")
wait(20)
hold("down", 40)
-- the ROUTE_31 arrival runs Route31CheckMomCallCallback (maps/Route31.asm:14)
for _ = 1, 900 do
if not world:busy() then break end
coroutine.yield()
end
wait(20)
print(("[driver] out of the cave at %s (%d,%d)")
:format(world.map.id, world.player.cellX, world.player.cellY))
assert(world.map.id == "ROUTE_31", "did not reach ROUTE_31")
print("[driver] holding UP:")
hold("up", 120, true)
wait(30)
local m, x, y = world.map.id, world.player.cellX, world.player.cellY
print(("[driver] ended at %s (%d,%d)"):format(m, x, y))
-- Anything above y=5 on Route 31 in that column is the inside of the
-- mountain; the honest outcomes are re-entering the cave or bumping.
local inside = (m == "ROUTE_31" and y < 5)
assert(not inside, ("walked into the mountain at (%d,%d)"):format(x, y))
-- The other two Dark Cave mouths, same shape: maps/Route46.asm warp 3 is
-- (14,5) and maps/Route45.asm warp 1 is (2,5), both COLL_CAVE tiles with
-- ordinary FLOOR above them in the collision data.
for _, c in ipairs({ { "ROUTE_46", 14, 5 }, { "ROUTE_45", 2, 5 } }) do
local mapId, wx, wy = c[1], c[2], c[3]
world:setMap(mapId, wx, wy, "up")
wait(20)
hold("up", 90)
wait(20)
if world.map.id == mapId then
assert(world.player.cellY >= wy,
("%s: walked above the cave mouth to (%d,%d)")
:format(mapId, world.player.cellX, world.player.cellY))
end
print(("[driver] %s mouth ended at %s (%d,%d)"):format(
mapId, world.map.id, world.player.cellX, world.player.cellY))
end
print("[driver] PASS the cave mouth cannot be walked through")
end
+78
View File
@@ -0,0 +1,78 @@
-- #1104: the Ruins of Alph ladders warp somewhere else.
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1104_test.lua love .
local Mon = require("src.battle.gen2.Mon")
-- maps/RuinsOfAlphInnerChamber.asm:76, maps/RuinsOfAlphOutside.asm:126,
-- maps/RuinsOfAlphKabutoChamber.asm and its three siblings.
local CASES = {
{ "RUINS_OF_ALPH_INNER_CHAMBER", 10, 13, "RUINS_OF_ALPH_OUTSIDE", 10, 13 },
{ "RUINS_OF_ALPH_OUTSIDE", 10, 13, "RUINS_OF_ALPH_INNER_CHAMBER", 10, 13 },
{ "RUINS_OF_ALPH_OUTSIDE", 2, 17, "RUINS_OF_ALPH_HO_OH_CHAMBER", 3, 9 },
{ "RUINS_OF_ALPH_OUTSIDE", 14, 7, "RUINS_OF_ALPH_KABUTO_CHAMBER", 3, 9 },
{ "RUINS_OF_ALPH_OUTSIDE", 2, 29, "RUINS_OF_ALPH_OMANYTE_CHAMBER", 3, 9 },
{ "RUINS_OF_ALPH_OUTSIDE", 16, 33, "RUINS_OF_ALPH_AERODACTYL_CHAMBER", 3, 9 },
{ "RUINS_OF_ALPH_OUTSIDE", 17, 11, "RUINS_OF_ALPH_RESEARCH_CENTER", 2, 7 },
{ "RUINS_OF_ALPH_KABUTO_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 14, 7, "down" },
{ "RUINS_OF_ALPH_HO_OH_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 2, 17, "down" },
{ "RUINS_OF_ALPH_OMANYTE_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 2, 29, "down" },
{ "RUINS_OF_ALPH_AERODACTYL_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 16, 33, "down" },
{ "RUINS_OF_ALPH_RESEARCH_CENTER", 2, 7, "RUINS_OF_ALPH_OUTSIDE", 17, 11, "down" },
}
return function(game)
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function clearDirs()
for _, d in ipairs({ "up", "down", "left", "right" }) do
game.input.state[d] = false
end
end
-- Hold until the map changes, then let the load settle and STOP: holding
-- past the arrival would walk the player off the landing tile.
local function walkUntilWarp(dir, frames, fromMap)
for _ = 1, frames do
clearDirs()
game.input.state[dir] = true
table.insert(game.input.pressQueue, dir)
coroutine.yield()
if game.world.map and game.world.map.id ~= fromMap then
clearDirs()
-- The ARRIVAL cell is the one the warp table names. Standing on a
-- door/cave/staircase tile then forces one step DOWN off it
-- (engine/overworld/player_movement.asm:201-213), so the settled cell
-- is legitimately one row lower on an outdoor mouth.
local ax, ay = game.world.player.cellX, game.world.player.cellY
for _ = 1, 45 do coroutine.yield() end
return game.world.map.id, ax, ay,
game.world.player.cellX, game.world.player.cellY
end
end
clearDirs()
return game.world.map and game.world.map.id, nil, nil
end
wait(30)
local world = game.world
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
local bad = 0
for _, c in ipairs(CASES) do
local src, wx, wy, wantMap, wantX, wantY = c[1], c[2], c[3], c[4], c[5], c[6]
-- default approach is from below, the way the report describes facing the
-- ladder from below; a chamber's exit sits on the bottom row instead
local dir = c[7] or "up"
local sy = (dir == "up") and wy + 1 or wy - 1
world:setMap(src, wx, sy, dir)
wait(12)
local from = ("%s (%d,%d)"):format(src, world.player.cellX, world.player.cellY)
local m, ax, ay, sx, sy = walkUntilWarp(dir, 90, src)
local ok = (m == wantMap and ax == wantX and ay == wantY)
if not ok then bad = bad + 1 end
print(("[driver] %-12s %s -> %s (%s,%s) settled (%s,%s) want %s (%d,%d)")
:format(ok and "OK" or "MISMATCH", from, tostring(m),
tostring(ax), tostring(ay), tostring(sx), tostring(sy),
wantMap, wantX, wantY))
end
print(("[driver] %s ruins warps: %d mismatches")
:format(bad == 0 and "PASS" or "FAIL", bad))
end
+100
View File
@@ -0,0 +1,100 @@
-- #1155: "MAGNITUDE always rolls a 4".
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1155_test.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local ROLLS = 15
local function battleScreen(game)
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
local function drain(game, screen, frames)
for _ = 1, (frames or 400) do
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
return true
end
U.tap(game, "a")
U.wait(3)
end
return false
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1155"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local caster = Mon.new(game.data, "DUNSPARCE", 50)
local def = assert(game.data.moves.MAGNITUDE, "MAGNITUDE is not in moves.lua")
caster.moves = { { id = "MAGNITUDE", pp = def.pp, maxPp = def.pp } }
print("[driver] MAGNITUDE stored effect = " .. tostring(def.effect)
.. ", stored power = " .. tostring(def.power))
game.save.party = { caster }
local seen, damages, shown = {}, {}, 0
local screen
for i = 1, ROLLS do
if not (screen and game.stack:top() == screen and not screen.battle.over) then
local dummy = Mon.new(game.data, "SNORLAX", 60)
dummy.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
assert(world:startBattle({ wild = dummy }), "startBattle failed")
screen = battleScreen(game)
drain(game, screen, 300)
end
local foe = screen.battle.enemy
local before = foe.hp
caster.moves[1].pp = def.pp
screen:submit({ kind = "move", move = "MAGNITUDE" })
for _, event in ipairs(screen.queue) do
local number = event.text and event.text:match("^Magnitude (%d+)")
if number then
seen[number] = (seen[number] or 0) + 1
if shown < 1 then
U.wait(1)
U.shot(game, out .. "/01-magnitude-text.png")
shown = 1
end
end
end
drain(game, screen, 400)
local dealt = before - foe.hp
if dealt > 0 then damages[dealt] = true end
if i % 10 == 0 then U.wait(5) end
end
local numbers = {}
for number, count in pairs(seen) do
numbers[#numbers + 1] = number .. "x" .. count
end
table.sort(numbers)
print("[driver] magnitudes over " .. ROLLS .. " uses: "
.. table.concat(numbers, " "))
local distinctPowers = 0
for _ in pairs(damages) do distinctPowers = distinctPowers + 1 end
local failures = {}
local function check(ok, what)
print((ok and "[ok] " or "[FAIL] ") .. what)
if not ok then failures[#failures + 1] = what end
end
check(#numbers > 1, "MAGNITUDE rolls more than one number")
check(seen["4"] == nil or seen["4"] < ROLLS,
"and is not stuck on MAGNITUDE 4")
check(distinctPowers > 1, "the rolled power reaches the damage (" ..
distinctPowers .. " distinct damage figures)")
if #failures > 0 then
error(#failures .. " magnitude checks failed")
end
print("[driver] #1155 clear; shot in " .. out)
end
+113
View File
@@ -0,0 +1,113 @@
-- #1157: SONIC BOOM has to deal a flat 20, not a rolled 8-10.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1157_test.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local function battleScreen(game)
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
local function drain(game, screen, frames)
for _ = 1, (frames or 400) do
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
return true
end
U.tap(game, "a")
U.wait(3)
end
return false
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1157"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local failures = {}
local function check(ok, what)
print((ok and "[ok] " or "[FAIL] ") .. what)
if not ok then failures[#failures + 1] = what end
end
-- The reporter's own matchup, three times over.
local caster = Mon.new(game.data, "MAGNEMITE", 20)
local boom = assert(game.data.moves.SONICBOOM, "SONICBOOM missing")
print("[driver] SONICBOOM effect = " .. tostring(boom.effect)
.. ", stored power = " .. tostring(boom.power))
caster.moves = { { id = "SONICBOOM", pp = boom.pp, maxPp = boom.pp } }
game.save.party = { caster }
-- SONIC BOOM is a 90% move, so a miss (0) is legal and simply does not
-- count: what matters is that every use that CONNECTS is 20.
local dealt, hits, wrong = {}, 0, 0
for i = 1, 6 do
local rattata = Mon.new(game.data, "RATTATA", 11)
rattata.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
assert(world:startBattle({ wild = rattata }), "startBattle failed")
local screen = battleScreen(game)
drain(game, screen, 300)
local before = rattata.hp
caster.hp = caster.maxHp or caster.hp
caster.moves[1].pp = boom.pp
screen:submit({ kind = "move", move = "SONICBOOM" })
drain(game, screen, 400)
dealt[i] = before - rattata.hp
if dealt[i] > 0 then
hits = hits + 1
if dealt[i] ~= 20 then wrong = wrong + 1 end
end
if i == 1 then U.shot(game, out .. "/01-sonicboom.png") end
for _ = 1, 400 do
if not game.stack:top() or not game.stack:top().battle then break end
U.tap(game, "a")
U.wait(3)
end
U.wait(45)
end
print("[driver] SONIC BOOM dealt " .. table.concat(dealt, ", ")
.. " (0 is a miss)")
check(hits >= 3, "SONIC BOOM connected at least three times")
check(wrong == 0, "and every hit was exactly 20")
-- The two siblings on the same command.
local dragon = Mon.new(game.data, "DRATINI", 25)
local rage = assert(game.data.moves.DRAGON_RAGE, "DRAGON_RAGE missing")
local toss = assert(game.data.moves.SEISMIC_TOSS, "SEISMIC_TOSS missing")
dragon.moves = {
{ id = "DRAGON_RAGE", pp = rage.pp, maxPp = rage.pp },
{ id = "SEISMIC_TOSS", pp = toss.pp, maxPp = toss.pp },
}
game.save.party = { dragon }
local target = Mon.new(game.data, "SNORLAX", 60)
target.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
assert(world:startBattle({ wild = target }), "startBattle failed")
local screen = battleScreen(game)
drain(game, screen, 300)
local before = target.hp
screen:submit({ kind = "move", move = "DRAGON_RAGE" })
drain(game, screen, 400)
local rageDealt = before - target.hp
before = target.hp
screen:submit({ kind = "move", move = "SEISMIC_TOSS" })
drain(game, screen, 400)
local tossDealt = before - target.hp
U.shot(game, out .. "/02-siblings.png")
print(("[driver] DRAGON RAGE %d, SEISMIC TOSS %d (user is L%d)")
:format(rageDealt, tossDealt, dragon.level))
check(rageDealt == 40, "DRAGON RAGE deals exactly 40")
check(tossDealt == dragon.level, "SEISMIC TOSS deals the user's level")
if #failures > 0 then
error(#failures .. " constant-damage checks failed")
end
print("[driver] #1157 fixed; shots in " .. out)
end
+96
View File
@@ -0,0 +1,96 @@
-- #1158: you could not see the trainer's incoming mon before the switch offer.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1158_test.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local function battleScreen(game)
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
local function runToPhase(game, screen, phase, frames)
for _ = 1, (frames or 600) do
if screen.phase == phase then return true end
U.tap(game, "a")
U.wait(3)
end
return false
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1158"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local failures = {}
local function check(ok, what)
print((ok and "[ok] " or "[FAIL] ") .. what)
if not ok then failures[#failures + 1] = what end
end
-- SHIFT is the only style that offers at all (CheckWhetherToAskSwitch).
game.options = game.options or {}
game.options.battleStyle = "SHIFT"
local lead = Mon.new(game.data, "TYPHLOSION", 40)
lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
local bench = Mon.new(game.data, "FERALIGATR", 40)
bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
game.save.party = { lead, bench }
local foe1 = Mon.new(game.data, "PIDGEY", 5)
foe1.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
local foe2 = Mon.new(game.data, "PIDGEOTTO", 20)
foe2.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
assert(world:startBattle({ trainer = { class = "FALKNER", name = "FALKNER",
party = { foe1, foe2 } } }), "trainer startBattle failed")
local screen = battleScreen(game)
runToPhase(game, screen, "menu", 400)
foe1.hp = 1
screen:submit({ kind = "move", move = "TACKLE" })
-- Page by page, collecting what the box actually showed.
local pages, sawBoxWithName = {}, false
local incoming = foe2.nickname or foe2.species or "PIDGEOTTO"
for _ = 1, 900 do
local message = screen.message
if message and pages[#pages] ~= message
and (screen.phase == "shift-intro" or screen.phase == "ask-shift") then
pages[#pages + 1] = message
if #pages == 1 then U.shot(game, out .. "/01-first-page.png") end
if message:find(incoming, 1, true) then
U.shot(game, out .. "/02-names-the-mon.png")
sawBoxWithName = true
end
end
if screen.phase == "ask-shift" and (screen.messageTimer or 0) <= 0 then
break
end
U.tap(game, "a")
U.wait(4)
end
U.shot(game, out .. "/03-yes-no.png")
for i, page in ipairs(pages) do
print(("[driver] page %d: %s"):format(i, (page:gsub("\n", " / "))))
end
check(sawBoxWithName,
"the incoming " .. incoming .. " is named in the box before the yes/no")
check(screen.phase == "ask-shift",
"and the prompt still ends on the YES/NO question")
check(#pages > 1, "the offer runs as pages, not one over-long line")
if #failures > 0 then
error(#failures .. " send-out order checks failed")
end
print("[driver] #1158 fixed; shots in " .. out)
end
+131
View File
@@ -0,0 +1,131 @@
-- #1159 (Fly / Dig leave the user on the field) and #1160 (64x64 front pics).
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1159_test.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local BattleState = require("src.ui.gen2.BattleState")
local function battleScreen(game)
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
local function drain(game, screen, frames)
for _ = 1, (frames or 400) do
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
return true
end
U.tap(game, "a")
U.wait(3)
end
return false
end
-- One drawPic call with love.graphics.draw intercepted: how many blits it made
-- and where the first one landed.
local function probePic(screen, mon, back)
local G = love.graphics
local real = G.draw
local count, x, y = 0, nil, nil
G.draw = function(image, a, b, ...)
count = count + 1
if count == 1 then
-- draw(image, x, y, ...) or draw(image, quad, x, y, ...)
if type(a) == "number" then x, y = a, b else x, y = b, (...) end
end
end
local ok, err = pcall(screen.drawPic, screen, mon, back)
G.draw = real
if not ok then error(err) end
return count, x, y
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1159"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local failures = {}
local function check(ok, what)
print((ok and "[ok] " or "[FAIL] ") .. what)
if not ok then failures[#failures + 1] = what end
end
------------------------------------------------------------------ #1159
local digger = Mon.new(game.data, "SANDSHREW", 30)
local dig = assert(game.data.moves.DIG, "DIG missing from the Gold cache")
digger.moves = { { id = "DIG", pp = dig.pp, maxPp = dig.pp } }
game.save.party = { digger }
local target = Mon.new(game.data, "SNORLAX", 40)
target.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
assert(world:startBattle({ wild = target }), "startBattle failed")
local screen = battleScreen(game)
drain(game, screen, 300)
local before = probePic(screen, screen.battle.player, true)
check(before > 0, "the back pic is on the field before DIG")
U.shot(game, out .. "/01-before-dig.png")
screen:submit({ kind = "move", move = "DIG" })
drain(game, screen, 600)
check(BattleState.isVanished(screen.battle.player) == true,
"SUBSTATUS_UNDERGROUND is set after the charge turn")
local underground = probePic(screen, screen.battle.player, true)
print("[driver] back-pic draws while underground: " .. underground)
check(underground == 0, "the back pic is GONE for the charge turn")
U.shot(game, out .. "/02-underground.png")
-- The enemy front pic is untouched by the player's own dig.
local foe = probePic(screen, screen.battle.enemy, false)
check(foe > 0, "the enemy front pic is unaffected")
-- Turn two: CheckCharge clears the bit and AppearUserRaiseSub redraws it.
screen:submit({ kind = "move", move = "DIG" })
drain(game, screen, 600)
check(BattleState.isVanished(screen.battle.player) == false,
"the substatus is cleared when the stored attack lands")
local after = probePic(screen, screen.battle.player, true)
print("[driver] back-pic draws after the attack: " .. after)
check(after > 0, "the back pic is back on the field")
U.shot(game, out .. "/03-resurfaced.png")
------------------------------------------------------------------ #1160
-- A stubbed pic of each size through the same placement code. trueColor is
-- set so the probe never enters the GBC palette shader.
local realPic = screen.pic
local function place(size, back)
local data = love.image.newImageData(size, size)
local image = love.graphics.newImage(data)
screen.pic = function() return image, true, "mods/front" .. size .. ".png" end
local n, x, y = probePic(screen, screen.battle.enemy, back)
screen.pic = realPic
return n, x, y
end
local _, x56, y56 = place(56, false)
print(("[driver] 56x56 front at (%s, %s)"):format(tostring(x56), tostring(y56)))
check(x56 == 96 and y56 == 0, "vanilla 56x56 front pic is unmoved at (96, 0)")
local _, x40, y40 = place(40, false)
print(("[driver] 40x40 front at (%s, %s)"):format(tostring(x40), tostring(y40)))
check(x40 == 104 and y40 == 16,
"a small 40x40 front pic still stands on the box's ground line")
local _, x64, y64 = place(64, false)
print(("[driver] 64x64 front at (%s, %s)"):format(tostring(x64), tostring(y64)))
check(x64 == 96 and y64 == 0,
"a 64x64 front pic pins to the box corner and overlaps the HUD")
if #failures > 0 then
error(#failures .. " checks failed")
end
print("[driver] #1159 / #1160 fixed; shots in " .. out)
end
+82
View File
@@ -0,0 +1,82 @@
-- #1167: the Ilex Forest hidden Ether and hidden Super Potion.
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1167_test.lua love .
local Mon = require("src.battle.gen2.Mon")
local HiddenItems = require("src.world.gen2.HiddenItems")
local CASES = {
{ name = "ETHER", x = 27, y = 1, event = 136 },
{ name = "SUPER_POTION", x = 17, y = 7, event = 137 },
{ name = "FULL_HEAL", x = 9, y = 17, event = 138 },
}
local FACE = {
up = { 0, 1 }, down = { 0, -1 }, left = { 1, 0 }, right = { -1, 0 },
}
return function(game)
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function tap(btn)
table.insert(game.input.pressQueue, btn)
game.input.state[btn] = true
coroutine.yield()
game.input.state[btn] = false
coroutine.yield()
end
wait(30)
local world = game.world
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
world:setMap("ILEX_FOREST", 9, 18, "up")
wait(30)
assert(world.map.id == "ILEX_FOREST", "not in ILEX_FOREST")
-- what the engine itself thinks it has for this map
local listed = HiddenItems.unfound(world.map.def, world.events)
print("[driver] engine hidden items on ILEX_FOREST:")
for _, row in ipairs(listed) do
print((" (%2d,%2d) item=%s flag=%d")
:format(row.x, row.y, tostring(row.item), row.event))
end
local function bagCount(name)
local n = 0
for _, row in ipairs((game.save.bag and game.save.bag.items) or {}) do
if row.item == name or row.id == name then n = n + (row.count or 1) end
end
return n
end
local bad = 0
for _, c in ipairs(CASES) do
local got = false
for dir, d in pairs(FACE) do
local sx, sy = c.x + d[1], c.y + d[2]
if world.map:isWalkable(sx, sy) then
world:setMap("ILEX_FOREST", sx, sy, dir)
wait(20)
world.player.facing = dir
local before = bagCount(c.name)
tap("a")
wait(60)
for _ = 1, 240 do
if not world:busy() then break end
tap("a")
end
wait(20)
if world.events:get(c.event) or bagCount(c.name) > before then
got = true
print(("[driver] OK %-13s at (%2d,%2d) picked up facing %s")
:format(c.name, c.x, c.y, dir))
break
end
end
end
if not got then
bad = bad + 1
print(("[driver] MISSING %-13s at (%2d,%2d)"):format(c.name, c.x, c.y))
end
end
print(("[driver] %s ilex hidden items: %d missing")
:format(bad == 0 and "PASS" or "FAIL", bad))
end
+48
View File
@@ -0,0 +1,48 @@
-- #1208: the launcher's save-slot line read a Gold save with Gen 1 eyes
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1208_test.lua love .
local U = require("tests.drivers.util")
local SaveData = require("src.core.SaveData")
local GoldSave = require("src.core.gen2.Save")
return function(game)
U.wait(45)
local save = game.save
assert(save and save.player, "gold save did not boot")
save.player.badges = { ZEPHYR = true, HIVE = true, PLAIN = true, FOG = true,
STORM = true, MINERAL = true, GLACIER = true, RISING = true }
save.player.kantoBadges = { BOULDER = true, CASCADE = true }
save.pokedex = save.pokedex or {}
save.pokedex.caught = { [155] = true, [158] = true, [152] = true }
local ok, err = game:writeSave()
if not ok then
U.log("FAIL gold writeSave:", tostring(err))
else
local want = GoldSave.summary(save)
local active = SaveData.activeSlot("gold")
local row
for _, slot in ipairs(SaveData.listSlots("gold")) do
if slot.id == active then row = slot end
end
if not (row and row.meta) then
U.log("FAIL launcher has no slot row for", tostring(active))
else
U.log(("launcher: %d badges - %s - %d caught"):format(
row.meta.badges, row.meta.timeText, row.meta.dexCount))
U.log(("continue: %d badges - %d:%02d - %d caught"):format(
want.badges, want.hours, want.minutes, want.caught))
if row.meta.badges == want.badges and row.meta.dexCount == want.caught then
U.log("PASS launcher summary matches CONTINUE for slot", tostring(active))
else
U.log("FAIL launcher summary disagrees with CONTINUE")
end
end
end
while true do
coroutine.yield()
end
end
@@ -0,0 +1,49 @@
-- In-game Gold SAVE with no launcher slot must leave a file CONTINUE can see.
--
-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-bug1107 \
-- POKEPORT_DRIVER=tests/drivers/gold_save_slot_bug1107_test.lua love .
local U = require("tests.drivers.util")
local SaveData = require("src.core.SaveData")
return function(game)
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
local before = SaveData.listSlots("gold")
local hadFile = false
for _, slot in ipairs(before) do
if slot.exists then hadFile = true break end
end
if not hadFile then
local opts = SaveData.loadOptions()
opts.saveSlots = opts.saveSlots or {}
opts.saveSlots.gold = nil
SaveData.saveOptions(opts)
SaveData.resetSlotState()
end
local ok, err = game:writeSave()
if not ok then
U.log("FAIL gold writeSave:", tostring(err))
else
local after = SaveData.listSlots("gold")
local found, path
for _, slot in ipairs(after) do
if slot.exists then
found = slot.id
path = SaveData.slotDiskPath("gold", slot.id)
break
end
end
if found then
U.log("PASS gold save is launcher-visible:", found, path or "")
else
U.log("FAIL gold save wrote but listSlots has no file")
end
end
while true do
coroutine.yield()
end
end
+49
View File
@@ -0,0 +1,49 @@
-- Herb shop intro over the Goldenrod Underground map (#1169).
-- pokegold engine/items/mart.asm:54 HerbShop, maps/GoldenrodUnderground.asm:158
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/herbshop_bug1169_test.lua love .
local U = require("tests.drivers.util")
return function(game)
local out = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR")
or "/tmp/bug1169"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
-- maps/GoldenrodUnderground.asm:52
world.clockDay = 0
world.mapScenes = world.mapScenes or {}
-- maps/GoldenrodUnderground.asm:679
assert(world:setMap("GOLDENROD_UNDERGROUND", 6, 21, "right"),
"setMap GOLDENROD_UNDERGROUND failed")
U.wait(8)
local granny
for _, npc in ipairs(world.npcs or {}) do
if npc.def and npc.def.sprite == "SPRITE_GRANNY" then granny = npc break end
end
if not granny then
U.log("FAIL granny not on the map (need Sunday)")
end
local MartMenu = require("src.ui.gen2.MartMenu")
U.tap(game, "a")
U.wait(6)
for _ = 1, 90 do
if getmetatable(game.stack:top()) == MartMenu then break end
U.tap(game, "a")
U.wait(4)
end
local top = game.stack:top()
if getmetatable(top) ~= MartMenu then
U.log("FAIL mart did not open")
elseif top.isOpaque then
U.log("FAIL mart is opaque")
else
U.log("herb shop intro over the map")
end
U.shot(game, out .. "/herbshop_intro.png")
while true do U.wait(60) end
end
+88
View File
@@ -0,0 +1,88 @@
-- Kurt stays at moveobject (11, 6) for the visit, then ROM spawn (16, 14)
-- on the next load.
-- #1184
-- maps/SlowpokeWellB1F.asm:48 / engine/overworld/map_setup.asm:78
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/kurt_well_bug1184_test.lua love .
local U = require("tests.drivers.util")
local KURT_ID = 8
local ROM_X, ROM_Y = 16, 14
local MOVE_X, MOVE_Y = 11, 6
local FLAG_KURT = 1856
local FLAG_ROCKETS = 1788
local function findKurt(world)
for _, npc in ipairs(world.npcs or {}) do
if npc.def and npc.def.sprite == "SPRITE_KURT" then return npc end
end
return nil
end
local function kurtDef(world)
local objects = world.map and world.map.def and world.map.def.objects
return objects and objects[KURT_ID - 1]
end
return function(game)
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
world.mapScenes = world.mapScenes or {}
world.mapScenes.PLAYERS_HOUSE_1F = 1
world.mapScenes.NEW_BARK_TOWN = 1
world.events:set(FLAG_KURT, false)
world.events:set(FLAG_ROCKETS, true)
assert(world:setMap("SLOWPOKE_WELL_B1F", 15, 14, "right"),
"SLOWPOKE_WELL_B1F did not load")
U.wait(4)
local pass, fail = 0, 0
local function claim(ok, text)
if ok then pass = pass + 1 else fail = fail + 1 end
U.log(ok and "PASS" or "FAIL", text)
end
local kurt = findKurt(world)
claim(kurt ~= nil, "Kurt is on the well map")
claim(kurt and kurt.cellX == ROM_X and kurt.cellY == ROM_Y,
("Kurt ROM spawn is (%d, %d), saw (%s, %s)")
:format(ROM_X, ROM_Y,
tostring(kurt and kurt.cellX), tostring(kurt and kurt.cellY)))
world:moveObject(KURT_ID, MOVE_X, MOVE_Y)
world:appearObject(KURT_ID)
U.wait(4)
kurt = findKurt(world)
claim(kurt and kurt.cellX == MOVE_X and kurt.cellY == MOVE_Y,
("after moveobject Kurt is at (%d, %d), saw (%s, %s)")
:format(MOVE_X, MOVE_Y,
tostring(kurt and kurt.cellX), tostring(kurt and kurt.cellY)))
assert(world:setMap("PLAYERS_HOUSE_1F", 3, 3, "down"),
"PLAYERS_HOUSE_1F did not load")
U.wait(2)
world.events:set(FLAG_KURT, false)
assert(world:setMap("SLOWPOKE_WELL_B1F", 15, 14, "right"),
"SLOWPOKE_WELL_B1F did not reload")
U.wait(4)
local def = kurtDef(world)
kurt = findKurt(world)
claim(def and def.x == ROM_X and def.y == ROM_Y,
("reload restored Kurt def to (%d, %d), saw (%s, %s)")
:format(ROM_X, ROM_Y,
tostring(def and def.x), tostring(def and def.y)))
claim(kurt and kurt.cellX == ROM_X and kurt.cellY == ROM_Y,
("reload put Kurt at (%d, %d), not the blocking cell (%d, %d); saw (%s, %s)")
:format(ROM_X, ROM_Y, MOVE_X, MOVE_Y,
tostring(kurt and kurt.cellX), tostring(kurt and kurt.cellY)))
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
U.log("Kurt should be at the well entrance, not on the inner path.")
while true do U.wait(60) end
end
+143
View File
@@ -0,0 +1,143 @@
-- Fury Attack / Barrage hit 2-5 times, Twineedle stays at 2. Issue #1168.
-- pokegold engine/battle/effect_commands.asm:5228 (BattleCommand_EndLoop).
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/multihit_bug1168_test.lua love .
-- Do not add POKEPORT_SPEED: the per-strike HP steps are what you are judging.
local U = require("tests.drivers.util")
local Battle = require("src.battle.gen2.Battle")
local Effects = require("src.battle.gen2.Effects")
local Mon = require("src.battle.gen2.Mon")
return function(game)
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 6)
end
local function claim(ok, text)
print((ok and "[multihit] PASS " or "[multihit] FAIL ") .. text)
end
U.wait(45)
local world = game.world
if not (world and world.map) then
print("[multihit] FAIL gold world did not boot")
while true do U.wait(60) end
end
local fury = game.data.moves and game.data.moves.FURY_ATTACK
local twine = game.data.moves and game.data.moves.TWINEEDLE
claim(fury ~= nil, "FURY_ATTACK is in the move table")
claim(fury and fury.effect == "EFFECT_MULTI_HIT",
"FURY_ATTACK is EFFECT_MULTI_HIT")
claim(twine ~= nil, "TWINEEDLE is in the move table")
claim(twine and twine.effect == "EFFECT_POISON_MULTI_HIT",
"TWINEEDLE is EFFECT_POISON_MULTI_HIT")
claim(Effects.hitCount("EFFECT_DOUBLE_HIT") == 2, "DOUBLE_HIT is 2")
claim(Effects.hitCount("EFFECT_POISON_MULTI_HIT") == 2,
"POISON_MULTI_HIT (Twineedle) is 2")
local seq, si = { 0, 1, 2, 0, 3, 3 }, 0
local function scripted()
si = si + 1
return seq[si] or 0
end
claim(Effects.hitCount("EFFECT_MULTI_HIT", scripted) == 2, "roll 0 -> 2 hits")
claim(Effects.hitCount("EFFECT_MULTI_HIT", scripted) == 3, "roll 1 -> 3 hits")
claim(Effects.hitCount("EFFECT_MULTI_HIT", scripted) == 2,
"roll 2 then 0 -> 2 hits")
claim(Effects.hitCount("EFFECT_MULTI_HIT", scripted) == 5,
"roll 3 then 3 -> 5 hits")
local function fiveHitRandom(n)
if n == 4 then return 3 end
return 0
end
local headP = Mon.new(game.data, "CYNDAQUIL", 20)
local headW = Mon.new(game.data, "SNORLAX", 20)
if headP and headW then
headP.moves = { { id = "FURY_ATTACK", pp = 20, maxPp = 20 } }
local b = Battle.new({
data = game.data, party = { headP }, wild = headW,
random = fiveHitRandom,
})
local landed, hitLine = 0, nil
for _, ev in ipairs(b:takeTurn({ kind = "move", move = "FURY_ATTACK" })) do
if ev.kind == "damage" and ev.side == "enemy" then
landed = landed + 1
end
if ev.kind == "message" and ev.text then
local n = ev.text:match("Hit (%d+) time")
if n then hitLine, landed = ev.text, tonumber(n) end
end
end
claim(landed == 5,
("scripted Fury Attack hit %d times (want 5, not 2)"):format(landed))
if hitLine then print("[multihit] " .. hitLine) end
else
claim(false, "could not build a headless Fury Attack pair")
end
local player = Mon.new(game.data, "CYNDAQUIL", 20)
if not player then
print("[multihit] FAIL could not build CYNDAQUIL")
while true do U.wait(60) end
end
player.moves = { { id = "FURY_ATTACK", pp = 20, maxPp = 20 } }
game.save.party = { player }
local wild = Mon.new(game.data, "SNORLAX", 20)
if not wild then
print("[multihit] FAIL could not build SNORLAX")
while true do U.wait(60) end
end
if not world:startBattle({ wild = wild }) then
print("[multihit] FAIL startBattle failed")
while true do U.wait(60) end
end
local screen
for _ = 1, 600 do
local top = game.stack:top()
if top and top.battle then screen = top break end
U.wait(1)
end
if not (screen and screen.battle) then
print("[multihit] FAIL battle screen never came up")
while true do U.wait(60) end
end
local left = 2
screen.battle.random = function(n)
if n == 4 and left > 0 then
left = left - 1
return 3
end
if left > 0 then return 0 end
if love and love.math and love.math.random then
return love.math.random(n) - 1
end
return math.random(n) - 1
end
for _ = 1, 200 do
if screen.phase == "menu" then break end
tap("a", 3)
end
if screen.phase ~= "menu" then
print("[multihit] FAIL never reached the battle menu")
while true do U.wait(60) end
end
tap("a")
U.wait(6)
tap("a")
print("[multihit] Fury Attack should strike five times on this turn.")
print("[multihit] later turns are 2-5. Twineedle would stay at 2.")
while true do U.wait(60) end
end
@@ -0,0 +1,92 @@
-- Radio Tower 5F director returns to the office after a downstairs/upstairs
-- reload, not on the stair warp at (12, 0).
-- #1164 / #1188
-- maps/RadioTower5F.asm:115 / engine/overworld/map_setup.asm:78
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/radiotower_softlock_bug1164_test.lua love .
local U = require("tests.drivers.util")
local DIRECTOR_ID = 2
local OFFICE_X, OFFICE_Y = 3, 6
local STAIR_X, STAIR_Y = 12, 0
local FLAG_ROCKETS = 1742
local FLAG_CIVILIANS = 1744
local function findDirector(world)
for _, npc in ipairs(world.npcs or {}) do
if npc.def and npc.def.sprite == "SPRITE_GENTLEMAN" then return npc end
end
return nil
end
local function directorDef(world)
local objects = world.map and world.map.def and world.map.def.objects
return objects and objects[DIRECTOR_ID - 1]
end
return function(game)
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
world.mapScenes = world.mapScenes or {}
world.mapScenes.RADIO_TOWER_5F = 2
world.mapScenes.RADIO_TOWER_4F = 0
world.events:set(FLAG_ROCKETS, true)
world.events:set(FLAG_CIVILIANS, false)
assert(world:setMap("RADIO_TOWER_5F", 10, 4, "down"),
"RADIO_TOWER_5F did not load")
U.wait(4)
local pass, fail = 0, 0
local function claim(ok, text)
if ok then pass = pass + 1 else fail = fail + 1 end
U.log(ok and "PASS" or "FAIL", text)
end
local director = findDirector(world)
claim(director ~= nil, "director is on 5F")
claim(director and director.cellX == OFFICE_X and director.cellY == OFFICE_Y,
("director office spawn is (%d, %d), saw (%s, %s)")
:format(OFFICE_X, OFFICE_Y,
tostring(director and director.cellX),
tostring(director and director.cellY)))
world:moveObject(DIRECTOR_ID, STAIR_X, STAIR_Y)
world:appearObject(DIRECTOR_ID)
U.wait(4)
director = findDirector(world)
claim(director and director.cellX == STAIR_X and director.cellY == STAIR_Y,
("after moveobject director is at the stairs (%d, %d), saw (%s, %s)")
:format(STAIR_X, STAIR_Y,
tostring(director and director.cellX),
tostring(director and director.cellY)))
assert(world:setMap("RADIO_TOWER_4F", 12, 4, "down"),
"RADIO_TOWER_4F did not load")
U.wait(2)
assert(world:setMap("RADIO_TOWER_5F", 10, 4, "down"),
"RADIO_TOWER_5F did not reload")
U.wait(4)
local def = directorDef(world)
director = findDirector(world)
claim(def and def.x == OFFICE_X and def.y == OFFICE_Y,
("reload restored director def to (%d, %d), saw (%s, %s)")
:format(OFFICE_X, OFFICE_Y,
tostring(def and def.x), tostring(def and def.y)))
claim(not (director and director.cellX == STAIR_X and director.cellY == STAIR_Y),
"director is not standing on the stair warp")
claim(director and director.cellX == OFFICE_X and director.cellY == OFFICE_Y,
("reload put director in the office (%d, %d); saw (%s, %s)")
:format(OFFICE_X, OFFICE_Y,
tostring(director and director.cellX),
tostring(director and director.cellY)))
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
U.log("The gentleman should be in the office. The stairs must be clear.")
while true do U.wait(60) end
end
@@ -0,0 +1,85 @@
-- Route 40 smashable rocks stay on their cell through rock_smash, then a
-- seamless Olivine round trip.
-- #1173
-- engine/overworld/movement.asm:163 / maps/Route40.asm:291
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/rocksmash_shift_bug1173_test.lua love .
local U = require("tests.drivers.util")
local ROCK_ID = 6
local ROCK_X, ROCK_Y = 12, 8
local SMASH = { 0x57, 10, 0x47 }
local function findRock(world, x, y)
for _, npc in ipairs(world.npcs or {}) do
if npc.def and npc.def.sprite == "SPRITE_ROCK"
and npc.cellX == x and npc.cellY == y then
return npc
end
end
return nil
end
local function rockAt(world, x, y)
for _, npc in ipairs(world.npcs or {}) do
if npc.def and npc.def.sprite == "SPRITE_ROCK"
and npc.cellX == x and npc.cellY == y then
return true
end
end
return false
end
return function(game)
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
world.mapScenes = world.mapScenes or {}
world.mapScenes.ROUTE_40 = 0
world.mapScenes.OLIVINE_CITY = 0
assert(world:setMap("ROUTE_40", 12, 9, "up"), "ROUTE_40 did not load")
U.wait(4)
local pass, fail = 0, 0
local function claim(ok, text)
if ok then pass = pass + 1 else fail = fail + 1 end
U.log(ok and "PASS" or "FAIL", text)
end
local rock = findRock(world, ROCK_X, ROCK_Y)
claim(rock ~= nil, ("rock is at (%d, %d)"):format(ROCK_X, ROCK_Y))
world:beginMovement(ROCK_ID, SMASH)
for _ = 1, 40 do
if not world.moveState then break end
U.wait(1)
end
claim(world.moveState == nil, "rock_smash stream finished")
rock = findRock(world, ROCK_X, ROCK_Y)
claim(rock ~= nil,
("after rock_smash the rock is still at (%d, %d), not one cell left")
:format(ROCK_X, ROCK_Y))
claim(not rockAt(world, ROCK_X - 1, ROCK_Y),
"no smashable rock slid one cell left")
assert(world:setMap("OLIVINE_CITY", 18, 15, "down", { seamless = true }),
"OLIVINE_CITY did not load")
U.wait(2)
assert(world:setMap("ROUTE_40", 12, 9, "up", { seamless = true }),
"ROUTE_40 did not reload")
U.wait(4)
claim(rockAt(world, ROCK_X, ROCK_Y),
("after Olivine round trip the rock is still at (%d, %d)")
:format(ROCK_X, ROCK_Y))
claim(not rockAt(world, ROCK_X - 1, ROCK_Y),
"seamless reload did not shift the rock left")
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
U.log("The three rocks should sit on (12, 8), (11, 7), (13, 6).")
while true do U.wait(60) end
end
@@ -0,0 +1,94 @@
-- After a wild faint, "Use next POKéMON?" yes/no; NO tries to run. Issue #1152.
-- pokegold engine/battle/core.asm:2590 (AskUseNextPokemon).
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/use_next_mon_bug1152_test.lua love .
-- Do not add POKEPORT_SPEED: you need the yes/no box to sit there.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
return function(game)
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 6)
end
local function claim(ok, text)
print((ok and "[use-next] PASS " or "[use-next] FAIL ") .. text)
return ok
end
U.wait(45)
local world = game.world
if not (world and world.map) then
print("[use-next] FAIL gold world did not boot")
while true do U.wait(60) end
end
local lead = Mon.new(game.data, "SENTRET", 5)
local backup = Mon.new(game.data, "CYNDAQUIL", 12)
if not (lead and backup) then
print("[use-next] FAIL could not build the party")
while true do U.wait(60) end
end
lead.hp = 1
lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
backup.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
game.save.party = { lead, backup }
local wild = Mon.new(game.data, "PIDGEY", 20)
if not wild then
print("[use-next] FAIL could not build a wild PIDGEY")
while true do U.wait(60) end
end
if not world:startBattle({ wild = wild }) then
print("[use-next] FAIL startBattle failed")
while true do U.wait(60) end
end
local screen
for _ = 1, 600 do
local top = game.stack:top()
if top and top.battle then screen = top break end
U.wait(1)
end
if not (screen and screen.battle) then
print("[use-next] FAIL battle screen never came up")
while true do U.wait(60) end
end
for _ = 1, 200 do
if screen.phase == "menu" then break end
tap("a", 3)
end
if screen.phase ~= "menu" then
print("[use-next] FAIL never reached the battle menu")
while true do U.wait(60) end
end
tap("a")
U.wait(6)
tap("a")
local sawAsk, sawForced = false, false
for _ = 1, 400 do
if screen.phase == "ask-next-mon" then sawAsk = true break end
if screen.phase == "forced-switch" or screen.phase == "submenu" then
sawForced = true
break
end
if screen.battle.over then break end
tap("a", 3)
end
claim(sawAsk, "wild faint opened Use next POKéMON?")
claim(not sawForced, "wild faint did not skip to the party list")
claim(screen.phase == "ask-next-mon",
"phase is ask-next-mon (YES switches, NO/B tries to run)")
print("[use-next] YES sends you to the party. NO or B tries to run.")
print("[use-next] a failed run still opens the party. trainers skip this.")
while true do U.wait(60) end
end
@@ -0,0 +1,58 @@
-- GameActivity exposes an optional Android host seam without requiring any
-- host implementation (or its native libraries) in the stock build.
-- Self-contained: luajit tests/engine/android_host_extension_test.lua
local path = "mobile/android/love/src/main/java/org/love2d/android/GameActivity.java"
local file = assert(io.open(path, "rb"))
local source = file:read("*a")
file:close()
local function check(value, message)
if not value then error(message, 2) end
end
local function position(text)
local p = source:find(text, 1, true)
check(p, "missing: " .. text)
return p
end
check(source:find("protected String[] getHostLibraries()", 1, true),
"host libraries are an overridable protected extension")
check(source:find("return new String[0];", 1, true),
"the vanilla host library list is empty")
local cpp = position('libraries[0] = "c++_shared";')
local mpg = position('libraries[1] = "mpg123";')
local openal = position('libraries[2] = "openal";')
local host = position("System.arraycopy(hostLibraries, 0, libraries, 3, hostLibraries.length);")
local love = position('libraries[libraries.length - 1] = "love";')
check(cpp < mpg and mpg < openal and openal < host and host < love,
"optional libraries load after dependencies while liblove remains last")
for _, hook in ipairs({
"onHostCreateBeforeSDL", "onHostCreateAfterSDL", "onHostResume",
"onHostPause", "onHostDestroy",
}) do
check(source:find("protected void " .. hook, 1, true),
hook .. " is a protected extension hook")
end
check(position("onHostCreateBeforeSDL(savedInstanceState);") <
position("super.onCreate(savedInstanceState);") and
position("super.onCreate(savedInstanceState);") <
position("onHostCreateAfterSDL(savedInstanceState);"),
"create hooks bracket SDL creation")
check(position("super.onResume();") < position("onHostResume();"),
"resume hook runs after SDL resumes")
check(position("onHostPause();") < position("super.onPause();"),
"pause hook runs before SDL pauses")
check(position("onHostDestroy();") < position("super.onDestroy();"),
"destroy hook runs before SDL destruction")
check(not source:lower():find("openxr", 1, true),
"generic Android activity must not require OpenXR")
check(not source:find("QuestActivity", 1, true) and
not source:find("QuestBridge", 1, true),
"generic Android activity must not require Quest classes")
print("android_host_extension_test: ok")
+108
View File
@@ -0,0 +1,108 @@
-- Public battle auxiliary actions are a narrow semantic entry point for tool
-- mods. They run only at the same settled ordinary decision boundary as a
-- battle checkpoint, consume no FIGHT/PKMN/ITEM/RUN action, and receive no
-- live BattleState object.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness").suite("battle menu auxiliary action")
local Fixtures = require("tests.modkit").fixtures
local BattleState = require("src.battle.BattleState")
local Hooks = require("src.mods.Hooks")
local Runtime = require("src.mods.Runtime")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local Data = Fixtures.fresh()
local function makeGame(kind)
local save = SaveData.newGame()
save.meta.playthroughId = "battle-menu-playthrough"
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
local stack = setmetatable({ states = {} }, { __index = StateStack })
local overworld = {
map = { id = save.player.map },
player = { cellX = save.player.x, cellY = save.player.y, facing = save.player.facing },
runner = { isRunning = function() return false end },
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
}
local game = { data = Data, save = save, stack = stack }
game.input = { wasPressed = function(_, button) return button == "start" end }
game.overworld = overworld
stack.states[1] = overworld
local battle = kind == "trainer"
and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
or BattleState.newWild(game, "FIXMON_B", 12)
battle.phase, battle.queue = "menu", {}
battle.checkpointOrigin = kind == "trainer"
and { kind = "trainer_encounter", map = save.player.map, npcId = "TRAINER_1",
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1, event = "EVENT_BEAT_TRAINER_1" }
or { kind = "wild_encounter", map = save.player.map }
battle.onFinish = function() end
stack.states[2] = battle
return game, battle
end
local oldHooks = Runtime.hooks
local hooks = Hooks.new()
Runtime.hooks = hooks
local game, battle = makeGame("wild")
local calls = 0
hooks:wrap("battle.menu_auxiliary", function(nextFn, liveGame, context)
calls = calls + 1
T.check(liveGame == game, "auxiliary action receives the live game")
T.same(context, { kind = "wild" }, "auxiliary action receives only data-only battle context")
return true
end, 0, "tool_fixture")
local originalIndex = battle.menuIndex
battle:update(1 / 60)
T.eq(calls, 1, "START reaches the public auxiliary action at a wild decision")
T.eq(battle.phase, "menu", "handled auxiliary action does not advance the battle")
T.eq(battle.menuIndex, originalIndex, "handled auxiliary action preserves cursor")
T.eq(#battle.queue, 0, "handled auxiliary action does not enqueue a turn")
hooks:removeOwner("tool_fixture")
local trainerGame, trainer = makeGame("trainer")
local trainerCalls = 0
hooks:wrap("battle.menu_auxiliary", function(_, liveGame, context)
trainerCalls = trainerCalls + 1
T.check(liveGame == trainerGame, "trainer action receives its live game")
T.same(context, { kind = "trainer" }, "trainer context remains data-only")
return true
end, 0, "trainer_fixture")
trainer:update(1 / 60)
T.eq(trainerCalls, 1, "START reaches the public auxiliary action at a trainer decision")
hooks:removeOwner("trainer_fixture")
local scriptedGame, scripted = makeGame("trainer")
scripted.checkpointOrigin = { kind = "script_battle", scriptId = "STORY_TEST", pc = 4 }
scripted.checkpointScriptContinuation = { kind = "script_battle" }
local scriptedCalls = 0
hooks:wrap("battle.menu_auxiliary", function(_, liveGame, context)
scriptedCalls = scriptedCalls + 1
T.check(liveGame == scriptedGame,
"scripted action receives the live game without its runner")
T.same(context, { kind = "trainer" },
"supported scripted trainer context remains data-only")
return true
end, 0, "scripted_fixture")
scripted:update(1 / 60)
T.eq(scriptedCalls, 1,
"START reaches the public auxiliary action at a supported scripted decision")
hooks:removeOwner("scripted_fixture")
local unsafeGame, unsafe = makeGame("wild")
unsafe.phase = "messages"
local unsafeCalls = 0
hooks:wrap("battle.menu_auxiliary", function() unsafeCalls = unsafeCalls + 1 return true end,
0, "unsafe_fixture")
unsafe:update(1 / 60)
T.eq(unsafeCalls, 0, "messages never expose the auxiliary action")
hooks:removeOwner("unsafe_fixture")
Runtime.hooks = oldHooks
T.finish()
+73
View File
@@ -0,0 +1,73 @@
-- Optional native-host display lifecycle. The default path is inert; a fake
-- backend proves callback order and arguments without graphics or a ROM.
-- luajit tests/engine/host_display_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local HostDisplay = require("src.core.HostDisplay")
-- Vanilla: no backend, no state requirement, and no invented return value.
HostDisplay.setBackend(nil)
eq(HostDisplay.update(1 / 60), nil, "default update is a no-op")
eq(HostDisplay.beginFrame("game", {}), nil, "default beginFrame is a no-op")
eq(HostDisplay.endFrame("game", {}), nil, "default endFrame is a no-op")
-- Bad installation fails at the boundary instead of producing a later frame
-- error whose source is difficult for a native host to diagnose.
local ok, err = pcall(HostDisplay.setBackend, function() end)
check(not ok, "non-table backend is rejected")
check(tostring(err):find("table or nil", 1, true) ~= nil,
"backend type error explains the accepted shape")
local calls = {}
local subject = { tag = "launcher-instance" }
local fake = {}
function fake:update(dt)
calls[#calls + 1] = { "update", self, dt }
return "updated"
end
function fake:beginFrame(kind, gotSubject)
calls[#calls + 1] = { "begin", self, kind, gotSubject }
return "begun"
end
function fake:endFrame(kind, gotSubject)
calls[#calls + 1] = { "end", self, kind, gotSubject }
return "ended"
end
HostDisplay.setBackend(fake)
eq(HostDisplay.update(0.25), "updated", "update return is forwarded")
eq(HostDisplay.beginFrame("launcher", subject), "begun",
"beginFrame return is forwarded")
eq(HostDisplay.endFrame("launcher", subject), "ended",
"endFrame return is forwarded")
eq(#calls, 3, "each lifecycle callback fires exactly once")
eq(calls[1][1], "update", "update is first")
eq(calls[1][2], fake, "update receives the backend as self")
eq(calls[1][3], 0.25, "update receives dt")
eq(calls[2][1], "begin", "beginFrame precedes endFrame")
eq(calls[2][3], "launcher", "beginFrame receives the frame kind")
eq(calls[2][4], subject, "beginFrame receives the drawn subject")
eq(calls[3][1], "end", "endFrame is last")
eq(calls[3][3], "launcher", "endFrame receives the frame kind")
eq(calls[3][4], subject, "endFrame receives the drawn subject")
-- Every callback is optional. Replacing and clearing a backend must not retain
-- callbacks from the old host across a restart or test process.
local partialCalls = 0
HostDisplay.setBackend({
endFrame = function() partialCalls = partialCalls + 1 end,
})
eq(HostDisplay.update(1), nil, "missing optional update remains a no-op")
eq(HostDisplay.beginFrame("editor", {}), nil,
"missing optional beginFrame remains a no-op")
HostDisplay.endFrame("editor", {})
eq(partialCalls, 1, "present optional callback still runs")
HostDisplay.setBackend(nil)
HostDisplay.endFrame("game", {})
eq(partialCalls, 1, "clearing backend detaches old callbacks")
T.finish("host display")
+114
View File
@@ -0,0 +1,114 @@
-- #1100 / #1135: the launcher gear offered TOUCH PAD, VIBRATION and the
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")
-- The rows are mobile-gated the same way OptionsMenu's are, so the suite has
-- to look like a phone; POKEPORT_TOUCH=1 is the other way in.
local realGetOS = love.system.getOS
love.system.getOS = function() return "Android" end
local LauncherSettings = require("src.import.LauncherSettings")
local TouchControls = require("src.core.TouchControls")
local function labels(model)
local out = {}
for _, section in ipairs(model.sections) do
for _, row in ipairs(section.rows) do out[#out + 1] = row.label end
end
return out
end
local function findRow(model, label)
for _, section in ipairs(model.sections) do
for _, row in ipairs(section.rows) do
if row.label == label then return row end
end
end
end
local function has(model, label)
for _, l in ipairs(labels(model)) do
if l == label then return true end
end
return false
end
local edited = 0
local hooks = { editTouchControls = function() edited = edited + 1 end }
local gold = LauncherSettings.open(hooks, "gold")
check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD")
check(has(gold, "VIBRATION"), "and VIBRATION")
check(has(gold, "TOUCH CONTROLS"), "and the layout editor")
-- Every write has to land in the gold block: the flat keys beside it are
-- Red's, and Gold's boot never reads them (src/core/gen2/Save.lua:299).
-- loadOptions seeds the flat Gen 1 defaults, so the check is that the Gold
-- rows leave them exactly as they found them.
local flatPad = gold.opts.touchControls
local flatBuzz = gold.opts.haptics
local pad = findRow(gold, "TOUCH PAD")
local before = pad.value()
pad.step(1)
check(pad.value() ~= before, "stepping TOUCH PAD flips it")
check(type(gold.opts.gold) == "table", "into the gold block")
eq(gold.opts.gold.touchControls.enabled, false, "which now carries enabled")
eq(gold.opts.touchControls, flatPad, "leaving the flat Gen 1 key alone")
local buzz = findRow(gold, "VIBRATION")
local buzzBefore = buzz.value()
buzz.step(1)
check(buzz.value() ~= buzzBefore, "stepping VIBRATION moves the level")
eq(gold.opts.gold.haptics, TouchControls.normalizeHaptics(gold.opts.gold.haptics),
"VIBRATION stores a level the shared module knows")
eq(gold.opts.haptics, flatBuzz, "also without touching Red's")
findRow(gold, "TOUCH CONTROLS").action()
eq(edited, 1, "the editor row reaches the host hook")
-- The Gen 1 gear is untouched by the extraction: same three rows, still on
-- the flat table.
local red = LauncherSettings.open(hooks, "red")
check(has(red, "TOUCH PAD"), "Red still offers TOUCH PAD")
check(has(red, "VIBRATION"), "and VIBRATION")
check(has(red, "TOUCH CONTROLS"), "and the layout editor")
findRow(red, "TOUCH PAD").step(1)
eq(red.opts.touchControls.enabled, false, "writing to the flat key")
eq(red.opts.gold, nil, "with no gold block invented")
eq(findRow(red, "TOUCH PAD").value() ~= nil, true, "and reading it back")
-- With no hook (the standalone save editor's shape) the editor row is gone
-- rather than dead, on both sides.
eq(has(LauncherSettings.open(nil, "gold"), "TOUCH CONTROLS"), false,
"no hook, no editor row on Gold")
eq(has(LauncherSettings.open(nil, "red"), "TOUCH CONTROLS"), false,
"nor on Red")
-- The Edit row hands the screen to the host, and the host has to know WHICH
-- game's block to write the dragged layout into: TouchControlsEditor persists
-- to opts.gold only when it was loaded with version = "gold". Source-shape
-- checks, the same way tests/engine/touch_controls_pad_cursor_test.lua pins
-- the handoff either side of this one.
local function read(path)
local f = assert(io.open(path, "r"))
local src = f:read("*a")
f:close()
return src
end
local importerSrc = read("src/import/RomImporter.lua")
check(importerSrc:find("self.onEditTouchControls(version)", 1, true) ~= nil,
"the gear hands the launcher tab to the host")
local mainSrc = read("main.lua")
local body = mainSrc:match("local function openTouchControlsEditor%((.-)%)")
eq(body, "version", "main.lua's opener takes that tab")
check(mainSrc:match("TouchEditor%.load%({%s*version = version") ~= nil,
"and loads the editor with it, so Gold's layout lands in the gold block")
love.system.getOS = realGetOS
T.finish("launcher gold touch rows")
@@ -0,0 +1,90 @@
-- Launcher modal focus routing. The immediate-mode focus ring is shared by
-- the main launcher and its mod dialogs, so arrows and an armed confirmation
-- must be handled before the modal key guard returns.
-- luajit tests/engine/launcher_modal_focus_input_test.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 Kit = require("src.ui.kit.Kit")
local RomImporter = require("src.import.RomImporter")
local function resetFocus()
Kit.focusId = "modal-primary"
Kit._navQueue = nil
Kit._activateId = nil
end
local function importer(field)
return setmetatable({ _flex = true, [field] = {} }, RomImporter)
end
local modalFields = {
"_modConfirm", "_modVersions", "_modReleaseNotes", "_findDetails",
}
for _, field in ipairs(modalFields) do
resetFocus()
local imp = importer(field)
imp:keypressed("right")
eq(Kit._navQueue, "right", field .. " accepts focus navigation")
check(imp._ringArmed, field .. " navigation arms the focus ring")
imp:keypressed("return")
eq(Kit._activateId, "modal-primary",
field .. " activates the focused modal control")
check(imp[field] ~= nil, field .. " remains open until its button dispatches")
end
-- Enter without prior focus navigation must not invent an activation. This is
-- the vanilla compatibility contract behind the launcher's legacy Enter-to-
-- play shortcut; a modal simply absorbs that otherwise-unhandled key.
do
resetFocus()
local imp = importer("_modConfirm")
imp:keypressed("return")
eq(Kit._activateId, nil, "unarmed modal Enter does not activate focus")
check(not imp._ringArmed, "unarmed modal Enter does not arm focus")
end
-- A focused text field remains exclusive. The modal fix must not move the
-- ring when an arrow is intended for a launcher text-input state.
do
resetFocus()
local imp = setmetatable({
_flex = true,
_findSearchFocus = true,
findQuery = "PIKA",
}, RomImporter)
imp:keypressed("right")
eq(Kit._navQueue, nil, "focused search field does not navigate the ring")
check(not imp._ringArmed, "focused search field does not arm the ring")
end
-- Escape remains owned by the existing modal close path.
do
resetFocus()
local imp = importer("_findDetails")
imp:keypressed("escape")
eq(imp._findDetails, nil, "Escape closes details")
end
do
resetFocus()
local imp = importer("_modReleaseNotes")
imp:keypressed("escape")
eq(imp._modReleaseNotes, nil, "Escape closes release notes")
end
do
resetFocus()
local imp = importer("_modVersions")
imp._modConfirm = {}
imp:keypressed("escape")
eq(imp._modConfirm, nil, "Escape closes confirmation")
eq(imp._modVersions, nil, "Escape clears versions behind confirmation")
end
resetFocus()
T.finish("launcher modal focus input")
+208
View File
@@ -0,0 +1,208 @@
-- Regression test for the title-music-bleeds-into-the-map bug: Continue,
-- F2 quickload, and checkpoint-resume used to drop the player into the
-- overworld while the old song (the title screen's, or F2's previous
-- location) was still cross-fading in over Music.MAP_FADE's ~1.2s,
-- audibly wrong since the player already had control. See
-- OverworldState:setMap (src/world/OverworldController.lua) for the
-- opts.freshBoot mechanism this exercises, and Game.lua for where it's
-- set (onContinue, New Game, F2, restoreCheckpointSave) and where it's
-- deliberately not (dev tooling's reuse of opts.via == "boot").
--
-- (A)-(A4) and (C) call the real Game:restoreSave, Game:keypressed("f2"),
-- Game:restoreCheckpointSave and Console:exec("warp ...") -- SaveData.load
-- stubbed to skip the slot/persistence format -- so a dropped freshBoot at
-- any real call site fails this test, not just a hand-built opts table.
-- (D) simulates HotReload's { via = "boot" } shape instead of calling
-- through its local, unexported reloadMap.
--
-- ROM-free (fixture dataset -- FIX_TOWN/FIX_ROUTE, tests/fixture_data),
-- like tests/engine/warp_sprite_hidden_bug916.lua, so the CI headless
-- tier (no data/generated/) runs it.
-- luajit tests/engine/resume_boot_music_no_fade.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.modkit")
local check = T.check
local eq = T.eq
local Source = {}
Source.__index = Source
function Source:play() self.playing = true end
function Source:stop() self.playing = false end
function Source:pause() self.playing = false end
function Source:isPlaying() return self.playing end
function Source:setLooping() end
function Source:setVolume(v) self.volume = v end
function Source:setPitch() end
function Source:setFilter() end
function Source:getDuration() return 1 end
local made = {} -- file -> the last source built for it
love.audio = {
newSource = function(file, mode)
made[file] = setmetatable({ file = file, mode = mode }, Source)
return made[file]
end,
}
local Data = T.fixtures.fresh()
-- fixture patches that let the overworld boot and run headlessly (same
-- set tests/engine/warp_sprite_hidden_bug916.lua needs for the same reason)
Data.tilesets.FIX_OUT.tilesPerRow = 16
Data.field.flyWarps = Data.field.flyWarps or {}
Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" }
Data.field.waterTilesets = {}
Data.field.forcedMovement = { tiles = {} }
-- no data.audio in the fixture dataset either; synthesize just enough for
-- real Music.lua playback to run against the real FIX_TOWN/FIX_ROUTE maps
Data.audio = Data.audio or {}
Data.audio.songs = Data.audio.songs or {}
Data.audio.songs.Music_TitleScreen = { file = "title.wav" }
Data.audio.mapSongs = Data.audio.mapSongs or {}
Data.audio.mapSongs.FIX_TOWN = "Music_FixTown"
Data.audio.songs.Music_FixTown = { file = "town.wav" }
Data.audio.mapSongs.FIX_ROUTE = "Music_FixRoute"
Data.audio.songs.Music_FixRoute = { file = "route.wav" }
local Music = require("src.core.Music")
local SaveData = require("src.core.SaveData")
local Game = require("src.core.Game")
local StateStack = require("src.core.StateStack")
local OverworldState = require("src.world.OverworldController")
local Console = require("src.dev.Console")
Game.data = Data
Game.save = SaveData.newGame()
Game.save.player.name = "RED"
Game.save.player.map = "FIX_TOWN"
StateStack:init()
Game.stack = StateStack
Game.overworld = OverworldState -- set once at boot in the real game (Game.lua)
Game.input = {
isDown = function() return false end,
wasPressed = function() return false end,
step = function() end, state = {}, pressQueue = {},
}
Game.renderer = {
beginWorldPass = function() end, endWorldPass = function() end,
beginUIPass = function() end, endUIPass = function() end,
worldViewSize = function() return 160, 144 end,
setSGBZones = function() end,
}
local function playing()
for file, src in pairs(made) do
if src.playing then return file end
end
return "(silence)"
end
local function finishFade()
for _ = 1, 7 * Music.MAP_FADE do Music.update(Data) end
end
-- ===========================================================================
-- (A) The real Game:restoreSave, called the way onContinue calls it.
-- ===========================================================================
Music.play(Data, "Music_TitleScreen")
eq(playing(), "title.wav", "title screen music is playing before Continue")
local loaded = SaveData.newGame()
loaded.player.map = "FIX_TOWN"
Game:restoreSave(loaded, false, { freshBoot = true })
eq(playing(), "town.wav",
"Continue's real restoreSave(..., {freshBoot=true}) swaps at once")
-- ===========================================================================
-- (A2) The same real Game:restoreSave with no opts at all -- its own
-- default (e.g. for any future caller that doesn't ask for freshBoot) is
-- the safe, ordinary crossfade, not a silent hard-cut.
-- ===========================================================================
Music.play(Data, "Music_TitleScreen") -- stand-in for whatever was playing
local loaded2 = SaveData.newGame()
loaded2.player.map = "FIX_TOWN"
Game:restoreSave(loaded2, false)
eq(playing(), "title.wav",
"restoreSave(...) with no opts still fades, not an instant swap")
finishFade()
eq(playing(), "town.wav", "...landing on the loaded save's map song")
-- ===========================================================================
-- (A3) The real Game:keypressed("f2") handler, both ways it's reachable:
-- at the title screen and mid-session. SaveData.load is stubbed rather
-- than round-tripped through the in-memory love.filesystem, to isolate
-- this test from the slot/persistence format.
-- ===========================================================================
local realLoad = SaveData.load
local loaded3 = SaveData.newGame()
loaded3.player.map = "FIX_TOWN"
SaveData.load = function() return loaded3, false end
StateStack:init() -- no overworld on the stack: "at the title screen"
Music.play(Data, "Music_TitleScreen")
Game:keypressed("f2")
eq(playing(), "town.wav",
"F2 from the title screen (overworld not on the stack) swaps at once")
StateStack:init()
StateStack.states[1] = OverworldState -- overworld already active: mid-session
Music.play(Data, "Music_TitleScreen") -- stand-in for the session's own song
Game:keypressed("f2")
eq(playing(), "town.wav",
"F2 mid-session (a live overworld already on the stack) also swaps at once")
SaveData.load = realLoad
StateStack:init()
-- ===========================================================================
-- (A4) The real Game:restoreCheckpointSave, called the way Checkpoint.resume
-- (RFC 0006's mod.checkpoint:resume) calls it.
-- ===========================================================================
Music.play(Data, "Music_TitleScreen")
local checkpointSave = SaveData.newGame()
checkpointSave.player.map = "FIX_TOWN"
Game:restoreCheckpointSave(checkpointSave)
eq(playing(), "town.wav",
"a title-session checkpoint resume swaps at once, no lingering title music")
StateStack:init()
-- ===========================================================================
-- (B) An ordinary warp (e.g. walking into a house) is unaffected: it still
-- cross-fades like any other map-to-map transition.
-- ===========================================================================
OverworldState:setMap("FIX_ROUTE", 3, 3, "up", {})
eq(playing(), "town.wav",
"an ordinary warp still fades: the old song is still playing right after setMap")
finishFade()
eq(playing(), "route.wav",
"...and lands on the new map's song once the fade completes")
-- ===========================================================================
-- (C) The real dev console `warp` verb (src/dev/Console.lua VERBS.warp).
-- ===========================================================================
Music.play(Data, "Music_TitleScreen") -- re-arm a "stale" song to prove intent
Console.new(Game):exec("warp FIX_TOWN 5 5")
eq(playing(), "title.wav",
"Console's real `warp` verb still fades, like an ordinary warp")
finishFade()
eq(playing(), "town.wav", "...landing on the target map's song")
-- ===========================================================================
-- (D) src/dev/HotReload.lua's reloadMap opts shape, simulated (see header)
-- rather than called through: reloadMap is local/unexported, and
-- HotReload.run's full loader teardown is out of scope for this fix.
-- ===========================================================================
Music.play(Data, "Music_TitleScreen")
OverworldState:setMap("FIX_ROUTE", 3, 3, "up", { via = "boot" })
eq(playing(), "title.wav",
"HotReload's { via = \"boot\" } setMap still fades, not an instant swap")
finishFade()
eq(playing(), "route.wav", "...landing on the reloaded map's song")
T.finish("resume_boot_music_no_fade")
@@ -51,7 +51,9 @@ check(mainSrc:find("prepareOverlayHandoff", 1, true) ~= nil
and mainSrc:find("openTouchControlsEditor", 1, true) ~= nil,
"main.lua mentions prepareOverlayHandoff + touch editor")
-- prepare must run inside openTouchControlsEditor, not only openEditor
local touchOpen = mainSrc:match("local function openTouchControlsEditor%(%)(.-)\nend")
-- The signature takes the launcher tab since #1100, so match any parameter
-- list rather than pinning the arity.
local touchOpen = mainSrc:match("local function openTouchControlsEditor%(.-%)(.-)\nend")
check(touchOpen ~= nil, "openTouchControlsEditor body found")
check(touchOpen:find("prepareOverlayHandoff", 1, true) ~= nil,
"openTouchControlsEditor prepares overlay handoff like the save editor")
+56
View File
@@ -104,6 +104,34 @@ local function nameSet(list)
return s
end
-- Host-family compatibility is independent from the numeric shell revision.
-- Missing fields preserve the historical ordinary-LOVE defaults.
do
check(Boot.canHost({}, 1, "love"),
"canHost: legacy payload defaults to ordinary LOVE host and shell 1")
check(Boot.canHost({ payloadHost = "love", minShell = 2 }, 2, "love"),
"canHost: matching host and sufficient shell pass")
check(not Boot.canHost({ payloadHost = "love", minShell = 2 }, 1, "love"),
"canHost: newer shell requirement fails")
check(not Boot.canHost({ payloadHost = "special", minShell = 1 }, 9, "love"),
"canHost: a high shell revision cannot override a host mismatch")
check(Boot.canHost({ payloadHost = "special", minShell = 3 }, 3, "special"),
"canHost: a specialized host accepts its own payload")
check(not Boot.canHost(nil, 1, "love"),
"canHost: malformed payload metadata fails closed")
end
-- Even an older mismatched-host payload is not ours to clean up.
do
local candidates = {
{ name = "other-old.love", engine = "0.5.0", minShell = 1,
payloadHost = "special" },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1, "love")
eq(chosen, nil, "select: old payload for another host is not runnable")
eq(#del, 0, "select: old payload for another host is not deleted")
end
-- empty candidate list: nothing to run, nothing to delete
do
local chosen, del = Boot.select({}, "1.0.0", 1)
@@ -127,6 +155,34 @@ do
check(not d["b.love"], "select: the chosen payload is never deleted")
end
-- A newer payload for another native host is neither selected nor deleted.
-- It may be valid for another full package sharing this save directory.
do
local candidates = {
{ name = "other.love", engine = "2.0.0", minShell = 1,
payloadHost = "special" },
{ name = "ours.love", engine = "1.5.0", minShell = 1,
payloadHost = "love" },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1, "love")
eq(chosen, "ours.love", "select: chooses the matching host payload")
eq(#del, 0, "select: incompatible newer host payload is retained")
end
-- The same candidate set selects the specialized payload when the bundled
-- package identifies as that host family.
do
local candidates = {
{ name = "ordinary.love", engine = "2.1.0", minShell = 1,
payloadHost = "love" },
{ name = "special.love", engine = "2.0.0", minShell = 1,
payloadHost = "special" },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1, "special")
eq(chosen, "special.love", "select: specialized package chooses its host payload")
eq(#del, 0, "select: newer ordinary payload remains available to its host")
end
-- skips payloads whose minShell is above the bundled shell, and KEEPS an
-- otherwise-newer one for a future shell upgrade instead of deleting it
do
+5
View File
@@ -252,10 +252,15 @@ do
local outcome
screen.onDone = function(result) outcome = result end
-- Battle lines end in `prompt` and PromptButton waits on A/B with no
-- countdown (home/joypad.asm:383-412), so the drain presses.
local function drain(cap)
for _ = 1, (cap or 3000) do
local waiting = (screen.messageTimer or 0) > 0
if waiting then Input:overlayPressed("a") end
Input:step()
screen:update(1 / 60)
if waiting then Input:overlayReleased("a") end
if screen.phase == "menu" or screen.phase == "done" then return true end
end
return false
+6
View File
@@ -159,10 +159,16 @@ local function newScreen(opts)
return screen, battle, player, save, pushed
end
-- Battle lines end in `prompt`, and PromptButton waits on A or B with no
-- frame countdown (home/joypad.asm:383-412), so a drain has to press like a
-- player does rather than wait for a timer that never runs out.
local function runToMenu(screen, cap)
for _ = 1, (cap or 3000) do
local waiting = (screen.messageTimer or 0) > 0
if waiting then Input:overlayPressed("a") end
Input:step()
screen:update(1 / 60)
if waiting then Input:overlayReleased("a") end
if screen.phase == "menu" then return true end
end
return false
+6
View File
@@ -157,10 +157,16 @@ local function newBattleOverWorld(inventory)
return screen, battle, world, save, pushed, player
end
-- Battle lines end in `prompt`, and PromptButton waits on A or B with no
-- frame countdown (home/joypad.asm:383-412), so a drain has to press like a
-- player does rather than wait for a timer that never runs out.
local function runToMenu(screen, cap)
for _ = 1, (cap or 3000) do
local waiting = (screen.messageTimer or 0) > 0
if waiting then Input:overlayPressed("a") end
Input:step()
screen:update(1 / 60)
if waiting then Input:overlayReleased("a") end
if screen.phase == "menu" then return true end
end
return false
+39 -21
View File
@@ -262,10 +262,21 @@ local function run(screen, frames)
end
end
-- One frame of a drain. Battle lines end in `prompt` and PromptButton waits
-- on A or B with no countdown (home/joypad.asm:383-412); the stats box waits
-- the same way (engine/battle/core.asm:7069), so a drain presses rather than
-- idling for a timer that never runs out.
local function drainStep(screen)
local waiting = (screen.messageTimer or 0) > 0 or screen.phase == "stats-box"
if waiting then Input:overlayPressed("a") end
Input:step()
screen:update(1 / 60)
if waiting then Input:overlayReleased("a") end
end
local function runToMenu(screen, cap)
for _ = 1, (cap or 3000) do
Input:step()
screen:update(1 / 60)
drainStep(screen)
if screen.phase == "menu" then return true end
end
return false
@@ -366,8 +377,7 @@ do
local crawled, restarted = false, false
for _ = 1, 3000 do
Input:step()
screen:update(1 / 60)
drainStep(screen)
if screen.shownLevel == 5 and (screen.shownExp or 0) > startExp then
crawled = true
end
@@ -404,8 +414,7 @@ do
local maxBefore = player.maxHp
screen:submit({ kind = "move", move = "TACKLE" })
for _ = 1, 3000 do
Input:step()
screen:update(1 / 60)
drainStep(screen)
if screen.phase == "done" then break end
end
check(player.maxHp > maxBefore, "the level-up raised the maximum")
@@ -601,8 +610,7 @@ do
local function runToPhase(screen, phase, cap)
for _ = 1, (cap or 4000) do
Input:step()
screen:update(1 / 60)
drainStep(screen)
if screen.phase == phase then return true end
end
return false
@@ -622,9 +630,15 @@ do
noBattle.enemy.hp = 1
local noTurn = noBattle.turn
noScreen:submit({ kind = "move", move = "TACKLE" })
check(runToPhase(noScreen, "ask-shift"), "the KO stops on OfferSwitch")
-- The `para` in BattleText_EnemyIsAboutToUseWillPlayerChangeMon splits the
-- offer (data/text/battle.asm:222-231): the incoming mon is named on an
-- earlier page, and only the last one carries the yes/no box (#1158).
check(runToPhase(noScreen, "shift-intro"), "the KO stops on OfferSwitch")
check((noScreen.message or ""):find("is about to use"),
"with BattleText_EnemyIsAboutToUseWillPlayerChangeMon")
check(runToPhase(noScreen, "ask-shift"), "and its pages reach the question")
check((noScreen.message or ""):find("change POK"),
"whose last page is the one YesNoBox opens over")
-- NO falls through to the send-out with nothing switched and nothing spent.
noScreen.messageTimer = 0
@@ -1083,8 +1097,7 @@ do
-- not ring is anything from the faint onward.
local latched, rangAfterFaint, sawExpLine = false, false, false
for _ = 1, 3000 do
Input:step()
screen:update(1 / 60)
drainStep(screen)
if screen.lowHealthAlarmDisabled then latched = true end
if latched and siren then rangAfterFaint = true end
if latched and (screen.message or ""):find("EXP") then sawExpLine = true end
@@ -1155,7 +1168,9 @@ do
eq(screen.phase, "refuse-move", "the disabled row is refused")
eq(screen.message, "The move is DISABLED!", "with BattleText_TheMoveIsDisabled")
eq(wild.hp, before, "and the enemy got no free turn")
run(screen, 120)
-- Both refusal lines end in `prompt` (data/text/battle.asm:315-323), so the
-- list comes back on a press, not on a timer.
tap("a")
eq(screen.phase, "moves", "the list comes back")
-- The same for a spent row.
@@ -1168,7 +1183,7 @@ do
eq(screen.message, "There's no PP left for this move!",
"with BattleText_TheresNoPPLeftForThisMove")
eq(wild.hp, before, "and still no enemy turn")
run(screen, 120)
tap("a")
eq(screen.phase, "moves", "and the list comes back again")
end
@@ -1221,8 +1236,7 @@ end
-- so the question is reached by pages rather than by one button.
local function runToPhase(screen, phase, cap)
for _ = 1, (cap or 900) do
Input:step()
screen:update(1 / 60)
drainStep(screen)
if screen.phase == phase then return true end
end
return false
@@ -1236,11 +1250,15 @@ do
check(runToPhase(screen, "ask-forget"), "and its pages run into the question")
eq(screen.message, "move to make room\nfor EMBER?",
"whose last page is the one YesNoBox opens over")
run(screen, 60)
local tap = tapper(screen)
-- The question's own `prompt` is read first and YesNoBox opens after it,
-- exactly as OfferSwitch does (engine/battle/core.asm:3298-3305), so the
-- answer is the second press, not the first.
tap("a")
eq(screen.phase, "ask-forget", "the last page holds until it is read")
tap("b")
eq(screen.phase, "stop-learning", "NO there asks whether to stop learning")
run(screen, 60)
tap("a")
tap("b")
eq(screen.phase, "learn-intro", "and NO to THAT reprints the ask")
check(runToPhase(screen, "ask-forget"), "which is a loop, not an exit")
@@ -1251,8 +1269,8 @@ end
do
local screen, lead = learnScreen()
check(runToPhase(screen, "ask-forget"), "the pages reach the question")
run(screen, 60)
local tap = tapper(screen)
tap("a") -- read the question
tap("a") -- YES
eq(screen.phase, "choose-forget", "YES opens the picker")
tap("a") -- slot 1
@@ -1263,9 +1281,9 @@ end
do
local screen, lead = learnScreen()
check(runToPhase(screen, "ask-forget"), "the pages reach the question")
run(screen, 60)
local tap = tapper(screen)
tap("a")
tap("a") -- read the question
tap("a") -- YES
eq(screen.phase, "choose-forget", "the picker is up")
screen.forgetIndex = 4
tap("a")
@@ -1273,7 +1291,7 @@ do
eq(screen.message, "HM moves can't be\nforgotten now.",
"with MoveCantForgetHMText")
eq(screen.forgetIndex, 1, "and `jr .loop` puts the cursor back on slot 1")
run(screen, 60)
tap("a")
eq(screen.phase, "choose-forget", "the picker is still up after the line")
eq(lead.moves[4].id, "SURF", "and SURF is still there")
end
+10 -5
View File
@@ -356,11 +356,14 @@ local optionsGame, optionsInput = newGame(Save.newGame())
local options = OptionsMenu.new(optionsGame, {
options = Save.defaultOptions(),
})
-- The cart's seven value rows, then the port's audio, speed and display
-- rows, then CANCEL -- which is what makes this screen scroll.
check("sixteen rows", #OptionsMenu.ROWS, 16)
-- The cart's seven value rows, then CONTROLS, the port's audio, speed and
-- display rows, the three touch rows and CANCEL -- which is what makes this
-- screen scroll. The touch three are gated to mobile by buildRows; ROWS
-- itself carries every descriptor.
check("twenty rows", #OptionsMenu.ROWS, 20)
check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame")
check("then the port's audio group", OptionsMenu.ROWS[8].key, "musicVol")
check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls")
check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol")
check("last row is CANCEL", OptionsMenu.ROWS[#OptionsMenu.ROWS].cancel, true)
check("starts on TEXT SPEED", options:row().key, "textSpeed")
check("default text speed", options.options.textSpeed, "MID")
@@ -401,7 +404,9 @@ local exiting = OptionsMenu.new(exitGame, {
options = Save.defaultOptions(),
onDone = function(o) savedOptions = o end,
})
exiting.index = #OptionsMenu.ROWS
-- The screen's own rows, not ROWS: buildRows drops the touch three off a
-- desktop, so the raw descriptor count overshoots CANCEL.
exiting.index = #exiting.rows
exitInput:press("a")
exiting:update(0)
check("CANCEL leaves", savedOptions ~= nil, true)
+5 -2
View File
@@ -17,14 +17,17 @@ local Nests = require("src.core.gen2.Nests")
-- Landmark indices from constants/landmark_constants.asm: Johto runs below
-- PALLET_TOWN ($2e), Kanto from it up to ROUTE_28.
local DATA = {
maps = {
-- gen2Maps, matching Game2's key for the Gold map table.
gen2Maps = {
ROUTE_29 = { landmark = 2 }, -- Johto
ILEX_FOREST = { landmark = 11 }, -- Johto
ROUTE_1 = { landmark = 0x2f }, -- Kanto
UNION_CAVE_1F = { landmark = 9 }, -- Johto
NOWHERE = { }, -- no landmark at all
},
encounters = {
-- Game2 loads the encounter tables under gen2Encounters, not `encounters`:
-- the flat name is Gen 1's and Gen2Compat only maps it for mods.
gen2Encounters = {
grass = {
ROUTE_29 = { slots = {
MORN = { { species = "PIDGEY" }, { species = "SENTRET" } },
+8 -5
View File
@@ -417,22 +417,25 @@ check("only the written byte is stored", memCount, 1)
local _, _, _, loadReport = Save.load("gold")
check("load reports a clean save", Save.emptyReport(loadReport), true)
-- A second write backs the first up, so the previous file is always recoverable.
-- A second write backs the first up, so the previous file is always
-- recoverable. The file lives at saves/<version>/<slot>.lua since Gold grew
-- launcher slots (#1107); the flat save_gold.lua is only the migration source.
local SLOT = "saves/gold/slot1.lua"
written.player.money = 5555
Save.save(written)
check("backup written", files["save_gold.lua.bak"] ~= nil, true)
check("backup written", files[SLOT .. ".bak"] ~= nil, true)
check("new value loads", Save.load("gold").player.money, 5555)
-- A corrupt main file falls back to the backup rather than losing the game.
files["save_gold.lua"] = "this is not a lua table"
files[SLOT] = "this is not a lua table"
local recoveredSave, how = Save.load("gold")
check("recovered from the backup", recoveredSave ~= nil, true)
check("recovery reported", how, "bak")
check("backup held the previous money", recoveredSave.player.money, 4321)
-- A staged .tmp is preferred over the backup: it is the newer of the two.
files["save_gold.lua"] = nil
files["save_gold.lua.tmp"] = files["save_gold.lua.bak"]
files[SLOT] = nil
files[SLOT .. ".tmp"] = files[SLOT .. ".bak"]
local staged, stagedHow = Save.load("gold")
check("recovered from the staged copy", staged ~= nil, true)
check("staged recovery reported", stagedHow, "tmp")
+2 -1
View File
@@ -3315,7 +3315,8 @@ local function grassLatchChecks()
check(hp.jumping, "and it is a jump")
check(not hp.grassShake, "no rustle spawns for the airborne cells")
check(not hp.inGrass, "and IN_GRASS is clear for the whole hop")
for _ = 1, PlayerMod.STEP_FRAMES + 1 do hopGrass:step() end
-- A hop clears two cells, so it runs two step-times, not one (#1165).
for _ = 1, PlayerMod.STEP_FRAMES * 2 + 1 do hopGrass:step() end
check(hp.inGrass, "the landing tile latches it once the hop ends")
end
grassLatchChecks()
@@ -48,10 +48,15 @@ function fs.load(path)
end
function fs.getDirectoryItems(path)
local items = {}
-- -print plus a basename in Lua, not -printf: that is a GNU extension and
-- BSD find (macOS) fails the whole call, which silently emptied the listing
-- and left the probe mod undiscovered.
local pipe = io.popen("find " .. quote(full(path))
.. " -mindepth 1 -maxdepth 1 -printf '%f\\n' 2>/dev/null")
.. " -mindepth 1 -maxdepth 1 -print 2>/dev/null")
if pipe then
for item in pipe:lines() do items[#items + 1] = item end
for item in pipe:lines() do
items[#items + 1] = item:match("[^/]+$") or item
end
pipe:close()
end
table.sort(items)
+12 -3
View File
@@ -21,6 +21,7 @@ local Net = require("src.link.Net")
local Pokemon = require("src.pokemon.Pokemon")
local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local Session = require("src.link.Session")
local S = require("tests.harness").suite("mod link")
local check, eq = S.check, S.eq
@@ -529,11 +530,19 @@ local function linkGame(name, species, data)
return { data = data or Data, save = save, stack = stack, input = mkInput() }
end
-- LinkState talks to a Session, not to a raw transport (src/link/LinkState.lua
-- :75 wraps every backend the same way), so a loopback end has to be wrapped
-- here too or :update reaches for a getStatus the transport does not have.
local function linkSession(transport, role)
return Session.new(transport, { role = role, kind = "link" })
end
-- two paired states, host already listening and guest already dialling
local function pairStates(gameA, gameB)
local netA, netB = Net.loopbackPair()
local host, guest = LinkState.new(gameA), LinkState.new(gameB)
host.net, guest.net = netA, netB
host.net = linkSession(netA, "host")
guest.net = linkSession(netB, "guest")
host.stage, guest.stage = "hosting", "joining"
gameA.stack:push(host)
gameB.stack:push(guest)
@@ -573,7 +582,7 @@ check(host.trade.strict, "a v2 verdict unpacks strictly")
local gameOld = linkGame("RED", "PIDGEY")
local oldNet, peerNet = Net.loopbackPair()
local v1guest = LinkState.new(gameOld)
v1guest.net = oldNet
v1guest.net = linkSession(oldNet, "guest")
v1guest.stage = "joining"
gameOld.stack:push(v1guest)
v1guest:update(1 / 60)
@@ -591,7 +600,7 @@ check(not v1guest.trade.strict, "the v1 path keeps the old unpack rules")
local gameLone = linkGame("RED", "PIDGEY")
local loneNet, silentNet = Net.loopbackPair()
local v1host = LinkState.new(gameLone)
v1host.net = loneNet
v1host.net = linkSession(loneNet, "host")
v1host.stage = "hosting"
gameLone.stack:push(v1host)
v1host:update(1 / 60)

Some files were not shown because too many files have changed in this diff Show More