android fixes for mods and saves (#297)

This commit is contained in:
bryanthaboi
2026-07-27 09:05:57 -04:00
committed by GitHub
parent be90e5b42c
commit 25df838b45
17 changed files with 787 additions and 95 deletions
+47 -15
View File
@@ -5,19 +5,39 @@ that runs before `Game:load`. Besides ROM import (see the file's own header)
it hosts a tabbed shell covering per-game save slots and a mod manager. This it hosts a tabbed shell covering per-game save slots and a mod manager. This
file documents the runtime model; the visual spec lives separately. file documents the runtime model; the visual spec lives separately.
## Android multi-ROM import ## Android multi-ROM / mod / save import
On Android, `love.system.pickFile()` opens the Storage Access Framework On Android, `love.system.pickFile([kind])` opens the Storage Access Framework
picker (`GameActivity.showRomFilePicker`); the chosen file is copied to picker (`GameActivity.showFilePicker`); the chosen file is copied into the app
`picked_rom.gb` in the app save directory. `RomImporter` then imports on save directory as:
refocus / Choose via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps
to a version that is **not** yet ready counts as pending. A leftover | `kind` | Destination |
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167). | --- | --- |
After a successful import the consumed save-dir `.gb` is removed. | nil / `"rom"` | `picked_rom.gb` (open) |
| `"mod"` | `picked_mod.zip` (open) |
| `"sav"` / `"save"` | `picked_save.sav` (open) |
Export uses a separate API: `love.system.createFile(suggestedName)`
`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies
staged `pending_export.sav` to the user-chosen URI and writes `export_done.flag`
for the launcher to acknowledge on refocus.
`RomImporter` then imports on refocus / Choose:
- **ROMs** via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps to a
version that is **not** yet ready counts as pending. A leftover
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
- **Mods** via `findPendingMod`: Prefer `picked_mod.zip`, or (on Choose) any
other `.zip` at the save-dir root (USB copy).
- **Saves** via `findPendingSav`: Prefer `picked_save.sav`, or (on Choose) any
other `.sav` at the save-dir root.
After a successful import the consumed save-dir file is removed.
**Manual check (device/emulator):** import Red → switch to Blue → Choose → **Manual check (device/emulator):** import Red → switch to Blue → Choose →
system file picker must appear (not a silent Red re-extract) → pick Blue → system file picker must appear (not a silent Red re-extract) → pick Blue →
Blue becomes ready beside Red. Blue becomes ready beside Red. On the MODS tab, Import mod .zip must open the
same system picker and install the chosen archive on return.
## Tab structure ## Tab structure
@@ -78,6 +98,10 @@ The launcher-facing API:
- `SaveData.createSlot(version)` -> new slot id, registered but with **no - `SaveData.createSlot(version)` -> new slot id, registered but with **no
save file written**. An empty slot means the title screen offers NEW GAME save file written**. An empty slot means the title screen offers NEW GAME
only, which needs no further changes. only, which needs no further changes.
- `SaveData.deleteSlot(version, slotId)` removes the slot's
main/`.bak`/`.tmp` files, drops it from the registry, and if it was active
points active at another remaining slot (or clears active when the list is
empty). The launcher's SAVE SLOT panel Delete control calls this.
## Launcher mod manager ## Launcher mod manager
@@ -114,6 +138,9 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
temp first (mount only reaches save-dir-relative paths), the same way temp first (mount only reaches save-dir-relative paths), the same way
`RomImporter` handles a dropped ROM. A failed copy rolls its partial tree `RomImporter` handles a dropped ROM. A failed copy rolls its partial tree
back, and every path unmounts and clears the staged temp file. back, and every path unmounts and clears the staged temp file.
- `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears
`options.mods[id]` so a later reinstall starts from the loader's default
(enabled). The mods panel Delete control calls this and re-derives the list.
## Import / Export save ## Import / Export save
@@ -122,9 +149,10 @@ through `src/import/SaveFileIO.lua`, which sits on top of
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`. `src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
- **Import save** is live once the game's ROM is imported (playable). It opens - **Import save** is live once the game's ROM is imported (playable). It opens
a native `.sav` picker (`chooseSav`, the per-OS dialogs mirror `chooseZip`; a native `.sav` picker (`chooseSav` on desktop; on Android,
Android has no picker and shows a drop hint). `SaveFileIO.importToSlot` `love.system.pickFile("sav")``picked_save.sav`, same SAF path as ROMs).
reads the bytes (an absolute path, a dropped LOVE file, or raw bytes), `SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir
relative name, a dropped LOVE file, or raw bytes),
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`), a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`),
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`). writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
@@ -136,9 +164,13 @@ through `src/import/SaveFileIO.lua`, which sits on top of
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
`rawImport`, so this is a zero-filled template export, which is valid), and `rawImport`, so this is a zero-filled template export, which is valid), and
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
(`love.filesystem.createDirectory("exports")`). It returns the absolute path (`love.filesystem.createDirectory("exports")`). On desktop it returns the
(`love.filesystem.getSaveDirectory()`), which the notice line shows with a absolute path (`love.filesystem.getSaveDirectory()`), which the notice line
desktop "Open folder" affordance (`love.system.openURL("file://" .. dir)`). shows with an "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
On Android the bytes are also staged as `pending_export.sav` and
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
player can save to Downloads / Drive / etc.; on return `export_done.flag`
makes focus show "Save exported."
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the - **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
currently active game tab; when a non-game tab (mods, or the locked yellow currently active game tab; when a non-game tab (mods, or the locked yellow
placeholder) is showing it defaults to red, the always-present first game placeholder) is showing it defaults to red, the always-present first game
+5 -5
View File
@@ -43,11 +43,11 @@ The embedded `game.love` deliberately excludes `data/generated/`,
`assets/generated/`, and any ROM. It contains the first-boot Lua importer and `assets/generated/`, and any ROM. It contains the first-boot Lua importer and
`tools/rom_manifest.json`. `tools/rom_manifest.json`.
ROM import on Android uses `love.system.pickFile()` ROM / mod / save import on Android uses `love.system.pickFile([kind])`
`GameActivity.showRomFilePicker` (Storage Access Framework), which copies the `GameActivity.showFilePicker` (Storage Access Framework), which copies the
chosen file to `picked_rom.gb` under the app save directory. `RomImporter` chosen file under the app save directory as `picked_rom.gb`,
imports pending (not-yet-ready) `.gb` files from that folder on Choose / `picked_mod.zip`, or `picked_save.sav`. `RomImporter` imports pending files
refocus; see `docs/launcher.md` (Android multi-ROM import). The APK payload from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
itself remains data-free (no embedded ROM or generated cache). itself remains data-free (no embedded ROM or generated cache).
### SDK / NDK ### SDK / NDK
+1 -1
View File
@@ -18,4 +18,4 @@ android.useAndroidX=true
android.defaults.buildfeatures.buildconfig=true android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
android.nonFinalResIds=true android.nonFinalResIds=true
app.name=Pokemon Red app.name=gen1recomp
@@ -183,13 +183,37 @@ void vibrate(double seconds)
env->DeleteLocalRef(activity); env->DeleteLocalRef(activity);
} }
bool showFilePicker() bool showFilePicker(const char *destFilename)
{ {
if (destFilename == nullptr || destFilename[0] == '\0')
destFilename = "picked_rom.gb";
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity"); jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "showRomFilePicker", "()Z"); jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
jboolean result = env->CallStaticBooleanMethod(activity, method); "(Ljava/lang/String;)Z");
jstring jname = env->NewStringUTF(destFilename);
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
env->DeleteLocalRef(jname);
env->DeleteLocalRef(activity);
return result;
}
bool showCreateDocument(const char *suggestedName)
{
if (suggestedName == nullptr || suggestedName[0] == '\0')
suggestedName = "export.sav";
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument",
"(Ljava/lang/String;)Z");
jstring jname = env->NewStringUTF(suggestedName);
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
env->DeleteLocalRef(jname);
env->DeleteLocalRef(activity); env->DeleteLocalRef(activity);
return result; return result;
@@ -63,9 +63,16 @@ void vibrate(double seconds);
* Shows the system's "pick a document" UI (Storage Access Framework). * Shows the system's "pick a document" UI (Storage Access Framework).
* Returns true if the picker was launched; the picked file (if any) is * Returns true if the picker was launched; the picked file (if any) is
* copied asynchronously by GameActivity.onActivityResult into the app's * copied asynchronously by GameActivity.onActivityResult into the app's
* external save directory, not returned here -- see src/import/RomImporter.lua. * external save directory under destFilename (default picked_rom.gb), not
* returned here -- see src/import/RomImporter.lua.
**/ **/
bool showFilePicker(); bool showFilePicker(const char *destFilename = nullptr);
/**
* Shows ACTION_CREATE_DOCUMENT so Lua can export a staged pending_export.sav
* to a user-chosen location. suggestedName is the dialog default filename.
**/
bool showCreateDocument(const char *suggestedName = nullptr);
/* /*
* Helper functions for the filesystem module * Helper functions for the filesystem module
@@ -22,6 +22,8 @@
#include "common/config.h" #include "common/config.h"
#include "System.h" #include "System.h"
#include <cstring>
#if defined(LOVE_MACOSX) #if defined(LOVE_MACOSX)
#include <CoreServices/CoreServices.h> #include <CoreServices/CoreServices.h>
#elif defined(LOVE_IOS) #elif defined(LOVE_IOS)
@@ -180,11 +182,32 @@ void System::vibrate(double seconds) const
#endif #endif
} }
bool System::pickFile() const bool System::pickFile(const char *kind) const
{ {
#ifdef LOVE_ANDROID #ifdef LOVE_ANDROID
return love::android::showFilePicker(); const char *dest = "picked_rom.gb";
if (kind != nullptr)
{
if (strcmp(kind, "mod") == 0)
dest = "picked_mod.zip";
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
dest = "picked_save.sav";
else if (strcmp(kind, "rom") == 0)
dest = "picked_rom.gb";
}
return love::android::showFilePicker(dest);
#else #else
LOVE_UNUSED(kind);
return false;
#endif
}
bool System::createFile(const char *suggestedName) const
{
#ifdef LOVE_ANDROID
return love::android::showCreateDocument(suggestedName);
#else
LOVE_UNUSED(suggestedName);
return false; return false;
#endif #endif
} }
@@ -111,9 +111,21 @@ public:
* Android only for now; the result (if any) is not returned here -- see * Android only for now; the result (if any) is not returned here -- see
* love::android::showFilePicker and src/import/RomImporter.lua. * love::android::showFilePicker and src/import/RomImporter.lua.
* *
* @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb,
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav.
* @return Whether the picker was shown. * @return Whether the picker was shown.
**/ **/
virtual bool pickFile() const; virtual bool pickFile(const char *kind = nullptr) const;
/**
* Shows the platform's native "create / save a file" UI (Android SAF
* ACTION_CREATE_DOCUMENT). Copies staged pending_export.sav from the app
* save directory to the user-chosen URI. See GameActivity.showCreateDocument.
*
* @param suggestedName Default filename shown in the dialog.
* @return Whether the create dialog was shown.
**/
virtual bool createFile(const char *suggestedName = nullptr) const;
/** /**
* Gets if the user is playing music on background. * Gets if the user is playing music on background.
@@ -97,7 +97,15 @@ int w_vibrate(lua_State *L)
int w_pickFile(lua_State *L) int w_pickFile(lua_State *L)
{ {
luax_pushboolean(L, instance()->pickFile()); const char *kind = luaL_optstring(L, 1, nullptr);
luax_pushboolean(L, instance()->pickFile(kind));
return 1;
}
int w_createFile(lua_State *L)
{
const char *suggested = luaL_optstring(L, 1, nullptr);
luax_pushboolean(L, instance()->createFile(suggested));
return 1; return 1;
} }
@@ -117,6 +125,7 @@ static const luaL_Reg functions[] =
{ "openURL", w_openURL }, { "openURL", w_openURL },
{ "vibrate", w_vibrate }, { "vibrate", w_vibrate },
{ "pickFile", w_pickFile }, { "pickFile", w_pickFile },
{ "createFile", w_createFile },
{ "hasBackgroundMusic", w_hasBackgroundMusic }, { "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 } { 0, 0 }
}; };
@@ -22,12 +22,15 @@ package org.love2d.android;
import org.libsdl.app.SDLActivity; import org.libsdl.app.SDLActivity;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream; import java.io.BufferedOutputStream;
import java.io.File; import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@@ -61,13 +64,24 @@ public class GameActivity extends SDLActivity {
protected final int[] recordAudioRequestDummy = new int[1]; protected final int[] recordAudioRequestDummy = new int[1];
public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2; public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
public static final int RECORD_AUDIO_REQUEST_CODE = 3; public static final int RECORD_AUDIO_REQUEST_CODE = 3;
public static final int ROM_PICKER_REQUEST_CODE = 4; public static final int FILE_PICKER_REQUEST_CODE = 4;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked ROM public static final int FILE_CREATE_REQUEST_CODE = 5;
/** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
// is dropped so RomImporter's existing folder scan finds it -- see // is dropped so RomImporter's existing folder scan finds it -- see
// src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's // src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's
// save directory to getExternalFilesDir()/save/<identity>). // save directory to getExternalFilesDir()/save/<identity>).
private static final String ROM_SAVE_IDENTITY = "pokemon-love2d"; private static final String ROM_SAVE_IDENTITY = "pokemon-love2d";
private static final String PICKED_ROM_FILENAME = "picked_rom.gb"; private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
private static final String PICKED_MOD_FILENAME = "picked_mod.zip";
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
// Destination basename for the in-flight SAF pick (set by showFilePicker).
private String pendingPickFilename = PICKED_ROM_FILENAME;
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
private String pendingCreateSuggestedName = "export.sav";
private static boolean immersiveActive = false; private static boolean immersiveActive = false;
private static boolean needToCopyGameInArchive = false; private static boolean needToCopyGameInArchive = false;
private boolean storagePermissionUnnecessary = false; private boolean storagePermissionUnnecessary = false;
@@ -341,59 +355,185 @@ public class GameActivity extends SDLActivity {
/** /**
* Shows the system document picker (Storage Access Framework) so the * Shows the system document picker (Storage Access Framework) so the
* player can pick their ROM from anywhere (Downloads, Drive, etc.) * player can pick a ROM / mod / save from anywhere (Downloads, Drive,
* without needing to know where the app's external files folder is. * etc.) without needing to know where the app's external files folder
* Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any) * is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
* arrives later in onActivityResult, not synchronously here. * arrives later in onActivityResult, not synchronously here.
*
* @param destFilename basename under the app save identity (e.g.
* picked_rom.gb, picked_mod.zip, picked_save.sav)
*/ */
@Keep @Keep
public static boolean showRomFilePicker() { public static boolean showFilePicker(String destFilename) {
if (android.os.Build.VERSION.SDK_INT < 19) return false; if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton; GameActivity self = (GameActivity) mSingleton;
if (self == null) return false; if (self == null) return false;
if (destFilename == null || destFilename.length() == 0) {
destFilename = PICKED_ROM_FILENAME;
}
// Reject path separators so a hostile JNI caller cannot escape the
// save identity directory.
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
return false;
}
self.pendingPickFilename = destFilename;
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE); intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*"); intent.setType("*/*");
try { try {
self.startActivityForResult(intent, ROM_PICKER_REQUEST_CODE); self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
return true; return true;
} catch (Exception e) { } catch (Exception e) {
Log.d("GameActivity", "could not open ROM file picker: " + e.getMessage()); Log.d("GameActivity", "could not open file picker: " + e.getMessage());
return false; return false;
} }
} }
/** ROM convenience wrapper; prefer showFilePicker with an explicit name. */
@Keep
public static boolean showRomFilePicker() {
return showFilePicker(PICKED_ROM_FILENAME);
}
/** Mod .zip convenience wrapper used by love.system.pickFile("mod"). */
@Keep
public static boolean showModFilePicker() {
return showFilePicker(PICKED_MOD_FILENAME);
}
/** Battery .sav convenience wrapper used by love.system.pickFile("sav"). */
@Keep
public static boolean showSaveFilePicker() {
return showFilePicker(PICKED_SAVE_FILENAME);
}
/**
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
* (pending_export.sav in the app save identity) to Downloads / Drive /
* etc. Suggested name is the dialog's default filename.
*/
@Keep
public static boolean showCreateDocument(String suggestedName) {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (suggestedName == null || suggestedName.length() == 0) {
suggestedName = "export.sav";
}
if (suggestedName.indexOf('/') >= 0 || suggestedName.indexOf('\\') >= 0) {
Log.d("GameActivity", "refusing unsafe create name: " + suggestedName);
return false;
}
File source = new File(
new File(self.getExternalFilesDir(null), "save"),
ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME);
if (!source.isFile()) {
Log.d("GameActivity", "no pending export at " + source);
return false;
}
self.pendingCreateSuggestedName = suggestedName;
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("application/octet-stream");
intent.putExtra(Intent.EXTRA_TITLE, suggestedName);
try {
self.startActivityForResult(intent, FILE_CREATE_REQUEST_CODE);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not open create-document picker: " + e.getMessage());
return false;
}
}
private File saveIdentityDir() {
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
}
private boolean copyFileToUri(File source, Uri destUri) {
InputStream in = null;
OutputStream out = null;
try {
in = new BufferedInputStream(new FileInputStream(source));
out = getContentResolver().openOutputStream(destUri);
if (out == null) return false;
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
out.write(buf, 0, n);
}
out.flush();
return true;
} catch (IOException e) {
Log.d("GameActivity", "copy to URI failed: " + e.getMessage());
return false;
} finally {
try { if (in != null) in.close(); } catch (IOException ignored) {}
try { if (out != null) out.close(); } catch (IOException ignored) {}
}
}
@Override @Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) { protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data); super.onActivityResult(requestCode, resultCode, data);
if (requestCode != ROM_PICKER_REQUEST_CODE) return; if (requestCode == FILE_CREATE_REQUEST_CODE) {
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
Log.d("GameActivity", "create-document cancelled");
return;
}
File source = new File(saveIdentityDir(), PENDING_EXPORT_FILENAME);
if (!source.isFile()) {
Log.d("GameActivity", "pending export missing at result time");
return;
}
Uri uri = data.getData();
if (copyFileToUri(source, uri)) {
// Signal Lua on next focus that the SAF export finished.
File flag = new File(saveIdentityDir(), EXPORT_DONE_FILENAME);
try {
FileOutputStream fos = new FileOutputStream(flag, false);
fos.write("ok".getBytes());
fos.close();
} catch (IOException e) {
Log.d("GameActivity", "could not write export_done flag: " + e.getMessage());
}
// Keep pending_export.sav so a retry still works; Lua may remove it.
} else {
Log.d("GameActivity", "could not write export to " + uri);
}
return;
}
if (requestCode != FILE_PICKER_REQUEST_CODE) return;
if (resultCode != RESULT_OK || data == null || data.getData() == null) { if (resultCode != RESULT_OK || data == null || data.getData() == null) {
Log.d("GameActivity", "ROM picker returned no file (cancelled?)"); Log.d("GameActivity", "file picker returned no file (cancelled?)");
return; return;
} }
Uri uri = data.getData(); Uri uri = data.getData();
File destDir = new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY); File destDir = saveIdentityDir();
if (!destDir.exists() && !destDir.mkdirs()) { if (!destDir.exists() && !destDir.mkdirs()) {
Log.d("GameActivity", "could not create " + destDir); Log.d("GameActivity", "could not create " + destDir);
return; return;
} }
File destFile = new File(destDir, PICKED_ROM_FILENAME); String destName = pendingPickFilename != null
? pendingPickFilename : PICKED_ROM_FILENAME;
File destFile = new File(destDir, destName);
InputStream source; InputStream source;
try { try {
source = getContentResolver().openInputStream(uri); source = getContentResolver().openInputStream(uri);
} catch (FileNotFoundException e) { } catch (FileNotFoundException e) {
Log.d("GameActivity", "could not open picked ROM: " + e.getMessage()); Log.d("GameActivity", "could not open picked file: " + e.getMessage());
return; return;
} }
if (source == null) { if (source == null) {
Log.d("GameActivity", "ContentResolver returned no stream for picked ROM"); Log.d("GameActivity", "ContentResolver returned no stream for picked file");
return; return;
} }
if (!copyAssetFile(source, destFile.getPath())) { if (!copyAssetFile(source, destFile.getPath())) {
Log.d("GameActivity", "could not copy picked ROM to " + destFile); Log.d("GameActivity", "could not copy picked file to " + destFile);
} }
} }
+38
View File
@@ -552,6 +552,44 @@ function SaveData.writeSlot(version, slotId, saveTable)
return true return true
end end
-- Delete a registered slot: remove its main/.bak/.tmp files, drop it from the
-- options registry, and if it was active point active at another remaining
-- slot (or clear active when the list is empty). Returns true, or false +
-- an error string when the id is unknown / not registered.
function SaveData.deleteSlot(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) then return false, "unknown version" end
if type(slotId) ~= "string" or slotId == "" then
return false, "missing slot id"
end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version]
if not reg or not reg.list then return false, "slot not registered" end
local found, idx = false, nil
for i, id in ipairs(reg.list) do
if id == slotId then found = true; idx = i; break end
end
if not found then return false, "slot not registered" end
local main, bak, tmp = slotNames(version, slotId)
remove(fs, main)
remove(fs, bak)
remove(fs, tmp)
table.remove(reg.list, idx)
if reg.active == slotId then
reg.active = reg.list[1] -- may be nil when the list is now empty
end
opts.saveSlots[version] = reg
SaveData.saveOptions(opts, fs)
slotsChecked[version] = true
activeSlotCache[version] = reg.active or false
return true
end
-- Test seam: drop the process-global slot cache so a suite can exercise -- Test seam: drop the process-global slot cache so a suite can exercise
-- migration/resolution against a freshly injected filesystem. Unused by -- migration/resolution against a freshly injected filesystem. Unused by
-- the game, which resolves each version exactly once per boot. -- the game, which resolves each version exactly once per boot.
+251 -42
View File
@@ -290,6 +290,39 @@ local function findPendingRom(ready)
return nil return nil
end end
-- Android SAF writes mod picks to picked_mod.zip; USB copies may use any
-- .zip basename at the save-dir root. preferAny=true also accepts those USB
-- copies (Choose / Import); focus only consumes the SAF basename so a random
-- leftover archive is never auto-installed on every refocus.
local function findPendingMod(preferAny)
local preferred = "picked_mod.zip"
if love.filesystem.getInfo(preferred, "file") then
return preferred
end
if not preferAny then return nil end
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.zip$") and love.filesystem.getInfo(name, "file") then
return name
end
end
return nil
end
-- Same pattern as findPendingMod for battery saves (picked_save.sav / *.sav).
local function findPendingSav(preferAny)
local preferred = "picked_save.sav"
if love.filesystem.getInfo(preferred, "file") then
return preferred
end
if not preferAny then return nil end
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.sav$") and love.filesystem.getInfo(name, "file") then
return name
end
end
return nil
end
local function chooseRom(promptName) local function chooseRom(promptName)
promptName = promptName or "Pokemon" promptName = promptName or "Pokemon"
local prompt = "Choose your " .. promptName .. " ROM" local prompt = "Choose your " .. promptName .. " ROM"
@@ -320,8 +353,8 @@ local function chooseRom(promptName)
end end
-- Open a native picker for a mod .zip (mirrors chooseRom's per-OS dialogs). -- Open a native picker for a mod .zip (mirrors chooseRom's per-OS dialogs).
-- Returns the chosen absolute path or nil. Android has no picker; the mods -- Returns the chosen absolute path or nil. Android uses love.system.pickFile
-- panel steers that case to the drag-drop hint instead. -- ("mod") instead -- see RomImporter:chooseMod.
local function chooseZip() local function chooseZip()
local prompt = "Choose a mod .zip" local prompt = "Choose a mod .zip"
local platform = love.system.getOS() local platform = love.system.getOS()
@@ -351,8 +384,8 @@ local function chooseZip()
end end
-- Open a native picker for a raw .sav battery save (mirrors chooseZip's per-OS -- Open a native picker for a raw .sav battery save (mirrors chooseZip's per-OS
-- dialogs). Returns the chosen absolute path or nil. Android has no picker; -- dialogs). Returns the chosen absolute path or nil. Android uses
-- the SAVE FILES card steers that case to the drag-drop hint instead. -- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport.
local function chooseSav() local function chooseSav()
local prompt = "Choose a .sav save file" local prompt = "Choose a .sav save file"
local platform = love.system.getOS() local platform = love.system.getOS()
@@ -429,10 +462,16 @@ function RomImporter.new(onComplete, opts)
-- affordance (desktop love.system.openURL). -- affordance (desktop love.system.openURL).
saveNotice = {}, saveNotice = {},
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array -- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
-- (refreshed lazily on first draw and after any toggle/install); modScroll -- (refreshed lazily on first draw and after any toggle/install/delete);
-- is the list scroll offset (px, clamped in draw); modNotice is the last -- modScroll is the list scroll offset (px, clamped in draw); modNotice is
-- install result { ok, text } shown as a line above the list. -- the last install/delete result { ok, text } shown as a line above the list.
mods = nil, modScroll = 0, modNotice = nil, mods = nil, modScroll = 0, modNotice = nil,
-- Android SAF: which game tab should receive the next picked_save.sav when
-- focus consumes it (set by chooseSaveImport before opening the picker).
androidPendingVersion = nil,
-- Android SAF create-document: which game's SAVE FILES card should show
-- "Save exported." when export_done.flag appears on focus.
androidPendingExportVersion = nil,
}, RomImporter) }, RomImporter)
for _, version in ipairs(GameVersion.ORDER) do for _, version in ipairs(GameVersion.ORDER) do
@@ -491,10 +530,40 @@ end
-- The system picker runs as a separate top activity, so LOVE's own -- The system picker runs as a separate top activity, so LOVE's own
-- love.focus/love.visible pause while it's up (see main.lua) -- once the -- love.focus/love.visible pause while it's up (see main.lua) -- once the
-- player returns here with a file picked, GameActivity has already copied -- player returns here with a file picked, GameActivity has already copied
-- it into the save directory, so a pending-ROM rescan on refocus picks it -- it into the save directory, so a pending-file rescan on refocus picks it
-- up without the player needing to tap the button again. -- up without the player needing to tap the button again. Mod and save SAF
-- drops (picked_mod.zip / picked_save.sav) are consumed first so a leftover
-- ROM pick cannot steal the focus path when both games are already ready.
function RomImporter:focus(f) function RomImporter:focus(f)
if not (f and self.android and self.workState ~= "working") then return end if not (f and self.android and self.workState ~= "working") then return end
-- SAF create-document finished: GameActivity wrote export_done.flag.
if love.filesystem.getInfo("export_done.flag", "file") then
love.filesystem.remove("export_done.flag")
love.filesystem.remove("pending_export.sav")
local version = self.androidPendingExportVersion or self:_savedropTarget()
self.androidPendingExportVersion = nil
self.saveNotice[version] = { ok = true, text = "Save exported." }
if self.tab == "mods" or self.tab == "yellow" then self.tab = version end
return
end
local modName = findPendingMod(false)
if modName then
self:_installMod(modName)
if self.modNotice and self.modNotice.ok then
love.filesystem.remove(modName)
end
return
end
local savName = findPendingSav(false)
if savName then
local version = self.androidPendingVersion or self:_savedropTarget()
self.androidPendingVersion = nil
self:_importSave(version, savName)
if self.saveNotice[version] and self.saveNotice[version].ok then
love.filesystem.remove(savName)
end
return
end
if self.ready.red and self.ready.blue then return end if self.ready.red and self.ready.blue then return end
local name, data = findPendingRom(self.ready) local name, data = findPendingRom(self.ready)
if name then self:startData(data, name) end if name then self:startData(data, name) end
@@ -672,13 +741,38 @@ function RomImporter:_installMod(source)
self.tab = "mods" self.tab = "mods"
end end
-- "Import mod .zip" button: open a native picker (desktop) and install the -- Remove an installed mod from the save-dir mods/ tree and refresh the panel.
-- pick. Android has no picker, so it points the player at the drop path. function RomImporter:_deleteMod(id)
if self.workState == "working" then return end
local LauncherMods = require("src.mods.LauncherMods")
local ok, res = LauncherMods.uninstall(id)
if ok then
self:_refreshMods()
self.modNotice = { ok = true, text = "Deleted " .. tostring(id) }
else
self.modNotice = { ok = false, text = tostring(res) }
end
end
-- "Import mod .zip" button: open a native picker and install the pick.
-- Android mirrors ROM import: scan for a pending .zip in the save dir (USB
-- or a fresh SAF drop), else love.system.pickFile("mod") -> picked_mod.zip
-- which focus/Choose consumes on return.
function RomImporter:chooseMod() function RomImporter:chooseMod()
if self.workState == "working" then return end if self.workState == "working" then return end
if self.android then if self.android then
self.modNotice = local name = findPendingMod(true)
{ ok = false, text = "Drop a mod .zip onto the window to install it." } if name then
self:_installMod(name)
if self.modNotice and self.modNotice.ok then
love.filesystem.remove(name)
end
return
end
if not love.system.pickFile("mod") then
self.modNotice = { ok = false,
text = "Could not open the file picker. Copy a mod .zip via USB." }
end
return return
end end
local path = chooseZip() local path = chooseZip()
@@ -722,13 +816,26 @@ function RomImporter:_importSave(version, source)
end end
end end
-- "Import save" button: open a native .sav picker (desktop) and import the pick. -- "Import save" button: open a native .sav picker and import the pick.
-- Android has no picker, so it points the player at the drop path. -- Android mirrors ROM / mod import via love.system.pickFile("sav").
function RomImporter:chooseSaveImport(version) function RomImporter:chooseSaveImport(version)
if self.workState == "working" then return end if self.workState == "working" then return end
if self.android then if self.android then
self.saveNotice[version] = local name = findPendingSav(true)
{ ok = false, text = "Drop a .sav onto the window to import it." } if name then
self.androidPendingVersion = version
self:_importSave(version, name)
if self.saveNotice[version] and self.saveNotice[version].ok then
love.filesystem.remove(name)
end
return
end
self.androidPendingVersion = version
if not love.system.pickFile("sav") then
self.androidPendingVersion = nil
self.saveNotice[version] = { ok = false,
text = "Could not open the file picker. Copy a .sav via USB." }
end
return return
end end
local path = chooseSav() local path = chooseSav()
@@ -736,16 +843,58 @@ function RomImporter:chooseSaveImport(version)
end end
-- "Export save" button: write the active slot back out to a raw .sav in the save -- "Export save" button: write the active slot back out to a raw .sav in the save
-- directory's exports/ folder and show the path (with an open-folder affordance) -- directory's exports/ folder. On desktop, show the path with an open-folder
-- on the SAVE FILES card. -- affordance. On Android, stage pending_export.sav and open the system
-- create-document picker (love.system.createFile) so the player can save to
-- Downloads / Drive / etc. -- the app-private exports/ path is not useful there.
function RomImporter:exportSave(version) function RomImporter:exportSave(version)
if self.workState == "working" then return end if self.workState == "working" then return end
local ok, res = require("src.import.SaveFileIO").exportActiveSlot(version) local ok, res = require("src.import.SaveFileIO").exportActiveSlot(version)
if ok then if not ok then
local dir = res:match("^(.*)[/\\][^/\\]+$")
self.saveNotice[version] = { ok = true, text = "Exported to " .. res, dir = dir }
else
self.saveNotice[version] = { ok = false, text = tostring(res) } self.saveNotice[version] = { ok = false, text = tostring(res) }
return
end
if self.android then
local rel = res:match("exports[/\\][^/\\]+$")
local data = rel and love.filesystem.read(rel)
if not data then
self.saveNotice[version] = { ok = false,
text = "Exported, but could not stage the file for the picker." }
return
end
local suggested = rel:match("[^/\\]+$") or "export.sav"
local wrote, writeErr = love.filesystem.write("pending_export.sav", data)
if not wrote then
self.saveNotice[version] = { ok = false,
text = "Could not stage the export: " .. tostring(writeErr) }
return
end
self.androidPendingExportVersion = version
if love.system.createFile and love.system.createFile(suggested) then
self.saveNotice[version] = { ok = true,
text = "Pick where to save " .. suggested .. "..." }
else
self.androidPendingExportVersion = nil
self.saveNotice[version] = { ok = true,
text = "Exported inside the app folder (picker unavailable)." }
end
return
end
local dir = res:match("^(.*)[/\\][^/\\]+$")
self.saveNotice[version] = { ok = true, text = "Exported to " .. res, dir = dir }
end
-- Delete a save slot from the registry and disk, then refresh the panel. If the
-- deleted slot was active, SaveData.deleteSlot points active at another slot.
function RomImporter:_deleteSlot(version, id)
if self.workState == "working" then return end
local SaveData = require("src.core.SaveData")
local ok, err = SaveData.deleteSlot(version, id)
if ok then
self:_refreshSlots(version)
self.saveNotice[version] = { ok = true, text = "Deleted " .. tostring(id) .. "." }
else
self.saveNotice[version] = { ok = false, text = tostring(err) }
end end
end end
@@ -1427,9 +1576,17 @@ function RomImporter:mousepressed(x, y, button)
end end
return return
end end
-- SAVE SLOT rows. On desktop a press only ARMS a click: _updateSlotDrag -- SAVE SLOT rows / Delete. Delete is checked first so a tap on the Delete
-- commits it on release when the pointer did not move (a moved pointer scrolls -- label never also selects the row. On desktop a press only ARMS a click:
-- instead). Android has no reliable pointer polling, so it selects on press. -- _updateSlotDrag commits it on release when the pointer did not move (a
-- moved pointer scrolls instead). Android has no reliable pointer polling,
-- so it selects on press. Delete fires immediately (small fixed target).
for _, r in ipairs(self.slotDeleteRects or {}) do
if inside(r, x, y) then
self:_deleteSlot(self.panelVersion, r.id)
return
end
end
for _, r in ipairs(self.slotRects or {}) do for _, r in ipairs(self.slotRects or {}) do
if inside(r, x, y) then if inside(r, x, y) then
if self.android then if self.android then
@@ -1445,12 +1602,18 @@ function RomImporter:mousepressed(x, y, button)
self:_newSlot(self.panelVersion); return self:_newSlot(self.panelVersion); return
end end
-- Mods panel: the import button dispatches on press (fixed header, no scroll -- Mods panel: the import button dispatches on press (fixed header, no scroll
-- conflict); a toggle switch, which lives in the scrollable list, only ARMS a -- conflict); Delete fires immediately; a toggle switch, which lives in the
-- press so _updateSlotDrag can tell a click from a drag-scroll (Android, with -- scrollable list, only ARMS a press so _updateSlotDrag can tell a click from
-- no pointer polling, toggles on press). -- a drag-scroll (Android, with no pointer polling, toggles on press).
if inside(self.modImportRect, x, y) then if inside(self.modImportRect, x, y) then
self:chooseMod(); return self:chooseMod(); return
end end
for _, r in ipairs(self.modDeleteRects or {}) do
if inside(r, x, y) then
self:_deleteMod(r.id)
return
end
end
for _, r in ipairs(self.modRects or {}) do for _, r in ipairs(self.modRects or {}) do
if inside(r, x, y) then if inside(r, x, y) then
if self.android then if self.android then
@@ -1759,7 +1922,8 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
elseif locked then elseif locked then
sfHintText, sfHintCol = "Not available yet.", PAL.warning sfHintText, sfHintCol = "Not available yet.", PAL.warning
elseif self.android then elseif self.android then
sfHintText, sfHintCol = "Import a .sav, or drop one on the window.", PAL.warning sfHintText, sfHintCol =
"Import or export a .sav with the system file picker.", PAL.warning
else else
sfHintText, sfHintCol = sfHintText, sfHintCol =
"Import a .sav to a new slot, or export the active slot.", PAL.warning "Import a .sav to a new slot, or export the active slot.", PAL.warning
@@ -1999,6 +2163,8 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
love.graphics.printf("No saves yet - start a new game or import one.", love.graphics.printf("No saves yet - start a new game or import one.",
rx + 12 * s, listTop + listH / 2 - self.hintFont:getHeight() / 2, rx + 12 * s, listTop + listH / 2 - self.hintFont:getHeight() / 2,
rw - 24 * s, "center") rw - 24 * s, "center")
self.slotRects = {}
self.slotDeleteRects = {}
elseif listH > 0 then elseif listH > 0 then
local nameH = self.slotNameFont:getHeight() local nameH = self.slotNameFont:getHeight()
local metaH = self.labelFont:getHeight() local metaH = self.labelFont:getHeight()
@@ -2017,6 +2183,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
self.slotScroll[version] = scroll self.slotScroll[version] = scroll
self.slotRects = {} self.slotRects = {}
self.slotDeleteRects = {}
love.graphics.setScissor(math.floor(rx), math.floor(listTop), love.graphics.setScissor(math.floor(rx), math.floor(listTop),
math.ceil(rw), math.ceil(listH)) math.ceil(rw), math.ceil(listH))
for i, slot in ipairs(slots) do for i, slot in ipairs(slots) do
@@ -2029,6 +2196,20 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
col(selected and PAL.green or PAL.cardBorder, selected and 0.9 or 0.22) col(selected and PAL.green or PAL.cardBorder, selected and 0.9 or 0.22)
love.graphics.rectangle("line", rx, ry, rw, rowH, rr, rr) love.graphics.rectangle("line", rx, ry, rw, rowH, rr, rr)
-- Delete label (bottom-right); reserve its width so name/meta don't overlap
love.graphics.setFont(self.hintFont)
local delText = "Delete"
local delW = self.hintFont:getWidth(delText)
local delH = self.hintFont:getHeight()
local delX = rx + rw - 12 * s - delW
local delY = ry + rowH - rowPadV - delH
local drect = { x = delX - 6 * s, y = delY - 4 * s,
width = delW + 12 * s, height = delH + 8 * s, id = slot.id }
local dhot = self:_hover(drect)
col(dhot and PAL.red or PAL.warning)
love.graphics.print(delText, delX, delY)
local rightReserve = delW + 18 * s
-- LOADED pill (top-right of the active row), then reserve its width -- LOADED pill (top-right of the active row), then reserve its width
local pillW = 0 local pillW = 0
if selected then if selected then
@@ -2048,7 +2229,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
love.graphics.setFont(self.slotNameFont) love.graphics.setFont(self.slotNameFont)
col(PAL.white) col(PAL.white)
local name = slot.name or "NEW GAME" local name = slot.name or "NEW GAME"
printB(ellipsize(self.slotNameFont, name, rw - 24 * s - pillW), printB(ellipsize(self.slotNameFont, name, rw - 24 * s - math.max(pillW, rightReserve)),
rx + 12 * s, ry + rowPadV) rx + 12 * s, ry + rowPadV)
local metaTxt local metaTxt
@@ -2061,7 +2242,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
end end
love.graphics.setFont(self.labelFont) love.graphics.setFont(self.labelFont)
col(PAL.warning) col(PAL.warning)
love.graphics.print(ellipsize(self.labelFont, metaTxt, rw - 24 * s), love.graphics.print(ellipsize(self.labelFont, metaTxt, rw - 24 * s - rightReserve),
rx + 12 * s, ry + rowPadV + nameH + 4 * s) rx + 12 * s, ry + rowPadV + nameH + 4 * s)
-- clip the hit rect to the visible list band so a partly-scrolled row -- clip the hit rect to the visible list band so a partly-scrolled row
@@ -2072,6 +2253,12 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
self.slotRects[#self.slotRects + 1] = self.slotRects[#self.slotRects + 1] =
{ x = rx, y = vy, width = rw, height = vy2 - vy, id = slot.id } { x = rx, y = vy, width = rw, height = vy2 - vy, id = slot.id }
end end
local dvy = math.max(drect.y, listTop)
local dvy2 = math.min(drect.y + drect.height, listBottom)
if dvy2 > dvy then
self.slotDeleteRects[#self.slotDeleteRects + 1] =
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = slot.id }
end
end end
end end
love.graphics.setScissor() love.graphics.setScissor()
@@ -2165,14 +2352,14 @@ function RomImporter:_drawModsPanel(x, y, w, h)
local top = y + headerH + 14 * s local top = y + headerH + 14 * s
-- notice line: the last install result, else the drag-drop hint -- notice line: the last install/delete result, else the platform hint
love.graphics.setFont(self.hintFont) love.graphics.setFont(self.hintFont)
if self.modNotice then if self.modNotice then
col(self.modNotice.ok and PAL.green or PAL.red) col(self.modNotice.ok and PAL.green or PAL.red)
love.graphics.printf(self.modNotice.text, x, top, w, "left") love.graphics.printf(self.modNotice.text, x, top, w, "left")
else else
col(PAL.warning) col(PAL.warning)
love.graphics.printf(self.android and "Copy a mod .zip via USB." love.graphics.printf(self.android and "Or copy a mod .zip via USB."
or "Or drop a mod .zip onto the window.", x, top, w, "left") or "Or drop a mod .zip onto the window.", x, top, w, "left")
end end
top = top + self.hintFont:getHeight() + 12 * s top = top + self.hintFont:getHeight() + 12 * s
@@ -2187,20 +2374,25 @@ function RomImporter:_drawModsPanel(x, y, w, h)
dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s) dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s)
love.graphics.setFont(self.hintFont) love.graphics.setFont(self.hintFont)
col(PAL.warning) col(PAL.warning)
love.graphics.printf("No mods installed - drop a mod .zip here to add one.", local emptyHint = self.android
and "No mods installed - tap Import mod .zip to add one."
or "No mods installed - drop a mod .zip here to add one."
love.graphics.printf(emptyHint,
x + 16 * s, top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center") x + 16 * s, top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center")
self.modRects = {} self.modRects = {}
self.modDeleteRects = {}
self._modMax = 0 self._modMax = 0
return return
end end
-- card metrics (design: rounded 14, padding 14x16; toggle 56x28) -- card metrics (design: rounded 14, padding 14x16; toggle 56x28; Delete under)
local padH, padV = 16 * s, 14 * s local padH, padV = 16 * s, 14 * s
local cardGap, cardR = 10 * s, 14 * s local cardGap, cardR = 10 * s, 14 * s
local tw, th = 52 * s, 28 * s local tw, th = 52 * s, 28 * s
local innerW = w - 2 * padH local innerW = w - 2 * padH
local chipH = self.hintFont:getHeight() + 8 * s local chipH = self.hintFont:getHeight() + 8 * s
local clusterH = chipH + 6 * s + th -- status chip stacked over the toggle local delH = self.hintFont:getHeight()
local clusterH = chipH + 6 * s + th + 6 * s + delH
love.graphics.setFont(self.stateFont) love.graphics.setFont(self.stateFont)
local nameH = self.stateFont:getHeight() local nameH = self.stateFont:getHeight()
@@ -2210,7 +2402,8 @@ function RomImporter:_drawModsPanel(x, y, w, h)
for i, m in ipairs(mods) do for i, m in ipairs(mods) do
local chipText = modStatusChip(m.status) local chipText = modStatusChip(m.status)
local chipW = self.hintFont:getWidth(chipText) + 20 * s local chipW = self.hintFont:getWidth(chipText) + 20 * s
local clusterW = math.max(chipW, tw) local delW = self.hintFont:getWidth("Delete")
local clusterW = math.max(chipW, tw, delW)
local leftW = math.max(40 * s, innerW - clusterW - 14 * s) local leftW = math.max(40 * s, innerW - clusterW - 14 * s)
local descH = 0 local descH = 0
if m.description ~= "" then if m.description ~= "" then
@@ -2221,7 +2414,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
local contentH = padV * 2 + nameH + (descH > 0 and (6 * s + descH) or 0) local contentH = padV * 2 + nameH + (descH > 0 and (6 * s + descH) or 0)
local cardH = math.max(contentH, padV * 2 + clusterH) local cardH = math.max(contentH, padV * 2 + clusterH)
layout[i] = { h = cardH, leftW = leftW, clusterW = clusterW, layout[i] = { h = cardH, leftW = leftW, clusterW = clusterW,
chipText = chipText, chipW = chipW } chipText = chipText, chipW = chipW, delW = delW }
total = total + cardH total = total + cardH
end end
total = total + (#mods - 1) * cardGap total = total + (#mods - 1) * cardGap
@@ -2231,6 +2424,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
local scroll = clamp(self.modScroll or 0, 0, maxScroll) local scroll = clamp(self.modScroll or 0, 0, maxScroll)
self.modScroll = scroll self.modScroll = scroll
self.modRects = {} self.modRects = {}
self.modDeleteRects = {}
love.graphics.setScissor(math.floor(x), math.floor(top), love.graphics.setScissor(math.floor(x), math.floor(top),
math.ceil(w), math.ceil(listH)) math.ceil(w), math.ceil(listH))
@@ -2269,7 +2463,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left") love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left")
end end
-- right cluster: status chip stacked over the toggle, vertically centred -- right cluster: status chip, toggle, Delete — vertically centred
local clusterX = x + w - padH - L.clusterW local clusterX = x + w - padH - L.clusterW
local clusterY = cy + (cardH - clusterH) / 2 local clusterY = cy + (cardH - clusterH) / 2
local _, chipColor = modStatusChip(m.status) local _, chipColor = modStatusChip(m.status)
@@ -2303,14 +2497,29 @@ function RomImporter:_drawModsPanel(x, y, w, h)
col(PAL.white) col(PAL.white)
love.graphics.circle("fill", kcx, ty + th / 2, kd / 2) love.graphics.circle("fill", kcx, ty + th / 2, kd / 2)
-- hit rect clipped to the visible list band (a partly-scrolled toggle is -- Delete under the toggle
-- only clickable where it actually shows) local delX = clusterX + (L.clusterW - L.delW) / 2
local delY = ty + th + 6 * s
local drect = { x = delX - 6 * s, y = delY - 2 * s,
width = L.delW + 12 * s, height = delH + 4 * s, id = m.id }
local dhot = self:_hover(drect)
love.graphics.setFont(self.hintFont)
col(dhot and PAL.red or PAL.warning)
love.graphics.print("Delete", delX, delY)
-- hit rects clipped to the visible list band
local vy = math.max(trect.y, top) local vy = math.max(trect.y, top)
local vy2 = math.min(trect.y + trect.height, top + listH) local vy2 = math.min(trect.y + trect.height, top + listH)
if vy2 > vy then if vy2 > vy then
self.modRects[#self.modRects + 1] = self.modRects[#self.modRects + 1] =
{ x = trect.x, y = vy, width = trect.width, height = vy2 - vy, id = m.id } { x = trect.x, y = vy, width = trect.width, height = vy2 - vy, id = m.id }
end end
local dvy = math.max(drect.y, top)
local dvy2 = math.min(drect.y + drect.height, top + listH)
if dvy2 > dvy then
self.modDeleteRects[#self.modDeleteRects + 1] =
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = m.id }
end
end end
cy = cy + cardH + cardGap cy = cy + cardH + cardGap
end end
+13 -5
View File
@@ -47,11 +47,19 @@ local function readSource(source)
return source return source
end end
local f, openErr = io.open(source, "rb") local f, openErr = io.open(source, "rb")
if not f then return nil, "could not read the save file: " .. tostring(openErr) end if f then
local data = f:read("*a") local data = f:read("*a")
f:close() f:close()
if type(data) ~= "string" then return nil, "the save file was empty" end if type(data) ~= "string" then return nil, "the save file was empty" end
return data return data
end
-- Android SAF drops (picked_save.sav) and USB copies land in the LOVE save
-- directory; io.open cannot see them, so fall back to love.filesystem.
if love and love.filesystem and love.filesystem.read then
local data = love.filesystem.read(source)
if type(data) == "string" then return data end
end
return nil, "could not read the save file: " .. tostring(openErr)
end end
-- importToSlot(source, version) -> ok, slotIdOrErr -- importToSlot(source, version) -> ok, slotIdOrErr
+34 -3
View File
@@ -3,11 +3,12 @@
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real -- manifests only. The full loader (src/mods/Loader.lua) still owns the real
-- load at boot; this reads the same options.mods enable-state the loader -- load at boot; this reads the same options.mods enable-state the loader
-- writes, derives per-mod status with the pure ManagerState.resolveToggle, -- writes, derives per-mod status with the pure ManagerState.resolveToggle,
-- and installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree. -- installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree, and
-- uninstalls a mod by removing that tree + clearing options.mods[id].
-- --
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and -- Split in two: the pure derivation (deriveList, locateRoot) has no love and
-- no filesystem, so the engine tier can table-drive it; the discovery and -- no filesystem, so the engine tier can table-drive it; the discovery,
-- install paths reach for love.filesystem and SaveData. -- install, and uninstall paths reach for love.filesystem and SaveData.
local Manifest = require("src.mods.Manifest") local Manifest = require("src.mods.Manifest")
local ManagerState = require("src.mods.ManagerState") local ManagerState = require("src.mods.ManagerState")
@@ -332,4 +333,34 @@ function LauncherMods.installZip(source)
return true, manifest.id return true, manifest.id
end end
-- uninstall(id) -> true | nil, errString
-- Removes mods/<id>/ from the save directory and clears options.mods[id] so the
-- loader and in-game manager no longer see it. Rejects unknown / missing ids.
-- Does not touch other mods' enable state.
function LauncherMods.uninstall(id)
if type(id) ~= "string" or id == "" then
return nil, "missing mod id"
end
if id:find("[/\\]") or id == "." or id == ".." then
return nil, "invalid mod id"
end
if not (love and love.filesystem) then
return nil, "mod uninstall needs LOVE"
end
local fs = love.filesystem
local dest = "mods/" .. id
if not fs.getInfo(dest) then
return nil, "mod '" .. id .. "' is not installed"
end
removeTree(dest)
-- Drop the enable flag so a reinstall of the same id starts from the
-- loader's default (enabled) rather than a stale false.
local options = SaveData.loadOptions()
if options.mods and options.mods[id] ~= nil then
options.mods[id] = nil
SaveData.saveOptions(options)
end
return true
end
return LauncherMods return LauncherMods
+18
View File
@@ -190,4 +190,22 @@ do
check(err ~= nil, "the no-root case carries a reason") check(err ~= nil, "the no-root case carries a reason")
end end
-- ------- uninstall: rejects bad ids without needing a real mods tree
do
local ok, err = LauncherMods.uninstall("")
eq(ok, nil, "empty id is rejected")
check(tostring(err):find("missing", 1, true) ~= nil, "empty-id reason")
ok, err = LauncherMods.uninstall("../escape")
eq(ok, nil, "path-like ids are rejected")
check(tostring(err):find("invalid", 1, true) ~= nil, "path-id reason")
ok, err = LauncherMods.uninstall("ghost")
-- Without a mods/ghost tree (and with the love stub's getInfo), uninstall
-- either needs LOVE or reports not installed -- never silently succeeds.
eq(ok, nil, "a missing mod does not uninstall")
check(err ~= nil, "missing-mod uninstall carries a reason")
end
T.finish("launcher_mods") T.finish("launcher_mods")
+35
View File
@@ -165,6 +165,41 @@ do
T.eq(SaveData.createSlot("red"), "slot3", "ids increment past the highest") T.eq(SaveData.createSlot("red"), "slot3", "ids increment past the highest")
end end
-- ---------------------------------------------- deleteSlot
do
local files = fresh()
local a = SaveData.createSlot("red")
local b = SaveData.createSlot("red")
SaveData.setActiveSlot("red", b)
local save = SaveData.newGame()
save.player.name = "KEEP"
T.check(SaveData.writeSlot("red", a, save), "seed slot1 with a save")
save.player.name = "GONE"
T.check(SaveData.writeSlot("red", b, save), "seed slot2 with a save")
local ok, err = SaveData.deleteSlot("red", b)
T.check(ok, "deleteSlot removes the active slot: " .. tostring(err))
T.eq(files["saves/red/slot2.lua"], nil, "slot2's file is gone")
T.check(files["saves/red/slot1.lua"] ~= nil, "the other slot's file stays")
local opts = SaveSerializer.decode(files["options.lua"])
T.eq(opts.saveSlots.red.active, a, "active falls back to the remaining slot")
T.eq(#opts.saveSlots.red.list, 1, "the deleted id is dropped from the list")
T.eq(opts.saveSlots.red.list[1], a, "only slot1 remains registered")
ok = SaveData.deleteSlot("red", a)
T.check(ok, "deleting the last slot succeeds")
opts = SaveSerializer.decode(files["options.lua"])
T.eq(#opts.saveSlots.red.list, 0, "the registry list is empty")
T.eq(opts.saveSlots.red.active, nil, "active clears when no slots remain")
T.eq(#SaveData.listSlots("red"), 0, "listSlots reports an empty install")
local bad, badErr = SaveData.deleteSlot("red", "slot99")
T.check(not bad, "deleting an unknown slot fails")
T.check(tostring(badErr):find("not registered", 1, true) ~= nil,
"unknown-slot error is user-presentable")
end
-- ---------------------------------------------- saveNames follows the slot -- ---------------------------------------------- saveNames follows the slot
do do
@@ -0,0 +1,104 @@
-- Android mod / save Import must open the SAF picker (love.system.pickFile
-- with kind) and consume picked_mod.zip / picked_save.sav on focus, mirroring
-- the ROM flow. Self-contained: `luajit tests/rom_importer_android_mod_pick_test.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("rom importer android mod/save pick")
local eq = S.eq
local check = S.check
local RomImporter = require("src.import.RomImporter")
love.system = love.system or {}
local saved = {
getOS = love.system.getOS,
pickFile = love.system.pickFile,
}
local pickCalls = {}
love.system.getOS = function() return "Android" end
love.system.pickFile = function(kind)
pickCalls[#pickCalls + 1] = kind or "rom"
return true
end
local function freshImporter(ready)
return setmetatable({
android = true,
workState = nil,
tab = "mods",
ready = {
red = ready.red and true or false,
blue = ready.blue and true or false,
},
saveNotice = {},
modNotice = nil,
androidPendingVersion = nil,
_installMod = function(self, source)
self._installed = source
self.modNotice = { ok = true, text = "Installed test" }
end,
_importSave = function(self, version, source)
self._imported = { version = version, source = source }
self.saveNotice[version] = { ok = true, text = "Imported" }
end,
_savedropTarget = RomImporter._savedropTarget,
_refreshMods = function() end,
_refreshSlots = function() end,
}, RomImporter)
end
-- Choose mod with nothing pending opens the mod picker.
pickCalls = {}
local ri = freshImporter({ red = true, blue = true })
ri:chooseMod()
eq(#pickCalls, 1, "chooseMod opens the picker when no pending zip exists")
eq(pickCalls[1], "mod", "chooseMod asks pickFile for a mod")
-- Pending USB zip installs without opening the picker.
love.filesystem.write("usb_mod.zip", "PK\0fake")
pickCalls = {}
ri = freshImporter({ red = true, blue = true })
ri:chooseMod()
eq(#pickCalls, 0, "chooseMod installs a pending zip without opening the picker")
eq(ri._installed, "usb_mod.zip", "chooseMod consumed the USB zip")
check(love.filesystem.getInfo("usb_mod.zip") == nil,
"successful install removes the pending zip")
-- Focus consumes picked_mod.zip even when both ROMs are already ready.
love.filesystem.write("picked_mod.zip", "PK\0saf")
ri = freshImporter({ red = true, blue = true })
ri:focus(true)
eq(ri._installed, "picked_mod.zip", "focus installs the SAF mod drop")
check(love.filesystem.getInfo("picked_mod.zip") == nil,
"successful focus install removes picked_mod.zip")
-- Choose save opens the sav picker when nothing is pending.
pickCalls = {}
ri = freshImporter({ red = true, blue = true })
ri.tab = "blue"
ri:chooseSaveImport("blue")
eq(#pickCalls, 1, "chooseSaveImport opens the picker when no pending sav exists")
eq(pickCalls[1], "sav", "chooseSaveImport asks pickFile for a sav")
eq(ri.androidPendingVersion, "blue", "pending version is remembered for focus")
-- Focus consumes picked_save.sav into the remembered version.
love.filesystem.write("picked_save.sav", string.rep("S", 32))
ri = freshImporter({ red = true, blue = true })
ri.androidPendingVersion = "blue"
ri:focus(true)
check(ri._imported ~= nil, "focus imports the SAF save drop")
eq(ri._imported.version, "blue", "focus imports into the pending version")
eq(ri._imported.source, "picked_save.sav", "focus reads the SAF save filename")
check(love.filesystem.getInfo("picked_save.sav") == nil,
"successful focus import removes picked_save.sav")
love.system.getOS = saved.getOS
love.system.pickFile = saved.pickFile
-- leftover cleanup if a failed assertion left files behind
love.filesystem.remove("usb_mod.zip")
love.filesystem.remove("picked_mod.zip")
love.filesystem.remove("picked_save.sav")
S.finish()
+2
View File
@@ -3115,6 +3115,8 @@ runSuites({ "tests/rom_importer_cursor_test.lua" })
-- ---------------------------------------------- Android second ROM pick (#167) -- ---------------------------------------------- Android second ROM pick (#167)
runSuites({ "tests/rom_importer_android_pick_test.lua" }) runSuites({ "tests/rom_importer_android_pick_test.lua" })
-- ---------------------------------------------- Android mod / save SAF pick
runSuites({ "tests/rom_importer_android_mod_pick_test.lua" })
-- ---------------------------------------------- parity workstream tests -- ---------------------------------------------- parity workstream tests
-- Each tests/parity_*.lua is a self-contained file (own bootstrap + check, -- Each tests/parity_*.lua is a self-contained file (own bootstrap + check,
-- error()s if any assertion fails). Globbed, so dropping a new parity -- error()s if any assertion fails). Globbed, so dropping a new parity