This commit is contained in:
bryanthaboi
2026-08-19 06:41:12 -04:00
66 changed files with 1716 additions and 64 deletions
+3 -2
View File
@@ -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 id under Gen 1's `name` key, which is the one payload difference the
numeric flag space forces. numeric flag space forces.
- *Menus (`src/ui/gen2/`):* `ui.start_menu.items`, `ui.title_menu.items`, - *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.options.rows`, `ui.party.submenu`, `ui.party.grid_navigation`,
`ui.list_menu`, `transition.style`. `ui.list_menu` covers Gold's script `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 (`ScriptMenu.lua`); the `Chrome.List` widget the START and title
menus draw with does not raise it yet, so those two are composed through menus draw with does not raise it yet, so those two are composed through
their own hooks only. their own hooks only.
+103 -15
View File
@@ -291,6 +291,78 @@ function closeSkinStudio()
end end
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) function bootGame(version)
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold); -- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red. -- 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 -- Apply the persisted Android orientation lock (#592) before the launcher
-- shows: SDL created the window with no orientation hint, so without this -- 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. -- No-op on desktop / iOS / when options.lua does not exist yet.
require("src.core.Orientation").applyOptions( require("src.core.Orientation").applyOptions(
require("src.core.SaveData").loadOptions()) 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 -- (#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 -- restart could: the ordering is the same on every launch. Read the
-- enabled mods' string catalogs -- data only, no entry chunk -- so a -- enabled mods' string catalogs -- data only, no entry chunk -- so a
-- translation reaches the launcher too. Game:load replaces this with the -- translation reaches the launcher too. The active game's loader replaces
-- real merged catalog once a version boots. -- this with the real merged catalog once a version boots.
do do
local preload = require("src.mods.LauncherMods").translationStrings() local preload = require("src.mods.LauncherMods").translationStrings()
if preload then require("src.core.Strings").load({ strings = preload }) end 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 -- 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). -- 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). -- Edit on a save row opens the bundled editor on that slot (openEditor).
Importer = RomImporter.new(function(version) Importer = makeLauncher()
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 end
function love.update(dt) function love.update(dt)
@@ -827,6 +889,27 @@ function love.handlers.audioreset()
if Sound then pcall(Sound.onDeviceReset) end if Sound then pcall(Sound.onDeviceReset) end
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) function love.touchpressed(id, x, y, dx, dy, pressure)
if editorMode then if editorMode then
-- iOS synthesizes mousepressed for the primary touch; forwarding here -- 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 -- 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 -- this Lua launcher via that hook. Vanilla behavior (used when no mod
-- claims the hook) is exactly the condition below. -- 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() local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
return Game and not Importer and not quitToLauncher and not scripted return Game and not Importer and not quitToLauncher and not scripted
and not launchedIntoGame and (isAndroid or not launchedIntoGame)
end) end)
if wouldReturnToLauncher then if wouldReturnToLauncher then
if isAndroid then
returnToLauncher()
return true -- abort this quit; the restart lands back in the launcher
end
quitToLauncher = true quitToLauncher = true
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this -- 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 -- once, so the restart really does land in the launcher (#887). A failed
@@ -29,7 +29,8 @@
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@drawable/love" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="${NAME}" > android:label="${NAME}" >
<meta-data <meta-data
android:name="android.allow_multiple_resumed_activities" android:name="android.allow_multiple_resumed_activities"
@@ -39,7 +40,7 @@
android:exported="true" android:exported="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:label="${NAME}" android:label="${NAME}"
android:launchMode="singleInstance" android:launchMode="singleTask"
android:screenOrientation="${ORIENTATION}" android:screenOrientation="${ORIENTATION}"
android:resizeableActivity="false" android:resizeableActivity="false"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" > android:theme="@android:style/Theme.NoTitleBar.Fullscreen" >
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -3,4 +3,9 @@
<color name="colorPrimary">#3F51B5</color> <color name="colorPrimary">#3F51B5</color>
<color name="colorPrimaryDark">#303F9F</color> <color name="colorPrimaryDark">#303F9F</color>
<color name="colorAccent">#FF4081</color> <color name="colorAccent">#FF4081</color>
<color name="ic_launcher_background">#FFFFFF</color>
<color name="shortcut_red">#E53935</color>
<color name="shortcut_blue">#1E88E5</color>
<color name="shortcut_yellow">#FDD835</color>
<color name="shortcut_gold">#D4AF37</color>
</resources> </resources>
@@ -48,6 +48,7 @@
#include "common/Module.h" #include "common/Module.h"
#include "audio/Audio.h" #include "audio/Audio.h"
#include "audio/openal/Audio.h" #include "audio/openal/Audio.h"
#include "event/Event.h"
namespace love namespace love
{ {
@@ -282,6 +283,70 @@ bool restartApp()
return result; return result;
} }
bool updateAppShortcuts(const std::vector<std::string> &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) bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept)
{ {
if (url == nullptr || destPath == nullptr) if (url == nullptr || destPath == nullptr)
@@ -1488,4 +1553,32 @@ Java_org_love2d_android_GameActivity_nativeAudioDeviceChanged(JNIEnv *env, jclas
love::audio::openal::pushAudioResetEvent(); love::audio::openal::pushAudioResetEvent();
} }
static void pushGameIntentEvent(const char *game)
{
auto eventmodule = love::Module::getInstance<love::event::Event>(love::Module::M_EVENT);
if (eventmodule == nullptr || game == nullptr)
return;
std::vector<love::Variant> 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 #endif // LOVE_ANDROID
@@ -90,6 +90,16 @@ bool syncHealthSteps();
**/ **/
bool restartApp(); bool restartApp();
/**
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
**/
bool updateAppShortcuts(const std::vector<std::string> &versions);
/**
* Returns the game version requested via initial launch Intent (if any).
**/
std::string getLaunchGame();
/** /**
* Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has * Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has
* no curl binary, so this is the transport src/core/HostShell.lua uses there * no curl binary, so this is the transport src/core/HostShell.lua uses there
@@ -245,6 +245,25 @@ bool System::restartApp() const
#endif #endif
} }
bool System::updateShortcuts(const std::vector<std::string> &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, bool System::httpDownload(const char *url, const char *destPath,
const char *userAgent, const char *accept) const const char *userAgent, const char *accept) const
{ {
@@ -143,6 +143,9 @@ public:
**/ **/
virtual bool restartApp() const; virtual bool restartApp() const;
virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
virtual std::string getLaunchGame() const;
/** /**
* Blocking HTTPS GET into an absolute host path (Android only; false * Blocking HTTPS GET into an absolute host path (Android only; false
* elsewhere). Android has no curl, which is what every other platform * elsewhere). Android has no curl, which is what every other platform
@@ -283,6 +283,34 @@ int w_tlsClose(lua_State *L)
return 0; 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<std::string> 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[] = static const luaL_Reg functions[] =
{ {
{ "getOS", w_getOS }, { "getOS", w_getOS },
@@ -297,6 +325,8 @@ static const luaL_Reg functions[] =
{ "createFile", w_createFile }, { "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps }, { "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp }, { "restartApp", w_restartApp },
{ "updateShortcuts", w_updateShortcuts },
{ "getLaunchGame", w_getLaunchGame },
{ "httpDownload", w_httpDownload }, { "httpDownload", w_httpDownload },
{ "httpPost", w_httpPost }, { "httpPost", w_httpPost },
{ "httpRequest", w_httpRequest }, { "httpRequest", w_httpRequest },
@@ -69,8 +69,11 @@ import android.os.Vibrator;
import android.provider.Settings; import android.provider.Settings;
import android.util.Log; import android.util.Log;
import android.util.DisplayMetrics; import android.util.DisplayMetrics;
import android.view.*; import android.content.pm.ShortcutInfo;
import android.content.pm.ShortcutManager;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.graphics.drawable.Icon;
import android.view.*;
import androidx.annotation.Keep; import androidx.annotation.Keep;
import androidx.core.app.ActivityCompat; import androidx.core.app.ActivityCompat;
@@ -159,6 +162,10 @@ public class GameActivity extends SDLActivity {
private static native void nativeAudioDeviceChanged(); private static native void nativeAudioDeviceChanged();
private static native void nativeOnGameIntent(String game);
private static String initialGame = "";
private AudioManager.OnAudioFocusChangeListener audioFocusListener = null; private AudioManager.OnAudioFocusChangeListener audioFocusListener = null;
private Object audioFocusRequest = null; private Object audioFocusRequest = null;
private Object audioDeviceCallback = null; private Object audioDeviceCallback = null;
@@ -228,6 +235,10 @@ public class GameActivity extends SDLActivity {
embed = getResources().getBoolean(R.bool.embed); embed = getResources().getBoolean(R.bool.embed);
needToCopyGameInArchive = embed; needToCopyGameInArchive = embed;
Intent startIntent = getIntent();
if (startIntent != null && startIntent.hasExtra("game")) {
initialGame = startIntent.getStringExtra("game");
}
if (!embed) { if (!embed) {
Intent intent = getIntent(); Intent intent = getIntent();
handleIntent(intent); handleIntent(intent);
@@ -261,6 +272,12 @@ public class GameActivity extends SDLActivity {
@Override @Override
protected void onNewIntent(Intent intent) { protected void onNewIntent(Intent intent) {
Log.d("GameActivity", "onNewIntent() with " + 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) { if (!embed) {
handleIntent(intent); handleIntent(intent);
resetNative(); resetNative();
@@ -673,6 +690,95 @@ public class GameActivity extends SDLActivity {
return true; // unreachable, but keeps the JNI signature honest 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<ShortcutInfo> 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 * Blocking HTTPS GET into destPath, exposed as love.system.httpDownload
* and used by src/core/HostShell.lua. Android ships no curl binary, so * and used by src/core/HostShell.lua. Android ships no curl binary, so
+1 -5
View File
@@ -2527,11 +2527,7 @@ end
-- the HUD label drawn in place of the level for a statused mon -- the HUD label drawn in place of the level for a statused mon
function BattleState:statusLabel(mon) function BattleState:statusLabel(mon)
local record = Status.recordFor(self.data.statuses, mon.status) return Status.hudLabelFor(self.data.statuses, mon.status)
if record then
return record.hudLabel or record.label or mon.status
end
return mon.status
end end
-- the one accuracy roll (MoveHitTest), hooked as battle.accuracy -- the one accuracy roll (MoveHitTest), hooked as battle.accuracy
+20 -5
View File
@@ -54,6 +54,13 @@ end
-- freeze the English. They are already translatable through the -- freeze the English. They are already translatable through the
-- statuses registry (mod.content.statuses:patch(id, { label = ... })). -- 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 -- The five persistent conditions as records: the beforeMove gauntlet, the
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict), -- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the -- 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. -- read these fields, so a mod's sixth status plugs into every consumer.
Status.RECORDS = { Status.RECORDS = {
SLP = { SLP = {
id = "SLP", label = "SLP", hudLabel = "SLP", id = "SLP", label = "SLP",
catchBonus = 25, shakeBonus = 10, catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 40, beforeMovePriority = 40,
beforeMove = function(battler, _, battle) beforeMove = function(battler, _, battle)
@@ -82,7 +89,7 @@ Status.RECORDS = {
end, end,
}, },
FRZ = { FRZ = {
id = "FRZ", label = "FRZ", hudLabel = "FRZ", id = "FRZ", label = "FRZ",
catchBonus = 25, shakeBonus = 10, catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 30, beforeMovePriority = 30,
beforeMove = function(battler, _, battle) beforeMove = function(battler, _, battle)
@@ -96,7 +103,7 @@ Status.RECORDS = {
end, end,
}, },
PSN = { PSN = {
id = "PSN", label = "PSN", hudLabel = "PSN", id = "PSN", label = "PSN",
catchBonus = 12, shakeBonus = 5, catchBonus = 12, shakeBonus = 5,
residual = damageOverTime("_HurtByPoisonText", residual = damageOverTime("_HurtByPoisonText",
Strings.source("%s's\nhurt by poison!")), Strings.source("%s's\nhurt by poison!")),
@@ -112,7 +119,7 @@ Status.RECORDS = {
end, end,
}, },
BRN = { BRN = {
id = "BRN", label = "BRN", hudLabel = "BRN", id = "BRN", label = "BRN",
catchBonus = 12, shakeBonus = 5, catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "attack", div = 2 }, statPenalty = { stat = "attack", div = 2 },
residual = damageOverTime("_HurtByBurnText", residual = damageOverTime("_HurtByBurnText",
@@ -124,7 +131,7 @@ Status.RECORDS = {
end, end,
}, },
PAR = { PAR = {
id = "PAR", label = "PAR", hudLabel = "PAR", id = "PAR", label = "PAR",
catchBonus = 12, shakeBonus = 5, catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "speed", div = 4 }, statPenalty = { stat = "speed", div = 4 },
beforeMovePriority = 10, beforeMovePriority = 10,
@@ -160,6 +167,14 @@ function Status.recordFor(statuses, id)
return (statuses or Status.RECORDS)[id] return (statuses or Status.RECORDS)[id]
end 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) local function battleStatuses(battle)
return battle and battle.data and battle.data.statuses return battle and battle.data and battle.data.statuses
end end
+1
View File
@@ -176,6 +176,7 @@ function Game:makeTitleState()
self:restoreSave(loaded, recovered, { freshBoot = true }) self:restoreSave(loaded, recovered, { freshBoot = true })
end end
end, end,
onExit = self.onExit,
}) })
title.screenId = title.screenId or "TitleState" title.screenId = title.screenId or "TitleState"
return title return title
+1
View File
@@ -320,6 +320,7 @@ function Game2:showMainMenu()
onNewGame = function() self:newGame() end, onNewGame = function() self:newGame() end,
onContinue = function(save) self:continueGame(save) end, onContinue = function(save) self:continueGame(save) end,
onOption = function() self:showOptions(function() self:showMainMenu() end) end, onOption = function() self:showOptions(function() self:showMainMenu() end) end,
onExit = self.onExit,
}) })
end end
+219
View File
@@ -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
+13
View File
@@ -67,10 +67,23 @@ local function argFlag(argv, name)
return false return false
end end
local cachedIntentGame = nil
-- Returns version, slotId (either may be nil). Command line wins over env, -- Returns version, slotId (either may be nil). Command line wins over env,
-- so a shortcut can override a machine-wide default. -- so a shortcut can override a machine-wide default.
function LaunchOptions.resolve(argv) 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")) local game = normalizeVersion(argValue(argv, "game"))
or intentGame
or normalizeVersion(os.getenv("POKEPORT_GAME")) or normalizeVersion(os.getenv("POKEPORT_GAME"))
or normalizeVersion(os.getenv("POKEPORT_LAUNCH")) or normalizeVersion(os.getenv("POKEPORT_LAUNCH"))
local slot = argValue(argv, "slot") or os.getenv("POKEPORT_SLOT") local slot = argValue(argv, "slot") or os.getenv("POKEPORT_SLOT")
+11
View File
@@ -300,6 +300,7 @@ function SaveData.defaultOptions()
-- Native mod enablement is an installation option, not save-slot data. -- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default. -- Missing entries mean enabled so newly installed mods work by default.
mods = {}, mods = {},
safeMode = false,
-- Mods the player forced past the target gate (Loader:_gateGeneration). -- Mods the player forced past the target gate (Loader:_gateGeneration).
-- modsGen2[id][version] = true, one answer per game; a bare `true` is the -- 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). -- pre-per-game shape and means the Gen 2 games only (see modForced).
@@ -386,6 +387,16 @@ function SaveData.mergeOptions(loaded)
return opts return opts
end 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) function SaveData.encode(data)
return SaveSerializer.encode(data) return SaveSerializer.encode(data)
end end
+45 -1
View File
@@ -422,7 +422,7 @@ local function discoverModSchemas(opts)
-- except experimental mods, which stay off until opted in. -- except experimental mods, which stay off until opted in.
local flag = require("src.core.SaveData").modEnabled(opts, m.id) local flag = require("src.core.SaveData").modEnabled(opts, m.id)
local enabled = flag == true or (flag == nil and not m.experimental) 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) local chunk = fs.load(path .. "/" .. m.options_schema)
if chunk then if chunk then
local okR, schema = pcall(chunk) local okR, schema = pcall(chunk)
@@ -521,9 +521,49 @@ local function modRows(opts, mod)
return true return true
end } end }
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 return rows
end 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) -- ------- Gen 2 (Gold)
-- --
-- Gold reads NONE of the rows above. Its OPTION screen writes a different -- 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 } sections[#sections + 1] = { title = mod.name, rows = rows }
end end
end end
sections[#sections + 1] = {
title = Strings("TROUBLESHOOTING"),
rows = troubleshootingRows(opts, hooks),
}
return { return {
opts = opts, opts = opts,
version = version, version = version,
+31 -11
View File
@@ -682,6 +682,7 @@ end
local function modStatusColor(status) local function modStatusColor(status)
if status == "ok" then return Strings("Ready"), PAL.green end 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 == "needs_import" then return Strings("Import required"), PAL.yellow end
if status == "conflict" then return Strings("Conflict"), PAL.red 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 -- 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 -- One compact coloured checkbox for each game. The cartridge colour carries
-- the game identity even when the row is narrow. -- the game identity even when the row is narrow.
local function modGameCheckbox(x, y, size, checked, game, id) local function modGameCheckbox(x, y, size, checked, game, id, enabled)
local color = cartColor(game) enabled = enabled ~= false
local focused = Kit.focusable(id, x, y, size, size) local color = enabled and cartColor(game) or PAL.steel
local hot = focused or Kit.hover(x, y, size, size) 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 if love.graphics then
Theme.fillRounded(x, y, size, size, PAL.bg, 1) Theme.fillRounded(x, y, size, size, PAL.bg, 1)
if checked then 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) hot and Theme.A.focus or Theme.A.hairline, 1)
end end
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 end
local function buildModsPanel(imp, x, y, w, availH, m) local function buildModsPanel(imp, x, y, w, availH, m)
imp:_ensureMods() imp:_ensureMods()
local ModUpdate = require("src.mods.ModUpdate") local ModUpdate = require("src.mods.ModUpdate")
local safeMode = imp.safeMode == true
local mods = imp.mods or {} local mods = imp.mods or {}
local gap = m.gap local gap = m.gap
local cy = y local cy = y
@@ -1866,9 +1869,11 @@ local function buildModsPanel(imp, x, y, w, availH, m)
action = function() imp:chooseMod() end }) action = function() imp:chooseMod() end })
btn(imp, place(disableW), cy, disableW, bh, "mods-disable-all", Strings("Disable all"), { btn(imp, place(disableW), cy, disableW, bh, "mods-disable-all", Strings("Disable all"), {
kind = "warn", font = "small", kind = "warn", font = "small",
enabled = not safeMode,
action = function() imp:_setAllMods(false) end }) action = function() imp:_setAllMods(false) end })
btn(imp, place(enableW), cy, enableW, bh, "mods-enable-all", Strings("Enable all"), { btn(imp, place(enableW), cy, enableW, bh, "mods-enable-all", Strings("Enable all"), {
kind = "good", font = "small", kind = "good", font = "small",
enabled = not safeMode,
action = function() imp:_setAllMods(true) end }) action = function() imp:_setAllMods(true) end })
btn(imp, place(checkFullW), cy, checkFullW, bh, "mods-check-updates", Strings("Check for updates"), { btn(imp, place(checkFullW), cy, checkFullW, bh, "mods-check-updates", Strings("Check for updates"), {
font = "small", font = "small",
@@ -1917,7 +1922,9 @@ local function buildModsPanel(imp, x, y, w, availH, m)
-- notice line -- notice line
local noticeText, noticeCol 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 noticeText = imp.modNotice.text
noticeCol = imp.modNotice.ok and PAL.green or PAL.red noticeCol = imp.modNotice.ok and PAL.green or PAL.red
else else
@@ -2043,7 +2050,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
local togKey = "mod-toggle-" .. mod.id .. "-" .. game local togKey = "mod-toggle-" .. mod.id .. "-" .. game
if modGameCheckbox(tx, gamesY, togH, if modGameCheckbox(tx, gamesY, togH,
mod.enabledByVersion and mod.enabledByVersion[game] == true, mod.enabledByVersion and mod.enabledByVersion[game] == true,
game, togKey) then game, togKey, not safeMode) then
local version = game local version = game
queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end) queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end)
flipped = true 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, btn(imp, place(swBtnW), ry + math.floor(4 * m.s), swBtnW, rowH - math.floor(8 * m.s), "prof-sw-" .. i,
Strings("Switch"), { Strings("Switch"), {
kind = "good", font = "micro", kind = "good", font = "micro",
enabled = not imp.safeMode,
action = function() action = function()
LauncherMods.applyProfile(p.name, options) LauncherMods.applyProfile(p.name, options)
if imp._refreshMods then imp:_refreshMods() end if imp._refreshMods then imp:_refreshMods() end
@@ -3105,8 +3113,10 @@ local function buildModHeaderActionsModal(imp, m)
local btns = { local btns = {
{ label = Strings("Mod profiles..."), action = function() imp._profilesPopup = true end }, { label = Strings("Mod profiles..."), action = function() imp._profilesPopup = true end },
{ label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(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("Enable all mods"), kind = "good", enabled = not imp.safeMode,
{ label = Strings("Disable all mods"), kind = "warn", action = function() imp:_setAllMods(false) end }, 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 }, { label = Strings("Sort mods..."), action = function() imp._sortPopup = "mods" end },
} }
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) 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 for i, b in ipairs(btns) do
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modheadact-" .. i, b.label, { btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modheadact-" .. i, b.label, {
kind = b.kind or "ghost", font = "small", kind = b.kind or "ghost", font = "small",
enabled = b.enabled,
action = function() action = function()
imp._modHeaderActionsPopup = nil imp._modHeaderActionsPopup = nil
b.action() b.action()
@@ -3737,6 +3748,7 @@ end
local function buildSettingsModal(imp, m) local function buildSettingsModal(imp, m)
local model = imp._settings local model = imp._settings
local SaveData = require("src.core.SaveData")
local pad = math.floor(18 * m.s) local pad = math.floor(18 * m.s)
local w = math.floor(640 * m.s) local w = math.floor(640 * m.s)
local h = math.floor(math.min(m.H - 2 * m.pad, m.H * 0.9)) 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) item.header)
else else
local row = item.row local row = item.row
local rowEnabled = not row.safeModeBlocked
or not SaveData.isSafeMode(model.opts)
local key = "set-" .. i local key = "set-" .. i
Kit.card(px + pad, ry, pw - 2 * pad, rowH, "hairline") Kit.card(px + pad, ry, pw - 2 * pad, rowH, "hairline")
local ix = px + pad + math.floor(12 * m.s) 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) ctlY + (m.btnH - Kit.textHeight("small")) / 2, PAL.detail)
btn(imp, rx - ew, ctlY, ew, m.btnH, btn(imp, rx - ew, ctlY, ew, m.btnH,
key .. "-edit", Strings("Edit"), { kind = "accent", font = "small", key .. "-edit", Strings("Edit"), { kind = "accent", font = "small",
enabled = rowEnabled,
action = function() action = function()
imp._settingsText = { row = row, text = tostring(row.value() or ""), imp._settingsText = { row = row, text = tostring(row.value() or ""),
maxLen = row.editText.maxLen } maxLen = row.editText.maxLen }
@@ -3863,12 +3878,14 @@ local function buildSettingsModal(imp, m)
elseif row.action then elseif row.action then
-- A plain action row (Reset rebinds, Touch controls): the whole right -- A plain action row (Reset rebinds, Touch controls): the whole right
-- side is one button rather than a value ladder. -- 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) + math.floor(24 * m.s)
Kit.text("small", Kit.ellipsize("small", row.label, Kit.text("small", Kit.ellipsize("small", row.label,
labelW or (inner - aw - math.floor(12 * m.s))), ix, labelY, PAL.text) labelW or (inner - aw - math.floor(12 * m.s))), ix, labelY, PAL.text)
btn(imp, rx - aw, ctlY, aw, m.btnH, 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", kind = row.danger and "danger" or "ghost", font = "small",
action = function() action = function()
if row.action() ~= false then model.save() end if row.action() ~= false then model.save() end
@@ -3884,12 +3901,14 @@ local function buildSettingsModal(imp, m)
or valW or valW
btn(imp, rx - stepW, ctlY, stepW, m.btnH, btn(imp, rx - stepW, ctlY, stepW, m.btnH,
key .. "-next", ">", { font = "small", key .. "-next", ">", { font = "small",
enabled = rowEnabled,
action = function() if row.step and row.step(1) then model.save() end end }) action = function() if row.step and row.step(1) then model.save() end end })
Kit.textCenter("small", Kit.ellipsize("small", tostring(row.value()), vw), Kit.textCenter("small", Kit.ellipsize("small", tostring(row.value()), vw),
rx - stepW - vw, ctlY + (m.btnH - Kit.textHeight("small")) / 2, vw, rx - stepW - vw, ctlY + (m.btnH - Kit.textHeight("small")) / 2, vw,
PAL.heading) PAL.heading)
btn(imp, rx - stepW - vw - stepW, ctlY, stepW, btn(imp, rx - stepW - vw - stepW, ctlY, stepW,
m.btnH, key .. "-prev", "<", { font = "small", m.btnH, key .. "-prev", "<", { font = "small",
enabled = rowEnabled,
action = function() if row.step and row.step(-1) then model.save() end end }) action = function() if row.step and row.step(-1) then model.save() end end })
end end
end end
@@ -4046,6 +4065,7 @@ local function buildDepResolverModal(imp, m)
local bw = Kit.textWidth("small", btnLabel) + math.floor(20 * m.s) local bw = Kit.textWidth("small", btnLabel) + math.floor(20 * m.s)
btn(imp, place(bw), ly, bw, chipH, "dep-dis-" .. i, btnLabel, { btn(imp, place(bw), ly, bw, chipH, "dep-dis-" .. i, btnLabel, {
kind = "warn", font = "small", kind = "warn", font = "small",
enabled = not imp.safeMode,
action = function() action = function()
local LauncherMods = require("src.mods.LauncherMods") local LauncherMods = require("src.mods.LauncherMods")
LauncherMods.setEnabled(dep.id, false, imp.modScope) LauncherMods.setEnabled(dep.id, false, imp.modScope)
+80 -3
View File
@@ -136,6 +136,9 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
-- costs nothing on a current cache and is the difference between every -- costs nothing on a current cache and is the difference between every
-- trainer battle opening with a picture and opening with none. -- trainer battle opening with a picture and opening with none.
"assets/generated/battle/trainers/falkner.png", "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", "assets/generated/audio/programs.bin",
}, },
} }
@@ -326,6 +329,32 @@ function RomImporter.isReady(version)
return marker == markerFor(version) and allRequiredFilesExist(version) return marker == markerFor(version) and allRequiredFilesExist(version)
end 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. -- Load the import manifest for a version and confirm it matches that ROM.
local function sha1(data) local function sha1(data)
local digest = love.data.hash("sha1", data) local digest = love.data.hash("sha1", data)
@@ -1393,6 +1422,7 @@ function RomImporter.new(onComplete, opts)
self.romName[version] = "pokemon_" .. info.id self.romName[version] = "pokemon_" .. info.id
.. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb") .. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb")
end end
RomImporter.syncAndroidShortcuts()
self:_applyLastVersionTab() self:_applyLastVersionTab()
self:_queueBaseRomScan() self:_queueBaseRomScan()
@@ -1787,6 +1817,7 @@ function RomImporter:_completeImport(version, prefix, displayName)
self.workState = "complete" self.workState = "complete"
self.completeVersion = version self.completeVersion = version
self.status = "Ready" self.status = "Ready"
RomImporter.syncAndroidShortcuts(version)
-- NX launcher stays put: keep the imports/ cleanup hint instead of -- NX launcher stays put: keep the imports/ cleanup hint instead of
-- overwriting it with a "Starting…" line that never boots from here. -- overwriting it with a "Starting…" line that never boots from here.
if self.launcher and self.isNX and type(displayName) == "string" then 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 -- 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). -- option block, and Gold's is not the flat Gen 1 one (#1100).
local hooks = {} local hooks = {}
local version = self.tab
hooks.reportIssue = function(opts)
return self:_reportIssue(opts, version)
end
if self.onEditTouchControls then if self.onEditTouchControls then
local version = self.tab local version = self.tab
hooks.editTouchControls = function() hooks.editTouchControls = function()
@@ -3615,11 +3650,13 @@ function RomImporter:_openSettings()
-- The tab the gear was opened on decides the row set: Gold reads a -- 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 -- different option block entirely, and offering it Gen 1's rows meant a
-- dozen controls that changed nothing (see LauncherSettings.gen2Rows). -- dozen controls that changed nothing (see LauncherSettings.gen2Rows).
local version = self.tab
local ok, model = pcall(function() local ok, model = pcall(function()
return require("src.import.LauncherSettings").open(hooks, version) return require("src.import.LauncherSettings").open(hooks, version)
end) 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 end
-- Quit from the launcher's own X. It goes through love.event.quit so main.lua's -- 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 end
function RomImporter:_closeSettings() 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._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 end
function RomImporter:_commitSettingsText() function RomImporter:_commitSettingsText()
@@ -3971,6 +4038,8 @@ end
-- so a still list costs nothing after the first paint. -- so a still list costs nothing after the first paint.
function RomImporter:_refreshMods() function RomImporter:_refreshMods()
local LauncherMods = require("src.mods.LauncherMods") 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 -- Once per session, ahead of the first listing: pull in any mod the player
-- unzipped beside the executable, which an ordinary (non-portable) install -- unzipped beside the executable, which an ordinary (non-portable) install
-- has no way to read. It happens here rather than behind a button because -- 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. -- so that game's checkbox and status chips reflect the new resolution.
-- Enabling an experimental mod arms a confirmation for that same game. -- Enabling an experimental mod arms a confirmation for that same game.
function RomImporter:_toggleMod(id, confirmed, version) 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 LauncherMods = require("src.mods.LauncherMods")
local cur, experimental = false, false local cur, experimental = false, false
for _, m in ipairs(self.mods or {}) do 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 -- 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. -- recovery action, and Delete is the only destructive one on this panel.
function RomImporter:_setAllMods(want, confirmed) 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 LauncherMods = require("src.mods.LauncherMods")
local ids, experimental = {}, false local ids, experimental = {}, false
for _, m in ipairs(self.mods or {}) do for _, m in ipairs(self.mods or {}) do
+20 -7
View File
@@ -291,6 +291,7 @@ function LauncherMods.deriveList(manifests, options, version)
local ordered = {} local ordered = {}
for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end
table.sort(ordered, function(a, b) return a.id < b.id 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 override is one answer per game (SaveData.modForced), the same scope
-- the loader resolves it under -- 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 -- matching the loader -- except experimental mods, which stay off until
-- the player opts in. Scoped through modScope, so this reads exactly what -- the player opts in. Scoped through modScope, so this reads exactly what
-- setEnabled writes and the loader loads for the selected game. -- setEnabled writes and the loader loads for the selected game.
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version)) if not safeMode then
if decided == nil then decided = not m.experimental end local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
if decided then enabledSet[m.id] = true end if decided == nil then decided = not m.experimental end
if decided then enabledSet[m.id] = true end
end
end end
local out = {} local out = {}
for _, m in ipairs(ordered) do 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 forced = forcedFor(m.id)
local status, detail = local status, detail
statusFor(byId, m.id, enabledSet, enabled, version, forcedFor) 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 -- nil, not false, when the panel is showing every game at once
local here = nil local here = nil
if version then here = ModTargets.runsHere(m, version, nil, forced) end if version then here = ModTargets.runsHere(m, version, nil, forced) end
@@ -332,7 +340,8 @@ function LauncherMods.deriveList(manifests, options, version)
local answers = {} local answers = {}
for _, game in ipairs(GameVersion.ORDER) do for _, game in ipairs(GameVersion.ORDER) do
local answer = SaveData.modEnabled(options, m.id, game) 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 end
return answers return answers
end)(), end)(),
@@ -346,6 +355,7 @@ function LauncherMods.deriveList(manifests, options, version)
-- panel is showing (src/mods/ModTargets.lua) -- panel is showing (src/mods/ModTargets.lua)
targets = ModTargets.chip(m), targets = ModTargets.chip(m),
targetsHere = here, targetsHere = here,
safeMode = safeMode,
} }
end end
return out return out
@@ -586,6 +596,7 @@ end
-- answer. The loader and the in-game manager use the same scope on next boot. -- answer. The loader and the in-game manager use the same scope on next boot.
function LauncherMods.setEnabled(id, enabled, version) function LauncherMods.setEnabled(id, enabled, version)
local options = SaveData.loadOptions() local options = SaveData.loadOptions()
if SaveData.isSafeMode(options) then return false end
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version)) SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
SaveData.saveOptions(options) SaveData.saveOptions(options)
LauncherMods.syncActiveProfile(options) LauncherMods.syncActiveProfile(options)
@@ -599,6 +610,7 @@ end
-- and leaves a half-applied state behind if one of them fails. -- and leaves a half-applied state behind if one of them fails.
function LauncherMods.setAllEnabled(ids, enabled, version) function LauncherMods.setAllEnabled(ids, enabled, version)
local options = SaveData.loadOptions() local options = SaveData.loadOptions()
if SaveData.isSafeMode(options) then return false end
local scope = SaveData.modScope(version) local scope = SaveData.modScope(version)
for _, id in ipairs(ids or {}) do for _, id in ipairs(ids or {}) do
if scope then if scope then
@@ -1184,6 +1196,7 @@ end
function LauncherMods.applyProfile(profileName, options) function LauncherMods.applyProfile(profileName, options)
options = options or SaveData.loadOptions() options = options or SaveData.loadOptions()
if SaveData.isSafeMode(options) then return false end
local profiles = options.modProfiles or {} local profiles = options.modProfiles or {}
local targetProfile local targetProfile
for _, p in ipairs(profiles) do for _, p in ipairs(profiles) do
+10 -1
View File
@@ -259,6 +259,7 @@ function Loader.new(opts)
modInput = {}, modEnv = {}, stepsQueues = {}, modInput = {}, modEnv = {}, stepsQueues = {},
fs = (opts and opts.fs) or (love and love.filesystem), fs = (opts and opts.fs) or (love and love.filesystem),
dev = dev, dev = dev,
safeMode = false,
-- Which generation this boot is (1 or 2). Fixed at construction: the -- Which generation this boot is (1 or 2). Fixed at construction: the
-- active version is set once in main.lua's bootGame before anything -- active version is set once in main.lua's bootGame before anything
-- builds a loader, and a run never changes generation underneath one. -- builds a loader, and a run never changes generation underneath one.
@@ -300,6 +301,8 @@ end
function Loader:_loadState() function Loader:_loadState()
self.disabled = {} self.disabled = {}
local options = SaveData.loadOptions(self.fs) local options = SaveData.loadOptions(self.fs)
self.safeMode = SaveData.isSafeMode(options)
Runtime.safeMode = self.safeMode
local scope = self:_enableScope() local scope = self:_enableScope()
local ids = {} local ids = {}
for id in pairs(options.mods or {}) do ids[id] = true end for id in pairs(options.mods or {}) do ids[id] = true end
@@ -415,6 +418,7 @@ function Loader:_writeOptionSchemas()
end end
function Loader:setEnabled(id, enabled) function Loader:setEnabled(id, enabled)
if self.safeMode then return false end
if not self.mods[id] then return false end if not self.mods[id] then return false end
self.disabled[id] = not enabled self.disabled[id] = not enabled
self.mods[id].enabled = 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 -- choice could not be persisted for a game, so the caller does not promise a
-- restart will honour it. -- restart will honour it.
function Loader:setGen2Forced(id, forced) function Loader:setGen2Forced(id, forced)
if self.safeMode then return false, false end
if not self.mods[id] then return false, false end if not self.mods[id] then return false, false end
self.gen2Forced[id] = forced or nil self.gen2Forced[id] = forced or nil
self:_saveState() self:_saveState()
@@ -1539,6 +1544,9 @@ function Loader:load(data)
require("src.mods.Builtins").install(self.content, data, self.generation) require("src.mods.Builtins").install(self.content, data, self.generation)
self:_loadState() self:_loadState()
self:_discover() 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 -- Existing installs stored one shared answer. Once their manifests are
-- known, split that answer across every game before the next launcher/game -- known, split that answer across every game before the next launcher/game
-- toggle can change one independently. _loadState already used the same -- 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. -- the one build where its env var is set.
for id, mod in pairs(self.mods) do for id, mod in pairs(self.mods) do
local envName = mod.manifest.force_enable_env 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 self.disabled[id] = nil
end end
end end
@@ -1740,6 +1748,7 @@ function Loader:status()
local manifest = {} local manifest = {}
for key, value in pairs(mod.manifest) do manifest[key] = value end for key, value in pairs(mod.manifest) do manifest[key] = value end
manifest.enabled = mod.enabled ~= false manifest.enabled = mod.enabled ~= false
manifest.safeMode = self.safeMode == true
manifest.state = mod.state or (manifest.enabled and "loaded" or "disabled") manifest.state = mod.state or (manifest.enabled and "loaded" or "disabled")
manifest.error = mod.failure manifest.error = mod.failure
-- set instead of `error` when the mod was left out for a reason that is -- set instead of `error` when the mod was left out for a reason that is
+37 -4
View File
@@ -365,9 +365,13 @@ end
function ManagerState:detailRows(m) function ManagerState:detailRows(m)
local rows = {} local rows = {}
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE", if Runtime.safeMode then
action = function() self:beginToggle(m) end } rows[#rows + 1] = { label = "SAFE MODE ACTIVE", inert = true }
if self:schemaFor(m) then 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.."), rows[#rows + 1] = { label = Strings("OPTIONS.."),
action = function() self:openOptions(m) end } action = function() self:openOptions(m) end }
end end
@@ -383,7 +387,8 @@ function ManagerState:detailRows(m)
-- what this mod does. -- what this mod does.
local loader = self.game.mods local loader = self.game.mods
local version, gen = self:targetGame() 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] = { rows[#rows + 1] = {
label = m.gen2Forced and Strings("DON'T TRY HERE") or Strings("TRY HERE ANYWAY"), label = m.gen2Forced and Strings("DON'T TRY HERE") or Strings("TRY HERE ANYWAY"),
action = function() self:toggleGen2Force(m) end } action = function() self:toggleGen2Force(m) end }
@@ -674,6 +679,10 @@ end
-- ------- the enable/disable flow -- ------- the enable/disable flow
function ManagerState:beginToggle(m) function ManagerState:beginToggle(m)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
if not m then return end if not m then return end
local want = not m.enabled local want = not m.enabled
local loader = self.game.mods 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 -- 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. -- memory only, which the notice says rather than promising a restart.
function ManagerState:toggleGen2Force(m) function ManagerState:toggleGen2Force(m)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
local loader = self.game.mods local loader = self.game.mods
if not (loader and loader.setGen2Forced) then return end if not (loader and loader.setGen2Forced) then return end
local want = not m.gen2Forced local want = not m.gen2Forced
@@ -743,6 +756,10 @@ function ManagerState:enableScope()
end end
function ManagerState:commitToggle(apply) function ManagerState:commitToggle(apply)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
local loader = self.game.mods local loader = self.game.mods
local opts = self:optionsTable() local opts = self:optionsTable()
local scope = self:enableScope() local scope = self:enableScope()
@@ -757,6 +774,10 @@ function ManagerState:commitToggle(apply)
end end
function ManagerState:discardChanges() function ManagerState:discardChanges()
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
local loader = self.game.mods local loader = self.game.mods
local opts = self:optionsTable() local opts = self:optionsTable()
local scope = self:enableScope() local scope = self:enableScope()
@@ -802,6 +823,10 @@ function ManagerState:persistOptions()
end end
function ManagerState:applyProfile(p) function ManagerState:applyProfile(p)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
local mods = self:manifestMap() local mods = self:manifestMap()
local set = self:enabledSet() local set = self:enabledSet()
local combined = {} local combined = {}
@@ -963,6 +988,10 @@ function ManagerState:optionValue(modId, row)
end end
function ManagerState:setOption(modId, key, value) function ManagerState:setOption(modId, key, value)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return false
end
local save = self.game.save local save = self.game.save
if save and save.options then if save and save.options then
save.options.modOptions = save.options.modOptions or {} save.options.modOptions = save.options.modOptions or {}
@@ -1123,6 +1152,10 @@ function ManagerState:buildOptionRows(m, schema)
end end
function ManagerState:openOptions(m) function ManagerState:openOptions(m)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
local schema = self:schemaFor(m) local schema = self:schemaFor(m)
if not schema then if not schema then
self:notify("NO OPTIONS") self:notify("NO OPTIONS")
+10
View File
@@ -33,11 +33,21 @@ Runtime.currentMod = nil
-- currentMod went back to nil (src/mods/Sandbox.lua) -- currentMod went back to nil (src/mods/Sandbox.lua)
Runtime.modRequire = nil Runtime.modRequire = nil
Runtime.safeMode = false
function Runtime.install(events, hooks, errors) function Runtime.install(events, hooks, errors)
Runtime.events, Runtime.hooks = events, hooks Runtime.events, Runtime.hooks = events, hooks
Runtime.errors = errors Runtime.errors = errors
end 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. -- 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 -- "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. -- console line, not something the manager can ask the player to disable.
+2 -2
View File
@@ -180,8 +180,8 @@ local function release(game)
and mon.ot == game.save.player.name then and mon.ot == game.save.player.name then
require("src.core.Sound").playCry(game.data, mon.species) require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
(t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name)) ((t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name))
:gsub("{RAM:wNameBuffer}", name))) :gsub("{RAM:wNameBuffer}", name))))
return return
end end
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
+2 -1
View File
@@ -18,6 +18,7 @@ local Theme = require("src.ui.Theme")
local FieldDefaults = require("src.world.FieldDefaults") local FieldDefaults = require("src.world.FieldDefaults")
local Map = require("src.world.Map") local Map = require("src.world.Map")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local Status = require("src.battle.Status")
local PartyMenu = {} local PartyMenu = {}
PartyMenu.__index = PartyMenu PartyMenu.__index = PartyMenu
@@ -821,7 +822,7 @@ function PartyMenu:draw()
if mon.hp <= 0 then if mon.hp <= 0 then
Font.draw(Strings("FNT"), 136, y) Font.draw(Strings("FNT"), 136, y)
elseif mon.status then elseif mon.status then
Font.draw(mon.status, 136, y) Font.draw(Status.hudLabelFor(self.game.data.statuses, mon.status), 136, y)
end end
-- the tile HP bar (DrawHP2 + SetPartyMenuHPBarColor). grayFill: -- the tile HP bar (DrawHP2 + SetPartyMenuHPBarColor). grayFill:
-- tinting the fill AND running it through the row's zone -- tinting the fill AND running it through the row's zone
+2 -1
View File
@@ -14,6 +14,7 @@ local Font = require("src.render.Font")
local TypeChart = require("src.battle.TypeChart") local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local Stats = require("src.pokemon.Stats") local Stats = require("src.pokemon.Stats")
local Status = require("src.battle.Status")
local SummaryMenu = {} local SummaryMenu = {}
SummaryMenu.__index = SummaryMenu SummaryMenu.__index = SummaryMenu
@@ -145,7 +146,7 @@ function SummaryMenu:draw()
HudTiles.drawHPBar(data, 11, 3, mon, 1, barZoned) -- wHPBarType 1 HudTiles.drawHPBar(data, 11, 3, mon, 1, barZoned) -- wHPBarType 1
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32) Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32)
Font.draw(Strings("STATUS/"), 72, 48) 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 -- stats box (0,8) 10x10: names rows 9/11/13/15, values indented
Font.drawBox(0, 8, 10, 10) Font.drawBox(0, 8, 10, 10)
+4 -1
View File
@@ -198,6 +198,7 @@ function TitleState.new(game, opts)
self.game = game self.game = game
self.onNewGame = opts.onNewGame self.onNewGame = opts.onNewGame
self.onContinue = opts.onContinue self.onContinue = opts.onContinue
self.onExit = opts.onExit
-- branding comes from field.title with the shipped art as fallback, so -- branding comes from field.title with the shipped art as fallback, so
-- a total conversion rebrands the title without replacing the screen -- a total conversion rebrands the title without replacing the screen
local title = (game.data.field and game.data.field.title) or {} 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") require("src.ui.Screens").push(game, "OptionsMenu")
end }) end })
table.insert(items, { label = Strings("EXIT GAME"), onSelect = function() 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() love.event.quit()
end end
end }) end })
+3
View File
@@ -2290,6 +2290,7 @@ function BattleState:openParty(forced)
-- :2702; engine/pokemon/party_menu.asm:660-679). Only the voluntary list -- :2702; engine/pokemon/party_menu.asm:660-679). Only the voluntary list
-- carries BattleMonMenu; PickPartyMonInBattle has no submenu. -- carries BattleMonMenu; PickPartyMonInBattle has no submenu.
prompt = forced and "which" or "choose", prompt = forced and "which" or "choose",
battle = true,
battleSubmenu = not forced, battleSubmenu = not forced,
onCancel = function() onCancel = function()
stack:pop() stack:pop()
@@ -2762,6 +2763,7 @@ function BattleState:openShiftParty()
self.phase = "submenu" self.phase = "submenu"
Screens.push(self.game, "Gen2PartyMenu", { Screens.push(self.game, "Gen2PartyMenu", {
prompt = "which", prompt = "which",
battle = true,
onCancel = function() onCancel = function()
stack:pop() stack:pop()
self.phase = "resolving" self.phase = "resolving"
@@ -3135,6 +3137,7 @@ function BattleState:useOnPartyMon(itemId, action)
self.phase = "submenu" self.phase = "submenu"
Screens.push(self.game, "Gen2PartyMenu", { Screens.push(self.game, "Gen2PartyMenu", {
prompt = "useItem", prompt = "useItem",
battle = true,
party = self.battle.party or (self.save and self.save.party), party = self.battle.party or (self.save and self.save.party),
onCancel = function() onCancel = function()
stack:pop() stack:pop()
+37 -1
View File
@@ -76,6 +76,23 @@ local BATTLE_SUBMENU_LEFT, BATTLE_SUBMENU_TOP = 11, 11
-- HP bar is 6 tiles wide (48px) in the party list. -- 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:wantsFillScale() return true end
function PartyMenu:drawsWidescreen() return true end function PartyMenu:drawsWidescreen() return true end
@@ -114,6 +131,7 @@ function PartyMenu.new(game, opts)
self.wantsSubmenu = opts.submenu == true self.wantsSubmenu = opts.submenu == true
-- BattleMenu_PKMN's `callfar BattleMonMenu` (engine/battle/core.asm:4810). -- BattleMenu_PKMN's `callfar BattleMonMenu` (engine/battle/core.asm:4810).
self.wantsBattleSubmenu = opts.battleSubmenu == true self.wantsBattleSubmenu = opts.battleSubmenu == true
self.battle = opts.battle == true
self.submenu = nil self.submenu = nil
-- The held slot while SwitchPartyMons' second pick is open; nil otherwise. -- The held slot while SwitchPartyMons' second pick is open; nil otherwise.
self.switchFrom = nil self.switchFrom = nil
@@ -146,6 +164,13 @@ function PartyMenu:isCancel()
return self.index > #self.party return self.index > #self.party
end 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 -- ------------------------------------------------------------- mon submenu
-- GetMonSubmenuItems, in its own order: every field move the mon knows first, -- GetMonSubmenuItems, in its own order: every field move the mon knows first,
@@ -477,7 +502,18 @@ function PartyMenu:update(_dt)
return return
end end
local total = self:count() 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 self.index = self.index > 1 and self.index - 1 or total
elseif input:wasPressed("down") then elseif input:wasPressed("down") then
self.index = self.index < total and self.index + 1 or 1 self.index = self.index < total and self.index + 1 or 1
@@ -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")
@@ -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)")
+2 -1
View File
@@ -397,7 +397,8 @@ local GEN2_HOOKS = {
"world.tod", "map.palette", "fieldmove.eligibility", "world.tod", "map.palette", "fieldmove.eligibility",
-- menus and the battle intro -- menus and the battle intro
"ui.start_menu.items", "ui.title_menu.items", "ui.options.rows", "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", "transition.style",
-- battle -- battle
"battle.damage", "battle.crit", "battle.accuracy", "battle.turn_order", "battle.damage", "battle.crit", "battle.accuracy", "battle.turn_order",
@@ -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")
@@ -26,5 +26,7 @@ check(helperStart ~= nil, "requiredFilesFor helper exists")
local helper = src:sub(helperStart, start) local helper = src:sub(helperStart, start)
check(helper:find("VERSION_REQUIRED_FILES_OVERRIDE", 1, true) ~= nil, check(helper:find("VERSION_REQUIRED_FILES_OVERRIDE", 1, true) ~= nil,
"requiredFilesFor consults VERSION_REQUIRED_FILES_OVERRIDE") "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() T.finish()
+137
View File
@@ -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()
@@ -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")
+11
View File
@@ -14,6 +14,7 @@ local NamingScreen = require("src.ui.NamingScreen")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox") local ChoiceBox = require("src.ui.ChoiceBox")
local PartyMenu = require("src.ui.PartyMenu") local PartyMenu = require("src.ui.PartyMenu")
local Gen2PartyMenu = require("src.ui.gen2.PartyMenu")
local Player = require("src.world.Player") local Player = require("src.world.Player")
local Music = require("src.core.Music") local Music = require("src.core.Music")
@@ -295,6 +296,16 @@ do
menu:update(0) menu:update(0)
check(menu.index == 4, check(menu.index == 4,
"removing the hook restores native list navigation immediately") "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 end
-- ------- music.volume (distance / indoor muffling) -- ------- music.volume (distance / indoor muffling)
+196
View File
@@ -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()