Compare commits

...

7 Commits

Author SHA1 Message Date
bryanthaboi e2f2b432ff bazinga moment 2 2026-07-22 15:42:19 -04:00
bryanthaboi 4c7633b56e PORTABLE MODE 2026-07-22 14:18:39 -04:00
bryanthaboi 943ae23e73 Update build.sh 2026-07-22 13:03:53 -04:00
bryanthaboi 839cf19088 bzinga 2026-07-22 12:28:51 -04:00
bryanthaboi 5019ba2caf Merge pull request #71 from bryanthaboi/bug-squashing
another attempt
2026-07-22 10:56:20 -04:00
bryanthaboi 290bde1c39 Merge pull request #70 from bryanthaboi/bug-squashing
android build fix
2026-07-22 10:51:02 -04:00
bryanthaboi b89b895962 squashing BUGS and adding COOL stuff
squashing BUGS and adding COOL stuff
2026-07-22 10:43:06 -04:00
27 changed files with 821 additions and 197 deletions
+9 -7
View File
@@ -50,18 +50,20 @@ developer data build, test suites, and cache management are covered in
By default the game keeps your save, options, and the private ROM-derived By default the game keeps your save, options, and the private ROM-derived
data cache in your OS's normal per-user app data folder. To keep everything data cache in your OS's normal per-user app data folder. To keep everything
next to the game instead (handy for a USB stick or portable drive you carry next to the game instead (handy for a USB stick or portable drive you carry
between computers), drop an empty file named `portable.txt` next to the between computers), drop an empty file named `portable.txt` next to the app
executable (or next to `main.lua`/`conf.lua` when running from source), then (next to `PokemonRed.app`/`.exe`, or next to `main.lua`/`conf.lua` when
launch the game. running from source), then launch the game. Portable mode is desktop-only
(Windows, Linux, macOS); it has no effect on Android or iOS, where the app
runs from a read-only package.
With `portable.txt` present: With `portable.txt` present:
- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to - `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to
that same folder instead of the OS save directory. that same folder instead of the OS save directory.
- After a ROM import, the generated `data/generated` and `assets/generated` - A ROM import writes the generated `data/generated` and `assets/generated`
cache is copied into that folder too, so a later launch (even on a cache straight into that folder too (nothing is left in the OS save
different computer, as long as the same folder comes along) reuses it directory), so a later launch reuses it without asking for the ROM again
without asking for the ROM again. even on a different computer, as long as the same folder comes along.
- Deleting `portable.txt` switches back to the normal OS save directory; nothing - Deleting `portable.txt` switches back to the normal OS save directory; nothing
already written to either location is touched automatically, so copy files already written to either location is touched automatically, so copy files
over yourself if you want to carry existing progress across the switch. over yourself if you want to carry existing progress across the switch.
+19 -4
View File
@@ -37,14 +37,29 @@ function love.conf(t)
local osName = love._os local osName = love._os
local mobile = osName == "Android" or osName == "iOS" local mobile = osName == "Android" or osName == "iOS"
if mobile then if mobile then
-- On Android/iOS, width/height aspect picks portrait vs landscape -- resizable is what unlocks orientation. SDL's Android backend, given no
-- (fullscreen alone is not enough). Use a tall portrait size; the -- SDL_HINT_ORIENTATIONS (LÖVE sets none), calls setRequestedOrientation
-- OS then resizes to the real display. highdpi is required for -- at window creation -- FULL_SENSOR when the window is resizable (rotates
-- Retina iOS (Android always behaves as highdpi). -- freely to portrait or landscape), otherwise locked to the window's w/h
-- aspect. So a non-resizable tall window forced portrait; resizable lets
-- the game follow the device. The renderer letterboxes the 160x144
-- viewport into whatever size results, and touch input is gesture-based,
-- so both orientations just work. iOS follows the Info.plist orientations
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
t.window.resizable = true
-- Starting size is a tall portrait hint; the OS resizes to the real
-- display and rotations resize again. highdpi is required for Retina iOS
-- (Android always behaves as highdpi).
t.window.width = 1080 t.window.width = 1080
t.window.height = 1920 t.window.height = 1920
t.window.fullscreen = true t.window.fullscreen = true
t.window.highdpi = true t.window.highdpi = true
-- Android only (irrelevant on iOS): puts the save directory under the
-- app's external-files folder, which is readable/writable via USB or a
-- file manager with no runtime permission, so RomImporter can ask the
-- player to copy their ROM there instead of needing a native file
-- picker (LOVE 11.5 on Android has none -- see src/import/RomImporter.lua).
t.externalstorage = osName == "Android"
else else
t.window.resizable = true t.window.resizable = true
end end
+4 -1
View File
@@ -184,7 +184,10 @@ end
-- unfocused, so reset input on either transition rather than trust it. -- unfocused, so reset input on either transition rather than trust it.
function love.focus(f) function love.focus(f)
if editorMode then return end if editorMode then return end
if Importer then return end if Importer then
if Importer.focus then Importer:focus(f) end
return
end
Game:focus(f) Game:focus(f)
end end
+3 -1
View File
@@ -6,7 +6,9 @@
#app.name=LÖVE for Android #app.name=LÖVE for Android
app.application_id=com.theboisclub.pokemonred app.application_id=com.theboisclub.pokemonred
app.orientation=portrait # fullUser: allow every orientation the player's device permits (portrait and
# landscape), honouring their auto-rotate lock. Was "portrait" (locked).
app.orientation=fullUser
app.version_code=32 app.version_code=32
app.version_name=11.5a app.version_name=11.5a
@@ -183,6 +183,18 @@ void vibrate(double seconds)
env->DeleteLocalRef(activity); env->DeleteLocalRef(activity);
} }
bool showFilePicker()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "showRomFilePicker", "()Z");
jboolean result = env->CallStaticBooleanMethod(activity, method);
env->DeleteLocalRef(activity);
return result;
}
/* /*
* Helper functions for the filesystem module * Helper functions for the filesystem module
*/ */
@@ -59,6 +59,14 @@ bool openURL(const std::string &url);
void vibrate(double seconds); void vibrate(double seconds);
/**
* Shows the system's "pick a document" UI (Storage Access Framework).
* Returns true if the picker was launched; the picked file (if any) is
* copied asynchronously by GameActivity.onActivityResult into the app's
* external save directory, not returned here -- see src/import/RomImporter.lua.
**/
bool showFilePicker();
/* /*
* Helper functions for the filesystem module * Helper functions for the filesystem module
*/ */
@@ -186,6 +186,18 @@ bool Filesystem::setIdentity(const char *ident, bool appendToPath)
save_path_full = storage_path + std::string("/save/") + save_identity; save_path_full = storage_path + std::string("/save/") + save_identity;
// love::android::mkdir is a single mkdir(), not mkdir -p: on a genuinely
// first-ever launch (nothing has touched this app's external-files dir
// before) save_directory doesn't exist yet either, so creating
// save_path_full in one step fails with ENOENT and PHYSFS_mount below
// silently never mounts anything for the rest of this process -- not
// just the save dir, but everything routed through it (save.lua/
// options.lua, the ROM-derived asset cache, RomImporter's Android
// folder scan). Ensure each level exists in order instead.
if (!love::android::directoryExists(save_directory.c_str()) &&
!love::android::mkdir(save_directory.c_str()))
SDL_Log("Error: Could not create save directory %s!", save_directory.c_str());
if (!love::android::directoryExists(save_path_full.c_str()) && if (!love::android::directoryExists(save_path_full.c_str()) &&
!love::android::mkdir(save_path_full.c_str())) !love::android::mkdir(save_path_full.c_str()))
SDL_Log("Error: Could not create save directory %s!", save_path_full.c_str()); SDL_Log("Error: Could not create save directory %s!", save_path_full.c_str());
@@ -338,6 +350,26 @@ bool Filesystem::setupWriteDirectory()
std::string temp_writedir = getDriveRoot(save_path_full); std::string temp_writedir = getDriveRoot(save_path_full);
std::string temp_createdir = skipDriveRoot(save_path_full); std::string temp_createdir = skipDriveRoot(save_path_full);
#ifdef LOVE_ANDROID
// getUserDirectory() falls back to $HOME/getpwuid() (physfs_platform_posix.c),
// which is meaningless on Android and unrelated to save_path_full (an
// SDL_AndroidGet*StoragePath() subdirectory -- see setIdentity above), so
// the generic check below never matches and falls through to setting the
// write dir to the drive root ("/"), which no Android app can write to.
// Anchor to the real Android storage root instead.
std::string androidStorageRoot = isAndroidSaveExternal()
? SDL_AndroidGetExternalStoragePath() : SDL_AndroidGetInternalStoragePath();
if (save_path_full.find(androidStorageRoot) == 0)
{
temp_writedir = androidStorageRoot;
temp_createdir = save_path_full.substr(androidStorageRoot.length());
size_t startpos = temp_createdir.find_first_not_of('/');
if (startpos != std::string::npos)
temp_createdir = temp_createdir.substr(startpos);
}
else
#endif
// On some sandboxed platforms, physfs will break when its write directory // On some sandboxed platforms, physfs will break when its write directory
// is the root of the drive and it tries to create a folder (even if the // is the root of the drive and it tries to create a folder (even if the
// folder's path is in a writable location.) If the user's home folder is // folder's path is in a writable location.) If the user's home folder is
@@ -180,6 +180,15 @@ void System::vibrate(double seconds) const
#endif #endif
} }
bool System::pickFile() const
{
#ifdef LOVE_ANDROID
return love::android::showFilePicker();
#else
return false;
#endif
}
bool System::hasBackgroundMusic() const bool System::hasBackgroundMusic() const
{ {
#if defined(LOVE_ANDROID) #if defined(LOVE_ANDROID)
@@ -106,6 +106,15 @@ public:
*/ */
virtual void vibrate(double seconds) const; virtual void vibrate(double seconds) const;
/**
* Shows the platform's native "pick a file" UI, if one is available.
* Android only for now; the result (if any) is not returned here -- see
* love::android::showFilePicker and src/import/RomImporter.lua.
*
* @return Whether the picker was shown.
**/
virtual bool pickFile() const;
/** /**
* Gets if the user is playing music on background. * Gets if the user is playing music on background.
* Throws an exception on unsupported platforms. * Throws an exception on unsupported platforms.
@@ -95,6 +95,12 @@ int w_vibrate(lua_State *L)
return 0; return 0;
} }
int w_pickFile(lua_State *L)
{
luax_pushboolean(L, instance()->pickFile());
return 1;
}
int w_hasBackgroundMusic(lua_State *L) int w_hasBackgroundMusic(lua_State *L)
{ {
lua_pushboolean(L, instance()->hasBackgroundMusic()); lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -110,6 +116,7 @@ static const luaL_Reg functions[] =
{ "getPowerInfo", w_getPowerInfo }, { "getPowerInfo", w_getPowerInfo },
{ "openURL", w_openURL }, { "openURL", w_openURL },
{ "vibrate", w_vibrate }, { "vibrate", w_vibrate },
{ "pickFile", w_pickFile },
{ "hasBackgroundMusic", w_hasBackgroundMusic }, { "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 } { 0, 0 }
}; };
@@ -61,6 +61,13 @@ 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;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked ROM
// is dropped so RomImporter's existing folder scan finds it -- see
// src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's
// save directory to getExternalFilesDir()/save/<identity>).
private static final String ROM_SAVE_IDENTITY = "pokemon-love2d";
private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
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;
@@ -332,6 +339,64 @@ public class GameActivity extends SDLActivity {
return openURL(url) == 0; return openURL(url) == 0;
} }
/**
* Shows the system document picker (Storage Access Framework) so the
* player can pick their ROM from anywhere (Downloads, Drive, etc.)
* without needing to know where the app's external files folder is.
* Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
* arrives later in onActivityResult, not synchronously here.
*/
@Keep
public static boolean showRomFilePicker() {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
try {
self.startActivityForResult(intent, ROM_PICKER_REQUEST_CODE);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not open ROM file picker: " + e.getMessage());
return false;
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode != ROM_PICKER_REQUEST_CODE) return;
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
Log.d("GameActivity", "ROM picker returned no file (cancelled?)");
return;
}
Uri uri = data.getData();
File destDir = new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
if (!destDir.exists() && !destDir.mkdirs()) {
Log.d("GameActivity", "could not create " + destDir);
return;
}
File destFile = new File(destDir, PICKED_ROM_FILENAME);
InputStream source;
try {
source = getContentResolver().openInputStream(uri);
} catch (FileNotFoundException e) {
Log.d("GameActivity", "could not open picked ROM: " + e.getMessage());
return;
}
if (source == null) {
Log.d("GameActivity", "ContentResolver returned no stream for picked ROM");
return;
}
if (!copyAssetFile(source, destFile.getPath())) {
Log.d("GameActivity", "could not copy picked ROM to " + destFile);
}
}
/** /**
* Copies a given file from the assets folder to the destination. * Copies a given file from the assets folder to the destination.
* *
+5
View File
@@ -62,10 +62,15 @@
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UISupportedInterfaceOrientations~ipad</key> <key>UISupportedInterfaceOrientations~ipad</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UTExportedTypeDeclarations</key> <key>UTExportedTypeDeclarations</key>
<array> <array>
+56 -5
View File
@@ -141,11 +141,20 @@ build_win() {
say "building Windows (win64) app" say "building Windows (win64) app"
local zip_name="love-$LOVE_VERSION-win64.zip" local zip_name="love-$LOVE_VERSION-win64.zip"
local love_zip="$CACHE/$zip_name" local love_zip="$CACHE/$zip_name"
# A cache hit only checks existence, not validity -- a prior run truncated
# by a network drop mid-download (curl still leaves the partial file if
# the exit code slips through) would otherwise be reused forever.
if [ -f "$love_zip" ] && ! unzip -tqq "$love_zip" >/dev/null 2>&1; then
warn "cached $zip_name is not a valid zip, removing and re-downloading"
rm -f "$love_zip"
fi
if [ ! -f "$love_zip" ]; then if [ ! -f "$love_zip" ]; then
say "downloading LÖVE $LOVE_VERSION win64 binaries" say "downloading LÖVE $LOVE_VERSION win64 binaries"
curl -fL --progress-bar \ curl -fL --progress-bar \
"https://github.com/love2d/love/releases/download/$LOVE_VERSION/$zip_name" \ "https://github.com/love2d/love/releases/download/$LOVE_VERSION/$zip_name" \
-o "$love_zip" || fail "download failed, check LOVE_VERSION or your network" -o "$love_zip" || fail "download failed, check LOVE_VERSION or your network"
unzip -tqq "$love_zip" >/dev/null 2>&1 \
|| fail "downloaded $zip_name is not a valid zip (truncated download?)"
fi fi
local extract_dir="$WORK/love-win64" local extract_dir="$WORK/love-win64"
@@ -174,21 +183,63 @@ build_linux() {
say "building Linux (x86_64 AppImage) app" say "building Linux (x86_64 AppImage) app"
local appimage_name="love-$LOVE_VERSION-x86_64.AppImage" local appimage_name="love-$LOVE_VERSION-x86_64.AppImage"
local love_appimage="$CACHE/$appimage_name" local love_appimage="$CACHE/$appimage_name"
# Same cache-validity gap as the win64 zip above: an AppImage is just an
# ELF, so check the magic bytes before trusting a cached copy is complete.
if [ -f "$love_appimage" ] && [ "$(head -c 4 "$love_appimage" | od -An -tx1 | tr -d ' \n')" != "7f454c46" ]; then
warn "cached $appimage_name is not a valid ELF binary, removing and re-downloading"
rm -f "$love_appimage"
fi
if [ ! -f "$love_appimage" ]; then if [ ! -f "$love_appimage" ]; then
say "downloading LÖVE $LOVE_VERSION Linux AppImage" say "downloading LÖVE $LOVE_VERSION Linux AppImage"
curl -fL --progress-bar \ curl -fL --progress-bar \
"https://github.com/love2d/love/releases/download/$LOVE_VERSION/$appimage_name" \ "https://github.com/love2d/love/releases/download/$LOVE_VERSION/$appimage_name" \
-o "$love_appimage" || fail "download failed, check LOVE_VERSION or your network" -o "$love_appimage" || fail "download failed, check LOVE_VERSION or your network"
[ "$(head -c 4 "$love_appimage" | od -An -tx1 | tr -d ' \n')" = "7f454c46" ] \
|| fail "downloaded $appimage_name is not a valid ELF binary (truncated download?)"
fi fi
chmod +x "$love_appimage" chmod +x "$love_appimage"
# Same fusion trick as the Windows exe: love looks for a zip appended to # The Windows-style `cat love.exe game.love` fusion does NOT work here:
# its own running binary, and an AppImage is just an ELF executable, so # an AppImage is a small runtime ELF with a squashfs appended, and at
# concatenating game.love onto it works the same way `cat love.exe # launch the runtime mounts the squashfs and executes bin/love from
# game.love` does on Windows. # *inside* it -- bytes appended to the outer file are never read, so
# users would just get vanilla LÖVE's no-game screen. Instead, unpack
# the squashfs, drop game.love in, point AppRun's FUSE_PATH hook at it
# (the hook ships commented-out in LÖVE's official AppImage), and glue
# runtime + repacked squashfs back together.
command -v unsquashfs >/dev/null && command -v mksquashfs >/dev/null \
|| fail "squashfs tools not found; install with: brew install squashfs"
# The squashfs starts right where the ELF ends:
# e_shoff + e_shnum * e_shentsize (all little-endian in the ELF64 header).
local e_shoff e_shentsize e_shnum sfs_offset
e_shoff=$(od -An -j40 -N8 -tu8 "$love_appimage" | tr -d ' ')
e_shentsize=$(od -An -j58 -N2 -tu2 "$love_appimage" | tr -d ' ')
e_shnum=$(od -An -j60 -N2 -tu2 "$love_appimage" | tr -d ' ')
sfs_offset=$((e_shoff + e_shentsize * e_shnum))
[ "$(dd if="$love_appimage" bs=1 skip="$sfs_offset" count=4 2>/dev/null)" = "hsqs" ] \
|| fail "no squashfs superblock at computed offset $sfs_offset (unexpected AppImage layout)"
local appdir="$WORK/linux-appdir"
rm -rf "$appdir"
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
cp "$LOVE_FILE" "$appdir/game.love"
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
# Match the upstream image's compression (gzip, 128K blocks) so the
# bundled runtime can read it.
local sfs_out="$WORK/game.squashfs"
rm -f "$sfs_out"
mksquashfs "$appdir" "$sfs_out" \
-comp gzip -b 131072 -noappend -all-root -no-xattrs -quiet >/dev/null
local out_bin="$WORK/$APP_NAME-x86_64.AppImage" local out_bin="$WORK/$APP_NAME-x86_64.AppImage"
rm -f "$out_bin" rm -f "$out_bin"
cat "$love_appimage" "$LOVE_FILE" > "$out_bin" head -c "$sfs_offset" "$love_appimage" > "$out_bin"
cat "$sfs_out" >> "$out_bin"
chmod +x "$out_bin" chmod +x "$out_bin"
local zip_out="$DIST/linux/$APP_NAME-linux.zip" local zip_out="$DIST/linux/$APP_NAME-linux.zip"
+1 -1
View File
@@ -101,7 +101,7 @@ def set_prop(text, key, value):
text = re.sub(r"(?m)^app\.name_byte_array=.*\n?", "", text) text = re.sub(r"(?m)^app\.name_byte_array=.*\n?", "", text)
text = set_prop(text, "app.name", name) text = set_prop(text, "app.name", name)
text = set_prop(text, "app.application_id", app_id) text = set_prop(text, "app.application_id", app_id)
text = set_prop(text, "app.orientation", "portrait") text = set_prop(text, "app.orientation", "fullUser")
if version: if version:
text = set_prop(text, "app.version_name", version) text = set_prop(text, "app.version_name", version)
text = set_prop(text, "app.version_code", version_code) text = set_prop(text, "app.version_code", version_code)
+1 -1
View File
@@ -155,7 +155,7 @@ require_ios_libraries
apply_ios_branding() { apply_ios_branding() {
[ -f "$OVERLAY_PLIST" ] || fail "missing overlay plist: $OVERLAY_PLIST" [ -f "$OVERLAY_PLIST" ] || fail "missing overlay plist: $OVERLAY_PLIST"
local dest="$XCODE_DIR/ios/love-ios.plist" local dest="$XCODE_DIR/ios/love-ios.plist"
say "applying iOS branding (portrait-only Info.plist, display name)" say "applying iOS branding (portrait + landscape Info.plist, display name)"
cp "$OVERLAY_PLIST" "$dest" cp "$OVERLAY_PLIST" "$dest"
} }
+6 -5
View File
@@ -349,17 +349,16 @@ function Game:gamepadaxis(joystick, axis, value)
Input:gamepadaxis(joystick, axis, value) Input:gamepadaxis(joystick, axis, value)
end end
-- Window lost focus or got minimized: any release event due while it was -- Window focus/visibility flips: a release due while unfocused/hidden can
-- unfocused/hidden can be swallowed by the OS instead of delivered here, -- be swallowed by the OS. Reset on both edges -- gaining focus with a
-- which would otherwise leave a held direction stuck on. -- physically held key won't re-fire keypressed, so trusting leftover
-- state is worse than asking the player to re-press.
function Game:focus(f) function Game:focus(f)
if f then return end
Input:reset() Input:reset()
TouchInput:reset() TouchInput:reset()
end end
function Game:visible(v) function Game:visible(v)
if v then return end
Input:reset() Input:reset()
TouchInput:reset() TouchInput:reset()
end end
@@ -369,6 +368,7 @@ end
-- flags it owned. -- flags it owned.
function Game:joystickremoved(joystick) function Game:joystickremoved(joystick)
Input:reset() Input:reset()
TouchInput:reset()
end end
function Game:touchpressed(id, x, y) function Game:touchpressed(id, x, y)
@@ -433,6 +433,7 @@ function Game:applyOptions(opts)
require("src.render.Tilt").applyOptions(opts) require("src.render.Tilt").applyOptions(opts)
require("src.render.GBCFX").applyOptions(opts) require("src.render.GBCFX").applyOptions(opts)
require("src.core.VideoMode").applyOptions(opts) require("src.core.VideoMode").applyOptions(opts)
Input:applyBindings(opts.bindings)
end end
function Game:restoreSave(loaded, recovered) function Game:restoreSave(loaded, recovered)
+93 -16
View File
@@ -3,7 +3,7 @@
local Input = {} local Input = {}
local BINDINGS = { local DEFAULT_BINDINGS = {
up = "up", w = "up", up = "up", w = "up",
down = "down", s = "down", down = "down", s = "down",
left = "left", a = "left", left = "left", a = "left",
@@ -18,8 +18,11 @@ local BINDINGS = {
-- Escape = start for desktop friendliness. -- Escape = start for desktop friendliness.
-- LÖVE's standard gamepad mapping (SDL game controller DB), consistent -- LÖVE's standard gamepad mapping (SDL game controller DB), consistent
-- across Xbox/PlayStation/generic controllers on desktop and mobile. -- across Xbox/PlayStation/generic controllers on desktop and mobile. Some
local GAMEPAD_BINDINGS = { -- third-party pads report their own SDL mapping for a given physical
-- button (e.g. Select/Back/View on off-brand XInput pads), which is what
-- src/ui/BindingsMenu.lua's rebinding is for -- see applyBindings below.
local DEFAULT_GAMEPAD_BINDINGS = {
dpup = "up", dpdown = "down", dpleft = "left", dpright = "right", dpup = "up", dpdown = "down", dpleft = "left", dpright = "right",
a = "a", b = "b", a = "a", b = "b",
start = "start", back = "select", start = "start", back = "select",
@@ -32,9 +35,33 @@ local STICK_ON = 0.5
local STICK_OFF = 0.3 local STICK_OFF = 0.3
function Input:init() function Input:init()
self:applyBindings(nil)
self:reset() self:reset()
end end
-- Layers a player's rebind choices (save.options.bindings, written by
-- src/ui/BindingsMenu.lua) on top of the defaults above. A rebind adds an
-- extra way to trigger that action instead of replacing the default key,
-- so e.g. Z/Enter/Space all still press A even after binding a 4th key to
-- it. Call whenever options load or change (see Game:applyOptions and
-- BindingsMenu:storeBinding) -- without this the menu records a choice
-- that never actually reaches gameplay.
function Input:applyBindings(overlay)
local keys, pads = {}, {}
for key, action in pairs(DEFAULT_BINDINGS) do keys[key] = action end
for button, action in pairs(DEFAULT_GAMEPAD_BINDINGS) do pads[button] = action end
for actionId, binding in pairs(overlay or {}) do
if type(binding) == "table" then
if binding.key then keys[binding.key] = actionId end
if binding.pad then pads[binding.pad] = actionId end
elseif type(binding) == "string" then
keys[binding] = actionId
end
end
self.keyBindings = keys
self.padBindings = pads
end
-- Purely event-driven state (press sets true, release sets false) has no -- Purely event-driven state (press sets true, release sets false) has no
-- fallback if a release event never arrives -- focus loss, a minimized -- fallback if a release event never arrives -- focus loss, a minimized
-- window, or a disconnected gamepad can all swallow the key-up/button-up -- window, or a disconnected gamepad can all swallow the key-up/button-up
@@ -44,45 +71,95 @@ function Input:reset()
self.state = {} self.state = {}
self.pressQueue = {} self.pressQueue = {}
self.pressed = {} self.pressed = {}
self.sources = {}
self.stickAxis = { x = 0, y = 0 } self.stickAxis = { x = 0, y = 0 }
self.stickDir = nil self.stickDir = nil
end end
function Input:keypressed(key) -- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the
local btn = BINDINGS[key] -- same GB button. Track them individually so releasing one doesn't clear
if btn then -- a hold another source still owns, and so a press+release that both land
-- before the next FixedStep can't be revived when step() drains the queue.
local function press(self, btn, source)
local sources = self.sources[btn]
if not sources then
sources = {}
self.sources[btn] = sources
end
if not sources[source] then
sources[source] = true
table.insert(self.pressQueue, btn) table.insert(self.pressQueue, btn)
end end
self.state[btn] = true
end end
function Input:keyreleased(key) local function release(self, btn, source)
local btn = BINDINGS[key] local sources = self.sources[btn]
if btn then if sources then
sources[source] = nil
if next(sources) == nil then
-- Leave an empty table (not nil) so step() can tell a real
-- source was released before the queue drained, versus a
-- synthetic pressQueue inject that never had sources at all.
self.state[btn] = false
end
else
self.state[btn] = false self.state[btn] = false
end end
end end
function Input:keypressed(key)
local btn = self.keyBindings[key]
if btn then
press(self, btn, "key:" .. key)
end
end
function Input:keyreleased(key)
local btn = self.keyBindings[key]
if btn then
release(self, btn, "key:" .. key)
end
end
-- Called once per fixed step: promote queued presses to this step's edges. -- Called once per fixed step: promote queued presses to this step's edges.
-- Hold state is owned by live sources (updated in press/release), not
-- re-asserted here -- otherwise a same-frame press→release leaves the
-- button stuck on after the queue drains.
-- Synthetic injects (tests/drivers writing pressQueue directly, with no
-- source entry) still set state so scripted holds keep working.
function Input:step() function Input:step()
self.pressed = {} self.pressed = {}
for _, btn in ipairs(self.pressQueue) do for _, btn in ipairs(self.pressQueue) do
self.pressed[btn] = true self.pressed[btn] = true
self.state[btn] = true local sources = self.sources[btn]
if sources == nil then
-- synthetic pressQueue inject (tests/drivers): no live source map
self.state[btn] = true
elseif next(sources) ~= nil then
self.state[btn] = true
end
-- sources == {}: real press fully released before this step — keep up
end
for btn, sources in pairs(self.sources) do
if next(sources) == nil then
self.sources[btn] = nil
end
end end
self.pressQueue = {} self.pressQueue = {}
end end
function Input:gamepadpressed(joystick, button) function Input:gamepadpressed(joystick, button)
local btn = GAMEPAD_BINDINGS[button] local btn = self.padBindings[button]
if btn then if btn then
table.insert(self.pressQueue, btn) press(self, btn, "pad:" .. button)
end end
end end
function Input:gamepadreleased(joystick, button) function Input:gamepadreleased(joystick, button)
local btn = GAMEPAD_BINDINGS[button] local btn = self.padBindings[button]
if btn then if btn then
self.state[btn] = false release(self, btn, "pad:" .. button)
end end
end end
@@ -112,10 +189,10 @@ function Input:gamepadaxis(joystick, axis, value)
if newDir ~= self.stickDir then if newDir ~= self.stickDir then
if self.stickDir then if self.stickDir then
self.state[self.stickDir] = false release(self, self.stickDir, "stick")
end end
if newDir then if newDir then
table.insert(self.pressQueue, newDir) press(self, newDir, "stick")
end end
self.stickDir = newDir self.stickDir = newDir
end end
+33 -7
View File
@@ -83,15 +83,41 @@ local function detectPortable()
portableChecked = true portableChecked = true
portableBase = false portableBase = false
if not (love and love.filesystem) then return false end if not (love and love.filesystem) then return false end
-- Desktop only: portable mode carries the save (and, since issue #74, the
-- ROM cache) in the game folder next to the executable/source. On
-- Android/iOS the source is a read-only package with no such folder, so
-- portable mode never applies there.
if love.system and love.system.getOS then
local osName = love.system.getOS()
if osName ~= "Windows" and osName ~= "Linux" and osName ~= "OS X" then
return false
end
end
local src = love.filesystem.getSource and love.filesystem.getSource()
local sbd = love.filesystem.getSourceBaseDirectory
and love.filesystem.getSourceBaseDirectory()
-- A packaged macOS build nests the game inside PokemonRed.app/Contents/
-- Resources, so getSource()/getSourceBaseDirectory() point INSIDE the
-- bundle -- not where the player drops portable.txt (next to the .app).
-- Recover the folder containing the .app so a packaged app finds its
-- marker. On Windows/Linux the executable is not a bundle, so this is nil
-- and the plain source-base directory (next to the .exe/AppImage) is used.
local function appContainer(path)
local appPath = path and path:match("^(.*%.app)/Contents/")
return appPath and appPath:match("^(.*)/[^/]+$") or nil
end
-- Order: the .app's containing folder (packaged macOS), then the
-- source-base directory (next to a packaged .exe/AppImage), then the
-- source itself (a `love <gamedir>` run drops portable.txt in the game
-- folder). First one holding the marker wins. Built by appending so a
-- nil (e.g. no .app in the path) never truncates the ipairs scan.
local candidates = {} local candidates = {}
if love.filesystem.getSourceBaseDirectory then local appDir = appContainer(src) or appContainer(sbd)
candidates[#candidates + 1] = love.filesystem.getSourceBaseDirectory() if appDir then candidates[#candidates + 1] = appDir end
end if sbd then candidates[#candidates + 1] = sbd end
if love.filesystem.getSource then if src then candidates[#candidates + 1] = src end
candidates[#candidates + 1] = love.filesystem.getSource()
end
for _, base in ipairs(candidates) do for _, base in ipairs(candidates) do
if base and base ~= "" and pathExists(base .. SEP .. PORTABLE_MARKER) then if base ~= "" and pathExists(base .. SEP .. PORTABLE_MARKER) then
portableBase = base portableBase = base
break break
end end
+232
View File
@@ -0,0 +1,232 @@
-- Routes ROM-derived cache I/O (data/generated, assets/generated and the
-- rom-cache.complete marker) to the right place.
--
-- Normally the cache lives in LÖVE's per-user OS save directory and is
-- written through love.filesystem. In portable mode it lives in the game
-- folder next to the executable instead (the folder holding portable.txt --
-- see SaveData), so nothing is left on the host machine. That folder is
-- written with raw io.* (love.filesystem can only write to the save dir) and
-- read back through love.filesystem, require and love.graphics.newImage --
-- which works because the folder is on the physfs read path:
--
-- * Source runs (`love <gamedir>`, what the Play-* launchers use): the
-- folder IS the physfs source, so it is already readable.
-- * Fused builds (the packaged .app/.exe): the folder sits next to the
-- executable and is NOT normally readable, so CacheFs mounts it onto the
-- read path via PhysFS. love.filesystem.mount refuses external folders,
-- but the underlying PHYSFS_mount (exported from love's framework) allows
-- them; we call it through LuaJIT's FFI.
--
-- Directories in the portable folder are created with a plain mkdir syscall
-- via FFI rather than os.execute, so importing never flashes a console window
-- on Windows (issue #74 -- the old per-file `os.execute("mkdir")` froze the
-- app behind a storm of one-frame cmd.exe windows).
--
-- Portable mode is desktop-only (Windows/Linux/macOS); on Android/iOS the
-- source is a read-only package with no game folder to write into, so
-- SaveData.isPortable() is false there and this module falls back to the
-- ordinary love.filesystem/save-directory behaviour.
local CacheFs = {}
local SEP = package.config:sub(1, 1)
-- lazily-resolved windowless mkdir: function(absolutePath) or false when
-- FFI is unavailable (the cache then stays on the save directory)
local mkdirFn = nil
local function resolveMkdir()
if mkdirFn ~= nil then return mkdirFn end
mkdirFn = false
local ok, ffi = pcall(require, "ffi")
if not ok then return mkdirFn end
if ffi.os == "Windows" then
-- kernel32 is reliably resolvable through ffi.C on Windows (the engine
-- already binds it in DiscordPresence); CreateDirectoryA returns
-- nonzero on success and 0 when the directory already exists -- both
-- fine, the result is ignored.
pcall(ffi.cdef,
"int CreateDirectoryA(const char *lpPathName, void *lpSecurityAttributes);")
local resolved = pcall(function() return ffi.C.CreateDirectoryA end)
if resolved then
mkdirFn = function(path) pcall(ffi.C.CreateDirectoryA, path, nil) end
end
else
pcall(ffi.cdef, "int mkdir(const char *pathname, unsigned int mode);")
local resolved = pcall(function() return ffi.C.mkdir end)
if resolved then
mkdirFn = function(path) pcall(ffi.C.mkdir, path, 493) end -- 0755
end
end
return mkdirFn
end
-- Mount an external directory onto the physfs read path (appended, so the
-- game's own source always wins a name clash). Returns true on success.
--
-- PHYSFS_mount is exported by love's own binary. How ffi finds it differs
-- per platform: on macOS/Linux the symbol is in the default namespace, so
-- ffi.C resolves it; on Windows it lives in love.dll, which ffi.C does NOT
-- search, so love.dll is loaded explicitly with ffi.load("love"). Try the
-- default first, then love.
local physfsMountFn = nil
local function resolveMount()
if physfsMountFn ~= nil then return physfsMountFn end
physfsMountFn = false
local ok, ffi = pcall(require, "ffi")
if not ok then return physfsMountFn end
pcall(ffi.cdef,
"int PHYSFS_mount(const char *newDir, const char *mountPoint, int appendToPath);")
local libs = {
function() return ffi.C end,
function() return ffi.load("love") end,
}
for _, getlib in ipairs(libs) do
local okl, lib = pcall(getlib)
if okl and lib then
local oks, fn = pcall(function() return lib.PHYSFS_mount end)
if oks and fn then
physfsMountFn = function(d)
local okr, ret = pcall(fn, d, "", 1)
return okr and ret ~= 0
end
break
end
end
end
return physfsMountFn
end
local function mountReadable(dir)
local fn = resolveMount()
if not fn then return false end
return fn(dir)
end
-- The portable game folder when the cache should live there, else nil.
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
-- desktop portable install (SaveData) and a working windowless mkdir.
local portableRoot = nil
local portableResolved = false
local function resolvePortableRoot()
if portableResolved then return portableRoot end
portableResolved = true
portableRoot = nil
if not resolveMkdir() then return nil end
local base = require("src.core.SaveData").portableBaseDir()
if not base then return nil end
if love.filesystem.getSource and base == love.filesystem.getSource() then
-- source run: the folder is already the physfs source
portableRoot = base
elseif mountReadable(base) then
-- fused build: base is next to the executable; mount it so io.* writes
-- there are visible to love.filesystem/require/newImage
portableRoot = base
end
return portableRoot
end
function CacheFs.root()
return resolvePortableRoot()
end
local function realPath(root, rel)
return root .. SEP .. rel:gsub("/", SEP)
end
-- create every parent directory of `rel` under `root` (best effort; an
-- already-existing directory is fine, a genuine failure surfaces when the
-- subsequent io.open write fails)
local function ensureParents(root, rel)
local mkdir = resolveMkdir()
if not mkdir then return end
local parts = {}
for part in rel:gmatch("[^/]+") do parts[#parts + 1] = part end
local cur = root
for i = 1, #parts - 1 do
cur = cur .. SEP .. parts[i]
mkdir(cur)
end
end
-- write cache-relative `rel` (forward-slash path) with the given bytes;
-- returns ok, err like love.filesystem.write
function CacheFs.write(rel, data)
local root = CacheFs.root()
if root then
ensureParents(root, rel)
local f, err = io.open(realPath(root, rel), "wb")
if not f then return false, err end
f:write(data)
f:close()
return true
end
local parent = rel:match("^(.*)/[^/]+$")
if parent and not love.filesystem.createDirectory(parent) then
local info = love.filesystem.getInfo(parent)
local reason = info and ("a " .. info.type .. " already exists there")
or "unknown reason"
return false, "could not create " .. parent .. ": " .. reason
end
return love.filesystem.write(rel, data)
end
-- read cache-relative `rel`; returns the bytes or nil
function CacheFs.read(rel)
local root = CacheFs.root()
if root then
local f = io.open(realPath(root, rel), "rb")
if not f then return nil end
local data = f:read("*a")
f:close()
return data
end
return love.filesystem.read(rel)
end
-- does cache-relative `rel` exist as a file?
function CacheFs.exists(rel)
local root = CacheFs.root()
if root then
local f = io.open(realPath(root, rel), "rb")
if not f then return false end
f:close()
return true
end
return love.filesystem.getInfo(rel, "file") ~= nil
end
-- remove a single cache-relative file
function CacheFs.remove(rel)
local root = CacheFs.root()
if root then
os.remove(realPath(root, rel))
return
end
love.filesystem.remove(rel)
end
-- Remove the game-folder copy of a cache subtree before a fresh import, so a
-- cache-format bump does not leave orphaned files behind. No-op when the
-- portable cache is inactive (the save-directory copy is cleared by
-- RomImporter's own removeTree). The tree is enumerated through
-- love.filesystem (the game folder is mounted) and the real files deleted
-- with os.remove; empty directories are harmless and left in place.
function CacheFs.removeTree(rel)
local root = CacheFs.root()
if not root then return end
local function walk(r)
local info = love.filesystem.getInfo(r)
if not info then return end
if info.type == "directory" then
for _, child in ipairs(love.filesystem.getDirectoryItems(r)) do
walk(r .. "/" .. child)
end
else
os.remove(realPath(root, r))
end
end
walk(rel)
end
return CacheFs
+6 -6
View File
@@ -126,14 +126,14 @@ function ImageWriter.columnsToRows(raw, tilesWide, tilesHigh, bytesPerTile)
end end
function ImageWriter.save(image, path) function ImageWriter.save(image, path)
local parent = path:match("^(.*)/[^/]+$")
if parent then
local ok, err = love.filesystem.createDirectory(parent)
if not ok then error("could not create " .. parent .. ": " .. tostring(err)) end
end
local ok, fileData = pcall(image.encode, image, "png") local ok, fileData = pcall(image.encode, image, "png")
if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end
local written, writeError = love.filesystem.write(path, fileData) -- CacheFs routes this to the OS save directory (normal builds) or straight
-- into the game folder (portable installs), creating parent directories as
-- needed. io.* needs the bytes as a string; love.filesystem would also
-- take the FileData, but getString() keeps one code path.
local CacheFs = require("src.import.CacheFs")
local written, writeError = CacheFs.write(path, fileData:getString())
if not written then if not written then
error("could not write " .. path .. ": " .. tostring(writeError)) error("could not write " .. path .. ": " .. tostring(writeError))
end end
+5 -6
View File
@@ -87,12 +87,11 @@ function LuaWriter.encode(value)
end end
function LuaWriter.write(path, value) function LuaWriter.write(path, value)
local parent = path:match("^(.*)/[^/]+$") -- CacheFs routes this to the OS save directory (normal builds) or straight
if parent then -- into the game folder (portable installs); it also creates the parent
local ok, err = love.filesystem.createDirectory(parent) -- directories. See src/import/CacheFs.lua.
if not ok then error("could not create " .. parent .. ": " .. tostring(err)) end local CacheFs = require("src.import.CacheFs")
end local ok, err = CacheFs.write(path, LuaWriter.encode(value))
local ok, err = love.filesystem.write(path, LuaWriter.encode(value))
if not ok then error("could not write " .. path .. ": " .. tostring(err)) end if not ok then error("could not write " .. path .. ": " .. tostring(err)) end
end end
+5 -4
View File
@@ -1669,10 +1669,11 @@ function RomExtractor:extractAudio()
chunks[index] = self.rom.data:sub(first, first + 0x3FFF) chunks[index] = self.rom.data:sub(first, first + 0x3FFF)
self:tick("Sound programs", index, #bankOrder + 2) self:tick("Sound programs", index, #bankOrder + 2)
end end
local ok, writeError = love.filesystem.createDirectory( -- CacheFs (not love.filesystem directly) so a portable install lands this
"assets/generated/audio") -- in the game folder with the rest of the cache; it creates the parent
if ok == false then error("could not create audio cache: " .. tostring(writeError)) end -- directory too.
ok, writeError = love.filesystem.write( local CacheFs = require("src.import.CacheFs")
local ok, writeError = CacheFs.write(
"assets/generated/audio/programs.bin", table.concat(chunks)) "assets/generated/audio/programs.bin", table.concat(chunks))
if not ok then error("could not write audio programs: " .. tostring(writeError)) end if not ok then error("could not write audio programs: " .. tostring(writeError)) end
+132 -130
View File
@@ -24,8 +24,11 @@ local REQUIRED_FILES = {
} }
local function allRequiredFilesExist() local function allRequiredFilesExist()
-- CacheFs.exists checks the game folder directly for a portable install,
-- otherwise the save directory through love.filesystem.
local CacheFs = require("src.import.CacheFs")
for _, path in ipairs(REQUIRED_FILES) do for _, path in ipairs(REQUIRED_FILES) do
if not love.filesystem.getInfo(path, "file") then return false end if not CacheFs.exists(path) then return false end
end end
return true return true
end end
@@ -38,128 +41,21 @@ local function sourceTreeHasData()
return real == love.filesystem.getSource() return real == love.filesystem.getSource()
end end
-- ------- portable ROM-derived asset cache -- ------- ROM cache location
-- --
-- The extracted cache (data/generated, assets/generated) is written -- The extracted cache (data/generated, assets/generated) plus the
-- exclusively through love.filesystem.write, which always targets the OS -- rom-cache.complete marker normally live in LÖVE's per-user OS save
-- save directory -- it cannot be redirected to an arbitrary folder. So a -- directory. A portable install instead keeps them in the game folder next
-- portable install mirrors the cache both ways instead: after a fresh -- to the executable (the folder holding portable.txt), so nothing is left on
-- import, every generated file is copied out to the portable folder -- the host machine. Every cache write/read/remove goes through CacheFs,
-- (SaveData.portableFs's io.* companion); on a later boot -- possibly on a -- which writes that folder with io.* and makes it readable (mounting it via
-- different machine sharing the same USB copy -- a matching portable -- PhysFS for a fused build) -- there is no mirror step and no per-file
-- cache is copied back into the save directory before the normal -- os.execute (issue #74: that flashed a console window per file on Windows
-- isReady() check runs, so nothing downstream needs to know the cache -- and froze the app).
-- ever lived anywhere but the save directory.
local PORTABLE_CACHE_DIRS = { "data/generated", "assets/generated" }
local PORTABLE_MANIFEST_NAME = "portable_cache_manifest.txt"
local PORTABLE_SEP = package.config:sub(1, 1)
local function walkLoveDir(dir, out)
out = out or {}
for _, name in ipairs(love.filesystem.getDirectoryItems(dir)) do
local full = dir .. "/" .. name
local info = love.filesystem.getInfo(full)
if info and info.type == "directory" then
walkLoveDir(full, out)
elseif info and info.type == "file" then
out[#out + 1] = full
end
end
return out
end
local function portablePath(base, relPath)
return base .. PORTABLE_SEP .. relPath:gsub("/", PORTABLE_SEP)
end
local function ensurePortableDir(fullDirPath)
if love.system.getOS() == "Windows" then
os.execute(('mkdir "%s" 2>NUL'):format(fullDirPath))
else
os.execute(("mkdir -p '%s' 2>/dev/null"):format(fullDirPath))
end
end
-- copies data/generated + assets/generated out to the portable folder
-- after a fresh import; a plain-text manifest travels alongside so a
-- later sync-in knows exactly which files to copy back without needing
-- to list an arbitrary external directory (io.* has no listdir)
local function syncCacheToPortable()
local SaveData = require("src.core.SaveData")
local base = SaveData.portableBaseDir()
if not base then return end
local manifest = {}
for _, dir in ipairs(PORTABLE_CACHE_DIRS) do
if love.filesystem.getInfo(dir, "directory") then
for _, relPath in ipairs(walkLoveDir(dir)) do
local data = love.filesystem.read(relPath)
if data then
local outPath = portablePath(base, relPath)
local outDir = outPath:match("^(.*)" .. PORTABLE_SEP .. "[^" .. PORTABLE_SEP .. "]+$")
if outDir then ensurePortableDir(outDir) end
local f, err = io.open(outPath, "wb")
if f then
f:write(data)
f:close()
manifest[#manifest + 1] = relPath
else
require("src.core.Logger").error(
"portable cache: could not write %s: %s", outPath, tostring(err))
end
end
end
end
end
local mf = io.open(base .. PORTABLE_SEP .. PORTABLE_MANIFEST_NAME, "wb")
if mf then
mf:write(table.concat(manifest, "\n"))
mf:close()
end
local mk = io.open(base .. PORTABLE_SEP .. MARKER_PATH, "wb")
if mk then
mk:write(CACHE_MARKER)
mk:close()
end
end
-- copies a matching portable cache back into the save directory before
-- isReady() runs its normal check; a mismatched or missing marker means
-- either no portable cache exists yet or it belongs to an older build, so
-- it is left alone and a fresh import proceeds as usual
local function syncCacheFromPortable()
local SaveData = require("src.core.SaveData")
local base = SaveData.portableBaseDir()
if not base then return end
local markerFile = io.open(base .. PORTABLE_SEP .. MARKER_PATH, "rb")
if not markerFile then return end
local marker = markerFile:read("*a")
markerFile:close()
if marker ~= CACHE_MARKER then return end
local manifestFile = io.open(base .. PORTABLE_SEP .. PORTABLE_MANIFEST_NAME, "rb")
if not manifestFile then return end
local manifestBody = manifestFile:read("*a")
manifestFile:close()
for relPath in manifestBody:gmatch("[^\r\n]+") do
local f = io.open(portablePath(base, relPath), "rb")
if f then
local data = f:read("*a")
f:close()
love.filesystem.write(relPath, data)
end
end
love.filesystem.write(MARKER_PATH, CACHE_MARKER)
end
function RomImporter.isReady()
if sourceTreeHasData() then return true end
if love.filesystem.read(MARKER_PATH) ~= CACHE_MARKER
and require("src.core.SaveData").isPortable() then
syncCacheFromPortable()
end
return love.filesystem.read(MARKER_PATH) == CACHE_MARKER
and allRequiredFilesExist()
end
-- Remove a cache subtree from the OS save directory. The realDirectory
-- guard keeps this from ever deleting the game folder (portable installs
-- read the cache from there) or a developer's checked-out source tree.
local function removeTree(path) local function removeTree(path)
local info = love.filesystem.getInfo(path) local info = love.filesystem.getInfo(path)
if not info then return end if not info then return end
@@ -179,6 +75,48 @@ local function removeTree(path)
end end
end end
-- Portable installs read the cache from the game folder. Any copy an
-- earlier non-portable run -- or the pre-#74 build, which always wrote the
-- cache to the save directory and only mirrored it out -- left behind would
-- shadow it, because physfs searches the save directory before the source.
-- Clear it out once, and only when a remnant is actually present so a clean
-- install pays nothing.
local saveDirPurged = false
local function purgeSaveDirCache()
if saveDirPurged then return end
saveDirPurged = true
local saveDir = love.filesystem.getSaveDirectory()
local function saveDirHas(rel)
local f = io.open(saveDir .. "/" .. rel, "rb")
if not f then return false end
f:close()
return true
end
if not (saveDirHas(MARKER_PATH) or saveDirHas(REQUIRED_FILES[1])) then
return
end
removeTree("data/generated")
removeTree("assets/generated")
love.filesystem.remove(MARKER_PATH)
end
function RomImporter.isReady()
local CacheFs = require("src.import.CacheFs")
if CacheFs.root() then
-- Portable: the cache lives in the game folder next to the executable
-- (mounted onto the read path for a fused build). Drop any stale
-- save-directory copy that would otherwise shadow it at runtime -- and,
-- for a source run, hide the game folder from sourceTreeHasData below.
purgeSaveDirCache()
end
-- Generated data sitting in the physfs source -- a developer checkout, a
-- Python/bootstrap build, or a source-run portable import -- is always
-- current (as it has always been). A fused portable install is not the
-- source, so it falls through to the version-marker gate.
if sourceTreeHasData() then return true end
return CacheFs.read(MARKER_PATH) == CACHE_MARKER and allRequiredFilesExist()
end
local function decodeManifest() local function decodeManifest()
local raw, readError = love.filesystem.read("tools/rom_manifest.json") local raw, readError = love.filesystem.read("tools/rom_manifest.json")
if not raw then error("ROM import metadata is missing: " .. tostring(readError)) end if not raw then error("ROM import metadata is missing: " .. tostring(readError)) end
@@ -226,6 +164,22 @@ local function commandOutput(command)
return result ~= "" and result or nil return result ~= "" and result or nil
end end
-- LOVE 11.5 on Android has no native file picker (love.window.showFileDialog
-- is a LOVE 12 nightly-only addition) and never fires love.filedropped, so
-- neither desktop path below works there. conf.lua points the Android save
-- directory at the app's external-files folder instead (readable/writable
-- via USB or a file manager, no runtime permission needed), and this scans
-- it directly through love.filesystem -- already mounted at the physfs
-- root, so no io.* absolute-path handling is needed.
local function scanForRom()
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.gb$") and love.filesystem.getInfo(name, "file") then
return name
end
end
return nil
end
local function chooseRom() local function chooseRom()
local platform = love.system.getOS() local platform = love.system.getOS()
if platform == "OS X" then if platform == "OS X" then
@@ -252,14 +206,16 @@ local function chooseRom()
end end
function RomImporter.new(onComplete) function RomImporter.new(onComplete)
local previousMarker = love.filesystem.read(MARKER_PATH) local previousMarker = require("src.import.CacheFs").read(MARKER_PATH)
local returning = previousMarker ~= nil and previousMarker ~= CACHE_MARKER local returning = previousMarker ~= nil and previousMarker ~= CACHE_MARKER
return setmetatable({ local android = love.system.getOS() == "Android"
local self = setmetatable({
onComplete = onComplete, onComplete = onComplete,
logo = love.graphics.newImage("assets/logo/logo.png"), logo = love.graphics.newImage("assets/logo/logo.png"),
bcg = love.graphics.newImage("assets/logo/bcg.png"), bcg = love.graphics.newImage("assets/logo/bcg.png"),
state = "waiting", state = "waiting",
returning = returning, returning = returning,
android = android,
status = returning and "More assets are needed from your ROM" status = returning and "More assets are needed from your ROM"
or "Choose or drop a Pokemon Red ROM", or "Choose or drop a Pokemon Red ROM",
detail = returning detail = returning
@@ -272,6 +228,30 @@ function RomImporter.new(onComplete)
pulse = 0, pulse = 0,
button = {}, button = {},
}, RomImporter) }, RomImporter)
if android then
self.status = returning and "More ROM assets needed" or "Get your Pokemon Red ROM (.gb) in"
self.detail = "Tap Choose ROM to pick your file"
local name = scanForRom()
if name then
self:startData(love.filesystem.read(name), name)
end
end
return self
end
-- The system picker runs as a separate top activity, so LOVE's own
-- love.focus/love.visible pause while it's up (see main.lua) -- once the
-- player returns here with a file picked, GameActivity has already copied
-- it into the folder scanForRom checks, so a rescan on refocus picks it up
-- without the player needing to tap the button again.
function RomImporter:focus(f)
if not (f and self.android and self.state ~= "working") then return end
local name = scanForRom()
if name then
self:startData(love.filesystem.read(name), name)
end
end end
function RomImporter:setError(message) function RomImporter:setError(message)
@@ -308,9 +288,15 @@ function RomImporter:startData(data, displayName)
end end
self.status = "Preparing private game data" self.status = "Preparing private game data"
coroutine.yield() coroutine.yield()
-- Clear any previous cache from both possible homes: the save directory
-- (removeTree) and, for a portable install, the game folder (CacheFs).
local CacheFs = require("src.import.CacheFs")
removeTree("data/generated") removeTree("data/generated")
removeTree("assets/generated") removeTree("assets/generated")
love.filesystem.remove(MARKER_PATH) love.filesystem.remove(MARKER_PATH)
CacheFs.removeTree("data/generated")
CacheFs.removeTree("assets/generated")
CacheFs.remove(MARKER_PATH)
local manifest = decodeManifest() local manifest = decodeManifest()
local RomExtractor = require("src.import.RomExtractor") local RomExtractor = require("src.import.RomExtractor")
@@ -325,13 +311,12 @@ function RomImporter:startData(data, displayName)
extractor:run() extractor:run()
self.romData = nil self.romData = nil
collectgarbage("collect") collectgarbage("collect")
local ok, writeError = love.filesystem.write(MARKER_PATH, CACHE_MARKER) -- Written last: the marker is what isReady() checks, so it must only
-- appear once every required file is in place. CacheFs puts it beside
-- the cache -- the game folder for a portable install, else the save
-- directory.
local ok, writeError = CacheFs.write(MARKER_PATH, CACHE_MARKER)
if not ok then error("could not finish the private cache: " .. tostring(writeError)) end if not ok then error("could not finish the private cache: " .. tostring(writeError)) end
if require("src.core.SaveData").isPortable() then
self.status = "Copying data to the portable folder"
coroutine.yield()
syncCacheToPortable()
end
self.state = "complete" self.state = "complete"
self.status = "Ready" self.status = "Ready"
self.detail = "Starting Pokemon Red..." self.detail = "Starting Pokemon Red..."
@@ -362,6 +347,22 @@ end
function RomImporter:choose() function RomImporter:choose()
if self.state == "working" then return end if self.state == "working" then return end
if self.android then
local name = scanForRom()
if name then
self:startData(love.filesystem.read(name), name)
elseif not love.system.pickFile() then
-- Picker unavailable (API < 19, or no document-picker app installed):
-- fall back to the USB folder-drop path. Not setError(): that status
-- text ("could not be imported") reads as a rejected file, not "none
-- found yet" -- and detail only renders 3 wrapped lines, so the path
-- again gets the line to itself.
self.state = "waiting"
self.status = "No picker available, copy your ROM into:"
self.detail = love.filesystem.getSaveDirectory()
end
return
end
local path = chooseRom() local path = chooseRom()
if path then if path then
self:startPath(path) self:startPath(path)
@@ -476,7 +477,8 @@ function RomImporter:draw()
buttonWidth, "center") buttonWidth, "center")
setColor255(74, 88, 72) setColor255(74, 88, 72)
love.graphics.setFont(smallFont) love.graphics.setFont(smallFont)
love.graphics.printf("or drop the .gb file here", love.graphics.printf(
self.android and "or copy the .gb via USB" or "or drop the .gb file here",
0, buttonY + buttonHeight + 12, width, "center") 0, buttonY + buttonHeight + 12, width, "center")
end end
+4 -2
View File
@@ -1,11 +1,12 @@
-- Rebinding over the logical Game Boy buttons (gap C2's file-12 half, -- Rebinding over the logical Game Boy buttons (gap C2's file-12 half,
-- 12-ui-extensibility 4.4): one row per button, A arms a "PRESS A BUTTON" -- 12-ui-extensibility 4.4): one row per button, A arms a "PRESS A BUTTON"
-- capture and the captured key or pad button lands in -- capture and the captured key or pad button lands in
-- save.options.bindings -- the overlay src/core/Bindings.lua -- save.options.bindings, which Input:applyBindings layers over its fixed
-- (04-mod-api-core) reads back over Input's fixed map. -- default map (see src/core/Input.lua and Game:applyOptions).
local Font = require("src.render.Font") local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu") local ListMenu = require("src.ui.ListMenu")
local Input = require("src.core.Input")
local BindingsMenu = setmetatable({}, { __index = ListMenu }) local BindingsMenu = setmetatable({}, { __index = ListMenu })
BindingsMenu.__index = BindingsMenu BindingsMenu.__index = BindingsMenu
@@ -78,6 +79,7 @@ function BindingsMenu:storeBinding(slot, value)
b[slot] = value b[slot] = value
opts.bindings[item.button.id] = b opts.bindings[item.button.id] = b
item.right = boundKey(opts.bindings, item.button):upper() item.right = boundKey(opts.bindings, item.button):upper()
Input:applyBindings(opts.bindings)
if game.writeOptions then game:writeOptions() end if game.writeOptions then game:writeOptions() end
end end
+6 -1
View File
@@ -3100,9 +3100,14 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
local outdoor = Map.isOutdoor(self.map.def) local outdoor = Map.isOutdoor(self.map.def)
require("src.core.Sound").play(Game.data, require("src.core.Sound").play(Game.data,
outdoor and "Go_Outside" or "Go_Inside") outdoor and "Go_Outside" or "Go_Inside")
-- stepping out of an outdoor door mat (the original's walk-out) -- stepping out of an outdoor door/cave entrance (the original's
-- walk-out). Auto-walk leaves the mat, so the arrival disable
-- (warpEntryCell / justWarped) is unnecessary -- and would let you
-- stand on the door without re-entering if you hold back into it.
if outdoor and self.player.facing == "down" if outdoor and self.player.facing == "down"
and self.map:isWarpTileCell(self.player.cellX, self.player.cellY) then and self.map:isWarpTileCell(self.player.cellX, self.player.cellY) then
self.warpEntryCell = nil
self.justWarped = false
self:scriptMove(self.player, "down", 1) self:scriptMove(self.player, "down", 1)
end end
end end
+56
View File
@@ -0,0 +1,56 @@
-- Same-frame press→release and multi-source hold regressions for Input.lua.
-- Self-contained: `luajit tests/input_hold_test.lua`; also dofile'd by
-- tests/run_tests.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("input hold")
local check = S.check
local Input = require("src.core.Input")
Input:init()
-- Quick tap before the next FixedStep must edge-fire without leaving isDown.
Input:keypressed("up")
Input:keyreleased("up")
Input:step()
check(Input:wasPressed("up"), "same-frame tap still edges wasPressed")
check(not Input:isDown("up"), "same-frame tap does not stick isDown")
Input:reset()
Input:keypressed("up")
Input:step()
check(Input:wasPressed("up"), "held press edges wasPressed")
check(Input:isDown("up"), "held press keeps isDown across step")
Input:step()
check(not Input:wasPressed("up"), "hold does not re-edge next step")
check(Input:isDown("up"), "hold stays down next step")
Input:keyreleased("up")
check(not Input:isDown("up"), "release clears isDown")
-- W and Up both map to up; releasing one must not drop the other.
Input:reset()
Input:keypressed("w")
Input:keypressed("up")
Input:step()
Input:keyreleased("w")
check(Input:isDown("up"), "second source keeps up held after first release")
Input:keyreleased("up")
check(not Input:isDown("up"), "last source release clears up")
-- Stick flick on→off before step must not stick.
Input:reset()
Input:gamepadaxis(nil, "leftx", -0.9)
Input:gamepadaxis(nil, "leftx", 0)
Input:step()
check(Input:wasPressed("left"), "stick flick edges wasPressed")
check(not Input:isDown("left"), "stick flick does not stick isDown")
-- Drivers that only inject pressQueue still get a one-step hold.
Input:reset()
table.insert(Input.pressQueue, "down")
Input:step()
check(Input:wasPressed("down"), "synthetic pressQueue edges wasPressed")
check(Input:isDown("down"), "synthetic pressQueue sets isDown")
S.finish()
+3
View File
@@ -2578,6 +2578,9 @@ do
check(status == 0 or status == true, "save_editor_mod_tests suite") check(status == 0 or status == true, "save_editor_mod_tests suite")
end end
-- ---------------------------------------------- input hold regressions
runSuites({ "tests/input_hold_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