diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d5d4456..0db3b785 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/ios-artifact-comment.yml b/.github/workflows/ios-artifact-comment.yml index b0bd58ce..3ba5386f 100644 --- a/.github/workflows/ios-artifact-comment.yml +++ b/.github/workflows/ios-artifact-comment.yml @@ -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 }}` diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c63c2576..7116a65f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" diff --git a/README.md b/README.md index c5bbecff..3710fd9e 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/ios-sideload.md b/docs/ios-sideload.md index ba4907da..8c19cf01 100644 --- a/docs/ios-sideload.md +++ b/docs/ios-sideload.md @@ -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 Wi‑Fi, or keep the phone plugged into the computer). diff --git a/docs/updater.md b/docs/updater.md index c27615a3..f117c898 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -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 diff --git a/main.lua b/main.lua index 11a2c003..64a9489e 100644 --- a/main.lua +++ b/main.lua @@ -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) diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 3bad1ea0..dbd409e8 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -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(); } diff --git a/mobile/ios/README.md b/mobile/ios/README.md index 5ae5439c..37f48b4e 100644 --- a/mobile/ios/README.md +++ b/mobile/ios/README.md @@ -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 | diff --git a/mobile/ios/app-repo.json b/mobile/ios/app-repo.json index db28fac8..7149b577 100644 --- a/mobile/ios/app-repo.json +++ b/mobile/ios/app-repo.json @@ -1,11 +1,11 @@ { - "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", diff --git a/mobile/ios/overlays/love-ios.plist b/mobile/ios/overlays/love-ios.plist index 6d7e2b30..d2f97a54 100644 --- a/mobile/ios/overlays/love-ios.plist +++ b/mobile/ios/overlays/love-ios.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion en CFBundleDisplayName - gen1recomp + gen1recomp++ CFBundleDocumentTypes @@ -32,7 +32,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - gen1recomp + gen1recomp++ CFBundlePackageType APPL CFBundleShortVersionString diff --git a/mobile/ios/patch_love_src.py b/mobile/ios/patch_love_src.py index b6d6eb40..e9a90a43 100644 --- a/mobile/ios/patch_love_src.py +++ b/mobile/ios/patch_love_src.py @@ -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() diff --git a/scripts/build.sh b/scripts/build.sh index 2828ac83..5c6f8bc5 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -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/-/gen1recomp.app (full xcodebuild output stays +# dist/ios/-/gen1recomp++.app (full xcodebuild output stays # under mobile/ios/build/Build/Products/) set -euo pipefail diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index 3a87dce9..9c7fac93 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -20,9 +20,9 @@ # - macOS + Xcode (xcodebuild) # - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md) # -# Output: dist/ios/-/gen1recomp.app (convenience copy) -# dist/ios/gen1recomp.ipa (device builds only) -# mobile/ios/build/Build/Products/-/gen1recomp.app +# Output: dist/ios/-/gen1recomp++.app (convenience copy) +# dist/ios/gen1recomp++.ipa (device builds only) +# mobile/ios/build/Build/Products/-/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 into dist/ios/gen1recomp.ipa for release / sideload tools. +# Pack Payload/.app into dist/ios/gen1recomp++.ipa for release / sideload tools. package_ipa() { local app="$1" local ipa="$DIST/$APP_NAME.ipa" diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 691ada53..e215c176 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -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 diff --git a/src/battle/gen2/Effects.lua b/src/battle/gen2/Effects.lua index 19f0b302..0cecd019 100644 --- a/src/battle/gen2/Effects.lua +++ b/src/battle/gen2/Effects.lua @@ -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 diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua index bded2027..3f530f53 100644 --- a/src/core/Checkpoint.lua +++ b/src/core/Checkpoint.lua @@ -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 diff --git a/src/core/HostDisplay.lua b/src/core/HostDisplay.lua new file mode 100644 index 00000000..d8ec5754 --- /dev/null +++ b/src/core/HostDisplay.lua @@ -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 diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 70dd44c7..b3873497 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -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, } diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index d4d645bf..b3422edb 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -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 diff --git a/src/core/Version.lua b/src/core/Version.lua index c57b91a2..0424bc82 100644 --- a/src/core/Version.lua +++ b/src/core/Version.lua @@ -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 diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index 6affceaa..3d754198 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -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 diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index c44deeb7..0fe7c4de 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -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 = { diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index da44939f..9984768c 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -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 diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index 8c5c08b5..e09077d6 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -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 diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index ef21f209..c83c5450 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -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 }, diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 47a9ab31..d3dfbd76 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -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) diff --git a/src/update/Boot.lua b/src/update/Boot.lua index b3936ad4..43f0781f 100644 --- a/src/update/Boot.lua +++ b/src/update/Boot.lua @@ -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) diff --git a/src/update/check_worker.lua b/src/update/check_worker.lua index 7a0dd1ba..0efc7ada 100644 --- a/src/update/check_worker.lua +++ b/src/update/check_worker.lua @@ -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 -- --------------------------------------------------------------------------- diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 565908a4..ce9f5a8d 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -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) diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 087b7bfe..f2ca4150 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -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, diff --git a/tests/drivers/gold_bug1091_test.lua b/tests/drivers/gold_bug1091_test.lua new file mode 100644 index 00000000..8b225927 --- /dev/null +++ b/tests/drivers/gold_bug1091_test.lua @@ -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 diff --git a/tests/drivers/gold_bug1097_test.lua b/tests/drivers/gold_bug1097_test.lua new file mode 100644 index 00000000..f1c7c123 --- /dev/null +++ b/tests/drivers/gold_bug1097_test.lua @@ -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 diff --git a/tests/drivers/gold_bug1104_test.lua b/tests/drivers/gold_bug1104_test.lua new file mode 100644 index 00000000..e9d814d5 --- /dev/null +++ b/tests/drivers/gold_bug1104_test.lua @@ -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 diff --git a/tests/drivers/gold_bug1155_test.lua b/tests/drivers/gold_bug1155_test.lua new file mode 100644 index 00000000..ba643887 --- /dev/null +++ b/tests/drivers/gold_bug1155_test.lua @@ -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 diff --git a/tests/drivers/gold_bug1157_test.lua b/tests/drivers/gold_bug1157_test.lua new file mode 100644 index 00000000..59895009 --- /dev/null +++ b/tests/drivers/gold_bug1157_test.lua @@ -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 diff --git a/tests/drivers/gold_bug1158_test.lua b/tests/drivers/gold_bug1158_test.lua new file mode 100644 index 00000000..b5b69ae3 --- /dev/null +++ b/tests/drivers/gold_bug1158_test.lua @@ -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 diff --git a/tests/drivers/gold_bug1159_test.lua b/tests/drivers/gold_bug1159_test.lua new file mode 100644 index 00000000..86bec478 --- /dev/null +++ b/tests/drivers/gold_bug1159_test.lua @@ -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 diff --git a/tests/drivers/gold_bug1167_test.lua b/tests/drivers/gold_bug1167_test.lua new file mode 100644 index 00000000..1fb7cb6c --- /dev/null +++ b/tests/drivers/gold_bug1167_test.lua @@ -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 diff --git a/tests/drivers/gold_bug1208_test.lua b/tests/drivers/gold_bug1208_test.lua new file mode 100644 index 00000000..bdbcf6c7 --- /dev/null +++ b/tests/drivers/gold_bug1208_test.lua @@ -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 diff --git a/tests/engine/android_host_extension_test.lua b/tests/engine/android_host_extension_test.lua new file mode 100644 index 00000000..14d7ae5a --- /dev/null +++ b/tests/engine/android_host_extension_test.lua @@ -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") diff --git a/tests/engine/host_display_test.lua b/tests/engine/host_display_test.lua new file mode 100644 index 00000000..51433cf4 --- /dev/null +++ b/tests/engine/host_display_test.lua @@ -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") diff --git a/tests/engine/launcher_gold_touch_rows.lua b/tests/engine/launcher_gold_touch_rows.lua new file mode 100644 index 00000000..5b8e0e12 --- /dev/null +++ b/tests/engine/launcher_gold_touch_rows.lua @@ -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") diff --git a/tests/engine/launcher_modal_focus_input_test.lua b/tests/engine/launcher_modal_focus_input_test.lua new file mode 100644 index 00000000..3d394e4d --- /dev/null +++ b/tests/engine/launcher_modal_focus_input_test.lua @@ -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") diff --git a/tests/engine/touch_controls_pad_cursor_test.lua b/tests/engine/touch_controls_pad_cursor_test.lua index ba4c3928..25fa9359 100644 --- a/tests/engine/touch_controls_pad_cursor_test.lua +++ b/tests/engine/touch_controls_pad_cursor_test.lua @@ -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") diff --git a/tests/engine/update_tests.lua b/tests/engine/update_tests.lua index 9bf67171..2fb696ab 100644 --- a/tests/engine/update_tests.lua +++ b/tests/engine/update_tests.lua @@ -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 diff --git a/tests/gen2_battle_end_test.lua b/tests/gen2_battle_end_test.lua index f947a976..3b98194b 100644 --- a/tests/gen2_battle_end_test.lua +++ b/tests/gen2_battle_end_test.lua @@ -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 diff --git a/tests/gen2_battle_items_test.lua b/tests/gen2_battle_items_test.lua index bfe81afb..dbfc8527 100644 --- a/tests/gen2_battle_items_test.lua +++ b/tests/gen2_battle_items_test.lua @@ -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 diff --git a/tests/gen2_battle_pack_test.lua b/tests/gen2_battle_pack_test.lua index 95608d0f..9de5bc3a 100644 --- a/tests/gen2_battle_pack_test.lua +++ b/tests/gen2_battle_pack_test.lua @@ -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 diff --git a/tests/gen2_battle_ui_test.lua b/tests/gen2_battle_ui_test.lua index b03a4f8a..e6090a1c 100644 --- a/tests/gen2_battle_ui_test.lua +++ b/tests/gen2_battle_ui_test.lua @@ -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 diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua index 229aab7e..00054183 100644 --- a/tests/gen2_menus_test.lua +++ b/tests/gen2_menus_test.lua @@ -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) diff --git a/tests/gen2_nests_test.lua b/tests/gen2_nests_test.lua index 1addd400..9e08a04c 100644 --- a/tests/gen2_nests_test.lua +++ b/tests/gen2_nests_test.lua @@ -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" } }, diff --git a/tests/gen2_save_test.lua b/tests/gen2_save_test.lua index e8a10659..e2b80b2b 100644 --- a/tests/gen2_save_test.lua +++ b/tests/gen2_save_test.lua @@ -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//.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") diff --git a/tests/gen2_world_test.lua b/tests/gen2_world_test.lua index b2c4121e..a8bc0987 100644 --- a/tests/gen2_world_test.lua +++ b/tests/gen2_world_test.lua @@ -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() diff --git a/tests/integration/title_checkpoint_cold_start.lua b/tests/integration/title_checkpoint_cold_start.lua index 6f754155..cfbf9c92 100644 --- a/tests/integration/title_checkpoint_cold_start.lua +++ b/tests/integration/title_checkpoint_cold_start.lua @@ -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) diff --git a/tests/mod_link_tests.lua b/tests/mod_link_tests.lua index eaa2839a..fb89e9e4 100644 --- a/tests/mod_link_tests.lua +++ b/tests/mod_link_tests.lua @@ -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) diff --git a/tests/modkit/cases/title_playthrough_context.lua b/tests/modkit/cases/title_playthrough_context.lua index ab40f26d..54857a53 100644 --- a/tests/modkit/cases/title_playthrough_context.lua +++ b/tests/modkit/cases/title_playthrough_context.lua @@ -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") diff --git a/tests/parity_yellow_pallet_pikachu.lua b/tests/parity_yellow_pallet_pikachu.lua index 57a2c910..f9b44580 100644 --- a/tests/parity_yellow_pallet_pikachu.lua +++ b/tests/parity_yellow_pallet_pikachu.lua @@ -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 = {} diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 3122785f..21c4790f 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -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)