diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index e412b4d2..1994d8a9 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -512,8 +512,9 @@ gains a field instead of the name gaining a prefix. id under Gen 1's `name` key, which is the one payload difference the numeric flag space forces. - *Menus (`src/ui/gen2/`):* `ui.start_menu.items`, `ui.title_menu.items`, - `ui.options.rows`, `ui.party.submenu`, `ui.naming.grid`, `ui.pc.items`, - `ui.list_menu`, `transition.style`. `ui.list_menu` covers Gold's script + `ui.options.rows`, `ui.party.submenu`, `ui.party.grid_navigation`, + `ui.naming.grid`, `ui.pc.items`, `ui.list_menu`, `transition.style`. + `ui.list_menu` covers Gold's script menus (`ScriptMenu.lua`); the `Chrome.List` widget the START and title menus draw with does not raise it yet, so those two are composed through their own hooks only. diff --git a/main.lua b/main.lua index 1a9cd3aa..87d81589 100644 --- a/main.lua +++ b/main.lua @@ -291,6 +291,78 @@ function closeSkinStudio() end end +local function makeLauncher() + local RomImporter = require("src.import.RomImporter") + local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1" + return RomImporter.new(function(version) + Importer = nil + bootGame(version) + end, { + launcher = true, + forceImport = forceImport, + onEditSave = openEditor, + onEditTouchControls = openTouchControlsEditor, + onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop() + and openSkinStudio or nil, + }) +end + +local function returnToLauncher() + if not Game then return end + + pcall(function() require("src.core.Music").stop() end) + pcall(function() require("src.core.Sound").stop() end) + if package.loaded["src.core.ChipAudio"] then + pcall(package.loaded["src.core.ChipAudio"].shutdown) + end + if package.loaded["src.core.DiscordPresence"] then + pcall(package.loaded["src.core.DiscordPresence"].shutdown) + end + if package.loaded["src.core.gen2.Clock"] then + pcall(package.loaded["src.core.gen2.Clock"].shutdown) + end + if package.loaded["src.net.Gen1Tls"] then + pcall(package.loaded["src.net.Gen1Tls"].shutdown) + end + if love.audio and love.audio.stop then + pcall(love.audio.stop) + end + + local GameVersion = require("src.core.GameVersion") + local currentVersion = GameVersion.get() + if currentVersion then + require("src.import.CacheFs").unmountVersion(currentVersion) + end + require("src.core.Data"):unloadGenerated() + + local Runtime = require("src.mods.Runtime") + if Runtime.reset then + Runtime.reset() + end + + Game = nil + autopilot = nil + driverCo = nil + + local Input = require("src.core.Input") + local TouchControls = require("src.core.TouchControls") + Input:reset() + TouchControls:reset() + + require("src.core.Orientation").applyOptions( + require("src.core.SaveData").loadOptions()) + + local preload = require("src.mods.LauncherMods").translationStrings() + if preload then require("src.core.Strings").load({ strings = preload }) end + + if love.window and love.window.setTitle then + local Version = require("src.core.Version") + love.window.setTitle(Version.title("Gen 1 Recompilation Project")) + end + + Importer = makeLauncher() +end + function bootGame(version) -- The launcher hands us the chosen game (Red / Blue / Yellow / Gold); -- scripted and headless runs fall back to POKEPORT_VERSION, then Red. @@ -382,7 +454,7 @@ function love.load(args) -- Apply the persisted Android orientation lock (#592) before the launcher -- shows: SDL created the window with no orientation hint, so without this - -- the launcher would rotate freely until Game:applyOptions runs at boot. + -- the launcher would rotate freely until options are applied at boot. -- No-op on desktop / iOS / when options.lua does not exist yet. require("src.core.Orientation").applyOptions( require("src.core.SaveData").loadOptions()) @@ -442,8 +514,8 @@ function love.load(args) -- (#767) only pays off if something fills that catalog this early, and no -- restart could: the ordering is the same on every launch. Read the -- enabled mods' string catalogs -- data only, no entry chunk -- so a - -- translation reaches the launcher too. Game:load replaces this with the - -- real merged catalog once a version boots. + -- translation reaches the launcher too. The active game's loader replaces + -- this with the real merged catalog once a version boots. do local preload = require("src.mods.LauncherMods").translationStrings() if preload then require("src.core.Strings").load({ strings = preload }) end @@ -484,17 +556,7 @@ function love.load(args) -- by its SHA-1 (GameVersion.forSha1); pressing Play boots that game (Gold -- goes to its own service owner, src/core/Game2.lua -- docs/gold-phase1.md). -- Edit on a save row opens the bundled editor on that slot (openEditor). - Importer = RomImporter.new(function(version) - Importer = nil - bootGame(version) - end, { - launcher = true, - forceImport = forceImport, - onEditSave = openEditor, - onEditTouchControls = openTouchControlsEditor, - onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop() - and openSkinStudio or nil, - }) + Importer = makeLauncher() end function love.update(dt) @@ -827,6 +889,27 @@ function love.handlers.audioreset() if Sound then pcall(Sound.onDeviceReset) end end +function love.handlers.intent_game(version) + if type(version) ~= "string" or version == "" then return end + version = version:lower():gsub("^%s+", ""):gsub("%s+$", "") + local GameVersion = require("src.core.GameVersion") + if GameVersion.VERSIONS and not GameVersion.VERSIONS[version] then return end + + local RomImporter = require("src.import.RomImporter") + if not RomImporter.isReady(version) then return end + + local currentVersion = GameVersion.get() + if Game and currentVersion == version then + return + end + + if Game then + returnToLauncher() + end + Importer = nil + bootGame(version) +end + function love.touchpressed(id, x, y, dx, dy, pressure) if editorMode then -- iOS synthesizes mousepressed for the primary touch; forwarding here @@ -1032,11 +1115,16 @@ function love.quit() -- docs/modding.md's core.quit_to_launcher entry) may veto returning to -- this Lua launcher via that hook. Vanilla behavior (used when no mod -- claims the hook) is exactly the condition below. + local isAndroid = (love.system and love.system.getOS and love.system.getOS() == "Android") local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function() return Game and not Importer and not quitToLauncher and not scripted - and not launchedIntoGame + and (isAndroid or not launchedIntoGame) end) if wouldReturnToLauncher then + if isAndroid then + returnToLauncher() + return true -- abort this quit; the restart lands back in the launcher + end quitToLauncher = true -- Tell the fresh boot to ignore any boot-straight-into-a-game option this -- once, so the restart really does land in the launcher (#887). A failed diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index a2de3161..33ad1ea6 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -29,7 +29,8 @@ diff --git a/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..35b25473 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_blue.png b/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_blue.png new file mode 100644 index 00000000..292907ed Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_blue.png differ diff --git a/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_gold.png b/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_gold.png new file mode 100644 index 00000000..a9fb6040 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_gold.png differ diff --git a/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_red.png b/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_red.png new file mode 100644 index 00000000..c2b97381 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_red.png differ diff --git a/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_yellow.png b/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_yellow.png new file mode 100644 index 00000000..424d1614 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_yellow.png differ diff --git a/mobile/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..ac37e148 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_blue.png b/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_blue.png new file mode 100644 index 00000000..253cac46 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_blue.png differ diff --git a/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_gold.png b/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_gold.png new file mode 100644 index 00000000..1df17543 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_gold.png differ diff --git a/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_red.png b/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_red.png new file mode 100644 index 00000000..591974c8 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_red.png differ diff --git a/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_yellow.png b/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_yellow.png new file mode 100644 index 00000000..d61705ab Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_yellow.png differ diff --git a/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..fc0f5e49 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_blue.png b/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_blue.png new file mode 100644 index 00000000..c77b0413 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_blue.png differ diff --git a/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_gold.png b/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_gold.png new file mode 100644 index 00000000..b1927bba Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_gold.png differ diff --git a/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_red.png b/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_red.png new file mode 100644 index 00000000..4e30e872 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_red.png differ diff --git a/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_yellow.png b/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_yellow.png new file mode 100644 index 00000000..35aadb5c Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_yellow.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..53f8022e Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_blue.png b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_blue.png new file mode 100644 index 00000000..595c1747 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_blue.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_gold.png b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_gold.png new file mode 100644 index 00000000..93596378 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_gold.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_red.png b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_red.png new file mode 100644 index 00000000..4efbcd33 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_red.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_yellow.png b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_yellow.png new file mode 100644 index 00000000..7afb90e5 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_yellow.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..f645ea96 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_blue.png b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_blue.png new file mode 100644 index 00000000..a594a435 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_blue.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_gold.png b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_gold.png new file mode 100644 index 00000000..a1d10860 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_gold.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_red.png b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_red.png new file mode 100644 index 00000000..44d26881 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_red.png differ diff --git a/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_yellow.png b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_yellow.png new file mode 100644 index 00000000..825a5760 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_yellow.png differ diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..a8a8fa55 --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..a8a8fa55 --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/android/app/src/main/res/values/colors.xml b/mobile/android/app/src/main/res/values/colors.xml index 3ab3e9cb..caf722e0 100644 --- a/mobile/android/app/src/main/res/values/colors.xml +++ b/mobile/android/app/src/main/res/values/colors.xml @@ -3,4 +3,9 @@ #3F51B5 #303F9F #FF4081 + #FFFFFF + #E53935 + #1E88E5 + #FDD835 + #D4AF37 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 31940e30..6aa94eef 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -48,6 +48,7 @@ #include "common/Module.h" #include "audio/Audio.h" #include "audio/openal/Audio.h" +#include "event/Event.h" namespace love { @@ -282,6 +283,70 @@ bool restartApp() return result; } +bool updateAppShortcuts(const std::vector &versions) +{ + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + if (activity == nullptr) + return false; + + jmethodID method = env->GetStaticMethodID(activity, "updateAppShortcuts", "([Ljava/lang/String;)Z"); + if (method == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return false; + } + + jclass stringClass = env->FindClass("java/lang/String"); + jobjectArray array = env->NewObjectArray((jsize) versions.size(), stringClass, nullptr); + for (size_t i = 0; i < versions.size(); ++i) + { + jstring jstr = env->NewStringUTF(versions[i].c_str()); + env->SetObjectArrayElement(array, (jsize) i, jstr); + env->DeleteLocalRef(jstr); + } + + jboolean result = env->CallStaticBooleanMethod(activity, method, array); + + env->DeleteLocalRef(array); + env->DeleteLocalRef(stringClass); + env->DeleteLocalRef(activity); + return result; +} + +std::string getLaunchGame() +{ + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + if (activity == nullptr) + return ""; + + jmethodID method = env->GetStaticMethodID(activity, "getLaunchGame", "()Ljava/lang/String;"); + if (method == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return ""; + } + + jstring jgame = (jstring) env->CallStaticObjectMethod(activity, method); + if (jgame == nullptr) + { + env->DeleteLocalRef(activity); + return ""; + } + + const char *str = env->GetStringUTFChars(jgame, nullptr); + std::string result = (str != nullptr) ? str : ""; + if (str != nullptr) + env->ReleaseStringUTFChars(jgame, str); + + env->DeleteLocalRef(jgame); + env->DeleteLocalRef(activity); + return result; +} + bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept) { if (url == nullptr || destPath == nullptr) @@ -1488,4 +1553,32 @@ Java_org_love2d_android_GameActivity_nativeAudioDeviceChanged(JNIEnv *env, jclas love::audio::openal::pushAudioResetEvent(); } +static void pushGameIntentEvent(const char *game) +{ + auto eventmodule = love::Module::getInstance(love::Module::M_EVENT); + if (eventmodule == nullptr || game == nullptr) + return; + + std::vector args; + args.push_back(love::Variant(std::string(game))); + + love::event::Message *msg = new love::event::Message("intent_game", args); + eventmodule->push(msg); + msg->release(); +} + +extern "C" JNIEXPORT void JNICALL +Java_org_love2d_android_GameActivity_nativeOnGameIntent(JNIEnv *env, jclass cls, jstring game) +{ + (void) cls; + if (game == nullptr) + return; + const char *str = env->GetStringUTFChars(game, nullptr); + if (str != nullptr) + { + pushGameIntentEvent(str); + env->ReleaseStringUTFChars(game, str); + } +} + #endif // LOVE_ANDROID 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 c323d21a..0b56890c 100644 --- a/mobile/android/love/src/jni/love/src/common/android.h +++ b/mobile/android/love/src/jni/love/src/common/android.h @@ -90,6 +90,16 @@ bool syncHealthSteps(); **/ bool restartApp(); +/** + * Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions. + **/ +bool updateAppShortcuts(const std::vector &versions); + +/** + * Returns the game version requested via initial launch Intent (if any). + **/ +std::string getLaunchGame(); + /** * Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has * no curl binary, so this is the transport src/core/HostShell.lua uses there 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 7f7e2d30..329cff19 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp @@ -245,6 +245,25 @@ bool System::restartApp() const #endif } +bool System::updateShortcuts(const std::vector &versions) const +{ +#ifdef LOVE_ANDROID + return love::android::updateAppShortcuts(versions); +#else + LOVE_UNUSED(versions); + return false; +#endif +} + +std::string System::getLaunchGame() const +{ +#ifdef LOVE_ANDROID + return love::android::getLaunchGame(); +#else + return ""; +#endif +} + bool System::httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept) const { 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 b5d0ff3f..17fd2e39 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.h +++ b/mobile/android/love/src/jni/love/src/modules/system/System.h @@ -143,6 +143,9 @@ public: **/ virtual bool restartApp() const; + virtual bool updateShortcuts(const std::vector &versions) const; + virtual std::string getLaunchGame() const; + /** * Blocking HTTPS GET into an absolute host path (Android only; false * elsewhere). Android has no curl, which is what every other platform 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 d75f9c85..2405c607 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 @@ -283,6 +283,34 @@ int w_tlsClose(lua_State *L) return 0; } +int w_updateShortcuts(lua_State *L) +{ + if (!lua_istable(L, 1)) + return luaL_error(L, "Expected table of game version strings"); + + std::vector versions; + int len = (int) luax_objlen(L, 1); + for (int i = 1; i <= len; ++i) + { + lua_rawgeti(L, 1, i); + if (lua_isstring(L, -1)) + versions.push_back(lua_tostring(L, -1)); + lua_pop(L, 1); + } + luax_pushboolean(L, instance()->updateShortcuts(versions)); + return 1; +} + +int w_getLaunchGame(lua_State *L) +{ + std::string game = instance()->getLaunchGame(); + if (game.empty()) + lua_pushnil(L); + else + luax_pushstring(L, game); + return 1; +} + static const luaL_Reg functions[] = { { "getOS", w_getOS }, @@ -297,6 +325,8 @@ static const luaL_Reg functions[] = { "createFile", w_createFile }, { "syncHealthSteps", w_syncHealthSteps }, { "restartApp", w_restartApp }, + { "updateShortcuts", w_updateShortcuts }, + { "getLaunchGame", w_getLaunchGame }, { "httpDownload", w_httpDownload }, { "httpPost", w_httpPost }, { "httpRequest", w_httpRequest }, 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 b60b5d5a..c484e900 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 @@ -69,8 +69,11 @@ import android.os.Vibrator; import android.provider.Settings; import android.util.Log; import android.util.DisplayMetrics; -import android.view.*; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; import android.content.pm.PackageManager; +import android.graphics.drawable.Icon; +import android.view.*; import androidx.annotation.Keep; import androidx.core.app.ActivityCompat; @@ -159,6 +162,10 @@ public class GameActivity extends SDLActivity { private static native void nativeAudioDeviceChanged(); + private static native void nativeOnGameIntent(String game); + + private static String initialGame = ""; + private AudioManager.OnAudioFocusChangeListener audioFocusListener = null; private Object audioFocusRequest = null; private Object audioDeviceCallback = null; @@ -228,6 +235,10 @@ public class GameActivity extends SDLActivity { embed = getResources().getBoolean(R.bool.embed); needToCopyGameInArchive = embed; + Intent startIntent = getIntent(); + if (startIntent != null && startIntent.hasExtra("game")) { + initialGame = startIntent.getStringExtra("game"); + } if (!embed) { Intent intent = getIntent(); handleIntent(intent); @@ -261,6 +272,12 @@ public class GameActivity extends SDLActivity { @Override protected void onNewIntent(Intent intent) { Log.d("GameActivity", "onNewIntent() with " + intent); + if (intent != null && intent.hasExtra("game")) { + String game = intent.getStringExtra("game"); + if (game != null && !game.isEmpty()) { + nativeOnGameIntent(game); + } + } if (!embed) { handleIntent(intent); resetNative(); @@ -673,6 +690,95 @@ public class GameActivity extends SDLActivity { return true; // unreachable, but keeps the JNI signature honest } + @Keep + public static String getLaunchGame() { + return initialGame != null ? initialGame : ""; + } + + @Keep + public static boolean updateAppShortcuts(String[] readyVersions) { + GameActivity self = (GameActivity) mSingleton; + if (self == null) return false; + if (android.os.Build.VERSION.SDK_INT < 25) return false; + try { + Context context = self.getApplicationContext(); + ShortcutManager shortcutManager = context.getSystemService(ShortcutManager.class); + if (shortcutManager == null) return false; + + if (readyVersions == null || readyVersions.length == 0) { + shortcutManager.removeAllDynamicShortcuts(); + return true; + } + + List shortcuts = new ArrayList<>(); + int maxShortcuts = Math.min(readyVersions.length, 4); + + for (int i = 0; i < maxShortcuts; i++) { + String ver = readyVersions[i]; + if (ver == null || ver.isEmpty()) continue; + String lower = ver.toLowerCase(); + String shortLabel; + String longLabel; + int iconResId; + + switch (lower) { + case "red": + shortLabel = "Play Red"; + longLabel = "Play Red"; + iconResId = context.getResources().getIdentifier("ic_shortcut_red", "drawable", context.getPackageName()); + break; + case "blue": + shortLabel = "Play Blue"; + longLabel = "Play Blue"; + iconResId = context.getResources().getIdentifier("ic_shortcut_blue", "drawable", context.getPackageName()); + break; + case "yellow": + shortLabel = "Play Yellow"; + longLabel = "Play Yellow"; + iconResId = context.getResources().getIdentifier("ic_shortcut_yellow", "drawable", context.getPackageName()); + break; + case "gold": + shortLabel = "Play Gold"; + longLabel = "Play Gold"; + iconResId = context.getResources().getIdentifier("ic_shortcut_gold", "drawable", context.getPackageName()); + break; + default: + String capitalized = lower.substring(0, 1).toUpperCase() + lower.substring(1); + shortLabel = "Play " + capitalized; + longLabel = "Play " + capitalized; + iconResId = context.getResources().getIdentifier("ic_shortcut_" + lower, "drawable", context.getPackageName()); + break; + } + + if (iconResId == 0) { + iconResId = context.getResources().getIdentifier("ic_launcher_foreground", "drawable", context.getPackageName()); + } + + Intent intent = new Intent(context, GameActivity.class); + intent.setAction(Intent.ACTION_VIEW); + intent.putExtra("game", lower); + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + + ShortcutInfo.Builder builder = new ShortcutInfo.Builder(context, "shortcut_" + lower) + .setShortLabel(shortLabel) + .setLongLabel(longLabel) + .setIntent(intent); + + if (iconResId != 0) { + builder.setIcon(Icon.createWithResource(context, iconResId)); + } + + shortcuts.add(builder.build()); + } + + shortcutManager.setDynamicShortcuts(shortcuts); + return true; + } catch (Exception e) { + Log.d("GameActivity", "could not update shortcuts: " + e.getMessage()); + return false; + } + } + /** * Blocking HTTPS GET into destPath, exposed as love.system.httpDownload * and used by src/core/HostShell.lua. Android ships no curl binary, so diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index ec26c48b..b976ffb4 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2527,11 +2527,7 @@ end -- the HUD label drawn in place of the level for a statused mon function BattleState:statusLabel(mon) - local record = Status.recordFor(self.data.statuses, mon.status) - if record then - return record.hudLabel or record.label or mon.status - end - return mon.status + return Status.hudLabelFor(self.data.statuses, mon.status) end -- the one accuracy roll (MoveHitTest), hooked as battle.accuracy diff --git a/src/battle/Status.lua b/src/battle/Status.lua index 9a217b65..86e5565c 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -54,6 +54,13 @@ end -- freeze the English. They are already translatable through the -- statuses registry (mod.content.statuses:patch(id, { label = ... })). -- +-- Do not add a matching hudLabel = "..." below: Status.hudLabelFor reads +-- hudLabel before label, and Registry:patch only overrides the fields a +-- mod actually passes, so a label-only translation patch would be +-- shadowed by this hudLabel forever. Nothing in this codebase gives +-- hudLabel a value different from label -- setting it here only recreates +-- that trap for no observed benefit. +-- -- The five persistent conditions as records: the beforeMove gauntlet, the -- residual sweep, the inflict text/immunities (StatusRegistry.inflict), -- the catch/wobble bonuses (Catching.attempt), the HUD label, and the @@ -61,7 +68,7 @@ end -- read these fields, so a mod's sixth status plugs into every consumer. Status.RECORDS = { SLP = { - id = "SLP", label = "SLP", hudLabel = "SLP", + id = "SLP", label = "SLP", catchBonus = 25, shakeBonus = 10, beforeMovePriority = 40, beforeMove = function(battler, _, battle) @@ -82,7 +89,7 @@ Status.RECORDS = { end, }, FRZ = { - id = "FRZ", label = "FRZ", hudLabel = "FRZ", + id = "FRZ", label = "FRZ", catchBonus = 25, shakeBonus = 10, beforeMovePriority = 30, beforeMove = function(battler, _, battle) @@ -96,7 +103,7 @@ Status.RECORDS = { end, }, PSN = { - id = "PSN", label = "PSN", hudLabel = "PSN", + id = "PSN", label = "PSN", catchBonus = 12, shakeBonus = 5, residual = damageOverTime("_HurtByPoisonText", Strings.source("%s's\nhurt by poison!")), @@ -112,7 +119,7 @@ Status.RECORDS = { end, }, BRN = { - id = "BRN", label = "BRN", hudLabel = "BRN", + id = "BRN", label = "BRN", catchBonus = 12, shakeBonus = 5, statPenalty = { stat = "attack", div = 2 }, residual = damageOverTime("_HurtByBurnText", @@ -124,7 +131,7 @@ Status.RECORDS = { end, }, PAR = { - id = "PAR", label = "PAR", hudLabel = "PAR", + id = "PAR", label = "PAR", catchBonus = 12, shakeBonus = 5, statPenalty = { stat = "speed", div = 4 }, beforeMovePriority = 10, @@ -160,6 +167,14 @@ function Status.recordFor(statuses, id) return (statuses or Status.RECORDS)[id] end +-- the HUD label for a status id: a mod's patched hudLabel/label if the +-- merged registry has one, the raw id otherwise (BattleState.statusLabel, +-- SummaryMenu.draw and PartyMenu.draw all read this the same way) +function Status.hudLabelFor(statuses, id) + local record = Status.recordFor(statuses, id) + return record and (record.hudLabel or record.label) or id +end + local function battleStatuses(battle) return battle and battle.data and battle.data.statuses end diff --git a/src/core/Game.lua b/src/core/Game.lua index 31f63ebd..136aab99 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -176,6 +176,7 @@ function Game:makeTitleState() self:restoreSave(loaded, recovered, { freshBoot = true }) end end, + onExit = self.onExit, }) title.screenId = title.screenId or "TitleState" return title diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 3de97efc..f95fca7c 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -320,6 +320,7 @@ function Game2:showMainMenu() onNewGame = function() self:newGame() end, onContinue = function(save) self:continueGame(save) end, onOption = function() self:showOptions(function() self:showMainMenu() end) end, + onExit = self.onExit, }) end diff --git a/src/core/IssueReport.lua b/src/core/IssueReport.lua new file mode 100644 index 00000000..910c1f74 --- /dev/null +++ b/src/core/IssueReport.lua @@ -0,0 +1,219 @@ +local SaveData = require("src.core.SaveData") +local Version = require("src.core.Version") + +local IssueReport = {} + +local FORM_URL = "https://github.com/bryanthaboi/gen1recomp/issues/new" +local TEMPLATE = "bug_report.yml" + +local function clean(value) + if value == nil then return nil end + local text = tostring(value):gsub("^%s+", ""):gsub("%s+$", "") + if text == "" or text == "unknown" or text == "Unknown" then return nil end + return text +end + +local function call(fn, ...) + if type(fn) ~= "function" then return nil end + local ok, a, b, c, d, e = pcall(fn, ...) + if not ok then return nil end + return a, b, c, d, e +end + +local function invoke(fn, ...) + if type(fn) ~= "function" then return false end + local ok, result = pcall(fn, ...) + return ok, result +end + +local function commandValue(command) + if not io or type(io.popen) ~= "function" then return nil end + local ok, pipe = pcall(io.popen, command, "r") + if not ok or not pipe then return nil end + local readOK, value = pcall(pipe.read, pipe, "*l") + pcall(pipe.close, pipe) + if not readOK then return nil end + return clean(value) +end + +local function percentEncode(value) + local text = tostring(value or "") + return (text:gsub("([^%w%-_%.~])", function(char) + return ("%%%02X"):format(char:byte()) + end)) +end + +local function formOS(raw) + local values = { + ["OS X"] = "macOS", + macOS = "macOS", + Windows = "Windows", + Linux = "Linux", + Android = "Android", + iOS = "iOS", + NX = "Nintendo Switch", + UWP = "Xbox", + Xbox = "Xbox", + } + return values[raw] or "" +end + +local function loveVersion() + local major, minor, revision, codename = call(love and love.getVersion) + if not major then return "" end + local result = tostring(major) .. "." .. tostring(minor) .. "." .. tostring(revision) + if codename and codename ~= "" then result = result .. " (" .. tostring(codename) .. ")" end + return result +end + +local function appVersion() + local version = clean(Version.engine) + if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end + return version +end + +local function deviceModel(rawOS, system) + local model = clean(call(system.getModel)) + if model then return model end + if rawOS == "OS X" or rawOS == "macOS" then + return commandValue("sysctl -n hw.model 2>/dev/null") + end + if rawOS == "Windows" then + return commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL") + end + if rawOS == "Linux" then + return commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null") + or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null") + end + if rawOS == "Android" then + return commandValue("getprop ro.product.model 2>/dev/null") + end + return nil +end + +local function modRows(context) + if context and type(context.mods) == "table" then return context.mods end + local ok, LauncherMods = pcall(require, "src.mods.LauncherMods") + if ok and LauncherMods and LauncherMods.list then + local listed = call(LauncherMods.list) + if type(listed) == "table" then return listed end + end + return {} +end + +local function modNames(rows, safeMode) + local enabled = {} + for _, mod in ipairs(rows or {}) do + if type(mod) == "table" then + local name = clean(mod.name or mod.id) + if name and not safeMode and mod.enabled == true then + enabled[#enabled + 1] = name + end + end + end + table.sort(enabled) + return enabled +end + +local function metadata(options, context) + local system = love and love.system or {} + local graphics = love and love.graphics or {} + local window = love and love.window or {} + local rawOS = clean(call(system.getOS)) + local model = deviceModel(rawOS, system) + local renderer, rendererVersion, _, rendererDevice = call(graphics.getRendererInfo) + local width, height = call(graphics.getDimensions) + local pixelWidth, pixelHeight = call(graphics.getPixelDimensions) + local modeWidth, modeHeight, flags = call(window.getMode) + local safeMode = SaveData.isSafeMode(options) + local rows = modRows(context or {}) + local enabledMods = modNames(rows, safeMode) + local lines = { "Diagnostics:" } + local function add(label, value) + value = clean(value) + if value then lines[#lines + 1] = "- " .. label .. ": " .. value end + end + add("Platform", formOS(rawOS)) + local hardware = model + if rendererDevice and rendererDevice ~= model then + hardware = hardware and (hardware .. " (" .. rendererDevice .. ")") or rendererDevice + end + add("Device", hardware) + local rendererDetails = clean(renderer) + if rendererDetails and clean(rendererVersion) then + rendererDetails = rendererDetails .. " " .. clean(rendererVersion) + end + add("Renderer", rendererDetails) + local displayWidth, displayHeight = width or modeWidth or pixelWidth, height or modeHeight or pixelHeight + if displayWidth and displayHeight then + add("Display", tostring(displayWidth) .. "x" .. tostring(displayHeight)) + end + if pixelWidth and pixelHeight + and (pixelWidth ~= displayWidth or pixelHeight ~= displayHeight) then + add("Pixel display", tostring(pixelWidth) .. "x" .. tostring(pixelHeight)) + end + if flags and flags.fullscreen == true then add("Fullscreen", "yes") end + local version = appVersion() + add("App", version ~= "" and Version.title() or "gen1recomp") + add("LÖVE", loveVersion()) + if safeMode then add("Safe mode", "on") end + return { + rawOS = rawOS, + os = formOS(rawOS), + device = model, + version = version, + safeMode = safeMode, + enabledMods = enabledMods, + metadata = table.concat(lines, "\n"), + } +end + +function IssueReport.build(options, context) + options = options or SaveData.loadOptions() + context = context or {} + local info = metadata(options, context) + local fields = { + summary = "", + mods_which = #info.enabledMods > 0 and table.concat(info.enabledMods, ", ") or "", + version = info.version or "", + location = "", + screenshot = "", + steps = "", + expected = "", + extra = info.metadata, + } + local params = { + "template=" .. percentEncode(TEMPLATE), + "title=" .. percentEncode("bug: replace this with a meaningful title"), + } + local order = { "summary", "mods_which", + "version", "location", "screenshot", "steps", "expected", "extra" } + for _, key in ipairs(order) do + params[#params + 1] = key .. "=" .. percentEncode(fields[key]) + end + return FORM_URL .. "?" .. table.concat(params, "&"), fields, info +end + +function IssueReport.open(options, context) + local url = IssueReport.build(options, context) + local system = love and love.system or {} + local opened, openResult = invoke(system.openURL, url) + if opened and openResult ~= false then + return true, url + end + local copied, copyResult = invoke(system.setClipboardText, url) + if copied and copyResult ~= false then + return true, url, "Issue URL copied to the clipboard." + end + local filesystem = love and love.filesystem or {} + local written, writeResult = invoke(filesystem.write, "issue-report-url.txt", url) + if written and writeResult ~= false then + return true, url, "Issue URL saved to issue-report-url.txt." + end + return false, url, "No browser, clipboard, or writable save directory is available for the issue report." +end + +IssueReport.percentEncode = percentEncode +IssueReport.metadata = metadata + +return IssueReport diff --git a/src/core/LaunchOptions.lua b/src/core/LaunchOptions.lua index cac66de6..0bc98a3c 100644 --- a/src/core/LaunchOptions.lua +++ b/src/core/LaunchOptions.lua @@ -67,10 +67,23 @@ local function argFlag(argv, name) return false end +local cachedIntentGame = nil + -- Returns version, slotId (either may be nil). Command line wins over env, -- so a shortcut can override a machine-wide default. function LaunchOptions.resolve(argv) + if cachedIntentGame == nil then + if love.system and love.system.getOS and love.system.getOS() == "Android" + and love.system.getLaunchGame then + cachedIntentGame = normalizeVersion(love.system.getLaunchGame()) or false + else + cachedIntentGame = false + end + end + local intentGame = cachedIntentGame or nil + local game = normalizeVersion(argValue(argv, "game")) + or intentGame or normalizeVersion(os.getenv("POKEPORT_GAME")) or normalizeVersion(os.getenv("POKEPORT_LAUNCH")) local slot = argValue(argv, "slot") or os.getenv("POKEPORT_SLOT") diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 2b03df44..e3d977f5 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -300,6 +300,7 @@ function SaveData.defaultOptions() -- Native mod enablement is an installation option, not save-slot data. -- Missing entries mean enabled so newly installed mods work by default. mods = {}, + safeMode = false, -- Mods the player forced past the target gate (Loader:_gateGeneration). -- modsGen2[id][version] = true, one answer per game; a bare `true` is the -- pre-per-game shape and means the Gen 2 games only (see modForced). @@ -386,6 +387,16 @@ function SaveData.mergeOptions(loaded) return opts end +function SaveData.isSafeMode(options) + return type(options) == "table" and options.safeMode == true +end + +function SaveData.setSafeMode(options, enabled) + if type(options) ~= "table" then return false end + options.safeMode = enabled == true + return options.safeMode +end + function SaveData.encode(data) return SaveSerializer.encode(data) end diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 43a820b4..a73d7eed 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -422,7 +422,7 @@ local function discoverModSchemas(opts) -- except experimental mods, which stay off until opted in. local flag = require("src.core.SaveData").modEnabled(opts, m.id) local enabled = flag == true or (flag == nil and not m.experimental) - if enabled then + if enabled and not SaveData.isSafeMode(opts) then local chunk = fs.load(path .. "/" .. m.options_schema) if chunk then local okR, schema = pcall(chunk) @@ -521,9 +521,49 @@ local function modRows(opts, mod) return true end } end + for _, row in ipairs(rows) do + row.safeModeBlocked = true + if row.step then + local step = row.step + row.step = function(dir) + if SaveData.isSafeMode(opts) then return false end + return step(dir) + end + end + if row.setText then + local setText = row.setText + row.setText = function(text) + if SaveData.isSafeMode(opts) then return false end + return setText(text) + end + end + end return rows end +local function troubleshootingRows(opts, hooks) + return { + { + label = Strings("SAFE MODE"), + actionLabel = function() + return SaveData.isSafeMode(opts) and Strings("Turn off") or Strings("Turn on") + end, + action = function() + SaveData.setSafeMode(opts, not SaveData.isSafeMode(opts)) + return true + end, + }, + { + label = Strings("REPORT ISSUE"), + actionLabel = Strings("Report bug"), + action = function() + if hooks and hooks.reportIssue then hooks.reportIssue(opts) end + return false + end, + }, + } +end + -- ------- Gen 2 (Gold) -- -- Gold reads NONE of the rows above. Its OPTION screen writes a different @@ -704,6 +744,10 @@ function LauncherSettings.open(hooks, version) sections[#sections + 1] = { title = mod.name, rows = rows } end end + sections[#sections + 1] = { + title = Strings("TROUBLESHOOTING"), + rows = troubleshootingRows(opts, hooks), + } return { opts = opts, version = version, diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index a2f56efe..32593ab8 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -682,6 +682,7 @@ end local function modStatusColor(status) if status == "ok" then return Strings("Ready"), PAL.green end + if status == "safe_mode" then return Strings("Safe mode"), PAL.yellow end if status == "needs_import" then return Strings("Import required"), PAL.yellow end if status == "conflict" then return Strings("Conflict"), PAL.red end -- not a fault: the mod is intact, this is simply not a game it is for @@ -1814,10 +1815,11 @@ end -- One compact coloured checkbox for each game. The cartridge colour carries -- the game identity even when the row is narrow. -local function modGameCheckbox(x, y, size, checked, game, id) - local color = cartColor(game) - local focused = Kit.focusable(id, x, y, size, size) - local hot = focused or Kit.hover(x, y, size, size) +local function modGameCheckbox(x, y, size, checked, game, id, enabled) + enabled = enabled ~= false + local color = enabled and cartColor(game) or PAL.steel + local focused = enabled and Kit.focusable(id, x, y, size, size) + local hot = enabled and (focused or Kit.hover(x, y, size, size)) if love.graphics then Theme.fillRounded(x, y, size, size, PAL.bg, 1) if checked then @@ -1829,12 +1831,13 @@ local function modGameCheckbox(x, y, size, checked, game, id) hot and Theme.A.focus or Theme.A.hairline, 1) end end - return Kit.press(x, y, size, size) or Kit._activateId == id + return enabled and (Kit.press(x, y, size, size) or Kit._activateId == id) end local function buildModsPanel(imp, x, y, w, availH, m) imp:_ensureMods() local ModUpdate = require("src.mods.ModUpdate") + local safeMode = imp.safeMode == true local mods = imp.mods or {} local gap = m.gap local cy = y @@ -1866,9 +1869,11 @@ local function buildModsPanel(imp, x, y, w, availH, m) action = function() imp:chooseMod() end }) btn(imp, place(disableW), cy, disableW, bh, "mods-disable-all", Strings("Disable all"), { kind = "warn", font = "small", + enabled = not safeMode, action = function() imp:_setAllMods(false) end }) btn(imp, place(enableW), cy, enableW, bh, "mods-enable-all", Strings("Enable all"), { kind = "good", font = "small", + enabled = not safeMode, action = function() imp:_setAllMods(true) end }) btn(imp, place(checkFullW), cy, checkFullW, bh, "mods-check-updates", Strings("Check for updates"), { font = "small", @@ -1917,7 +1922,9 @@ local function buildModsPanel(imp, x, y, w, availH, m) -- notice line local noticeText, noticeCol - if imp.modNotice then + if safeMode then + noticeText, noticeCol = "Safe mode is on. All mods are disabled. Turn it off in Settings to change mod toggles.", PAL.yellow + elseif imp.modNotice then noticeText = imp.modNotice.text noticeCol = imp.modNotice.ok and PAL.green or PAL.red else @@ -2043,7 +2050,7 @@ local function buildModsPanel(imp, x, y, w, availH, m) local togKey = "mod-toggle-" .. mod.id .. "-" .. game if modGameCheckbox(tx, gamesY, togH, mod.enabledByVersion and mod.enabledByVersion[game] == true, - game, togKey) then + game, togKey, not safeMode) then local version = game queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end) flipped = true @@ -3079,6 +3086,7 @@ local function buildProfilesModal(imp, m) btn(imp, place(swBtnW), ry + math.floor(4 * m.s), swBtnW, rowH - math.floor(8 * m.s), "prof-sw-" .. i, Strings("Switch"), { kind = "good", font = "micro", + enabled = not imp.safeMode, action = function() LauncherMods.applyProfile(p.name, options) if imp._refreshMods then imp:_refreshMods() end @@ -3105,8 +3113,10 @@ local function buildModHeaderActionsModal(imp, m) local btns = { { label = Strings("Mod profiles..."), action = function() imp._profilesPopup = true end }, { label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end }, - { label = Strings("Enable all mods"), kind = "good", action = function() imp:_setAllMods(true) end }, - { label = Strings("Disable all mods"), kind = "warn", action = function() imp:_setAllMods(false) end }, + { label = Strings("Enable all mods"), kind = "good", enabled = not imp.safeMode, + action = function() imp:_setAllMods(true) end }, + { label = Strings("Disable all mods"), kind = "warn", enabled = not imp.safeMode, + action = function() imp:_setAllMods(false) end }, { label = Strings("Sort mods..."), action = function() imp._sortPopup = "mods" end }, } local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) @@ -3119,6 +3129,7 @@ local function buildModHeaderActionsModal(imp, m) for i, b in ipairs(btns) do btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modheadact-" .. i, b.label, { kind = b.kind or "ghost", font = "small", + enabled = b.enabled, action = function() imp._modHeaderActionsPopup = nil b.action() @@ -3737,6 +3748,7 @@ end local function buildSettingsModal(imp, m) local model = imp._settings + local SaveData = require("src.core.SaveData") local pad = math.floor(18 * m.s) local w = math.floor(640 * m.s) local h = math.floor(math.min(m.H - 2 * m.pad, m.H * 0.9)) @@ -3826,6 +3838,8 @@ local function buildSettingsModal(imp, m) item.header) else local row = item.row + local rowEnabled = not row.safeModeBlocked + or not SaveData.isSafeMode(model.opts) local key = "set-" .. i Kit.card(px + pad, ry, pw - 2 * pad, rowH, "hairline") local ix = px + pad + math.floor(12 * m.s) @@ -3855,6 +3869,7 @@ local function buildSettingsModal(imp, m) ctlY + (m.btnH - Kit.textHeight("small")) / 2, PAL.detail) btn(imp, rx - ew, ctlY, ew, m.btnH, key .. "-edit", Strings("Edit"), { kind = "accent", font = "small", + enabled = rowEnabled, action = function() imp._settingsText = { row = row, text = tostring(row.value() or ""), maxLen = row.editText.maxLen } @@ -3863,12 +3878,14 @@ local function buildSettingsModal(imp, m) elseif row.action then -- A plain action row (Reset rebinds, Touch controls): the whole right -- side is one button rather than a value ladder. - local aw = Kit.textWidth("small", row.actionLabel or Strings("Run")) + local actionLabel = type(row.actionLabel) == "function" + and row.actionLabel() or row.actionLabel or Strings("Run") + local aw = Kit.textWidth("small", actionLabel) + math.floor(24 * m.s) Kit.text("small", Kit.ellipsize("small", row.label, labelW or (inner - aw - math.floor(12 * m.s))), ix, labelY, PAL.text) btn(imp, rx - aw, ctlY, aw, m.btnH, - key .. "-act", row.actionLabel or Strings("Run"), { + key .. "-act", actionLabel, { kind = row.danger and "danger" or "ghost", font = "small", action = function() if row.action() ~= false then model.save() end @@ -3884,12 +3901,14 @@ local function buildSettingsModal(imp, m) or valW btn(imp, rx - stepW, ctlY, stepW, m.btnH, key .. "-next", ">", { font = "small", + enabled = rowEnabled, action = function() if row.step and row.step(1) then model.save() end end }) Kit.textCenter("small", Kit.ellipsize("small", tostring(row.value()), vw), rx - stepW - vw, ctlY + (m.btnH - Kit.textHeight("small")) / 2, vw, PAL.heading) btn(imp, rx - stepW - vw - stepW, ctlY, stepW, m.btnH, key .. "-prev", "<", { font = "small", + enabled = rowEnabled, action = function() if row.step and row.step(-1) then model.save() end end }) end end @@ -4046,6 +4065,7 @@ local function buildDepResolverModal(imp, m) local bw = Kit.textWidth("small", btnLabel) + math.floor(20 * m.s) btn(imp, place(bw), ly, bw, chipH, "dep-dis-" .. i, btnLabel, { kind = "warn", font = "small", + enabled = not imp.safeMode, action = function() local LauncherMods = require("src.mods.LauncherMods") LauncherMods.setEnabled(dep.id, false, imp.modScope) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 979b4cce..8b1e3bea 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -136,6 +136,9 @@ local VERSION_REQUIRED_FILES_OVERRIDE = { -- costs nothing on a current cache and is the difference between every -- trainer battle opening with a picture and opening with none. "assets/generated/battle/trainers/falkner.png", + -- BattleStart_TrainerHuds cannot draw its party rows from a cache made + -- before the four ball tiles were extracted (#1502). + "assets/generated/battle/hud/balls.png", "assets/generated/audio/programs.bin", }, } @@ -326,6 +329,32 @@ function RomImporter.isReady(version) return marker == markerFor(version) and allRequiredFilesExist(version) end +function RomImporter.syncAndroidShortcuts(activeVersion) + if not (love.system and love.system.getOS and love.system.getOS() == "Android" + and love.system.updateShortcuts) then + return false + end + + local allVersions = { "red", "blue", "yellow", "gold" } + local ready = {} + local seen = {} + + if activeVersion and RomImporter.isReady(activeVersion) then + table.insert(ready, activeVersion) + seen[activeVersion] = true + end + + for _, v in ipairs(allVersions) do + if not seen[v] and RomImporter.isReady(v) then + table.insert(ready, v) + seen[v] = true + if #ready >= 4 then break end + end + end + + return love.system.updateShortcuts(ready) +end + -- Load the import manifest for a version and confirm it matches that ROM. local function sha1(data) local digest = love.data.hash("sha1", data) @@ -1393,6 +1422,7 @@ function RomImporter.new(onComplete, opts) self.romName[version] = "pokemon_" .. info.id .. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb") end + RomImporter.syncAndroidShortcuts() self:_applyLastVersionTab() self:_queueBaseRomScan() @@ -1787,6 +1817,7 @@ function RomImporter:_completeImport(version, prefix, displayName) self.workState = "complete" self.completeVersion = version self.status = "Ready" + RomImporter.syncAndroidShortcuts(version) -- NX launcher stays put: keep the imports/ cleanup hint instead of -- overwriting it with a "Starting…" line that never boots from here. if self.launcher and self.isNX and type(displayName) == "string" then @@ -3598,6 +3629,10 @@ function RomImporter:_openSettings() -- The tab rides along: the editor persists the layout into that game's own -- option block, and Gold's is not the flat Gen 1 one (#1100). local hooks = {} + local version = self.tab + hooks.reportIssue = function(opts) + return self:_reportIssue(opts, version) + end if self.onEditTouchControls then local version = self.tab hooks.editTouchControls = function() @@ -3615,11 +3650,13 @@ function RomImporter:_openSettings() -- The tab the gear was opened on decides the row set: Gold reads a -- different option block entirely, and offering it Gen 1's rows meant a -- dozen controls that changed nothing (see LauncherSettings.gen2Rows). - local version = self.tab local ok, model = pcall(function() return require("src.import.LauncherSettings").open(hooks, version) end) - if ok and model then self._settings = model end + if ok and model then + self._settings = model + self._settingsSafeModeAtOpen = require("src.core.SaveData").isSafeMode(model.opts) + end end -- Quit from the launcher's own X. It goes through love.event.quit so main.lua's @@ -3630,8 +3667,38 @@ function RomImporter:_quitApp() end function RomImporter:_closeSettings() - if self._settings then self._settings.save() end + local model = self._settings + if model then + model.save() + local safeMode = require("src.core.SaveData").isSafeMode(model.opts) + if safeMode ~= self._settingsSafeModeAtOpen then + self.mods = nil + self.safeMode = safeMode + self._modSortCache = nil + self._modInfoFetch = nil + end + end self._settings = nil + self._settingsSafeModeAtOpen = nil +end + +function RomImporter:_reportIssue(options, version) + local ok, IssueReport = pcall(require, "src.core.IssueReport") + if not ok then + self.modNotice = { ok = false, text = "Could not prepare the issue report." } + return false + end + local opened, url, reason = IssueReport.open(options, { + version = version, + mods = self.mods, + }) + if not opened then + self.modNotice = { ok = false, text = reason or "Could not open the issue report." } + return false + end + self._lastIssueReportURL = url + if reason then self.modNotice = { ok = true, text = reason } end + return true end function RomImporter:_commitSettingsText() @@ -3971,6 +4038,8 @@ end -- so a still list costs nothing after the first paint. function RomImporter:_refreshMods() local LauncherMods = require("src.mods.LauncherMods") + local SaveData = require("src.core.SaveData") + self.safeMode = SaveData.isSafeMode(SaveData.loadOptions()) -- Once per session, ahead of the first listing: pull in any mod the player -- unzipped beside the executable, which an ordinary (non-portable) install -- has no way to read. It happens here rather than behind a button because @@ -4110,6 +4179,10 @@ end -- so that game's checkbox and status chips reflect the new resolution. -- Enabling an experimental mod arms a confirmation for that same game. function RomImporter:_toggleMod(id, confirmed, version) + if self.safeMode then + self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." } + return + end local LauncherMods = require("src.mods.LauncherMods") local cur, experimental = false, false for _, m in ipairs(self.mods or {}) do @@ -4150,6 +4223,10 @@ end -- must not be the way around it. Disabling needs no confirm -- it is the -- recovery action, and Delete is the only destructive one on this panel. function RomImporter:_setAllMods(want, confirmed) + if self.safeMode then + self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." } + return + end local LauncherMods = require("src.mods.LauncherMods") local ids, experimental = {}, false for _, m in ipairs(self.mods or {}) do diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index 10922dea..970e9517 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -291,6 +291,7 @@ function LauncherMods.deriveList(manifests, options, version) local ordered = {} for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end table.sort(ordered, function(a, b) return a.id < b.id end) + local safeMode = SaveData.isSafeMode(options) -- the override is one answer per game (SaveData.modForced), the same scope -- the loader resolves it under @@ -304,17 +305,24 @@ function LauncherMods.deriveList(manifests, options, version) -- matching the loader -- except experimental mods, which stay off until -- the player opts in. Scoped through modScope, so this reads exactly what -- setEnabled writes and the loader loads for the selected game. - local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version)) - if decided == nil then decided = not m.experimental end - if decided then enabledSet[m.id] = true end + if not safeMode then + local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version)) + if decided == nil then decided = not m.experimental end + if decided then enabledSet[m.id] = true end + end end local out = {} for _, m in ipairs(ordered) do - local enabled = enabledSet[m.id] == true + local enabled = not safeMode and enabledSet[m.id] == true local forced = forcedFor(m.id) - local status, detail = - statusFor(byId, m.id, enabledSet, enabled, version, forcedFor) + local status, detail + if safeMode then + status, detail = "safe_mode", "Disabled by safe mode" + else + status, detail = + statusFor(byId, m.id, enabledSet, enabled, version, forcedFor) + end -- nil, not false, when the panel is showing every game at once local here = nil if version then here = ModTargets.runsHere(m, version, nil, forced) end @@ -332,7 +340,8 @@ function LauncherMods.deriveList(manifests, options, version) local answers = {} for _, game in ipairs(GameVersion.ORDER) do local answer = SaveData.modEnabled(options, m.id, game) - answers[game] = answer == true or (answer == nil and not m.experimental) + answers[game] = not safeMode + and (answer == true or (answer == nil and not m.experimental)) end return answers end)(), @@ -346,6 +355,7 @@ function LauncherMods.deriveList(manifests, options, version) -- panel is showing (src/mods/ModTargets.lua) targets = ModTargets.chip(m), targetsHere = here, + safeMode = safeMode, } end return out @@ -586,6 +596,7 @@ end -- answer. The loader and the in-game manager use the same scope on next boot. function LauncherMods.setEnabled(id, enabled, version) local options = SaveData.loadOptions() + if SaveData.isSafeMode(options) then return false end SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version)) SaveData.saveOptions(options) LauncherMods.syncActiveProfile(options) @@ -599,6 +610,7 @@ end -- and leaves a half-applied state behind if one of them fails. function LauncherMods.setAllEnabled(ids, enabled, version) local options = SaveData.loadOptions() + if SaveData.isSafeMode(options) then return false end local scope = SaveData.modScope(version) for _, id in ipairs(ids or {}) do if scope then @@ -1184,6 +1196,7 @@ end function LauncherMods.applyProfile(profileName, options) options = options or SaveData.loadOptions() + if SaveData.isSafeMode(options) then return false end local profiles = options.modProfiles or {} local targetProfile for _, p in ipairs(profiles) do diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 5dbad269..8ec4767d 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -259,6 +259,7 @@ function Loader.new(opts) modInput = {}, modEnv = {}, stepsQueues = {}, fs = (opts and opts.fs) or (love and love.filesystem), dev = dev, + safeMode = false, -- Which generation this boot is (1 or 2). Fixed at construction: the -- active version is set once in main.lua's bootGame before anything -- builds a loader, and a run never changes generation underneath one. @@ -300,6 +301,8 @@ end function Loader:_loadState() self.disabled = {} local options = SaveData.loadOptions(self.fs) + self.safeMode = SaveData.isSafeMode(options) + Runtime.safeMode = self.safeMode local scope = self:_enableScope() local ids = {} for id in pairs(options.mods or {}) do ids[id] = true end @@ -415,6 +418,7 @@ function Loader:_writeOptionSchemas() end function Loader:setEnabled(id, enabled) + if self.safeMode then return false end if not self.mods[id] then return false end self.disabled[id] = not enabled self.mods[id].enabled = enabled @@ -427,6 +431,7 @@ end -- choice could not be persisted for a game, so the caller does not promise a -- restart will honour it. function Loader:setGen2Forced(id, forced) + if self.safeMode then return false, false end if not self.mods[id] then return false, false end self.gen2Forced[id] = forced or nil self:_saveState() @@ -1539,6 +1544,9 @@ function Loader:load(data) require("src.mods.Builtins").install(self.content, data, self.generation) self:_loadState() self:_discover() + if self.safeMode then + for id in pairs(self.mods) do self.disabled[id] = true end + end -- Existing installs stored one shared answer. Once their manifests are -- known, split that answer across every game before the next launcher/game -- toggle can change one independently. _loadState already used the same @@ -1574,7 +1582,7 @@ function Loader:load(data) -- the one build where its env var is set. for id, mod in pairs(self.mods) do local envName = mod.manifest.force_enable_env - if envName and os.getenv(envName) == "1" then + if not self.safeMode and envName and os.getenv(envName) == "1" then self.disabled[id] = nil end end @@ -1740,6 +1748,7 @@ function Loader:status() local manifest = {} for key, value in pairs(mod.manifest) do manifest[key] = value end manifest.enabled = mod.enabled ~= false + manifest.safeMode = self.safeMode == true manifest.state = mod.state or (manifest.enabled and "loaded" or "disabled") manifest.error = mod.failure -- set instead of `error` when the mod was left out for a reason that is diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua index 3066888e..5dd87ef2 100644 --- a/src/mods/ManagerState.lua +++ b/src/mods/ManagerState.lua @@ -365,9 +365,13 @@ end function ManagerState:detailRows(m) local rows = {} - rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE", - action = function() self:beginToggle(m) end } - if self:schemaFor(m) then + if Runtime.safeMode then + rows[#rows + 1] = { label = "SAFE MODE ACTIVE", inert = true } + else + rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE", + action = function() self:beginToggle(m) end } + end + if not Runtime.safeMode and self:schemaFor(m) then rows[#rows + 1] = { label = Strings("OPTIONS.."), action = function() self:openOptions(m) end } end @@ -383,7 +387,8 @@ function ManagerState:detailRows(m) -- what this mod does. local loader = self.game.mods local version, gen = self:targetGame() - if loader and loader.setGen2Forced and not ModTargets.supports(m, version, gen) then + if loader and loader.setGen2Forced and not Runtime.safeMode + and not ModTargets.supports(m, version, gen) then rows[#rows + 1] = { label = m.gen2Forced and Strings("DON'T TRY HERE") or Strings("TRY HERE ANYWAY"), action = function() self:toggleGen2Force(m) end } @@ -674,6 +679,10 @@ end -- ------- the enable/disable flow function ManagerState:beginToggle(m) + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return + end if not m then return end local want = not m.enabled local loader = self.game.mods @@ -711,6 +720,10 @@ end -- override is scoped to THIS game, and a boot that cannot name one keeps it in -- memory only, which the notice says rather than promising a restart. function ManagerState:toggleGen2Force(m) + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return + end local loader = self.game.mods if not (loader and loader.setGen2Forced) then return end local want = not m.gen2Forced @@ -743,6 +756,10 @@ function ManagerState:enableScope() end function ManagerState:commitToggle(apply) + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return + end local loader = self.game.mods local opts = self:optionsTable() local scope = self:enableScope() @@ -757,6 +774,10 @@ function ManagerState:commitToggle(apply) end function ManagerState:discardChanges() + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return + end local loader = self.game.mods local opts = self:optionsTable() local scope = self:enableScope() @@ -802,6 +823,10 @@ function ManagerState:persistOptions() end function ManagerState:applyProfile(p) + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return + end local mods = self:manifestMap() local set = self:enabledSet() local combined = {} @@ -963,6 +988,10 @@ function ManagerState:optionValue(modId, row) end function ManagerState:setOption(modId, key, value) + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return false + end local save = self.game.save if save and save.options then save.options.modOptions = save.options.modOptions or {} @@ -1123,6 +1152,10 @@ function ManagerState:buildOptionRows(m, schema) end function ManagerState:openOptions(m) + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return + end local schema = self:schemaFor(m) if not schema then self:notify("NO OPTIONS") diff --git a/src/mods/Runtime.lua b/src/mods/Runtime.lua index 0b2c94a8..a6bf7d3b 100644 --- a/src/mods/Runtime.lua +++ b/src/mods/Runtime.lua @@ -33,11 +33,21 @@ Runtime.currentMod = nil -- currentMod went back to nil (src/mods/Sandbox.lua) Runtime.modRequire = nil +Runtime.safeMode = false + function Runtime.install(events, hooks, errors) Runtime.events, Runtime.hooks = events, hooks Runtime.errors = errors end +function Runtime.reset() + Runtime.events = NullEvents + Runtime.hooks = NullHooks + Runtime.errors = nil + Runtime.currentMod = nil + Runtime.modRequire = nil +end + -- attribute a runtime failure to the mod that owns the offending record. -- "base" is the engine's own owner id: a vanilla record that fails is a -- console line, not something the manager can ask the player to disable. diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index 76b22781..5dc2b694 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -180,8 +180,8 @@ local function release(game) and mon.ot == game.save.player.name then require("src.core.Sound").playCry(game.data, mon.species) game.stack:push(TextBox.new(game, - (t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name)) - :gsub("{RAM:wNameBuffer}", name))) + ((t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name)) + :gsub("{RAM:wNameBuffer}", name)))) return end game.stack:push(TextBox.new(game, diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 833b5ffb..4e2b9535 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -18,6 +18,7 @@ local Theme = require("src.ui.Theme") local FieldDefaults = require("src.world.FieldDefaults") local Map = require("src.world.Map") local Strings = require("src.core.Strings") +local Status = require("src.battle.Status") local PartyMenu = {} PartyMenu.__index = PartyMenu @@ -821,7 +822,7 @@ function PartyMenu:draw() if mon.hp <= 0 then Font.draw(Strings("FNT"), 136, y) elseif mon.status then - Font.draw(mon.status, 136, y) + Font.draw(Status.hudLabelFor(self.game.data.statuses, mon.status), 136, y) end -- the tile HP bar (DrawHP2 + SetPartyMenuHPBarColor). grayFill: -- tinting the fill AND running it through the row's zone diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index 30d0de4f..789c3135 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -14,6 +14,7 @@ local Font = require("src.render.Font") local TypeChart = require("src.battle.TypeChart") local Strings = require("src.core.Strings") local Stats = require("src.pokemon.Stats") +local Status = require("src.battle.Status") local SummaryMenu = {} SummaryMenu.__index = SummaryMenu @@ -145,7 +146,7 @@ function SummaryMenu:draw() HudTiles.drawHPBar(data, 11, 3, mon, 1, barZoned) -- wHPBarType 1 Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32) Font.draw(Strings("STATUS/"), 72, 48) - Font.draw(mon.status or "OK", 128, 48) + Font.draw(Status.hudLabelFor(data.statuses, mon.status) or "OK", 128, 48) -- stats box (0,8) 10x10: names rows 9/11/13/15, values indented Font.drawBox(0, 8, 10, 10) diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 2159dc4a..4956c75d 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -198,6 +198,7 @@ function TitleState.new(game, opts) self.game = game self.onNewGame = opts.onNewGame self.onContinue = opts.onContinue + self.onExit = opts.onExit -- branding comes from field.title with the shipped art as fallback, so -- a total conversion rebrands the title without replacing the screen local title = (game.data.field and game.data.field.title) or {} @@ -514,7 +515,9 @@ function TitleState:openMenu() require("src.ui.Screens").push(game, "OptionsMenu") end }) table.insert(items, { label = Strings("EXIT GAME"), onSelect = function() - if love.event and love.event.quit then + if self.onExit then + self.onExit() + elseif love.event and love.event.quit then love.event.quit() end end }) diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 9254d2b9..c4a4de4e 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -2290,6 +2290,7 @@ function BattleState:openParty(forced) -- :2702; engine/pokemon/party_menu.asm:660-679). Only the voluntary list -- carries BattleMonMenu; PickPartyMonInBattle has no submenu. prompt = forced and "which" or "choose", + battle = true, battleSubmenu = not forced, onCancel = function() stack:pop() @@ -2762,6 +2763,7 @@ function BattleState:openShiftParty() self.phase = "submenu" Screens.push(self.game, "Gen2PartyMenu", { prompt = "which", + battle = true, onCancel = function() stack:pop() self.phase = "resolving" @@ -3135,6 +3137,7 @@ function BattleState:useOnPartyMon(itemId, action) self.phase = "submenu" Screens.push(self.game, "Gen2PartyMenu", { prompt = "useItem", + battle = true, party = self.battle.party or (self.save and self.save.party), onCancel = function() stack:pop() diff --git a/src/ui/gen2/PartyMenu.lua b/src/ui/gen2/PartyMenu.lua index 107c05a7..6cae3190 100644 --- a/src/ui/gen2/PartyMenu.lua +++ b/src/ui/gen2/PartyMenu.lua @@ -76,6 +76,23 @@ local BATTLE_SUBMENU_LEFT, BATTLE_SUBMENU_TOP = 11, 11 -- HP bar is 6 tiles wide (48px) in the party list. +local function gridIndex(index, count, direction) + if count < 1 then return nil end + local row, col = math.floor((index - 1) / 2), (index - 1) % 2 + if direction == "left" or direction == "right" then + local other = row * 2 + (1 - col) + 1 + return other <= count and other or index + end + local step = direction == "up" and -1 or direction == "down" and 1 + if not step then return nil end + local rows = math.ceil(count / 2) + for offset = 1, rows do + local other = ((row + step * offset) % rows) * 2 + col + 1 + if other <= count then return other end + end + return index +end + function PartyMenu:wantsFillScale() return true end function PartyMenu:drawsWidescreen() return true end @@ -114,6 +131,7 @@ function PartyMenu.new(game, opts) self.wantsSubmenu = opts.submenu == true -- BattleMenu_PKMN's `callfar BattleMonMenu` (engine/battle/core.asm:4810). self.wantsBattleSubmenu = opts.battleSubmenu == true + self.battle = opts.battle == true self.submenu = nil -- The held slot while SwitchPartyMons' second pick is open; nil otherwise. self.switchFrom = nil @@ -146,6 +164,13 @@ function PartyMenu:isCancel() return self.index > #self.party end +function PartyMenu:gridNavigation() + if not self.battle + or not Runtime.wantsHook("ui.party.grid_navigation") then return false end + return Runtime.call("ui.party.grid_navigation", function() return false end, + self) == true +end + -- ------------------------------------------------------------- mon submenu -- GetMonSubmenuItems, in its own order: every field move the mon knows first, @@ -477,7 +502,18 @@ function PartyMenu:update(_dt) return end local total = self:count() - if input:wasPressed("up") then + local grid + if self:gridNavigation() then + local direction = input:wasPressed("left") and "left" + or input:wasPressed("right") and "right" + or input:wasPressed("up") and "up" + or input:wasPressed("down") and "down" + grid = gridIndex(self.index, #self.party, direction) + end + if grid then + self.index = grid + self:storeCursor() + elseif input:wasPressed("up") then self.index = self.index > 1 and self.index - 1 or total elseif input:wasPressed("down") then self.index = self.index < total and self.index + 1 or 1 diff --git a/tests/engine/android_exit_to_launcher_test.lua b/tests/engine/android_exit_to_launcher_test.lua new file mode 100644 index 00000000..247366d5 --- /dev/null +++ b/tests/engine/android_exit_to_launcher_test.lua @@ -0,0 +1,80 @@ +-- Test returning to launcher from Gen 1 and Gen 2 on Android without closing the process +-- luajit tests/engine/android_exit_to_launcher_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local TitleState = require("src.ui.TitleState") +local Gen2MainMenu = require("src.ui.gen2.MainMenu") +local Runtime = require("src.mods.Runtime") + +-- 1. Test Gen 1 TitleState onExit callback support +do + local exitCalled = false + local dummyGame = { + data = { field = {} }, + stack = { + states = {}, + push = function(self, state) table.insert(self.states, state) end, + top = function(self) return self.states[#self.states] end, + pop = function(self) return table.remove(self.states) end, + }, + } + local state = TitleState.new(dummyGame, { + onExit = function() + exitCalled = true + end, + }) + state:openMenu() + local menu = dummyGame.stack:top() + check(menu ~= nil, "TitleState:openMenu opens a menu") + local exitItem = nil + for _, item in ipairs(menu.items or {}) do + if tostring(item.label):find("EXIT", 1, true) then + exitItem = item + break + end + end + check(exitItem ~= nil, "Gen 1 TitleState menu contains an EXIT GAME item") + if exitItem and exitItem.onSelect then + exitItem.onSelect() + end + check(exitCalled, "Selecting EXIT GAME in Gen 1 TitleState invokes onExit callback") +end + +-- 2. Test Gen 2 MainMenu onExit callback support +do + local exitCalled = false + local dummyGame2 = { + data = {}, + stack = { + states = {}, + push = function(self, state) table.insert(self.states, state) end, + top = function(self) return self.states[#self.states] end, + pop = function(self) return table.remove(self.states) end, + }, + } + local menu = Gen2MainMenu.new(dummyGame2, { + hasSave = false, + onExit = function() + exitCalled = true + end, + }) + menu:choose("exit") + check(exitCalled, "Selecting EXIT GAME in Gen 2 MainMenu invokes onExit callback") +end + +-- 3. Test Runtime.reset restores NullEvents and NullHooks +do + Runtime.install({ emit = function() end }, { call = function() end }, { "err" }) + check(Runtime.errors ~= nil, "Runtime has errors list after install") + Runtime.reset() + check(Runtime.errors == nil, "Runtime.reset clears errors") + check(Runtime.currentMod == nil, "Runtime.reset clears currentMod") + check(Runtime.modRequire == nil, "Runtime.reset clears modRequire") +end + +T.finish("android_exit_to_launcher_test") diff --git a/tests/engine/android_shortcuts_payload_test.lua b/tests/engine/android_shortcuts_payload_test.lua new file mode 100644 index 00000000..46da7e7c --- /dev/null +++ b/tests/engine/android_shortcuts_payload_test.lua @@ -0,0 +1,81 @@ +-- tests/engine/android_shortcuts_payload_test.lua +-- Tests Android dynamic shortcuts synchronization, launch options intent resolution, +-- and love.handlers.intent_game in-process game hot-swapping. +-- luajit tests/engine/android_shortcuts_payload_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +require("main") + +-- 1. Verify LaunchOptions handles getLaunchGame on Android +local LaunchOptions = require("src.core.LaunchOptions") + +local savedOS = love.system and love.system.getOS +local savedGetLaunchGame = love.system and love.system.getLaunchGame + +love.system = love.system or {} +love.system.getOS = function() return "Android" end +love.system.getLaunchGame = function() return "gold" end + +local game, slot = LaunchOptions.resolve({}) +check(game == "gold", "LaunchOptions resolves intent game from love.system.getLaunchGame on Android") + +local gameCli, slotCli = LaunchOptions.resolve({ "--game=red" }) +check(gameCli == "red", "CLI --game flag overrides intent game") + +-- 2. Verify RomImporter.syncAndroidShortcuts ranking and 4-item cap +local RomImporter = require("src.import.RomImporter") + +local originalIsReady = RomImporter.isReady +local capturedShortcuts = nil + +love.system.updateShortcuts = function(versions) + capturedShortcuts = versions + return true +end + +-- Mock isReady +RomImporter.isReady = function(v) + return v == "red" or v == "gold" or v == "blue" or v == "yellow" +end + +local ok = RomImporter.syncAndroidShortcuts("gold") +check(ok == true, "syncAndroidShortcuts returns true on Android") +check(#capturedShortcuts == 4, "syncAndroidShortcuts caps at 4 items") +check(capturedShortcuts[1] == "gold", "activeVersion 'gold' is placed first") + +-- Test with subset of ready games (e.g. only Red and Gold) +RomImporter.isReady = function(v) + return v == "red" or v == "gold" +end + +capturedShortcuts = nil +RomImporter.syncAndroidShortcuts("red") +check(#capturedShortcuts == 2, "syncAndroidShortcuts only includes ready ROMs") +check(capturedShortcuts[1] == "red" and capturedShortcuts[2] == "gold", "ready ROMs correctly passed") + +-- Test on non-Android platform (safe no-op) +love.system.getOS = function() return "Linux" end +capturedShortcuts = nil +local nonAndroidOk = RomImporter.syncAndroidShortcuts("red") +check(nonAndroidOk == false, "syncAndroidShortcuts safely no-ops on non-Android") +check(capturedShortcuts == nil, "no shortcuts updated on non-Android") + +-- 3. Verify love.handlers.intent_game definition +check(type(love.handlers.intent_game) == "function", "main.lua defines love.handlers.intent_game") + +-- Restore +RomImporter.isReady = originalIsReady +if savedOS then + love.system.getOS = savedOS +else + love.system.getOS = nil +end +love.system.getLaunchGame = savedGetLaunchGame +love.system.updateShortcuts = nil + +print("8/8 checks passed (android_shortcuts_payload_test)") diff --git a/tests/engine/gate_gen2_mod_api.lua b/tests/engine/gate_gen2_mod_api.lua index 68cff2e8..0da5fc1e 100644 --- a/tests/engine/gate_gen2_mod_api.lua +++ b/tests/engine/gate_gen2_mod_api.lua @@ -397,7 +397,8 @@ local GEN2_HOOKS = { "world.tod", "map.palette", "fieldmove.eligibility", -- menus and the battle intro "ui.start_menu.items", "ui.title_menu.items", "ui.options.rows", - "ui.party.submenu", "ui.naming.grid", "ui.pc.items", "ui.list_menu", + "ui.party.submenu", "ui.party.grid_navigation", "ui.naming.grid", + "ui.pc.items", "ui.list_menu", "transition.style", -- battle "battle.damage", "battle.crit", "battle.accuracy", "battle.turn_order", diff --git a/tests/engine/pikachu_unhappy_release_crash.lua b/tests/engine/pikachu_unhappy_release_crash.lua new file mode 100644 index 00000000..0f4ded1c --- /dev/null +++ b/tests/engine/pikachu_unhappy_release_crash.lua @@ -0,0 +1,106 @@ +-- BoxMenu's Yellow-only "Pikachu looks unhappy" release path +-- (release()'s isYellow()/species=="PIKACHU"/otId/ot branch) pushes its +-- TextBox with `TextBox.new(game, (...):gsub(...))` -- the gsub call is +-- the last argument, unparenthesized, so Lua expands its second return +-- value (the substitution count) into TextBox.new's third parameter, +-- onDone. TextBox.lua later calls onDone() unconditionally once the box +-- is dismissed, and a number is not callable: every release of your own +-- caught Pikachu in Yellow crashed, regardless of its nickname (unlike +-- the separate %-escape gsub bug, this needs no special save content -- +-- ordinary play reaches it every time). ROM-free: registers a fake +-- Data.pokemon.PIKACHU cloned from the fixture species so the species == +-- "PIKACHU" check can be exercised without a real ROM import. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local ids = T.fixtures.ids +require("src.render.Font").load(Data) + +-- clone a real fixture species under the literal id release() checks for +Data.pokemon.PIKACHU = Data.pokemon[ids.species[1]] + +local Pokemon = require("src.pokemon.Pokemon") +local Boxes = require("src.pokemon.Boxes") +local TextBox = require("src.render.TextBox") +local BoxMenu = require("src.ui.BoxMenu") +local ListMenu = require("src.ui.ListMenu") +local ChoiceBox = require("src.ui.ChoiceBox") +local SaveData = require("src.core.SaveData") +local GameVersion = require("src.core.GameVersion") +local Sound = require("src.core.Sound") + +local realCry, realPlay = Sound.playCry, Sound.play +Sound.playCry = function() end +Sound.play = function() end + +local stack = { states = {} } +function stack:push(s) self.states[#self.states + 1] = s end +function stack:pop() + local t = self.states[#self.states] + self.states[#self.states] = nil + return t +end +function stack:top() return self.states[#self.states] end +function stack:update(dt) + local t = self:top() + if t and t.update then t:update(dt) end +end + +local pressed = {} +local function press(btn) + pressed = { [btn] = true } + stack:update(1 / 60) + pressed = {} +end + +local function topMt() return getmetatable(stack:top()) end +local function mash(btn, cond, n) + for _ = 1, (n or 400) do + if cond() then return true end + press(btn) + end + return false +end + +GameVersion.set("yellow") + +local save = SaveData.newGame() +local game = { + data = Data, + save = save, + stack = stack, + input = { + wasPressed = function(_, key) return pressed[key] or false end, + isDown = function() return false end, + }, +} +game.save.options = game.save.options or {} +game.save.options.textSpeed = 1 + +local box = Boxes.active(save) +local mon = Pokemon.new(Data, "PIKACHU", 5) +mon.otId = save.player.id +mon.ot = save.player.name +box[1] = mon + +stack:push(BoxMenu.new(game)) +press("down"); press("down"); press("a") -- open RELEASE list +T.check(topMt() == ListMenu, "RELEASE opens the box list") + +-- release() calls Sound.playCry (stubbed) then pushes the "unhappy" +-- TextBox before any confirmation prompt -- pre-fix this line itself +-- raises "attempt to call field 'onDone' (a number value)" the moment +-- TextBox.new stores the leaked count and something dismisses the box. +local ok, err = pcall(function() + press("a") -- choose the Pikachu; release() runs synchronously here + T.check(topMt() == TextBox, "the unhappy-Pikachu TextBox opens directly, no confirm prompt") + -- dismiss it: this is what calls onDone, which is where the pre-fix + -- leaked count used to crash + mash("a", function() return topMt() ~= TextBox end) +end) +T.check(ok, "releasing your own caught Pikachu in Yellow does not crash: " .. tostring(err)) + +GameVersion.set("red") +Sound.playCry, Sound.play = realCry, realPlay +T.finish("pikachu_unhappy_release_crash") diff --git a/tests/engine/rom_importer_source_tree_test.lua b/tests/engine/rom_importer_source_tree_test.lua index 8a66d846..508ba043 100644 --- a/tests/engine/rom_importer_source_tree_test.lua +++ b/tests/engine/rom_importer_source_tree_test.lua @@ -26,5 +26,7 @@ check(helperStart ~= nil, "requiredFilesFor helper exists") local helper = src:sub(helperStart, start) check(helper:find("VERSION_REQUIRED_FILES_OVERRIDE", 1, true) ~= nil, "requiredFilesFor consults VERSION_REQUIRED_FILES_OVERRIDE") +check(src:find('"assets/generated/battle/hud/balls.png"', 1, true) ~= nil, + "Gold caches require the trainer HUD ball sheet") T.finish() diff --git a/tests/engine/safe_mode_issue_report.lua b/tests/engine/safe_mode_issue_report.lua new file mode 100644 index 00000000..428bbcd8 --- /dev/null +++ b/tests/engine/safe_mode_issue_report.lua @@ -0,0 +1,137 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("safe mode and issue report") +local check = S.check + +local SaveData = require("src.core.SaveData") +local LauncherMods = require("src.mods.LauncherMods") +local IssueReport = require("src.core.IssueReport") +local Version = require("src.core.Version") + +local options = SaveData.defaultOptions() +check(not SaveData.isSafeMode(options), "safe mode defaults off") +SaveData.setSafeMode(options, true) +check(SaveData.isSafeMode(options), "safe mode can be enabled") + +local manifests = { + { id = "alpha", name = "Alpha", version = "1.0.0", experimental = false, + raw = {}, dependencySpecs = {}, conflictSpecs = {} }, + { id = "beta", name = "Beta", version = "1.0.0", experimental = false, + raw = {}, dependencySpecs = {}, conflictSpecs = {} }, +} +options.mods.alpha = false +options.mods.beta = true +local rows = LauncherMods.deriveList(manifests, options, "red") +check(#rows == 2, "safe mode keeps installed mods visible") +check(not rows[1].enabled and not rows[2].enabled, + "safe mode disables every launcher mod row") +check(rows[1].status == "safe_mode" and rows[2].status == "safe_mode", + "safe mode explains every disabled launcher row") + +SaveData.setSafeMode(options, false) +rows = LauncherMods.deriveList(manifests, options, "red") +local byId = {} +for _, row in ipairs(rows) do byId[row.id] = row end +check(not byId.alpha.enabled and byId.beta.enabled, + "turning safe mode off restores saved mod choices") + +local previousLove = _G.love +local openedURL +_G.love = { + getVersion = function() return 12, 0, 0, "Mysterious Mysteries" end, + system = { + getOS = function() return "iOS" end, + getModel = function() return "iPad Test" end, + openURL = function(url) openedURL = url end, + }, + graphics = { + getRendererInfo = function() + return "Metal", "3.0", "Apple", "Simulator GPU" + end, + getDimensions = function() return 1024, 768 end, + getPixelDimensions = function() return 2048, 1536 end, + }, + window = { + getMode = function() return 1024, 768, { fullscreen = false } end, + }, +} + +local url, fields, info = IssueReport.build({ + safeMode = true, + lastVersion = "gold", +}, { + version = "gold", + mods = { { id = "alpha", name = "Alpha", enabled = true } }, +}) +check(url:find("template=bug_report.yml", 1, true) ~= nil, + "report URL selects the bug form") +check(url:find("title=bug%3A%20replace%20this%20with%20a%20meaningful%20title", 1, true) ~= nil, + "report URL uses the requested bug title") +check(info.os == "iOS", + "report metadata maps the platform") +check(fields.mods_which == "", + "safe mode leaves the optional mod list blank") +check(not url:find("game=", 1, true) + and not url:find("os=", 1, true) + and not url:find("mods_enabled=", 1, true), + "report URL omits unsupported dropdown and checkbox prefills") +check(fields.summary == "" and fields.location == "" and fields.screenshot == "" + and fields.steps == "" and fields.expected == "", + "report leaves user-entered fields blank") +check(info.metadata:find("Device: iPad Test", 1, true) ~= nil + and info.metadata:find("LÖVE: 12.0.0", 1, true) ~= nil + and info.metadata:find("Safe mode: on", 1, true) ~= nil, + "report metadata includes device and app details") +check(not info.metadata:find("unknown", 1, true), + "report metadata omits unknown values") +check(not info.metadata:find("Game id", 1, true) + and not info.metadata:find("Game:", 1, true) + and not info.metadata:find("Mods:", 1, true) + and not info.metadata:find("Processors", 1, true) + and not info.metadata:find("Power", 1, true), + "report metadata omits redundant system fields") + +local previousEngine = Version.engine +Version.engine = "0.1.50" +local _, versionFields, versionInfo = IssueReport.build({}, { mods = {} }) +check(versionFields.version == "0.1.50" + and versionInfo.metadata:find("App: gen1recomp v0.1.50", 1, true) ~= nil, + "report uses the stamped app version") +Version.engine = previousEngine +local _, _, developmentInfo = IssueReport.build({}, { mods = {} }) +check(not developmentInfo.metadata:find("0.0.0-dev", 1, true), + "report omits an unstamped development version") + +local previousOS = love.system.getOS +local previousModel = love.system.getModel +local previousIO = _G.io +love.system.getOS = function() return "OS X" end +love.system.getModel = nil +_G.io = { + popen = function() + return { + read = function() return "MacBookPro18,3" end, + close = function() end, + } + end, +} +local desktopInfo = IssueReport.metadata({}, { mods = {} }) +check(desktopInfo.device == "MacBookPro18,3", + "report finds desktop device model when LOVE has no model") +love.system.getOS = function() return "UWP" end +local xboxInfo = IssueReport.metadata({}, { mods = {} }) +check(xboxInfo.os == "Xbox", "report maps the Xbox runtime platform") +love.system.getOS = previousOS +love.system.getModel = previousModel +_G.io = previousIO + +local opened = IssueReport.open({ safeMode = false }, { + version = "red", + mods = {}, +}) +check(opened and openedURL and openedURL:find("title=bug%3A%20replace%20this%20with%20a%20meaningful%20title", 1, true) ~= nil, + "report action opens the generated URL") + +_G.love = previousLove + +S.finish() diff --git a/tests/engine/status_abbreviation_translation_test.lua b/tests/engine/status_abbreviation_translation_test.lua new file mode 100644 index 00000000..7c888a6a --- /dev/null +++ b/tests/engine/status_abbreviation_translation_test.lua @@ -0,0 +1,166 @@ +-- SummaryMenu.lua:148 and PartyMenu.lua:824 used to draw mon.status as a +-- bare literal ("PSN", "PAR", "BRN", "FRZ", "SLP"), invisible to any +-- translation a mod supplies. Unlike the strings catalog, a mod translates +-- status abbreviations through the statuses content registry +-- (mod.content.statuses:patch(id, { label = value }), label only), the +-- same registry src/battle/BattleState.lua:statusLabel already reads in +-- battle. This test drives both screens' status draw with a mod-patched +-- registry and checks the patched label reaches Font.draw, not the raw +-- status id. +-- +-- It also guards a second bug found alongside the first: Status.RECORDS' +-- five vanilla entries used to set hudLabel to the same literal as label +-- ("FRZ", hudLabel = "FRZ", ...). Since Status.hudLabelFor (and +-- BattleState:statusLabel before it) reads "hudLabel or label", and +-- Registry:patch only overrides the fields a mod actually passes, a +-- real label-only patch left the untouched vanilla hudLabel shadowing it +-- forever -- the translation was stored but never displayed anywhere, +-- in or out of battle. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = love or {} +love.graphics = { + setColor = function() end, + rectangle = function() end, + draw = function() end, + push = function() end, pop = function() end, + translate = function() end, scale = function() end, +} + +package.loaded["src.render.Font"] = { + draw = function() end, + drawCode = function() end, + drawBox = function() end, +} +package.loaded["src.render.HudTiles"] = { + statusTile = function() end, + tile = function() end, + drawHPBar = function() end, +} +package.loaded["src.render.PaletteFX"] = { + shader = function() return nil end, + pal = function() return nil end, + markTrueColor = function() end, +} +package.loaded["src.ui.Theme"] = { cursor = 0, cursorHollow = 0 } +package.loaded["src.render.Assets"] = {} +package.loaded["src.world.FieldDefaults"] = {} +package.loaded["src.world.Map"] = {} +package.loaded["src.mods.Runtime"] = { wantsHook = function() return false end } +package.loaded["src.ui.Screens"] = {} +package.loaded["src.core.Logger"] = { warn = function() end } + +local Status = require("src.battle.Status") + +-- a mod's registered status translation, same shape mod.content.statuses: +-- patch(id, { label = ..., hudLabel = ... }) merges into Data.statuses +local function moddedStatuses() + local statuses = {} + for id, record in pairs(Status.RECORDS) do statuses[id] = record end + statuses.PSN = { id = "PSN", label = "PSN", hudLabel = "TOX" } + return statuses +end + +local Font = package.loaded["src.render.Font"] +local drawn +local origDraw = Font.draw +Font.draw = function(text, x, y) + drawn[#drawn + 1] = { text = text, x = x, y = y } + return origDraw(text, x, y) +end + +local function mkDef() + return { name = "BULBASAUR", dex = 1, types = { "GRASS" } } +end + +local function mkMon(status) + return { + nickname = "SAUR", species = "BULBASAUR", level = 5, + hp = 10, stats = { hp = 10, attack = 5, defense = 5, speed = 5, special = 5 }, + status = status, + } +end + +-- ---- SummaryMenu: page 1's STATUS/ line (~line 148-151) ---- +do + local SummaryMenu = assert(loadfile("src/ui/SummaryMenu.lua"))() + local game = { + data = { pokemon = { BULBASAUR = mkDef() }, statuses = moddedStatuses() }, + save = { player = { id = 1, name = "RED" } }, + } + local menu = setmetatable( + { game = game, mon = mkMon("PSN"), page = 1 }, SummaryMenu) + drawn = {} + menu:draw() + local statusDraw + for _, d in ipairs(drawn) do + if d.x == 128 and d.y == 48 then statusDraw = d end + end + T.check(statusDraw ~= nil, "SummaryMenu draws a status label at (128,48)") + T.eq(statusDraw.text, "TOX", + "SummaryMenu draws the mod-patched hudLabel, not the raw status id") +end + +-- vanilla (no mod): falls back to the plain id, same as before the fix +do + local SummaryMenu = assert(loadfile("src/ui/SummaryMenu.lua"))() + local game = { + data = { pokemon = { BULBASAUR = mkDef() }, statuses = nil }, + save = { player = { id = 1, name = "RED" } }, + } + local menu = setmetatable( + { game = game, mon = mkMon("PSN"), page = 1 }, SummaryMenu) + drawn = {} + menu:draw() + local statusDraw + for _, d in ipairs(drawn) do + if d.x == 128 and d.y == 48 then statusDraw = d end + end + T.eq(statusDraw.text, "PSN", + "SummaryMenu still shows the vanilla PSN label with no mod loaded") +end + +-- ---- PartyMenu: the roster row's status column (~line 824-827) ---- +do + local PartyMenu = assert(loadfile("src/ui/PartyMenu.lua"))() + PartyMenu.drawIcon = function() end + local game = { + data = { pokemon = { BULBASAUR = mkDef() }, statuses = moddedStatuses(), + text = {} }, + save = { party = { mkMon("PSN") } }, + } + local list = setmetatable({ game = game, index = 1 }, PartyMenu) + drawn = {} + list:draw() + local statusDraw + for _, d in ipairs(drawn) do + if d.x == 136 then statusDraw = d end + end + T.check(statusDraw ~= nil, "PartyMenu draws a status label at x=136") + T.eq(statusDraw.text, "TOX", + "PartyMenu draws the mod-patched hudLabel, not the raw status id") +end + +-- ---- real Registry:patch, not a hand-built table: a label-only patch (the +-- shape a translation mod would send for every one of the 5 vanilla +-- statuses) must reach the HUD despite the vanilla record already +-- defining hudLabel ---- +do + local Registry = require("src.mods.Registry") + local reg = Registry.new("statuses", { semantics = "record", target = "statuses" }) + reg.base = function() return Status.RECORDS end + local LABEL_ONLY_PATCH = { SLP = "SOM", FRZ = "GEL", PSN = "PSN", BRN = "BRU", PAR = "PAR" } + for id, translated in pairs(LABEL_ONLY_PATCH) do + reg:patch(id, { label = translated }, "mod") + end + local merged = {} + for id in pairs(Status.RECORDS) do merged[id] = reg:get(id) end + for id, translated in pairs(LABEL_ONLY_PATCH) do + T.eq(Status.hudLabelFor(merged, id), translated, + "a label-only mod patch on " .. id .. " reaches the HUD label") + end +end + +T.finish("status_abbreviation_translation_test") diff --git a/tests/mod_qol_hooks_tests.lua b/tests/mod_qol_hooks_tests.lua index d4b82643..d065c079 100644 --- a/tests/mod_qol_hooks_tests.lua +++ b/tests/mod_qol_hooks_tests.lua @@ -14,6 +14,7 @@ local NamingScreen = require("src.ui.NamingScreen") local TextBox = require("src.render.TextBox") local ChoiceBox = require("src.ui.ChoiceBox") local PartyMenu = require("src.ui.PartyMenu") +local Gen2PartyMenu = require("src.ui.gen2.PartyMenu") local Player = require("src.world.Player") local Music = require("src.core.Music") @@ -295,6 +296,16 @@ do menu:update(0) check(menu.index == 4, "removing the hook restores native list navigation immediately") + + local gold = Gen2PartyMenu.new(game, { battle = true }) + unsub = wrap("ui.party.grid_navigation", function() return true end) + gold:update(0) + check(gold.index == 3, + "a Gold battle party can follow the same companion grid") + unsub() + gold:update(0) + check(gold.index == 4, + "Gold restores native party list navigation without the hook") end -- ------- music.volume (distance / indoor muffling) diff --git a/tools/generate_android_icons.py b/tools/generate_android_icons.py new file mode 100644 index 00000000..9cf412c8 --- /dev/null +++ b/tools/generate_android_icons.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +Generates Android Adaptive Icon (based on cleaned love.png Pokéball + Gen1Recomp emblem) +and 3D Cartridge Shortcut assets for all density buckets (mdpi, hdpi, xhdpi, xxhdpi, xxxhdpi). +""" + +import os +from collections import deque +import numpy as np +from PIL import Image, ImageDraw, ImageFilter + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +RES_DIR = os.path.join(ROOT, "mobile", "android", "app", "src", "main", "res") + +DENSITIES = { + "mdpi": {"shortcut": 48, "adaptive": 108}, + "hdpi": {"shortcut": 72, "adaptive": 162}, + "xhdpi": {"shortcut": 96, "adaptive": 216}, + "xxhdpi": {"shortcut": 144, "adaptive": 324}, + "xxxhdpi": {"shortcut": 192, "adaptive": 432}, +} + +SHELL_COLORS = { + "red": {"main": (230, 45, 55), "dark": (175, 25, 35), "light": (255, 90, 100)}, + "blue": {"main": (35, 125, 235), "dark": (20, 85, 175), "light": (80, 165, 255)}, + "yellow": {"main": (255, 205, 10), "dark": (210, 160, 0), "light": (255, 230, 80)}, + "gold": {"main": (225, 170, 40), "dark": (170, 120, 20), "light": (245, 200, 80)}, +} + +def extract_cleaned_love_emblem(): + """Extracts the Pokéball/Gen1Recomp emblem from love.png with transparent bg and deepened blacks.""" + src_path = os.path.join(RES_DIR, "drawable-xxxhdpi", "love.png") + if not os.path.exists(src_path): + src_path = os.path.join(RES_DIR, "drawable-xxhdpi", "love.png") + src = Image.open(src_path).convert("RGBA") + arr = np.array(src, dtype=np.uint8) + h, w = arr.shape[:2] + + visited = np.zeros((h, w), dtype=bool) + bg_mask = np.zeros((h, w), dtype=bool) + + queue = deque() + for x in range(w): + queue.append((0, x)); queue.append((h-1, x)) + for y in range(h): + queue.append((y, 0)); queue.append((y, w-1)) + + bg_ref = np.array([255, 237, 254], dtype=float) + while queue: + y, x = queue.popleft() + if visited[y, x]: continue + visited[y, x] = True + color = arr[y, x, :3].astype(float) + if np.max(np.abs(color - bg_ref)) < 28: + bg_mask[y, x] = True + for dy, dx in [(-1,0), (1,0), (0,-1), (0,1)]: + ny, nx = y + dy, x + dx + if 0 <= ny < h and 0 <= nx < w and not visited[ny, nx]: + queue.append((ny, nx)) + + out_arr = arr.copy().astype(float) + out_arr[bg_mask, 3] = 0 + + # Deepen the soft blacks/outlines for crispness: + fg_mask = ~bg_mask + rgb = out_arr[fg_mask, :3] + lum = 0.299 * rgb[:, 0] + 0.587 * rgb[:, 1] + 0.114 * rgb[:, 2] + + for i in range(len(rgb)): + l = lum[i] + if l < 110: + factor = (l / 110.0) ** 1.8 + rgb[i, 0] = max(0, rgb[i, 0] * factor * 0.7) + rgb[i, 1] = max(0, rgb[i, 1] * factor * 0.7) + rgb[i, 2] = max(0, rgb[i, 2] * factor * 0.8) + + out_arr[fg_mask, :3] = np.clip(rgb, 0, 255) + return Image.fromarray(out_arr.astype(np.uint8)) + +CLEANED_EMBLEM = extract_cleaned_love_emblem() + +def create_adaptive_foreground(size): + emblem = CLEANED_EMBLEM.copy() + target_size = int(size * 0.78) + emblem.thumbnail((target_size, target_size), Image.Resampling.LANCZOS) + + canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + x = (size - emblem.width) // 2 + y = (size - emblem.height) // 2 + canvas.paste(emblem, (x, y), emblem) + return canvas + +def create_adaptive_monochrome(size): + fg = create_adaptive_foreground(size) + arr = np.array(fg, dtype=float) + lum = 0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2] + alpha = arr[:, :, 3] + + mono_alpha = np.zeros_like(alpha) + valid = alpha > 20 + mono_alpha[valid & (lum > 70)] = 255 + mono_alpha[valid & (lum <= 70)] = 0 + + mono_img = np.zeros((size, size, 4), dtype=np.uint8) + mono_img[:, :, 0] = 255 + mono_img[:, :, 1] = 255 + mono_img[:, :, 2] = 255 + mono_img[:, :, 3] = mono_alpha.astype(np.uint8) + + return Image.fromarray(mono_img) + +def render_3d_cartridge(version, size): + colors = SHELL_COLORS[version] + S = size * 4 + + # Transparent canvas so the cartridge sits directly on launcher's white circle plate + canvas = Image.new("RGBA", (S, S), (0, 0, 0, 0)) + draw = ImageDraw.Draw(canvas) + + # Tight padding to maximize cartridge size + pad_x = int(S * 0.05) + pad_y = int(S * 0.03) + cw = S - pad_x * 2 + ch = S - pad_y * 2 + + # Soft drop shadow + shadow = Image.new("RGBA", (S, S), (0, 0, 0, 0)) + sdraw = ImageDraw.Draw(shadow) + sdraw.rounded_rectangle([pad_x + 8, pad_y + 14, pad_x + cw + 8, pad_y + ch + 14], + radius=int(S * 0.05), fill=(0, 0, 0, 90)) + shadow = shadow.filter(ImageFilter.GaussianBlur(int(S * 0.03))) + canvas.paste(shadow, (0, 0), shadow) + + radius = int(S * 0.045) + depth = int(S * 0.03) + draw.rounded_rectangle([pad_x, pad_y + depth, pad_x + cw, pad_y + ch + depth], + radius=radius, fill=colors["dark"]) + draw.rounded_rectangle([pad_x, pad_y, pad_x + cw, pad_y + ch], + radius=radius, fill=colors["main"]) + + notch_w = int(cw * 0.65) + notch_h = int(ch * 0.07) + notch_x = pad_x + (cw - notch_w) // 2 + notch_y = pad_y + int(ch * 0.035) + draw.rounded_rectangle([notch_x, notch_y, notch_x + notch_w, notch_y + notch_h], + radius=int(notch_h * 0.4), fill=colors["dark"]) + draw.rounded_rectangle([notch_x, notch_y - 2, notch_x + notch_w, notch_y + notch_h - 2], + radius=int(notch_h * 0.4), fill=colors["light"]) + + label_margin_x = int(cw * 0.09) + label_top_y = pad_y + int(ch * 0.20) + label_w = cw - label_margin_x * 2 + label_h = int(ch * 0.70) + label_x = pad_x + label_margin_x + + draw.rounded_rectangle([label_x - 3, label_top_y - 3, label_x + label_w + 3, label_top_y + label_h + 3], + radius=int(radius * 0.7), fill=colors["dark"]) + + label_path = os.path.join(ROOT, "assets", "labels", f"{version}.png") + if os.path.exists(label_path): + label_img = Image.open(label_path).convert("RGBA") + label_img = label_img.resize((label_w, label_h), Image.Resampling.LANCZOS) + + mask = Image.new("L", (label_w, label_h), 0) + mdraw = ImageDraw.Draw(mask) + mdraw.rounded_rectangle([0, 0, label_w, label_h], radius=int(radius * 0.5), fill=255) + + canvas.paste(label_img, (label_x, label_top_y), mask) + else: + draw.rounded_rectangle([label_x, label_top_y, label_x + label_w, label_top_y + label_h], + radius=int(radius * 0.5), fill=(240, 240, 240, 255)) + + draw.rounded_rectangle([pad_x, pad_y, pad_x + cw, pad_y + ch], + radius=radius, outline=colors["light"], width=max(2, int(S * 0.008))) + + return canvas.resize((size, size), Image.Resampling.LANCZOS) + +def main(): + os.makedirs(os.path.join(RES_DIR, "values"), exist_ok=True) + os.makedirs(os.path.join(RES_DIR, "mipmap-anydpi-v26"), exist_ok=True) + + for density, sizes in DENSITIES.items(): + drawable_dir = os.path.join(RES_DIR, f"drawable-{density}") + os.makedirs(drawable_dir, exist_ok=True) + + fg = create_adaptive_foreground(sizes["adaptive"]) + fg.save(os.path.join(drawable_dir, "ic_launcher_foreground.png"), "PNG") + + for ver in ("red", "blue", "yellow", "gold"): + cart = render_3d_cartridge(ver, sizes["shortcut"]) + cart.save(os.path.join(drawable_dir, f"ic_shortcut_{ver}.png"), "PNG") + + print(f"Generated assets for drawable-{density}") + +if __name__ == "__main__": + main()