mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 05:00:43 +02:00
android fixes for mods and saves (#297)
This commit is contained in:
+47
-15
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user