Merge pull request #1617 from bryanthaboi/dev

some major buggies and some minor huggies
This commit is contained in:
bryanthaboi
2026-08-20 17:34:16 -04:00
committed by GitHub
88 changed files with 3680 additions and 358 deletions
+25 -4
View File
@@ -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"
+20 -2
View File
@@ -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
+5 -4
View File
@@ -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
+4 -1
View File
@@ -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
+17 -15
View File
@@ -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 = {
+23 -4
View File
@@ -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-<version>.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-<X.Y.Z>.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
+15 -5
View File
@@ -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-<version>-android.apk`.
`gen1recomp-<version>-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.
+1 -1
View File
@@ -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:
```
+27 -3
View File
@@ -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']
@@ -8,6 +8,10 @@
the link screen shows as "(Operation not permitted)" (issue #287).
scripts/build_android.sh must not strip this again. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Required only to hand a checksum-verified, user-selected GitHub release
APK to Android's own Package Installer. Android still shows the install
confirmation and enforces package/signing-key/version compatibility. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step
counter, which Android 10+ gates behind this runtime permission.
Requested only on the first sync call (the Pokéwalker mod's SYNC
@@ -35,6 +39,18 @@
<meta-data
android:name="android.allow_multiple_resumed_activities"
android:value="true" />
<!-- The full-update APK is copied into this small cache subdirectory
before it is handed to Package Installer. Keep the provider private
and expose only that directory, never a storage root. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.full_update_provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/full_update_paths" />
</provider>
<activity
android:name="org.love2d.android.GameActivity"
android:exported="true"
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Deliberately narrow: FileProvider may grant only the staged APK, never
arbitrary app, external, or shared storage. -->
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="full_update" path="full-update/" />
</paths>
+2 -1
View File
@@ -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
-1
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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()
@@ -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<std::string> &versions)
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
@@ -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.
**/
@@ -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<std::string> &versions) const
{
#ifdef LOVE_ANDROID
@@ -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<std::string> &versions) const;
virtual std::string getLaunchGame() const;
@@ -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 },
@@ -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 : "";
+39 -10
View File
@@ -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/" \;
+1
View File
@@ -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",
}
+121 -26
View File
@@ -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
+6 -4
View File
@@ -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
+7 -22
View File
@@ -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 = {
+43 -13
View File
@@ -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", {
+4 -3
View File
@@ -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.
+9
View File
@@ -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
+15 -3
View File
@@ -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"),
+26
View File
@@ -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")
+6
View File
@@ -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"
+37 -11
View File
@@ -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
+13 -2
View File
@@ -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)
+2 -1
View File
@@ -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
+14 -5
View File
@@ -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
+16 -7
View File
@@ -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)
+13 -19
View File
@@ -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
+66 -3
View File
@@ -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)
+21 -5
View File
@@ -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
+17 -5
View File
@@ -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()
+103 -16
View File
@@ -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
+70 -29
View File
@@ -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
+3
View File
@@ -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)
+82 -14
View File
@@ -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
+120 -19
View File
@@ -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()
+6 -1
View File
@@ -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): "<PLAYER> used the\n<ITEM>."
-- World already wrote the counter and took the item out of the bag, so
+14 -3
View File
@@ -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
+5 -2
View File
@@ -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
+60 -8
View File
@@ -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), <ID> ($73), <LV> ($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. <NEXT> 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
+137 -5
View File
@@ -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()
+112 -15
View File
@@ -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
+38 -5
View File
@@ -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("<PK><MN>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
+43 -9
View File
@@ -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
@@ -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")
@@ -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.")
+46
View File
@@ -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
+70
View File
@@ -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
+53 -14
View File
@@ -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")
+121
View File
@@ -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()
@@ -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)")
@@ -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)")
+18 -1
View File
@@ -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
@@ -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()
+23 -9
View File
@@ -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()
@@ -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")
@@ -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")
@@ -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)")
+51
View File
@@ -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)")
+210
View File
@@ -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")
@@ -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)")
@@ -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")
+98
View File
@@ -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, "<PK><MN>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, "<PK><MN>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, "<PK><MN>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)")
+44 -2
View File
@@ -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
@@ -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")
@@ -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)")
+45
View File
@@ -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)")
+14
View File
@@ -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",
+5 -3
View File
@@ -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
+149
View File
@@ -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()
+51
View File
@@ -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
+54
View File
@@ -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 = {}
+23 -8
View File
@@ -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")
+9 -6
View File
@@ -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
+9
View File
@@ -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, "; "))
+7
View File
@@ -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")
+2
View File
@@ -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()
+3
View File
@@ -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.