diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b41bc888..e3df3ec4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -373,10 +373,31 @@ jobs: unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \ || { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; } - - name: Build Android + - name: Materialize Android release signing key + env: + KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }} run: | set -euo pipefail - scripts/build_android.sh --version "${{ needs.version.outputs.version }}" + [ -n "$KEYSTORE_B64" ] || { + echo "::error::ANDROID_RELEASE_KEYSTORE_B64 is required for a publishable Android update" + exit 1 + } + python3 - <<'PY' + import base64, os, pathlib + encoded = os.environ["KEYSTORE_B64"] + path = pathlib.Path(os.environ["RUNNER_TEMP"]) / "gen1recomp-android-release.keystore" + path.write_bytes(base64.b64decode(encoded, validate=True)) + PY + + - name: Build Android + env: + GEN1RECOMP_ANDROID_KEYSTORE: ${{ runner.temp }}/gen1recomp-android-release.keystore + GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }} + GEN1RECOMP_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }} + GEN1RECOMP_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }} + run: | + set -euo pipefail + scripts/build_android.sh --release --version "${{ needs.version.outputs.version }}" - name: Install xcbeautify run: | @@ -498,8 +519,8 @@ jobs: [ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; } cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage" chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage" - apk="$(find dist/android/debug -name '*.apk' | head -1)" - [ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; } + apk="$(find dist/android/release -name '*.apk' | head -1)" + [ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/release"; exit 1; } cp "$apk" "$outdir/gen1recomp-${v}-android.apk" ipa="dist/ios/gen1recomp++.ipa" diff --git a/data/scripts/gyms.lua b/data/scripts/gyms.lua index ddf126c8..1050caef 100644 --- a/data/scripts/gyms.lua +++ b/data/scripts/gyms.lua @@ -38,6 +38,22 @@ local function retryTmGive(game, ow, victoryKey, done) return true end +-- The badge line + its jingle, armed for the battle screen the way +-- SaveEndBattleTextPointers does (PewterGym.asm:117-119) (#1606) +local function badgeEndBattleText(game, victoryKey) + local reward = victoryKey and require("data.scripts.victories")[victoryKey] + if not (reward and reward.dialogue) then return nil end + local text = game.data.text or {} + local pages = {} + for _, label in ipairs(reward.dialogue) do + if text[label] and text[label] ~= "" then + pages[#pages + 1] = text[label] + end + end + if #pages == 0 then return nil end + return table.concat(pages, "\f"), reward.badgeSound +end + -- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent -- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints -- _PewterGymBrockPreBattleText and engages the leader battle @@ -58,7 +74,8 @@ M.PEWTER_GYM.talk = { game.data.text._PewterGymBrockPostBattleAdviceText or "Go to the GYM in\nCERULEAN and test\nyour abilities!", done)) else - ow:engageTrainer(npc, done) + local text, sound = badgeEndBattleText(game, "OPP_BROCK#1") + ow:engageTrainer(npc, done, text, nil, sound) end end, } @@ -91,7 +108,8 @@ local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryK game.stack:push(TextBox.new(game, game.data.text[adviceLabel] or fallback, finish)) else - ow:engageTrainer(npc, done) + local text, sound = badgeEndBattleText(game, victoryKey) + ow:engageTrainer(npc, done, text, nil, sound) end end end diff --git a/data/scripts/story.lua b/data/scripts/story.lua index a4ad40ec..818d8281 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -837,13 +837,14 @@ M.SILPH_CO_11F = { -- every Silph rocket leaves off-screen (the street rockets are -- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not -- run here: the battle's own callbacks are still unwinding, so - -- queueScript starts it on the first idle overworld frame -- - -- after the end-battle "Arrgh!!" box victories.lua OPP_GIOVANNI#2 - -- pushes (#722). + -- queueScript starts it on the first idle overworld frame (#722). if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then ow:queueScript(silphAftermathRows()) end - end, nil, true) + end, + -- "Arrgh!!" is armed for the battle screen, not the map + -- (scripts/SilphCo11F.asm:264-266 SaveEndBattleTextPointers) #1606 + game.data.text._SilphCo10FGiovanniILostAgainText, true) end) end)) return true diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 3536dd8f..5a7a8bda 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -216,7 +216,10 @@ local function dojoMasterGate(game, ow, x, y) if not master or ow:trainerDefeated(master) then return false end ow.player.facing = "right" master:facePlayer(ow.player) - ow:engageTrainer(master) + -- scripts/FightingDojo.asm:117-119 SaveEndBattleTextPointers (#1606) + ow:engageTrainer(master, nil, + ((game.data or {}).text or {})._FightingDojoKarateMasterDefeatedText, + nil, nil, false) return true end diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index 6f42068c..94221532 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -647,27 +647,29 @@ end local rocketRows = { { "face_player" }, -- 1 { "check_flag", "EVENT_GOT_TM28" }, -- 2 - { "jump_if_true", 15 }, -- 3 → CeruleanHideRocket + { "jump_if_true", 16 }, -- 3 → CeruleanHideRocket { "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4 - { "jump_if_true", 9 }, -- 5 + { "jump_if_true", 10 }, -- 5 { "show_text", "_CeruleanCityRocketText" }, -- 6 - { "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7 - { "jump_if_false", "end" }, -- 8 - { "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9 - { "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10 - { "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints) - { "set_flag", "EVENT_GOT_TM28" }, -- 12 - { "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13 - { "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14 - { "fade", "out" }, -- 15 GBFadeOutToBlack + -- scripts/CeruleanCity.asm:297 SaveEndBattleTextPointers + { "save_end_battle_text", "_CeruleanCityRocketIGiveUpText" }, -- 7 + { "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 8 + { "jump_if_false", "end" }, -- 9 + { "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 10 + { "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 11 + { "give_item", "TM_DIG", 1, false }, -- 12 (row 14 prints) + { "set_flag", "EVENT_GOT_TM28" }, -- 13 + { "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 14 + { "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 15 + { "fade", "out" }, -- 16 GBFadeOutToBlack -- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2 -- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south -- door neighbour -- the swap reconnects the city (Bill's ticket does -- the same in story.lua; either route is enough). - { "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16 - { "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17 - { "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18 - { "fade", "in" }, -- 19 GBFadeInFromBlack + { "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 17 + { "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 18 + { "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 19 + { "fade", "in" }, -- 20 GBFadeInFromBlack } M.CERULEAN_CITY = { diff --git a/docs/updater.md b/docs/updater.md index 5935b617..5e3b70d6 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -64,7 +64,8 @@ mounted or deleted as stale; the launcher directs the player to a full package. Each tagged release `vX.Y.Z` carries the existing per-platform archives (`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`, -`-android.apk`) plus two assets the updater itself consumes: +`-linux-arm64.AppImage`, `-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and +PortMaster archives) plus two assets the updater itself consumes: - `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern `gen1recomp-.love` (see `isPayloadName` in `Boot.lua` and @@ -75,8 +76,9 @@ Each tagged release `vX.Y.Z` carries the existing per-platform archives filename otherwise to match the asset name exactly. A release missing either asset is treated as "no in-place update available": -`Check` reports `needs_full` and sends the player to `Check.releaseUrl()` -(`https://github.com/bryanthaboi/gen1recomp/releases/latest`). +`Check` reports `needs_full`. It also selects the exact current platform asset +from the same release and persists the requirement, so it is visible again on +every launch, including offline launches. ## Save-directory layout @@ -85,6 +87,7 @@ Under the save directory (identity `pokemon-love2d`): ``` updates/gen1recomp-.love downloaded payload(s) updates/pending.txt crash-guard marker +updates/full-update.json persistent native-package requirement ``` `pending.txt` holds the filename of the payload currently being chainloaded. @@ -106,7 +109,8 @@ bundled game, in that case. against the GitHub releases API; safe to call every frame, it is a no-op once a check is in flight or has reached a terminal state. `Check.state()` reports `idle | checking | uptodate | available | downloading | ready | - needs_full | error` plus the latest version and download progress. + needs_full | full_downloading | full_ready | error` plus the latest version, + download progress, and (when applicable) the selected full-package asset. 3. **Download + verify**: on `available`, `Check.download()` tells the worker to fetch the payload, polling the growing `.part` file for progress. On completion the worker re-fetches `sha256sums.txt`, verifies @@ -117,6 +121,14 @@ bundled game, in that case. 4. **Restart to apply**: a `ready` payload just sits in `updates/` until the player relaunches; the next launch's Boot step (1) is what actually mounts and runs it. There is no in-session hot-swap. +5. **Native-package requirement**: when `minShell` or `payloadHost` is + incompatible, the worker writes `full-update.json` and surfaces a + persistent launcher control. Android downloads the release APK, verifies + its SHA-256 entry from `sha256sums.txt`, then invokes Android's Package + Installer. The installer asks the user for consent and enforces package, + version-code, and signing-certificate compatibility. iOS links the + sideload repository for a re-sideload; Xbox, desktop, and PortMaster builds + link their correctly named full package. Switch keeps its native OTA flow. ## Known limitations @@ -140,6 +152,13 @@ bundled game, in that case. still need a full reinstall (`minShell` / `payloadHost` gate → `needs_full`). Applying a downloaded payload on Android relaunches via `love.system.restartApp`; iOS still uses in-process `quit("restart")`. +- **Android full updates are user-confirmed and certificate-bound.** The app + uses a private `FileProvider` cache path plus + `Intent.ACTION_INSTALL_PACKAGE`, checks Android 8+'s per-app + "install unknown apps" setting, and never requests a silent install. The + release job must use the original long-lived Android signing key; a new key + causes Android to reject an in-place update and requires a one-time manual + reinstall. See [mobile/ANDROID.md](../mobile/ANDROID.md). - **Dev/source runs never self-update.** `Boot.run` returns immediately when `love.filesystem.isFused()` is false, and a working tree's `engine` is the `"0.0.0-dev"` placeholder that always reports up to date, so a source diff --git a/mobile/ANDROID.md b/mobile/ANDROID.md index 7680c614..8718896b 100644 --- a/mobile/ANDROID.md +++ b/mobile/ANDROID.md @@ -93,8 +93,9 @@ transport, exactly as a missing curl does. love-android 11.5a expects: - **JDK 17** -- Android SDK with **API 34** +- Android SDK with **API 36** (Android 16; latest 36.x Build-Tools) - NDK **25.2.9519653** (Apple Silicon host supported) +- **minSdk 19** (Android 4.4), **targetSdk 36** (Android 16) Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write `local.properties` when it finds `~/Library/Android/sdk`. @@ -122,15 +123,24 @@ scripts, tests, and mobile build sources are excluded. | `app.application_id` | `com.theboisclub.pokemonred` | | `app.name` | Pokemon Red | | `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. | -| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted | -| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept | +| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*1,000,000 + minor*1,000 + patch); left as-is if `--version` is omitted | +| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept; REQUEST_INSTALL_PACKAGES is limited to the user-confirmed full-update installer | ## Releases `.github/workflows/release.yml` builds the APK with `--version` set to the release version and publishes it alongside the macOS/Windows/Linux builds as -`PokemonRed--android.apk`. +`gen1recomp--android.apk`. ## Signing -Signed with the default Android keystore (no setup required). +Production APKs are built with `scripts/build_android.sh --release`. They must +be signed with the same long-lived certificate as the currently installed app: +Android's Package Installer rejects an update with a different signing +certificate. Store that keystore and its passwords only in CI secrets, expose +them as `GEN1RECOMP_ANDROID_KEYSTORE`, +`GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD`, `GEN1RECOMP_ANDROID_KEY_ALIAS`, and +`GEN1RECOMP_ANDROID_KEY_PASSWORD`, and never commit the keystore. A newly +created certificate cannot update users who have an APK signed by a different +legacy key; those users need one final manual reinstall before in-app updates +can take over. diff --git a/mobile/android/README.md b/mobile/android/README.md index ca2aebab..b77d4ab2 100644 --- a/mobile/android/README.md +++ b/mobile/android/README.md @@ -41,7 +41,7 @@ Quick Start: Before you start, install JDK 17 (not later not earlier). If you intend to build from Android Studio, skip this step as Android Studio bundles its own JDK 17. -Install Android SDK with SDK API 34 (34.x.y) and Android NDK 25.2.9519653, set the environment variable +Install Android SDK with SDK API 36 (latest 36.x Build-Tools) and Android NDK 25.2.9519653, set the environment variable `ANDROID_SDK_ROOT` to your Android SDK location and run: ``` diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index b5f49c2e..9a1e7354 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -10,9 +10,12 @@ android { applicationId project.properties["app.application_id"] versionCode project.properties["app.version_code"].toInteger() versionName project.properties["app.version_name"] - minSdk 16 - compileSdk 34 - targetSdk 34 + // NDK r25 no longer supports API 16; API 19 is Android 4.4 and keeps + // the native toolchain and package-installer bridge on a supported ABI. + minSdk 19 + // Android 16 / API 36: current Android distribution target. + compileSdk 36 + targetSdk 36 def getAppName = { def nameArray = project.properties["app.name_byte_array"] @@ -38,10 +41,31 @@ android { ORIENTATION:project.properties["app.orientation"], ] } + // Release signing lives outside the repository. The release build script + // requires all five values below, while debug builds intentionally remain + // usable without them. + def releaseStore = System.getenv("GEN1RECOMP_ANDROID_KEYSTORE") + def releaseStorePassword = System.getenv("GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD") + def releaseKeyAlias = System.getenv("GEN1RECOMP_ANDROID_KEY_ALIAS") + def releaseKeyPassword = System.getenv("GEN1RECOMP_ANDROID_KEY_PASSWORD") + def hasReleaseSigning = releaseStore && releaseStorePassword && releaseKeyAlias && releaseKeyPassword + + if (hasReleaseSigning) { + signingConfigs { + release { + storeFile file(releaseStore) + storePassword releaseStorePassword + keyAlias releaseKeyAlias + keyPassword releaseKeyPassword + } + } + } + buildTypes { release { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + if (hasReleaseSigning) signingConfig signingConfigs.release } } flavorDimensions = ['mode', 'recording'] diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 33ad1ea6..a12254e6 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,10 @@ the link screen shows as "(Operation not permitted)" (issue #287). scripts/build_android.sh must not strip this again. --> + + + + + + + + + diff --git a/mobile/android/build.gradle b/mobile/android/build.gradle index 3ddb7b3e..48ebeee4 100644 --- a/mobile/android/build.gradle +++ b/mobile/android/build.gradle @@ -18,7 +18,8 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.1.1' + // Android 16 / API 36 requires Android Gradle Plugin 8.9+. + classpath 'com.android.tools.build:gradle:8.9.2' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties index df2f35ec..8f9a1f99 100644 --- a/mobile/android/gradle.properties +++ b/mobile/android/gradle.properties @@ -15,7 +15,6 @@ app.version_name=11.5a # No need to modify anything past this line! android.enableJetifier=false android.useAndroidX=true -android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=true android.nonFinalResIds=true app.name=gen1recomp diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties index 0c85a1f7..4eaec467 100644 --- a/mobile/android/gradle/wrapper/gradle-wrapper.properties +++ b/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/mobile/android/love/build.gradle b/mobile/android/love/build.gradle index eb3dc16e..de825748 100644 --- a/mobile/android/love/build.gradle +++ b/mobile/android/love/build.gradle @@ -10,9 +10,9 @@ android { ndkVersion '25.2.9519653' defaultConfig { - minSdk 16 - compileSdk 34 - targetSdk 34 + minSdk 19 + compileSdk 36 + targetSdk 36 externalNativeBuild { ndkBuild { arguments "-j" + Runtime.runtime.availableProcessors() diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index 6aa94eef..fd446869 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -283,6 +283,40 @@ bool restartApp() return result; } +bool installApk(const char *path) +{ + if (path == nullptr || path[0] == '\0') + return false; + + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + // This may be called from Lua's main thread, but use the activity object + // class just like httpDownload so a future worker caller does not depend on + // the system JNI class loader finding the app class. + void *rawActivity = SDL_AndroidGetActivity(); + if (rawActivity == nullptr) + return false; + jobject activityObj = (jobject) rawActivity; + jclass activity = env->GetObjectClass(activityObj); + env->DeleteLocalRef(activityObj); + + jmethodID method = env->GetStaticMethodID(activity, "installApk", + "(Ljava/lang/String;Ljava/lang/String;)Z"); + if (method == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return false; + } + + jstring jpath = env->NewStringUTF(path); + jstring jroot = env->NewStringUTF(bridgeSaveDirectory()); + jboolean result = env->CallStaticBooleanMethod(activity, method, jpath, jroot); + env->DeleteLocalRef(jroot); + env->DeleteLocalRef(jpath); + env->DeleteLocalRef(activity); + return result; +} + bool updateAppShortcuts(const std::vector &versions) { JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); diff --git a/mobile/android/love/src/jni/love/src/common/android.h b/mobile/android/love/src/jni/love/src/common/android.h index 0b56890c..64a493fd 100644 --- a/mobile/android/love/src/jni/love/src/common/android.h +++ b/mobile/android/love/src/jni/love/src/common/android.h @@ -90,6 +90,12 @@ bool syncHealthSteps(); **/ bool restartApp(); +/** + * Stages a checksum-verified APK from the current save directory and starts + * Android's user-confirmed Package Installer flow. Android-only. + **/ +bool installApk(const char *path); + /** * Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions. **/ diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.cpp b/mobile/android/love/src/jni/love/src/modules/system/System.cpp index 329cff19..54a5be46 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp @@ -245,6 +245,16 @@ bool System::restartApp() const #endif } +bool System::installApk(const char *path) const +{ +#ifdef LOVE_ANDROID + return love::android::installApk(path); +#else + LOVE_UNUSED(path); + return false; +#endif +} + bool System::updateShortcuts(const std::vector &versions) const { #ifdef LOVE_ANDROID diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h index 17fd2e39..2fcfad46 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.h +++ b/mobile/android/love/src/jni/love/src/modules/system/System.h @@ -143,6 +143,9 @@ public: **/ virtual bool restartApp() const; + /** Starts Android's user-confirmed install flow for a verified APK. */ + virtual bool installApk(const char *path) const; + virtual bool updateShortcuts(const std::vector &versions) const; virtual std::string getLaunchGame() const; diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp index 2405c607..0acdbf0c 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp @@ -132,6 +132,13 @@ int w_restartApp(lua_State *L) return 1; } +int w_installApk(lua_State *L) +{ + const char *path = luaL_checkstring(L, 1); + luax_pushboolean(L, instance()->installApk(path)); + return 1; +} + int w_httpDownload(lua_State *L) { const char *url = luaL_checkstring(L, 1); @@ -325,6 +332,7 @@ static const luaL_Reg functions[] = { "createFile", w_createFile }, { "syncHealthSteps", w_syncHealthSteps }, { "restartApp", w_restartApp }, + { "installApk", w_installApk }, { "updateShortcuts", w_updateShortcuts }, { "getLaunchGame", w_getLaunchGame }, { "httpDownload", w_httpDownload }, 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 e59fe574..2fe59948 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 @@ -45,6 +45,7 @@ import android.app.AlarmManager; import android.app.AlertDialog; import android.app.PendingIntent; import android.content.Context; +import android.content.ClipData; import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; @@ -77,6 +78,7 @@ import android.view.*; import androidx.annotation.Keep; import androidx.core.app.ActivityCompat; +import androidx.core.content.FileProvider; public class GameActivity extends SDLActivity { private static DisplayMetrics metrics = null; @@ -696,6 +698,103 @@ public class GameActivity extends SDLActivity { return true; // unreachable, but keeps the JNI signature honest } + /** + * Stages a verified release APK in cache and asks Android's Package + * Installer to update this package. This never silently installs an APK: + * the platform owns both the unknown-sources consent and final install + * confirmation. `updateRoot` comes from the native save directory and is + * checked before any file is read, so a Lua caller cannot turn this into a + * general-purpose local-file sharing bridge. + */ + @Keep + public static boolean installApk(final String sourcePath, final String updateRoot) { + final GameActivity self = (GameActivity) mSingleton; + if (self == null || sourcePath == null || updateRoot == null) return false; + final File source; + try { + source = new File(sourcePath).getCanonicalFile(); + File root = new File(updateRoot, "updates").getCanonicalFile(); + String rootPath = root.getPath() + File.separator; + if (!source.getPath().startsWith(rootPath) + || !source.isFile() || source.length() == 0 + || !source.getName().matches("gen1recomp-[0-9]+\\.[0-9]+\\.[0-9]+-android\\.apk")) { + return false; + } + } catch (IOException e) { + Log.d("GameActivity", "invalid update APK path: " + e.getMessage()); + return false; + } + + // Android 8+ lets the user decide whether this app is trusted to + // request package installs. Send them to the per-app setting first; + // they deliberately tap Install again after granting it. + if (android.os.Build.VERSION.SDK_INT >= 26 + && !self.getPackageManager().canRequestPackageInstalls()) { + try { + Intent settings = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:" + self.getPackageName())); + self.startActivity(settings); + return true; + } catch (Exception e) { + Log.d("GameActivity", "could not open install-source settings: " + e.getMessage()); + return false; + } + } + + // Copying an APK can be large; keep both I/O and checksum-verified + // source access off the UI thread. The FileProvider exposes this cache + // child only after it has been fully written and renamed. + new Thread(new Runnable() { + @Override public void run() { + File stagedDir = new File(self.getCacheDir(), "full-update"); + File partial = new File(stagedDir, "update.apk.part"); + File staged = new File(stagedDir, "update.apk"); + try { + if (!stagedDir.exists() && !stagedDir.mkdirs()) return; + copyFile(source, partial); + if (staged.exists() && !staged.delete()) return; + if (!partial.renameTo(staged)) return; + self.runOnUiThread(new Runnable() { + @Override public void run() { launchPackageInstaller(self, staged); } + }); + } catch (Exception e) { + Log.d("GameActivity", "could not stage update APK: " + e.getMessage()); + } finally { + if (partial.exists()) partial.delete(); + } + } + }, "gen1recomp-apk-stage").start(); + return true; + } + + private static void copyFile(File source, File destination) throws IOException { + InputStream in = new BufferedInputStream(new FileInputStream(source)); + OutputStream out = new BufferedOutputStream(new FileOutputStream(destination)); + try { + byte[] buffer = new byte[32768]; + int count; + while ((count = in.read(buffer)) != -1) out.write(buffer, 0, count); + } finally { + try { out.close(); } catch (IOException ignored) {} + try { in.close(); } catch (IOException ignored) {} + } + } + + private static void launchPackageInstaller(GameActivity activity, File apk) { + try { + Context context = activity.getApplicationContext(); + Uri uri = FileProvider.getUriForFile(context, + context.getPackageName() + ".full_update_provider", apk); + Intent install = new Intent(Intent.ACTION_INSTALL_PACKAGE); + install.setData(uri); + install.setClipData(ClipData.newRawUri("apk", uri)); + install.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + activity.startActivity(install); + } catch (Exception e) { + Log.d("GameActivity", "could not open package installer: " + e.getMessage()); + } + } + @Keep public static String getLaunchGame() { return initialGame != null ? initialGame : ""; diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 1661cfc5..e5b508a5 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -1,19 +1,20 @@ #!/usr/bin/env bash # Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a. # -# Usage: scripts/build_android.sh [--version X.Y.Z] [--package-only] +# Usage: scripts/build_android.sh [--version X.Y.Z] [--release] [--package-only] # # --version X.Y.Z set app.version_name / app.version_code (else left as-is) +# --release build the production-signed release APK (requires the +# GEN1RECOMP_ANDROID_* signing environment variables) # --package-only zip game.love + apply branding; skip gradle # # Prerequisites: # - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md) -# - Android SDK + NDK (SDK API 34, NDK 25.2.9519653) +# - Android SDK + NDK (SDK API 36, NDK 25.2.9519653) # - JDK 17 # # Output (after gradle): -# dist/android/debug/*.apk (convenience copy) -# mobile/android/app/build/outputs/apk/embedNoRecord/debug/*.apk +# dist/android/debug/*.apk (normal local build) or dist/android/release/*.apk set -euo pipefail @@ -26,6 +27,7 @@ APP_NAME="gen1recomp" APPLICATION_ID="com.theboisclub.pokemonred" LOVE_ANDROID_VERSION="11.5a" NDK_VERSION="25.2.9519653" +ANDROID_API="36" YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json" YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}" GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json" @@ -35,6 +37,7 @@ SILVER_MANIFEST_URL="${SILVER_MANIFEST_URL:-https://raw.githubusercontent.com/br VERSION="" PACKAGE_ONLY=false +RELEASE=false say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; } @@ -44,11 +47,12 @@ while [ $# -gt 0 ]; do case "$1" in --version) VERSION="$2"; shift ;; --package-only) PACKAGE_ONLY=true ;; + --release) RELEASE=true ;; -h|--help) sed -n '2,20p' "$0" exit 0 ;; - *) fail "unknown argument: $1 (try --version X.Y.Z or --package-only)" ;; + *) fail "unknown argument: $1 (try --version X.Y.Z, --release, or --package-only)" ;; esac shift done @@ -62,7 +66,22 @@ if [ -n "$VERSION" ]; then rest="${VERSION#*.}" minor="${rest%%.*}" patch="${rest##*.}" - VERSION_CODE=$((major * 10000 + minor * 100 + patch)) + # Reserve three digits for each lower component. This stays monotonic across + # 1.0.100 -> 1.1.0, unlike the old two-digit encoding, and remains inside + # Android's signed 32-bit versionCode range for normal release versions. + if [ "$minor" -gt 999 ] || [ "$patch" -gt 999 ] || [ "$major" -gt 2099 ]; then + fail "--version components exceed Android versionCode limits" + fi + VERSION_CODE=$((major * 1000000 + minor * 1000 + patch)) +fi + +if $RELEASE; then + for var in GEN1RECOMP_ANDROID_KEYSTORE GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD \ + GEN1RECOMP_ANDROID_KEY_ALIAS GEN1RECOMP_ANDROID_KEY_PASSWORD; do + [ -n "${!var:-}" ] || fail "--release requires $var" + done + [ -f "$GEN1RECOMP_ANDROID_KEYSTORE" ] \ + || fail "Android signing keystore does not exist: $GEN1RECOMP_ANDROID_KEYSTORE" fi # --------------------------------------------------------------- preconditions @@ -389,13 +408,18 @@ require_android_sdk() { export ANDROID_SDK_ROOT=\$HOME/Library/Android/sdk or create mobile/android/local.properties with: sdk.dir=/path/to/Android/sdk - love-android $LOVE_ANDROID_VERSION expects SDK API 34 and NDK $NDK_VERSION + love-android $LOVE_ANDROID_VERSION expects SDK API $ANDROID_API and NDK $NDK_VERSION (see mobile/ANDROID.md)." fi export ANDROID_SDK_ROOT="$sdk" export ANDROID_HOME="$sdk" + if [ ! -d "$sdk/platforms/android-$ANDROID_API" ]; then + fail "Android SDK platform android-$ANDROID_API is not installed. + Install Android $ANDROID_API (and the latest 36.x Build-Tools) in SDK Manager." + fi + local props="$ANDROID_DIR/local.properties" # Always rewrite so a leftover Docker sdk.dir=/opt/android-sdk cannot stick. printf 'sdk.dir=%s\n' "$sdk" > "$props" @@ -412,7 +436,12 @@ require_android_sdk() { # --------------------------------------------------------------- gradle run_gradle() { - local task="assembleEmbedNoRecordDebug" + local variant="debug" + $RELEASE && variant="release" + # Keep this compatible with macOS's bundled Bash 3.2 (no ${var^}). + local variant_title="Debug" + $RELEASE && variant_title="Release" + local task="assembleEmbedNoRecord$variant_title" local build_dir="$ANDROID_DIR" # ndk-build is GNU make underneath and cannot cope with spaces anywhere in @@ -447,12 +476,12 @@ run_gradle() { You can still iterate on the .love payload with: scripts/build_android.sh --package-only" fi - local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/debug" + local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/$variant" if [ -d "$out_dir" ]; then say "APK output:" find "$out_dir" -name '*.apk' -exec ls -lh {} \; - local dist_dir="$DIST/debug" + local dist_dir="$DIST/$variant" rm -rf "$dist_dir" mkdir -p "$dist_dir" find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \; diff --git a/src/battle/BattleSafety.lua b/src/battle/BattleSafety.lua index fb4881f3..89cf6836 100644 --- a/src/battle/BattleSafety.lua +++ b/src/battle/BattleSafety.lua @@ -8,6 +8,7 @@ local BattleSafety = {} local BATTLE_BUSY_FIELDS = { "current", "afterQueue", "nextInsert", "pendingHit", "waitingUI", "waitingSound", "waitFrames", "draining", "animPlaying", "growIn", + "shrinkOut", "introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result", } diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 68644961..4a61ef0a 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1364,7 +1364,7 @@ function BattleState:updateQueue() -- subanimation (or just the coarse fx when animations are off). -- item.hit carries the target's blink + damage sound, applied when -- the animation ends (hitRow rows carry a hit with no animation -- - -- thrash/rage continuation turns that skip the announcement). + -- Mimic, whose animation waits on a successful copy). if item.anim or item.hitRow then -- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then -- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass @@ -2495,9 +2495,12 @@ function BattleState:openOldManBag() -- POKé BALLs; pokeyellow's SimulatedInputBattleItemList, shared by -- the Viridian tutorial and Oak's catch, has one. local qty = require("src.core.GameVersion").isYellow() and "x1" or "x50" + -- the tutorial bag rides DisplayBagMenu's LIST_MENU_BOX over the battle + -- screen (engine/battle/core.asm:2210) list = ListMenu.new(game, "ITEMS", { { value = "POKE_BALL", label = Strings("POKé BALL"), right = qty }, }, { + itemBox = true, script = function(l) l.scriptTimer = (l.scriptTimer or 0) + 1 if l.scriptTimer == 81 then @@ -2708,9 +2711,11 @@ function BattleState:resolveSwitch(newMon) self.afterQueue = "menu" self:act(function() -- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the - -- outgoing pic and holds 50 frames before the mon is recalled + -- outgoing pic and holds 50 frames, then AnimateRetreatingPlayerMon + -- runs before the mon is recalled self:sayNextAuto(self:withdrawText(self.player.name), Timing.SWITCH_PLAYER_MON) + self:queueRetreatAnim() self:actNext(function() self:restoreMimicked(self.player) -- the battle copy leaves with it local previous = self.player @@ -3322,6 +3327,26 @@ function BattleState:queueSendOutAnim(append) if append then self:act(fn) else self:actNext(fn) end end +-- AnimateRetreatingPlayerMon (core.asm:1769-1796); the Yellow starter Pikachu +-- slides off instead (pokeyellow core.asm:1862-1866, animations.asm:1259) +function BattleState:queueRetreatAnim() + if self:starterPikachuSendOut() then + self:actNext(function() self:slidePic("playerMon", 0, -64, 8, 3) end) + self:waitNext(24) + self:actNext(function() + -- .clearScreenArea keeps the 7x7 area blank until the swap + -- (pokeyellow core.asm:1867-1871) (#1545) + self.sendingOut = true + self:slidePic("playerMon") + end) + else + self:actNext(function() + self.shrinkOut = { battler = self.player, frame = 0 } + end) + self:waitNext(7) + end +end + -- Should the low-health alarm sound this frame? pokered keys it off -- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets -- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is @@ -3538,6 +3563,12 @@ function BattleState:updateFx() self.growIn.frame = self.growIn.frame + 1 if self.growIn.frame >= 12 then self.growIn = nil end end + -- the retreat shrink (AnimateRetreatingPlayerMon): 4+3 frames, then the + -- 7x7 area holds cleared (scale 0) until the swap replaces the battler + if self.shrinkOut then + self.shrinkOut.frame = self.shrinkOut.frame + 1 + if self.shrinkOut.battler ~= self.player then self.shrinkOut = nil end + end -- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren -- loops while the player's bar is red; see lowHealthAlarmActive local Sound = require("src.core.Sound") @@ -3802,6 +3833,8 @@ function BattleState:statusInterrupt(user, target, selectedId) { rng = self.rng, forceCrit = false, typeless = true, screens = target }) self:sayNext(self:romText("_HurtItselfText", "It hurt itself in\nits confusion!")) + -- HandleSelfConfusionDamage (core.asm:3706-3714, enemy side :5807-5811) + self:animNext("POUND", not user.isPlayer) self:clearVolatiles(user, true) self:applyDamage(user, dmg) if user.mon.hp <= 0 then self:onFaint(user) end @@ -3894,18 +3927,28 @@ function BattleState:performMove(user, target, moveInst, isCalled) end self.moveAnimRow = nil - if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then - self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name)) - -- the move's animation plays right after the announcement; the - -- damage path attaches the target's hit blink to this row so the - -- blink follows the animation (pokered's order). Mimic is the - -- exception (announceAnim = false): PlayCurrentMoveAnimation runs - -- only after a successful copy, never on a miss -- applyMimic queues it - if not (record and record.announceAnim == false) then - self.nextInsert = (self.nextInsert or 0) + 1 - self.moveAnimRow = { anim = move.id, attackerIsPlayer = user.isPlayer } - table.insert(self.queue, self.nextInsert, self.moveAnimRow) + local thrashing = user.thrashTurns and moveInst == user.thrashMove + and user.thrashAnnounced or false + if thrashing then + -- .ThrashingAboutCheck (core.asm:3531-3552) + self:sayNextAuto(self:romText("_ThrashingAboutText", "%s's\nthrashing about!", + displayName(user))) + user.thrashTurns = user.thrashTurns - 1 + if user.thrashTurns <= 0 then + user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil + if not user.confusedTurns then user.confusedTurns = self.rng(2, 5) end end + else + self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name)) + end + -- PlayCurrentMoveAnimation follows the announcement; Mimic (announceAnim + -- = false) queues it from applyMimic after a successful copy + if not (record and record.announceAnim == false) then + self.nextInsert = (self.nextInsert or 0) + 1 + -- ld a, THRASH / ld [wPlayerMoveNum] (core.asm:3534-3535, :5909-5910) #1577 + self.moveAnimRow = { anim = thrashing and "THRASH" or move.id, + attackerIsPlayer = user.isPlayer } + table.insert(self.queue, self.nextInsert, self.moveAnimRow) end Runtime.emit("battle.move_used", { battle = self, user = user, target = target, move = move, @@ -3913,6 +3956,9 @@ function BattleState:performMove(user, target, moveInst, isCalled) }) local ctx = EffectRegistry.makeCtx(self, user, target, move, moveInst, isCalled) + -- .ThrashingAboutCheck jumps past JumpMoveEffect into PlayerCalcMoveDamage + -- (core.asm:3540), so SpecialEffectsCont never re-runs on a locked turn + ctx.thrashing = thrashing -- Metronome / Mirror Move re-entry; a nil pick means the record -- already said its failure text @@ -4185,8 +4231,11 @@ function BattleState:awardExp() end local function applyShare(mon, split, announce) local playerId = self.game.save.player and self.game.save.player.id - local traded = mon.traded == true - or (mon.otId ~= nil and playerId ~= nil and mon.otId ~= playerId) + -- GainExperience (engine/battle/experience.asm:69-88) compares the + -- stored MON_OTID against wPlayerID every award; no persistent flag + -- mon.traded covers otId-less mons (repairTradedOtIds, old link peers) #1488 + local traded = playerId ~= nil and ((mon.otId ~= nil and mon.otId ~= playerId) + or (mon.otId == nil and mon.traded == true)) local levels, gained = Experience.apply(self.data, mon, self.enemy.def, self.enemy.mon.level, self.kind == "trainer", split, traded) @@ -4338,21 +4387,36 @@ function BattleState:enemyMonFainted() -- the battle queue's own \f handling (not TextBox.lua's) does not -- page a sayChoice string the same way -- left as two calls. self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName)) + -- EnemySendOutFirstMon .next9/.next8 (core.asm:1390-1409) and + -- HasMonFainted's NoWillText (core.asm:1473-1488) self:sayChoice( Strings("Will %s\nchange POKéMON?", self.game.save.player.name), function(yes) if not yes then return end local game = self.game - Screens.push(game, "PartyMenu", { + local shiftOpts, reopenShift + reopenShift = function(text) + table.insert(self.queue, 1, { ui = function() + return self:buildScreen("PartyMenu", shiftOpts) + end }) + table.insert(self.queue, 1, { text = text }) + end + shiftOpts = { battle = self, party = self:playerPartyView(), forceSwitch = true, onSwitch = function(mon) - if mon ~= self.player.mon and mon.hp > 0 then + if mon == self.player.mon then + reopenShift(self:romText("_AlreadyOutText", + "%s is\nalready out!", self.player.name)) + elseif mon.hp <= 0 then + reopenShift(self:romText("_NoWillText", "There's no will\nto fight!")) + else shiftSwitchMon = mon end end, - }) + } + Screens.push(game, "PartyMenu", shiftOpts) end, { box = Theme.trainerSwitchBox }) end self:act(function() @@ -4395,10 +4459,11 @@ function BattleState:enemyMonFainted() local mon = shiftSwitchMon if not mon then return end -- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame - -- hold, then the recall and the send-out + -- hold, AnimateRetreatingPlayerMon, then the recall and the send-out self.nextInsert = 0 self:sayNextAuto(self:withdrawText(self.player.name), Timing.SWITCH_PLAYER_MON) + self:queueRetreatAnim() self:actNext(function() local previous = self.player self.player = makeBattler(self.data, mon, true, self.game.save) @@ -4470,9 +4535,22 @@ function BattleState:enemyMonFainted() -- TrainerNamePointers aims those entries at wTrainerName). The tag -- prints once, so a `para` page carries no second copy (#566). local tag = self.trainer and self.trainer.name + -- the badge jingle (sound_get_item_1 and friends) rides the armed + -- line's first page, as the script's text command would (#1606) + local sfx = self.endBattleSound + local data = self.data for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do if page ~= "" then - self:sayNext(tag and (tag .. ": " .. page) or page) + local line = tag and (tag .. ": " .. page) or page + if sfx then + local id = sfx + self:sayNextWaitSfx(line, function() + return require("src.core.Sound").play(data, id) + end) + sfx = nil + else + self:sayNext(line) + end tag = nil end end @@ -5088,10 +5166,14 @@ function BattleState:openParty() battle = self, party = self:playerPartyView(), onSwitch = function(mon) + -- PartyMenuOrRockOrRun's SWITCH .partyMonDeselected (core.asm:2396-2408) if mon == self.player.mon then - self:say(Strings("%s is\nalready out!", self.player.name)) + self:say(self:romText("_AlreadyOutText", + "%s is\nalready out!", self.player.name)) + self:act(function() self:openParty() end) elseif mon.hp <= 0 then self:say(self:romText("_NoWillText", "There's no will\nto fight!")) + self:act(function() self:openParty() end) else self:resolveSwitch(mon) end @@ -5235,6 +5317,16 @@ function BattleState:growInScale(battler) return f < 3 and 0 or f < 7 and 3 / 7 or 5 / 7 end +-- AnimateRetreatingPlayerMon's CopyDownscaledMonTiles stages +-- (core.asm:1769-1796) +function BattleState:shrinkOutScale(battler) + local shrink = self.shrinkOut + if not shrink or shrink.battler ~= battler then return nil end + -- scale 0 past Delay3: the area stays cleared until the swap + -- (core.asm:1790-1796) (#1563) + return shrink.frame < 4 and 5 / 7 or shrink.frame < 7 and 3 / 7 or 0 +end + -- battler hidden this frame? (damage blink) -- -- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits @@ -5871,15 +5963,18 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) local s = BattleState.resolveBattleScale(self.data, "back", imagePathOf(self.player.sprite), self.player.mon and self.player.mon.species) - local gs = self:growInScale(self.player) + local gs = self:growInScale(self.player) or self:shrinkOutScale(self.player) if gs then - -- the player-side AnimateSendingOutMon grow (after the poof, - -- core.asm:1757-1762): feet pinned at y=96, horizontal centre - -- pinned, mod scale composed with the grow stage + -- the player-side AnimateSendingOutMon grow (core.asm:1757-1762) and + -- the AnimateRetreatingPlayerMon shrink (core.asm:1769-1796) local eff = s * gs if eff > 0 then + -- the retreat stages sit one tile right of the grow-in's + -- (hlcoord 3,7 / 4,9 vs 2,7 / 3,9, core.asm:1770-1788) (#1563) + local shrinkX = self.shrinkOut + and self.shrinkOut.battler == self.player and 8 or 0 love.graphics.draw(img, - 8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx, + 8 + shrinkX - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx, 96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff) end else diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 09699e0b..525a6c58 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -108,13 +108,17 @@ end -- The damaging pipeline, extracted from the performMove monolith: every -- stage keeps the original's exact check order and rng consumption --- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy -> +-- (pre-accuracy -> invulnerability -> gate -> hit count -> accuracy -> -- damage choice -> hits -> messages -> after-damage -> secondary run). function EffectRegistry.runDamaging(battle, ctx, record) local user, target = ctx.user, ctx.target local move, moveInst = ctx.move, ctx.moveInst local neverMiss = record and record.neverMiss + -- SpecialEffectsCont's JumpMoveEffect (core.asm:3129-3133) runs before + -- MoveHitTest's INVULNERABLE test (:3150), mid-Fly/Dig included (#1565) + if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end + -- Swift ignores semi-invulnerability (MoveHitTest returns hit for -- SWIFT_EFFECT before the INVULNERABLE check) if target.invulnerable and not neverMiss then @@ -143,8 +147,6 @@ function EffectRegistry.runDamaging(battle, ctx, record) local hits = hitCount(ctx, record) - if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end - if not neverMiss then if not battle:accuracyRoll(move, user, target) then -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) @@ -221,7 +223,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType -- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the -- announcement-time moveAnimRow, later hits queue fresh anim rows. - -- Thrash/rage continuations have no announcement anim -- a bare + -- Mimic queues no announcement anim (announceAnim = false) -- a bare -- hitRow carries the blink instead. -- PlayApplyingAttackSound (engine/battle/animations.asm, the routine after -- PlayApplyingAttackAnimation) picks the sound off wDamageMultipliers -- 10 diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index b07ae63c..1912967e 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -582,30 +582,15 @@ MoveEffects.full = { }, THRASH_PETAL_DANCE_EFFECT = { -- ThrashPetalDanceEffect (effects.asm:791-808) runs before damage - -- (data/battle/special_effects.asm:22) and animates the setup turn + -- (data/battle/special_effects.asm:22, core.asm:3531-3552) beforeAccuracy = function(ctx) local user = ctx.user - if not user.thrashTurns then - ctx.battle:animBeforeMove( - user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer) - end - end, - afterDamage = function(ctx) - local user = ctx.user - if not user.thrashTurns then - user.thrashTurns = ctx.rng(2, 3) -- 3-4 attacks total, then confusion - user.thrashMove = ctx.moveInst - user.thrashAnnounced = true - else - user.thrashTurns = user.thrashTurns - 1 - if user.thrashTurns <= 0 then - user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil - if not user.confusedTurns then - user.confusedTurns = ctx.rng(2, 5) - ctx.say(romText(ctx.battle.data, "_BecameConfusedText", "%s\nbecame confused!", displayName(user))) - end - end - end + if ctx.thrashing or user.thrashTurns then return end + user.thrashTurns = ctx.rng(2, 3) -- 3-4 attacks total, then confusion + user.thrashMove = ctx.moveInst + user.thrashAnnounced = true + ctx.battle:animBeforeMove( + user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer) end, }, JUMP_KICK_EFFECT = { diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 16ed674e..06d57035 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -166,6 +166,9 @@ Battle.SUBSTATUS_ITEMS = { -- be run from or Roared away. Battle.BATTLETYPE_FORCESHINY = 7 Battle.BATTLETYPE_TRAP = 9 +-- LostBattle's .canlose arm (engine/battle/core.asm:2766): the only battle +-- type whose loss still prints the trainer's own line instead of a whiteout. +Battle.BATTLETYPE_CANLOSE = 1 -- BadgeStatBoosts (engine/battle/core.asm:6534): each of these Johto badges -- raises the PLAYER's in-battle stat by 1/8. The routine walks every other @@ -889,10 +892,13 @@ Battle.PRIORITY = { EFFECT_ENDURE = 3, EFFECT_COUNTER = -1, EFFECT_MIRROR_COAT = -1, - EFFECT_VITAL_THROW = -1, + EFFECT_FORCE_SWITCH = -1, -- Whirlwind, Roar: priority 0, below BASE } function Battle:movePriority(moveId) + -- GetMovePriority `cp VITAL_THROW / ld a, 0 / ret z` + -- (engine/battle/core.asm:787-789). + if moveId == "VITAL_THROW" then return -1 end local def = self:moveDef(moveId) return (def and Battle.PRIORITY[def.effect]) or 0 end @@ -2403,10 +2409,11 @@ Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker) self.enemy = party[target] self.enemy.volatile = carried end - self:emit({ kind = "send", side = side, - mon = side == "player" and self.player or self.enemy, - text = "Go! " .. self:monName(side == "player" and self.player - or self.enemy) .. "!" }) + local sent = side == "player" and self.player or self.enemy + self:emit({ kind = "send", side = side, mon = sent, + hp = sent.hp or 0, status = sent.status or false, + level = sent.level, experience = sent.experience, + text = "Go! " .. self:monName(sent) .. "!" }) end -- BattleCommand_TrapTarget's .Traps table, one line per move: target first, @@ -2665,6 +2672,8 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender, self.stages.enemy = Battle.newStages() end self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming, + hp = incoming.hp or 0, status = incoming.status or false, + level = incoming.level, experience = incoming.experience, text = self:monName(incoming) .. " was dragged out!" }) self:breakTrapsOnSend(incoming) self:spikesDamage(incoming) @@ -3084,6 +3093,7 @@ function Battle:resolveFaints() if self.trainer then self:emit({ kind = "message", text = (self.trainer.name or "TRAINER") .. " was defeated!" }) + self:printWinLossText("win") self:awardPrizeMoney() end -- CheckPayDay, on the win arm only (engine/battle/core.asm:7971-7976, @@ -3110,6 +3120,8 @@ function Battle:resolveFaints() -- can offer a shift on (engine/battle/core.asm:2241-2278). self:emit({ kind = "send", side = "enemy", mon = self.enemy, replacement = true, + hp = self.enemy.hp or 0, status = self.enemy.status or false, + level = self.enemy.level, experience = self.enemy.experience, text = (self.trainer and self.trainer.name or "Foe") .. " sent out " .. self:monName(self.enemy) .. "!" }) Runtime.emit("battle.battler_switched", { @@ -3152,6 +3164,11 @@ function Battle:resolveFaints() local nextIndex = Battle.firstHealthy(self.party) if not nextIndex then self:emit({ kind = "message", text = "You have no more POKéMON!" }) + -- LostBattle (engine/battle/core.asm:2763-2782): only BATTLETYPE_CANLOSE + -- reaches PrintWinLossText on a loss; every other loss whites out. + if self.battleType == Battle.BATTLETYPE_CANLOSE then + self:printWinLossText("lose") + end self:endBattle("lose") return true end @@ -3177,14 +3194,23 @@ function Battle:resolveFaints() return false end --- WinTrainerBattle's money arm, which runs after BattleText_EnemyWasDefeated --- and the frontpic slide: the four quarters are dealt between the wallet and --- Mom's savings and then one StdBattleTextbox names the figure. --- --- The `ld a, [wDebugFlags] / bit DEBUG_BATTLE_F` skip in front of --- PrintWinLossText is the trainer's own after-battle line, which this port --- runs from the script on the way out of the battle rather than from here. --- The payout is not gated on it either way. +-- WinTrainerBattle (engine/battle/core.asm:2310-2323), LostBattle's .canlose +-- arm (:2769-2782), PrintWinLossText (home/trainers.asm:230) +function Battle:printWinLossText(result) + local trainer = self.trainer + if not trainer then return end + -- The DEBUG_BATTLE_F skip sits in front of PrintWinLossText alone, behind + -- the slide (engine/battle/core.asm:2310, :2320-2323). + -- The CANLOSE loss arm runs ClearBox first (:2770-2773). + self:emit({ kind = "trainer-return", cleared = result == "lose" or nil }) + local text = (result == "lose") and trainer.lossText or trainer.winText + if type(text) ~= "string" or text == "" then return end + -- FarPrintText prints the pointer alone: no trainer-name tag in front of + -- it, unlike Gen 1's TrainerEndBattleText (pokered home/trainers.asm:355). + self:emit({ kind = "win-text", text = text }) +end + +-- WinTrainerBattle's money arm (engine/battle/core.asm:2310-2323) function Battle:awardPrizeMoney() local save = self.save if not (save and save.player) then return nil end @@ -3505,6 +3531,8 @@ function Battle:switch(index) self.participants[index] = true self.stages.player = Battle.newStages() self:emit({ kind = "send", side = "player", mon = mon, + hp = mon.hp or 0, status = mon.status or false, + level = mon.level, experience = mon.experience, text = "Go! " .. self:monName(mon) .. "!" }) -- battle.battler_switched, the payload BattleState:resolveSwitch emits on -- Gen 1: the side record, whoever walked in, and whoever walked out. @@ -3969,6 +3997,8 @@ function Battle:enemyTrySwitchOrItem() self:clearVolatile(self.enemy) self.stages.enemy = Battle.newStages() self:emit({ kind = "send", side = "enemy", mon = self.enemy, + hp = self.enemy.hp or 0, status = self.enemy.status or false, + level = self.enemy.level, experience = self.enemy.experience, text = (self.trainer.name or "TRAINER") .. " sent out " .. self:monName(self.enemy) .. "!" }) Runtime.emit("battle.battler_switched", { diff --git a/src/core/Game.lua b/src/core/Game.lua index b4bb8d99..389c8aa1 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -609,9 +609,10 @@ function Game:draw() -- ...and for the same reason the UI's own scale has to know the world is -- still the backdrop while an opaque menu covers it. Renderer:uiScale -- steps the UI down with the survey zoom only while a world is behind it, - -- gated on this frame's world pass -- which the party menu and the bag end - -- by being opaque. Without this hold they lose the step-down and blit at - -- full fit scale over a battle drawn at the zoomed-out one. + -- gated on this frame's world pass -- which the party menu ends by being + -- opaque (the bag's item box shows the map around it, #1521). Without + -- this hold it loses the step-down and blits at full fit scale over a + -- battle drawn at the zoomed-out one. Renderer.uiWorldHold = Renderer.battleDim ~= nil -- ...and a battle keeps its dialogue box and YES/NO inside its own screen -- instead of letting them dock to the window edge. diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index 945144d6..edc7b862 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -220,6 +220,9 @@ function Save.newGame(opts) phoneContacts = {}, tradeFlags = {}, pokedex = { seen = {}, caught = {} }, + -- wLastDexMode (engine/pokedex/pokedex.asm:59-61): the sort mode the + -- #DEX reopens in. NEW_MODE is the cart's zero byte. + lastDexMode = "NEW", -- wUnownDex: the distinct Unown FORMS caught, in catching order. A second -- record beside the #DEX because the #DEX knows only the species -- (src/core/gen2/Unown.lua). @@ -685,6 +688,12 @@ function Save.validate(save) scrubEvents(save, report) scrubMapScenes(save, report) scrubPlayerState(save, report) + -- wLastDexMode: only the three modes the #DEX has (PokedexMenu MODES); + -- a hand-edited value falls back to NEW_MODE, the cart's zero byte + if save.lastDexMode ~= "NEW" and save.lastDexMode ~= "OLD" + and save.lastDexMode ~= "A-Z" then + save.lastDexMode = "NEW" + end -- The `mailmsg` structs get the same treatment for the same reason: their -- `type` byte is an item id nothing else in the save vouches for, and a -- party key outside 1..6 or a MAILBOX past MAILBOX_CAPACITY is a region the diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 2d5d9418..eeee5ed3 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -1316,15 +1316,27 @@ function LauncherView._updateControl(imp) elseif status == "downloading" then local pct = st.progress and math.floor(st.progress * 100) or 0 return status, Strings("Updating %d%%", pct), nil, false + elseif status == "full_downloading" then + local pct = st.progress and math.floor(st.progress * 100) or 0 + return status, Strings("Downloading app %d%%", pct), nil, false elseif status == "available" then return status, st.latest and (Strings("Update v") .. st.latest) or Strings("Update"), function() pcall(imp.Check.download) end, true elseif status == "ready" then return status, Strings("Restart to update"), function() require("src.core.HostShell").restart() end, true - elseif status == "needs_full" then - return status, Strings("Open releases"), - function() love.system.openURL(imp.Check.releaseUrl()) end, true + elseif status == "needs_full" or status == "full_ready" then + local action = imp.Check.fullUpdateAction and imp.Check.fullUpdateAction() + local label = action and action.label or "Open releases" + local url = action and action.url or imp.Check.releaseUrl() + return status, Strings(label), + function() + if action and action.kind and imp.Check.performFullUpdate then + pcall(imp.Check.performFullUpdate) + else + love.system.openURL(url) + end + end, true end -- idle / uptodate / error: offer a manual check, with no glow. return status, Strings("Check for updates"), diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 737f0e71..d3736b8e 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -3439,6 +3439,18 @@ function RomExtractorGen2:extractScriptsAndText(maps, stdScripts) elseif info.name == "givepoke" then cmd.species, cmd.level, cmd.item, cmd.trainer = args[1], args[2], args[3], args[4] + -- Script_givepoke (engine/overworld/scripting.asm:1806) + if size == 8 then + local function readAt(lo, hi) + local addr = (args[lo] or 0) + (args[hi] or 0) * 0x100 + if not romAddrOk(bank, addr) then return nil end + local okStr, str = pcall(self.rom.readString, self.rom, + bank, addr, charmap, 0x50, 16) + return okStr and str or nil + end + cmd.name = readAt(5, 6) + cmd.otName = readAt(7, 8) + end elseif info.name == "pokepic" or info.name == "disappear" then cmd.species = args[1] -- pokepic cmd.object = args[1] -- disappear (same byte) @@ -5231,6 +5243,20 @@ function RomExtractorGen2:extractMenuGfx() end if eggHatch.egg or eggHatch.shell then out.eggHatch = eggHatch end + -- StatsScreenPageTilesGFX (gfx/font.asm:23), the 17 tiles + -- LoadStatsScreenPageTilesGFX lands at vTiles2 $31 (engine/gfx/load_font.asm:90). + local hpBarBorder = self.symbols["EnemyHPBarBorderGFX"] + if hpBarBorder then + local address = hpBarBorder[2] - 17 * 16 + self:write2bpp(self.rom:bytes(hpBarBorder[1], address, 17 * 16), + 17 * 8, 8, "menu/stats_tiles.png") + out.stats = { + sheet = "assets/generated/menu/stats_tiles.png", + tiles = 17, + firstTile = 0x31, + } + end + -- Goldenrod Game Corner: Slot Machine graphics assets if self.symbols["Slots1LZ"] then local raw1 = self:decompressLz3Symbol("Slots1LZ") diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 0ef0c6ef..2915b4eb 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -93,6 +93,12 @@ function ItemEffects.healsHP(id) or id == "REVIVE" or id == "MAX_REVIVE" end +-- .useRareCandy prints over the still-drawn party menu +-- (engine/items/item_effects.asm:1392-1418) +function ItemEffects.keepsPartyMenuOpen(id) + return ItemEffects.healsHP(id) or id == "RARE_CANDY" +end + function ItemEffects.isBattleMedicine(id) return HEAL_AMOUNT[id] ~= nil or STATUS_HEAL[id] ~= nil or id == "MAX_POTION" or id == "FULL_RESTORE" diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index 279daa50..9dd64d69 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -19,6 +19,9 @@ local romText = require("src.core.RomText") local Evolution = {} +-- engine/pokemon/evos_moves.asm:122-123 (ld c, 50 / call DelayFrames) +local EVOLVING_TEXT_FRAMES = 50 + Evolution.METHODS = { LEVEL = { check = function(game, mon, evo, trigger) @@ -157,27 +160,50 @@ end -- Play the evolution movie (flashing forms), then apply + text. -- Headless (no real graphics) falls back to the plain text flow. function Evolution.evolve(game, mon, newSpecies, onDone, via) + local oldName = mon.nickname or game.data.pokemon[mon.species].name + -- IsEvolvingText, DelayFrames 50; ClearScreenArea then wipes rows 0-11 + -- ONLY, so the box rides through EvolveMon (evos_moves.asm:120-134) + local isEvolving = romText(game.data, "_IsEvolvingText", + "What?\n%s is\nevolving!", oldName) if love.image and love.image.newImageData then - -- forward `via` so EvolutionState can keep trade evolutions - -- non-cancelable (LINK_STATE_TRADING) while others accept B (#213) - Screens.push(game, "EvolutionState", mon, newSpecies, onDone, via) + local intro + intro = TextBox.new(game, isEvolving, nil, { stay = { + onShown = function() + -- DelayFrames 50 with the box and the old screen still up + -- (evos_moves.asm:122-123) + local hold = { t = 0 } + hold.update = function() + hold.t = hold.t + 1 + if hold.t < EVOLVING_TEXT_FRAMES then return end + game.stack:pop() -- this hold + -- forward `via` so trade evolutions stay non-cancelable while + -- others accept B (evos_moves.asm:72-75) (#213) + Screens.push(game, "EvolutionState", mon, newSpecies, function() + -- the result/cancel box owns the intro box's pop (#1596) + if game.stack:top() == intro then game.stack:pop() end + if onDone then onDone() end + end, via) + end + hold.draw = function() end + game.stack:push(hold) + end, + } }) + game.stack:push(intro) return end Music.play(game.data, Music.special(game.data, "evolution")) - local oldName = mon.nickname or game.data.pokemon[mon.species].name Evolution.apply(game, mon, newSpecies, via) - -- the congrats page keeps the engine wording: _EvolvedText extracts - -- truncated (it stops at a dynamic marker the decoder does not follow) - local msg = romText(game.data, "_IsEvolvingText", - "What?\n%s is\nevolving!", oldName) - .. "\f" .. Strings("Congratulations!\nYour %s\nevolved into\n%s!", - oldName, game.data.pokemon[newSpecies].name) + -- EvolvedText then IntoText in the same box (evos_moves.asm:136-150) + local msg = isEvolving .. "\f" + .. romText(game.data, "_EvolvedText", "%s evolved", oldName) + .. romText(game.data, "_IntoText", "\ninto %s!", + game.data.pokemon[newSpecies].name) game.stack:push(TextBox.new(game, msg, function() Music.restoreMap(game.data) -- re-run the evolved species' level-up learn check before onDone -- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12) Evolution.learnEvolutionMoves(game, mon, onDone) - end)) + end, TextBox.soundOpts(game, "Get_Item2"))) end -- Entry point for mods whose methods fire outside the vanilla moments diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index a3dea27b..fc324825 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -44,7 +44,8 @@ local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 } -- waits for nothing, shows no blinking arrow, and never pops itself -- -- whoever pushed it owns the pop. stay.onShown fires once, on the frame -- the last page finishes typing, which is where the caller pushes whatever --- goes on top of it (#591). +-- goes on top of it (#591). stay.prompt waits out one arrowed A/B press +-- first (TextCommand_PROMPT_BUTTON, home/text.asm:434-444) (#1511). function TextBox.new(game, text, onDone, opts) local self = setmetatable({}, TextBox) self.game = game @@ -296,6 +297,15 @@ function TextBox:update(dt) -- exactly once (#591) if self.stay then if not self.stayShown then + -- stay.prompt: arrowed A/B wait, then the box stays up + -- (TextCommand_PROMPT_BUTTON, home/text.asm:434-444) + if self.stay.prompt + and not (input:wasPressed("a") or input:wasPressed("b")) then + return + end + if self.stay.prompt then + require("src.core.Sound").play(self.game.data, "Press_AB") + end self.stayShown = true if self.stay.onShown then self.stay.onShown() end end @@ -494,7 +504,8 @@ function TextBox:draw() Font.draw(money, 152 - Font.width(money), 8) end if (self.waiting or (self.done and not self.choice and not self.auto - and not self.stay)) + and (not self.stay + or (self.stay.prompt and not self.stayShown)))) and self.blink < 30 then -- page-advance cursor: glyph $EE by default, the blinking down arrow -- the original prints via `ld a, "▼"` (home/text.asm) diff --git a/src/script/gen2/Specials.lua b/src/script/gen2/Specials.lua index 3fc9b7dd..6a29da98 100644 --- a/src/script/gen2/Specials.lua +++ b/src/script/gen2/Specials.lua @@ -1694,7 +1694,8 @@ H.RandomPhoneWildMon = function(vm) local entry = contact and contact.map and grass and grass[contact.map] local slots = entry and entry.slots if not slots then return end - local daytime = (w and w.daytime) or "DAY" + -- wTimeOfDay, not the palette pin (wildmons.asm:861) + local daytime = (w and (w.tod or w.daytime)) or "DAY" if daytime == "DARK" then daytime = "NITE" end local slot = (slots[daytime] or slots.DAY or {})[Specials.random(4)] if slot and slot.species then nameSpecies(vm, slot.species) end diff --git a/src/script/gen2/Vm.lua b/src/script/gen2/Vm.lua index 8b60c13b..4fe546fa 100644 --- a/src/script/gen2/Vm.lua +++ b/src/script/gen2/Vm.lua @@ -543,10 +543,14 @@ local function runCmd(self, cmd, op) local species = cmd.species or arg1(cmd) local level = cmd.level or (cmd.args and cmd.args[2]) or 5 local item = cmd.item or (cmd.args and cmd.args[3]) or 0 + local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0 if self.givePokeFn then - local mon = self.givePokeFn(species, level, item) + -- engine/pokemon/move_mon.asm:1695-1736: the trainer arm copies the + -- script's own nickname and OT name in instead of asking for one. + local named = trainer ~= 0 + and { nickname = cmd.name, otName = cmd.otName } or nil + local mon = self.givePokeFn(species, level, item, named) -- engine/pokemon/move_mon.asm:1753-1757 - local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0 if mon and trainer == 0 then Specials.askNickname(self, mon) end @@ -1114,7 +1118,9 @@ local function runCmd(self, cmd, op) -- really does run here. if self.reloadMapFn then self.reloadMapFn(true) end elseif op == "winlosstext" then - -- Overrides the struct's win/loss text for this battle only. + -- Overrides the struct's win/loss text for this battle only; a 0 + -- argument zeroes that pointer (engine/overworld/scripting.asm:651) + self.winLossArmed = true self.winTextOverride = cmd.winText self.lossTextOverride = cmd.lossText elseif op == "trainertext" then @@ -1122,9 +1128,11 @@ local function runCmd(self, cmd, op) local obj = self.trainerObject or {} local key if which == 1 then - key = self.winTextOverride or obj.winText + key = self.winLossArmed and self.winTextOverride + or (not self.winLossArmed and obj.winText or nil) elseif which == 2 then - key = self.lossTextOverride or obj.lossText + key = self.winLossArmed and self.lossTextOverride + or (not self.winLossArmed and obj.lossText or nil) else key = obj.seenText end @@ -2425,6 +2433,7 @@ function Vm:start(scriptKey) self.battleOutcome = nil self.winTextOverride = nil self.lossTextOverride = nil + self.winLossArmed = nil -- The whiteout abort is per-run too: a script that ended because the player -- was wiped must not stop the next one before it starts. self.aborted = false diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index b4a5dffb..a59adc2e 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -17,10 +17,13 @@ local function buildItems(game) local items = {} for _, id in ipairs(Bag.order(game.save)) do local def = game.data.items[id] + -- PrintListMenuEntries skips the quantity for anything IsKeyItem_ owns: + -- the KeyItemFlags bitfield plus the HMs (item_effects.asm:2616-2641) + local unsellable = (def and def.keyItem) or id:find("^HM_") ~= nil table.insert(items, { value = id, label = def and def.name or id, - right = "x" .. game.save.inventory[id], + right = (not unsellable) and ("x" .. game.save.inventory[id]) or nil, }) end return items @@ -334,8 +337,13 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) local Evolution = require("src.pokemon.Evolution") local evoTo, evo = Evolution.pendingFor(game, target, { kind = "levelup" }) + -- the party menu stays up through TryEvolvingMon and only + -- comes down at RemoveUsedItem (item_effects.asm:1392-1418) if evoTo then - Evolution.evolve(game, target, evoTo, nil, evo and evo.method) + Evolution.evolve(game, target, evoTo, closePicker, + evo and evo.method) + else + closePicker() end return end @@ -399,10 +407,9 @@ local function pickTargetAndUse(game, battle, id, list) local opts = { pickOnly = true, battle = battle, - -- HP medicine animates its bar with the picker still up (#252). Only - -- out of battle: the in-battle tail closes the bag list underneath - -- first, which needs the picker already gone. - keepOpen = (not battle) and ItemEffects.healsHP(id), + -- HP medicine animates with the picker up (#252), RARE CANDY prints over + -- the party menu (item_effects.asm:1392-1418) + keepOpen = (not battle) and ItemEffects.keepsPartyMenuOpen(id), onSwitch = function(mon, picker) if not wantsMove then useOn(game, battle, id, mon, list, nil, picker) @@ -470,7 +477,9 @@ function BagMenu.new(game, opts) local list list = ListMenu.new(game, "ITEMS", buildItems(game), { kind = "bag", - footer = ("¥%d"):format(game.save.money), + -- StartMenu_Item zeroes wPrintItemPrices and draws no money box: the + -- LIST_MENU_BOX floats over the map (engine/menus/start_sub_menus.asm) + itemBox = true, -- B returns to the start menu when the bag was opened from it onCancel = opts.onCancel, -- SELECT reorders items like the original bag (swap_items.asm) diff --git a/src/ui/EvolutionState.lua b/src/ui/EvolutionState.lua index 972ec407..5f4fc75f 100644 --- a/src/ui/EvolutionState.lua +++ b/src/ui/EvolutionState.lua @@ -1,6 +1,7 @@ -- The evolution movie (engine/movie/evolution.asm): the mon's pic -- flashes back and forth with the evolved form, speeding up, then the --- new form appears with its cry and the congratulations text. +-- new form appears with its cry and the "evolved into" text +-- (engine/pokemon/evos_moves.asm:120-128). -- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the -- joypad during the flash: a fresh B press aborts the evolution -- the mon -- keeps its species and _StoppedEvolvingText ("Huh? MON stopped evolving!") @@ -9,14 +10,13 @@ -- and stone evolutions, where the B press is read but thrown away because -- ItemUseEvoStone left wForceEvolution set (#290). -local Font = require("src.render.Font") local Music = require("src.core.Music") -local Strings = require("src.core.Strings") local romText = require("src.core.RomText") +-- Not opaque: ClearScreenArea wipes rows 0-11 only (evos_moves.asm:126-128), +-- so the "is evolving!" box beneath stays visible through the flash (#1596). local EvolutionState = {} EvolutionState.__index = EvolutionState -EvolutionState.isOpaque = true -- SGB: SetPal_PokemonWholeScreen for the mon on display function EvolutionState:sgbPalettes(game) @@ -127,11 +127,11 @@ function EvolutionState:update(dt) require("src.core.Sound").playCry(game.data, self.newSpecies) local TextBox = require("src.render.TextBox") local newName = game.data.pokemon[self.newSpecies].name - -- _EvolvedText extracts truncated (it stops at a dynamic marker the - -- decoder does not follow), so the engine's wording stands here - game.stack:push(TextBox.new(game, - Strings("Congratulations!\nYour %s\nevolved into\n%s!", - self.oldName, newName), + -- EvolvedText then IntoText in the same box (PrintText_NoCreatingTextBox), + -- then SFX_GET_ITEM_2 (engine/pokemon/evos_moves.asm:136-153) + local msg = romText(game.data, "_EvolvedText", "%s evolved", self.oldName) + .. romText(game.data, "_IntoText", "\ninto %s!", newName) + game.stack:push(TextBox.new(game, msg, function() Music.restoreMap(game.data) game.stack:pop() -- the evolution screen itself @@ -141,13 +141,15 @@ function EvolutionState:update(dt) -- first so the "learned MOVE!" text / forget prompt push onto the -- overworld / battle-return, not this state. Evolution.learnEvolutionMoves(game, self.mon, self.onDone) - end)) + end, + TextBox.soundOpts(game, "Get_Item2"))) end end function EvolutionState:draw() love.graphics.setColor(1, 1, 1, 1) - love.graphics.rectangle("fill", 0, 0, 160, 144) + -- rows 0-11 only (hlcoord 0,0 / lb bc, 12, 20, evos_moves.asm:126-128) + love.graphics.rectangle("fill", 0, 0, 160, 96) -- accelerating flash between the two forms local sprite, spriteTrueColor @@ -172,14 +174,6 @@ function EvolutionState:draw() require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions()) end end - - love.graphics.setColor(0, 0, 0, 1) - if not self.done then - Font.draw(Strings("What?"), 8, 104) - Font.draw(self.oldName .. " is", 8, 114) - Font.draw(Strings("evolving!"), 8, 124) - end - love.graphics.setColor(1, 1, 1, 1) end return EvolutionState diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index b3856a0c..01d0a24c 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -17,6 +17,14 @@ function ListMenu:sgbPalettes(game) end local ROWS = 7 +-- LIST_MENU_BOX 4,2 - 19,12 (data/text_boxes.asm:13); 4 names from +-- hlcoord 6,4 two rows apart (home/list_menu.asm:51-52, 364-365, 471-479) +local ITEM_BOX = { tx = 4, ty = 2, tw = 16, th = 11 } +local ITEM_ROWS = 4 +local ITEM_NAME_X, ITEM_TOP_Y = 48, 32 +local ITEM_CURSOR_X = 40 +local ITEM_QTY_X, ITEM_QTY_END = 112, 136 +local ITEM_MORE_X, ITEM_MORE_Y = 144, 88 -- frames to wait before key-repeat kicks in, then between repeats local REPEAT_DELAY = 16 local REPEAT_RATE = 4 @@ -83,7 +91,20 @@ function ListMenu.new(game, title, items, opts) -- for their whole run (engine/menus/pc.asm, engine/menus/players_pc.asm), -- so their lists opt out of the A/B beep the same way Menu's noSound does self.noSound = opts.noSound or false - self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS) + -- the bag's item list: a partial box the map stays visible around, not a + -- screen of its own (home/list_menu.asm:29-31) + self.itemBox = opts.itemBox or false + if self.itemBox then + self.isOpaque = false + -- keep RunDefaultPaletteCommand's last palette: ItemMenuLoop never sets + -- its own (engine/menus/start_sub_menus.asm:300) + self.sgbPalettes = false + -- wMaxMenuItem is 2 for item lists; the fourth printed row is a + -- look-ahead the cursor cannot reach (home/list_menu.asm:46-48) + self.cursorRows = 3 + end + self.rows = opts.rows or (self.itemBox and ITEM_ROWS) + or ((opts.dialogue or opts.messageBox) and 4 or ROWS) return self end @@ -100,8 +121,9 @@ local function moveIndex(self, delta) end local function syncScroll(self) - if self.index - self.scroll > self.rows then - self.scroll = self.index - self.rows + local maxRow = self.cursorRows or self.rows + if self.index - self.scroll > maxRow then + self.scroll = self.index - maxRow end if self.index - self.scroll < 1 then self.scroll = self.index - 1 end end @@ -206,7 +228,48 @@ function ListMenu:close() if top == self then self.game.stack:pop() end end +-- PrintListMenuEntries, minus the price column StartMenu_Item never asks for +-- (wPrintItemPrices = 0, engine/menus/start_sub_menus.asm) +function ListMenu:drawItemBox() + love.graphics.setColor(1, 1, 1, 1) + Font.drawBox(ITEM_BOX.tx, ITEM_BOX.ty, ITEM_BOX.tw, ITEM_BOX.th) + love.graphics.setColor(0, 0, 0, 1) + if #self.items == 0 then + Font.draw(Strings("Nothing here."), ITEM_NAME_X, ITEM_TOP_Y) + end + local shown = 0 + for row = 1, self.rows do + local i = self.scroll + row + local item = self.items[i] + if not item then break end + shown = shown + 1 + local y = ITEM_TOP_Y + (row - 1) * 16 + Font.draw(item.label, ITEM_NAME_X, y) + if item.right then + -- '×' at column 14, PrintNumber's two right-aligned digits after it + -- (home/list_menu.asm:479-490) + local count = item.right:sub(2) + Font.draw(item.right:sub(1, 1), ITEM_QTY_X, y + 8) + Font.draw(count, ITEM_QTY_END - Font.width(count), y + 8) + end + if i == self.index then + Font.drawCode(self.hollowIndex == i + and Theme.cursorHollow or Theme.cursor, ITEM_CURSOR_X, y) + end + if self.swapIndex == i and i ~= self.index then + Font.drawCode(Theme.cursorHollow, ITEM_CURSOR_X, y) + end + end + -- the terminator prints CANCEL and returns before the '▼' + -- (home/list_menu.asm:372, 518-524) + if shown == self.rows then + Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y) + end + love.graphics.setColor(1, 1, 1, 1) +end + function ListMenu:draw() + if self.itemBox then return self:drawItemBox() end love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.setColor(0, 0, 0, 1) diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 5a97908a..00079f18 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -34,6 +34,9 @@ function Menu.new(game, items, opts) if self.tx + self.tw > 20 then self.tx = math.max(0, 20 - self.tw) end end self.rowStep = opts.rowStep or 2 + -- engine/movie/oak_speech/oak_speech2.asm:162 (DisplayIntroNameTextBox) + self.title = opts.title + self.itemY = opts.itemY -- maxVisible: cap the box to this many rows and scroll the rest instead -- of growing past it (e.g. the start menu, whose row count varies with -- save state and mod hooks); nil/unset keeps every caller's old @@ -116,7 +119,17 @@ function Menu:draw() self.tw * 8, self.th * 8, self.anchor) end Font.drawBox(self.tx, self.ty, self.tw, self.th) + -- PlaceString at hlcoord 3,0 writes over the border row it was just + -- drawn on (oak_speech2.asm:162-170) + if self.title then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", (self.tx + 3) * 8, self.ty * 8, + #Font.split(self.title) * 8, 8) + end love.graphics.setColor(0, 0, 0, 1) + if self.title then + Font.draw(self.title, (self.tx + 3) * 8, self.ty * 8) + end local visible = (self.maxVisible and math.min(self.maxVisible, #self.items)) or #self.items -- Row Y: pokered's boxed menus anchor the choices to the BOTTOM interior @@ -130,15 +143,18 @@ function Menu:draw() -- USE/TOSS is th = 5 for two choices (#284, matching text_boxes.asm's -- USE_TOSS_MENU_TEMPLATE rows 10..14), and a top anchor pushed TOSS onto -- the bottom border (#564, #572). + local function rowY(row) + if self.itemY then + return (self.ty + self.itemY + (row - 1) * self.rowStep) * 8 + end + return (self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8 + end for row = 1, visible do local item = self.items[self.scroll + row] if not item then break end - Font.draw(item.label, (self.tx + 2) * 8, - (self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8) + Font.draw(item.label, (self.tx + 2) * 8, rowY(row)) end - local cursorRow = self.index - self.scroll - Font.drawCode(Theme.cursor, (self.tx + 1) * 8, - (self.ty + self.th - 2 - (visible - cursorRow) * self.rowStep) * 8) + Font.drawCode(Theme.cursor, (self.tx + 1) * 8, rowY(self.index - self.scroll)) -- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState -- use, sat on the bottom border like TextBox's page-advance cursor. It -- has to be the border row, not ty + th - 2: that is the last interior diff --git a/src/ui/NamingScreen.lua b/src/ui/NamingScreen.lua index 9c82cfb7..342b88ca 100644 --- a/src/ui/NamingScreen.lua +++ b/src/ui/NamingScreen.lua @@ -67,6 +67,7 @@ function NamingScreen.new(game, opts) self.game = game self.title = opts.title or Strings("YOUR NAME?") self.presets = opts.presets + self.introBox = opts.introBox self.maxLen = opts.maxLen or 7 self.default = opts.default self.onDone = opts.onDone @@ -95,13 +96,24 @@ function NamingScreen:enter() onSelect = function() -- the menu already popped itself; pop the naming screen too self.game.stack:pop() - if self.onDone then self.onDone(preset) end + if self.onDone then self.onDone(preset, false) end end, }) end - self.game.stack:push(Menu.new(self.game, items, { - tx = 4, ty = 0, tw = 12, th = #items * 2 + 2, cancelable = false, - })) + if self.introBox then + -- DisplayIntroNameTextBox (oak_speech2.asm:162): TextBoxBorder at + -- hlcoord 0,0 with b=$a c=$9, "NAME" at hlcoord 3,0, list at hlcoord 2,2 + -- TextBoxBorder's b = $a is a fixed 12-row box, whatever the preset + -- list's length (oak_speech2.asm:163-166) + self.game.stack:push(Menu.new(self.game, items, { + tx = 0, ty = 0, tw = 11, th = 12, + itemY = 2, title = Strings("NAME"), cancelable = false, + })) + else + self.game.stack:push(Menu.new(self.game, items, { + tx = 4, ty = 0, tw = 12, th = #items * 2 + 2, cancelable = false, + })) + end end end @@ -125,7 +137,7 @@ function NamingScreen:confirm() end Sound.play(self.game.data, "Press_AB") self.game.stack:pop() - if self.onDone then self.onDone(name) end + if self.onDone then self.onDone(name, true) end end function NamingScreen:grid() diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index 5da99051..b02bb2e9 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -35,6 +35,29 @@ OakSpeech.letterboxWhite = true local FADE_FRAMES = 24 local WIPE_FRAMES = 32 +-- OakSpeechSlidePicRight / OakSpeechSlidePicLeft (oak_speech2.asm:67-89) +local SLIDE_TILES = 6 +local SLIDE_FRAMES = 3 + +local PicSlide = {} +PicSlide.__index = PicSlide + +function PicSlide:update(dt) + -- OakSpeechSlidePicLeft: ClearScreenArea, ld c, 10 / DelayFrames, Delay3 + -- before the first slide step (oak_speech2.asm:69-78) + if (self.delay or 0) > 0 then + self.delay = self.delay - 1 + return + end + self.t = self.t + 1 + local tiles = math.min(SLIDE_TILES, math.floor(self.t / SLIDE_FRAMES)) + self.speech.picSlide = (self.dir > 0 and tiles or (SLIDE_TILES - tiles)) * 8 + if tiles >= SLIDE_TILES then + self.game.stack:pop() + if self.onDone then self.onDone() end + end +end + -- naming presets are boot config (field.boot.namePresets), which a total -- conversion replaces; the Red/Blue lists remain the fallback local function namePresets(game, who, fallback) @@ -155,6 +178,10 @@ function OakSpeech.defaultSteps(speech) kind = "say", textKey = "_IntroducePlayerText", pic = "player", + -- oak_speech.asm:89-92: MovePicLeft, then IntroducePlayerText's + -- `prompt` (text_2.asm:1730) waits for A and leaves the box up + reveal = "wipe", + stay = true, }, { id = "name_player", @@ -171,12 +198,19 @@ function OakSpeech.defaultSteps(speech) id = "confirm_player_name", kind = "say", textKey = "_YourNameIsText", + -- _YourNameIsText's `prompt` (text_2.asm:1766), then GBFadeOutToWhite + -- / ClearScreen with the box still up (oak_speech.asm:93-94) + fadeOut = true, }, { id = "ask_rival_name", kind = "say", textKey = "_IntroduceRivalText", pic = "rival", + -- oak_speech.asm:98-101: FadeInIntroPic, then IntroduceRivalText's + -- `prompt` (text_2.asm:1740) leaves the box up for ChooseRivalName + reveal = "fade", + stay = true, }, { id = "name_rival", @@ -191,6 +225,9 @@ function OakSpeech.defaultSteps(speech) id = "confirm_rival_name", kind = "say", textKey = "_HisNameIsText", + -- _HisNameIsText's `prompt` (text_2.asm:1772) then the .skipSpeech + -- fade with the box up (oak_speech.asm:103-104) + fadeOut = true, }, { id = "legend", @@ -378,7 +415,25 @@ function OakSpeech:runStep(step) self:applyPic(step) self:afterReveal(step, function() self:runCry(step) - self:sayText(self:stepText(step), function() self:advance() end) + if step.stay or step.fadeOut then + local box = TextBox.new(self.game, self:stepText(step), nil, + { stay = { prompt = true, onShown = function() + if step.fadeOut then + -- GBFadeOutToWhite / ClearScreen (oak_speech.asm:93-94) + self.game.stack:push(require("src.render.Transition") + .whiteFlash(self.game, nil, function() + self:closeHoldBox() + self:advance() + end)) + else + self:advance() + end + end } }) + self.holdBox = box + self.game.stack:push(box) + else + self:sayText(self:stepText(step), function() self:advance() end) + end end) elseif kind == "demo" then -- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A @@ -394,20 +449,36 @@ function OakSpeech:runStep(step) local presets = step.presets or namePresets(self.game, step.presetsWho or who, step.presetsFallback or { "RED" }) - require("src.ui.Screens").push(self.game, "NamingScreen", { - title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")), - presets = presets, - maxLen = step.maxLen or self.nameLen, - onDone = function(name) - if who == "rival" then - self.game.save.player.rival = name - else - self.game.save.player.name = name - end - self:recordAnswer(step, 1, name, name) - self:advance() - end, - }) + local function openNaming() + require("src.ui.Screens").push(self.game, "NamingScreen", { + title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")), + presets = presets, + introBox = true, + maxLen = step.maxLen or self.nameLen, + onDone = function(name, custom) + if who == "rival" then + self.game.save.player.rival = name + else + self.game.save.player.name = name + end + self:recordAnswer(step, 1, name, name) + -- YourNameIsText / HisNameIsText print into the box this one + -- held (oak_speech2.asm:26-28, :59-61) + self:closeHoldBox() + if custom then + -- .customName: ClearScreen / Delay3 / pic recentered, no + -- slide-back (oak_speech2.asm:21-25) + self.picSlide = 0 + self:advance() + else + -- OakSpeechSlidePicLeft's 13-frame pre-slide beat + -- (oak_speech2.asm:69-78) + self:slidePic(-1, function() self:advance() end, 13) + end + end, + }) + end + self:slidePic(1, openNaming) elseif kind == "choice" then self:applyPic(step) self:afterReveal(step, function() @@ -518,6 +589,22 @@ function OakSpeech:revealPic(kind, next) } end +-- ..(engine/movie/oak_speech/oak_speech2.asm ln 67) +function OakSpeech:slidePic(dir, onDone, delay) + self.picSlide = (dir > 0 and 0 or SLIDE_TILES * 8) + self.game.stack:push(setmetatable({ + game = self.game, speech = self, dir = dir, t = 0, onDone = onDone, + delay = delay, + }, PicSlide)) +end + +-- IntroducePlayerText's text_end box (oak_speech.asm:90) is ours to close +function OakSpeech:closeHoldBox() + local box = self.holdBox + self.holdBox = nil + if box and self.game.stack:top() == box then self.game.stack:pop() end +end + function OakSpeech:advance() self.step = self.step + 1 -- picFlip belongs to the pic, not to the step: OakSpeechText2 prints 2A @@ -615,7 +702,7 @@ function OakSpeech:draw() -- it like the sprite buffer does ((8 - w) >> 1) tiles across, -- bottom-aligned local w, h = self.pic:getDimensions() - local x = 48 + math.floor((8 - w / 8) / 2) * 8 + local x = 48 + math.floor((8 - w / 8) / 2) * 8 + (self.picSlide or 0) local y = 32 + (7 - h / 8) * 8 local reveal = self.picReveal local off = 0 diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index 6d6394be..f4c6d5c8 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -11,6 +11,7 @@ local Renderer = require("src.render.Renderer") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local Strings = require("src.core.Strings") +local Theme = require("src.ui.Theme") local StartMenu = {} @@ -19,6 +20,7 @@ local function sameItems(_, items) return items end function StartMenu.new(game) local flags = game.save.flags or {} local items = {} + local menu -- vanilla start submenus return here on B (RedisplayStartMenu): the -- generic Menu pops the start menu when a row is selected, so each @@ -51,42 +53,81 @@ function StartMenu.new(game) end }) -- SAVE shows the player/badges/dex/time panel then asks to confirm - -- (PrintSaveScreenText) - table.insert(items, { label = Strings("SAVE"), onSelect = function() + -- (PrintSaveScreenText); StartMenu_SaveReset never clears the START menu + -- box, so it stays on screen beside the panel (start_sub_menus.asm:641-647) + table.insert(items, { label = Strings("SAVE"), keepOpen = true, + onSelect = function() local TextBox = require("src.render.TextBox") local badges = require("src.inventory.Badges").count(game.data, game.save) local owned = 0 for _ in pairs(game.save.pokedex and game.save.pokedex.owned or {}) do owned = owned + 1 end + -- the panel is a static snapshot; the cart prints it once + -- (main_menu.asm:390-401) local t = math.floor(game.save.playTime or 0) - local panel = Strings("PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d", - game.save.player.name or "RED", badges, owned, - math.floor(t / 3600), math.floor(t / 60) % 60) - game.stack:push(TextBox.new(game, - panel .. Strings("\fWould you like to\nSAVE the game?"), nil, { - choice = function(yes) - if not yes then return end - -- SaveMenu .save (engine/menus/save.asm:164-181): "Now saving..." - -- is a bare PlaceString held by DelayFrames 120, then GameSavedText, - -- which ends in `done` and so never reaches TX_PROMPT_BUTTON. - -- Neither page takes a button press (#765); the second waits on - -- SFX_SAVE (PlaySoundWaitForCurrent + WaitForSoundToFinish) and then - -- DelayFrames 30. The write itself is invisible either side of the - -- "Now saving..." hold, so it stays on that box's onDone. - game.stack:push(TextBox.new(game, Strings("Now saving..."), function() - game:writeSave() - game.stack:push(TextBox.new(game, - Strings("%s saved\nthe game!", game.save.player.name or "RED"), - nil, { auto = { - sound = function() - return require("src.core.Sound").play(game.data, "Save") - end, - delay = 30, - } })) - end, { auto = { delay = 120 } })) + -- PrintSaveScreenText draws its own border at hlcoord 4,0 (b=8, c=$e) and + -- leaves it up under the prompt -- engine/menus/main_menu.asm:381-405 + local panel + panel = { + delay = 0, + update = function() + -- ld c, 30 / jp DelayFrames: the bare panel holds before the + -- prompt (main_menu.asm:404-405) + panel.delay = panel.delay + 1 + if panel.delay == 30 then panel.openPrompt() end end, - })) + draw = function() + Font.drawBox(4, 0, 16, 10) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(Strings("PLAYER"), 5 * 8, 2 * 8) + Font.draw(game.save.player.name or "RED", 12 * 8, 2 * 8) + Font.draw(Strings("BADGES"), 5 * 8, 4 * 8) + Font.draw(("%2d"):format(badges), 17 * 8, 4 * 8) + Font.draw(Strings("POKéDEX"), 5 * 8, 6 * 8) + Font.draw(("%3d"):format(owned), 16 * 8, 6 * 8) + Font.draw(Strings("TIME"), 5 * 8, 8 * 8) + Font.draw(("%3d:%02d"):format(math.floor(t / 3600), + math.floor(t / 60) % 60), 13 * 8, 8 * 8) + love.graphics.setColor(1, 1, 1, 1) + end, + } + local function closePanel() + if game.stack:top() == panel then game.stack:pop() end + -- SaveMenu returns into HoldTextDisplayOpen, not RedisplayStartMenu + -- (start_sub_menus.asm:645-647): the kept-open START menu goes too + if menu and game.stack:top() == menu then game.stack:pop() end + end + panel.openPrompt = function() + game.stack:push(TextBox.new(game, + Strings("Would you like to\nSAVE the game?"), nil, { + -- SaveTheGame_YesOrNo pins its TWO_OPTION_MENU at hlcoord 0, 7 rather + -- than the shared right-hand one -- engine/menus/save.asm:186-192 + choiceBox = Theme.saveBox, + choice = function(yes) + if not yes then closePanel() return end + -- SaveMenu .save (engine/menus/save.asm:164-181): "Now saving..." + -- is a bare PlaceString held by DelayFrames 120, then GameSavedText, + -- which ends in `done` and so never reaches TX_PROMPT_BUTTON. + -- Neither page takes a button press (#765); the second waits on + -- SFX_SAVE (PlaySoundWaitForCurrent + WaitForSoundToFinish) and then + -- DelayFrames 30. The write itself is invisible either side of the + -- "Now saving..." hold, so it stays on that box's onDone. + game.stack:push(TextBox.new(game, Strings("Now saving..."), function() + game:writeSave() + game.stack:push(TextBox.new(game, + Strings("%s saved\nthe game!", game.save.player.name or "RED"), + closePanel, { auto = { + sound = function() + return require("src.core.Sound").play(game.data, "Save") + end, + delay = 30, + } })) + end, { auto = { delay = 120 } })) + end, + })) + end + game.stack:push(panel) end }) table.insert(items, { label = Strings("OPTION"), onSelect = function() @@ -142,7 +183,7 @@ function StartMenu.new(game) -- with Menu's moreArrow showing while there's more below. local rowStep = 2 local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep) - local menu = Menu.new(game, items, + menu = Menu.new(game, items, -- the START menu hugs the top-right corner of the SCREEN, not of a -- centred letterbox: at 9,0 x 11 it is already flush with the top and -- right of the 20x18 grid, so the anchor keeps it flush when the view diff --git a/src/ui/Theme.lua b/src/ui/Theme.lua index 74b9b4d0..036dc9f1 100644 --- a/src/ui/Theme.lua +++ b/src/ui/Theme.lua @@ -23,6 +23,9 @@ local Theme = { -- EnemySendOutFirstMon inlines its own TWO_OPTION_MENU at hlcoord 0, 7 -- instead of the shared right-hand one -- engine/battle/core.asm:1378-1384 trainerSwitchBox = { tx = 0, ty = 7, tw = 6, th = 5 }, + -- SaveTheGame_YesOrNo pins its TWO_OPTION_MENU at hlcoord 0, 7 too -- + -- engine/menus/save.asm:186-192 + saveBox = { tx = 0, ty = 7, tw = 6, th = 5 }, } function Theme.load(data) diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 4956c75d..6abe6074 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -392,6 +392,17 @@ function TitleState:updateSequence() self.phase = "loop" self.blinkTimer = 0 end + elseif self.phase == "exitCry" then + -- .finishedWaiting: PlayCry then WaitForSoundToFinish before the + -- white-out (engine/movie/title.asm:241-243) + self.timer = self.timer + 1 + local playing = self.exitCrySrc and self.exitCrySrc.isPlaying + and self.exitCrySrc:isPlaying() + if self.timer >= 3 and (not playing or self.timer > 180) then + self.exitCrySrc = nil + self.phase = "loop" + self:toMenu() + end end end @@ -460,8 +471,8 @@ function ContinueInfo:update(dt) self.game.stack:pop() if self.title.onContinue then self.title.onContinue() end elseif input:wasPressed("b") then + -- the CONTINUE / NEW GAME menu is still open underneath (main_menu.asm:91-92) self.game.stack:pop() - self.title:openMenu() end end @@ -497,7 +508,10 @@ function TitleState:openMenu() local game = self.game local items = {} if hasSave() then - table.insert(items, { label = Strings("CONTINUE"), onSelect = function() + -- DisplayContinueGameInfo leaves the menu box up behind the info window + -- (engine/menus/main_menu.asm:36-39, :91-92) + table.insert(items, { label = Strings("CONTINUE"), keepOpen = true, + onSelect = function() -- peek at the save for the info window; fall through if the -- file can't be read local ok, loaded = pcall(require("src.core.SaveData").load) @@ -511,9 +525,15 @@ function TitleState:openMenu() table.insert(items, { label = Strings("NEW GAME"), onSelect = function() if self.onNewGame then self.onNewGame() end end }) - table.insert(items, { label = Strings("OPTION"), onSelect = function() - require("src.ui.Screens").push(game, "OptionsMenu") - end }) + -- DisplayOptionMenu returns to .mainMenuLoop, which redraws the box + -- (engine/menus/main_menu.asm ln 87-90) + local menu + table.insert(items, { label = Strings("OPTION"), keepOpen = true, + onSelect = function() + -- .mainMenuLoop re-zeroes wCurrentMenuItem on re-entry (main_menu.asm:56-57) + if menu then menu.index = 1 end + require("src.ui.Screens").push(game, "OptionsMenu") + end }) table.insert(items, { label = Strings("EXIT GAME"), onSelect = function() if self.onExit then self.onExit() @@ -529,7 +549,14 @@ function TitleState:openMenu() type(hooked)) end local th = #items * 2 + 2 - local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th }) + menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th }) + -- .mainMenuLoop's B branch jumps back to DisplayTitleScreen, which opens + -- with GBPalWhiteOut and reruns the whole boot cinematic + -- (engine/menus/main_menu.asm:69-70, title.asm:29) + menu.onCancel = function() + game.stack:push(require("src.render.Transition").whiteFlash(game, nil, + function() self:restartSequence() end)) + end -- full-width title LOGO zones would recolor this box; see sgbPalettes. -- Menu.new may have grown tw for longer (e.g. localized) labels, so the -- recolor zone follows the box's real width instead of the vanilla 13. @@ -537,6 +564,38 @@ function TitleState:openMenu() game.stack:push(menu) end +-- .mainMenuLoop's B branch: DisplayTitleScreen from the top +-- (main_menu.asm:70, title.asm:39-222) +function TitleState:restartSequence() + self.menuOpen = false + pcall(Music.stop) + self.scy = 0x40 + self.phase = "drop" + self.dropStep, self.dropLeft = 1, nil + self.showBubble = not self.yellowLayout + self.timer = 0 + self.blinkTimer = 0 + self.blinkAt = nil + self.cycleIndex = 1 + self.scrollPhase = "hold" + self.scrollFrame = 1 + self.monOffset = 0 + self.ballY = BALL_REST + self.ribbonOffset = nil + self.whooshSrc, self.crySrc, self.exitCrySrc = nil, nil, nil +end + +-- .finishedWaiting: GBPalWhiteOutWithDelay3 then ClearScreen before MainMenu, +-- which clears again itself (engine/movie/title.asm ln 243, main_menu.asm ln 26) +function TitleState:toMenu() + local game = self.game + game.stack:push(require("src.render.Transition").whiteFlash(game, nil, + function() + self.menuOpen = true + self:openMenu() + end)) +end + -- ..(engine/movie/title.asm ln 271) function TitleState:pickNewMon() if #self.cycleSpecies < 2 then return end @@ -589,6 +648,11 @@ function TitleState:updateCycle() end function TitleState:update(dt) + -- an onSelect that handed control back without a new state (a failed + -- CONTINUE load, a mod row) re-runs DisplayTitleScreen (main_menu.asm:70) + if self.menuOpen and self.game.stack:top() == self then + self:restartSequence() + end if self.phase ~= "loop" then self:updateSequence() return @@ -599,10 +663,10 @@ function TitleState:update(dt) if input:wasPressed("start") or input:wasPressed("a") then -- .go_to_main_menu voices PikachuCry11 on the way out local Sound = require("src.core.Sound") - if not Sound.playPikaCry(self.game.data, 11) then - Sound.playCry(self.game.data, "PIKACHU") - end - self:openMenu() + self.exitCrySrc = Sound.playPikaCry(self.game.data, 11) + or Sound.playCry(self.game.data, "PIKACHU") + self.phase = "exitCry" + self.timer = 0 end return end @@ -613,23 +677,27 @@ function TitleState:update(dt) if input:wasPressed("start") or input:wasPressed("a") then -- the title mon cries when you leave the title (.finishedWaiting); -- Yellow's fixed Pikachu title always cries Pikachu. - require("src.core.Sound").playCry(self.game.data, + self.exitCrySrc = require("src.core.Sound").playCry(self.game.data, self.yellowLayout and "PIKACHU" or self.cycleSpecies[self.cycleIndex]) - self:openMenu() + self.phase = "exitCry" + self.timer = 0 end end -- ..(engine/movie/title.asm ln 28) function TitleState:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + -- MainMenu's own ClearScreen wipes the logo, mon and sprites before the + -- CONTINUE / NEW GAME border is drawn (engine/menus/main_menu.asm ln 26) + if self.menuOpen then return end local PaletteFX = require("src.render.PaletteFX") local playerImage = self.player if playerImage and PaletteFX.usesSpriteObp() then playerImage = require("src.render.SpriteRenderer").obpImage( self.playerPath, PaletteFX.ogObj()) end - love.graphics.setColor(1, 1, 1, 1) - love.graphics.rectangle("fill", 0, 0, 160, 144) local scrollY = -(self.scy or 0) -- ..(engine/movie/title.asm ln 28) local preRibbon = not self.yellowLayout diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 2274a09d..679de38e 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -38,6 +38,10 @@ local Sound = require("src.core.Sound") -- Only for playerPic: the player.sprite raiser both generations share. local Sprites = require("src.pokemon.Sprites") local Strings = require("src.core.Strings") +local SummaryMenu = require("src.ui.gen2.SummaryMenu") +-- Only for TextBox.substitute: the {PLAYER} / {RIVAL} markers a map text +-- carries into the battle box (PrintWinLossText, home/trainers.asm:230). +local TextBox = require("src.render.TextBox") local Unown = require("src.core.gen2.Unown") local BattleState = {} @@ -87,6 +91,20 @@ local TRAINER_SLIDE_STEPS = 8 local TRAINER_SLIDE_FRAMES_PER_STEP = 2 local TRAINER_SLIDE_FRAMES = TRAINER_SLIDE_STEPS * TRAINER_SLIDE_FRAMES_PER_STEP +-- BattleWinSlideInEnemyTrainerFrontpic (engine/battle/core.asm:6279-6318) and +-- WinTrainerBattle's DelayFrames 40 (:2311) +local WIN_SLIDE_STEPS = 6 +local WIN_SLIDE_FRAMES_PER_STEP = 4 +local WIN_SLIDE_FRAMES = WIN_SLIDE_STEPS * WIN_SLIDE_FRAMES_PER_STEP +local WIN_SLIDE_REST_TILES = 2 +local WIN_SLIDE_DELAY_FRAMES = 40 + +local function winSlideTiles(frames) + local step = math.min(WIN_SLIDE_STEPS, + math.floor(frames / WIN_SLIDE_FRAMES_PER_STEP) + 1) + return WIN_SLIDE_STEPS + WIN_SLIDE_REST_TILES - step +end + -- MonFaintedAnimation (engine/battle/core.asm), which PlayerMonFaintedAnimation -- and EnemyMonFaintedAnimation both fall into with the fainted side's pic -- corner: the pic's tilemap rows are copied DOWN one row per step and the row @@ -141,6 +159,10 @@ local MENU_COL_SPACING = 6 -- the count PrintNum writes after it (two digits, leading zeros) at (13,16). local CONTEST_MENU_BOX_X = 2 local CONTEST_MENU_COL_SPACING = 12 + +-- PrintMoveType prints the type table's own names; only these two differ from +-- the constant (data/types/names.asm). +local TYPE_NAMES = SummaryMenu.TYPE_NAMES -- charmap.asm's quantity glyph, spelled the way MartMenu spells it. local CONTEST_BALL_LABEL = "PARKBALL\xc3\x97" @@ -508,6 +530,14 @@ function BattleState:pushAll(events) for _, event in ipairs(events or {}) do self:push(event) end end +-- LearnMove returns before HandleEnemyMonFaint's send-out/prize arms +-- (engine/battle/core.asm:1959-2010). +function BattleState:pushFront(events) + for i = #(events or {}), 1, -1 do + table.insert(self.queue, 1, events[i]) + end +end + function BattleState:pic(mon, back) local def = self.pokemon and mon and self.pokemon[mon.species] local path = def and (back and def.spriteBack or def.spriteFront) @@ -686,6 +716,8 @@ function BattleState:drawPic(mon, back) -- One tile per two frames to the right, SlideBattlePicOut's own step. if enemyTrainer and self.trainerSlide then px = px + math.floor(self.trainerSlide / TRAINER_SLIDE_FRAMES_PER_STEP) * 8 + elseif enemyTrainer and self.winSlide then + px = px + winSlideTiles(self.winSlide) * 8 end -- The pic's own scale (battle_sprite_scales, then the species record, then -- 1x) composed with whatever square BattleBGEffect_RunPicResizeScript has @@ -1371,7 +1403,7 @@ function BattleState:advanceQueue() and self.shownHp[event.side] ~= event.hp then self.hpAnim = { side = event.side, to = event.hp } elseif event.kind == "send" and event.side and event.mon and self.shownHp then - self.shownHp[event.side] = event.mon.hp or 0 + self.shownHp[event.side] = event.hp or event.mon.hp or 0 if self.hpAnim and self.hpAnim.side == event.side then self.hpAnim = nil end end -- And the same lag for the status tag (home/battle.asm:150); a send snaps it @@ -1379,8 +1411,9 @@ function BattleState:advanceQueue() if self.shownStatus and event.side and (event.kind == "status" or (event.kind == "send" and event.mon)) then - self.shownStatus[event.side] = - (event.kind == "send" and event.mon.status or event.status) or false + local shown = event.status + if event.kind == "send" and shown == nil then shown = event.mon.status end + self.shownStatus[event.side] = shown or false end -- AnimateExpBar (engine/battle/core.asm:7191) is called from INSIDE -- GiveExperiencePoints before the exp is committed (the call at :6888 sits @@ -1419,12 +1452,12 @@ function BattleState:advanceQueue() -- inside SendOutPlayerMon and nothing on the enemy's path touches them. self.menuIndex = 1 self.moveIndex = 1 - -- The incoming mon's own level and exp bar: SendOutPlayerMon reloads - -- wBattleMon* from the party slot and UpdatePlayerHUD draws them at its - -- tail (:3838), so both snap here the way shownHp does above. - self.shownLevel = event.mon.level or 1 - self.shownExp = self:expPixels(event.mon, event.mon.level, - event.mon.experience) + -- SendOutPlayerMon reloads wBattleMon* from the party slot (:3838): + -- snap from the emit-time snapshot, not the live table (#1514). + local level = event.level or event.mon.level or 1 + self.shownLevel = level + self.shownExp = self:expPixels(event.mon, level, + event.experience or event.mon.experience) self.expAnim = nil end end @@ -1435,6 +1468,30 @@ function BattleState:advanceQueue() self.trainerSlide = 0 return end + -- BattleWinSlideInEnemyTrainerFrontpic and the DelayFrames 40 behind it + -- (engine/battle/core.asm:2310-2312) + if event.kind == "trainer-return" then + -- LostBattle's ClearBox wipes the live foe pic and HUD before the slide + -- (engine/battle/core.asm:2770-2773) + if event.cleared then + self.showEnemyHud = false + self.ballRows.enemy = false + end + if not self.enemyTrainerImage then return self:advanceQueue() end + self.showEnemyTrainer = true + self.picHidden.enemy = false + self.winSlide = 0 + self.winSliding = true + return + end + -- PrintWinLossText (home/trainers.asm:230): one FarPrintText of the trainer + -- struct's own line, paged and held for A/B like any other map text. + if event.kind == "win-text" then + local text = event.text + if self.game then text = TextBox.substitute(self.game, text) end + self:showPages(text) + return + end -- The shiny sparkle: hBattleTurn 1 and wBattleAnimParam 1 pick -- BattleAnim_SendOutMon's `.Shiny` arm on the enemy (core.asm:8708-8715). if event.kind == "shiny-flash" then @@ -1917,6 +1974,17 @@ function BattleState:update(_dt) return end + -- BattleWinSlideInEnemyTrainerFrontpic plus WinTrainerBattle's DelayFrames + -- 40 (engine/battle/core.asm:6279-6318, :2310-2312) + if self.winSliding then + self.winSlide = self.winSlide + 1 + if self.winSlide >= WIN_SLIDE_FRAMES + WIN_SLIDE_DELAY_FRAMES then + self.winSliding = nil + self:advanceQueue() + end + return + end + -- SlideBattlePicOut is a plain loop with DelayFrames in it, so it owns the -- screen the same way (engine/battle/core.asm:2882). if self.trainerSlide then @@ -2004,6 +2072,8 @@ function BattleState:update(_dt) self.phase = "stats-box" return end + -- PrintWinLossText's line pages like any map text (home/text.asm:403-448) + if self:nextPage() then return end self:advanceQueue() return end @@ -2292,7 +2362,7 @@ function BattleState:update(_dt) learn.move, learn.moveName) self.pendingLearn = nil self.phase = "resolving" - self:pushAll(self.battle:takeEvents()) + self:pushFront(self.battle:takeEvents()) self:advanceQueue() end return @@ -2872,7 +2942,7 @@ function BattleState:finishDecline() self.pendingLearn = nil self.phase = "resolving" if learn then self.battle:declineForget(learn.index, learn.moveName) end - self:pushAll(self.battle:takeEvents()) + self:pushFront(self.battle:takeEvents()) self:advanceQueue() end @@ -3475,6 +3545,23 @@ function BattleState:printMessage() end end +-- MoveInfoBox (engine/battle/core.asm:5403-5478): "TYPE/" at (1,9), the type +-- at (2,10), cur/max PP at (5,11), or "Disabled!" at (1,10). +function BattleState:drawMoveInfoBox(move) + if not move then return end + local fighter = self.battle and self.battle.player + if fighter and self.battle:moveDisabled(fighter, move.id) then + Chrome.print("Disabled!", 1, 10) + return + end + local def = self.game and self.game.data and self.game.data.moves + and self.game.data.moves[move.id] + Chrome.print("TYPE/", 1, 9) + local moveType = def and def.type + Chrome.print(moveType and (TYPE_NAMES[moveType] or moveType) or "", 2, 10) + Chrome.print(("%2d/%2d"):format(move.pp or 0, move.maxPp or 0), 5, 11) +end + function BattleState:drawPanel() Chrome.clear() -- A tutorial battle legitimately has no player mon, so only the enemy is @@ -3494,7 +3581,15 @@ function BattleState:drawPanel() -- Message box across the bottom, with the menu window over its right half -- -- the cart draws the prompt into the full-width box and then opens the menu -- on top, so the tail of a long name is simply covered. + -- MoveSelectionScreen type 0 is two boxes: the name-only list + -- (engine/battle/core.asm:5074-5084) and MoveInfoBox's (:5407-5410). + local moveMenu = self.phase == "moves" Chrome.box(0, 12, 20, 6) + if moveMenu then + -- List box first (core.asm:5074-5084), MoveInfoBox on top (:5157). + Chrome.box(4, 12, 16, 6) + Chrome.box(0, 8, 11, 5) + end if self.phase == "menu" then self:printMessage() local boxX = self.contest and CONTEST_MENU_BOX_X or MENU_BOX_X @@ -3520,24 +3615,30 @@ function BattleState:drawPanel() moves = (mon and mon.moves) or moves end local cursorRow = forgetting and self.forgetIndex or self.moveIndex + -- w2DMenuCursorInitX 5 with the names at hlcoord 6 (core.asm:5086-5107). + local cursorCol = moveMenu and 5 or 1 + local nameCol = moveMenu and 6 or 2 for i, move in ipairs(moves) do local ty = 13 + (i - 1) -- Cursor in the box's own gutter, not clipped against the border. - if i == cursorRow then Chrome.cursor(1, ty) end + if i == cursorRow then Chrome.cursor(cursorCol, ty) end -- The held slot's marker. `.battle_player_moves` writes '▷' into the -- row wSwappingMove names (engine/battle/core.asm:5157-5165) so a move -- picked up for a swap is visible while the cursor moves off it. It - -- sits a column right of the cursor gutter, where the cart puts it - -- (hlcoord 5, 13 against the cursor's own column), and only while the - -- move list itself is up -- the forget picker has no swapping. - if not forgetting and self.moveSwapIndex == i then - Chrome.print("\u{25B7}", 0, ty) + -- hlcoord 5, 13 is the cursor's own gutter, so PlaceMenuCursor covers + -- the marker on the cursor's row. + if not forgetting and self.moveSwapIndex == i and i ~= cursorRow then + Chrome.print("\u{25B7}", cursorCol, ty) end local def = self.game and self.game.data and self.game.data.moves and self.game.data.moves[move.id] - Chrome.print((def and def.name) or move.id, 2, ty) - Chrome.printRight(("%d/%d"):format(move.pp or 0, move.maxPp or 0), 19, ty) + Chrome.print((def and def.name) or move.id, nameCol, ty) + if not moveMenu then + Chrome.printRight(("%d/%d"):format(move.pp or 0, move.maxPp or 0), + 19, ty) + end end + if moveMenu then self:drawMoveInfoBox(moves[cursorRow]) end else -- Battle messages wrap inside the box rather than running off the frame. self:printMessage() diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index d1e3c2d7..72aa6132 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -337,10 +337,15 @@ function PackMenu:useSelected() return end local world = self.world - local result = world and world.useFieldItem and world:useFieldItem(row.id) + local result, extra = nil, nil + if world and world.useFieldItem then result, extra = world:useFieldItem(row.id) end if result then if result == "nowhere" then self.message = OAK_THIS_ISNT_THE_TIME + elseif result == "coin_case" then + -- _CoinCaseCountText (data/text/common_3.asm:336): "Coins:" then the + -- count, text_decimal 4 digits with PRINTNUM_LEFTALIGN_F so no padding. + self.message = { "Coins:", tostring(extra or 0) } elseif result == "repel_used" then -- ItemUsedText (data/text/common_3.asm): " used the\n." -- World already wrote the counter and took the item out of the bag, so diff --git a/src/ui/gen2/PokedexMenu.lua b/src/ui/gen2/PokedexMenu.lua index 480bd9ef..fbd14aef 100644 --- a/src/ui/gen2/PokedexMenu.lua +++ b/src/ui/gen2/PokedexMenu.lua @@ -150,7 +150,11 @@ function PokedexMenu.new(game, opts) self.pokemon = opts.pokemon or data.pokemon self.palettes = opts.palettes or data.gen2Palettes self.onClose = opts.onClose + -- InitPokedex: wLastDexMode -> wCurDexMode (engine/pokedex/pokedex.asm:97). self.modeIndex = 1 + for i, name in ipairs(MODES) do + if self.save and name == self.save.lastDexMode then self.modeIndex = i end + end self.index = 1 self.scroll = 0 self.view = "list" -- list | entry | area | option | search | results | unown @@ -317,6 +321,13 @@ function PokedexMenu:cursorVisible() return ((self.entryBlink or 0) % 32) < 20 end +-- Pokedex: wCurDexMode -> wLastDexMode on the way out +-- (engine/pokedex/pokedex.asm:60), which lives in the saved game data. +function PokedexMenu:close() + if self.save then self.save.lastDexMode = MODES[self.modeIndex] end + if self.onClose then self.onClose() end +end + function PokedexMenu:update(_dt) self.entryBlink = (self.entryBlink or 0) + 1 local input = self.game and self.game.input @@ -328,8 +339,8 @@ function PokedexMenu:update(_dt) if input:wasPressed("a") or input:wasPressed("b") then if self.page == 1 then self.page = 2 - elseif self.onClose then - self.onClose() + else + self:close() end end return @@ -367,7 +378,7 @@ function PokedexMenu:update(_dt) if self.view == "search" then return self:updateSearch(input) end if self.view == "unown" then return self:updateUnown(input) end if input:wasPressed("b") then - if self.onClose then self.onClose() end + self:close() return elseif input:wasPressed("select") then -- Pokedex_UpdateMainScreen: SELECT opens the OPTION screen and START the diff --git a/src/ui/gen2/Pokegear.lua b/src/ui/gen2/Pokegear.lua index a7cb56d0..4d94027e 100644 --- a/src/ui/gen2/Pokegear.lua +++ b/src/ui/gen2/Pokegear.lua @@ -1382,7 +1382,8 @@ end -- wTimeOfDay, as the cart numbers it: MORN 0, DAY 1, NITE 2, DARK 3. function Pokegear:timeOfDayIndex() local world = self.game and self.game.world - local daytime = (world and world.daytime) + -- the unpinned clock split, not the palette pin (pokegear.asm:1456, :1957) + local daytime = (world and (world.tod or world.daytime)) or Palettes.clockDaytime(self.clock and self.clock.hour or nil) return (Palettes.DAYTIME_ID[daytime] or 2) - 1 end @@ -2280,7 +2281,9 @@ function Pokegear:drawPanel() self:drawClock() end -- Last: the arrow is an OBJ and composites over whatever the card drew. - self:drawModeArrow() + -- _FlyMap has no card strip and never animates it + -- (engine/pokegear/pokegear.asm:1999). + if not self.fly then self:drawModeArrow() end G.setColor(1, 1, 1, 1) end diff --git a/src/ui/gen2/SummaryMenu.lua b/src/ui/gen2/SummaryMenu.lua index 7f4dc4ee..7fdbdd74 100644 --- a/src/ui/gen2/SummaryMenu.lua +++ b/src/ui/gen2/SummaryMenu.lua @@ -36,9 +36,8 @@ -- $3f the shiny ⁂ icon (stats_tiles tile 14) -- $40 / $41 the left and right HP/exp bar end caps -- --- The extractor does not carry that sheet yet, so `pageTile` draws those seven --- shapes directly and takes the sheet the moment menu_gfx grows a `stats` --- entry. Everything that IS a glyph goes through the font: ◀ ($71), ▶ ($ed), +-- The extractor writes that sheet as menu_gfx.stats, which `pageTile` draws. +-- Everything that IS a glyph goes through the font: ◀ ($71), ▶ ($ed), -- № ($74), ($73), ($6e) and the row-7 rule's $62 (the empty HP/exp -- bar cell, which is FontBattleExtra's -- hence Font.useBattleExtra(true) -- around the whole screen, exactly as the party menu does). @@ -95,6 +94,14 @@ local TILE_BAR_CAP_RIGHT = 0x41 -- made of (StatsScreen_PlaceHorizontalDivider). local TILE_HORIZONTAL_DIVIDER = 0x62 +-- gfx/stats/pages.pal, the three palettes _CGB_StatsScreenHPPals copies to +-- wBGPals1 slots 3-5 (engine/gfx/cgb_layouts.asm:199-212) +local PAGE_PALETTES = { + { { 255, 255, 255 }, { 255, 156, 255 }, { 255, 123, 255 }, { 0, 0, 0 } }, + { { 255, 255, 255 }, { 173, 255, 115 }, { 140, 255, 0 }, { 0, 0, 0 } }, + { { 255, 255, 255 }, { 140, 255, 255 }, { 140, 255, 255 }, { 0, 0, 0 } }, +} + -- PrintTempMonStats' .StatNames, and the wTempMon fields it prints beside -- them. steps two rows, so the five labels are 2 rows apart and the -- values start one row below the first label. @@ -821,12 +828,37 @@ end -- ----------------------------------------------------------------- drawing --- A tile out of StatsScreenPageTilesGFX. The extractor does not carry that --- sheet, so each of the seven shapes it needs is drawn here; the moment --- menu_gfx grows a `stats` entry this can take the real tiles instead. +-- menu_gfx.stats, the 17 tiles LoadStatsScreenPageTilesGFX lands at vTiles2 +-- tile $31 (engine/gfx/load_font.asm:90-95) +function SummaryMenu:statsTiles() + if self.statsSheet ~= nil then return self.statsSheet or nil end + local gfx = (self.menuGfx or {}).stats + local image = gfx and self:picImage(gfx.sheet) + if not image then + self.statsSheet = false + return nil + end + local w, h = image:getDimensions() + local quads = {} + for index = 0, (gfx.tiles or 17) - 1 do + quads[(gfx.firstTile or 0x31) + index] = + love.graphics.newQuad(index * 8, 0, 8, 8, w, h) + end + self.statsSheet = { image = image, quads = quads } + return self.statsSheet +end + +-- A tile out of StatsScreenPageTilesGFX. The fallback arm draws each of the +-- seven shapes by hand for a cache built before menu_gfx.stats existed. function SummaryMenu:pageTile(id, tx, ty) local G = love.graphics local px, py = tx * 8, ty * 8 + local sheet = self:statsTiles() + if sheet and sheet.quads[id] then + G.setColor(1, 1, 1, 1) + G.draw(sheet.image, sheet.quads[id], px, py) + return + end G.setColor(0, 0, 0, 1) if id == TILE_VERTICAL_DIVIDER then G.rectangle("fill", px + 3, py, 2, 8) @@ -847,11 +879,30 @@ end -- (17,5), all small ($36) first, then the one for this page redrawn large -- ($3a). The routine writes the four tiles as [hli]/[hld], a row down, then -- [hli]/[hl] -- which is why it is a 2x2 block and not a 2x1 strip. -function SummaryMenu:drawPageSquare(tx, ty, large) +function SummaryMenu:drawPageSquare(tx, ty, large, colors) local G = love.graphics local px, py = tx * 8, ty * 8 -- $3a..$3d for the page that is up, $36..$39 for the other two. local first = large and TILE_SQUARE_LARGE or TILE_SQUARE_SMALL + local sheet = self:statsTiles() + if sheet and sheet.quads[first] then + -- [hli] / [hld], a row down, [hli] / [hl]: the four tiles in that + -- order (engine/pokemon/stats_screen.asm:841-853). + local function body() + G.setColor(1, 1, 1, 1) + G.draw(sheet.image, sheet.quads[first], px, py) + G.draw(sheet.image, sheet.quads[first + 1], px + 8, py) + G.draw(sheet.image, sheet.quads[first + 2], px, py + 8) + G.draw(sheet.image, sheet.quads[first + 3], px + 8, py + 8) + end + if colors and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end + G.setColor(1, 1, 1, 1) + return + end local inset = first == TILE_SQUARE_LARGE and 2 or 5 local size = 16 - inset * 2 G.setColor(0, 0, 0, 1) @@ -862,7 +913,7 @@ end function SummaryMenu:drawPageIndicators() local columns = { 13, 15, 17 } for i, tx in ipairs(columns) do - self:drawPageSquare(tx, 5, i == self.page) + self:drawPageSquare(tx, 5, i == self.page, PAGE_PALETTES[i]) end end @@ -1134,6 +1185,7 @@ end SummaryMenu.STAT_LABELS = STAT_LABELS SummaryMenu.STAT_KEYS = STAT_KEYS SummaryMenu.TYPE_NAMES = TYPE_NAMES +SummaryMenu.PAGE_PALETTES = PAGE_PALETTES SummaryMenu.levelText = levelText return SummaryMenu diff --git a/src/update/Check.lua b/src/update/Check.lua index c087485d..95f309cb 100644 --- a/src/update/Check.lua +++ b/src/update/Check.lua @@ -4,7 +4,8 @@ -- on a background love.thread worker (src/update/check_worker.lua); this module -- is only the thin main-thread state machine the UI polls. Two channels carry -- the conversation: --- "update_check_cmd" main -> worker: { cmd = "check" | "download" | "quit" } +-- "update_check_cmd" main -> worker: { cmd = "check" | "download" | +-- "download_full" | "quit" } -- "update_check_state" worker -> main: { status, latest, progress, error } -- -- Nothing here ever blocks or throws into the game loop: when love.thread is @@ -21,6 +22,42 @@ local Platform = require("src.core.Platform") Check.REPO = "bryanthaboi/gen1recomp" +-- Full native packages are deliberately named by release target, not by the +-- generic .love payload. Keeping the mapping here makes the release parser, +-- worker and launcher agree on exactly which asset a platform may offer. +-- Switch owns its native OTA launcher and therefore never reaches this code. +local function fullAssetName(version, osName, arch, port) + if port == "rg34xxsp" then + return "gen1recomp-" .. version .. "-rg34xxsp-stockos64-mod.zip" + elseif port == "portmaster" then + return "gen1recomp-" .. version .. "-sbc-portmaster.zip" + elseif osName == "Android" then + return "gen1recomp-" .. version .. "-android.apk" + elseif osName == "iOS" then + return "gen1recomp++-" .. version .. "-ios.ipa" + elseif osName == "OS X" or osName == "macOS" then + return "gen1recomp-" .. version .. "-macos.zip" + elseif osName == "Windows" then + return "gen1recomp-" .. version .. "-windows.zip" + elseif osName == "UWP" then + return "gen1recomp-" .. version .. "-xbox-uwp.zip" + elseif osName == "NX" then + return "gen1recomp-" .. version .. "-switch.zip" + elseif osName == "Linux" and (arch == "arm64" or arch == "aarch64") then + return "gen1recomp-" .. version .. "-linux-arm64.AppImage" + elseif osName == "Linux" then + return "gen1recomp-" .. version .. "-linux.zip" + end + return nil +end + +function Check.fullAssetName(version, osName, arch, port) + if type(version) ~= "string" or not version:match("^%d+%.%d+%.%d+$") then + return nil + end + return fullAssetName(version, osName, arch, port) +end + local CMD = "update_check_cmd" local STATE = "update_check_state" @@ -50,7 +87,7 @@ end -- document is not a release with a strict X.Y.Z tag. Json is injected so the -- worker can pass a filesystem-loaded codec; on the main thread / in tests it -- falls back to require. -function Check.parseRelease(jsonText, Json) +function Check.parseRelease(jsonText, Json, target) Json = Json or require("src.link.Json") local notJson = Json.describeUnexpected(jsonText) if notJson then return nil, notJson end @@ -66,11 +103,15 @@ function Check.parseRelease(jsonText, Json) return nil, "release tag is not X.Y.Z: " .. tostring(doc.tag_name) end local payloadName = "gen1recomp-" .. version .. ".love" + target = type(target) == "table" and target or {} + local fullName = fullAssetName(version, target.os, target.arch, target.port) return { version = version, payloadName = payloadName, payload = Check.pickAsset(doc.assets, payloadName), sums = Check.pickAsset(doc.assets, "sha256sums.txt"), + fullName = fullName, + full = fullName and Check.pickAsset(doc.assets, fullName) or nil, -- GitHub release body: already fetched with the update check, shown by -- the launcher's Patch notes footer button. notes = type(doc.body) == "string" and doc.body or "", @@ -106,6 +147,37 @@ local workerReady -- nil = untried, true = running, false = unavailable local requested -- a check has been asked for this session local cache = { status = "idle" } -- newest snapshot from the worker +local function target() + local osName = love and love.system and love.system.getOS and love.system.getOS() or nil + local arch = jit and jit.arch or nil + local port = os.getenv("POKEPORT_PORTMASTER") + return { os = osName, arch = arch, port = port } +end + +local function readPersistedFullRequirement() + if not (love and love.filesystem and love.filesystem.getInfo) then return nil end + local path = "updates/full-update.json" + if not love.filesystem.getInfo(path) then return nil end + local text = love.filesystem.read(path) + if type(text) ~= "string" then return nil end + local ok, Json = pcall(require, "src.link.Json") + if not ok or not Json then return nil end + local decodedOk, requirement = pcall(Json.decode, text) + if not decodedOk or type(requirement) ~= "table" then return nil end + if type(requirement.version) ~= "string" then return nil end + return requirement +end + +local persistedRequirement = readPersistedFullRequirement() +if persistedRequirement then + cache = { + status = "needs_full", + latest = persistedRequirement.version, + reason = persistedRequirement.reason, + full = persistedRequirement.full, + } +end + local function ensureWorker() if workerReady ~= nil then return workerReady end if not Platform.networkValidated() then @@ -167,11 +239,13 @@ function Check.start(force) end requested = true cache = { status = "checking", notes = cache.notes, latest = cache.latest } - cmdCh:push({ cmd = "check" }) + cmdCh:push({ cmd = "check", target = target() }) end --- Current snapshot: { status, latest, progress, error, notes }. status is one of --- idle | checking | uptodate | available | downloading | ready | needs_full | error. +-- Current snapshot: { status, latest, progress, error, notes, reason, full }. +-- status is one of +-- idle | checking | uptodate | available | downloading | ready | needs_full | +-- full_downloading | full_ready | error. function Check.state() drain() return { @@ -180,9 +254,67 @@ function Check.state() progress = cache.progress, error = cache.error, notes = cache.notes, + reason = cache.reason, + full = cache.full, } end +-- The full-update record is intentionally persistent. An offline launch still +-- tells the player why this native shell cannot run the downloaded release. +function Check.fullUpdateAction() + drain() + local st = Check.state() + if st.status ~= "needs_full" and st.status ~= "full_ready" then return nil end + local osName = love and love.system and love.system.getOS and love.system.getOS() or "" + if osName == "Android" and type(love.system.installApk) == "function" + and type(st.full) == "table" and type(st.full.url) == "string" then + if st.status == "full_ready" and type(st.full.path) == "string" then + return { label = "Install Android update", kind = "install" } + end + return { label = "Download Android update", kind = "download" } + end + if osName == "iOS" then + return { label = "Re-sideload app", url = + "https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json" } + elseif osName == "UWP" then + return { label = "Open Xbox install guide", url = Check.releaseUrl() } + elseif type(st.full) == "table" and type(st.full.url) == "string" then + return { label = "Download full update", url = st.full.url } + end + return { label = "Open releases", url = Check.releaseUrl() } +end + +function Check.downloadFull() + drain() + if not cmdCh or cache.status ~= "needs_full" then return false end + if not (cache.full and cache.full.url) then return false end + cache = { status = "full_downloading", latest = cache.latest, progress = 0, + reason = cache.reason, full = cache.full, notes = cache.notes } + cmdCh:push({ cmd = "download_full" }) + return true +end + +function Check.installFull() + drain() + if cache.status ~= "full_ready" then return false end + local path = cache.full and cache.full.path + if type(path) ~= "string" or path == "" then return false end + if not (love and love.system and type(love.system.installApk) == "function") then return false end + local ok, started = pcall(love.system.installApk, path) + return ok and started == true +end + +function Check.performFullUpdate() + local action = Check.fullUpdateAction() + if not action then return false end + if action.kind == "download" then return Check.downloadFull() end + if action.kind == "install" then return Check.installFull() end + if action.url and love and love.system and love.system.openURL then + return pcall(love.system.openURL, action.url) + end + return false +end + -- Start downloading the payload announced by an "available" check. A no-op in -- any other state (the worker still holds the release info from the check). function Check.download() diff --git a/src/update/check_worker.lua b/src/update/check_worker.lua index 175a5457..81138572 100644 --- a/src/update/check_worker.lua +++ b/src/update/check_worker.lua @@ -2,7 +2,8 @@ -- -- Runs on a love.thread so no curl call, sha256 pass or archive probe ever -- touches the render thread. Talks over two channels: --- "update_check_cmd" in: { cmd = "check" | "download" | "quit" } +-- "update_check_cmd" in: { cmd = "check" | "download" | +-- "download_full" | "quit" } -- "update_check_state" out: { status, latest, progress, error } -- -- Transport is HostShell: curl via io.popen on desktop, the JNI @@ -44,6 +45,11 @@ local Boot = loadModule("src/update/Boot.lua") local cmdCh = love.thread.getChannel("update_check_cmd") local stateCh = love.thread.getChannel("update_check_state") +-- The release chosen by the last check. Declare this before post() so status +-- messages consistently preserve its release notes instead of accidentally +-- reading a global named `pending`. +local pending = nil + local function post(t) if pending and type(t) == "table" and t.notes == nil then t.notes = pending.notes @@ -57,10 +63,6 @@ local saveDir = love.filesystem.getSaveDirectory() local API_URL = "https://api.github.com/repos/bryanthaboi/gen1recomp/releases/latest" --- the release picked by the last "check"; kept between commands so "download" --- knows the payload url/size/name without re-fetching -local pending = nil - -- --------------------------------------------------------------------------- -- shell / fetch -- --------------------------------------------------------------------------- @@ -138,6 +140,10 @@ local function verifyPayload(rel, payloadName, sumsText) return true end +local function verifyFullPackage(rel, assetName, sumsText) + return verifyPayload(rel, assetName, sumsText) +end + -- true = ok to run, false = payload needs a newer shell (needs_full). When Boot -- cannot probe (module missing during parallel dev, or a probe failure) we allow -- it: Boot.run's crash-guard handles a payload that turns out unrunnable. @@ -147,8 +153,34 @@ local function gatePasses(rel) if not info then return true end local shell = (Version and Version.shell) or 1 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) + if info.payloadHost and info.payloadHost ~= payloadHost then return false, "payload_host" end + if info.minShell and info.minShell > shell then return false, "min_shell" end + if Boot.canHost and not Boot.canHost(info, shell, payloadHost) then return false, "shell_gate" end + return true +end + +local function persistFullRequirement(rel, reason) + if not (rel and rel.version and Json) then return end + local full = rel.full + local record = { + version = rel.version, + reason = reason or "full_package_required", + full = full and { name = rel.fullName, url = full.url, size = full.size } or nil, + } + pcall(function() + love.filesystem.createDirectory("updates") + love.filesystem.write("updates/full-update.json", Json.encode(record)) + end) +end + +local function postFullRequirement(rel, reason) + persistFullRequirement(rel, reason) + post({ status = "needs_full", latest = rel and rel.version, reason = reason, + full = rel and rel.full and { name = rel.fullName, url = rel.full.url, size = rel.full.size } or nil }) +end + +local function clearFullRequirement() + pcall(function() love.filesystem.remove("updates/full-update.json") end) end local function cacheNotes(ver, notes) @@ -177,7 +209,7 @@ end -- check -- --------------------------------------------------------------------------- -local function doCheck() +local function doCheck(target) post({ status = "checking" }) if not canFetch() then @@ -193,7 +225,7 @@ local function doCheck() return end - local rel, perr = Check.parseRelease(body, Json) + local rel, perr = Check.parseRelease(body, Json, target) if not rel then post({ status = "error", error = perr or "bad release json" }) return @@ -212,6 +244,9 @@ local function doCheck() end if compareVersions(rel.version, currentEngine) <= 0 then + -- We are now running a native shell at least as new as GitHub's latest + -- release, so a former minShell/payloadHost prompt no longer applies. + clearFullRequirement() post({ status = "uptodate", latest = rel.version }) return end @@ -219,7 +254,7 @@ local function doCheck() -- A newer release, but without the .love payload or its sums we cannot do an -- in-place update: send the user to the full installers. if not (rel.payload and rel.payload.url and rel.sums and rel.sums.url) then - post({ status = "needs_full", latest = rel.version }) + postFullRequirement(rel, "payload_missing") return end @@ -229,9 +264,10 @@ local function doCheck() if love.filesystem.getInfo(finalRel) then local sums = fetchText(rel.sums.url) if sums and verifyPayload(finalRel, rel.payloadName, sums) then - if gatePasses(finalRel) == false then + local allowed, reason = gatePasses(finalRel) + if allowed == false then love.filesystem.remove(finalRel) - post({ status = "needs_full", latest = rel.version }) + postFullRequirement(rel, reason) return end post({ status = "ready", latest = rel.version }) @@ -353,9 +389,10 @@ local function doDownload() return end - if gatePasses(partRel) == false then + local allowed, reason = gatePasses(partRel) + if allowed == false then love.filesystem.remove(partRel) - post({ status = "needs_full", latest = rel.version }) + postFullRequirement(rel, reason) return end @@ -374,6 +411,63 @@ local function doDownload() post({ status = "ready", latest = rel.version }) end +-- Full native-package download. At present Android consumes the verified file +-- through its Package Installer bridge. Other platforms retain the same +-- release metadata and fall back to their platform-specific external update +-- channel rather than attempting to overwrite a running executable. +local function doDownloadFull() + if not (pending and pending.full and pending.full.url and pending.fullName + and pending.sums and pending.sums.url) then + post({ status = "error", error = "full package is unavailable" }) + return + end + + local rel = pending + local asset = rel.full + local name = rel.fullName + love.filesystem.createDirectory("updates") + local partRel = "updates/" .. name .. ".part" + local doneRel = "updates/" .. name + local partAbs = saveDir .. "/" .. partRel + local doneAbs = saveDir .. "/" .. doneRel + love.filesystem.remove(partRel) + love.filesystem.remove(doneRel) + post({ status = "full_downloading", latest = rel.version, progress = 0, + reason = "full_package_required", full = { name = name, url = asset.url, size = asset.size } }) + + local ok = HostShell and HostShell.httpDownload(asset.url, partAbs, UA, nil, 900) + if not ok then + love.filesystem.remove(partRel) + postFullRequirement(rel, "full_download_failed") + return + end + + local sums = fetchText(rel.sums.url) + if not sums then + love.filesystem.remove(partRel) + postFullRequirement(rel, "full_checksum_fetch_failed") + return + end + local valid, err = verifyFullPackage(partRel, name, sums) + if not valid then + love.filesystem.remove(partRel) + post({ status = "error", error = err or "full package verification failed" }) + return + end + if not os.rename(partAbs, doneAbs) then + local data = love.filesystem.read(partRel) + if not data then + post({ status = "error", error = "full package finalize failed" }) + return + end + love.filesystem.write(doneRel, data) + love.filesystem.remove(partRel) + end + persistFullRequirement(rel, "full_package_required") + post({ status = "full_ready", latest = rel.version, reason = "full_package_required", + full = { name = name, url = asset.url, size = asset.size, path = doneAbs } }) +end + -- --------------------------------------------------------------------------- -- command loop -- --------------------------------------------------------------------------- @@ -384,11 +478,14 @@ while true do if cmd.cmd == "quit" then break elseif cmd.cmd == "check" then - local ok, err = pcall(doCheck) + local ok, err = pcall(doCheck, cmd.target) if not ok then post({ status = "error", error = tostring(err) }) end elseif cmd.cmd == "download" then local ok, err = pcall(doDownload) if not ok then post({ status = "error", error = tostring(err) }) end + elseif cmd.cmd == "download_full" then + local ok, err = pcall(doDownloadFull) + if not ok then post({ status = "error", error = tostring(err) }) end end end end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 088fbe84..1bc821ee 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -2967,6 +2967,24 @@ function OverworldState:openPC(onDone) self:openOaksPC(done) end, }) + + -- engine/pokemon/bills_pc.asm:48-60 PKMN LEAGUE row (#1566) + if #(Game.save.hallOfFame or {}) > 0 then + table.insert(items, { + label = Strings("LEAGUE"), + keepOpen = true, + onSelect = function() + -- pc.asm PKMNLeague plays SFX_ENTER_PC, then PKMNLeaguePC prints + -- AccessedHoFPCText (engine/menus/pc.asm:67, league_pc.asm:2) + require("src.core.Sound").play(Game.data, "Enter_PC") + Game.stack:push(TextBox.new(Game, + romText(Game.data, "_AccessedHoFPCText", + "Accessed POKéMON\nLEAGUE's site.\fAccessed the HALL\nOF FAME List."), + function() Screens.push(Game, "LeaguePC") end)) + done() + end, + }) + end end local hooked = Runtime.call("ui.pc.items", sameItems, Game, items) @@ -3301,7 +3319,8 @@ end -- the approach walk and then EngageMapTrainer with no further text: the -- caller already showed the box, so the battle starts without a second -- one (#869). -function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText) +function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText, + endBattleSound, endBattleIsReward) local d = npc.def Runtime.emit("world.trainer_engaged", { npc = npc, trainerClass = d.trainerClass, partyIndex = d.trainerParty }) @@ -3357,6 +3376,13 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText -- cuts (#282). Substituted here because BattleState:say takes finished -- text, while TextBox expanded the {PLAYER}/{RIVAL} tokens itself. battle.endBattleText = wonText and TextBox.substitute(Game, wonText) or nil + -- the badge jingle rides the armed line's first page on the battle + -- screen (sound_get_item_1 in _TX_PRE dialogue; see gyms.lua) (#1606) + battle.endBattleSound = endBattleText ~= nil and endBattleSound or nil + -- one truth for both checkVictoryRewards call sites; endBattleIsReward + -- = false marks an armed line that is NOT the victories dialogue (#1606) + battle.rewardDialogueShown = endBattleText ~= nil + and endBattleIsReward ~= false battle.onFinish = function(result) if result == "win" then Game.save.defeatedTrainers[npc.id] = true @@ -3366,7 +3392,8 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText -- checkVictoryRewards pushes the badge/prize box and starts the map's -- onVictory script UNDER whatever runs next, so the player still sees -- EndBattle (now inside the battle), then the reward, then AfterBattle - self:checkVictoryRewards(d.trainerClass, d.trainerParty) + self:checkVictoryRewards(d.trainerClass, d.trainerParty, + battle.rewardDialogueShown) self:afterBattle(result, battle) if onDone then onDone() end else @@ -3477,7 +3504,10 @@ end -- SetEvent / SetEventRange do after the leader victory. -- `hide` is { { mapId, objName }, ... } -- HideObject on those toggles -- (e.g. Brock victory clears PEWTERCITY_YOUNGSTER / ROUTE22_RIVAL1). -function OverworldState:checkVictoryRewards(trainerClass, partyIndex) +-- `shownOnBattleScreen`: `dialogue` already rode the battle screen as the +-- armed end-battle line (scripts/CeruleanGym.asm:113) +function OverworldState:checkVictoryRewards(trainerClass, partyIndex, + shownOnBattleScreen) local victories = require("data.scripts.victories") local reward = victories[trainerClass .. "#" .. tostring(partyIndex or 1)] if not reward then return self:runVictoryHook() end @@ -3510,7 +3540,9 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex) end local chain = rewardChain() if reward.dialogue then - chain.add(reward.dialogue, reward.badgeSound) + if not shownOnBattleScreen then + chain.add(reward.dialogue, reward.badgeSound) + end if reward.item then chain.add(reward.tmPre) if tmGiven then @@ -4423,7 +4455,8 @@ function OverworldState:restoreBattleContinuation(battle, origin) if result == "win" then game.save.defeatedTrainers[origin.npcId] = true if origin.event then game.save.flags[origin.event] = true end - self:checkVictoryRewards(battle.oppClass, battle.partyIndex) + self:checkVictoryRewards(battle.oppClass, battle.partyIndex, + battle.rewardDialogueShown) end self:afterBattle(result, battle) self.engaging = false diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 527d7d66..28ea2689 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -391,15 +391,19 @@ end -- Gen 2 moveset lives in `levelMoves` (EvosAttacks). So every scripted gift, -- the STARTER included, arrived knowing nothing: FIGHT listed no moves and the -- battle had no legal action left in it. -local function givePokeMon(data, speciesIndex, level, itemIndex) +local function givePokeMon(data, speciesIndex, level, itemIndex, opts) local id = speciesByIndex(data.pokemon, speciesIndex) if not id then return nil end return Mon.new(data, id, level or 5, { item = itemIndex and itemIndex ~= 0 and itemByIndex(data.items, itemIndex) or nil, + nickname = opts and opts.nickname or nil, }) end +-- GivePoke's trainer arm (engine/pokemon/move_mon.asm:1698-1736) +local RANDY_OT_ID = 1001 + local function loadGenerated(path) -- Same NX gold/ fallback Game2 uses. World:load is what surfaces -- "Gold cache incomplete" when maps.lua is invisible at the unprefixed path. @@ -997,13 +1001,18 @@ function World:load() setStringBuffer = function(value) if self.game then self.game.stringBuffer = value end end, - givePoke = function(speciesIndex, level, item) + givePoke = function(speciesIndex, level, item, opts) local data = self.game and self.game.data local save = self.game and self.game.save if not (data and save) then return end save.party = save.party or {} - local mon = givePokeMon(data, speciesIndex, level, item) + local mon = givePokeMon(data, speciesIndex, level, item, opts) if mon then + if opts and opts.otName then + mon.ot = opts.otName + mon.otName = opts.otName + mon.otId = RANDY_OT_ID + end -- GivePoke -> TryAddMonToParty -> AddPartyMon (move_mon.asm:44-56, :143-149). Mon.stampOT(save, mon) Party.add(save.party, mon) @@ -1458,13 +1467,11 @@ function World:mapSceneOf(group, mapNum) end -- wTimeOfDay (constants/ram_constants.asm): MORN_F 0, DAY_F 1, NITE_F 2, --- DARKNESS_F 3. Palettes.daytimeFor has already resolved the clock and the --- map's own PALETTE_* override into one of four names, so this is a rename --- rather than a second clock. +-- DARKNESS_F 3, off the RTC hour (engine/tilesets/timeofday_pals.asm:5-11) local TIME_OF_DAY_ID = { MORN = 0, DAY = 1, NITE = 2, DARK = 3 } function World:timeOfDayId() - return TIME_OF_DAY_ID[self.daytime or "DAY"] or 1 + return TIME_OF_DAY_ID[self.tod or self.daytime or "DAY"] or 1 end -- GetWeekday -> wCurDay, which the RTC counts SUNDAY 0 .. SATURDAY 6 -- the @@ -4369,6 +4376,8 @@ function World:useFieldItem(itemId) if itemId == "SACRED_ASH" then return self:useSacredAsh() end if itemId == "ESCAPE_ROPE" then return self:useEscapeRope(itemId) end if itemId == "SQUIRTBOTTLE" then return self:useSquirtbottle() end + -- CoinCaseEffect (engine/items/item_effects.asm:2243). + if itemId == "COIN_CASE" then return "coin_case", self:coins() end if REPEL_STEPS[itemId] then return self:useRepel(itemId) end if TROPHY_BOXES[itemId] then return self:openTrophyBox(itemId) end if not World.isRod(itemId, items) then return nil end @@ -5802,7 +5811,9 @@ function World:battleMusicContext(opts) members = trainer and trainer.classId and members and members[trainer.classId] or nil, landmark = self.map and self.map.def and self.map.def.landmark, - daytime = self.daytime, + -- PlayBattleMusic reads wTimeOfDay (engine/battle/start_battle.asm:24), + -- not the map's pinned palette set. + daytime = self.tod, } end @@ -5999,6 +6010,24 @@ function World:startBattle(opts, onDone) return true end +-- wWinTextPointer / wLossTextPointer (home/trainers.asm:120), overwritten by +-- `winlosstext` (engine/overworld/scripting.asm:651) +function World:trainerWinLossText() + local vm = self.vm + if not vm then return nil, nil end + local obj = vm.trainerObject or {} + local text = self.text or {} + -- `winlosstext` writes BOTH pointers; a 0 argument destroys the struct + -- value rather than falling back to it (engine/overworld/scripting.asm:651) + local winKey, lossKey + if vm.winLossArmed then + winKey, lossKey = vm.winTextOverride, vm.lossTextOverride + else + winKey, lossKey = obj.winText, obj.lossText + end + return winKey and text[winKey] or nil, lossKey and text[lossKey] or nil +end + -- `startbattle` from a script: a trainer record (class + member) or a -- loadwildmon pair. The VM is parked on the yield until onDone fires, so the -- rest of the trainer script (flag set, after-battle text) runs on return. @@ -6057,6 +6086,9 @@ function World:startScriptedBattle(record, wild, onDone) attributes = record.attributes, items = record.items, } + -- wWinTextPointer / wLossTextPointer, read by PrintWinLossText on the + -- battle screen (home/trainers.asm:230) (#1512) + opts.trainer.winText, opts.trainer.lossText = self:trainerWinLossText() elseif wild and wild.species then local id, def = speciesByIndex(data and data.pokemon, wild.species) -- InitEnemyMon `.NotRoaming` / BATTLETYPE.FORCESHINY: the DV pair is @@ -9194,8 +9226,10 @@ function World:stepContext() local def = self.map and self.map.def return { data = self.game and self.game.data, + -- CheckTime reads wTimeOfDay (engine/events/checktime.asm:2), so the + -- caller windows follow the clock even inside a pinned-palette room. phone = { - map = def, maps = self.maps, daytime = self.daytime, + map = def, maps = self.maps, daytime = self.tod, clock = self.game and self.game.clock, }, -- GetMapPhoneService: zero means the map HAS service, which maps.lua has diff --git a/tests/drivers/evolution_flip_bug1412_test.lua b/tests/drivers/evolution_flip_bug1412_test.lua index 7d49a6cd..dfa978c4 100644 --- a/tests/drivers/evolution_flip_bug1412_test.lua +++ b/tests/drivers/evolution_flip_bug1412_test.lua @@ -23,8 +23,13 @@ return function(game) game.save.party = { mon } -- engine/movie/evolution.asm:103 Evolution.evolve(game, mon, "RAICHU", nil, "ITEM") - U.wait(12) - local top = game.stack:top() + -- the IsEvolvingText box now holds 50+ frames before the movie (#1596) + local top + for _ = 1, 300 do + top = game.stack:top() + if top and top.screenId == "EvolutionState" then break end + U.wait(1) + end check("the evolution screen opened", top and top.screenId == "EvolutionState") U.shot(game, DIR .. "/bug1412_evo_old_pikachu.png") diff --git a/tests/drivers/evolution_true_color_bug494_test.lua b/tests/drivers/evolution_true_color_bug494_test.lua index 07ec5da7..7e9ca04d 100644 --- a/tests/drivers/evolution_true_color_bug494_test.lua +++ b/tests/drivers/evolution_true_color_bug494_test.lua @@ -39,9 +39,13 @@ return function(game) local mon = Pokemon.new(game.data, "PIKACHU", 20) game.save.party = { mon } Evolution.evolve(game, mon, "RAICHU") - U.wait(12) - - local top = game.stack:top() + -- the IsEvolvingText box now holds 50+ frames before the movie (#1596) + local top + for _ = 1, 300 do + top = game.stack:top() + if top and top.screenId == "EvolutionState" then break end + U.wait(1) + end check("the evolution screen opened", top and top.screenId == "EvolutionState") U.log("Issue #494: true-color sprites during evolutions and trades") U.log("Watch this PIKACHU evolve into RAICHU in ADVANCED colors.") diff --git a/tests/drivers/gold_bug1479_probe.lua b/tests/drivers/gold_bug1479_probe.lua new file mode 100644 index 00000000..2b178d01 --- /dev/null +++ b/tests/drivers/gold_bug1479_probe.lua @@ -0,0 +1,46 @@ +-- #1479: TILESET_KANTO roof probe (LoadMapGroupRoof, home/map.asm:1738-1749) +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_bug1479_probe.lua love . + +local U = require("tests.drivers.util") + +local SPOTS = { + { "ROUTE_28", 6, 6 }, + { "SILVER_CAVE_OUTSIDE", 10, 20 }, + { "SILVER_CAVE_OUTSIDE", 10, 8 }, + { "ROUTE_22", 10, 8 }, + { "VIRIDIAN_CITY", 10, 10 }, +} + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-kanto" + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + local failed = false + for i, spot in ipairs(SPOTS) do + local def = world.maps[spot[1]] + if not def then + U.log("FAIL no map", spot[1]) + failed = true + else + world:setMap(spot[1], spot[2], spot[3], "down") + U.wait(10) + U.shot(game, ("%s/%02d-%s.png"):format(out, i, spot[1]:lower())) + -- home/map.asm:1738-1749: a Kanto-tileset map takes no map-group roof, + -- so its atlas is cached under the bare tileset name. + if def.tileset == "TILESET_KANTO" then + for key in pairs(world.atlasCache or {}) do + if key:find("TILESET_KANTO|", 1, true) then + U.log("FAIL Kanto atlas took a roof:", key) + failed = true + end + end + end + U.log("shot", spot[1], def.tileset) + end + end + + U.log(failed and "RESULT FAIL" or "RESULT PASS", "shots in", out) + love.event.quit(failed and 1 or 0) +end diff --git a/tests/drivers/gold_bug1569_test.lua b/tests/drivers/gold_bug1569_test.lua new file mode 100644 index 00000000..e3625988 --- /dev/null +++ b/tests/drivers/gold_bug1569_test.lua @@ -0,0 +1,70 @@ +-- #1569 givepoke names (scripting.asm:1817, move_mon.asm:1698-1736) +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_bug1569_test.lua love . + +local U = require("tests.drivers.util") + +local function findGivepoke(scripts) + for key, rows in pairs(scripts) do + if type(rows) == "table" then + for _, row in ipairs(rows) do + if row.op == "givepoke" and (row.trainer or 0) ~= 0 then + return key, row + end + end + end + end + return nil +end + +return function(game) + U.wait(45) + local world = game.world + assert(world and world.vm, "gold world did not boot") + + local failed = false + local key, row = findGivepoke(world.scripts or {}) + if not row then + U.log("FAIL no trainer-form givepoke in the extracted scripts") + failed = true + else + U.log("givepoke row from", tostring(key)) + if row.name ~= "KENYA" then + U.log("FAIL nickname is", tostring(row.name), "want KENYA") + failed = true + end + if row.otName ~= "RANDY" then + U.log("FAIL OT name is", tostring(row.otName), "want RANDY") + failed = true + end + end + + game.save.party = {} + local give = world.vm.givePokeFn + assert(give, "the VM has no givePoke hook") + local mon = give(row and row.species or 21, row and row.level or 10, 0, + { nickname = "KENYA", otName = "RANDY" }) + if not mon then + U.log("FAIL givePoke made no mon") + failed = true + else + if mon.nickname ~= "KENYA" then + U.log("FAIL mon nickname is", tostring(mon.nickname)) + failed = true + end + if mon.otName ~= "RANDY" or mon.ot ~= "RANDY" then + U.log("FAIL mon OT is", tostring(mon.ot), tostring(mon.otName)) + failed = true + end + if mon.otId ~= 1001 then + U.log("FAIL mon OT id is", tostring(mon.otId), "want RANDY_OT_ID 1001") + failed = true + end + if mon.species ~= "SPEAROW" then + U.log("FAIL species is", tostring(mon.species)) + failed = true + end + end + + U.log(failed and "RESULT FAIL" or "RESULT PASS") + love.event.quit(failed and 1 or 0) +end diff --git a/tests/drivers/gym_leader_victory_test.lua b/tests/drivers/gym_leader_victory_test.lua index 59a2ef33..075adb8e 100644 --- a/tests/drivers/gym_leader_victory_test.lua +++ b/tests/drivers/gym_leader_victory_test.lua @@ -50,7 +50,8 @@ return function(game) U.wait(5) local ow = game.stack:top() U.shot(game, DIR .. "/" .. shots.prefix .. "_0_gym.png") - ow:checkVictoryRewards(class, party) + -- true: the badge line rode the battle screen on the real path (#1606) + ow:checkVictoryRewards(class, party, true) U.wait(10) for _, s in ipairs(shots.pages) do advancePages(s.want, shots.prefix .. "_" .. s.name) @@ -63,26 +64,64 @@ return function(game) game.save.player.name = game.save.player.name or "RED" - runLeader("PEWTER_GYM", 4, 3, "OPP_BROCK", 1, { - prefix = "brock", - pages = { - { want = "BOULDERBADGE", name = "1_badge" }, - { want = "FLASH", name = "2_flash" }, - { want = "Wait!", name = "3_wait" }, - { want = "TM34", name = "4_tm34" }, - { want = "BIDE", name = "5_bide" }, - }, - }) + -- The REAL gym path (#1606): the badge line and jingle must ride the + -- battle it pushes (scripts/PewterGym.asm:117-119). + while game.stack:top() do game.stack:pop() end + local Pokemon = require("src.pokemon.Pokemon") + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.save.flags = game.save.flags or {} + game.save.inventory = game.save.inventory or {} + game.save.defeatedTrainers = game.save.defeatedTrainers or {} + game.stack:push(OW, "PEWTER_GYM", 4, 3, "up") + U.wait(5) + local realOw = game.stack:top() + local brock + for _, npc in ipairs(realOw.npcs or {}) do + if npc.def and npc.def.trainerClass == "OPP_BROCK" then brock = npc end + end + assert(brock, "Brock stands in PEWTER_GYM") + require("data.scripts.gyms").PEWTER_GYM.talk.TEXT_PEWTERGYM_BROCK( + game, realOw, brock, function() end) + local battle + for _ = 1, 600 do + local top = game.stack:top() + if top and top.oppClass == "OPP_BROCK" and top.onFinish then + battle = top + break + end + U.tap(game, "a") + U.wait(2) + end + assert(battle, "the real gym path reaches Brock's BattleState") + assert(type(battle.endBattleText) == "string" and #battle.endBattleText > 0, + "the battle carries the armed badge line (#1606)") + assert(battle.endBattleSound == "Get_Item1", + "and the badge jingle beside it (sound_level_up, PewterGym.asm)") + U.shot(game, DIR .. "/brock_real_battle.png") + U.log("force-finishing Brock's battle to run the reward chain") + battle.onFinish("win") + if game.stack:top() == battle then game.stack:pop() end + U.wait(10) + -- rewardDialogueShown: the badge line rode the battle screen, so the map + -- chain opens on the TM prelude, not on a reprint of the badge line + for _, s in ipairs({ + { want = "Wait!", name = "1_wait" }, + { want = "TM34", name = "2_tm34" }, + { want = "BIDE", name = "3_bide" }, + }) do + advancePages(s.want, "brock_" .. s.name) + end assert(game.save.flags.EVENT_BEAT_BROCK, "EVENT_BEAT_BROCK") assert(game.save.inventory.BOULDERBADGE, "BOULDERBADGE") assert((game.save.inventory.TM_BIDE or 0) >= 1, "TM_BIDE") + -- checkVictoryRewards direct drive, as after a battle whose badge line + -- rode the battle screen (shownOnBattleScreen = true) runLeader("CERULEAN_GYM", 5, 5, "OPP_MISTY", 1, { prefix = "misty", pages = { - { want = "CASCADEBADGE", name = "1_badge" }, - { want = "CUT", name = "2_cut" }, - { want = "TM11", name = "3_tm11" }, + { want = "CUT", name = "1_cut" }, + { want = "TM11", name = "2_tm11" }, }, }) assert(game.save.flags.EVENT_BEAT_MISTY, "EVENT_BEAT_MISTY") diff --git a/tests/engine/bag_item_box_bug1521.lua b/tests/engine/bag_item_box_bug1521.lua new file mode 100644 index 00000000..1e7afecc --- /dev/null +++ b/tests/engine/bag_item_box_bug1521.lua @@ -0,0 +1,121 @@ +-- The bag's item list is LIST_MENU_BOX (#1521): home/list_menu.asm:29-31, +-- :51-52, :364-365, :471-479, :518-521, data/text_boxes.asm:13 + +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") + +-- Font wants a real atlas; the geometry is what this suite is about, so it +-- records the calls instead. ListMenu and Theme bind Font at require time. +local realFont = package.loaded["src.render.Font"] +local calls = {} +package.loaded["src.render.Font"] = { + BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 }, + draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end, + drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end, + drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end, + width = function(text) return #tostring(text) * 8 end, +} +package.loaded["src.ui.ListMenu"] = nil +package.loaded["src.ui.Theme"] = nil +local ListMenu = require("src.ui.ListMenu") +local Theme = require("src.ui.Theme") + +local function found(kind, pred) + for _, c in ipairs(calls) do + if c[1] == kind and pred(c) then return c end + end + return nil +end + +local function newList(count) + local items = {} + for i = 1, count do + items[i] = { value = "ITEM_" .. i, label = "ITEM " .. i, right = "x" .. i } + end + return ListMenu.new({}, "ITEMS", items, { kind = "bag", itemBox = true }) +end + +do + local list = newList(6) + eq(list.rows, 4, "PrintListMenuEntries prints 4 names, not 7") + eq(list.isOpaque, false, + "the box is partial, so the map keeps drawing behind it") + + calls = {} + list:draw() + + local box = found("box", function(c) return true end) + if check(box ~= nil, "the list draws LIST_MENU_BOX") then + eq(box[2], 4, "upper-left X 4") + eq(box[3], 2, "upper-left Y 2") + eq(box[4], 16, "through lower-right X 19") + eq(box[5], 11, "through lower-right Y 12") + end + + -- names at hlcoord 6, 4 and every two rows after it + for row = 1, 4 do + local y = 32 + (row - 1) * 16 + check(found("draw", function(c) + return c[2] == "ITEM " .. row and c[3] == 48 and c[4] == y + end) ~= nil, "name " .. row .. " sits at (48, " .. y .. ")") + end + check(found("draw", function(c) return c[2] == "ITEM 5" end) == nil, + "the fifth name is scrolled out, not printed below the box") + + -- the quantity: '×' at column 14, the count right-aligned after it + check(found("draw", function(c) + return c[2] == "x" and c[3] == 112 and c[4] == 40 + end) ~= nil, "the first quantity's '×' is a row down at column 14") + check(found("draw", function(c) + return c[2] == "1" and c[3] == 128 and c[4] == 40 + end) ~= nil, "with the count right-aligned in the two columns after it") + + check(found("code", function(c) + return c[2] == Theme.cursor and c[3] == 40 and c[4] == 32 + end) ~= nil, "the cursor is in column 5 (wTopMenuItemX)") + check(found("code", function(c) + return c[2] == Theme.moreArrow and c[3] == 144 and c[4] == 88 + end) ~= nil, "a full page ends with the '▼'") + + -- nothing else: no title, no money footer (wPrintItemPrices = 0) + check(found("draw", function(c) return tostring(c[2]):find("¥") end) == nil, + "the everyday bag has no money box (that is the mart's screen)") + check(found("draw", function(c) return c[2] == "ITEMS" end) == nil, + "and no title row: the box carries no header text") +end + +-- the box keeps the palette beneath and caps the cursor at wMaxMenuItem +do + local list = newList(6) + eq(list.sgbPalettes, false, + "no SET_PAL_GENERIC: ItemMenuLoop keeps RunDefaultPaletteCommand's " + .. "palette (start_sub_menus.asm:300)") + eq(list.cursorRows, 3, + "wMaxMenuItem 2: three cursor rows (home/list_menu.asm:46-48)") + list.game = { input = { wasPressed = function(_, b) return b == "down" end, + isDown = function() return false end } } + for _ = 1, 3 do list:update(1 / 60) end + eq(list.index, 4, "three downs reach the fourth item") + eq(list.index - list.scroll, 3, + "scrolling instead of dropping the cursor onto the look-ahead row") +end + +-- a short list stops at its last name, and the terminator's CANCEL row is +-- what would follow -- never the '▼' +do + local list = newList(2) + calls = {} + list:draw() + check(found("code", function(c) return c[2] == Theme.moreArrow end) == nil, + "a page that runs out of names prints no '▼' (:372)") +end + +package.loaded["src.render.Font"] = realFont +package.loaded["src.ui.ListMenu"] = nil +package.loaded["src.ui.Theme"] = nil +require("src.ui.Screens").invalidate() + +T.finish() diff --git a/tests/engine/battle_retreat_switch_bug1563.lua b/tests/engine/battle_retreat_switch_bug1563.lua new file mode 100644 index 00000000..dc2af372 --- /dev/null +++ b/tests/engine/battle_retreat_switch_bug1563.lua @@ -0,0 +1,177 @@ +-- SwitchPlayerMon (core.asm:2419-2423), AnimateRetreatingPlayerMon (:1769-1796) +-- (#1563); pokeyellow core.asm:1862-1866 (#1545); core.asm:1471-1488 (#1608) +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local function newBattle() + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 40), Pokemon.new(Data, "FIXMON_A", 40) } + local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } + local battle = BattleState.newWild(game, "FIXMON_C", 40) + battle.rng = function(a) if a then return a end return 0 end + return battle +end + +-- --------------------------------------------------------------------- +-- the shrink stages and their frame budget +-- --------------------------------------------------------------------- +do + local battle = newBattle() + battle.queue, battle.nextInsert = {}, 0 + battle:queueRetreatAnim() + T.eq(#battle.queue, 2, "the retreat queues its start act plus the hold") + T.eq(battle.queue[2].wait, 7, "4 frames at 5x5 then Delay3 at 3x3") + T.check(battle:shrinkOutScale(battle.player) == nil, "nothing shrinks yet") + battle.queue[1].fn() + T.eq(battle.shrinkOut.battler, battle.player, "the outgoing pic is the one drawn") + T.eq(battle:shrinkOutScale(battle.player), 5 / 7, "wDownscaledMonSize 0 -> 5x5") + battle.shrinkOut.frame = 3 + T.eq(battle:shrinkOutScale(battle.player), 5 / 7, "for four frames") + battle.shrinkOut.frame = 4 + T.eq(battle:shrinkOutScale(battle.player), 3 / 7, "wDownscaledMonSize 1 -> 3x3") + battle.shrinkOut.frame = 6 + T.eq(battle:shrinkOutScale(battle.player), 3 / 7, "through Delay3") + battle.shrinkOut.frame = 7 + T.eq(battle:shrinkOutScale(battle.player), 0, + "then the 7x7 area holds cleared: no full-size flash before the swap") + T.check(battle:shrinkOutScale({}) == nil, + "the swapped-in battler draws normally") + T.check(battle:shrinkOutScale(battle.enemy) == nil, + "AnimateRetreatingPlayerMon is player-side only") +end + +-- --------------------------------------------------------------------- +-- the Yellow starter slides off instead of shrinking +-- --------------------------------------------------------------------- +do + local battle = newBattle() + battle.starterPikachuSendOut = function() return true end + battle.queue, battle.nextInsert = {}, 0 + battle:queueRetreatAnim() + T.eq(#battle.queue, 3, "start act, the slide's hold, then the slot clear") + battle.queue[1].fn() + local slide = battle.picOff and battle.picOff.playerMon + T.check(slide ~= nil, "the back pic slot is sliding") + T.eq(slide.to, -64, "8 tiles off the left edge") + T.eq(slide.hold, 3, "wSlideMonDelay 3 V-blanks per tile") + T.eq(battle.queue[2].wait, 24, "8 tiles x 3 frames") + T.check(battle:shrinkOutScale(battle.player) == nil, "and no downscale stage") + battle.queue[3].fn() + T.check((battle.picOff or {}).playerMon == nil, "the slot clears afterwards") + T.eq(battle.sendingOut, true, + "and stays hidden until the swap's send-out (#1545)") +end + +-- --------------------------------------------------------------------- +-- resolveSwitch runs the retreat between the withdraw text and the swap +-- --------------------------------------------------------------------- +do + local battle = newBattle() + battle.queue, battle.nextInsert = {}, 0 + local outgoing = battle.player.mon + battle:resolveSwitch(battle.game.save.party[2]) + local withdraw = table.remove(battle.queue, 1) + battle.nextInsert = 0 + withdraw.fn() + T.check(battle.queue[1] and battle.queue[1].text ~= nil, "RetreatMon text first") + T.check(battle.queue[2] and battle.queue[2].fn ~= nil, "then the retreat start") + T.eq(battle.queue[3] and battle.queue[3].wait, 7, "then its hold") + T.eq(battle.player.mon, outgoing, "the party slot has not been swapped yet") +end + +-- --------------------------------------------------------------------- +-- a fainted pick reopens the party list (#1608) +-- --------------------------------------------------------------------- +do + local battle = newBattle() + battle.queue, battle.nextInsert = {}, 0 + battle.buildScreen = function(_, _, opts) return opts end + battle:openParty() + local opts = battle.queue[1].ui() + battle.queue, battle.nextInsert = {}, 0 + battle.game.save.party[2].hp = 0 + opts.onSwitch(battle.game.save.party[2]) + T.check(battle.queue[1] and battle.queue[1].text + and battle.queue[1].text:find("no will", 1, true) ~= nil, + "HasMonFainted prints NoWillText") + T.check(battle.queue[2] and battle.queue[2].fn ~= nil, "and queues the reprompt") + battle.queue[2].fn() + T.check(battle.queue[3] and battle.queue[3].ui ~= nil, + "GoBackToPartyMenu puts the list back up") + + battle.queue, battle.nextInsert = {}, 0 + opts.onSwitch(battle.player.mon) + T.check(battle.queue[1] and battle.queue[1].text + and battle.queue[1].text:find("already out", 1, true) ~= nil, + "AlreadyOutText for the mon that is already out") + T.check(battle.queue[2] and battle.queue[2].fn ~= nil, "which also reprompts") +end + +-- --------------------------------------------------------------------- +-- the SHIFT prompt's picker reprompts on a dead or already-out pick too +-- (HasMonFainted's NoWillText, core.asm:1473-1488) (#1608) +-- --------------------------------------------------------------------- +do + local save = SaveData.newGame() + save.player.name = "RED" + save.party = { Pokemon.new(Data, "FIXMON_A", 30), + Pokemon.new(Data, "FIXMON_B", 30) } + save.options = { battleStyle = "shift" } + local game = { data = Data, save = save, + stack = { top = function() return nil end, + push = function() end, pop = function() end } } + local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + battle.participants = {} + battle.buildScreen = function(_, _, opts) return opts end + -- capture the picker opts the SHIFT branch pushes + local captured + Data.screens = Data.screens or {} + Data.screens.PartyMenu = function(_, opts) captured = opts; return {} end + require("src.ui.Screens").invalidate() + battle.enemyParty[1].hp = 0 + battle.enemy.mon = battle.enemyParty[1] + battle:enemyMonFainted() + local choiceRow + for _, row in ipairs(battle.queue) do + if row.choice then choiceRow = row end + end + T.check(choiceRow ~= nil, "SHIFT queues the change-POKeMON choice") + choiceRow.choice(true) + T.check(captured ~= nil and captured.forceSwitch == true, + "YES opens the forced party picker") + + battle.queue, battle.nextInsert = {}, 0 + save.party[2].hp = 0 + captured.onSwitch(save.party[2]) + T.check(battle.queue[1] and battle.queue[1].text + and battle.queue[1].text:find("no will", 1, true) ~= nil, + "a fainted SHIFT pick prints NoWillText first") + T.check(battle.queue[2] and battle.queue[2].ui ~= nil, + "then the picker goes straight back up, ahead of the send-out") + T.eq(battle.queue[2].ui(), captured, "with the same forced opts") + + battle.queue, battle.nextInsert = {}, 0 + captured.onSwitch(battle.player.mon) + T.check(battle.queue[1] and battle.queue[1].text + and battle.queue[1].text:find("already out", 1, true) ~= nil, + "an already-out SHIFT pick prints AlreadyOutText") + T.check(battle.queue[2] and battle.queue[2].ui ~= nil, "and reprompts too") + + battle.queue, battle.nextInsert = {}, 0 + save.party[2].hp = 10 + captured.onSwitch(save.party[2]) + T.eq(#battle.queue, 0, "a healthy pick queues no reprompt rows") + Data.screens.PartyMenu = nil + require("src.ui.Screens").invalidate() +end + +T.finish("retreat animation and switch reprompt (#1563, #1545, #1608)") diff --git a/tests/engine/confusion_selfhit_anim_bug1578.lua b/tests/engine/confusion_selfhit_anim_bug1578.lua new file mode 100644 index 00000000..5a3e1c21 --- /dev/null +++ b/tests/engine/confusion_selfhit_anim_bug1578.lua @@ -0,0 +1,64 @@ +-- HandleSelfConfusionDamage (engine/battle/core.asm:3672-3714, enemy side +-- :5806-5811) (#1578) +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local function newBattle() + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 40) } + local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } + local battle = BattleState.newWild(game, "FIXMON_C", 40) + -- cp 50 percent + 1: rand < 128 hurts the user + battle.rng = function() return 0 end + return battle +end + +local function rowsOf(battle) + local out = {} + for _, item in ipairs(battle.queue) do + if item.anim then out[#out + 1] = item end + end + return out +end + +do + local battle = newBattle() + battle.queue, battle.nextInsert = {}, 0 + battle.player.confusedTurns = 3 + T.eq(battle:statusInterrupt(battle.player, battle.enemy, nil), true, + "the self-hit interrupts the player's action") + local anims = rowsOf(battle) + T.eq(#anims, 2, "the IsConfusedText onomatopoeia, then the self-hit's own") + T.eq(anims[1].anim, "CONF_PLAYER_ANIM", "CONF_PLAYER_ANIM rides IsConfusedText") + T.eq(anims[2].anim, "POUND", "wAnimationID 1 is POUND") + T.eq(anims[2].attackerIsPlayer, false, + "hWhoseTurn is flipped to the opponent, so it plays against the player") + T.check(anims[2].hit == nil, "wAnimationType 0 adds no shake or blink layer") + local text, anim + for i, item in ipairs(battle.queue) do + if item.text and item.text:find("confusion", 1, true) then text = text or i end + if item.anim == "POUND" then anim = i end + end + T.check(text and anim and text < anim, "HurtItselfText prints before the animation") +end + +do + local battle = newBattle() + battle.queue, battle.nextInsert = {}, 0 + battle.enemy.confusedTurns = 3 + battle:statusInterrupt(battle.enemy, battle.player, nil) + local anims = rowsOf(battle) + T.eq(anims[#anims].anim, "POUND", "the enemy side animates too") + T.eq(anims[#anims].attackerIsPlayer, true, "from the player's side of hWhoseTurn") +end + +T.finish("confusion self-hit animation (#1578)") diff --git a/tests/engine/evo_stone_cancel_bug883_test.lua b/tests/engine/evo_stone_cancel_bug883_test.lua index 6a04f155..66cd8735 100644 --- a/tests/engine/evo_stone_cancel_bug883_test.lua +++ b/tests/engine/evo_stone_cancel_bug883_test.lua @@ -30,7 +30,9 @@ package.loaded["src.core.Sound"] = { playCry = function() end, } package.loaded["src.render.TextBox"] = { - new = function(_, text, done) return { textBox = true, text = text, done = done } end, + new = function(_, text, done, opts) + return { textBox = true, text = text, done = done, opts = opts } + end, } -- BagMenu and PartyMenu bind TextBox at require time, so they load against the -- stub; Screens caches its factory per id and must be told to forget. @@ -126,6 +128,21 @@ local function useStone(game) game.input.pressed = "a" picker:update(1 / 60) game.input.pressed = nil + -- IsEvolvingText STAYS up; its onShown starts the DelayFrames 50 hold, + -- which then pushes the movie over it (evos_moves.asm:120-134) (#1596) + local intro = game.stack:top() + if not (intro and intro.textBox and intro.opts and intro.opts.stay) then + return nil, "the \"is evolving!\" box never opened" + end + if not tostring(intro.text):find("evolving") then + return nil, "the box before the movie is not _IsEvolvingText" + end + intro.opts.stay.onShown() + local hold = game.stack:top() + for _ = 1, 60 do + if pushed then break end + if hold.update then hold.update() end + end return list end diff --git a/tests/engine/evolution_dialogue_bug1596_test.lua b/tests/engine/evolution_dialogue_bug1596_test.lua new file mode 100644 index 00000000..cf6986ab --- /dev/null +++ b/tests/engine/evolution_dialogue_bug1596_test.lua @@ -0,0 +1,133 @@ +-- The evolution dialogue is the cart's, in the cart's order (#1596): +-- engine/pokemon/evos_moves.asm:120-134 (the clear is rows 0-11 only), +-- :136-150, :151-153 + +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 played = {} +package.loaded["src.core.Sound"] = { + play = function(_, id) played[#played + 1] = id end, + playCry = function() end, +} + +local Fixtures = require("tests.modkit.fixtures") +local Evolution = require("src.pokemon.Evolution") +local EvolutionState = require("src.ui.EvolutionState") +local Input = require("src.core.Input") +local Pokemon = require("src.pokemon.Pokemon") +local StateStack = require("src.core.StateStack") +local TextBox = require("src.render.TextBox") + +local Data = Fixtures.fresh() +require("src.render.Font").load(Data) + +local EVO_LEVEL = 16 + +local function newGame() + local game = { data = Data } + local mon = Pokemon.new(Data, "FIXMON_A", EVO_LEVEL) + game.save = { + party = { mon }, + player = { name = "RED", id = 1 }, + options = { textSpeed = 5 }, + flags = {}, + pokedex = { seen = {}, owned = {} }, + } + game.stack = setmetatable({}, { __index = StateStack }) + game.stack:init() + game.input = Input + Input:init() + return game, mon +end + +local function step(game) + game.input:step() + game.stack:update(1 / 60) +end + +local function textOf(box) + local out = {} + for _, page in ipairs(box.pages) do + for _, line in ipairs(page) do out[#out + 1] = line end + end + return table.concat(out, " ") +end + +-- The box that goes up before the movie, and the frames it holds for. +do + local game, mon = newGame() + Evolution.evolve(game, mon, "FIXMON_B", nil, "LEVEL") + local intro = game.stack:top() + if check(getmetatable(intro) == TextBox, + "_IsEvolvingText goes up in a real bordered text box first") then + check(textOf(intro):find("is evolving"), + "and it is the cart's line: " .. textOf(intro)) + check(intro.stay ~= nil and not intro.stay.prompt, + "which waits for no button (IsEvolvingText ends in `done`) " + .. "and stays up under whatever follows") + end + -- it hands off to the movie on its own, with no input at all + local top + for _ = 1, 900 do + top = game.stack:top() + if getmetatable(top) == EvolutionState then break end + step(game) + end + check(getmetatable(top) == EvolutionState, + "the flash movie opens once the DelayFrames 50 hold has passed") + local underneath = false + for _, s in ipairs(game.stack.states or {}) do + if s == intro then underneath = true end + end + check(underneath, "the 'is evolving!' box is still on the stack under the " + .. "flash (ClearScreenArea wipes rows 0-11 only, evos_moves.asm:126-128)") + check(not EvolutionState.isOpaque, + "and the flash screen is not opaque, so the box beneath draws") + eq(mon.species, "FIXMON_A", + "and nothing has evolved yet while the box was up") +end + +-- What closes the movie: EvolvedText + IntoText, and the jingle. +do + local game, mon = newGame() + Evolution.evolve(game, mon, "FIXMON_B", nil, "LEVEL") + local evo + for _ = 1, 900 do + evo = game.stack:top() + if getmetatable(evo) == EvolutionState then break end + step(game) + end + assert(getmetatable(evo) == EvolutionState, "the movie never opened") + played = {} + for _ = 1, 600 do + if evo.done then break end + step(game) + end + eq(mon.species, "FIXMON_B", "the mon evolved") + local box = game.stack:top() + if check(getmetatable(box) == TextBox, "and the result text is a text box") then + local said = textOf(box) + check(said:find("evolved") and said:find("into"), + "_EvolvedText + _IntoText print together: " .. said) + check(not said:find("Congratulations"), + "no fabricated \"Congratulations!\" line (it is in no ROM)") + check(box.auto ~= nil and box.auto.sound ~= nil, + "and the box carries a jingle the way sound_get_item_1 boxes do") + -- type it out; auto.sound fires once the last page has landed + for _ = 1, 900 do + if box.autoStarted then break end + step(game) + end + local heard = false + for _, id in ipairs(played) do + if id == "Get_Item2" then heard = true end + end + check(heard, "SFX_GET_ITEM_2 plays on that box (evos_moves.asm:151)") + end +end + +T.finish() diff --git a/tests/engine/evolution_hold_b_bug968_test.lua b/tests/engine/evolution_hold_b_bug968_test.lua index 78174bce..fc4c5502 100644 --- a/tests/engine/evolution_hold_b_bug968_test.lua +++ b/tests/engine/evolution_hold_b_bug968_test.lua @@ -52,6 +52,14 @@ local function step(game) game.stack:update(1 / 60) end +local function textOf(box) + local out = {} + for _, page in ipairs(box.pages) do + for _, line in ipairs(page) do out[#out + 1] = line end + end + return table.concat(out, " ") +end + -- the post-battle sequence: grew-to-level box, then Evolution.checkParty local function levelUpBox(game, mon) game.stack:push(TextBox.new(game, "FIXMON A grew\nto level 16!", @@ -69,21 +77,27 @@ local function dismissWithB(game, mon) if not box.done then return nil, "the level-up text never finished typing" end Input:keypressed(B_KEY) step(game) - local top = game.stack:top() + -- IsEvolvingText holds its own box for DelayFrames 50 before EvolveMon + -- runs (engine/pokemon/evos_moves.asm:120-134) + local intro = game.stack:top() + if getmetatable(intro) ~= TextBox then + return nil, "the \"is evolving!\" box never opened" + end + if not textOf(intro):find("is evolving") then + return nil, "the box before the movie is not _IsEvolvingText" + end + local top + for _ = 1, 900 do + top = game.stack:top() + if getmetatable(top) == EvolutionState then break end + step(game) + end if getmetatable(top) ~= EvolutionState then return nil, "the evolution screen never opened" end return top end -local function textOf(box) - local out = {} - for _, page in ipairs(box.pages) do - for _, line in ipairs(page) do out[#out + 1] = line end - end - return table.concat(out, " ") -end - -- B held out of the text box: the movie must run to the end and evolve. do local game, mon = newGame() diff --git a/tests/engine/gen2_dex_mode_persist_bug1474.lua b/tests/engine/gen2_dex_mode_persist_bug1474.lua new file mode 100644 index 00000000..3b89a858 --- /dev/null +++ b/tests/engine/gen2_dex_mode_persist_bug1474.lua @@ -0,0 +1,51 @@ +-- wLastDexMode (engine/pokedex/pokedex.asm:60, :97) (#1474) +-- luajit tests/engine/gen2_dex_mode_persist_bug1474.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 PokedexMenu = require("src.ui.gen2.PokedexMenu") + +local save = { position = { map = "ROUTE_30" } } +local game = { data = {}, save = save } + +local first = PokedexMenu.new(game, {}) +eq(first.modeIndex, 1, "a save with no remembered mode opens in NEW") + +-- what the OPTION screen's .ChangeMode leaves behind +first.modeIndex = 3 +first:close() +eq(save.lastDexMode, "A-Z", "closing the dex writes the live mode into the save") + +local second = PokedexMenu.new(game, {}) +eq(second.modeIndex, 3, "reopening the dex restores the remembered mode") + +second.modeIndex = 2 +second:close() +eq(PokedexMenu.new(game, {}).modeIndex, 2, "OLD survives the same way") + +local fresh = PokedexMenu.new({ data = {}, save = {} }, {}) +eq(fresh.modeIndex, 1, "a fresh save still starts on NEW") + +local closed = false +local menu = PokedexMenu.new(game, { onClose = function() closed = true end }) +menu:close() +check(closed, "close still runs the caller's onClose") + +-- Save.newGame seeds the key and Save.validate clamps a hand-edited value +local Save = require("src.core.gen2.Save") +eq(Save.newGame().lastDexMode, "NEW", + "a brand-new save carries the key from the start (wLastDexMode's zero)") +local edited = Save.newGame() +edited.lastDexMode = "SPICY" +Save.validate(edited) +eq(edited.lastDexMode, "NEW", "validate clamps an out-of-range mode to NEW") +local kept = Save.newGame() +kept.lastDexMode = "A-Z" +Save.validate(kept) +eq(kept.lastDexMode, "A-Z", "and keeps a legal one") + +T.finish("gen2 pokedex mode persistence bug 1474") diff --git a/tests/engine/gen2_fly_map_arrow_bug1477.lua b/tests/engine/gen2_fly_map_arrow_bug1477.lua new file mode 100644 index 00000000..ceae1666 --- /dev/null +++ b/tests/engine/gen2_fly_map_arrow_bug1477.lua @@ -0,0 +1,32 @@ +-- The Fly map draws no Pokegear mode arrow (engine/pokegear/pokegear.asm:1999) (#1477) +-- luajit tests/engine/gen2_fly_map_arrow_bug1477.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +love = love or require("tests.love_stub") + +local Pokegear = require("src.ui.gen2.Pokegear") + +local function drawnArrow(fly) + local drew = false + local self = setmetatable({ + fly = fly, + styled = function() return true end, + groundColor = function() return { 0, 0, 0 } end, + card = function() return { id = "map", label = fly and "FLY" or "MAP" } end, + drawMap = function() end, + drawModeArrow = function() drew = true end, + }, { __index = Pokegear }) + local realRect = love.graphics.rectangle + love.graphics.rectangle = function() end + self:drawPanel() + love.graphics.rectangle = realRect + return drew +end + +check(drawnArrow(false), "the Pokegear MAP card still animates the arrow") +check(not drawnArrow(true), "_FlyMap draws no mode-indicator arrow") + +T.finish("gen2 fly map arrow bug 1477") diff --git a/tests/engine/gen2_kanto_no_roof_bug1479.lua b/tests/engine/gen2_kanto_no_roof_bug1479.lua new file mode 100644 index 00000000..6a436c65 --- /dev/null +++ b/tests/engine/gen2_kanto_no_roof_bug1479.lua @@ -0,0 +1,61 @@ +-- #1479 / #1449: TILESET_KANTO takes no map-group roof (home/map.asm:1738-1749) +-- luajit tests/engine/gen2_kanto_no_roof_bug1479.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local MapPreview = require("src.world.gen2.MapPreview") + +local baker = { + tilesets = { + TILESET_KANTO = { image = "assets/generated/tilesets/kanto.png" }, + TILESET_JOHTO = { image = "assets/generated/tilesets/johto.png" }, + }, + roofs = { + mapGroupRoofs = { [19] = "ROOF_SILVER" }, + roofs = { ROOF_SILVER = {} }, + }, + atlasCache = {}, + mapImages = {}, +} + +-- data/maps/maps.asm:396: SilverCaveOutside and Route28 are both group 19 +-- (MapGroup_Silver) and both TILESET_KANTO. +MapPreview.atlasFor(baker, { tileset = "TILESET_KANTO", group = 19 }) +MapPreview.atlasFor(baker, { tileset = "TILESET_JOHTO", group = 19 }) + +local keys = {} +for key in pairs(baker.atlasCache) do keys[key] = true end + +T.check(keys["TILESET_KANTO"], + "the Kanto atlas is cached under the bare tileset, with no roof") +T.check(not keys["TILESET_KANTO|ROOF_SILVER"], + "and never under a map-group roof") +T.check(keys["TILESET_JOHTO|ROOF_SILVER"], + "while TILESET_JOHTO still takes its group's roof") + +-- World:atlasFor is the copy the bug was filed against; it has its own +-- ROOF_TILESETS gate, so pin it separately from MapPreview's +local realAssets = package.loaded["src.render.Assets"] +package.loaded["src.render.Assets"] = setmetatable({ + image = function() return { setFilter = function() end } end, + resolve = function(path) return path end, +}, { __index = realAssets or { register = function() end } }) +package.loaded["src.world.gen2.World"] = nil +local World = require("src.world.gen2.World") +local world = setmetatable({ + tilesets = baker.tilesets, + roofs = baker.roofs, + atlasCache = {}, +}, { __index = World }) +world:atlasFor({ tileset = "TILESET_KANTO", group = 19 }) +world:atlasFor({ tileset = "TILESET_JOHTO", group = 19 }) +T.check(world.atlasCache["TILESET_KANTO"] ~= nil + and world.atlasCache["TILESET_KANTO|ROOF_SILVER"] == nil, + "World:atlasFor bakes Kanto with no map-group roof (home/map.asm:1738-1749)") +T.check(world.atlasCache["TILESET_JOHTO|ROOF_SILVER"] ~= nil, + "and still roofs TILESET_JOHTO by group") +package.loaded["src.render.Assets"] = realAssets +package.loaded["src.world.gen2.World"] = nil + +T.finish("gen2 Kanto roof gate (#1479)") diff --git a/tests/engine/gen2_stats_tiles_bug1558.lua b/tests/engine/gen2_stats_tiles_bug1558.lua new file mode 100644 index 00000000..de6fe03c --- /dev/null +++ b/tests/engine/gen2_stats_tiles_bug1558.lua @@ -0,0 +1,51 @@ +-- #1558: menu_gfx.stats -> $31..$41 quads (engine/gfx/load_font.asm:90-95); +-- PAGE_PALETTES is gfx/stats/pages.pal (cgb_layouts.asm:199-212) +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local SummaryMenu = require("src.ui.gen2.SummaryMenu") + +-- gfx/stats/pages.pal, RGB 5-bit rows in ROM order: pink, green, blue +local ROM_PALS = { + { { 31, 31, 31 }, { 31, 19, 31 }, { 31, 15, 31 }, { 0, 0, 0 } }, + { { 31, 31, 31 }, { 21, 31, 14 }, { 17, 31, 0 }, { 0, 0, 0 } }, + { { 31, 31, 31 }, { 17, 31, 31 }, { 17, 31, 31 }, { 0, 0, 0 } }, +} +local function up(v) return math.floor(v * 255 / 31 + 0.5) end +for p = 1, 3 do + for c = 1, 4 do + for ch = 1, 3 do + T.eq(SummaryMenu.PAGE_PALETTES[p][c][ch], up(ROM_PALS[p][c][ch]), + ("pages.pal palette %d color %d channel %d"):format(p, c, ch)) + end + end +end + +-- the quads: 17 tiles from $31, one 8x8 cell each off the 136x8 sheet +love.graphics = love.graphics or {} +local realNewQuad = love.graphics.newQuad +love.graphics.newQuad = function(x, y, w, h) + return { x = x, y = y, w = w, h = h } +end +local menu = setmetatable({ + menuGfx = { stats = { sheet = "stats", tiles = 17, firstTile = 0x31 } }, + picImage = function() + return { getDimensions = function() return 136, 8 end } + end, +}, { __index = SummaryMenu }) +local sheet = menu:statsTiles() +if T.check(sheet ~= nil, "menu_gfx.stats builds the tile sheet") then + for id = 0x31, 0x41 do + local q = sheet.quads[id] + T.check(q ~= nil and q.x == (id - 0x31) * 8 and q.w == 8 and q.h == 8, + ("tile $%02x maps to sheet cell %d"):format(id, id - 0x31)) + end + T.check(sheet.quads[0x42] == nil, "and exactly 17 tiles, no more") +end + +-- a cache built before menu_gfx.stats existed keeps the hand-drawn fallback +local old = setmetatable({ menuGfx = {} }, { __index = SummaryMenu }) +T.check(old:statsTiles() == nil, "no menu_gfx.stats falls back, not crashes") +love.graphics.newQuad = realNewQuad + +T.finish("gen2 stats tiles and page palettes (#1558)") diff --git a/tests/engine/gen2_win_loss_text_bug1512.lua b/tests/engine/gen2_win_loss_text_bug1512.lua new file mode 100644 index 00000000..27be15a7 --- /dev/null +++ b/tests/engine/gen2_win_loss_text_bug1512.lua @@ -0,0 +1,210 @@ +-- engine/battle/core.asm:2310-2323 (WinTrainerBattle), :2763-2782 (LostBattle), +-- home/trainers.asm:120 and :230 (PrintWinLossText) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local Battle = require("src.battle.gen2.Battle") +local Mon = require("src.battle.gen2.Mon") +local World = require("src.world.gen2.World") + +local LEVEL = 10 + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" }, +} + +local POKEMON = { + growthRates = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, + }, + RATTATA = { + id = "RATTATA", index = 19, name = "RATTATA", + baseStats = { hp = 30, attack = 56, defense = 35, speed = 72, + specialAttack = 25, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 51, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = { NORMAL = { id = "NORMAL", index = 0, + category = "physical" } }, matchups = {} }, + items = {}, +} + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +local function mon() + local m = Mon.new(DATA, "RATTATA", LEVEL, { dvs = perfect }) + m.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + return m +end + +local function newSave() + return { player = { name = "GOLD", money = 1000, id = 4242 }, + mom = { savedMoney = 0 }, party = {} } +end + +local function newBattle(trainer, battleType) + local save = newSave() + local battle = Battle.new({ data = DATA, party = { mon() }, + trainer = trainer, battleType = battleType, save = save, + random = function(n) return 99 % math.max(1, n or 1) end }) + return battle, save +end + +local function trainerRecord(extra) + local record = { name = "SAGE CHOW", baseMoney = 3, party = { mon() } } + for key, value in pairs(extra or {}) do record[key] = value end + return record +end + +local function kinds(events) + local out = {} + for i, event in ipairs(events or {}) do out[i] = event.kind end + return table.concat(out, ",") +end + +local function indexOf(events, kind) + for i, event in ipairs(events or {}) do + if event.kind == kind then return i, event end + end + return nil +end + +-- The win arm: defeated line, frontpic slide-in, the trainer's own line, money. +do + local battle = newBattle(trainerRecord({ winText = "Th-Thank you!" })) + battle.enemy.hp = 0 + T.check(battle:resolveFaints(), "the last enemy mon ends the battle") + local events = battle:takeEvents() + local defeated = indexOf(events, "faint") + local ret = indexOf(events, "trainer-return") + local win, winEvent = indexOf(events, "win-text") + local money = indexOf(events, "money") + T.check(ret and win and money, "all three win rows are queued: " .. + kinds(events)) + T.check(defeated < ret, "the last faint event precedes the pic's return") + T.check(ret < win, "the frontpic slides back in before the line") + T.check(win < money, "and PrintWinLossText runs before the payout") + T.eq(winEvent.text, "Th-Thank you!", "the struct's win text is printed") +end + +-- The pic comes back whether or not there is a line: the DEBUG_BATTLE_F skip +-- sits in front of PrintWinLossText alone. +do + local battle = newBattle(trainerRecord()) + battle.enemy.hp = 0 + battle:resolveFaints() + local events = battle:takeEvents() + T.check(indexOf(events, "trainer-return"), "the slide-in is unconditional") + T.eq(indexOf(events, "win-text"), nil, "with no line to print") +end + +-- A wild battle has no trainer and no line. +do + local save = newSave() + local wild = mon() + local battle = Battle.new({ data = DATA, party = { mon() }, wild = wild, + save = save, random = function(n) return 99 % math.max(1, n or 1) end }) + battle.enemy.hp = 0 + battle:resolveFaints() + local events = battle:takeEvents() + T.eq(indexOf(events, "trainer-return"), nil, "no frontpic to slide back in") + T.eq(indexOf(events, "win-text"), nil, "and nothing to print") +end + +-- The loss arm: only BATTLETYPE_CANLOSE reaches PrintWinLossText. +do + local battle = newBattle(trainerRecord({ winText = "Th-Thank you!", + lossText = "...Too weak..." }), Battle.BATTLETYPE_CANLOSE) + battle.player.hp = 0 + T.check(battle:resolveFaints(), "the whiteout ends the battle") + local events = battle:takeEvents() + local _, lossEvent = indexOf(events, "win-text") + T.check(lossEvent, "the loss line is printed: " .. kinds(events)) + T.eq(lossEvent.text, "...Too weak...", "wLossTextPointer, not the win one") +end + +do + local battle = newBattle(trainerRecord({ lossText = "...Too weak..." })) + battle.player.hp = 0 + battle:resolveFaints() + local events = battle:takeEvents() + T.eq(indexOf(events, "win-text"), nil, "an ordinary loss whites out instead") +end + +-- wWinTextPointer / wLossTextPointer: the map object's struct, or whatever +-- `winlosstext` overwrote the pair with. +do + local world = { text = { ["3:4000"] = "Th-Thank you!", + ["3:4100"] = "...Too weak...", ["3:4200"] = "Scripted win." } } + world.vm = { trainerObject = { winText = "3:4000", lossText = "3:4100" } } + local win, loss = World.trainerWinLossText(world) + T.eq(win, "Th-Thank you!", "the struct's win text is decoded") + T.eq(loss, "...Too weak...", "and its loss text with it") + + world.vm.winLossArmed = true + world.vm.winTextOverride = "3:4200" + win, loss = World.trainerWinLossText(world) + T.eq(win, "Scripted win.", "`winlosstext` overwrites the pointer") + -- winlosstext writes BOTH pointers; its 0 loss argument destroyed the + -- struct's value (engine/overworld/scripting.asm:651) + T.eq(loss, nil, "and a 0 loss argument zeroes the loss pointer with it") + + world.vm.trainerObject = nil + world.vm.winTextOverride = nil + world.vm.winLossArmed = nil + win, loss = World.trainerWinLossText(world) + T.eq(win, nil, "a battle with no trainer object has no line") + T.eq(loss, nil, "on either side") + + world.vm = nil + T.eq(World.trainerWinLossText(world), nil, "and neither has one with no VM") +end + +-- The screen side: the slide-in owns the frames the way SlideBattlePicOut +-- does, and the line pages like the map text it is. +local BattleState = require("src.ui.gen2.BattleState") + +local function newScreen(battle, queue, image) + return setmetatable({ + battle = battle, queue = queue, picHidden = {}, evolvable = {}, + phase = "resolving", messageTimer = 0, enemyTrainerImage = image, + }, { __index = BattleState }) +end + +do + local battle = newBattle(trainerRecord({ winText = "Th-Thank you!" })) + local screen = newScreen(battle, { { kind = "trainer-return" }, + { kind = "win-text", text = "Th-Thank you!" } }, {}) + screen:advanceQueue() + T.eq(screen.winSlide, 0, "the slide starts on the frame the event runs") + T.check(screen.winSliding, "and owns the screen while it runs") + T.check(screen.showEnemyTrainer, "the beaten trainer is back on the field") + T.eq(screen.picHidden.enemy, false, "in the box the fainted mon left empty") + T.eq(#screen.queue, 1, "the line is still waiting behind it") +end + +do + local battle = newBattle(trainerRecord()) + local screen = newScreen(battle, { { kind = "trainer-return" }, + { kind = "win-text", text = "Th-Thank you!\fReally." } }, nil) + screen:advanceQueue() + T.eq(screen.winSlide, nil, "no cached pic, no slide") + T.eq(screen.message, "Th-Thank you!", "the line runs straight away") + T.check(screen.messagePages, "with its `para` page held back") + T.check(screen:nextPage(), "which the queue waits for") + T.eq(screen.message, "Really.", "before the second page shows") +end + +T.finish("gen2 win loss text bug 1512") diff --git a/tests/engine/gym_leader_end_battle_text_bug1606.lua b/tests/engine/gym_leader_end_battle_text_bug1606.lua new file mode 100644 index 00000000..5be0ac84 --- /dev/null +++ b/tests/engine/gym_leader_end_battle_text_bug1606.lua @@ -0,0 +1,147 @@ +-- A gym leader's badge line prints from TrainerBattleVictory (#1606): +-- scripts/CeruleanGym.asm:111, PewterGym.asm:117, home/trainers.asm:341 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local gyms = require("data.scripts.gyms") +local victories = require("data.scripts.victories") +local OW = require("src.world.OverworldController") + +local function setUpvalue(fn, name, val) + local i = 1 + while true do + local n = debug.getupvalue(fn, i) + if not n then return false end + if n == name then debug.setupvalue(fn, i, val); return true end + i = i + 1 + end +end + +-- one page of text per label, so a joined chain is readable in a failure +local text = {} +for _, key in ipairs({ "OPP_BROCK#1", "OPP_MISTY#1", "OPP_LT_SURGE#1", + "OPP_ERIKA#1", "OPP_KOGA#1", "OPP_SABRINA#1", + "OPP_BLAINE#1", "OPP_GIOVANNI#3" }) do + for _, label in ipairs(victories[key].dialogue or {}) do + text[label] = "text:" .. label + end + for _, label in ipairs(victories[key].tmPre or {}) do + text[label] = "text:" .. label + end + for _, label in ipairs(victories[key].tmDialogue or {}) do + text[label] = "text:" .. label + end +end + +local fakeGame = { data = { text = text }, save = { flags = {} } } + +local armed, armedSound +local fakeOw = { + engageTrainer = function(_, _, _, endBattleText, _, endBattleSound) + armed, armedSound = endBattleText, endBattleSound + end, +} + +local function armedFor(mapId, textId, victoryKey) + armed, armedSound = nil, nil + gyms[mapId].talk[textId](fakeGame, fakeOw, { id = "npc#1" }, function() end) + local labels = victories[victoryKey].dialogue + local want = {} + for i, label in ipairs(labels) do want[i] = text[label] end + return armed, table.concat(want, "\f") +end + +-- every leader, in badge order +local leaders = { + { "PEWTER_GYM", "TEXT_PEWTERGYM_BROCK", "OPP_BROCK#1" }, + { "CERULEAN_GYM", "TEXT_CERULEANGYM_MISTY", "OPP_MISTY#1" }, + { "VERMILION_GYM", "TEXT_VERMILIONGYM_LT_SURGE", "OPP_LT_SURGE#1" }, + { "CELADON_GYM", "TEXT_CELADONGYM_ERIKA", "OPP_ERIKA#1" }, + { "FUCHSIA_GYM", "TEXT_FUCHSIAGYM_KOGA", "OPP_KOGA#1" }, + { "SAFFRON_GYM", "TEXT_SAFFRONGYM_SABRINA", "OPP_SABRINA#1" }, + { "CINNABAR_GYM", "TEXT_CINNABARGYM_BLAINE", "OPP_BLAINE#1" }, + { "VIRIDIAN_GYM", "TEXT_VIRIDIANGYM_GIOVANNI", "OPP_GIOVANNI#3" }, +} +for _, entry in ipairs(leaders) do + local got, want = armedFor(entry[1], entry[2], entry[3]) + T.eq(got, want, entry[3] .. " arms its badge line for the battle screen") + -- the dialogue's sound command rides the armed line onto the battle + -- screen too (sound_get_item_1 / sound_get_key_item) (#1606) + T.eq(armedSound, victories[entry[3]].badgeSound, + entry[3] .. " arms its badge jingle beside the line") +end + +-- Brock's armed label is one text chain of two text_far pages +-- (PewterGymBrockReceivedBoulderBadgeText), so both ride the battle screen +local brock = select(1, armedFor("PEWTER_GYM", "TEXT_PEWTERGYM_BROCK", + "OPP_BROCK#1")) +T.check(brock:find("\f", 1, true) ~= nil, + "Brock's badge line keeps its BoulderBadgeInfo page") + +-- the beaten branch still talks instead of re-engaging +armed = nil +fakeGame.save.flags.EVENT_BEAT_MISTY = true +fakeGame.save.flags.EVENT_GOT_TM11 = true +local realStack = { push = function() end } +gyms.CERULEAN_GYM.talk.TEXT_CERULEANGYM_MISTY( + { data = { text = text }, save = fakeGame.save, stack = realStack }, + fakeOw, { id = "npc#1" }, function() end) +T.eq(armed, nil, "a beaten leader does not re-arm the badge line") +fakeGame.save.flags.EVENT_BEAT_MISTY = nil +fakeGame.save.flags.EVENT_GOT_TM11 = nil + +-- checkVictoryRewards must not reprint what the battle screen showed +local boxes +local textBoxStub = { + new = function(_, str, onDone) + boxes[#boxes + 1] = str + return { onDone = onDone } + end, + soundOpts = function() return nil end, +} +local pushed +local rewardGame = { + data = { text = text, items = { TM_BUBBLEBEAM = { name = "TM11" } } }, + save = { flags = {}, inventory = {}, player = { name = "RED" } }, + stack = { push = function(_, box) pushed[#pushed + 1] = box end }, +} +T.check(setUpvalue(OW.checkVictoryRewards, "Game", rewardGame), + "Game upvalue on checkVictoryRewards") +-- TextBox is only named inside the rewardChain closure; the chunk-level +-- upvalue cell is shared, so any closure that names it will do +T.check(setUpvalue(OW.engageTrainer, "TextBox", textBoxStub), + "TextBox upvalue on the reward chain") + +local fakeSelf = setmetatable({ + map = { id = "CERULEAN_GYM", def = { label = "CeruleanGym" } }, + runVictoryHook = function() end, +}, { __index = OW }) + +local function rewardPages(shownOnBattleScreen) + boxes, pushed = {}, {} + rewardGame.save.flags = {} + rewardGame.save.inventory = {} + fakeSelf:checkVictoryRewards("OPP_MISTY", 1, shownOnBattleScreen) + -- the chain pushes one box at a time; walk it to the end + local i = 1 + while pushed[i] do + local box = pushed[i] + i = i + 1 + if box.onDone then box.onDone() end + end + return table.concat(boxes, "\f") +end + +local badge = text["_CeruleanGymMistyReceivedCascadeBadgeText"] +local onMap = rewardPages(false) +T.check(onMap:find(badge, 1, true) ~= nil, + "without the battle-screen line the reward chain still shows the badge text") +local afterBattleScreen = rewardPages(true) +T.eq(afterBattleScreen:find(badge, 1, true), nil, + "the badge line is not reprinted on the map once the battle screen showed it") +T.check(afterBattleScreen:find(text["_CeruleanGymMistyCascadeBadgeInfoText"], + 1, true) ~= nil, + "the TM hand-over still runs on the map") +T.check(rewardGame.save.inventory.CASCADEBADGE == 1, "the badge is still given") + +T.finish("gym end battle text (#1606)") diff --git a/tests/engine/intro_title_naming_bug1510_1511.lua b/tests/engine/intro_title_naming_bug1510_1511.lua new file mode 100644 index 00000000..fe7a1703 --- /dev/null +++ b/tests/engine/intro_title_naming_bug1510_1511.lua @@ -0,0 +1,193 @@ +-- Title -> main menu handoff (#1510) and the Oak-intro naming layout (#1511): +-- title.asm .finishedWaiting, oak_speech2.asm ChoosePlayerName +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.load() + +local SaveData = require("src.core.SaveData") +local Menu = require("src.ui.Menu") +local NamingScreen = require("src.ui.NamingScreen") +local OakSpeech = require("src.ui.OakSpeech") +local TitleState = require("src.ui.TitleState") + +local function newStack() + local stack = { states = {} } + function stack:push(state, ...) + table.insert(self.states, state) + if type(state.enter) == "function" then state:enter(...) end + end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + return stack +end + +local function newGame() + return { + data = Data, + save = SaveData.newGame(), + stack = newStack(), + input = { wasPressed = function() return false end, + isDown = function() return false end }, + } +end + +local function run(state, frames) + for _ = 1, frames do + if state.game.stack:top() ~= state then return end + state:update(1 / 60) + end +end + +-- ------------------------------------------------- #1510: title -> menu + +local game = newGame() +local title = TitleState.new(game, {}) +game.stack:push(title) + +title:toMenu() +local flash = game.stack:top() +T.check(flash ~= title, "START pushes a state before the menu") +T.eq(flash.isOpaque, true, "GBPalWhiteOutWithDelay3 covers the title art") +T.check(not title.menuOpen, "the title is still drawing itself mid-blink") + +run(flash, 60) +local menu = game.stack:top() +T.check(getmetatable(menu) == Menu, "the blink hands off to the main menu") +T.eq(title.menuOpen, true, "MainMenu's ClearScreen takes the title art down") +T.eq(menu.tx, 0, "the CONTINUE / NEW GAME box is still at hlcoord 0,0") + +-- OPTION returns to .mainMenuLoop, so its row must not close the box +local option +for _, item in ipairs(menu.items) do + if item.label == "OPTION" then option = item end +end +T.check(option ~= nil and option.keepOpen == true, + "OPTION keeps the main menu on the stack") + +-- B: DisplayTitleScreen opens with GBPalWhiteOut +T.check(type(menu.onCancel) == "function", "the main menu cancels back out") +game.stack:pop() +menu.onCancel() +local back = game.stack:top() +T.eq(back.isOpaque, true, "backing out blinks white too") +T.eq(title.menuOpen, true, "the title art stays down until the blink ends") +run(back, 60) +T.eq(title.menuOpen, false, "and comes back once the blink is over") +T.eq(game.stack:top(), title, "leaving the menu lands back on the title") +-- main_menu.asm:70 jumps back to DisplayTitleScreen: the whole cinematic reruns +T.eq(title.phase, "drop", "cancel reruns the boot cinematic from the logo drop") + +-- a stranded menuOpen (an onSelect that handed control straight back) may +-- not leave a blank white title behind +title.menuOpen = true +title:update(1 / 60) +T.eq(title.menuOpen, false, "a stranded menuOpen clears on the next update") + +-- .finishedWaiting: PlayCry then WaitForSoundToFinish before the white-out +-- (engine/movie/title.asm:241-243) +while title.phase ~= "loop" do title:updateSequence() end +game.input.wasPressed = function(_, b) return b == "start" end +title:update(1 / 60) +game.input.wasPressed = function() return false end +T.eq(title.phase, "exitCry", "START waits out the cry before the white-out") +T.eq(game.stack:top(), title, "no flash is pushed on the cry frame") +run(title, 10) +T.check(game.stack:top() ~= title, "the flash follows once the cry is done") + +-- ------------------------------------------- #1511: the intro NAME box + +local ngame = newGame() +local naming = NamingScreen.new(ngame, { + presets = { "RED", "ASH", "JACK" }, introBox = true, +}) +ngame.stack:push(naming) +local box = ngame.stack:top() +T.check(getmetatable(box) == Menu, "the preset list is a bordered menu") +-- DisplayIntroNameTextBox: TextBoxBorder at hlcoord 0,0 with b=$a, c=$9 +T.eq(box.tx, 0, "the name box starts at column 0") +T.eq(box.ty, 0, "the name box starts at row 0") +T.eq(box.tw, 11, "c=$9 plus both border columns is 11 tiles wide") +T.eq(box.th, 12, "b=$a plus both border rows is 12 tiles tall") +T.eq(box.title, "NAME", "the NAME label rides the box's top border") +T.eq(box.itemY, 2, "wTopMenuItemY 2: the list is anchored down from the top") +T.eq(box.cancelable, false, "there is no way out of the naming choice") + +-- every other NamingScreen caller keeps the old preset box +local plain = NamingScreen.new(newGame(), { presets = { "RED" } }) +local pgame = plain.game +pgame.stack:push(plain) +T.eq(pgame.stack:top().tx, 4, "a non-intro preset list is unchanged") +T.eq(pgame.stack:top().title, nil, "and carries no header") + +-- ------------------------------------------ #1511: the pic slide + box + +local steps = OakSpeech.defaultSteps({}) +local byId = {} +for _, step in ipairs(steps) do byId[step.id] = step end +-- oak_speech.asm:86-91 MovePicLeft, then a text_end box that stays up +T.eq(byId.ask_player_name.reveal, "wipe", "the player pic wipes in") +T.eq(byId.ask_player_name.stay, true, "and its question box stays on screen") +T.eq(byId.ask_rival_name.reveal, "fade", "the rival pic fades in") +T.eq(byId.ask_rival_name.stay, true, "and its box stays on screen too") + +local sgame = newGame() +local speech = OakSpeech.new(sgame, function() end) +sgame.stack:push(speech) +speech.steps = steps +speech.step = 0 +speech:runStep(byId.name_player) +local slide = sgame.stack:top() +T.check(slide ~= speech, "the name beat slides the pic before the box opens") +T.eq(speech.picSlide, 0, "the slide starts where the pic already sat") +-- six tiles, one per Delay3 +run(slide, 6 * 3) +T.eq(speech.picSlide, 48, "OakSpeechSlidePicRight ends six tiles across") +T.check(sgame.stack:top() ~= slide, "the slide pops itself when it lands") + +-- ------------------------------------- #1511: the question box stays up + +local fgame = newGame() +local flow = OakSpeech.new(fgame, function() end) +fgame.stack:push(flow) +flow.steps = OakSpeech.defaultSteps(flow) +for i, step in ipairs(flow.steps) do + if step.id == "ask_player_name" then flow.step = i - 1 end +end +flow:advance() +local function pump(frames) + for _ = 1, frames do + local top = fgame.stack:top() + if top.update then top:update(1 / 60) end + if getmetatable(fgame.stack:top()) == Menu then return end + end +end +pump(400) +-- _IntroducePlayerText ends in `prompt` (text_2.asm:1730): arrowed A wait +T.check(getmetatable(fgame.stack:top()) ~= Menu, + "the question box waits for A before the name list") +T.check(fgame.stack:top() == flow.holdBox and flow.holdBox.done, + "the typed-out question box is on top, waiting for the press") +fgame.input.wasPressed = function(_, b) return b == "a" end +fgame.stack:top():update(1 / 60) +fgame.input.wasPressed = function() return false end +pump(400) +T.check(getmetatable(fgame.stack:top()) == Menu, "A opens the preset list") +T.eq(fgame.stack.states[2], flow.holdBox, + "IntroducePlayerText's box is still on the stack under the name list") +T.check(flow.holdBox.stayShown == true, + "prompt-then-hold: the box stays up after the press (home/text.asm:434)") + +local preset = fgame.stack:top() +preset.index = 2 +fgame.input.wasPressed = function(_, b) return b == "a" end +preset:update(1 / 60) +fgame.input.wasPressed = function() return false end +T.eq(fgame.save.player.name, "RED", "picking a preset names the player") +T.eq(flow.holdBox, nil, "and takes the question box down with the list") +-- 13-frame ClearScreenArea / DelayFrames beat, then six tiles of slide +-- (oak_speech2.asm:69-78) +run(fgame.stack:top(), 13 + 6 * 3) +T.eq(flow.picSlide, 0, "OakSpeechSlidePicLeft puts the pic back") + +T.finish("intro_title_naming_bug1510_1511") diff --git a/tests/engine/pc_league_row_bug1566.lua b/tests/engine/pc_league_row_bug1566.lua new file mode 100644 index 00000000..510ab4ba --- /dev/null +++ b/tests/engine/pc_league_row_bug1566.lua @@ -0,0 +1,98 @@ +-- The PC gains a PKMN LEAGUE row after the Hall of Fame (#1566): +-- DisplayPCMainMenu (engine/pokemon/bills_pc.asm:5), PKMNLeague (pc.asm:67) +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local OW = require("src.world.OverworldController") + +local function setUpvalue(fn, name, val) + local i = 1 + while true do + local n = debug.getupvalue(fn, i) + if not n then return false end + if n == name then debug.setupvalue(fn, i, val); return true end + i = i + 1 + end +end + +local pushed = {} +local fakeGame = { + data = { text = { + _TurnedOnPC1Text = "RED turned on\nthe PC.", + _AccessedHoFPCText = "Accessed POKéMON\nLEAGUE's site.", + } }, + save = { + flags = { EVENT_GOT_POKEDEX = true }, + player = { name = "RED" }, + }, + stack = { push = function(_, item) pushed[#pushed + 1] = item end }, +} + +local sounds = {} +package.loaded["src.core.Sound"] = { + play = function(_, name) sounds[#sounds + 1] = name end, +} +local menuItems +package.loaded["src.ui.Menu"] = { + new = function(_, items) menuItems = items; return { menu = true } end, +} +local opened = {} +T.check(setUpvalue(OW.openPC, "Game", fakeGame), "Game upvalue on openPC") +T.check(setUpvalue(OW.openPC, "Screens", { + push = function(_, id) opened[#opened + 1] = id end, +}), "Screens upvalue on openPC") +T.check(setUpvalue(OW.openPC, "TextBox", { + new = function(_, text, onDone) return { text = text, onDone = onDone } end, +}), "TextBox upvalue on openPC") + +local fakeSelf = setmetatable({}, { __index = OW }) + +local function labels() + pushed, menuItems, sounds, opened = {}, nil, {}, {} + fakeSelf:openPC(function() end) + pushed[1].onDone() -- close TurnedOnPC1Text; the menu goes up behind it + local names = {} + for i, item in ipairs(menuItems or {}) do names[i] = item.label end + return names +end + +local function indexOf(list, label) + for i, name in ipairs(list) do + if name == label then return i end + end +end + +-- wNumHoFTeams == 0: three rows plus LOG OFF (bills_pc.asm .noLeaguePC) +local before = labels() +T.eq(indexOf(before, "LEAGUE"), nil, + "no PKMN LEAGUE row before the Hall of Fame") +T.eq(#before, 4, "BILL's PC, the player's PC, PROF.OAK's PC and LOG OFF") + +-- one recorded team is enough, and it stays for good +fakeGame.save.hallOfFame = { { { species = "PIKACHU", level = 80 } } } +local after = labels() +local iLeague = indexOf(after, "LEAGUE") +T.check(iLeague, "PKMN LEAGUE appears once a team is in the Hall of Fame") +T.eq(after[iLeague - 1], "PROF.OAK's PC", "it follows PROF.OAK's PC") +T.eq(after[iLeague + 1], "LOG OFF", "and LOG OFF still closes the menu") +T.check(menuItems[iLeague].keepOpen, + "B returns to the PC menu (ReloadMainMenu), it does not log off") + +-- selecting it: SFX_ENTER_PC, AccessedHoFPCText, then the roster screen +menuItems[iLeague].onSelect() +T.eq(sounds[#sounds], "Enter_PC", "PKMNLeague plays SFX_ENTER_PC") +local box = pushed[#pushed] +T.eq(box.text, fakeGame.data.text._AccessedHoFPCText, + "PKMNLeaguePC prints AccessedHoFPCText first") +box.onDone() +T.same(opened, { "LeaguePC" }, "the Hall of Fame roster screen opens") + +-- without the Pokedex, .noOaksPC2 skips Oak's PC and the league row alike +-- (bills_pc.asm:48-49, :68-72); only the box height ignores it (:5-7) +fakeGame.save.flags.EVENT_GOT_POKEDEX = nil +local noDex = labels() +T.eq(indexOf(noDex, "LEAGUE"), nil, + "no dex, no PKMN LEAGUE row, HoF teams or not") +T.eq(indexOf(noDex, "PROF.OAK's PC"), nil, "and no PROF.OAK's PC either") + +T.finish("pc league row (#1566)") diff --git a/tests/engine/rare_candy_bag_open_bug796.lua b/tests/engine/rare_candy_bag_open_bug796.lua index 6aee10cf..37646a11 100644 --- a/tests/engine/rare_candy_bag_open_bug796.lua +++ b/tests/engine/rare_candy_bag_open_bug796.lua @@ -136,8 +136,9 @@ do local list, why = useFromBag(game, nil, "RARE_CANDY") if check(list ~= nil, "the bag opened and reached the picker: " .. tostring(why)) then eq(mon.level, 6, "the candy leveled the mon 5 -> 6") - check(not inStack(game.stack, isPicker), - "the pickOnly picker popped itself before onSwitch") + -- .useRareCandy over the party list (item_effects.asm:1392-1418) #1594 + check(inStack(game.stack, isPicker), + "the party picker is still up under the level text (#1594)") check(inStack(game.stack, function(s) return s == list end), "the bag list is STILL on the stack (.useItem_partyMenu re-enters " .. "StartMenu_Item, it does not CloseStartMenu) (#796)") @@ -158,6 +159,47 @@ do end end +-- .useRareCandy: TryEvolvingMon runs over the party list +-- (item_effects.asm:1392-1418) (#1594) +do + local evolveCalls = {} + package.loaded["src.pokemon.Evolution"] = { + pendingFor = function() return "FIXMON_B", { method = "LEVEL" } end, + evolve = function(_, _, to, onDone, via) + evolveCalls[#evolveCalls + 1] = { to = to, onDone = onDone, via = via } + end, + } + package.loaded["src.battle.BattleState"] = { + StatBox = { new = function(_, _, cb) return { statBox = true, cb = cb } end }, + } + local game = freshGame(3) + local list = useFromBag(game, nil, "RARE_CANDY") + if check(list ~= nil, "the bag reached the picker (evolution case)") then + local box = game.stack:top() + check(isBox(box), "the level line prints first") + game.stack:pop() -- a real TextBox pops itself before onDone + box.done() + local stat = game.stack:top() + if check(stat and stat.statBox, "then the stat window") then + game.stack:pop() -- as does the stat window before its callback + stat.cb() + eq(#evolveCalls, 1, "the pending evolution starts") + check(inStack(game.stack, isPicker), + "with the party picker STILL up: the evolution prints over it, " + .. "not over the bag list (#1594)") + check(type(evolveCalls[1].onDone) == "function", + "closePicker rides the evolution's completion callback") + evolveCalls[1].onDone() + check(not inStack(game.stack, isPicker), + "and the picker comes down once the evolution flow completes") + check(inStack(game.stack, function(s) return s == list end), + "while the bag list survives (#796)") + end + end + package.loaded["src.pokemon.Evolution"] = nil + package.loaded["src.battle.BattleState"] = nil +end + -- The last candy: the row goes away (RemoveUsedItem empties the slot) and the -- cursor clamps to a real row -- but the list itself still must not close. do diff --git a/tests/engine/save_confirm_layout_bug1522.lua b/tests/engine/save_confirm_layout_bug1522.lua new file mode 100644 index 00000000..cc2705af --- /dev/null +++ b/tests/engine/save_confirm_layout_bug1522.lua @@ -0,0 +1,70 @@ +-- SAVE confirmation layout (#1522): PrintSaveScreenText's own box and +-- SaveTheGame_YesOrNo's TWO_OPTION_MENU at hlcoord 0, 7 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.load() + +local SaveData = require("src.core.SaveData") +local StartMenu = require("src.ui.StartMenu") +local ChoiceBox = require("src.ui.ChoiceBox") + +local function newStack() + local stack = { states = {} } + function stack:push(state) table.insert(self.states, state) end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + return stack +end + +local game = { + data = Data, + save = SaveData.newGame(), + stack = newStack(), + input = { wasPressed = function() return false end, + isDown = function() return false end }, +} +game.save.player.name = "RED" +game.save.playTime = 3 * 3600 + 7 * 60 + +local menu = StartMenu.new(game) +local save +for _, item in ipairs(menu.items) do + if item.label == "SAVE" then save = item end +end +T.check(save ~= nil, "the start menu lists SAVE") + +save.onSelect() +-- PrintSaveScreenText ends `ld c, 30 / jp DelayFrames`: the bare panel +-- holds 30 frames before the prompt (main_menu.asm:404-405) +T.eq(#game.stack.states, 1, "the panel shows alone first") +for _ = 1, 30 do game.stack:top().update() end +T.eq(#game.stack.states, 2, "the panel and the prompt are two separate states") +local panel, prompt = game.stack.states[1], game.stack.states[2] +T.check(type(panel.draw) == "function" and not panel.isTextBox, + "the info panel is its own drawn state, not a TextBox page") +T.check(prompt.isTextBox == true, "the prompt is the dialogue box on top") +T.eq(#prompt.pages, 1, "the prompt is one page: no \\f-merged info panel") +T.check(prompt.pages[1][1]:find("Would you like to"), + "the prompt page is WouldYouLikeToSaveText") + +-- save.asm:188 hlcoord 0, 7 +T.eq(prompt.choiceBox.tx, 0, "the save Yes/No box sits at column 0 (left)") +T.eq(prompt.choiceBox.ty, 7, "the save Yes/No box sits at row 7") +T.eq(prompt.choiceBox, require("src.ui.Theme").saveBox, + "the geometry routes through Theme so field.theme can restyle it") +local choice = ChoiceBox.new(game, function() end, { box = prompt.choiceBox }) +T.eq(choice.tx, 0, "ChoiceBox honours the save-specific left placement") + +-- answering NO takes the panel back down with the prompt +prompt.done = true +prompt:update(1 / 60) +local yesno = game.stack:top() +T.check(getmetatable(yesno) == ChoiceBox, "the prompt pushes the Yes/No box") +T.eq(yesno.tx, 0, "the pushed Yes/No box is the left-hand one") +game.stack:pop() +game.stack:pop() +prompt.choice(false) +T.eq(#game.stack.states, 0, "declining closes the info panel too") + +T.finish("save_confirm_layout_bug1522") diff --git a/tests/engine/thrash_setup_anim_bug1532.lua b/tests/engine/thrash_setup_anim_bug1532.lua index 8e5ad7aa..3eaee52e 100644 --- a/tests/engine/thrash_setup_anim_bug1532.lua +++ b/tests/engine/thrash_setup_anim_bug1532.lua @@ -49,6 +49,13 @@ local function indexOf(rows, name) return nil end +local function hasText(battle, needle) + for _, item in ipairs(battle.queue) do + if item.text and item.text:find(needle, 1, true) then return true end + end + return false +end + -- --------------------------------------------------------------------- -- the player's setup turn: the effect animation precedes the move's own -- --------------------------------------------------------------------- @@ -73,6 +80,17 @@ do battle:performMove(battle.player, battle.enemy, slot) T.check(indexOf(animRows(battle), "SHRINKING_SQUARE_ANIM") == nil, "a locked-in Thrash queues no setup animation") + -- .ThrashingAboutCheck (core.asm:3534-3535, enemy mirror :5909-5910) (#1577) + T.check(indexOf(animRows(battle), "THRASH") ~= nil, + "a continuation turn animates THRASH, not the locked move's own id") + T.check(indexOf(animRows(battle), "FIX_THRASH") == nil, + "so the locked move's own animation does not play") + T.check(hasText(battle, "thrashing about"), + "ThrashingAboutText prints in place of the used-move line") + T.check(not hasText(battle, "used FIX THRASH"), + "and the used-move line does not") + T.eq(battle.player.thrashTurns, 1, + "the continuation turn runs wPlayerNumAttacksLeft down") end -- --------------------------------------------------------------------- @@ -104,6 +122,27 @@ do T.check(indexOf(rows, "SHRINKING_SQUARE_ANIM") ~= nil, "the setup animation survives a miss") T.check(indexOf(rows, "FIX_THRASH") == nil, "while the move's own anim is cancelled") + -- ThrashPetalDanceEffect commits before MoveHitTest + -- (core.asm:3129-3133, effects.asm:791-808) (#1565) + T.eq(battle.player.thrashTurns, 2, "the miss still rolls wPlayerNumAttacksLeft") + T.check(battle:menuLockedAction(battle.player) ~= nil, + "and the user is locked into Thrash next turn") +end + +-- --------------------------------------------------------------------- +-- JumpMoveEffect (core.asm:3129-3133) before MoveHitTest INVULNERABLE (:3150) (#1565) +do + local battle = newBattle() + battle.queue, battle.nextInsert = {}, 0 + battle.enemy.invulnerable = true + battle:performMove(battle.player, battle.enemy, { id = "FIX_THRASH", pp = 20 }) + T.check(indexOf(animRows(battle), "SHRINKING_SQUARE_ANIM") ~= nil, + "the setup animation plays against a mid-Fly/Dig target") + T.eq(battle.player.thrashTurns, 2, + "the 2-3 roll commits against a mid-Fly/Dig target") + T.check(battle:menuLockedAction(battle.player) ~= nil, + "and the user is locked into Thrash next turn") + T.check(hasText(battle, "attack missed"), "while the attack itself misses") end T.finish("thrash setup animation (#1532)") diff --git a/tests/engine/traded_exp_otid_bug1488.lua b/tests/engine/traded_exp_otid_bug1488.lua new file mode 100644 index 00000000..0a571e2e --- /dev/null +++ b/tests/engine/traded_exp_otid_bug1488.lua @@ -0,0 +1,45 @@ +-- GainExperience compares MON_OTID against wPlayerID at every award +-- (engine/battle/experience.asm:69-88) (#1488) +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local function boostedText(mutate) + local save = SaveData.newGame() + save.player.id = save.player.id or 1234 + save.party = { Pokemon.new(Data, "FIXMON_A", 20) } + mutate(save.party[1], save) + local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } + local battle = BattleState.newWild(game, "FIXMON_C", 10) + battle.participants = { [save.party[1]] = true } + battle:enemyMonFainted() + for _, item in ipairs(battle.queue) do + if item.text and item.text:find("boosted", 1, true) then return true end + end + return false +end + +T.eq(boostedText(function(mon, save) mon.otId = save.player.id end), false, + "a mon whose OTID is the player's own earns no boost") +T.eq(boostedText(function(mon, save) mon.otId = (save.player.id or 0) + 1 end), true, + "a foreign OTID trips BoostExp") +T.eq(boostedText(function(mon, save) + -- traded away and traded back: the stored OTID is the player's again + mon.traded = true + mon.otId = save.player.id +end), false, "and a mon traded back to its original trainer loses it (#1488)") +T.eq(boostedText(function(mon) + -- repairTradedOtIds leaves traded mons with otId nil (#1265, #1461) + mon.traded = true + mon.otId = nil +end), true, "a traded mon with no stored OTID keeps the boost (#1488)") + +T.finish("traded exp boost is an OTID comparison (#1488)") diff --git a/tests/engine/update_check_tests.lua b/tests/engine/update_check_tests.lua index 1fab2838..96d8111f 100644 --- a/tests/engine/update_check_tests.lua +++ b/tests/engine/update_check_tests.lua @@ -34,6 +34,20 @@ eq(rel.payload.size, 12345, "payload asset size picked") eq(rel.sums.url, "http://x/sums", "sums asset url picked") eq(rel.notes, "", "missing release body becomes empty notes") +-- Native package assets are target-specific. This is intentionally separate +-- from the generic .love payload, which a new shell may be unable to host. +local android = Check.parseRelease(body, nil, { os = "Android", arch = "arm64" }) +eq(android.fullName, "gen1recomp-1.4.2-android.apk", "Android package name derived") +eq(android.full, nil, "missing Android package is surfaced as nil") +eq(Check.fullAssetName("1.4.2", "Android", "arm64"), + "gen1recomp-1.4.2-android.apk", "Android full asset mapping") +eq(Check.fullAssetName("1.4.2", "Linux", "aarch64"), + "gen1recomp-1.4.2-linux-arm64.AppImage", "Linux ARM package mapping") +eq(Check.fullAssetName("1.4.2", "iOS", "arm64"), + "gen1recomp++-1.4.2-ios.ipa", "iOS package mapping") +eq(Check.fullAssetName("not-a-version", "Android", "arm64"), nil, + "invalid full-package version rejected") + local withNotes = Check.parseRelease(Json.encode({ tag_name = "v1.4.2", body = "## Issues closed\n\n- #1 cart padding", diff --git a/tests/gen2_battle_end_test.lua b/tests/gen2_battle_end_test.lua index 3b98194b..529c6d09 100644 --- a/tests/gen2_battle_end_test.lua +++ b/tests/gen2_battle_end_test.lua @@ -149,9 +149,11 @@ do local events = battle:takeTurn({ kind = "move", move = "ROAR" }) check(saidSomethingLike(events, "fled in fear!"), "FledInFearText: the wild mon is blown away") - check(not saidSomethingLike(events, "used TACKLE!"), - "and the mon that left never takes its half of the turn") - eq(player.hp, hpBefore, "so nothing came back the other way") + -- EFFECT_FORCE_SWITCH is priority 0, below BASE_PRIORITY + -- (data/moves/effects_priorities.asm:5): Roar goes last (#1475) + check(saidSomethingLike(events, "used TACKLE!"), + "so the wild mon takes its half of the turn first, Speed regardless") + check(player.hp < hpBefore, "and its hit landed before the blow-away") eq(battle.over, true, "the battle is over") eq(battle.outcome, "fled", "as the cart's DRAW") end diff --git a/tests/gen2_battle_ui_test.lua b/tests/gen2_battle_ui_test.lua index 11486dc0..d88c636f 100644 --- a/tests/gen2_battle_ui_test.lua +++ b/tests/gen2_battle_ui_test.lua @@ -1306,4 +1306,153 @@ do eq(lead.moves[4].id, "SURF", "and SURF is still there") end +-- ---- GetMovePriority's Vital Throw carve-out (#1475) ---------------------- +-- engine/battle/core.asm:787-789 +do + local screen = newScreen() + check(runToMenu(screen), "reached the menu") + local battle = screen.battle + local moves = battle.data.moves + moves.VITAL_THROW = { id = "VITAL_THROW", name = "VITALTHROW", power = 70, + type = "NORMAL", accuracy = 100, pp = 10, effect = "EFFECT_ALWAYS_HIT" } + moves.SWIFT = { id = "SWIFT", name = "SWIFT", power = 60, type = "NORMAL", + accuracy = 100, pp = 20, effect = "EFFECT_ALWAYS_HIT" } + moves.QUICK_ATTACK = { id = "QUICK_ATTACK", name = "QUICKATTACK", power = 40, + type = "NORMAL", accuracy = 100, pp = 30, effect = "EFFECT_PRIORITY_HIT" } + eq(battle:movePriority("VITAL_THROW"), -1, "VITAL_THROW goes last") + eq(battle:movePriority("SWIFT"), 0, + "while SWIFT, which shares its effect, keeps BASE_PRIORITY") + eq(battle:movePriority("QUICK_ATTACK"), 1, "and the table still reads") + moves.ROAR_FIX = { id = "ROAR_FIX", name = "ROAR", power = 0, + type = "NORMAL", accuracy = 100, pp = 20, effect = "EFFECT_FORCE_SWITCH" } + -- MoveEffectPriorities: EFFECT_FORCE_SWITCH is 0, below BASE_PRIORITY + -- (data/moves/effects_priorities.asm:5) + eq(battle:movePriority("ROAR_FIX"), -1, + "Whirlwind and Roar sit below BASE_PRIORITY") + eq(battle:orderOf("VITAL_THROW", "ROAR_FIX"), "player", + "VITAL_THROW ties a force-switch move (0 vs 0), so Speed decides") + eq(battle:orderOf("TACKLE", "TACKLE"), "player", + "the faster mon leads on equal priority") + eq(battle:orderOf("VITAL_THROW", "TACKLE"), "enemy", + "but VITAL_THROW loses to a normal move whatever the Speed") + moves.VITAL_THROW, moves.SWIFT, moves.QUICK_ATTACK = nil, nil, nil + moves.ROAR_FIX = nil +end + +-- ---- a send-out snapshots HP at send time (#1514) ------------------------- +-- SendOutPlayerMon's tail (engine/battle/core.asm:3796-3838) +do + local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + local bench = Mon.new(DATA, "TOTODILE", 10, { dvs = perfect }) + local screen, battle = newScreen({ player = lead, party = { lead, bench } }) + check(runToMenu(screen), "reached the menu") + battle:takeEvents() + check(battle:switch(2), "the bench mon comes in") + local send = battle:takeEvents()[1] + eq(send.kind, "send", "the switch emits a send-out") + eq(send.hp, bench.hp, "carrying a numeric HP snapshot") + bench.hp = bench.hp - 7 + check(send.hp ~= bench.hp, + "which the rest of the turn's damage cannot walk back") + screen.shownHp.player = 0 + screen:push(send) + screen:advanceQueue() + eq(screen.shownHp.player, send.hp, + "and the HUD opens on the snapshot, not on the post-hit value") +end + +-- ---- the send-out snapshots level and exp the same way (#1514) ------------ +-- SendOutPlayerMon reloads wBattleMon* from the party slot (core.asm:3796-3838) +do + local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + local bench = Mon.new(DATA, "TOTODILE", 10, { dvs = perfect }) + local screen, battle = newScreen({ player = lead, party = { lead, bench } }) + check(runToMenu(screen), "reached the menu") + battle:takeEvents() + check(battle:switch(2), "the bench mon comes in") + local send = battle:takeEvents()[1] + eq(send.level, bench.level, "the send carries a level snapshot") + eq(send.experience, bench.experience, "and an experience snapshot") + -- awardExperience mutates the live table before the UI dequeues the send + bench.level = bench.level + 3 + bench.experience = (bench.experience or 0) + 5000 + screen:push(send) + screen:advanceQueue() + eq(screen.shownLevel, send.level, + "the HUD opens on the send-time level, not the post-award one") + eq(screen.shownExp, screen:expPixels(bench, send.level, send.experience), + "and the exp bar fills from the send-time experience") +end + +-- ---- LearnMove finishes before the queued send-out (#1516) ---------------- +-- LearnMove inside GiveExperiencePoints (engine/battle/core.asm:1959-2010) +do + local screen, lead = learnScreen() + screen:push({ kind = "send", side = "enemy", mon = { hp = 1 }, hp = 1, + text = "JOE sent out PIDGEY!" }) + check(runToPhase(screen, "ask-forget"), "the pages reach the question") + 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 + eq(lead.moves[1].id, "EMBER", "the move is learned") + check(screen.message and screen.message:find("forgot", 1, true) ~= nil, + "and its line prints ahead of the send-out that was already queued") +end + +-- ---- MoveSelectionScreen's two boxes (#1478) ------------------------------ +-- engine/battle/core.asm:5074-5094, MoveInfoBox :5403-5478 +do + local Chrome = require("src.ui.gen2.Chrome") + local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) + lead.moves = { { id = "TACKLE", pp = 30, maxPp = 35 }, + { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } } + local screen = newScreen({ player = lead, party = { lead } }) + check(runToMenu(screen), "reached the menu") + screen.phase = "moves" + screen.moveIndex = 1 + + local boxes, prints = {}, {} + local saved = { box = Chrome.box, print = Chrome.print, + printRight = Chrome.printRight, cursor = Chrome.cursor } + Chrome.box = function(x, y, w, h) + boxes[#boxes + 1] = ("%d,%d,%d,%d"):format(x, y, w, h) + end + Chrome.print = function(text, x, y) + prints[#prints + 1] = ("%s@%d,%d"):format(tostring(text), x, y) + end + Chrome.printRight = function(text, x, y) + prints[#prints + 1] = ("R:%s@%d,%d"):format(tostring(text), x, y) + end + Chrome.cursor = function(x, y) + prints[#prints + 1] = ("cursor@%d,%d"):format(x, y) + end + local ok, err = pcall(function() screen:drawPanel() end) + Chrome.box, Chrome.print = saved.box, saved.print + Chrome.printRight, Chrome.cursor = saved.printRight, saved.cursor + check(ok, "the move menu draws: " .. tostring(err)) + + local drawn = table.concat(boxes, " ") + check(drawn:find("0,8,11,5", 1, true) ~= nil, "the TYPE/PP box is drawn") + check(drawn:find("4,12,16,6", 1, true) ~= nil, "over the narrow list box") + -- SafeLoadTempTilemapToTilemap keeps the full battle textbox under the + -- move list (core.asm:4689); Textbox then MoveInfoBox over it (:5084, :5157). + check(drawn:find("0,12,20,6", 1, true) ~= nil, + "over the restored full-width message box") + local base = drawn:find("0,12,20,6", 1, true) + local list = drawn:find("4,12,16,6", 1, true) + local info = drawn:find("0,8,11,5", 1, true) + check(base < list and list < info, + "painted base box, then list box, then info box") + local text = table.concat(prints, " ") + check(text:find("TACKLE@6,13", 1, true) ~= nil, "names sit at column 6") + check(text:find("cursor@5,13", 1, true) ~= nil, "with the cursor at 5") + check(text:find("TYPE/@1,9", 1, true) ~= nil, "TYPE/ at (1,9)") + check(text:find("NORMAL@2,10", 1, true) ~= nil, "the type name at (2,10)") + check(text:find("30/35@5,11", 1, true) ~= nil, + "and only the highlighted move's PP, at (5,11)") + check(text:match("R:%d+/%d+@19,1%d") == nil, "no PP is printed per row") +end + S.finish() diff --git a/tests/gen2_time_routing_test.lua b/tests/gen2_time_routing_test.lua index 0b5dded9..ebe40ff1 100644 --- a/tests/gen2_time_routing_test.lua +++ b/tests/gen2_time_routing_test.lua @@ -109,6 +109,57 @@ do eq(world.daytime, HOST_DAYTIME, "so it is lit by the host clock") end +-- ---- a pinned palette lights the room, it does not stop the clock (#1557) --- +-- timeofday_pals.asm:114 and :5-11, checktime.asm:2, data/maps/maps.asm:427 +do + local save = {} + Clock.setTime(save, 21, 0) + local world = worldWithSave(save) + world.map.def.palette = "PALETTE_DAY" + world.map.def.environment = "INDOOR" + world:applyPalettes() + eq(world.daytime, "DAY", "the pinned room is still lit like day at 21:00") + eq(world.tod, "NITE", "but the world clock knows it is night") + eq(world:timeOfDayId(), 2, "and wTimeOfDay answers NITE_F") + + -- Script_checktime: CheckTime's bit for wTimeOfDay ANDed with the mask. + local function checktime(mask) + local scripts = { generation = 2, + ["s:t"] = { { op = "checktime", args = { mask } } } } + local vm = Vm.new(scripts, {}, world.events, + { getTimeOfDay = function() return world:timeOfDayId() end }) + vm:start("s:t") + for _ = 1, 100 do + if not vm:running() then break end + vm:update() + end + return vm.scriptVar + end + eq(checktime(4), 1, "checktime NITE is TRUE inside the PALETTE_DAY room") + eq(checktime(2), 0, "and checktime DAY is FALSE there") + + -- the two out-of-World consumers read wTimeOfDay too (#1557) + local Pokegear = require("src.ui.gen2.Pokegear") + eq(Pokegear.timeOfDayIndex({ game = { world = world } }), 2, + "the radio's program pick answers NITE, not the pin (pokegear.asm:1456)") + local Specials = require("src.script.gen2.Specials") + local buffer + local pvm = { curPhoneCaller = 1, + setStringBuffer = function(_, name) buffer = name end, + specials = { world = { + tod = world.tod, daytime = world.daytime, + encounters = { grass = { PLAYERS_HOUSE_1F = { slots = { + NITE = { { species = "NITEMON" }, { species = "NITEMON" }, + { species = "NITEMON" }, { species = "NITEMON" } }, + DAY = { { species = "DAYMON" }, { species = "DAYMON" }, + { species = "DAYMON" }, { species = "DAYMON" } }, + } } } }, + } } } + Specials.ALL.RandomPhoneWildMon(pvm) + eq(buffer, "NITEMON", + "RandomPhoneWildMon reads the NITE column (wildmons.asm:861)") +end + -- ---- the hour-window respawn is not eaten by a busy frame ------------------- -- UpdateTimePals runs every second; the port rides that poll to redo what a -- map load would (wObjectMasks). A rollover that lands on a busy frame has to diff --git a/tests/gen2_vm_test.lua b/tests/gen2_vm_test.lua index 643e57cd..d0e3a6b3 100644 --- a/tests/gen2_vm_test.lua +++ b/tests/gen2_vm_test.lua @@ -89,6 +89,60 @@ for _, row in ipairs(log) do end check(gotText, "getmonname filled STRBUF in received text") +-- givepoke's trainer arm (#1569): Script_givepoke (engine/overworld/ +-- scripting.asm:1817-1824), GivePoke (engine/pokemon/move_mon.asm:1695-1736) +do + local given, asked = nil, false + local kenyaVm = Vm.new({ generation = 2, + ["s:randy"] = { + { op = "givepoke", species = 21, level = 10, item = 0, trainer = 1, + name = "KENYA", otName = "RANDY" }, + { op = "end" }, + }, + }, {}, Events.new(), { + givePoke = function(species, level, item, opts) + given = { species = species, level = level, opts = opts } + return { species = "SPEAROW" } + end, + askNickname = function() asked = true end, + }) + check(kenyaVm:start("s:randy"), "Randy's script starts") + for _ = 1, 10 do kenyaVm:update() end + check(given ~= nil, "the gift reaches givePoke") + check(given.opts ~= nil, "the trainer arm carries the two names") + eq(given.opts.nickname, "KENYA", "the nickname is the script's own") + eq(given.opts.otName, "RANDY", "and so is the OT name") + check(not asked, "no nickname prompt on the trainer arm") +end + +-- Every other givepoke in the game is the flag-FALSE form: no names, and the +-- nickname prompt still runs (engine/pokemon/move_mon.asm:1753-1757). +do + local given, asked = nil, false + local plainVm = Vm.new({ generation = 2, + ["s:eevee"] = { + { op = "givepoke", species = 133, level = 20, item = 0, trainer = 0 }, + { op = "end" }, + }, + }, {}, Events.new(), { + givePoke = function(species, level, item, opts) + given = { opts = opts } + return { species = "EEVEE" } + end, + showText = function(_, onDone) onDone() end, + -- GiveANickname_YesNo (move_mon.asm:1753-1757): the prompt's yes/no + yesorno = function(onChoose) + asked = true + onChoose(false) + end, + }) + plainVm:start("s:eevee") + for _ = 1, 10 do plainVm:update() end + check(given ~= nil and given.opts == nil, + "the flag-FALSE form hands givePoke no names") + check(asked, "and the nickname prompt still runs on it") +end + -- Phone + verbosegiveitem (Elm directions / aide potion) local phone = {} local bag = {} diff --git a/tests/gen2_world_test.lua b/tests/gen2_world_test.lua index 79649797..7de0e454 100644 --- a/tests/gen2_world_test.lua +++ b/tests/gen2_world_test.lua @@ -298,15 +298,21 @@ landGame.input:press("a") landPack:update(0) check(landPack.message == nil, "a button clears the message") --- An item World claims nothing for still falls through to the PACK's own --- onChoose (TM teaching). +-- CoinCaseEffect (engine/items/item_effects.asm:2243) is a MenuTextboxWaitButton +-- over _CoinCaseCountText: the PACK stays open and nothing reaches onChoose. +landGame.save.player.coins = 250 landPack.index = 3 landGame.input:press("a") landPack:update(0) landGame.input:press("a") landPack:update(0) -eq(chosen, "COIN_CASE", "an unhandled item reaches onChoose untouched") +check(landPack.message ~= nil, "the COIN CASE prints inside the PACK") +eq(landPack.message[1], "Coins:", "_CoinCaseCountText's first row") +eq(landPack.message[2], "250", "and the count on the second") +eq(chosen, nil, "the COIN CASE never reaches onChoose") eq(landGame.stack.cleared, 0, "and does not quit the PACK either") +landGame.input:press("a") +landPack:update(0) -- Facing water: the roll lands on $2 .FishGotSomething, the PACK quits -- (PACKSTATE_QUITRUNSCRIPT) and Script_FishCastRod's cast owns the world. @@ -354,8 +360,10 @@ eq(busyWorld:useRod("OLD_ROD"), "nowhere", "no fishing from inside a battle") busyWorld.battleActive = nil busyWorld.vm = { running = function() return true end, update = function() end } eq(busyWorld:useRod("OLD_ROD"), "nowhere", "no fishing while a script runs") -check(busyWorld:useFieldItem("COIN_CASE") == nil, +check(busyWorld:useFieldItem("POTION") == nil, "useFieldItem passes an unhandled item back to the PACK") +eq(busyWorld:useFieldItem("COIN_CASE"), "coin_case", + "the COIN CASE is ITEMMENU_CURRENT and World claims it") -- ---- A2. REPEL / SUPER REPEL / MAX REPEL ---------------------------------- -- UseRepel (engine/items/item_effects.asm): the step count is the only thing @@ -1701,14 +1709,21 @@ check(hw:mapSceneOf(3, 4) == nil, "a map with NO scene_var row answers nil, which the VM turns into $ff") check(hw:mapSceneOf(9, 9) == nil, "and an unresolvable pair is nil too") +-- hw.tod is the production read (the unpinned wTimeOfDay split, #1557); +-- hw.daytime is the palette pin it must NOT follow +hw.tod = "DAY" eq(hw:timeOfDayId(), 1, "DAY is wTimeOfDay 1") -hw.daytime = "MORN" +hw.tod = "MORN" eq(hw:timeOfDayId(), 0, "MORN is 0") -hw.daytime = "NITE" +hw.tod = "NITE" eq(hw:timeOfDayId(), 2, "NITE is 2") +hw.tod = "NITE" hw.daytime = "DARK" -eq(hw:timeOfDayId(), 3, "DARKNESS is 3") -hw.daytime = "DAY" +eq(hw:timeOfDayId(), 2, "a PALETTE_DARK pin does not leak into wTimeOfDay") +hw.tod = nil +hw.daytime = "DARK" +eq(hw:timeOfDayId(), 3, "DARKNESS is 3 only on the tod-less fallback arm") +hw.tod, hw.daytime = nil, "DAY" eq(hw:gsVersion(), 0, "checkver: a Gold save is 0") eq(hookWorld({ version = "silver" }):gsVersion(), 1, "and a Silver save is 1") diff --git a/tests/mod_world_tests.lua b/tests/mod_world_tests.lua index 95478dee..ed4b4fbe 100644 --- a/tests/mod_world_tests.lua +++ b/tests/mod_world_tests.lua @@ -978,14 +978,17 @@ do game.stack:pop() battle.onFinish("win") - for _ = 1, 12 do + -- the "is evolving!" box types out and holds DelayFrames 50 before the + -- movie (evos_moves.asm:120-134) (#1596), so drive frames to reach it + game.input.wasPressed = function() return false end + local evoTop + for _ = 1, 900 do local t = game.stack:top() - if not t or t.screenId == "EvolutionState" then break end - game.stack:pop() - if t.onDone then t.onDone() end + if not t then break end + if t.screenId == "EvolutionState" then evoTop = t break end + if t.update then t:update(1 / 60) else break end end - check(game.stack:top() and game.stack:top().screenId == "EvolutionState", - "the win reaches the evolution screen") + check(evoTop ~= nil, "the win reaches the evolution screen") end do diff --git a/tests/parity_cerulean_rocket.lua b/tests/parity_cerulean_rocket.lua index 9aaa076a..6866d28b 100644 --- a/tests/parity_cerulean_rocket.lua +++ b/tests/parity_cerulean_rocket.lua @@ -67,6 +67,15 @@ end check(iGotJump and iGotJump[2] == iFadeOut, "EVENT_GOT_TM28 jumps to CeruleanHideRocket fade-out") +-- SaveEndBattleTextPointers before EngageMapTrainer +-- (scripts/CeruleanCity.asm:295) (#1579) +local iBattle = find("start_battle") +local iGiveUp = find("save_end_battle_text", + "_CeruleanCityRocketIGiveUpText") +check(iGiveUp, "the thief arms _CeruleanCityRocketIGiveUpText") +check(iBattle and iGiveUp == iBattle - 1, + "SaveEndBattleTextPointers runs just before the battle") + local ScriptRunner = require("src.script.ScriptRunner") local problems = ScriptRunner.validate(rows) eq(#problems, 0, "Rocket script validates: " .. table.concat(problems, "; ")) diff --git a/tests/parity_rare_candy_menu.lua b/tests/parity_rare_candy_menu.lua index ed03d62b..e6c936d6 100644 --- a/tests/parity_rare_candy_menu.lua +++ b/tests/parity_rare_candy_menu.lua @@ -142,8 +142,15 @@ do eq(game.save.inventory.RARE_CANDY, 2, "the candy was consumed") check(game.stack.states[1] == list, "the bag list is STILL on the stack under the level text (#796)") + -- .useRareCandy redraws the party menu before it prints + -- (item_effects.asm:1392-1418) #1594 + check(isPicker(game.stack.states[2]), + "the party picker is the backdrop for the level text (#1594)") finishLevelUp(game, box) + for _, s in ipairs(game.stack.states) do + check(not isPicker(s), "the picker comes down when the sequence ends") + end eq(game.stack:top(), list, "after the stat window the bag is back on top (StartMenu_Item)") eq(list.index, row, "the cursor is still on the RARE CANDY row") diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 84900f8a..4ba9ccff 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -1258,7 +1258,9 @@ do "_GainedText + _ExpPointsText show the amount") Game.save.party = { Pokemon.new(Data, "BULBASAUR", 30) } + -- GainExperience: MON_OTID vs wPlayerID (experience.asm:69-88) (#1488) Game.save.party[1].traded = true + Game.save.party[1].otId = (Game.save.player.id or 0) + 1 local eb2 = BattleState.newWild(Game, "RATTATA", 10) eb2.participants = { [Game.save.party[1]] = true } eb2:enemyMonFainted() diff --git a/tools/make_gold_manifest.py b/tools/make_gold_manifest.py index 9105a695..0176f072 100644 --- a/tools/make_gold_manifest.py +++ b/tools/make_gold_manifest.py @@ -735,6 +735,9 @@ REQUIRED_SYMBOLS = { # nine fill cells. "HP:" and the ten HP-bar cells come from # FontBattleExtra, which is already extracted. "EnemyHPBarBorderGFX", "HPExpBarBorderGFX", "ExpBarGFX", + # gfx/stats/stats_tiles.png + gfx/stats/pages.pal, StatsScreen_LoadFont + # and _CGB_StatsScreenHPPals (#1558) + "StatsScreenPageTilesGFX", "StatsScreenPagePals", # The player's own battle back-pic (gfx/player/chris_back.2bpp.lz). It is # what stands in the player's pic box for the whole battle intro, before # SendOutPlayerMon swaps in the mon's backpic.