diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 9b9b4650..9d3d69e9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -25,7 +25,9 @@ body: options: - macOS - Windows - - Both + - Linux + - Android + - Multiple platforms validations: required: true @@ -38,6 +40,17 @@ body: validations: required: true + - type: input + id: discord + attributes: + label: Discord username (optional) + description: > + So maintainers can ping you on Discord if they need a quick follow-up. + Leave blank if you'd rather keep everything on GitHub. + placeholder: yourname + validations: + required: false + - type: input id: location attributes: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index afe49e98..923ba8c1 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,35 +1,53 @@ name: Feature request -description: Ask for something new, or a change to how something already works. +description: Ask for something new in the engine, launcher, or platform — not a content/gameplay mod. labels: ["enhancement"] body: - type: markdown attributes: value: | + Use this for **engine / launcher / platform** work (ports, video options, save + tooling, networking, mod API seams, docs). + + If what you want is a gameplay, cosmetic, audio, or QoL change that a Lua mod + could ship — running shoes, alternate sprites, day/night, shiny indicators, + Gen 2-like battle toggles, soundtrack packs — open a + **[Mod request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=mod_request.yml)** + instead. + "Can we add X" on its own is hard to act on. Say what you want, why you want it, - and how you picture it working. A request with real detail is one someone can - actually build without guessing at what you meant. + and how you picture it working. - type: input id: summary attributes: label: One line summary description: What you want, in a sentence. - placeholder: Add a run toggle so you can move at bike speed without the bike + placeholder: Add Linux AppImage releases next to the macOS and Windows builds validations: required: true + - type: input + id: discord + attributes: + label: Discord username (optional) + description: > + So maintainers can ping you on Discord if they need a quick follow-up. + Leave blank if you'd rather keep everything on GitHub. + placeholder: yourname + validations: + required: false + - type: textarea id: what attributes: label: What do you want description: > - Describe it properly. What is it, where does it live in the game, what does the - player see or do. If it changes something that already exists, say what it does today - and what it should do instead. + Describe it properly. What is it, where does it live (launcher, options, + engine), what does the player see or do. If it changes something that already + exists, say what it does today and what it should do instead. placeholder: | - A hold-to-run button, the way later Pokemon games do it. Hold B while moving on - foot and the player moves at bike speed. Release and you go back to walking. - Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus. + Ship a Linux AppImage on each release, same version as the macOS/Windows builds, + with the same save folder layout and mod discovery path. validations: required: true @@ -41,9 +59,8 @@ body: What's annoying or missing right now. What does this fix. If it's just because you think it would be fun, say that, it's a real answer. placeholder: | - Backtracking across routes you've already cleared is slow, and the bike is a menu - dive away and doesn't work in a lot of places. Running would cut a lot of dead time - without making the game easier. + LÖVE already runs on Linux; without a packaged build, players have to assemble + it themselves and miss release notes / update checks. validations: required: true @@ -52,14 +69,12 @@ body: attributes: label: How should it work description: > - The specifics. Which button, which menu, what happens in the edge cases. If you don't + The specifics. Which menu, what happens in the edge cases. If you don't know, say what you'd expect as a player and leave the rest open. placeholder: | - - Hold B on the overworld to run - - Same speed as the bike - - Disabled anywhere the bike is disabled - - Sprite should probably need a running animation, not sure what that takes - - No effect on encounter rates + - GitHub Releases asset next to the .dmg / .exe + - Same options.lua / mods/ layout as desktop + - Documented in the README install section validations: required: true diff --git a/.github/ISSUE_TEMPLATE/mod_request.yml b/.github/ISSUE_TEMPLATE/mod_request.yml new file mode 100644 index 00000000..740b91e2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/mod_request.yml @@ -0,0 +1,115 @@ +name: Mod request +description: Ask for a gameplay, cosmetic, audio, or QoL change that belongs as a Lua mod. +labels: ["mod request"] +body: + - type: markdown + attributes: + value: | + This tracker is for ideas that should ship as **mods**, not as core engine + features — alternate sprites, running shoes, day/night, shiny indicators, + soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc. + + The engine already exposes a lot of this through registries and hooks + ([modding wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki)). + Use a **Feature request** instead for launcher / ports / video options / + networking / save tooling / new API seams. + + - type: input + id: summary + attributes: + label: One line summary + description: What the mod should do, in a sentence. + placeholder: Hold B to run at bike speed on the overworld + validations: + required: true + + - type: input + id: discord + attributes: + label: Discord username (optional) + description: > + So maintainers or mod authors can ping you on Discord if they pick this up. + Leave blank if you'd rather keep everything on GitHub. + placeholder: yourname + validations: + required: false + + - type: textarea + id: what + attributes: + label: What should the mod do + description: > + Describe the player-facing behavior. What changes, where, what does the + player see or press. If it toggles from Options or a START-menu entry, say so. + placeholder: | + Hold B while walking outdoors to move at bike speed. Release to walk again. + Same places the bike is allowed; no effect in battles or menus. + validations: + required: true + + - type: textarea + id: why + attributes: + label: Why is this worth doing as a mod + description: > + Why optional/modded rather than a core option. Who wants it on, who wants + vanilla left alone. + placeholder: | + Great for replaying and backtracking, but some people want a strict Gen 1 + pace. A mod (or an opt-in mod option) keeps both camps happy. + validations: + required: true + + - type: textarea + id: how + attributes: + label: How should it work + description: > + Buttons, menus, edge cases, whether it needs new art/audio. If you know a + hook or registry that fits (movement.speed, pokemon.sprite, rulesets, …), + mention it — otherwise leave it open. + placeholder: | + - Hold B on the overworld + - Same step timing as the bike + - Disabled where the bike is disabled + - Prefer hooks:wrap("movement.speed") if that still fits + validations: + required: true + + - type: dropdown + id: vanilla + attributes: + label: With the mod off, is vanilla unchanged + options: + - Yes — parity when disabled + - No — it would replace something always-on + - Not sure + validations: + required: true + + - type: dropdown + id: category + attributes: + label: Best-fit mod category + description: Same taxonomy as example mods (BALANCE, GRAPHICS, AUDIO, …). + options: + - GAMEPLAY / QoL + - GRAPHICS + - AUDIO + - BALANCE / ruleset + - CONTENT (maps, encounters, trainers) + - UI / TOOL + - TOTAL_CONVERSION-ish + - Not sure + validations: + required: true + + - type: textarea + id: extra + attributes: + label: Anything else + description: > + Reference screenshots, other games/hacks that do it, related issues, or + "I'd like to try writing this myself." Leave blank if nothing comes to mind. + validations: + required: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 306289ec..37e30ec4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,9 @@ name: ci on: push: - branches: [main] + # Integration branch + release branch. PRs already run via pull_request + # (any base); this list is only for post-merge push runs. + branches: [dev, main] pull_request: # a force-push while CI is mid-run should cancel the stale run, not queue diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7495d123..b323a133 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,9 @@ name: Release # - To force a specific version, either: # * run this workflow manually (Actions tab) and type it into "version", or # * put "[release X.Y.Z]" anywhere in the commit message. +# +# Branch model: day-to-day work merges to `dev`. Releases stay on `main` only +# so promoting `dev` -> `main` is the ship gate that cuts a build. on: push: diff --git a/docs/launcher.md b/docs/launcher.md index 57b14d76..e2c323e7 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -5,19 +5,39 @@ that runs before `Game:load`. Besides ROM import (see the file's own header) it hosts a tabbed shell covering per-game save slots and a mod manager. This file documents the runtime model; the visual spec lives separately. -## Android multi-ROM import +## Android multi-ROM / mod / save import -On Android, `love.system.pickFile()` opens the Storage Access Framework -picker (`GameActivity.showRomFilePicker`); the chosen file is copied to -`picked_rom.gb` in the app save directory. `RomImporter` then imports on -refocus / Choose via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps -to a version that is **not** yet ready counts as pending. A leftover -`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167). -After a successful import the consumed save-dir `.gb` is removed. +On Android, `love.system.pickFile([kind])` opens the Storage Access Framework +picker (`GameActivity.showFilePicker`); the chosen file is copied into the app +save directory as: + +| `kind` | Destination | +| --- | --- | +| nil / `"rom"` | `picked_rom.gb` (open) | +| `"mod"` | `picked_mod.zip` (open) | +| `"sav"` / `"save"` | `picked_save.sav` (open) | + +Export uses a separate API: `love.system.createFile(suggestedName)` → +`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies +staged `pending_export.sav` to the user-chosen URI and writes `export_done.flag` +for the launcher to acknowledge on refocus. + +`RomImporter` then imports on refocus / Choose: + +- **ROMs** via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps to a + version that is **not** yet ready counts as pending. A leftover + `picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167). +- **Mods** via `findPendingMod`: Prefer `picked_mod.zip`, or (on Choose) any + other `.zip` at the save-dir root (USB copy). +- **Saves** via `findPendingSav`: Prefer `picked_save.sav`, or (on Choose) any + other `.sav` at the save-dir root. + +After a successful import the consumed save-dir file is removed. **Manual check (device/emulator):** import Red → switch to Blue → Choose → system file picker must appear (not a silent Red re-extract) → pick Blue → -Blue becomes ready beside Red. +Blue becomes ready beside Red. On the MODS tab, Import mod .zip must open the +same system picker and install the chosen archive on return. ## Tab structure @@ -78,6 +98,10 @@ The launcher-facing API: - `SaveData.createSlot(version)` -> new slot id, registered but with **no save file written**. An empty slot means the title screen offers NEW GAME only, which needs no further changes. +- `SaveData.deleteSlot(version, slotId)` removes the slot's + main/`.bak`/`.tmp` files, drops it from the registry, and if it was active + points active at another remaining slot (or clears active when the list is + empty). The launcher's SAVE SLOT panel Delete control calls this. ## Launcher mod manager @@ -114,6 +138,9 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only temp first (mount only reaches save-dir-relative paths), the same way `RomImporter` handles a dropped ROM. A failed copy rolls its partial tree back, and every path unmounts and clears the staged temp file. +- `LauncherMods.uninstall(id)` removes `mods//` and clears + `options.mods[id]` so a later reinstall starts from the loader's default + (enabled). The mods panel Delete control calls this and re-derives the list. ## Import / Export save @@ -122,9 +149,10 @@ through `src/import/SaveFileIO.lua`, which sits on top of `src/save_convert/SaveConvert.lua` and the slot API in `SaveData`. - **Import save** is live once the game's ROM is imported (playable). It opens - a native `.sav` picker (`chooseSav`, the per-OS dialogs mirror `chooseZip`; - Android has no picker and shows a drop hint). `SaveFileIO.importToSlot` - reads the bytes (an absolute path, a dropped LOVE file, or raw bytes), + a native `.sav` picker (`chooseSav` on desktop; on Android, + `love.system.pickFile("sav")` → `picked_save.sav`, same SAF path as ROMs). + `SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir + relative name, a dropped LOVE file, or raw bytes), guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`), writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`). @@ -136,9 +164,13 @@ through `src/import/SaveFileIO.lua`, which sits on top of slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps `rawImport`, so this is a zero-filled template export, which is valid), and writes `exports/gen1recomp--.sav` in the save directory - (`love.filesystem.createDirectory("exports")`). It returns the absolute path - (`love.filesystem.getSaveDirectory()`), which the notice line shows with a - desktop "Open folder" affordance (`love.system.openURL("file://" .. dir)`). + (`love.filesystem.createDirectory("exports")`). On desktop it returns the + absolute path (`love.filesystem.getSaveDirectory()`), which the notice line + shows with an "Open folder" affordance (`love.system.openURL("file://" .. dir)`). + On Android the bytes are also staged as `pending_export.sav` and + `love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the + player can save to Downloads / Drive / etc.; on return `export_done.flag` + makes focus show "Save exported." - **Drag-drop.** `filedropped` routes a `.sav` to the import path for the currently active game tab; when a non-game tab (mods, or the locked yellow placeholder) is showing it defaults to red, the always-present first game diff --git a/mobile/ANDROID.md b/mobile/ANDROID.md index 6d3251b6..1d4d6849 100644 --- a/mobile/ANDROID.md +++ b/mobile/ANDROID.md @@ -43,11 +43,11 @@ The embedded `game.love` deliberately excludes `data/generated/`, `assets/generated/`, and any ROM. It contains the first-boot Lua importer and `tools/rom_manifest.json`. -ROM import on Android uses `love.system.pickFile()` → -`GameActivity.showRomFilePicker` (Storage Access Framework), which copies the -chosen file to `picked_rom.gb` under the app save directory. `RomImporter` -imports pending (not-yet-ready) `.gb` files from that folder on Choose / -refocus; see `docs/launcher.md` (Android multi-ROM import). The APK payload +ROM / mod / save import on Android uses `love.system.pickFile([kind])` → +`GameActivity.showFilePicker` (Storage Access Framework), which copies the +chosen file under the app save directory as `picked_rom.gb`, +`picked_mod.zip`, or `picked_save.sav`. `RomImporter` imports pending files +from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload itself remains data-free (no embedded ROM or generated cache). ### SDK / NDK diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties index a3c7a5f7..df2f35ec 100644 --- a/mobile/android/gradle.properties +++ b/mobile/android/gradle.properties @@ -18,4 +18,4 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=true android.nonFinalResIds=true -app.name=Pokemon Red +app.name=gen1recomp diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index 875d4b77..7f0cfdca 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -183,13 +183,37 @@ void vibrate(double seconds) env->DeleteLocalRef(activity); } -bool showFilePicker() +bool showFilePicker(const char *destFilename) { + if (destFilename == nullptr || destFilename[0] == '\0') + destFilename = "picked_rom.gb"; + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); jclass activity = env->FindClass("org/love2d/android/GameActivity"); - jmethodID method = env->GetStaticMethodID(activity, "showRomFilePicker", "()Z"); - jboolean result = env->CallStaticBooleanMethod(activity, method); + jmethodID method = env->GetStaticMethodID(activity, "showFilePicker", + "(Ljava/lang/String;)Z"); + jstring jname = env->NewStringUTF(destFilename); + jboolean result = env->CallStaticBooleanMethod(activity, method, jname); + env->DeleteLocalRef(jname); + + env->DeleteLocalRef(activity); + return result; +} + +bool showCreateDocument(const char *suggestedName) +{ + if (suggestedName == nullptr || suggestedName[0] == '\0') + suggestedName = "export.sav"; + + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + + jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument", + "(Ljava/lang/String;)Z"); + jstring jname = env->NewStringUTF(suggestedName); + jboolean result = env->CallStaticBooleanMethod(activity, method, jname); + env->DeleteLocalRef(jname); env->DeleteLocalRef(activity); return result; diff --git a/mobile/android/love/src/jni/love/src/common/android.h b/mobile/android/love/src/jni/love/src/common/android.h index 7902066b..10cd0802 100644 --- a/mobile/android/love/src/jni/love/src/common/android.h +++ b/mobile/android/love/src/jni/love/src/common/android.h @@ -63,9 +63,16 @@ void vibrate(double seconds); * Shows the system's "pick a document" UI (Storage Access Framework). * Returns true if the picker was launched; the picked file (if any) is * copied asynchronously by GameActivity.onActivityResult into the app's - * external save directory, not returned here -- see src/import/RomImporter.lua. + * external save directory under destFilename (default picked_rom.gb), not + * returned here -- see src/import/RomImporter.lua. **/ -bool showFilePicker(); +bool showFilePicker(const char *destFilename = nullptr); + +/** + * Shows ACTION_CREATE_DOCUMENT so Lua can export a staged pending_export.sav + * to a user-chosen location. suggestedName is the dialog default filename. + **/ +bool showCreateDocument(const char *suggestedName = nullptr); /* * Helper functions for the filesystem module diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.cpp b/mobile/android/love/src/jni/love/src/modules/system/System.cpp index 3afbbca0..0a844e58 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp @@ -22,6 +22,8 @@ #include "common/config.h" #include "System.h" +#include + #if defined(LOVE_MACOSX) #include #elif defined(LOVE_IOS) @@ -180,11 +182,32 @@ void System::vibrate(double seconds) const #endif } -bool System::pickFile() const +bool System::pickFile(const char *kind) const { #ifdef LOVE_ANDROID - return love::android::showFilePicker(); + const char *dest = "picked_rom.gb"; + if (kind != nullptr) + { + if (strcmp(kind, "mod") == 0) + dest = "picked_mod.zip"; + else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0) + dest = "picked_save.sav"; + else if (strcmp(kind, "rom") == 0) + dest = "picked_rom.gb"; + } + return love::android::showFilePicker(dest); #else + LOVE_UNUSED(kind); + return false; +#endif +} + +bool System::createFile(const char *suggestedName) const +{ +#ifdef LOVE_ANDROID + return love::android::showCreateDocument(suggestedName); +#else + LOVE_UNUSED(suggestedName); return false; #endif } diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h index 6542820c..c3a174fa 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.h +++ b/mobile/android/love/src/jni/love/src/modules/system/System.h @@ -111,9 +111,21 @@ public: * Android only for now; the result (if any) is not returned here -- see * love::android::showFilePicker and src/import/RomImporter.lua. * + * @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb, + * "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav. * @return Whether the picker was shown. **/ - virtual bool pickFile() const; + virtual bool pickFile(const char *kind = nullptr) const; + + /** + * Shows the platform's native "create / save a file" UI (Android SAF + * ACTION_CREATE_DOCUMENT). Copies staged pending_export.sav from the app + * save directory to the user-chosen URI. See GameActivity.showCreateDocument. + * + * @param suggestedName Default filename shown in the dialog. + * @return Whether the create dialog was shown. + **/ + virtual bool createFile(const char *suggestedName = nullptr) const; /** * Gets if the user is playing music on background. diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp index 567d2b46..07d572a1 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp @@ -97,7 +97,15 @@ int w_vibrate(lua_State *L) int w_pickFile(lua_State *L) { - luax_pushboolean(L, instance()->pickFile()); + const char *kind = luaL_optstring(L, 1, nullptr); + luax_pushboolean(L, instance()->pickFile(kind)); + return 1; +} + +int w_createFile(lua_State *L) +{ + const char *suggested = luaL_optstring(L, 1, nullptr); + luax_pushboolean(L, instance()->createFile(suggested)); return 1; } @@ -117,6 +125,7 @@ static const luaL_Reg functions[] = { "openURL", w_openURL }, { "vibrate", w_vibrate }, { "pickFile", w_pickFile }, + { "createFile", w_createFile }, { "hasBackgroundMusic", w_hasBackgroundMusic }, { 0, 0 } }; diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 8a9a63ae..96f4aee5 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -22,12 +22,15 @@ package org.love2d.android; import org.libsdl.app.SDLActivity; +import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.File; +import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -61,13 +64,24 @@ public class GameActivity extends SDLActivity { protected final int[] recordAudioRequestDummy = new int[1]; public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2; public static final int RECORD_AUDIO_REQUEST_CODE = 3; - public static final int ROM_PICKER_REQUEST_CODE = 4; - // Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked ROM + public static final int FILE_PICKER_REQUEST_CODE = 4; + public static final int FILE_CREATE_REQUEST_CODE = 5; + /** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */ + public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE; + // Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file // is dropped so RomImporter's existing folder scan finds it -- see // src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's // save directory to getExternalFilesDir()/save/). private static final String ROM_SAVE_IDENTITY = "pokemon-love2d"; private static final String PICKED_ROM_FILENAME = "picked_rom.gb"; + private static final String PICKED_MOD_FILENAME = "picked_mod.zip"; + private static final String PICKED_SAVE_FILENAME = "picked_save.sav"; + private static final String PENDING_EXPORT_FILENAME = "pending_export.sav"; + private static final String EXPORT_DONE_FILENAME = "export_done.flag"; + // Destination basename for the in-flight SAF pick (set by showFilePicker). + private String pendingPickFilename = PICKED_ROM_FILENAME; + // Suggested download name for the in-flight SAF create (set by showCreateDocument). + private String pendingCreateSuggestedName = "export.sav"; private static boolean immersiveActive = false; private static boolean needToCopyGameInArchive = false; private boolean storagePermissionUnnecessary = false; @@ -341,59 +355,185 @@ public class GameActivity extends SDLActivity { /** * Shows the system document picker (Storage Access Framework) so the - * player can pick their ROM from anywhere (Downloads, Drive, etc.) - * without needing to know where the app's external files folder is. - * Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any) + * player can pick a ROM / mod / save from anywhere (Downloads, Drive, + * etc.) without needing to know where the app's external files folder + * is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any) * arrives later in onActivityResult, not synchronously here. + * + * @param destFilename basename under the app save identity (e.g. + * picked_rom.gb, picked_mod.zip, picked_save.sav) */ @Keep - public static boolean showRomFilePicker() { + public static boolean showFilePicker(String destFilename) { if (android.os.Build.VERSION.SDK_INT < 19) return false; GameActivity self = (GameActivity) mSingleton; if (self == null) return false; + if (destFilename == null || destFilename.length() == 0) { + destFilename = PICKED_ROM_FILENAME; + } + // Reject path separators so a hostile JNI caller cannot escape the + // save identity directory. + if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) { + Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename); + return false; + } + self.pendingPickFilename = destFilename; Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("*/*"); try { - self.startActivityForResult(intent, ROM_PICKER_REQUEST_CODE); + self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE); return true; } catch (Exception e) { - Log.d("GameActivity", "could not open ROM file picker: " + e.getMessage()); + Log.d("GameActivity", "could not open file picker: " + e.getMessage()); return false; } } + /** ROM convenience wrapper; prefer showFilePicker with an explicit name. */ + @Keep + public static boolean showRomFilePicker() { + return showFilePicker(PICKED_ROM_FILENAME); + } + + /** Mod .zip convenience wrapper used by love.system.pickFile("mod"). */ + @Keep + public static boolean showModFilePicker() { + return showFilePicker(PICKED_MOD_FILENAME); + } + + /** Battery .sav convenience wrapper used by love.system.pickFile("sav"). */ + @Keep + public static boolean showSaveFilePicker() { + return showFilePicker(PICKED_SAVE_FILENAME); + } + + /** + * Shows ACTION_CREATE_DOCUMENT so the player can save a staged export + * (pending_export.sav in the app save identity) to Downloads / Drive / + * etc. Suggested name is the dialog's default filename. + */ + @Keep + public static boolean showCreateDocument(String suggestedName) { + if (android.os.Build.VERSION.SDK_INT < 19) return false; + GameActivity self = (GameActivity) mSingleton; + if (self == null) return false; + if (suggestedName == null || suggestedName.length() == 0) { + suggestedName = "export.sav"; + } + if (suggestedName.indexOf('/') >= 0 || suggestedName.indexOf('\\') >= 0) { + Log.d("GameActivity", "refusing unsafe create name: " + suggestedName); + return false; + } + File source = new File( + new File(self.getExternalFilesDir(null), "save"), + ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME); + if (!source.isFile()) { + Log.d("GameActivity", "no pending export at " + source); + return false; + } + + self.pendingCreateSuggestedName = suggestedName; + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("application/octet-stream"); + intent.putExtra(Intent.EXTRA_TITLE, suggestedName); + try { + self.startActivityForResult(intent, FILE_CREATE_REQUEST_CODE); + return true; + } catch (Exception e) { + Log.d("GameActivity", "could not open create-document picker: " + e.getMessage()); + return false; + } + } + + private File saveIdentityDir() { + return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY); + } + + private boolean copyFileToUri(File source, Uri destUri) { + InputStream in = null; + OutputStream out = null; + try { + in = new BufferedInputStream(new FileInputStream(source)); + out = getContentResolver().openOutputStream(destUri); + if (out == null) return false; + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + } + out.flush(); + return true; + } catch (IOException e) { + Log.d("GameActivity", "copy to URI failed: " + e.getMessage()); + return false; + } finally { + try { if (in != null) in.close(); } catch (IOException ignored) {} + try { if (out != null) out.close(); } catch (IOException ignored) {} + } + } + @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); - if (requestCode != ROM_PICKER_REQUEST_CODE) return; + if (requestCode == FILE_CREATE_REQUEST_CODE) { + if (resultCode != RESULT_OK || data == null || data.getData() == null) { + Log.d("GameActivity", "create-document cancelled"); + return; + } + File source = new File(saveIdentityDir(), PENDING_EXPORT_FILENAME); + if (!source.isFile()) { + Log.d("GameActivity", "pending export missing at result time"); + return; + } + Uri uri = data.getData(); + if (copyFileToUri(source, uri)) { + // Signal Lua on next focus that the SAF export finished. + File flag = new File(saveIdentityDir(), EXPORT_DONE_FILENAME); + try { + FileOutputStream fos = new FileOutputStream(flag, false); + fos.write("ok".getBytes()); + fos.close(); + } catch (IOException e) { + Log.d("GameActivity", "could not write export_done flag: " + e.getMessage()); + } + // Keep pending_export.sav so a retry still works; Lua may remove it. + } else { + Log.d("GameActivity", "could not write export to " + uri); + } + return; + } + if (requestCode != FILE_PICKER_REQUEST_CODE) return; if (resultCode != RESULT_OK || data == null || data.getData() == null) { - Log.d("GameActivity", "ROM picker returned no file (cancelled?)"); + Log.d("GameActivity", "file picker returned no file (cancelled?)"); return; } Uri uri = data.getData(); - File destDir = new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY); + File destDir = saveIdentityDir(); if (!destDir.exists() && !destDir.mkdirs()) { Log.d("GameActivity", "could not create " + destDir); return; } - File destFile = new File(destDir, PICKED_ROM_FILENAME); + String destName = pendingPickFilename != null + ? pendingPickFilename : PICKED_ROM_FILENAME; + File destFile = new File(destDir, destName); InputStream source; try { source = getContentResolver().openInputStream(uri); } catch (FileNotFoundException e) { - Log.d("GameActivity", "could not open picked ROM: " + e.getMessage()); + Log.d("GameActivity", "could not open picked file: " + e.getMessage()); return; } if (source == null) { - Log.d("GameActivity", "ContentResolver returned no stream for picked ROM"); + Log.d("GameActivity", "ContentResolver returned no stream for picked file"); return; } if (!copyAssetFile(source, destFile.getPath())) { - Log.d("GameActivity", "could not copy picked ROM to " + destFile); + Log.d("GameActivity", "could not copy picked file to " + destFile); } } diff --git a/mods/examples/README.md b/mods/examples/README.md index b47d539c..967bbe2c 100644 --- a/mods/examples/README.md +++ b/mods/examples/README.md @@ -1,6 +1,6 @@ # Example mod gallery -Seven reference mods, one per modder persona. Each is small enough to read +Eight reference mods, one per modder persona. Each is small enough to read in one sitting, exercises a different slice of the mod API, and is a real, runnable, tested mod — not a snippet. @@ -16,6 +16,7 @@ Copy the one closest to what you want to build. | 5 | [`example_weather`](example_weather) | Mechanic designer | `MECHANIC` | Rain that scales WATER and FIRE damage, behind a ruleset | | 6 | [`example_dexnav`](example_dexnav) | Tool builder | `TOOL` | A START-menu dex overlay with an inter-mod API | | 7 | [`example_mini_conversion`](example_mini_conversion) | TC team | `TOTAL_CONVERSION` | Sable Cove: one town, three species, one badge | +| 8 | [`example_silly_oak`](example_silly_oak) | Intro author | `UI` | Extra Oak questions, sprite swaps, answers in `mod.save` | ## None of these load by default @@ -46,7 +47,8 @@ and `field`, and exercises: - both buses — `events:on` / `events:emit` and `hooks:wrap` — across `music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`, `battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`, - `game.ready` and `assets.transformed` + `game.ready`, `assets.transformed`, `intro.oak_speech.build`, + `intro.oak_speech.answered` and `intro.oak_speech.finished` - `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`, `mod:read`, `mod.log` and `mod.path` - asset transforms, the `trueColor` opt-out, script labels and `choice`, @@ -66,7 +68,7 @@ mods/examples// .modkitignore keeps the suite out of the distributed package ``` -`tests/mod_examples_tests.lua` in the engine's own suite loads all seven +`tests/mod_examples_tests.lua` in the engine's own suite loads all eight together and asserts the above, so the gallery cannot rot. These example mods arent perfect and are just examples to show you basics of wahts possible, but way more than just this is possible. \ No newline at end of file diff --git a/mods/examples/example_silly_oak/CHANGELOG.md b/mods/examples/example_silly_oak/CHANGELOG.md new file mode 100644 index 00000000..ec81d39b --- /dev/null +++ b/mods/examples/example_silly_oak/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/). +Version headings match `manifest.json`'s `version`. + +## 1.0.0 + +### Added + +- `intro.oak_speech.build` wrap that injects toast / MEW / snack / rival-trust / pineapple beats. +- Answers written to `mod.save` via `intro.oak_speech.answered`. +- Custom `toast_kid.png` sprite shown mid-speech. diff --git a/mods/examples/example_silly_oak/README.md b/mods/examples/example_silly_oak/README.md new file mode 100644 index 00000000..7278827e --- /dev/null +++ b/mods/examples/example_silly_oak/README.md @@ -0,0 +1,43 @@ +# Silly Oak Intro Example + +Hooks Oak's NEW GAME speech: extra questions, sprite swaps (Oak, rival, +player, MEW, and a custom Toast Kid pic), and answers stored in `mod.save`. + +## Try it (play through yourself) + +```sh +rm -rf mods/example_silly_oak +cp -r mods/examples/example_silly_oak mods/ +love . +``` + +Then **NEW GAME** and mash A / pick the menus. Disable or delete +`mods/example_silly_oak` when you're done so vanilla boots clean. + +## Headless check + +```sh +luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua +``` + +## Auto driver (screenshots + save asserts) + +```sh +rm -rf mods/example_silly_oak +cp -r mods/examples/example_silly_oak mods/ +SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \ + POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua POKEPORT_SPEED=8 love . +``` + +`POKEPORT_IDENTITY` keeps this run's save out of your normal slot. + +## What it demonstrates + +| Seam | Where | +|---|---| +| `hooks:wrap("intro.oak_speech.build")` | `main.lua` — reshape the step list | +| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` — anchored on vanilla step ids | +| step kinds `say` / `yesno` / `choice` | `main.lua` | +| pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` | +| `events:on("intro.oak_speech.answered")` | `main.lua` → `mod.save` | +| `events:on("intro.oak_speech.finished")` | `main.lua` | diff --git a/mods/examples/example_silly_oak/assets/toast_kid.png b/mods/examples/example_silly_oak/assets/toast_kid.png new file mode 100644 index 00000000..4778bddd Binary files /dev/null and b/mods/examples/example_silly_oak/assets/toast_kid.png differ diff --git a/mods/examples/example_silly_oak/main.lua b/mods/examples/example_silly_oak/main.lua new file mode 100644 index 00000000..8fe9ae02 --- /dev/null +++ b/mods/examples/example_silly_oak/main.lua @@ -0,0 +1,107 @@ +-- Gallery entry: reshape Oak's intro speech with extra questions, sprite +-- swaps (oak / rival / player / pokemon / a custom image), and answers +-- that land in mod.save. +-- +-- Uses hooks:wrap("intro.oak_speech.build") plus intro.oak_speech.answered. + +return function(mod) + local toastPic = mod.path .. "/assets/toast_kid.png" + + mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech) + steps = next(steps, speech) + + -- after oak says hello, immediately derail + mod.ui.insertStepAfter(steps, "oak_welcome", { + id = "silly_quiz_intro", + kind = "say", + pic = "oak", + text = "Before we start,\nI have a few\vquestions.\fImportant ones.\nScientific ones.", + }) + + mod.ui.insertStepAfter(steps, "silly_quiz_intro", { + id = "silly_toast", + kind = "yesno", + pic = "oak", + saveKey = "likes_toast", + text = "Do you like\ntoast?", + }) + + -- brand new sprite mid-speech + mod.ui.insertStepAfter(steps, "silly_toast", { + id = "silly_toast_kid", + kind = "say", + pic = { type = "image", path = toastPic }, + reveal = "fade", + saveKey = nil, + text = "This is Toast Kid.\nHe is not a\vPOKéMON.\fHe just showed up\none day.\fAnyway.", + }) + + -- existing mon with a wipe + cry, parked after the real demo mon + mod.ui.insertStepAfter(steps, "demo_mon", { + id = "silly_mew", + kind = "say", + pic = { type = "pokemon", id = "MEW" }, + reveal = "wipe", + cry = "MEW", + text = "This is MEW.\nPlease do not\vtell anyone\vI showed you.", + }) + + mod.ui.insertStepAfter(steps, "silly_mew", { + id = "silly_snack", + kind = "choice", + pic = "oak", + saveKey = "snack", + text = "Pick a snack.\nThis goes on\vyour permanent\vrecord.", + choices = { "BERRIES", "LEFTOVERS", "OLD ROD" }, + }) + + -- swap to rival pic for a loaded question before naming him + mod.ui.insertStepBefore(steps, "ask_rival_name", { + id = "silly_trust", + kind = "choice", + pic = "rival", + reveal = "fade", + saveKey = "trusts_rival", + text = "Look at this kid.\nTrustworthy?", + choices = { "SURE", "NO" }, + values = { true, false }, + }) + + -- player pic for one last bit after both names are set + mod.ui.insertStepAfter(steps, "name_rival", { + id = "silly_pineapple", + kind = "yesno", + pic = "player", + saveKey = "pineapple_on_pizza", + text = "{PLAYER}. Be honest.\nPineapple on\vpizza?", + }) + + mod.ui.insertStepAfter(steps, "silly_pineapple", { + id = "silly_closing", + kind = "say", + pic = "oak", + text = "Great. Terrible.\nI have notes.\fLet's pretend this\nwas normal.", + }) + + return steps + end) + + -- every answered step with a saveKey lands in mod.save (and therefore + -- save.modData[mod.id] once the slot is written) + mod.events:on("intro.oak_speech.answered", function(ev) + if not ev.saveKey then return end + mod.save:set(ev.saveKey, ev.value) + mod.log:info("intro answer %s = %s", tostring(ev.saveKey), tostring(ev.value)) + end) + + mod.events:on("intro.oak_speech.finished", function(ev) + local answers = ev.answers or {} + for key, value in pairs(answers) do + if mod.save:get(key) == nil then + mod.save:set(key, value) + end + end + mod.save:set("quiz_done", true) + mod.log:info("silly oak quiz done") + end) +end diff --git a/mods/examples/example_silly_oak/manifest.json b/mods/examples/example_silly_oak/manifest.json new file mode 100644 index 00000000..928b5889 --- /dev/null +++ b/mods/examples/example_silly_oak/manifest.json @@ -0,0 +1,15 @@ +{ + "id": "example_silly_oak", + "name": "Silly Oak Intro Example", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "profile": "content", + "category": "UI", + "game_version": ">=0.0.0-0 <2.0.0", + "priority": 100, + "dependencies": [], + "optional_dependencies": [], + "conflicts": [], + "description": "Reshapes Oak's intro speech with extra questions, sprite swaps, and answers saved to mod.save." +} diff --git a/mods/examples/example_silly_oak/mod.card b/mods/examples/example_silly_oak/mod.card new file mode 100644 index 00000000..71b1b029 --- /dev/null +++ b/mods/examples/example_silly_oak/mod.card @@ -0,0 +1,21 @@ +-- Sharing metadata for the manager detail pane. +return { + summary = "Oak asks dumb questions during the intro and remembers your answers.", + author = "Pokemon Gen 1 Recompilation Project", + contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project", + tags = { "intro", "ui", "oak", "hooks" }, + differences = { + changed = { + "Oak's NEW GAME speech gains extra questions and sprite beats", + }, + added = { + "mod.save keys: likes_toast, snack, trusts_rival, pineapple_on_pizza, quiz_done", + "Custom Toast Kid pic mid-intro", + }, + known = { "vanilla naming and the shrink-away still run" }, + }, + credits = { + { who = "Pokemon Gen 1 Recompilation Project", for_ = "the intro.oak_speech hooks" }, + }, + compat = { engine = ">=0.0.0 <2.0.0", modApi = 2 }, +} diff --git a/mods/examples/example_silly_oak/tests/example_silly_oak_test.lua b/mods/examples/example_silly_oak/tests/example_silly_oak_test.lua new file mode 100644 index 00000000..1bceba2c --- /dev/null +++ b/mods/examples/example_silly_oak/tests/example_silly_oak_test.lua @@ -0,0 +1,159 @@ +-- Standalone: luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua +-- Covers the intro.oak_speech build hook, step helpers, sprite descriptors, +-- and answers landing in mod.save. +-- +-- Needs an imported ROM dataset (data/generated/). Headless CI and a +-- fresh checkout without a ROM skip cleanly -- the gallery is also +-- covered by tests/mod_examples_tests.lua when generated data is present. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local function hasGenerated() + local handle = io.open("data/generated/constants.lua", "r") + if handle then handle:close() return true end + return false +end +if not hasGenerated() then + print("example_silly_oak_test skipped (needs data/generated/)") + os.exit(0) +end + +local T = require("tests.modkit") +local Runtime = require("src.mods.Runtime") +local OakSpeech = require("src.ui.OakSpeech") +local Data = require("src.core.Data") +Data:load() + +local run = T.sdk.loadMod("mods/examples/example_silly_oak", { data = Data }) +T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")") + +local mod = run.mod +T.check(mod ~= nil and mod.state == "loaded", "mod reached the loaded state") +local ModUI = require("src.ui.ModUI") +local bucket = function() + return run.loader.modSave.example_silly_oak or {} +end +local toastPath = (mod.path or "mods/examples/example_silly_oak") + .. "/assets/toast_kid.png" + +-- ------- build hook injects every silly beat around vanilla anchors + +local speech = OakSpeech.new({ + data = Data, + save = { player = { name = "RED", rival = "BLUE" } }, + stack = { push = function() end, pop = function() end }, +}, nil) +local steps = speech:buildSteps() + +local ids = {} +for _, step in ipairs(steps) do ids[#ids + 1] = step.id end +local function has(id) + for _, x in ipairs(ids) do if x == id then return true end end + return false +end + +T.check(has("oak_welcome") and has("name_player") and has("shrink"), + "vanilla anchors still present") +T.check(has("silly_quiz_intro") and has("silly_toast") and has("silly_toast_kid"), + "toast quiz beats injected") +T.check(has("silly_mew") and has("silly_snack"), + "MEW reveal and snack choice injected") +T.check(has("silly_trust") and has("silly_pineapple") and has("silly_closing"), + "rival trust + pineapple beats injected") + +-- order: toast kid before demo_mon, mew after demo_mon, trust before rival ask +local function indexOf(id) + for i, x in ipairs(ids) do if x == id then return i end end + return 0 +end +T.check(indexOf("silly_toast_kid") < indexOf("demo_mon"), + "Toast Kid shows before the demo mon") +T.check(indexOf("demo_mon") < indexOf("silly_mew"), + "MEW shows after the demo mon") +T.check(indexOf("silly_trust") < indexOf("ask_rival_name"), + "trust question is before rival naming") +T.check(indexOf("name_rival") < indexOf("silly_pineapple") + and indexOf("silly_pineapple") < indexOf("legend"), + "pineapple lands between rival name and the legend beat") + +-- ------- step shapes cover choice / yesno / custom image / pokemon + +local byId = {} +for _, step in ipairs(steps) do byId[step.id] = step end + +T.eq(byId.silly_toast.kind, "yesno", "toast is a yes/no") +T.eq(byId.silly_toast.saveKey, "likes_toast", "toast writes likes_toast") +T.eq(byId.silly_snack.kind, "choice", "snack is a multi choice") +T.eq(#byId.silly_snack.choices, 3, "snack has three options") +T.check(byId.silly_toast_kid.pic and byId.silly_toast_kid.pic.type == "image", + "Toast Kid uses a custom image pic") +T.check(byId.silly_mew.pic and byId.silly_mew.pic.type == "pokemon" + and byId.silly_mew.pic.id == "MEW" and byId.silly_mew.cry == "MEW", + "MEW beat uses pokemon pic + cry") +T.eq(byId.silly_trust.pic, "rival", "trust question shows the rival pic") + +-- ------- resolvePic covers trainer / pokemon / player / image shorthand + +local oakImg = OakSpeech.resolvePic({ data = Data }, "oak", speech) +local rivalImg = OakSpeech.resolvePic({ data = Data }, "rival", speech) +local playerImg = OakSpeech.resolvePic({ data = Data }, "player", speech) +local mewImg, mewFlip = OakSpeech.resolvePic({ data = Data }, + { type = "pokemon", id = "MEW", flip = true }, speech) +local customImg = OakSpeech.resolvePic({ data = Data }, + { type = "image", path = toastPath }, speech) +-- headless love stub may return nil images; the call itself must not throw +T.check(oakImg == speech.oakPic or oakImg == nil or type(oakImg) == "userdata" + or type(oakImg) == "table", + "oak shorthand resolves without error") +T.check(rivalImg == speech.rivalPic or rivalImg == nil or type(rivalImg) == "userdata" + or type(rivalImg) == "table", + "rival shorthand resolves without error") +T.check(playerImg == speech.playerPic or playerImg == nil + or type(playerImg) == "userdata" or type(playerImg) == "table", + "player shorthand resolves without error") +T.check(mewFlip == true, "pokemon flip flag is honored") +T.check(customImg ~= nil or true, "custom image path is accepted") + +-- ------- answered event writes mod.save (loader.modSave bucket) + +Runtime.emit("intro.oak_speech.answered", { + saveKey = "likes_toast", value = true, label = "YES", index = 1, + step = byId.silly_toast, speech = speech, +}) +Runtime.emit("intro.oak_speech.answered", { + saveKey = "snack", value = "OLD ROD", label = "OLD ROD", index = 3, + step = byId.silly_snack, speech = speech, +}) +Runtime.emit("intro.oak_speech.answered", { + saveKey = "trusts_rival", value = false, label = "NO", index = 2, + step = byId.silly_trust, speech = speech, +}) +Runtime.emit("intro.oak_speech.answered", { + saveKey = "pineapple_on_pizza", value = true, label = "YES", index = 1, + step = byId.silly_pineapple, speech = speech, +}) +Runtime.emit("intro.oak_speech.finished", { + speech = speech, answers = speech.answers, +}) + +local saved = bucket() +T.eq(saved.likes_toast, true, "likes_toast saved") +T.eq(saved.snack, "OLD ROD", "snack saved") +T.eq(saved.trusts_rival, false, "trusts_rival saved") +T.eq(saved.pineapple_on_pizza, true, "pineapple_on_pizza saved") +T.eq(saved.quiz_done, true, "quiz_done stamped on finish") + +-- ------- ModUI step helpers (public surface) + +local tiny = { + { id = "a", kind = "say" }, + { id = "b", kind = "say" }, +} +ModUI.insertStepAfter(tiny, "a", { id = "mid", kind = "choice" }) +T.eq(tiny[2].id, "mid", "insertStepAfter lands behind the anchor") +ModUI.insertStepBefore(tiny, "b", { id = "pre_b", kind = "yesno" }) +T.eq(tiny[3].id, "pre_b", "insertStepBefore lands ahead of the anchor") +ModUI.removeStep(tiny, "mid") +T.check(tiny[2].id ~= "mid", "removeStep drops by id") + +run.release() +T.finish("example_silly_oak") diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 3d84675c..51ad400d 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -299,8 +299,13 @@ local function makeBattler(data, mon, isPlayer, save) curStats = mon.stats, curTypes = def.types, curMoves = mon.moves, - sprite = getImage(isPlayer and def.spriteBack or def.spriteFront, - monPalette(data, mon.species), def.trueColor), + sprite = (function() + local Sprites = require("src.pokemon.Sprites") + local path, tc = Sprites.path(data, mon.species, + isPlayer and "back" or "front", + { mon = mon, kind = "battle" }) + return getImage(path, monPalette(data, mon.species), tc) + end)(), } end @@ -318,13 +323,16 @@ BattleState.makeBattler = makeBattler function BattleState:speciesSprite(species, isPlayerSide) local def = self.data.pokemon[species] if not def then return nil end + local Sprites = require("src.pokemon.Sprites") + local path, tc = Sprites.path(self.data, species, + isPlayerSide and "back" or "front", { kind = "battle" }) local PaletteFX = require("src.render.PaletteFX") local colors = PaletteFX.monPal(self.data, species, true) local name = "GRAYMON" if PaletteFX.usesGbcPack() then name = "redpp:GRAYMON" end - return getImage(isPlayerSide and def.spriteBack or def.spriteFront, + return getImage(path, colors and { name = name, colors = colors } or nil, - def.trueColor) + tc) end local function markSeen(game, species) @@ -3868,8 +3876,11 @@ function BattleState:colorMode() if g and g.newCanvas and g.setScissor and g.setShader and g.getCanvas and love.image and PaletteFX.pack(self.data) and PaletteFX.shader() then - local ok1, bg = pcall(g.newCanvas, 160, 144) - local ok2, wv = pcall(g.newCanvas, 160, 144) + -- 160x144 real pixels, not DPI units, or the colored battle background + -- resamples against the UI canvas on mobile (#208; PixelCanvas.lua) + local PixelCanvas = require("src.render.PixelCanvas") + local ok1, bg = pcall(PixelCanvas.new, 160, 144) + local ok2, wv = pcall(PixelCanvas.new, 160, 144) if ok1 and ok2 and bg and wv then self.bgCanvas, self.waveCanvas = bg, wv ready = true @@ -4473,6 +4484,11 @@ function BattleState:draw() love.graphics.rectangle("fill", 0, 0, 160, 144) end love.graphics.setColor(1, 1, 1, 1) + -- battle.overlay: shiny sparkles, custom HUD chrome, etc. Draw-only; + -- the vanilla link is a no-op so an empty chain costs nothing. + if Runtime.wantsHook("battle.overlay") then + Runtime.call("battle.overlay", function() end, self) + end end return BattleState diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index cc29ef58..31b7c598 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -581,8 +581,12 @@ MoveEffects.full = { }, HYPER_BEAM_EFFECT = { afterDamage = function(ctx) - -- no recharge when the target faints OR its substitute breaks - if ctx.target.mon.hp > 0 and not ctx.brokeSub then + -- Gen 1: no recharge when the target faints OR its substitute breaks. + -- Ruleset hyperBeamSkipRechargeOnKO=false forces Gen 2+ always-recharge. + local ruleset = ctx.battle and ctx.battle.ruleset + local skipOnKO = not ruleset or ruleset.hyperBeamSkipRechargeOnKO ~= false + local targetDown = ctx.target.mon.hp <= 0 or ctx.brokeSub + if not skipOnKO or not targetDown then ctx.user.mustRecharge = true end end, diff --git a/src/battle/rulesets/gen1_faithful.lua b/src/battle/rulesets/gen1_faithful.lua index 9b517c9d..7b338511 100644 --- a/src/battle/rulesets/gen1_faithful.lua +++ b/src/battle/rulesets/gen1_faithful.lua @@ -16,4 +16,7 @@ return { -- Wild/trainer enemies never spend PP (DecrementPP only touches the -- player side in pokered). They therefore never Struggle from empty PP. enemyUnlimitedPP = true, + -- Gen 1 Hyper Beam: no recharge when the target faints (or its + -- substitute breaks). Set false to always recharge like Gen 2+. + hyperBeamSkipRechargeOnKO = true, } diff --git a/src/battle/rulesets/modern_clean.lua b/src/battle/rulesets/modern_clean.lua index 28a8c466..d251d301 100644 --- a/src/battle/rulesets/modern_clean.lua +++ b/src/battle/rulesets/modern_clean.lua @@ -11,4 +11,6 @@ return { focusEnergyBug = false, -- Gen 2+ style: AI opponents deplete PP and can Struggle when empty. enemyUnlimitedPP = false, + -- Gen 2+: Hyper Beam always forces a recharge turn, even on a KO. + hyperBeamSkipRechargeOnKO = false, } diff --git a/src/core/Game.lua b/src/core/Game.lua index 8049f32c..195915a5 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -96,6 +96,20 @@ function Game:load() end Logger.info("game loaded") + -- Scaling bug reports (#87, #208) are unanswerable without these three + -- numbers: LOVE units, drawable pixels, and the integer physical pixels + -- per GB pixel the renderer settled on. Cheap, once, and it turns "it + -- looks stretched" into something reproducible. + if love.graphics and love.graphics.getDimensions then + local ww, wh = love.graphics.getDimensions() + local pw, ph = ww, wh + if love.graphics.getPixelDimensions then + pw, ph = love.graphics.getPixelDimensions() + end + Logger.info(string.format( + "display: %dx%d units, %dx%d px, fit scale %d px/GB px", + ww, wh, pw, ph, Renderer:fitScale())) + end end -- the merged field.boot: spawn, names, money and the naming presets a diff --git a/src/core/Music.lua b/src/core/Music.lua index 324f09fc..74b4b1ff 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -24,7 +24,31 @@ local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 } local filterLevel = 0 local function applyVolume(src) - if src then pcall(src.setVolume, src, VOLUME * volumeScale) end + if not src then return end + local vol = VOLUME * volumeScale + if Runtime.wantsHook("music.volume") then + local ctx = { + song = state.current, + mapSong = state.mapSong, + onBike = state.onBike, + surfing = state.surfing, + fading = state.fade ~= nil, + optionScale = volumeScale, + } + local ok, Game = pcall(require, "src.core.Game") + if ok and Game then + local ow = Game.overworld + if ow and ow.player then + ctx.x, ctx.y = ow.player.cellX, ow.player.cellY + ctx.mapId = ow.map and ow.map.id + ctx.tod = ow.tod + end + end + vol = Runtime.call("music.volume", function(v) return v end, vol, ctx) + vol = tonumber(vol) or (VOLUME * volumeScale) + if vol < 0 then vol = 0 end + end + pcall(src.setVolume, src, vol) end -- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered @@ -408,6 +432,12 @@ end -- restores the map theme after a one-shot jingle function Music.update(data) if state.chip then require("src.core.ChipAudio").update() end + -- distance / indoor muffling mods re-apply volume every frame while + -- subscribed; otherwise applyVolume only runs on song/option changes + if Runtime.wantsHook("music.volume") and not state.fade then + applyVolume(state.source) + applyVolume(state.loopSource) + end -- volume ramp (Music.fadeOut): hold the current level for `control` -- frames, then drop one level (FadeOutAudio decrements both rAUDVOL -- nibbles when its counter reaches 0); at level 0 the music stops. diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 1214db33..d9f6182b 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -552,6 +552,44 @@ function SaveData.writeSlot(version, slotId, saveTable) return true end +-- Delete a registered slot: remove its main/.bak/.tmp files, drop it from the +-- options registry, and if it was active point active at another remaining +-- slot (or clear active when the list is empty). Returns true, or false + +-- an error string when the id is unknown / not registered. +function SaveData.deleteSlot(version, slotId) + version = version or GameVersion.get() + if not knownVersion(version) then return false, "unknown version" end + if type(slotId) ~= "string" or slotId == "" then + return false, "missing slot id" + end + local fs = persistFs(nil) + ensureVersionSlots(version, fs) + local opts = SaveData.loadOptions(fs) + opts.saveSlots = opts.saveSlots or {} + local reg = opts.saveSlots[version] + if not reg or not reg.list then return false, "slot not registered" end + local found, idx = false, nil + for i, id in ipairs(reg.list) do + if id == slotId then found = true; idx = i; break end + end + if not found then return false, "slot not registered" end + + local main, bak, tmp = slotNames(version, slotId) + remove(fs, main) + remove(fs, bak) + remove(fs, tmp) + + table.remove(reg.list, idx) + if reg.active == slotId then + reg.active = reg.list[1] -- may be nil when the list is now empty + end + opts.saveSlots[version] = reg + SaveData.saveOptions(opts, fs) + slotsChecked[version] = true + activeSlotCache[version] = reg.active or false + return true +end + -- Test seam: drop the process-global slot cache so a suite can exercise -- migration/resolution against a freshly injected filesystem. Unused by -- the game, which resolves each version exactly once per boot. diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 57546109..de9f14c2 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -290,6 +290,39 @@ local function findPendingRom(ready) return nil end +-- Android SAF writes mod picks to picked_mod.zip; USB copies may use any +-- .zip basename at the save-dir root. preferAny=true also accepts those USB +-- copies (Choose / Import); focus only consumes the SAF basename so a random +-- leftover archive is never auto-installed on every refocus. +local function findPendingMod(preferAny) + local preferred = "picked_mod.zip" + if love.filesystem.getInfo(preferred, "file") then + return preferred + end + if not preferAny then return nil end + for _, name in ipairs(love.filesystem.getDirectoryItems("")) do + if name:lower():match("%.zip$") and love.filesystem.getInfo(name, "file") then + return name + end + end + return nil +end + +-- Same pattern as findPendingMod for battery saves (picked_save.sav / *.sav). +local function findPendingSav(preferAny) + local preferred = "picked_save.sav" + if love.filesystem.getInfo(preferred, "file") then + return preferred + end + if not preferAny then return nil end + for _, name in ipairs(love.filesystem.getDirectoryItems("")) do + if name:lower():match("%.sav$") and love.filesystem.getInfo(name, "file") then + return name + end + end + return nil +end + local function chooseRom(promptName) promptName = promptName or "Pokemon" local prompt = "Choose your " .. promptName .. " ROM" @@ -320,8 +353,8 @@ local function chooseRom(promptName) end -- Open a native picker for a mod .zip (mirrors chooseRom's per-OS dialogs). --- Returns the chosen absolute path or nil. Android has no picker; the mods --- panel steers that case to the drag-drop hint instead. +-- Returns the chosen absolute path or nil. Android uses love.system.pickFile +-- ("mod") instead -- see RomImporter:chooseMod. local function chooseZip() local prompt = "Choose a mod .zip" local platform = love.system.getOS() @@ -351,8 +384,8 @@ local function chooseZip() end -- Open a native picker for a raw .sav battery save (mirrors chooseZip's per-OS --- dialogs). Returns the chosen absolute path or nil. Android has no picker; --- the SAVE FILES card steers that case to the drag-drop hint instead. +-- dialogs). Returns the chosen absolute path or nil. Android uses +-- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport. local function chooseSav() local prompt = "Choose a .sav save file" local platform = love.system.getOS() @@ -429,10 +462,16 @@ function RomImporter.new(onComplete, opts) -- affordance (desktop love.system.openURL). saveNotice = {}, -- MODS panel state (pass 3): mods is the cached LauncherMods.list() array - -- (refreshed lazily on first draw and after any toggle/install); modScroll - -- is the list scroll offset (px, clamped in draw); modNotice is the last - -- install result { ok, text } shown as a line above the list. + -- (refreshed lazily on first draw and after any toggle/install/delete); + -- modScroll is the list scroll offset (px, clamped in draw); modNotice is + -- the last install/delete result { ok, text } shown as a line above the list. mods = nil, modScroll = 0, modNotice = nil, + -- Android SAF: which game tab should receive the next picked_save.sav when + -- focus consumes it (set by chooseSaveImport before opening the picker). + androidPendingVersion = nil, + -- Android SAF create-document: which game's SAVE FILES card should show + -- "Save exported." when export_done.flag appears on focus. + androidPendingExportVersion = nil, }, RomImporter) for _, version in ipairs(GameVersion.ORDER) do @@ -491,10 +530,40 @@ end -- The system picker runs as a separate top activity, so LOVE's own -- love.focus/love.visible pause while it's up (see main.lua) -- once the -- player returns here with a file picked, GameActivity has already copied --- it into the save directory, so a pending-ROM rescan on refocus picks it --- up without the player needing to tap the button again. +-- it into the save directory, so a pending-file rescan on refocus picks it +-- up without the player needing to tap the button again. Mod and save SAF +-- drops (picked_mod.zip / picked_save.sav) are consumed first so a leftover +-- ROM pick cannot steal the focus path when both games are already ready. function RomImporter:focus(f) if not (f and self.android and self.workState ~= "working") then return end + -- SAF create-document finished: GameActivity wrote export_done.flag. + if love.filesystem.getInfo("export_done.flag", "file") then + love.filesystem.remove("export_done.flag") + love.filesystem.remove("pending_export.sav") + local version = self.androidPendingExportVersion or self:_savedropTarget() + self.androidPendingExportVersion = nil + self.saveNotice[version] = { ok = true, text = "Save exported." } + if self.tab == "mods" or self.tab == "yellow" then self.tab = version end + return + end + local modName = findPendingMod(false) + if modName then + self:_installMod(modName) + if self.modNotice and self.modNotice.ok then + love.filesystem.remove(modName) + end + return + end + local savName = findPendingSav(false) + if savName then + local version = self.androidPendingVersion or self:_savedropTarget() + self.androidPendingVersion = nil + self:_importSave(version, savName) + if self.saveNotice[version] and self.saveNotice[version].ok then + love.filesystem.remove(savName) + end + return + end if self.ready.red and self.ready.blue then return end local name, data = findPendingRom(self.ready) if name then self:startData(data, name) end @@ -672,13 +741,38 @@ function RomImporter:_installMod(source) self.tab = "mods" end --- "Import mod .zip" button: open a native picker (desktop) and install the --- pick. Android has no picker, so it points the player at the drop path. +-- Remove an installed mod from the save-dir mods/ tree and refresh the panel. +function RomImporter:_deleteMod(id) + if self.workState == "working" then return end + local LauncherMods = require("src.mods.LauncherMods") + local ok, res = LauncherMods.uninstall(id) + if ok then + self:_refreshMods() + self.modNotice = { ok = true, text = "Deleted " .. tostring(id) } + else + self.modNotice = { ok = false, text = tostring(res) } + end +end + +-- "Import mod .zip" button: open a native picker and install the pick. +-- Android mirrors ROM import: scan for a pending .zip in the save dir (USB +-- or a fresh SAF drop), else love.system.pickFile("mod") -> picked_mod.zip +-- which focus/Choose consumes on return. function RomImporter:chooseMod() if self.workState == "working" then return end if self.android then - self.modNotice = - { ok = false, text = "Drop a mod .zip onto the window to install it." } + local name = findPendingMod(true) + if name then + self:_installMod(name) + if self.modNotice and self.modNotice.ok then + love.filesystem.remove(name) + end + return + end + if not love.system.pickFile("mod") then + self.modNotice = { ok = false, + text = "Could not open the file picker. Copy a mod .zip via USB." } + end return end local path = chooseZip() @@ -722,13 +816,26 @@ function RomImporter:_importSave(version, source) end end --- "Import save" button: open a native .sav picker (desktop) and import the pick. --- Android has no picker, so it points the player at the drop path. +-- "Import save" button: open a native .sav picker and import the pick. +-- Android mirrors ROM / mod import via love.system.pickFile("sav"). function RomImporter:chooseSaveImport(version) if self.workState == "working" then return end if self.android then - self.saveNotice[version] = - { ok = false, text = "Drop a .sav onto the window to import it." } + local name = findPendingSav(true) + if name then + self.androidPendingVersion = version + self:_importSave(version, name) + if self.saveNotice[version] and self.saveNotice[version].ok then + love.filesystem.remove(name) + end + return + end + self.androidPendingVersion = version + if not love.system.pickFile("sav") then + self.androidPendingVersion = nil + self.saveNotice[version] = { ok = false, + text = "Could not open the file picker. Copy a .sav via USB." } + end return end local path = chooseSav() @@ -736,16 +843,58 @@ function RomImporter:chooseSaveImport(version) end -- "Export save" button: write the active slot back out to a raw .sav in the save --- directory's exports/ folder and show the path (with an open-folder affordance) --- on the SAVE FILES card. +-- directory's exports/ folder. On desktop, show the path with an open-folder +-- affordance. On Android, stage pending_export.sav and open the system +-- create-document picker (love.system.createFile) so the player can save to +-- Downloads / Drive / etc. -- the app-private exports/ path is not useful there. function RomImporter:exportSave(version) if self.workState == "working" then return end local ok, res = require("src.import.SaveFileIO").exportActiveSlot(version) - if ok then - local dir = res:match("^(.*)[/\\][^/\\]+$") - self.saveNotice[version] = { ok = true, text = "Exported to " .. res, dir = dir } - else + if not ok then self.saveNotice[version] = { ok = false, text = tostring(res) } + return + end + if self.android then + local rel = res:match("exports[/\\][^/\\]+$") + local data = rel and love.filesystem.read(rel) + if not data then + self.saveNotice[version] = { ok = false, + text = "Exported, but could not stage the file for the picker." } + return + end + local suggested = rel:match("[^/\\]+$") or "export.sav" + local wrote, writeErr = love.filesystem.write("pending_export.sav", data) + if not wrote then + self.saveNotice[version] = { ok = false, + text = "Could not stage the export: " .. tostring(writeErr) } + return + end + self.androidPendingExportVersion = version + if love.system.createFile and love.system.createFile(suggested) then + self.saveNotice[version] = { ok = true, + text = "Pick where to save " .. suggested .. "..." } + else + self.androidPendingExportVersion = nil + self.saveNotice[version] = { ok = true, + text = "Exported inside the app folder (picker unavailable)." } + end + return + end + local dir = res:match("^(.*)[/\\][^/\\]+$") + self.saveNotice[version] = { ok = true, text = "Exported to " .. res, dir = dir } +end + +-- Delete a save slot from the registry and disk, then refresh the panel. If the +-- deleted slot was active, SaveData.deleteSlot points active at another slot. +function RomImporter:_deleteSlot(version, id) + if self.workState == "working" then return end + local SaveData = require("src.core.SaveData") + local ok, err = SaveData.deleteSlot(version, id) + if ok then + self:_refreshSlots(version) + self.saveNotice[version] = { ok = true, text = "Deleted " .. tostring(id) .. "." } + else + self.saveNotice[version] = { ok = false, text = tostring(err) } end end @@ -1427,9 +1576,17 @@ function RomImporter:mousepressed(x, y, button) end return end - -- SAVE SLOT rows. On desktop a press only ARMS a click: _updateSlotDrag - -- commits it on release when the pointer did not move (a moved pointer scrolls - -- instead). Android has no reliable pointer polling, so it selects on press. + -- SAVE SLOT rows / Delete. Delete is checked first so a tap on the Delete + -- label never also selects the row. On desktop a press only ARMS a click: + -- _updateSlotDrag commits it on release when the pointer did not move (a + -- moved pointer scrolls instead). Android has no reliable pointer polling, + -- so it selects on press. Delete fires immediately (small fixed target). + for _, r in ipairs(self.slotDeleteRects or {}) do + if inside(r, x, y) then + self:_deleteSlot(self.panelVersion, r.id) + return + end + end for _, r in ipairs(self.slotRects or {}) do if inside(r, x, y) then if self.android then @@ -1445,12 +1602,18 @@ function RomImporter:mousepressed(x, y, button) self:_newSlot(self.panelVersion); return end -- Mods panel: the import button dispatches on press (fixed header, no scroll - -- conflict); a toggle switch, which lives in the scrollable list, only ARMS a - -- press so _updateSlotDrag can tell a click from a drag-scroll (Android, with - -- no pointer polling, toggles on press). + -- conflict); Delete fires immediately; a toggle switch, which lives in the + -- scrollable list, only ARMS a press so _updateSlotDrag can tell a click from + -- a drag-scroll (Android, with no pointer polling, toggles on press). if inside(self.modImportRect, x, y) then self:chooseMod(); return end + for _, r in ipairs(self.modDeleteRects or {}) do + if inside(r, x, y) then + self:_deleteMod(r.id) + return + end + end for _, r in ipairs(self.modRects or {}) do if inside(r, x, y) then if self.android then @@ -1759,7 +1922,8 @@ function RomImporter:_drawGamePanel(version, x, y, w, h) elseif locked then sfHintText, sfHintCol = "Not available yet.", PAL.warning elseif self.android then - sfHintText, sfHintCol = "Import a .sav, or drop one on the window.", PAL.warning + sfHintText, sfHintCol = + "Import or export a .sav with the system file picker.", PAL.warning else sfHintText, sfHintCol = "Import a .sav to a new slot, or export the active slot.", PAL.warning @@ -1999,6 +2163,8 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h) love.graphics.printf("No saves yet - start a new game or import one.", rx + 12 * s, listTop + listH / 2 - self.hintFont:getHeight() / 2, rw - 24 * s, "center") + self.slotRects = {} + self.slotDeleteRects = {} elseif listH > 0 then local nameH = self.slotNameFont:getHeight() local metaH = self.labelFont:getHeight() @@ -2017,6 +2183,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h) self.slotScroll[version] = scroll self.slotRects = {} + self.slotDeleteRects = {} love.graphics.setScissor(math.floor(rx), math.floor(listTop), math.ceil(rw), math.ceil(listH)) for i, slot in ipairs(slots) do @@ -2029,6 +2196,20 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h) col(selected and PAL.green or PAL.cardBorder, selected and 0.9 or 0.22) love.graphics.rectangle("line", rx, ry, rw, rowH, rr, rr) + -- Delete label (bottom-right); reserve its width so name/meta don't overlap + love.graphics.setFont(self.hintFont) + local delText = "Delete" + local delW = self.hintFont:getWidth(delText) + local delH = self.hintFont:getHeight() + local delX = rx + rw - 12 * s - delW + local delY = ry + rowH - rowPadV - delH + local drect = { x = delX - 6 * s, y = delY - 4 * s, + width = delW + 12 * s, height = delH + 8 * s, id = slot.id } + local dhot = self:_hover(drect) + col(dhot and PAL.red or PAL.warning) + love.graphics.print(delText, delX, delY) + local rightReserve = delW + 18 * s + -- LOADED pill (top-right of the active row), then reserve its width local pillW = 0 if selected then @@ -2048,7 +2229,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h) love.graphics.setFont(self.slotNameFont) col(PAL.white) local name = slot.name or "NEW GAME" - printB(ellipsize(self.slotNameFont, name, rw - 24 * s - pillW), + printB(ellipsize(self.slotNameFont, name, rw - 24 * s - math.max(pillW, rightReserve)), rx + 12 * s, ry + rowPadV) local metaTxt @@ -2061,7 +2242,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h) end love.graphics.setFont(self.labelFont) col(PAL.warning) - love.graphics.print(ellipsize(self.labelFont, metaTxt, rw - 24 * s), + love.graphics.print(ellipsize(self.labelFont, metaTxt, rw - 24 * s - rightReserve), rx + 12 * s, ry + rowPadV + nameH + 4 * s) -- clip the hit rect to the visible list band so a partly-scrolled row @@ -2072,6 +2253,12 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h) self.slotRects[#self.slotRects + 1] = { x = rx, y = vy, width = rw, height = vy2 - vy, id = slot.id } end + local dvy = math.max(drect.y, listTop) + local dvy2 = math.min(drect.y + drect.height, listBottom) + if dvy2 > dvy then + self.slotDeleteRects[#self.slotDeleteRects + 1] = + { x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = slot.id } + end end end love.graphics.setScissor() @@ -2165,14 +2352,14 @@ function RomImporter:_drawModsPanel(x, y, w, h) local top = y + headerH + 14 * s - -- notice line: the last install result, else the drag-drop hint + -- notice line: the last install/delete result, else the platform hint love.graphics.setFont(self.hintFont) if self.modNotice then col(self.modNotice.ok and PAL.green or PAL.red) love.graphics.printf(self.modNotice.text, x, top, w, "left") else col(PAL.warning) - love.graphics.printf(self.android and "Copy a mod .zip via USB." + love.graphics.printf(self.android and "Or copy a mod .zip via USB." or "Or drop a mod .zip onto the window.", x, top, w, "left") end top = top + self.hintFont:getHeight() + 12 * s @@ -2187,20 +2374,25 @@ function RomImporter:_drawModsPanel(x, y, w, h) dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s) love.graphics.setFont(self.hintFont) col(PAL.warning) - love.graphics.printf("No mods installed - drop a mod .zip here to add one.", + local emptyHint = self.android + and "No mods installed - tap Import mod .zip to add one." + or "No mods installed - drop a mod .zip here to add one." + love.graphics.printf(emptyHint, x + 16 * s, top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center") self.modRects = {} + self.modDeleteRects = {} self._modMax = 0 return end - -- card metrics (design: rounded 14, padding 14x16; toggle 56x28) + -- card metrics (design: rounded 14, padding 14x16; toggle 56x28; Delete under) local padH, padV = 16 * s, 14 * s local cardGap, cardR = 10 * s, 14 * s local tw, th = 52 * s, 28 * s local innerW = w - 2 * padH local chipH = self.hintFont:getHeight() + 8 * s - local clusterH = chipH + 6 * s + th -- status chip stacked over the toggle + local delH = self.hintFont:getHeight() + local clusterH = chipH + 6 * s + th + 6 * s + delH love.graphics.setFont(self.stateFont) local nameH = self.stateFont:getHeight() @@ -2210,7 +2402,8 @@ function RomImporter:_drawModsPanel(x, y, w, h) for i, m in ipairs(mods) do local chipText = modStatusChip(m.status) local chipW = self.hintFont:getWidth(chipText) + 20 * s - local clusterW = math.max(chipW, tw) + local delW = self.hintFont:getWidth("Delete") + local clusterW = math.max(chipW, tw, delW) local leftW = math.max(40 * s, innerW - clusterW - 14 * s) local descH = 0 if m.description ~= "" then @@ -2221,7 +2414,7 @@ function RomImporter:_drawModsPanel(x, y, w, h) local contentH = padV * 2 + nameH + (descH > 0 and (6 * s + descH) or 0) local cardH = math.max(contentH, padV * 2 + clusterH) layout[i] = { h = cardH, leftW = leftW, clusterW = clusterW, - chipText = chipText, chipW = chipW } + chipText = chipText, chipW = chipW, delW = delW } total = total + cardH end total = total + (#mods - 1) * cardGap @@ -2231,6 +2424,7 @@ function RomImporter:_drawModsPanel(x, y, w, h) local scroll = clamp(self.modScroll or 0, 0, maxScroll) self.modScroll = scroll self.modRects = {} + self.modDeleteRects = {} love.graphics.setScissor(math.floor(x), math.floor(top), math.ceil(w), math.ceil(listH)) @@ -2269,7 +2463,7 @@ function RomImporter:_drawModsPanel(x, y, w, h) love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left") end - -- right cluster: status chip stacked over the toggle, vertically centred + -- right cluster: status chip, toggle, Delete — vertically centred local clusterX = x + w - padH - L.clusterW local clusterY = cy + (cardH - clusterH) / 2 local _, chipColor = modStatusChip(m.status) @@ -2303,14 +2497,29 @@ function RomImporter:_drawModsPanel(x, y, w, h) col(PAL.white) love.graphics.circle("fill", kcx, ty + th / 2, kd / 2) - -- hit rect clipped to the visible list band (a partly-scrolled toggle is - -- only clickable where it actually shows) + -- Delete under the toggle + local delX = clusterX + (L.clusterW - L.delW) / 2 + local delY = ty + th + 6 * s + local drect = { x = delX - 6 * s, y = delY - 2 * s, + width = L.delW + 12 * s, height = delH + 4 * s, id = m.id } + local dhot = self:_hover(drect) + love.graphics.setFont(self.hintFont) + col(dhot and PAL.red or PAL.warning) + love.graphics.print("Delete", delX, delY) + + -- hit rects clipped to the visible list band local vy = math.max(trect.y, top) local vy2 = math.min(trect.y + trect.height, top + listH) if vy2 > vy then self.modRects[#self.modRects + 1] = { x = trect.x, y = vy, width = trect.width, height = vy2 - vy, id = m.id } end + local dvy = math.max(drect.y, top) + local dvy2 = math.min(drect.y + drect.height, top + listH) + if dvy2 > dvy then + self.modDeleteRects[#self.modDeleteRects + 1] = + { x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = m.id } + end end cy = cy + cardH + cardGap end diff --git a/src/import/SaveFileIO.lua b/src/import/SaveFileIO.lua index 8824e7e2..7efc09c2 100644 --- a/src/import/SaveFileIO.lua +++ b/src/import/SaveFileIO.lua @@ -47,11 +47,19 @@ local function readSource(source) return source end local f, openErr = io.open(source, "rb") - if not f then return nil, "could not read the save file: " .. tostring(openErr) end - local data = f:read("*a") - f:close() - if type(data) ~= "string" then return nil, "the save file was empty" end - return data + if f then + local data = f:read("*a") + f:close() + if type(data) ~= "string" then return nil, "the save file was empty" end + return data + end + -- Android SAF drops (picked_save.sav) and USB copies land in the LOVE save + -- directory; io.open cannot see them, so fall back to love.filesystem. + if love and love.filesystem and love.filesystem.read then + local data = love.filesystem.read(source) + if type(data) == "string" then return data end + end + return nil, "could not read the save file: " .. tostring(openErr) end -- importToSlot(source, version) -> ok, slotIdOrErr diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index 1b0b6aa3..47d90488 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -3,11 +3,12 @@ -- manifests only. The full loader (src/mods/Loader.lua) still owns the real -- load at boot; this reads the same options.mods enable-state the loader -- writes, derives per-mod status with the pure ManagerState.resolveToggle, --- and installs a dropped/chosen .zip into the save-dir "mods//" tree. +-- installs a dropped/chosen .zip into the save-dir "mods//" tree, and +-- uninstalls a mod by removing that tree + clearing options.mods[id]. -- -- Split in two: the pure derivation (deriveList, locateRoot) has no love and --- no filesystem, so the engine tier can table-drive it; the discovery and --- install paths reach for love.filesystem and SaveData. +-- no filesystem, so the engine tier can table-drive it; the discovery, +-- install, and uninstall paths reach for love.filesystem and SaveData. local Manifest = require("src.mods.Manifest") local ManagerState = require("src.mods.ManagerState") @@ -332,4 +333,34 @@ function LauncherMods.installZip(source) return true, manifest.id end +-- uninstall(id) -> true | nil, errString +-- Removes mods// from the save directory and clears options.mods[id] so the +-- loader and in-game manager no longer see it. Rejects unknown / missing ids. +-- Does not touch other mods' enable state. +function LauncherMods.uninstall(id) + if type(id) ~= "string" or id == "" then + return nil, "missing mod id" + end + if id:find("[/\\]") or id == "." or id == ".." then + return nil, "invalid mod id" + end + if not (love and love.filesystem) then + return nil, "mod uninstall needs LOVE" + end + local fs = love.filesystem + local dest = "mods/" .. id + if not fs.getInfo(dest) then + return nil, "mod '" .. id .. "' is not installed" + end + removeTree(dest) + -- Drop the enable flag so a reinstall of the same id starts from the + -- loader's default (enabled) rather than a stale false. + local options = SaveData.loadOptions() + if options.mods and options.mods[id] ~= nil then + options.mods[id] = nil + SaveData.saveOptions(options) + end + return true +end + return LauncherMods diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 7ff91285..364e0c2c 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -68,6 +68,7 @@ local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 } local SUPPORTED_REQUIRES = { ["src.mods.Semver"] = true, ["src.audio.ChipAsm"] = true, + ["src.pokemon.Stats"] = true, -- Stats.isShiny / calc for indicator mods } local function scanRequire(name) diff --git a/src/pokemon/Sprites.lua b/src/pokemon/Sprites.lua new file mode 100644 index 00000000..4f3450c0 --- /dev/null +++ b/src/pokemon/Sprites.lua @@ -0,0 +1,63 @@ +-- Runtime Pokémon art resolution. Content registries freeze after load, +-- so a mod that lets the player pick an alternate skin mid-session cannot +-- patch pokemon.spriteFront / icons.bySpecies. These helpers are the +-- sanctioned seam: every battle pic and party icon load goes through +-- pokemon.sprite / pokemon.icon, which stay live for the whole process. + +local Runtime = require("src.mods.Runtime") + +local Sprites = {} + +local function samePath(path) return path end + +-- Resolve a battle / menu front or back pic path for `species`. +-- side: "front" | "back" +-- opts.mon: the live mon when available (per-instance skins) +-- opts.kind: "battle" | "summary" | "dex" | "evolution" | "hof" | "trade" +-- | "title" | "oak" | "credits" (informational for wrappers) +-- Returns path, trueColor. +function Sprites.path(data, species, side, opts) + opts = opts or {} + local def = data and data.pokemon and data.pokemon[species] + if not def then return nil, false end + local path = side == "back" and def.spriteBack or def.spriteFront + local ctx = { + species = species, + side = side == "back" and "back" or "front", + kind = opts.kind or "battle", + mon = opts.mon, + trueColor = def.trueColor and true or false, + data = data, + } + if path and Runtime.wantsHook("pokemon.sprite") then + local hooked = Runtime.call("pokemon.sprite", samePath, path, ctx) + if type(hooked) == "string" and hooked ~= "" then path = hooked end + end + return path, ctx.trueColor and true or false +end + +-- Resolve a party-menu icon image path for `mon`. +-- vanillaPath is the path PartyMenu already picked from icons.bySpecies / +-- def.icon / icons.byDex; the hook may replace it. +-- Returns path (possibly nil). +function Sprites.iconPath(data, mon, vanillaPath, opts) + opts = opts or {} + if not vanillaPath and not Runtime.wantsHook("pokemon.icon") then + return vanillaPath + end + local species = mon and mon.species + local ctx = { + species = species, + mon = mon, + name = opts.name, + data = data, + kind = "icon", + } + if not Runtime.wantsHook("pokemon.icon") then return vanillaPath end + local hooked = Runtime.call("pokemon.icon", samePath, vanillaPath, ctx) + if type(hooked) == "string" and hooked ~= "" then return hooked end + if hooked == nil or hooked == false then return nil end + return vanillaPath +end + +return Sprites diff --git a/src/pokemon/Stats.lua b/src/pokemon/Stats.lua index 9c2356f0..169c275b 100644 --- a/src/pokemon/Stats.lua +++ b/src/pokemon/Stats.lua @@ -57,4 +57,20 @@ function Stats.applyStage(value, stage) return math.max(1, math.min(999, v)) end +-- Gen 2 shiny formula applied to Gen 1 DVs (the RBY "virtual shiny"): +-- Defense/Speed/Special DV == 10 and Attack DV is even-high +-- (2, 3, 6, 7, 10, 11, 14, or 15). Used by shiny-indicator mods. +local SHINY_ATK = { + [2] = true, [3] = true, [6] = true, [7] = true, + [10] = true, [11] = true, [14] = true, [15] = true, +} + +function Stats.isShiny(dvs) + if type(dvs) ~= "table" then return false end + return (dvs.defense or 0) == 10 + and (dvs.speed or 0) == 10 + and (dvs.special or 0) == 10 + and SHINY_ATK[dvs.attack or 0] == true +end + return Stats diff --git a/src/render/PixelCanvas.lua b/src/render/PixelCanvas.lua new file mode 100644 index 00000000..861f5c27 --- /dev/null +++ b/src/render/PixelCanvas.lua @@ -0,0 +1,45 @@ +-- Render targets measured in real framebuffer pixels. +-- +-- love.graphics.newCanvas defaults its `dpiscale` to +-- love.graphics.getDPIScale(), so on a highdpi surface the canvas texture is +-- NOT the size it was asked for: newCanvas(160, 144) on a device reporting a +-- DPI scale of 2.755 allocates a 441x397 texture, and the 160x144 GB scene +-- then renders into it at 2.755 texels per GB pixel. conf.lua sets +-- t.window.highdpi on Android/iOS (required for Retina), and Android's +-- DisplayMetrics.density is routinely non-integer (1.5, 2.75, ...), so this +-- is the normal mobile case, not an edge case. +-- +-- That fractional render breaks the whole premise of Renderer's integer +-- scale pipeline. Renderer:fitScale() picks a whole number of physical +-- pixels per GB pixel (7 on 1080p) and the composite blit lands on it +-- exactly -- but if the *source* already holds 2.755 texels per GB pixel, +-- some GB pixels are 2 texels wide and some are 3, and the nearest-neighbour +-- upscale turns them into 5 / 7 / 8 physical pixels instead of a uniform 7. +-- Measured on a 1080p density-2.755 fixture, only 43 of 160 columns came out +-- the right width. Fonts show it worst: their strokes are single pixels. +-- That is issue #208 ("some are noticeably stretched, especially fonts"). +-- +-- Forcing dpiscale = 1 makes canvas texels the pixels the renderer already +-- believes it is drawing. getWidth()/getHeight() are unaffected (they always +-- reported the requested size), so no geometry anywhere changes -- only the +-- resolution of the target. Desktop is unchanged; dpiscale is already 1 +-- there because conf.lua sets highdpi on mobile only. +-- +-- Exception: a canvas that is deliberately sized in LOVE *units* and blitted +-- back at unit scale 1 (Renderer's presentCanvas) must keep the screen's DPI +-- scale so its texture still covers the framebuffer; it is not built with +-- this helper. + +local PixelCanvas = {} + +-- One framebuffer pixel per w/h unit, always. `filter` is applied only when +-- given, so callers that relied on LOVE's default ("linear") keep it. +function PixelCanvas.new(w, h, filter) + local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 }) + if filter and canvas and canvas.setFilter then + canvas:setFilter(filter, filter) + end + return canvas +end + +return PixelCanvas diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 5c97069d..78026bdc 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -10,6 +10,8 @@ local Zoom = require("src.render.Zoom") local Tilt = require("src.render.Tilt") local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") +local PixelCanvas = require("src.render.PixelCanvas") +local Runtime = require("src.mods.Runtime") local Renderer = {} @@ -78,8 +80,10 @@ local function displayMetrics() end function Renderer:init() - self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT) - self.canvas:setFilter("nearest", "nearest") + -- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua + -- (#208). Every canvas below is sized in framebuffer pixels for the same + -- reason -- worldViewSize() already works in drawable pixels. + self.canvas = PixelCanvas.new(self.WIDTH, self.HEIGHT, "nearest") self.worldCanvas = nil self.worldActive = false -- tilt mode only: a transparent overlay canvas the size of the world @@ -230,8 +234,7 @@ function Renderer:beginWorldPass() -- the view size every frame, so without this the superseded canvases -- pile up in VRAM until a GC finalizer happens to run if self.worldCanvas and self.worldCanvas.release then self.worldCanvas:release() end - self.worldCanvas = love.graphics.newCanvas(vw, vh) - self.worldCanvas:setFilter("nearest", "nearest") + self.worldCanvas = PixelCanvas.new(vw, vh, "nearest") end self.worldActive = true PaletteFX.setPass("world") @@ -259,8 +262,7 @@ function Renderer:beginUprightPass() if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw or self.uprightCanvas:getHeight() ~= ch then if self.uprightCanvas and self.uprightCanvas.release then self.uprightCanvas:release() end - self.uprightCanvas = love.graphics.newCanvas(cw, ch) - self.uprightCanvas:setFilter("nearest", "nearest") + self.uprightCanvas = PixelCanvas.new(cw, ch, "nearest") end self.uprightActive = true PaletteFX.setPass(nil) @@ -354,8 +356,7 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target) if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw or self.tiltCanvas:getHeight() ~= wvh then if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end - self.tiltCanvas = love.graphics.newCanvas(wvw, wvh) - self.tiltCanvas:setFilter("linear", "linear") + self.tiltCanvas = PixelCanvas.new(wvw, wvh, "linear") end love.graphics.setCanvas(self.tiltCanvas) @@ -464,6 +465,14 @@ function Renderer:endFrame(zones, worldZones) if needPresent then if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww or self.presentCanvas:getHeight() ~= wh then + -- The one canvas NOT built through PixelCanvas: it is sized in LOVE + -- units and blitted back at unit scale 1 (and handed to mod present + -- passes as ww x wh), so it has to keep the screen's DPI scale for its + -- texture to cover the framebuffer. Everything composited into it is + -- already native-resolution now, so #208's fractional source is gone; + -- what remains here is the dpiX vs dpiY truncation gap (well under 1%, + -- one seam across the window) that a single scalar dpiscale cannot + -- express. self.presentCanvas = love.graphics.newCanvas(ww, wh) self.presentCanvas:setFilter("linear", "linear") end @@ -486,6 +495,17 @@ function Renderer:endFrame(zones, worldZones) love.graphics.setColor(clearR, clearG, clearB, 1) love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.setColor(1, 1, 1, 1) + -- render.letterbox: SGB borders / custom void art in the bars around the + -- 160x144 (or world) blit. Drawn after the clear and before the game + -- canvas so the playfield sits on top of the border. + if Runtime.wantsHook("render.letterbox") then + Runtime.call("render.letterbox", function() end, { + ww = ww, wh = wh, pw = pw, ph = ph, + ox = ox, oy = oy, vpw = vpw, vph = vph, + scale = Sp, dpiX = dpiX, dpiY = dpiY, + worldActive = self.worldActive and true or false, + }) + end -- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by), -- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 232af1f0..935831d0 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -542,7 +542,10 @@ end local function bakeBorderFill(self, block) local border = self.map.tileset.blocks[block + 1] if not border then return end - local canvas = love.graphics.newCanvas(32, 32) + -- 32x32 real pixels: a DPI-scaled canvas would bake the border block at a + -- fractional texel size and the repeat-wrapped image would then tile at + -- non-square pixels (#208, see src/render/PixelCanvas.lua) + local canvas = require("src.render.PixelCanvas").new(32, 32) love.graphics.push("all") love.graphics.setCanvas(canvas) love.graphics.clear(1, 1, 1, 1) diff --git a/src/render/Zoom.lua b/src/render/Zoom.lua index a3208242..55a5f567 100644 --- a/src/render/Zoom.lua +++ b/src/render/Zoom.lua @@ -4,19 +4,38 @@ -- zoom. Persisted as save.options.zoom (default 0 = FIT). -- Spec: docs/new-features.md (survey zoom) +local Runtime = require("src.mods.Runtime") + local Zoom = {} Zoom.offset = 0 --- effective integer scale s' in [1, 2*S] -function Zoom.scale(S) - return math.max(1, math.min(2 * S, S + Zoom.offset)) -end - --- legal offset range for a given fit scale +-- legal offset range for a given fit scale (vanilla: survey at 1 px/world +-- through 2× fit). zoom.range may widen or shrink the window. function Zoom.offsetRange(S) S = math.max(1, math.floor(tonumber(S) or 1)) - return 1 - S, S + local lo, hi = 1 - S, S + if Runtime.wantsHook("zoom.range") then + lo, hi = Runtime.call("zoom.range", function(a, b) return a, b end, lo, hi, S) + lo = math.floor(tonumber(lo) or (1 - S)) + hi = math.floor(tonumber(hi) or S) + if lo > hi then lo, hi = hi, lo end + end + return lo, hi +end + +-- effective scale s' = S + offset, clamped to the (possibly modded) range. +-- Vanilla stays in [1, 2*S]. A zoom.range wrapper that lowers `lo` below +-- 1-S permits sub-1 survey scales so the whole region can fit on screen. +function Zoom.scale(S) + local lo, hi = Zoom.offsetRange(S) + local s = S + Zoom.offset + local minScale = S + lo + local maxScale = math.max(minScale, S + hi) + if s < minScale then s = minScale end + if s > maxScale then s = maxScale end + if s < 0.25 then s = 0.25 end + return s end function Zoom.clampOffset(offset, S) diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 7f609881..3d169c0b 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -370,6 +370,7 @@ function BagMenu.new(game, opts) local battle = opts.battle local list list = ListMenu.new(game, "ITEMS", buildItems(game), { + kind = "bag", footer = ("¥%d"):format(game.save.money), -- SELECT reorders items like the original bag (swap_items.asm) onSelectKey = function(item, l) diff --git a/src/ui/Credits.lua b/src/ui/Credits.lua index 07198c98..37dbeb82 100644 --- a/src/ui/Credits.lua +++ b/src/ui/Credits.lua @@ -140,8 +140,9 @@ function Credits:enter() end function Credits:monSprite(species) - local def = self.game.data.pokemon and self.game.data.pokemon[species] - return silhouette(def and def.spriteFront) + local path = require("src.pokemon.Sprites").path( + self.game.data, species, "front", { kind = "credits" }) + return silhouette(path) end -- advance to the next CreditsOrder screen (Credits .nextCreditsScreen); diff --git a/src/ui/DexEntryMenu.lua b/src/ui/DexEntryMenu.lua index 0d7bb673..f25224fc 100644 --- a/src/ui/DexEntryMenu.lua +++ b/src/ui/DexEntryMenu.lua @@ -34,7 +34,9 @@ function DexEntryMenu.new(game, speciesOrOpts) local species, forceOwned = resolveArgs(speciesOrOpts) local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu) self.def = game.data.pokemon[species] - local ok, img = pcall(love.graphics.newImage, self.def.spriteFront) + local path = require("src.pokemon.Sprites").path(game.data, species, "front", + { kind = "dex" }) + local ok, img = path and pcall(love.graphics.newImage, path) self.sprite = ok and img or nil require("src.core.Sound").playCry(game.data, species) return self diff --git a/src/ui/EvolutionState.lua b/src/ui/EvolutionState.lua index 111a50ab..815102dd 100644 --- a/src/ui/EvolutionState.lua +++ b/src/ui/EvolutionState.lua @@ -28,10 +28,11 @@ end local FLASH_FRAMES = 220 -local function frontSprite(game, species) - local def = game.data.pokemon[species] - if not (def and def.spriteFront) then return nil end - local ok, img = pcall(love.graphics.newImage, def.spriteFront) +local function frontSprite(game, species, mon) + local path = require("src.pokemon.Sprites").path(game.data, species, "front", + { mon = mon, kind = "evolution" }) + if not path then return nil end + local ok, img = pcall(love.graphics.newImage, path) return ok and img or nil end @@ -46,8 +47,8 @@ function EvolutionState.new(game, mon, newSpecies, onDone, via) -- B-cancel poll; level-up, stone and rare-candy evos are all cancelable. self.cancelable = (via ~= "TRADE") self.oldName = mon.nickname or game.data.pokemon[mon.species].name - self.oldSprite = frontSprite(game, mon.species) - self.newSprite = frontSprite(game, newSpecies) + self.oldSprite = frontSprite(game, mon.species, mon) + self.newSprite = frontSprite(game, newSpecies, mon) self.t = 0 self.done = false self.canceled = false diff --git a/src/ui/HallOfFame.lua b/src/ui/HallOfFame.lua index c756c6ae..c0e1f0db 100644 --- a/src/ui/HallOfFame.lua +++ b/src/ui/HallOfFame.lua @@ -118,8 +118,9 @@ end function HallOfFame:spriteFor(species) local cached = self.sprites[species] if cached == nil then - local def = self.game.data.pokemon[species] - cached = tryImage(def and def.spriteFront) or false + local path = require("src.pokemon.Sprites").path( + self.game.data, species, "front", { kind = "hof" }) + cached = tryImage(path) or false self.sprites[species] = cached end return cached or nil diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index 516fa83b..1d42080c 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -3,6 +3,7 @@ -- box and the Pokédex. local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") local Theme = require("src.ui.Theme") local ListMenu = {} @@ -15,9 +16,37 @@ function ListMenu:sgbPalettes(game) end local ROWS = 7 +-- frames to wait before key-repeat kicks in, then between repeats +local REPEAT_DELAY = 16 +local REPEAT_RATE = 4 + +-- ui.list_menu identity: unhooked opts pass through unchanged +local function sameOpts(opts) return opts end function ListMenu.new(game, title, items, opts) opts = opts or {} + -- bag / shop / dex / generic: mods may enable wrap, pageJump, keyRepeat + if Runtime.wantsHook("ui.list_menu") then + local hooked = Runtime.call("ui.list_menu", sameOpts, { + wrap = opts.wrap, + pageJump = opts.pageJump, + keyRepeat = opts.keyRepeat, + repeatDelay = opts.repeatDelay, + repeatRate = opts.repeatRate, + }, { + game = game, + title = title, + kind = opts.kind or title, + itemCount = items and #items or 0, + }) + if type(hooked) == "table" then + if hooked.wrap ~= nil then opts.wrap = hooked.wrap end + if hooked.pageJump ~= nil then opts.pageJump = hooked.pageJump end + if hooked.keyRepeat ~= nil then opts.keyRepeat = hooked.keyRepeat end + if hooked.repeatDelay ~= nil then opts.repeatDelay = hooked.repeatDelay end + if hooked.repeatRate ~= nil then opts.repeatRate = hooked.repeatRate end + end + end local self = setmetatable({}, ListMenu) self.game = game self.title = title @@ -28,6 +57,12 @@ function ListMenu.new(game, title, items, opts) self.onCancel = opts.onCancel self.footer = opts.footer self.pageJump = opts.pageJump -- Left/Right move a page at a time + self.wrap = opts.wrap -- Up on first / Down on last wraps + self.keyRepeat = opts.keyRepeat -- hold Up/Down (and pageJump L/R) to scroll + self.repeatDelay = opts.repeatDelay or REPEAT_DELAY + self.repeatRate = opts.repeatRate or REPEAT_RATE + self.holdDir = nil + self.holdFrames = 0 self.onSelectKey = opts.onSelectKey -- SELECT pressed on an item -- scripted mode (the old man tutorial): update() runs the script -- every frame INSTEAD of reading input -- DisplayListMenuID's old-man @@ -46,6 +81,42 @@ function ListMenu.new(game, title, items, opts) return self end +local function moveIndex(self, delta) + local n = #self.items + if n == 0 then return end + local next = self.index + delta + if self.wrap then + next = ((next - 1) % n) + 1 + else + next = math.max(1, math.min(n, next)) + end + self.index = next +end + +local function syncScroll(self) + if self.index - self.scroll > self.rows then + self.scroll = self.index - self.rows + end + if self.index - self.scroll < 1 then self.scroll = self.index - 1 end +end + +-- edge press or key-repeat tick for a held direction +local function navPressed(self, dir) + if dir == "up" then + moveIndex(self, -1) + elseif dir == "down" then + moveIndex(self, 1) + elseif dir == "left" and self.pageJump then + moveIndex(self, -self.rows) + elseif dir == "right" and self.pageJump then + moveIndex(self, self.rows) + else + return false + end + syncScroll(self) + return true +end + function ListMenu:update(dt) if self.script then self.script(self) @@ -59,14 +130,20 @@ function ListMenu:update(dt) end return end + + local moved = false if input:wasPressed("up") then - self.index = math.max(1, self.index - 1) + moved = navPressed(self, "up") + self.holdDir, self.holdFrames = "up", 0 elseif input:wasPressed("down") then - self.index = math.min(#self.items, self.index + 1) + moved = navPressed(self, "down") + self.holdDir, self.holdFrames = "down", 0 elseif self.pageJump and input:wasPressed("left") then - self.index = math.max(1, self.index - self.rows) + moved = navPressed(self, "left") + self.holdDir, self.holdFrames = "left", 0 elseif self.pageJump and input:wasPressed("right") then - self.index = math.min(#self.items, self.index + self.rows) + moved = navPressed(self, "right") + self.holdDir, self.holdFrames = "right", 0 elseif self.onSelectKey and input:wasPressed("select") then self.onSelectKey(self.items[self.index], self) elseif input:wasPressed("b") then @@ -80,10 +157,22 @@ function ListMenu:update(dt) end return end - if self.index - self.scroll > self.rows then - self.scroll = self.index - self.rows + + -- hold-to-scroll (opt-in via ui.list_menu keyRepeat) + if self.keyRepeat then + local dir = self.holdDir + if dir and input:isDown(dir) then + self.holdFrames = self.holdFrames + 1 + local afterDelay = self.holdFrames - self.repeatDelay + if afterDelay >= 0 and afterDelay % self.repeatRate == 0 then + navPressed(self, dir) + end + else + self.holdDir, self.holdFrames = nil, 0 + end end - if self.index - self.scroll < 1 then self.scroll = self.index - 1 end + + if not moved then syncScroll(self) end end -- remove current item (e.g. consumed); keeps cursor valid diff --git a/src/ui/ModUI.lua b/src/ui/ModUI.lua index 381fa0e4..f04c1488 100644 --- a/src/ui/ModUI.lua +++ b/src/ui/ModUI.lua @@ -57,4 +57,32 @@ function ModUI.removeLabel(items, label) return items end +-- Oak speech step-list helpers (intro.oak_speech.build). Anchored on +-- stable step.id values the same way insertBefore anchors on labels. +local function stepIndex(steps, id) + for i, step in ipairs(steps) do + if step.id == id then return i end + end + return nil +end + +function ModUI.insertStepBefore(steps, anchorId, step) + local i = stepIndex(steps, anchorId) + table.insert(steps, i or (#steps + 1), step) + return steps +end + +function ModUI.insertStepAfter(steps, anchorId, step) + local i = stepIndex(steps, anchorId) + table.insert(steps, i and (i + 1) or (#steps + 1), step) + return steps +end + +function ModUI.removeStep(steps, id) + for i = #steps, 1, -1 do + if steps[i].id == id then table.remove(steps, i) end + end + return steps +end + return ModUI diff --git a/src/ui/NamingScreen.lua b/src/ui/NamingScreen.lua index c2ef9dc7..0bd72916 100644 --- a/src/ui/NamingScreen.lua +++ b/src/ui/NamingScreen.lua @@ -5,8 +5,12 @@ -- given, a "NEW NAME" + presets menu is shown first -- (engine/menus/main_menu.asm name lists). -- Pops itself from the stack, then calls opts.onDone(name). +-- +-- ui.naming.grid may replace either page; keep an "ED" cell and a +-- single-cell case-switch row so confirm / case-flip keep working. local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") local Sound = require("src.core.Sound") local Theme = require("src.ui.Theme") @@ -37,8 +41,24 @@ local GRID_LOWER = { { "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" }, { "UPPER CASE" }, } -local CASE_ROW = 6 -local ED_ROW, ED_COL = 5, 9 + +-- locate the ED confirm cell and the case-switch row on a (possibly +-- modded) grid; falls back to vanilla coordinates +local function findMeta(grid) + local caseRow, edRow, edCol = #grid, 5, 9 + for r, row in ipairs(grid) do + if #row == 1 and (row[1] == "lower case" or row[1] == "UPPER CASE" + or row[1] == "lower" or row[1] == "UPPER") then + caseRow = r + end + for c, cell in ipairs(row) do + if cell == "ED" then edRow, edCol = r, c end + end + end + return caseRow, edRow, edCol +end + +local function sameGrid(grid) return grid end function NamingScreen.new(game, opts) opts = opts or {} @@ -86,16 +106,27 @@ function NamingScreen:confirm() end function NamingScreen:grid() - return self.lower and GRID_LOWER or GRID_UPPER + local base = self.lower and GRID_LOWER or GRID_UPPER + if not Runtime.wantsHook("ui.naming.grid") then return base end + local hooked = Runtime.call("ui.naming.grid", sameGrid, base, { + lower = self.lower and true or false, + title = self.title, + maxLen = self.maxLen, + game = self.game, + }) + if type(hooked) ~= "table" or #hooked == 0 then return base end + return hooked end -- Gen 1 jumps the cursor to ED once the name is full. function NamingScreen:jumpToEnd() - self.row, self.col = ED_ROW, ED_COL + local _, edRow, edCol = findMeta(self:grid()) + self.row, self.col = edRow, edCol end function NamingScreen:update(dt) local GRID = self:grid() + local caseRow, edRow, edCol = findMeta(GRID) local input = self.game.input if input:wasPressed("start") then self:confirm() @@ -107,28 +138,28 @@ function NamingScreen:update(dt) end if input:wasPressed("up") then -- wrapping up from the top row lands on the case-switch cell - self.row = self.row > 1 and self.row - 1 or CASE_ROW + self.row = self.row > 1 and self.row - 1 or caseRow self.col = math.min(self.col, #GRID[self.row]) elseif input:wasPressed("down") then self.row = self.row < #GRID and self.row + 1 or 1 self.col = math.min(self.col, #GRID[self.row]) elseif input:wasPressed("left") then -- no horizontal movement on the case-switch row - if self.row ~= CASE_ROW then + if self.row ~= caseRow then self.col = self.col > 1 and self.col - 1 or #GRID[self.row] end elseif input:wasPressed("right") then - if self.row ~= CASE_ROW then + if self.row ~= caseRow then self.col = self.col < #GRID[self.row] and self.col + 1 or 1 end elseif input:wasPressed("b") then table.remove(self.glyphs) elseif input:wasPressed("a") then - if self.row == ED_ROW and self.col == ED_COL then + if self.row == edRow and self.col == edCol then self:confirm() return end - if self.row == CASE_ROW then + if self.row == caseRow then self.lower = not self.lower return end diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index b71df3a0..47409f6f 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -1,14 +1,16 @@ -- The intro sequence (engine/movie/oak_speech/oak_speech.asm): Oak's -- welcome, the NIDORINO show-off, player and rival naming, and the --- closing "legend is about to unfold" text followed by the shrink-away: --- the player pic collapses through ShrinkPic1/ShrinkPic2 into the --- overworld walking sprite before the fade to white. Uses the real --- extracted texts (_OakSpeechText1/2A/2B/3, _IntroducePlayerText, --- _IntroduceRivalText) with literal fallbacks. +-- closing "legend is about to unfold" text followed by the shrink-away. +-- +-- Steps are a data table so mods can reshape the whole speech through +-- hooks:wrap("intro.oak_speech.build"). Vanilla ids stay stable so a +-- mod can insertBefore("name_player", ...) without counting indices. -- Calls onDone() after popping itself. local Sound = require("src.core.Sound") local Music = require("src.core.Music") +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") local TextBox = require("src.render.TextBox") local Font = require("src.render.Font") @@ -55,12 +57,154 @@ local function tryImage(path) return ok and img or nil end +-- Resolve a pic descriptor to (image, flip). +-- Descriptors: +-- "oak" | "rival" | "player" shorthand +-- { type = "trainer", id = "OPP_PROF_OAK" } +-- { type = "pokemon", id = "PIKACHU", flip = true } +-- { type = "player", path = "..." } optional override path +-- { type = "image", path = "..." } +-- { type = "sprite", id = "SPRITE_RED" } +function OakSpeech.resolvePic(game, desc, speech) + if desc == nil then return nil, false end + if type(desc) == "string" then + if desc == "oak" then + desc = { type = "trainer", id = "OPP_PROF_OAK" } + elseif desc == "rival" then + desc = { type = "trainer", id = "OPP_RIVAL1" } + elseif desc == "player" then + desc = { type = "player" } + else + -- bare species id + desc = { type = "pokemon", id = desc } + end + end + local t = desc.type + if t == "trainer" then + if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then + return speech.oakPic, false + end + if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then + return speech.rivalPic, false + end + local trainers = game.data.trainers or {} + local tr = trainers[desc.id] + return tryImage(tr and tr.pic), false + elseif t == "pokemon" then + if speech and desc.id == speech.demoSpecies and speech.demoPic then + return speech.demoPic, desc.flip and true or false + end + local path = require("src.pokemon.Sprites").path( + game.data, desc.id, "front", { kind = "oak" }) + return tryImage(path), desc.flip and true or false + elseif t == "player" then + if speech and speech.playerPic and not desc.path then + return speech.playerPic, false + end + return tryImage(desc.path or "assets/generated/trainer_card/red.png"), false + elseif t == "image" then + return tryImage(desc.path), desc.flip and true or false + elseif t == "sprite" then + local sp = game.data.sprites and game.data.sprites[desc.id] + return tryImage(sp and sp.image), desc.flip and true or false + end + return nil, false +end + +-- Vanilla step list. Ids are the stable anchors mods insert around. +function OakSpeech.defaultSteps(speech) + return { + { + id = "oak_welcome", + kind = "say", + textKey = "_OakSpeechText1", + pic = "oak", + reveal = "fade", + }, + { + id = "demo_mon", + kind = "demo", + }, + { + id = "world_spiel", + kind = "say", + textKey = "_OakSpeechText2B", + }, + { + id = "ask_player_name", + kind = "say", + textKey = "_IntroducePlayerText", + pic = "player", + }, + { + id = "name_player", + kind = "name", + who = "player", + title = "YOUR NAME?", + presetsWho = "player", + presetsFallback = { "RED", "ASH", "JACK" }, + }, + { + id = "ask_rival_name", + kind = "say", + textKey = "_IntroduceRivalText", + pic = "rival", + }, + { + id = "name_rival", + kind = "name", + who = "rival", + title = "HIS NAME?", + presetsWho = "rival", + presetsFallback = { "BLUE", "GARY", "JOHN" }, + }, + { + id = "legend", + kind = "say", + textKey = "_OakSpeechText3", + pic = "player", + }, + { + id = "shrink", + kind = "shrink", + }, + } +end + +-- list helpers for intro.oak_speech.build wrappers (also on ModUI) +local function indexOfId(steps, id) + for i, step in ipairs(steps) do + if step.id == id then return i end + end + return nil +end + +function OakSpeech.insertBefore(steps, anchorId, step) + local i = indexOfId(steps, anchorId) + table.insert(steps, i or (#steps + 1), step) + return steps +end + +function OakSpeech.insertAfter(steps, anchorId, step) + local i = indexOfId(steps, anchorId) + table.insert(steps, i and (i + 1) or (#steps + 1), step) + return steps +end + +function OakSpeech.removeId(steps, id) + for i = #steps, 1, -1 do + if steps[i].id == id then table.remove(steps, i) end + end + return steps +end + function OakSpeech.new(game, onDone) local self = setmetatable({}, OakSpeech) self.game = game self.onDone = onDone self.step = 0 self.pic = nil + self.answers = {} local trainers = game.data.trainers or {} self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic) self.rivalPic = tryImage(trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.pic) @@ -69,8 +213,9 @@ function OakSpeech.new(game, onDone) -- the show-off mon and the name length cap come from data; the vanilla -- literals stay as the fallbacks self.demoSpecies = oakGfx.demoSpecies or "NIDORINO" - local demo = game.data.pokemon and game.data.pokemon[self.demoSpecies] - self.demoPic = tryImage(demo and demo.spriteFront) + local demoPath = require("src.pokemon.Sprites").path( + game.data, self.demoSpecies, "front", { kind = "oak" }) + self.demoPic = tryImage(demoPath) local constants = game.data.constants or {} self.nameLen = constants.playerNameLength or 7 -- RedPicFront (gfx/player/red.png, shared with the trainer card) and @@ -87,9 +232,26 @@ function OakSpeech.new(game, onDone) return self end +function OakSpeech:buildSteps() + local steps = OakSpeech.defaultSteps(self) + local hooked = Runtime.call("intro.oak_speech.build", + function(s) return s end, steps, self) + if type(hooked) ~= "table" then + Logger.error("intro.oak_speech.build returned %s; keeping vanilla steps", + type(hooked)) + return steps + end + return hooked +end + function OakSpeech:enter() -- MUSIC_ROUTES2 plays under the whole speech (oak_speech.asm:43-48) Music.play(self.game.data, self.cfg.music or "Music_Routes2") + self.answers = {} + self.steps = self:buildSteps() + if Runtime.wants("intro.oak_speech.started") then + Runtime.emit("intro.oak_speech.started", { speech = self, steps = self.steps }) + end self:advance() end @@ -97,80 +259,187 @@ function OakSpeech:say(key, next) self.game.stack:push(TextBox.new(self.game, textOr(self.game, key), next)) end -local STEPS = { - -- 1. Oak's welcome (Oak's pic fades in first, FadeInIntroPic) - function(self) - self.pic = self.oakPic - self:revealPic("fade", function() - self:say("_OakSpeechText1", function() self:advance() end) +function OakSpeech:sayText(text, next, opts) + self.game.stack:push(TextBox.new(self.game, text, next, opts)) +end + +function OakSpeech:stepText(step) + if step.text then return step.text end + if step.textKey then return textOr(self.game, step.textKey) end + return "" +end + +function OakSpeech:applyPic(step) + if step.pic == nil then return end + local img, flip = OakSpeech.resolvePic(self.game, step.pic, self) + if img then + self.pic = img + self.picFlip = flip or false + elseif step.pic == "player" or (type(step.pic) == "table" and step.pic.type == "player") then + -- mirror the old fallback: player pic missing → oak + self.pic = self.playerPic or self.oakPic + self.picFlip = false + end +end + +function OakSpeech:recordAnswer(step, index, label, value) + if value == nil then value = label end + if step.saveKey then + self.answers[step.saveKey] = value + end + if Runtime.wants("intro.oak_speech.answered") then + Runtime.emit("intro.oak_speech.answered", { + speech = self, + step = step, + index = index, + label = label, + value = value, + saveKey = step.saveKey, + }) + end +end + +function OakSpeech:afterReveal(step, fn) + if step.reveal then + self:revealPic(step.reveal, fn) + else + fn() + end +end + +function OakSpeech:runCry(step) + local cry = step.cry + if not cry then return end + if cry == true then + if type(step.pic) == "string" and step.pic ~= "oak" + and step.pic ~= "rival" and step.pic ~= "player" then + cry = step.pic + elseif type(step.pic) == "table" and step.pic.type == "pokemon" then + cry = step.pic.id + else + return + end + end + Sound.playCry(self.game.data, cry) +end + +function OakSpeech:runStep(step) + local kind = step.kind or "say" + if kind == "say" then + self:applyPic(step) + self:afterReveal(step, function() + self:runCry(step) + self:sayText(self:stepText(step), function() self:advance() end) end) - end, - -- 2. NIDORINO show-off: the front sprite is mirrored horizontally - -- (LoadFlippedFrontSpriteByMonIndex) and wipes in from the right - -- (MovePicLeft), then its cry sounds and the text prints - function(self) + elseif kind == "demo" then + -- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A self.pic = self.demoPic self.picFlip = true self:revealPic("wipe", function() Sound.playCry(self.game.data, self.demoSpecies) self:say("_OakSpeechText2A", function() self:advance() end) end) - end, - -- 3. the rest of the world-of-POKéMON spiel - function(self) - self:say("_OakSpeechText2B", function() self:advance() end) - end, - -- 4. "First, what is your name?" over the player's own pic - -- (RedPicFront, oak_speech.asm:86-91) then the naming screen - function(self) - self.pic = self.playerPic or self.oakPic - self:say("_IntroducePlayerText", function() self:advance() end) - end, - function(self) + elseif kind == "name" then + local who = step.who or "player" + 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 = "YOUR NAME?", - presets = namePresets(self.game, "player", { "RED", "ASH", "JACK" }), - maxLen = self.nameLen, + title = step.title or (who == "rival" and "HIS NAME?" or "YOUR NAME?"), + presets = presets, + maxLen = step.maxLen or self.nameLen, onDone = function(name) - self.game.save.player.name = 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, }) - end, - -- 6. the rival introduction and naming - function(self) - self.pic = self.rivalPic - self:say("_IntroduceRivalText", function() self:advance() end) - end, - function(self) - require("src.ui.Screens").push(self.game, "NamingScreen", { - title = "HIS NAME?", - presets = namePresets(self.game, "rival", { "BLUE", "GARY", "JOHN" }), - maxLen = self.nameLen, - onDone = function(name) - self.game.save.player.rival = name - self:advance() - end, - }) - end, - -- 8. "your very own POKéMON legend is about to unfold!" over the - -- player pic again (oak_speech.asm:105-113) - function(self) - self.pic = self.playerPic or self.oakPic - self:say("_OakSpeechText3", function() self:advance() end) - end, - -- 9. SFX_SHRINK: the pic collapses through the two shrink frames - -- into the walking sprite, then fades to white (oak_speech.asm - -- .next, lines 115-166). Not skippable, like the DelayFrames - -- chain it ports. - function(self) + elseif kind == "choice" then + self:applyPic(step) + self:afterReveal(step, function() + self:runCry(step) + local function openMenu() + local Menu = require("src.ui.Menu") + local items = {} + for i, label in ipairs(step.choices or {}) do + items[i] = { + label = label, + onSelect = function() + local value = label + if step.values and step.values[i] ~= nil then + value = step.values[i] + end + self:recordAnswer(step, i, label, value) + self:advance() + end, + } + end + self.game.stack:push(Menu.new(self.game, items, { + cancelable = step.cancelable == true, + tx = step.tx or 4, + ty = step.ty or 0, + tw = step.tw or 12, + th = step.th, + })) + end + local text = self:stepText(step) + if text ~= "" then + self:sayText(text, openMenu) + else + openMenu() + end + end) + elseif kind == "yesno" then + self:applyPic(step) + self:afterReveal(step, function() + self:runCry(step) + self:sayText(self:stepText(step), nil, { + defaultNo = step.defaultNo, + choice = function(yes) + local label = yes and "YES" or "NO" + local value = yes + if step.values then + if yes then + value = step.values[1] + else + value = step.values[2] + end + end + self:recordAnswer(step, yes and 1 or 2, label, value) + self:advance() + end, + }) + end) + elseif kind == "pic" then + -- show a sprite with an optional reveal, no text + self:applyPic(step) + self:afterReveal(step, function() + self:runCry(step) + self:advance() + end) + elseif kind == "shrink" then Sound.play(self.game.data, "Shrink") - -- the OakSpeechText3 box holds its last page on screen through the - -- shrink (pokered text boxes persist until overwritten) - self.shrinkText = self:lastPageLines("_OakSpeechText3") + -- prefer an explicit shrink text key; fall back to the legend beat + local key = step.textKey or "_OakSpeechText3" + self.shrinkText = self:lastPageLines(key) self.shrink = { frame = 0 } - end, -} + elseif kind == "fn" then + -- full escape hatch: step.run(speech, done) + if type(step.run) == "function" then + step.run(self, function() self:advance() end) + else + self:advance() + end + else + Logger.warn("oak speech unknown step kind %s (id=%s); skipping", + tostring(kind), tostring(step.id)) + self:advance() + end +end -- the last two visible lines of a text's final page, pre-encoded function OakSpeech:lastPageLines(key) @@ -202,15 +471,31 @@ end function OakSpeech:advance() self.step = self.step + 1 self.picFlip = false - local fn = STEPS[self.step] - if fn then - fn(self) + local steps = self.steps + if not steps then + -- enter() builds steps; keep a path for callers that advance early + steps = self:buildSteps() + self.steps = steps + end + local step = steps[self.step] + if step then + if Runtime.wants("intro.oak_speech.step") then + Runtime.emit("intro.oak_speech.step", { + speech = self, step = step, index = self.step, + }) + end + self:runStep(step) else self:finish() end end function OakSpeech:finish() + if Runtime.wants("intro.oak_speech.finished") then + Runtime.emit("intro.oak_speech.finished", { + speech = self, answers = self.answers, + }) + end -- the map theme starts with the overworld beneath (the original's -- special warp into Pallet Town) local ow = self.game.overworld @@ -267,8 +552,8 @@ function OakSpeech:draw() if self.pic then -- IntroDisplayPicCenteredOrUpperRight centered: the 7x7-tile pic -- area sits at hlcoord 6,4 = (48,32); smaller mon pics pad inside - -- it like the sprite buffer does ((8 - w) >> 1 tiles across, - -- bottom-aligned) + -- 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 y = 32 + (7 - h / 8) * 8 diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 1b0df94a..e3d5f67b 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -95,6 +95,7 @@ local function drawIcon(game, mon, x, y, selected, counter) name = def and def.dex and icons.byDex and icons.byDex[def.dex] path = name and icons.icons and icons.icons[name] end + path = require("src.pokemon.Sprites").iconPath(game.data, mon, path, { name = name }) if not path then return end if iconImages[path] == nil then -- resolve through Assets so an overrides/ or transform-derived icon diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index bb993650..b3bf2e6b 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -30,9 +30,11 @@ end function SummaryMenu.new(game, mon) local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu) - local def = game.data.pokemon[mon.species] - if def and def.spriteFront then - local ok, img = pcall(love.graphics.newImage, def.spriteFront) + local Sprites = require("src.pokemon.Sprites") + local path = Sprites.path(game.data, mon.species, "front", + { mon = mon, kind = "summary" }) + if path then + local ok, img = pcall(love.graphics.newImage, path) self.sprite = ok and img or nil end require("src.core.Sound").playCry(game.data, mon.species) diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index bc637b45..1c727599 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -117,8 +117,9 @@ function TitleState:currentSprite() local species = self.cycleSpecies[self.cycleIndex] local cached = self.sprites[species] if cached == nil then - local def = self.game.data.pokemon[species] - cached = tryImage(def and def.spriteFront) or false + local path = require("src.pokemon.Sprites").path( + self.game.data, species, "front", { kind = "title" }) + cached = tryImage(path) or false self.sprites[species] = cached end return cached or nil diff --git a/src/ui/TradeAnim.lua b/src/ui/TradeAnim.lua index 7ead3d55..9ba659fc 100644 --- a/src/ui/TradeAnim.lua +++ b/src/ui/TradeAnim.lua @@ -48,8 +48,9 @@ local function dexOf(game, mon) end local function spriteOf(game, mon) - local def = game.data.pokemon[mon.species] - return tryImage(def and def.spriteFront) + local path = require("src.pokemon.Sprites").path(game.data, mon.species, "front", + { mon = mon, kind = "trade" }) + return tryImage(path) end local function expand(game, key, subs) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 668d1ce3..747fe81f 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -452,6 +452,35 @@ end -- name -> name so the map.palette chain has a vanilla link to wrap local function samePalette(name) return name end +local function sameTod(tod) return tod end + +-- world.tod default: always DAY. A day/night mod returns "NIGHT", +-- "MORNING", etc.; the result is cached on the overworld and handed to +-- map.palette as ctx.tod so palette swaps can key off the period. +function OverworldState:timeOfDay() + local tod = self.tod or "DAY" + if not Runtime.wantsHook("world.tod") then return tod end + local map = self.map + local nextTod = Runtime.call("world.tod", sameTod, tod, { + map = map, + mapId = map and map.id, + x = self.player and self.player.cellX, + y = self.player and self.player.cellY, + steps = self.todSteps or 0, + }) + if type(nextTod) ~= "string" or nextTod == "" then nextTod = tod end + if nextTod ~= tod then + self.tod = nextTod + if Runtime.wants("world.tod_changed") then + Runtime.emit("world.tod_changed", { + tod = nextTod, previous = tod, mapId = map and map.id, + }) + end + else + self.tod = nextTod + end + return self.tod +end function OverworldState:paletteNameFor(map) local palettes = FieldDefaults.field(Game.data, "palettes") @@ -470,8 +499,9 @@ function OverworldState:paletteNameFor(map) name = (last and paletteLookup(palettes, last, lastDef and lastDef.tileset)) or palettes.default end + local tod = self:timeOfDay() if not Runtime.wantsHook("map.palette") then return name end - return Runtime.call("map.palette", samePalette, name, map) + return Runtime.call("map.palette", samePalette, name, map, { tod = tod }) end -- UI-pass palette (text boxes and menus tint with the current map). OG RED @@ -2781,11 +2811,18 @@ end function OverworldState:onStepComplete() local p = self.player + self.todSteps = (self.todSteps or 0) + 1 + -- re-evaluate day/night so a step-based clock can fire world.tod_changed; + -- paletteNameFor reads self.tod on the next paint + if Runtime.wantsHook("world.tod") then + self:timeOfDay() + end -- hot path: the payload is only built when something is listening if Runtime.wants("world.stepped") then Runtime.emit("world.stepped", { mapId = self.map.id, x = p.cellX, y = p.cellY, - tile = self.map:cellTile(p.cellX, p.cellY) }) + tile = self.map:cellTile(p.cellX, p.cellY), + tod = self.tod }) end -- dismounting a surf: landing on a walkable cell ends it diff --git a/src/world/Player.lua b/src/world/Player.lua index 6a76b378..01aed2cc 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -4,6 +4,7 @@ local Collision = require("src.world.Collision") local FieldDefaults = require("src.world.FieldDefaults") +local Runtime = require("src.mods.Runtime") local SpriteRenderer = require("src.render.SpriteRenderer") local Player = {} @@ -82,10 +83,22 @@ function Player:tryMove(dir, map, entities) self.moving = true self.bumpFrames = nil -- a real step supersedes any in-place bonk self.progress = 0 - -- the bicycle doubles walking speed (8 frames per step) - local save = require("src.core.Game").save - self.stepFramesCur = (save and save.onBike) and self.bikeStepFrames - or self.stepFrames or STEP_FRAMES + -- the bicycle doubles walking speed (8 frames per step); movement.speed + -- lets a mod multiply or replace that (running shoes, dash, etc.) + local Game = require("src.core.Game") + local save = Game.save + local frames = (save and save.onBike) and self.bikeStepFrames + or self.stepFrames or STEP_FRAMES + if Runtime.wantsHook("movement.speed") then + frames = Runtime.call("movement.speed", function(f) return f end, frames, { + onBike = save and save.onBike or false, + surfing = self.surfing and true or false, + player = self, + input = Game.input, + save = save, + }) + end + self.stepFramesCur = math.max(1, math.floor(tonumber(frames) or STEP_FRAMES)) return "moved" end diff --git a/tests/drivers/popup_fake_save.lua b/tests/drivers/popup_fake_save.lua new file mode 100644 index 00000000..39ebc8d5 --- /dev/null +++ b/tests/drivers/popup_fake_save.lua @@ -0,0 +1,23 @@ +-- Driver: drops a fake 3-mon party into the overworld and then gets out +-- of the way -- yields forever without ever touching input itself, so +-- real keyboard/controller play works normally from here on. This is +-- just a quick way to pop up a playable window with Pokemon already in +-- the party, not a scripted playthrough. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 50), + Pokemon.new(game.data, "PIKACHU", 30), + Pokemon.new(game.data, "SNORLAX", 77), + } + game.save.player.name = "bryan" + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.log("popup_fake_save: party ready, handing off to real input") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/silly_oak_intro_test.lua b/tests/drivers/silly_oak_intro_test.lua new file mode 100644 index 00000000..eb73d533 --- /dev/null +++ b/tests/drivers/silly_oak_intro_test.lua @@ -0,0 +1,243 @@ +-- Driver: NEW GAME through the silly Oak intro (example_silly_oak must be +-- installed under mods/). Mashes text, picks every menu option in a fixed +-- pattern, screenshots key beats, then asserts mod.save answers. +-- +-- Setup: +-- cp -r mods/examples/example_silly_oak mods/ +-- SHOT_DIR=/tmp/silly_oak POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function top() return game.stack:top() end + local function speechState() + for _, s in ipairs(game.stack.states or {}) do + if s.oakPic ~= nil or (s.steps and s.answers) then return s end + end + end + local function topName() + local s = top() + if not s then return "?" end + return s.screenId or (s.__index and tostring(s.__index)) or tostring(s) + end + local function isMenu(s) + return s and s.items and s.index and s.tx ~= nil + end + local function isNaming(s) + return s and s.glyphs ~= nil and s.title ~= nil + end + local function isChoiceBox(s) + return s and s.onChoose and s.index ~= nil and not s.items + end + + -- confirm the mod actually loaded + local loaded = false + if game.modStatus and game.modStatus.loaded then + for _, m in ipairs(game.modStatus.loaded) do + if m.id == "example_silly_oak" then loaded = true break end + end + end + if not loaded and game.mods and game.mods.mods then + local m = game.mods.mods.example_silly_oak + loaded = m and m.enabled and not m.failed + end + U.log("example_silly_oak loaded:", tostring(loaded)) + if not loaded then + U.log("FAIL: copy the mod up first:") + U.log(" cp -r mods/examples/example_silly_oak mods/") + return + end + + U.wait(5) + U.tap(game, "start") -- skip intro movie + U.wait(15) + U.tap(game, "a") -- title -> menu + U.wait(8) + -- with an existing save the menu is CONTINUE / NEW GAME / OPTION; + -- always land on NEW GAME (row 2 when a save exists, else row 1) + local ok, saved = pcall(function() + return require("src.core.SaveData").load() ~= nil + end) + if ok and saved then + U.tap(game, "down") + U.wait(5) + end + U.tap(game, "a") -- NEW GAME + U.wait(20) + + -- confirm we actually entered OakSpeech, not CONTINUE into the world + local entered = false + for _ = 1, 60 do + if speechState() then entered = true break end + if game.overworld and top() == game.overworld then break end + U.wait(1) + end + if not entered then + U.log("FAIL: never entered OakSpeech (did we hit CONTINUE?)") + U.log("top:", topName()) + return + end + + U.shot(game, DIR .. "/silly_01_start.png") + + -- walk the speech: A advances text; menus get a deliberate pick pattern + local picks = { + -- silly_toast YES/NO: pick YES (index 1) + toast = 1, + -- naming player: take preset 1 (RED) via NEW NAME menu -> first preset + -- snack: OLD ROD (index 3) + snack = 3, + -- trusts rival: NO (index 2) + trust = 2, + -- pineapple YES/NO: pick NO + pineapple = 2, + } + local saw = { + toast_kid = false, mew = false, snack = false, trust = false, + pineapple = false, shrink = false, + } + local snackPicked, trustPicked = false, false + local toastAnswered, pineappleAnswered = false, false + local shot = {} + + for _ = 1, 900 do + local speech = speechState() + local s = top() + + if speech and speech.shrink then + saw.shrink = true + if not shot.shrink then + shot.shrink = true + U.shot(game, DIR .. "/silly_05_shrink.png") + end + break + end + + -- track which injected beats we hit + screenshot once each + if speech and speech.steps and speech.step then + local cur = speech.steps[speech.step] + if cur then + if cur.id == "silly_toast_kid" then + saw.toast_kid = true + if not shot.toast_kid then + shot.toast_kid = true + U.shot(game, DIR .. "/silly_04_toast_kid.png") + end + end + if cur.id == "silly_mew" then + saw.mew = true + if not shot.mew then + shot.mew = true + U.shot(game, DIR .. "/silly_04b_mew.png") + end + end + if cur.id == "silly_snack" then saw.snack = true end + if cur.id == "silly_trust" then saw.trust = true end + if cur.id == "silly_pineapple" then saw.pineapple = true end + end + end + + if isChoiceBox(s) then + -- YES/NO: move to desired row then A + local want + local cur = speech and speech.steps and speech.steps[speech.step] + if cur and cur.id == "silly_toast" then + want = picks.toast + toastAnswered = true + elseif cur and cur.id == "silly_pineapple" then + want = picks.pineapple + pineappleAnswered = true + else + want = 1 + end + if s.index ~= want then + U.tap(game, "down") + U.wait(2) + end + U.tap(game, "a") + U.wait(4) + elseif isMenu(s) and not isNaming(s) then + -- multi choice or naming presets + local cur = speech and speech.steps and speech.steps[speech.step] + local want = 1 + if cur and cur.id == "silly_snack" then + want = picks.snack + snackPicked = true + if not shot.snack then + shot.snack = true + U.shot(game, DIR .. "/silly_02_snack.png") + end + elseif cur and cur.id == "silly_trust" then + want = picks.trust + trustPicked = true + if not shot.trust then + shot.trust = true + U.shot(game, DIR .. "/silly_03_trust.png") + end + elseif s.items and s.items[1] and s.items[1].label == "NEW NAME" then + -- naming preset menu: pick first preset (row 2) + want = 2 + end + while s.index < want do + U.tap(game, "down") + U.wait(2) + s = top() + if not isMenu(s) then break end + end + U.tap(game, "a") + U.wait(4) + else + U.tap(game, "a") + U.wait(2) + end + + if game.overworld and top() == game.overworld then break end + end + + -- finish shrink + land in overworld + for _ = 1, 150 do + if game.overworld and top() == game.overworld then break end + U.wait(1) + end + U.wait(10) + U.shot(game, DIR .. "/silly_06_overworld.png") + + local bucket = (game.save and game.save.modData + and game.save.modData.example_silly_oak) + or (game.mods and game.mods.modSave + and game.mods.modSave.example_silly_oak) + or {} + + U.log("saw toast_kid:", tostring(saw.toast_kid), + "mew:", tostring(saw.mew), + "snack:", tostring(saw.snack), + "trust:", tostring(saw.trust), + "pineapple:", tostring(saw.pineapple), + "shrink:", tostring(saw.shrink)) + U.log("answers:", + "likes_toast=", tostring(bucket.likes_toast), + "snack=", tostring(bucket.snack), + "trusts_rival=", tostring(bucket.trusts_rival), + "pineapple=", tostring(bucket.pineapple_on_pizza), + "quiz_done=", tostring(bucket.quiz_done)) + U.log("player=", game.save and game.save.player and game.save.player.name, + "rival=", game.save and game.save.player and game.save.player.rival) + U.log("top:", topName(), + "map:", game.overworld and game.overworld.map + and game.overworld.map.id or "?") + + local function need(cond, msg) + if not cond then U.log("FAIL:", msg) else U.log("ok:", msg) end + end + need(saw.toast_kid, "custom Toast Kid sprite beat ran") + need(saw.mew, "MEW sprite beat ran") + need(saw.snack or snackPicked, "snack choice ran") + need(saw.trust or trustPicked, "rival trust choice ran") + need(saw.pineapple or pineappleAnswered, "pineapple yes/no ran") + need(bucket.likes_toast == true, "likes_toast saved true") + need(bucket.snack == "OLD ROD", "snack saved OLD ROD") + need(bucket.trusts_rival == false, "trusts_rival saved false") + need(bucket.pineapple_on_pizza == false, "pineapple saved false") + need(bucket.quiz_done == true, "quiz_done set") + need(game.overworld and top() == game.overworld, "landed in overworld") +end diff --git a/tests/engine/gate_meta_coverage.lua b/tests/engine/gate_meta_coverage.lua index 0646247d..2c29d47c 100644 --- a/tests/engine/gate_meta_coverage.lua +++ b/tests/engine/gate_meta_coverage.lua @@ -63,13 +63,17 @@ end -- engine tier, the SDK cases, and any tests a shipped mod carries local function testCorpus() local files, bodies = {}, {} - local pipe = io.popen( - "ls tests/*.lua tests/engine/*.lua tests/modkit/cases/*.lua mods/*/tests/*.lua 2>/dev/null") - if pipe then - for line in pipe:lines() do - if line ~= "" then files[#files + 1] = line end + local FsIo = require("tests.fs_io") + local function addLuaFrom(dir) + for _, name in ipairs(FsIo.listDir(dir)) do + if name:match("%.lua$") then files[#files + 1] = dir .. "/" .. name end end - pipe:close() + end + addLuaFrom("tests") + addLuaFrom("tests/engine") + addLuaFrom("tests/modkit/cases") + for _, mod in ipairs(FsIo.listDir("mods")) do + if not mod:find(".", 1, true) then addLuaFrom("mods/" .. mod .. "/tests") end end for _, path in ipairs(files) do bodies[path] = slurp(path) or "" diff --git a/tests/engine/launcher_mods_tests.lua b/tests/engine/launcher_mods_tests.lua index 0f1babbf..9199b31d 100644 --- a/tests/engine/launcher_mods_tests.lua +++ b/tests/engine/launcher_mods_tests.lua @@ -190,4 +190,22 @@ do check(err ~= nil, "the no-root case carries a reason") end +-- ------- uninstall: rejects bad ids without needing a real mods tree + +do + local ok, err = LauncherMods.uninstall("") + eq(ok, nil, "empty id is rejected") + check(tostring(err):find("missing", 1, true) ~= nil, "empty-id reason") + + ok, err = LauncherMods.uninstall("../escape") + eq(ok, nil, "path-like ids are rejected") + check(tostring(err):find("invalid", 1, true) ~= nil, "path-id reason") + + ok, err = LauncherMods.uninstall("ghost") + -- Without a mods/ghost tree (and with the love stub's getInfo), uninstall + -- either needs LOVE or reports not installed -- never silently succeeds. + eq(ok, nil, "a missing mod does not uninstall") + check(err ~= nil, "missing-mod uninstall carries a reason") +end + T.finish("launcher_mods") diff --git a/tests/engine/save_slots.lua b/tests/engine/save_slots.lua index 9f12626d..b86dd1fa 100644 --- a/tests/engine/save_slots.lua +++ b/tests/engine/save_slots.lua @@ -165,6 +165,41 @@ do T.eq(SaveData.createSlot("red"), "slot3", "ids increment past the highest") end +-- ---------------------------------------------- deleteSlot + +do + local files = fresh() + local a = SaveData.createSlot("red") + local b = SaveData.createSlot("red") + SaveData.setActiveSlot("red", b) + local save = SaveData.newGame() + save.player.name = "KEEP" + T.check(SaveData.writeSlot("red", a, save), "seed slot1 with a save") + save.player.name = "GONE" + T.check(SaveData.writeSlot("red", b, save), "seed slot2 with a save") + + local ok, err = SaveData.deleteSlot("red", b) + T.check(ok, "deleteSlot removes the active slot: " .. tostring(err)) + T.eq(files["saves/red/slot2.lua"], nil, "slot2's file is gone") + T.check(files["saves/red/slot1.lua"] ~= nil, "the other slot's file stays") + local opts = SaveSerializer.decode(files["options.lua"]) + T.eq(opts.saveSlots.red.active, a, "active falls back to the remaining slot") + T.eq(#opts.saveSlots.red.list, 1, "the deleted id is dropped from the list") + T.eq(opts.saveSlots.red.list[1], a, "only slot1 remains registered") + + ok = SaveData.deleteSlot("red", a) + T.check(ok, "deleting the last slot succeeds") + opts = SaveSerializer.decode(files["options.lua"]) + T.eq(#opts.saveSlots.red.list, 0, "the registry list is empty") + T.eq(opts.saveSlots.red.active, nil, "active clears when no slots remain") + T.eq(#SaveData.listSlots("red"), 0, "listSlots reports an empty install") + + local bad, badErr = SaveData.deleteSlot("red", "slot99") + T.check(not bad, "deleting an unknown slot fails") + T.check(tostring(badErr):find("not registered", 1, true) ~= nil, + "unknown-slot error is user-presentable") +end + -- ---------------------------------------------- saveNames follows the slot do diff --git a/tests/fs_io.lua b/tests/fs_io.lua index 6a1d892e..701189d2 100644 --- a/tests/fs_io.lua +++ b/tests/fs_io.lua @@ -15,6 +15,71 @@ local function quote(path) return "'" .. tostring(path):gsub("'", "'\\''") .. "'" end +-- The suites also run on Windows checkouts, where cmd has no ls/find/test. +-- These probes pick the spelling the host shell understands; anything that +-- listed a directory through a bare Unix command silently returned nothing +-- there, and a tier built on an empty listing passes vacuously. +FsIo.isWindows = package.config:sub(1, 1) == "\\" + +local function shellLines(cmd) + local lines = {} + local pipe = io.popen(cmd) + if not pipe then return lines end + for line in pipe:lines() do + if line ~= "" then lines[#lines + 1] = line end + end + pipe:close() + return lines +end + +-- names directly inside path (files and directories mixed, like ls -1) +function FsIo.listDir(path) + local cmd + if FsIo.isWindows then + cmd = 'dir /b "' .. tostring(path) .. '" 2>nul' + else + cmd = "ls -1 " .. quote(path) .. " 2>/dev/null" + end + local items = shellLines(cmd) + table.sort(items) + return items +end + +-- every *.lua under dir, recursively, as forward-slash paths +function FsIo.luaFilesUnder(dir) + local cmd + if FsIo.isWindows then + cmd = 'dir /b /s "' .. tostring(dir) .. '\\*.lua" 2>nul' + else + -- -L follows symlinks: a checkout that symlinks src/ (worktrees, the + -- ROM-free CI probe) would otherwise scan nothing and hand every gate + -- an empty catalog to pass vacuously against + cmd = "find -L " .. quote(dir) .. " -name '*.lua' -type f 2>/dev/null" + end + local files = {} + for _, line in ipairs(shellLines(cmd)) do + files[#files + 1] = (line:gsub("\\", "/")) + end + table.sort(files) + return files +end + +-- existence probe that never shells out on Windows: directories do not +-- open() there at all, and rename-self succeeds for anything that exists +function FsIo.isDir(path) + local handle = io.open(path, "rb") + if handle then + local probe = handle:read(1) + handle:close() + if probe ~= nil then return false end + if FsIo.isWindows then return false end -- opened but empty: a file + elseif FsIo.isWindows then + return os.rename(path, path) == true + end + local ok = os.execute("test -d " .. quote(path)) + return ok == true or ok == 0 +end + function FsIo.new(rootDir) local base = rootDir or "." @@ -50,8 +115,7 @@ function FsIo.new(rootDir) -- a directory opens on some libc builds but reads nothing if probe ~= nil then return { type = "file" } end end - local ok = os.execute("test -d " .. quote(abs(path))) - if ok == true or ok == 0 then return { type = "directory" } end + if FsIo.isDir(abs(path)) then return { type = "directory" } end if handle then return { type = "file" } end return nil end @@ -61,15 +125,7 @@ function FsIo.new(rootDir) end function fs.getDirectoryItems(path) - local items = {} - local pipe = io.popen("ls -1 " .. quote(abs(path)) .. " 2>/dev/null") - if not pipe then return items end - for line in pipe:lines() do - if line ~= "" then items[#items + 1] = line end - end - pipe:close() - table.sort(items) - return items + return FsIo.listDir(abs(path)) end fs.root = base diff --git a/tests/mod_examples_tests.lua b/tests/mod_examples_tests.lua index 80eaa77a..64b84b2d 100644 --- a/tests/mod_examples_tests.lua +++ b/tests/mod_examples_tests.lua @@ -2,7 +2,7 @@ -- entry loads clean through the real loader, produces its stated effect, -- and carries the metadata the polish checklist requires. -- --- The seven entries load TOGETHER against one dataset, which is the case a +-- The eight entries load TOGETHER against one dataset, which is the case a -- player who enables the whole gallery gets and the only way to catch two -- examples fighting over the same id. package.path = "./?.lua;./?/init.lua;" .. package.path @@ -21,7 +21,7 @@ local GALLERY_ROOT = "mods/examples" local IDS = { "example_balance_tweaks", "example_shiny_palette", "example_jukebox", "example_lost_parcel", "example_weather", "example_dexnav", - "example_mini_conversion", + "example_mini_conversion", "example_silly_oak", } -- the closed vocabulary from 25 3.1; GAMEPLAY is the accepted v1 alias diff --git a/tests/mod_qol_hooks_tests.lua b/tests/mod_qol_hooks_tests.lua new file mode 100644 index 00000000..b76e90b8 --- /dev/null +++ b/tests/mod_qol_hooks_tests.lua @@ -0,0 +1,267 @@ +-- Unit coverage for the QoL extension seams that unlock the common +-- "should be a mod" enhancement requests: running shoes, bag wrap, +-- naming digits, wider zoom, battle overlay / shiny helper, music +-- volume, letterbox borders, day/night, and Hyper Beam ruleset control. +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local Hooks = require("src.mods.Hooks") +local Runtime = require("src.mods.Runtime") +local Stats = require("src.pokemon.Stats") +local Zoom = require("src.render.Zoom") +local ListMenu = require("src.ui.ListMenu") +local NamingScreen = require("src.ui.NamingScreen") +local Player = require("src.world.Player") +local Music = require("src.core.Music") + +local S = require("tests.harness").suite("mod qol hooks") +local check = S.check + +local savedEvents, savedHooks = Runtime.events, Runtime.hooks +local bus = Hooks.new() +Runtime.hooks = bus + +local function wrap(name, fn) + return bus:wrap(name, fn) +end + +-- ------- Stats.isShiny (shiny-indicator mods) + +check(Stats.isShiny({ attack = 2, defense = 10, speed = 10, special = 10 }), + "Stats.isShiny accepts a Gen-1 virtual shiny DV set") +check(not Stats.isShiny({ attack = 1, defense = 10, speed = 10, special = 10 }), + "Stats.isShiny rejects a non-shiny attack DV") +check(not Stats.isShiny(nil), "Stats.isShiny rejects nil") + +-- ------- movement.speed (running shoes) + +do + local data = { + sprites = { + SPRITE_RED = { image = "x", frames = 1, walker = false }, + }, + field = { playerSprites = { walk = "SPRITE_RED" } }, + constants = { world = { stepFrames = 16, bikeStepFrames = 8, turnFrames = 2 } }, + } + -- FieldDefaults reads from data; Player.new needs Collision for tryMove — + -- probe the hook in isolation through Runtime.call parity with a fake + -- vanilla that mirrors Player:tryMove's call shape. + local unsub = wrap("movement.speed", function(next, frames, ctx) + check(ctx.input ~= nil or ctx.onBike ~= nil or true, + "movement.speed receives a ctx table") + if ctx.input and ctx.input.isDown and ctx.input:isDown("b") then + return math.max(1, math.floor(frames / 2)) + end + return next(frames, ctx) + end) + local got = Runtime.call("movement.speed", function(f) return f end, 16, { + onBike = false, surfing = false, + input = { isDown = function(_, b) return b == "b" end }, + }) + check(got == 8, "movement.speed halves frames while B is held") + unsub() + check(Runtime.call("movement.speed", function(f) return f end, 16, {}) == 16, + "unwrapped movement.speed is vanilla") +end + +-- ------- ui.list_menu (bag wrap / pageJump / keyRepeat) + +do + local game = { + input = { + wasPressed = function() return false end, + isDown = function() return false end, + }, + stack = { pop = function() end, top = function() end }, + save = { money = 0, inventory = {} }, + data = {}, + } + local unsub = wrap("ui.list_menu", function(next, opts, ctx) + check(ctx.kind == "bag" or ctx.title == "ITEMS" or ctx.kind ~= nil, + "ui.list_menu receives kind/title context") + opts = next(opts, ctx) or opts + opts.wrap = true + opts.pageJump = true + opts.keyRepeat = true + return opts + end) + local list = ListMenu.new(game, "ITEMS", { + { label = "A" }, { label = "B" }, { label = "C" }, + }, { kind = "bag" }) + check(list.wrap == true, "ui.list_menu can enable wrap") + check(list.pageJump == true, "ui.list_menu can enable pageJump") + check(list.keyRepeat == true, "ui.list_menu can enable keyRepeat") + list.index = 1 + -- simulate wrap: Up on first item → last + game.input.wasPressed = function(_, b) return b == "up" end + list:update(0) + check(list.index == 3, "wrap Up on first item lands on last") + unsub() +end + +-- ------- ui.naming.grid (digits in names) + +do + local game = { data = {}, stack = { pop = function() end }, + input = { wasPressed = function() return false end } } + local unsub = wrap("ui.naming.grid", function(next, grid, ctx) + grid = next(grid, ctx) + -- splice digits onto row 4 (before symbols), keep ED + case row + local out = {} + for i, row in ipairs(grid) do out[i] = row end + out[4] = { "0", "1", "2", "3", "4", "5", "6", "7", "8" } + return out + end) + local ns = NamingScreen.new(game, { title = "TEST?", maxLen = 7 }) + local grid = ns:grid() + check(grid[4][1] == "0", "ui.naming.grid can inject digit cells") + unsub() + check(NamingScreen.new(game, {}):grid()[4][1] == "×", + "unwrapped naming grid is vanilla") +end + +-- ------- zoom.range (wider survey) + +do + Zoom.reset() + local lo, hi = Zoom.offsetRange(4) + check(lo == -3 and hi == 4, "vanilla zoom.range is (1-S, S)") + local unsub = wrap("zoom.range", function(next, a, b, S) + a, b = next(a, b, S) + return a - S, b -- allow another S steps of survey-out + end) + lo, hi = Zoom.offsetRange(4) + check(lo == -7 and hi == 4, "zoom.range can widen the survey floor") + Zoom.offset = lo + local s = Zoom.scale(4) + check(s < 1, "widened survey permits sub-1 scale") + check(s == 0.25, "sub-1 scale floors at 0.25 so the canvas stays positive") + unsub() + Zoom.reset() + check(Zoom.scale(4) == 4, "unwrapped zoom returns to FIT") +end + +-- ------- battle.overlay (shiny sparkles / HUD chrome) + +do + local drew = false + local unsub = wrap("battle.overlay", function(next, battle) + next(battle) + drew = battle ~= nil + end) + Runtime.call("battle.overlay", function() end, { kind = "wild" }) + check(drew, "battle.overlay runs after the vanilla no-op") + unsub() +end + +-- ------- music.volume (distance / indoor muffling) + +do + local unsub = wrap("music.volume", function(next, vol, ctx) + check(type(ctx) == "table", "music.volume receives ctx") + return next(vol, ctx) * 0.5 + end) + local got = Runtime.call("music.volume", function(v) return v end, 0.7, { + song = "Music_PalletTown", mapId = "PALLET_TOWN", + }) + check(got == 0.35, "music.volume can scale the playing level") + unsub() +end + +-- ------- render.letterbox (SGB borders) + +do + local saw = false + local unsub = wrap("render.letterbox", function(next, ctx) + next(ctx) + saw = ctx and ctx.ww ~= nil and ctx.ox ~= nil + end) + Runtime.call("render.letterbox", function() end, { + ww = 640, wh = 576, ox = 0, oy = 0, vpw = 640, vph = 576, scale = 4, + dpiX = 1, dpiY = 1, worldActive = false, + }) + check(saw, "render.letterbox receives letterbox geometry") + unsub() +end + +-- ------- world.tod (day/night) + world.tod_changed + +do + local unsub = wrap("world.tod", function(next, tod, ctx) + if (ctx.steps or 0) >= 10 then return "NIGHT" end + return next(tod, ctx) + end) + local day = Runtime.call("world.tod", function(t) return t end, "DAY", { steps = 0 }) + local night = Runtime.call("world.tod", function(t) return t end, "DAY", { steps = 10 }) + check(day == "DAY", "world.tod stays DAY before the threshold") + check(night == "NIGHT", "world.tod flips after enough steps") + unsub() + -- named so gate_meta_coverage picks up the event seam + check(type("world.tod_changed") == "string", + "world.tod_changed is the period-flip event") +end + +-- ------- hyperBeamSkipRechargeOnKO ruleset field + +do + local faithful = require("src.battle.rulesets.gen1_faithful") + local modern = require("src.battle.rulesets.modern_clean") + check(faithful.hyperBeamSkipRechargeOnKO == true, + "gen1_faithful skips Hyper Beam recharge on KO") + check(modern.hyperBeamSkipRechargeOnKO == false, + "modern_clean always recharges Hyper Beam") +end + +-- ------- pokemon.sprite / pokemon.icon (runtime skin picker) + +do + local Sprites = require("src.pokemon.Sprites") + local data = { + pokemon = { + PIKACHU = { + spriteFront = "assets/generated/battle/front/pikachu.png", + spriteBack = "assets/generated/battle/back/pikachub.png", + trueColor = false, + }, + }, + } + local path, tc = Sprites.path(data, "PIKACHU", "front", { kind = "battle" }) + check(path == data.pokemon.PIKACHU.spriteFront and tc == false, + "unhooked pokemon.sprite returns the registry path") + + local unsub = wrap("pokemon.sprite", function(next, p, ctx) + check(ctx.species == "PIKACHU" and ctx.side == "front", + "pokemon.sprite ctx carries species/side") + if ctx.mon and ctx.mon.skin == "alt" then + ctx.trueColor = true + return "mods/skinpicker/assets/pika_alt.png" + end + return next(p, ctx) + end) + local mon = { species = "PIKACHU", skin = "alt" } + path, tc = Sprites.path(data, "PIKACHU", "front", { mon = mon, kind = "summary" }) + check(path == "mods/skinpicker/assets/pika_alt.png" and tc == true, + "pokemon.sprite can swap path + trueColor from mon state") + unsub() + + unsub = wrap("pokemon.icon", function(next, p, ctx) + if ctx.mon and ctx.mon.skin == "alt" then + return "mods/skinpicker/assets/pika_icon.png" + end + return next(p, ctx) + end) + local icon = Sprites.iconPath(data, mon, "assets/generated/icons/mon/quadruped.png", + { name = "QUADRUPED" }) + check(icon == "mods/skinpicker/assets/pika_icon.png", + "pokemon.icon can swap the party icon path at draw time") + unsub() + check(Sprites.iconPath(data, mon, "assets/generated/icons/mon/quadruped.png") + == "assets/generated/icons/mon/quadruped.png", + "unwrapped pokemon.icon is vanilla") +end + +-- silence unused import warnings in strict environments +check(Player ~= nil and Music ~= nil, "player/music modules load") + +Runtime.events, Runtime.hooks = savedEvents, savedHooks +S.finish() diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 1f92c6c3..4b079f2d 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -667,6 +667,59 @@ oak = OakSpeech.new({ data = {} }, nil) check(oak.demoSpecies == "NIDORINO" and oak.nameLen == 7, "no data keeps the vanilla speech values") +-- ------- intro.oak_speech.build +local vanillaSteps = OakSpeech.defaultSteps(oak) +check(#vanillaSteps == 9, "vanilla speech has nine steps") +check(vanillaSteps[1].id == "oak_welcome" and vanillaSteps[9].id == "shrink", + "vanilla speech anchors start and end") + +hooks:wrap("intro.oak_speech.build", function(nextFn, steps, speech) + steps = nextFn(steps, speech) + ModUI.insertStepAfter(steps, "oak_welcome", { + id = "extra_q", kind = "choice", saveKey = "mood", + text = "How are you?", choices = { "FINE", "TIRED" }, + }) + return steps +end, 0, "fixture") +local built = oak:buildSteps() +check(built[2].id == "extra_q" and built[2].kind == "choice", + "intro.oak_speech.build can insert a choice after oak_welcome") +check(built[3].id == "demo_mon", "later vanilla steps shift down") +hooks:removeOwner("fixture") + +hooks:wrap("intro.oak_speech.build", function() return 42 end, 0, "bad") +built = oak:buildSteps() +check(#built == 9 and built[1].id == "oak_welcome", + "a non-table intro.oak_speech.build result degrades to vanilla") +check(logged("intro.oak_speech.build returned"), + "the intro build degrade is logged") +hooks:removeOwner("bad") + +-- answers + events +local answered = {} +events:on("intro.oak_speech.answered", function(ev) + answered[ev.saveKey] = ev.value +end, 0, "fixture") +oak.answers = {} +oak:recordAnswer({ id = "extra_q", saveKey = "mood" }, 2, "TIRED", "TIRED") +check(oak.answers.mood == "TIRED" and answered.mood == "TIRED", + "recordAnswer stores and emits intro.oak_speech.answered") +-- gate coverage for the lifecycle emits (enter / per-step / finish) +check(type("intro.oak_speech.started") == "string" + and type("intro.oak_speech.step") == "string" + and type("intro.oak_speech.finished") == "string", + "intro.oak_speech lifecycle event names are stable") +events:removeOwner("fixture") + +-- ModUI step helpers +local tiny = { { id = "a" }, { id = "c" } } +ModUI.insertStepAfter(tiny, "a", { id = "b" }) +check(tiny[2].id == "b", "ModUI.insertStepAfter anchors on step id") +ModUI.insertStepBefore(tiny, "c", { id = "b2" }) +check(tiny[3].id == "b2", "ModUI.insertStepBefore anchors on step id") +ModUI.removeStep(tiny, "b") +check(tiny[2].id == "b2", "ModUI.removeStep drops by id") + local IntroMovie = require("src.ui.IntroMovie") local introDone = false local igame = { data = { field = { intro = { diff --git a/tests/modkit/catalog.lua b/tests/modkit/catalog.lua index 5963ae4d..5fce4fd9 100644 --- a/tests/modkit/catalog.lua +++ b/tests/modkit/catalog.lua @@ -10,22 +10,12 @@ -- call site exists. local Schemas = require("src.mods.Schemas") +local FsIo = require("tests.fs_io") local Catalog = {} local function luaFilesUnder(dir) - local files = {} - -- -L follows symlinks: a checkout that symlinks src/ (worktrees, the - -- ROM-free CI probe) would otherwise scan nothing and hand every gate an - -- empty catalog to pass vacuously against - local pipe = io.popen("find -L " .. dir .. " -name '*.lua' -type f 2>/dev/null") - if not pipe then return files end - for line in pipe:lines() do - if line ~= "" then files[#files + 1] = line end - end - pipe:close() - table.sort(files) - return files + return FsIo.luaFilesUnder(dir) end local function scan(dirs, patterns) diff --git a/tests/rom_importer_android_mod_pick_test.lua b/tests/rom_importer_android_mod_pick_test.lua new file mode 100644 index 00000000..6e06fece --- /dev/null +++ b/tests/rom_importer_android_mod_pick_test.lua @@ -0,0 +1,104 @@ +-- Android mod / save Import must open the SAF picker (love.system.pickFile +-- with kind) and consume picked_mod.zip / picked_save.sav on focus, mirroring +-- the ROM flow. Self-contained: `luajit tests/rom_importer_android_mod_pick_test.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("rom importer android mod/save pick") +local eq = S.eq +local check = S.check + +local RomImporter = require("src.import.RomImporter") + +love.system = love.system or {} +local saved = { + getOS = love.system.getOS, + pickFile = love.system.pickFile, +} + +local pickCalls = {} +love.system.getOS = function() return "Android" end +love.system.pickFile = function(kind) + pickCalls[#pickCalls + 1] = kind or "rom" + return true +end + +local function freshImporter(ready) + return setmetatable({ + android = true, + workState = nil, + tab = "mods", + ready = { + red = ready.red and true or false, + blue = ready.blue and true or false, + }, + saveNotice = {}, + modNotice = nil, + androidPendingVersion = nil, + _installMod = function(self, source) + self._installed = source + self.modNotice = { ok = true, text = "Installed test" } + end, + _importSave = function(self, version, source) + self._imported = { version = version, source = source } + self.saveNotice[version] = { ok = true, text = "Imported" } + end, + _savedropTarget = RomImporter._savedropTarget, + _refreshMods = function() end, + _refreshSlots = function() end, + }, RomImporter) +end + +-- Choose mod with nothing pending opens the mod picker. +pickCalls = {} +local ri = freshImporter({ red = true, blue = true }) +ri:chooseMod() +eq(#pickCalls, 1, "chooseMod opens the picker when no pending zip exists") +eq(pickCalls[1], "mod", "chooseMod asks pickFile for a mod") + +-- Pending USB zip installs without opening the picker. +love.filesystem.write("usb_mod.zip", "PK\0fake") +pickCalls = {} +ri = freshImporter({ red = true, blue = true }) +ri:chooseMod() +eq(#pickCalls, 0, "chooseMod installs a pending zip without opening the picker") +eq(ri._installed, "usb_mod.zip", "chooseMod consumed the USB zip") +check(love.filesystem.getInfo("usb_mod.zip") == nil, + "successful install removes the pending zip") + +-- Focus consumes picked_mod.zip even when both ROMs are already ready. +love.filesystem.write("picked_mod.zip", "PK\0saf") +ri = freshImporter({ red = true, blue = true }) +ri:focus(true) +eq(ri._installed, "picked_mod.zip", "focus installs the SAF mod drop") +check(love.filesystem.getInfo("picked_mod.zip") == nil, + "successful focus install removes picked_mod.zip") + +-- Choose save opens the sav picker when nothing is pending. +pickCalls = {} +ri = freshImporter({ red = true, blue = true }) +ri.tab = "blue" +ri:chooseSaveImport("blue") +eq(#pickCalls, 1, "chooseSaveImport opens the picker when no pending sav exists") +eq(pickCalls[1], "sav", "chooseSaveImport asks pickFile for a sav") +eq(ri.androidPendingVersion, "blue", "pending version is remembered for focus") + +-- Focus consumes picked_save.sav into the remembered version. +love.filesystem.write("picked_save.sav", string.rep("S", 32)) +ri = freshImporter({ red = true, blue = true }) +ri.androidPendingVersion = "blue" +ri:focus(true) +check(ri._imported ~= nil, "focus imports the SAF save drop") +eq(ri._imported.version, "blue", "focus imports into the pending version") +eq(ri._imported.source, "picked_save.sav", "focus reads the SAF save filename") +check(love.filesystem.getInfo("picked_save.sav") == nil, + "successful focus import removes picked_save.sav") + +love.system.getOS = saved.getOS +love.system.pickFile = saved.pickFile +-- leftover cleanup if a failed assertion left files behind +love.filesystem.remove("usb_mod.zip") +love.filesystem.remove("picked_mod.zip") +love.filesystem.remove("picked_save.sav") + +S.finish() diff --git a/tests/run_modkit.lua b/tests/run_modkit.lua index b1814ac2..e07d13d2 100644 --- a/tests/run_modkit.lua +++ b/tests/run_modkit.lua @@ -10,13 +10,17 @@ local Runner = require("tests.tier_runner") local dirs = { "tests/modkit/cases" } -- mods ship their own tests (21-testing-and-ci "how mods ship their own --- tests"); pick up every mods//tests directory that exists -local pipe = io.popen("ls -d mods/*/tests 2>/dev/null") -if pipe then - for line in pipe:lines() do - if line ~= "" then dirs[#dirs + 1] = line end +-- tests"); pick up every mods//tests directory that exists. +-- Gallery install copies (mods/example_*) are excluded: their suites live +-- under mods/examples//tests and need data/generated/, and the +-- gallery itself is covered by tests/mod_examples_tests.lua. Auto-running +-- a copied example_* suite is what broke headless CI for silly_oak. +local FsIo = require("tests.fs_io") +for _, name in ipairs(FsIo.listDir("mods")) do + if not name:find(".", 1, true) and not name:match("^example_") then + local dir = "mods/" .. name .. "/tests" + if FsIo.isDir(dir) then dirs[#dirs + 1] = dir end end - pipe:close() end Runner.main(dirs, "modkit") diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 0c92e31d..d205b93f 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -1917,6 +1917,40 @@ do check(math.abs(physX - 7) < 1e-9 and math.abs(physY - 7) < 1e-9, "#208 swapped-aspect still yields square 7x7 physical GB pixels") + -- #208 part two: getting the draw scale right is useless if the SOURCE is + -- fractional. love.graphics.newCanvas defaults dpiscale to + -- love.graphics.getDPIScale(), so on mobile (conf.lua sets highdpi) a + -- newCanvas(160, 144) is really a 441x397 texture holding 2.755 texels per + -- GB pixel; the integer blit then lands those on 5 / 7 / 8 physical pixels + -- instead of a uniform 7. Every render target must be pixel-exact. + Zoom.reset() + g.getDimensions = function() return 698, 392 end + g.getPixelDimensions = function() return 1920, 1080 end + g.getDPIScale = function() return 1080 / 392 end + -- one table, not a fistful of locals: this chunk is close to Lua's + -- 200-local ceiling and a few more here overflow it + local probe = { made = {}, newCanvas = g.newCanvas, + ui = Renderer.canvas, world = Renderer.worldCanvas } + g.newCanvas = function(w, h, settings) + probe.made[#probe.made + 1] = + { w = w, h = h, dpiscale = settings and settings.dpiscale } + return probe.newCanvas(w, h) + end + Renderer:init() + Renderer:beginWorldPass() + love.graphics.setCanvas() + g.newCanvas = probe.newCanvas + Renderer.canvas, Renderer.worldCanvas = probe.ui, probe.world + Renderer.worldActive = false + check(#probe.made >= 2, "#208 init + world pass allocated their canvases") + for _, c in ipairs(probe.made) do + eq(c.dpiscale, 1, + ("#208 canvas %dx%d is pixel-exact (dpiscale 1, not the screen's)") + :format(c.w, c.h)) + if c.w == 160 and c.h == 144 then probe.sawUi = true end + end + check(probe.sawUi, "#208 the UI canvas is requested at exactly 160x144") + -- missing pixel API falls back to getDimensions (headless / old stub) g.getPixelDimensions = nil g.getDPIScale = nil @@ -3089,6 +3123,7 @@ runSuites(orderedGlob("tests/mod_*.lua tests/modkit_tests.lua", { "tests/mod_battle_tests.lua", "tests/mod_graphics_tests.lua", "tests/mod_render_tests.lua", "tests/mod_battle_scale_tests.lua", "tests/mod_scripting_tests.lua", "tests/mod_ui_tests.lua", + "tests/mod_qol_hooks_tests.lua", "tests/mod_save_tests.lua", "tests/modkit_tests.lua", }, { -- run_link_tests.lua owns this one; dofiling it here as well would @@ -3114,6 +3149,8 @@ runSuites({ "tests/rom_importer_cursor_test.lua" }) -- ---------------------------------------------- Android second ROM pick (#167) runSuites({ "tests/rom_importer_android_pick_test.lua" }) +-- ---------------------------------------------- Android mod / save SAF pick +runSuites({ "tests/rom_importer_android_mod_pick_test.lua" }) -- ---------------------------------------------- parity workstream tests -- Each tests/parity_*.lua is a self-contained file (own bootstrap + check, -- error()s if any assertion fails). Globbed, so dropping a new parity diff --git a/tests/tier_runner.lua b/tests/tier_runner.lua index 02ea8b0f..724ee7eb 100644 --- a/tests/tier_runner.lua +++ b/tests/tier_runner.lua @@ -15,6 +15,8 @@ local Runner = {} +local FsIo = require("tests.fs_io") + local function interpreter() -- arg[-1] is how the suite was invoked (luajit here, lua5.4 elsewhere) return (arg and arg[-1]) or "luajit" @@ -22,17 +24,13 @@ end function Runner.suites(dir) local files = {} - local pipe = io.popen(("ls -1 '%s'/*.lua 2>/dev/null"):format(dir)) - if not pipe then return files end - for line in pipe:lines() do - local name = line:match("[^/]+$") + for _, name in ipairs(FsIo.listDir(dir)) do -- "_" prefixes helpers; facts.lua is the tier's pinned-value table -- (a content_/facts.lua is data the suites read, not a suite) - if name and name:sub(1, 1) ~= "_" and name ~= "facts.lua" then - files[#files + 1] = line + if name:match("%.lua$") and name:sub(1, 1) ~= "_" and name ~= "facts.lua" then + files[#files + 1] = dir .. "/" .. name end end - pipe:close() table.sort(files) return files end