Compare commits

..

19 Commits

Author SHA1 Message Date
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
59 changed files with 1893 additions and 231 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 }}`
+12 -11
View File
@@ -444,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; }
@@ -580,7 +580,7 @@ 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"
@@ -602,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
@@ -623,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"
+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).
+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).
+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
+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 |
+11 -4
View File
@@ -1,17 +1,24 @@
{
"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.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",
+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
+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"
+18 -2
View File
@@ -1604,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
+14 -6
View File
@@ -179,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
@@ -189,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
+12
View File
@@ -313,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
@@ -394,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
@@ -474,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
+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
+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,
}
+9 -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
+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
+5
View File
@@ -764,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 = {
+11 -1
View File
@@ -2652,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
@@ -2741,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
+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 },
+54 -11
View File
@@ -106,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."
@@ -548,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).
@@ -571,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
@@ -582,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.
@@ -903,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
@@ -1365,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).
@@ -1864,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
@@ -2464,13 +2507,13 @@ 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?"
self.messageTimer = MESSAGE_FRAMES
-- 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)
+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
-- ---------------------------------------------------------------------------
+18 -5
View File
@@ -1397,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
@@ -3515,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)
+2 -1
View File
@@ -3946,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,
+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,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")
+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")
@@ -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)
@@ -207,6 +207,11 @@ if type(storage) == "table" then
T.check(type(normalBytes) == "string" and normalBytes ~= "",
"first checkpoint anchor is durably represented before restart")
local anchoredAt = SaveSerializer.decode(normalBytes).meta.savedAt
-- Model a durable checkpoint captured by the previous shipped engine.
-- RFC 0004 treats engineVersion as compatibility metadata, not runtime state.
checkpoint.identity.engineVersion = "0.1.79"
SaveData.resetSlotState()
local titleRuntime = makeRuntime(SaveData.newGame({ version = "red" }), true)
titleRuntime.save.options = { volume = 9, bindings = {} }
@@ -224,7 +229,14 @@ if type(storage) == "table" then
version = "red", meta = { playthroughId = originalId },
}, fs).savedAt, anchoredAt,
"title bootstrap never rewrites the first normal save")
T.same(checkpoints:capture(titleRuntime), checkpoint,
local recaptured = checkpoints:capture(titleRuntime)
T.eq(recaptured and recaptured.identity
and recaptured.identity.engineVersion, Version.engine,
"cross-version resume recaptures the running engine version")
if recaptured and recaptured.identity then
recaptured.identity.engineVersion = checkpoint.identity.engineVersion
end
T.same(recaptured, checkpoint,
"bootstrapped overworld differentially recaptures the selected checkpoint")
T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1,
"a successfully verified title resume emits checkpoint.restored exactly once")
+16
View File
@@ -64,6 +64,17 @@ local function freshGame(mapX, mapY)
worldViewSize = function() return 160, 144 end,
setSGBZones = function() end,
}
-- OverworldState is a singleton and :enter does not clear the movement
-- latches, so a scenario that runs after a few thousand other checks can
-- inherit a half-finished script and never walk. Reset them here: this
-- suite asserts a frame budget, so it has to start from a known state.
OverworldState.scriptMoves = {}
OverworldState.pendingScripts = {}
OverworldState.emote = nil
OverworldState.engaging = false
OverworldState.teleportOut = nil
OverworldState.transitioning = nil
OverworldState.wildEncounterGraceSteps = 0
StateStack:push(OverworldState, "PALLET_TOWN", mapX, mapY, "up")
Game.overworld = OverworldState
return pressed
@@ -114,6 +125,11 @@ end
-- keeps running straight into the lab.
-- =====================================================================
scenario(function()
-- The trigger tile is in Pallet's north grass, so the escort walk rolls for
-- wild encounters as it goes. Pin the stream: run standalone this suite got
-- one draw sequence and run inside tests/run_tests.lua another, and one of
-- them dropped a battle on top of the escort and ate the frame budget.
math.randomseed(require("tests.harness").SEED)
GameVersion.set("red")
local pressed = freshGame(8, 2)
local played = {}
+12 -4
View File
@@ -2788,15 +2788,23 @@ do
press("down")
eq(om.index, 25, "cursor reaches CONTROLS")
press("down")
eq(om.index, 26, "CANCEL stays the fixed final row")
eq(om.scroll, 21, "CANCEL keeps the last option boxes on screen")
eq(om.index, 26, "cursor reaches DATE FORMAT")
press("down")
eq(om.index, 27, "cursor reaches TIME FORMAT")
press("down")
-- CANCEL is appended after the descriptor list rather than living in it, so
-- it lands one past #rows and the window holds the last six boxes. Counted
-- off #rows so the next row added here is not read as a wrap bug.
local cancelRow = #om.rows + 1
eq(om.index, cancelRow, "CANCEL stays the fixed final row")
eq(om.scroll, cancelRow - 5, "CANCEL keeps the last option boxes on screen")
om:draw() -- smoke: scrolled layout draws under the headless stub
press("a")
check(popped, "A on CANCEL closes the options menu")
local om2 = OptionsMenu.new(og)
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
eq(om2.index, 26, "up from the top wraps to CANCEL")
eq(om2.scroll, 21, "wrapping to CANCEL scrolls to the tail")
eq(om2.index, cancelRow, "up from the top wraps to CANCEL")
eq(om2.scroll, cancelRow - 5, "wrapping to CANCEL scrolls to the tail")
-- headless-safe: no love.audio, setters only update internal state
require("src.core.Music").applyOptions(og.save.options)
require("src.core.Sound").applyOptions(og.save.options)