mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2f2b432ff | |||
| 4c7633b56e | |||
| 943ae23e73 | |||
| 839cf19088 | |||
| 5019ba2caf | |||
| 290bde1c39 | |||
| b89b895962 |
@@ -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
|
||||
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
|
||||
between computers), drop an empty file named `portable.txt` next to the
|
||||
executable (or next to `main.lua`/`conf.lua` when running from source), then
|
||||
launch the game.
|
||||
between computers), drop an empty file named `portable.txt` next to the app
|
||||
(next to `PokemonRed.app`/`.exe`, or next to `main.lua`/`conf.lua` when
|
||||
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:
|
||||
|
||||
- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to
|
||||
that same folder instead of the OS save directory.
|
||||
- After a ROM import, the generated `data/generated` and `assets/generated`
|
||||
cache is copied into that folder too, so a later launch (even on a
|
||||
different computer, as long as the same folder comes along) reuses it
|
||||
without asking for the ROM again.
|
||||
- A ROM import writes the generated `data/generated` and `assets/generated`
|
||||
cache straight into that folder too (nothing is left in the OS save
|
||||
directory), so a later launch reuses it 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
|
||||
already written to either location is touched automatically, so copy files
|
||||
over yourself if you want to carry existing progress across the switch.
|
||||
|
||||
@@ -37,14 +37,29 @@ function love.conf(t)
|
||||
local osName = love._os
|
||||
local mobile = osName == "Android" or osName == "iOS"
|
||||
if mobile then
|
||||
-- On Android/iOS, width/height aspect picks portrait vs landscape
|
||||
-- (fullscreen alone is not enough). Use a tall portrait size; the
|
||||
-- OS then resizes to the real display. highdpi is required for
|
||||
-- Retina iOS (Android always behaves as highdpi).
|
||||
-- resizable is what unlocks orientation. SDL's Android backend, given no
|
||||
-- SDL_HINT_ORIENTATIONS (LÖVE sets none), calls setRequestedOrientation
|
||||
-- at window creation -- FULL_SENSOR when the window is resizable (rotates
|
||||
-- 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.height = 1920
|
||||
t.window.fullscreen = 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
|
||||
t.window.resizable = true
|
||||
end
|
||||
|
||||
@@ -184,7 +184,10 @@ end
|
||||
-- unfocused, so reset input on either transition rather than trust it.
|
||||
function love.focus(f)
|
||||
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)
|
||||
end
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
#app.name=LÖVE for Android
|
||||
|
||||
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_name=11.5a
|
||||
|
||||
|
||||
@@ -183,6 +183,18 @@ void vibrate(double seconds)
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -59,6 +59,14 @@ bool openURL(const std::string &url);
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -186,6 +186,18 @@ bool Filesystem::setIdentity(const char *ident, bool appendToPath)
|
||||
|
||||
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()) &&
|
||||
!love::android::mkdir(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_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
|
||||
// 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
|
||||
|
||||
@@ -180,6 +180,15 @@ void System::vibrate(double seconds) const
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::pickFile() const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::showFilePicker();
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::hasBackgroundMusic() const
|
||||
{
|
||||
#if defined(LOVE_ANDROID)
|
||||
|
||||
@@ -106,6 +106,15 @@ public:
|
||||
*/
|
||||
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.
|
||||
* Throws an exception on unsupported platforms.
|
||||
|
||||
@@ -95,6 +95,12 @@ int w_vibrate(lua_State *L)
|
||||
return 0;
|
||||
}
|
||||
|
||||
int w_pickFile(lua_State *L)
|
||||
{
|
||||
luax_pushboolean(L, instance()->pickFile());
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_hasBackgroundMusic(lua_State *L)
|
||||
{
|
||||
lua_pushboolean(L, instance()->hasBackgroundMusic());
|
||||
@@ -110,6 +116,7 @@ static const luaL_Reg functions[] =
|
||||
{ "getPowerInfo", w_getPowerInfo },
|
||||
{ "openURL", w_openURL },
|
||||
{ "vibrate", w_vibrate },
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "hasBackgroundMusic", w_hasBackgroundMusic },
|
||||
{ 0, 0 }
|
||||
};
|
||||
|
||||
@@ -61,6 +61,13 @@ public class GameActivity extends SDLActivity {
|
||||
protected final int[] recordAudioRequestDummy = new int[1];
|
||||
public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
|
||||
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 needToCopyGameInArchive = false;
|
||||
private boolean storagePermissionUnnecessary = false;
|
||||
@@ -332,6 +339,64 @@ public class GameActivity extends SDLActivity {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -62,10 +62,15 @@
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
|
||||
+56
-5
@@ -141,11 +141,20 @@ build_win() {
|
||||
say "building Windows (win64) app"
|
||||
local zip_name="love-$LOVE_VERSION-win64.zip"
|
||||
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
|
||||
say "downloading LÖVE $LOVE_VERSION win64 binaries"
|
||||
curl -fL --progress-bar \
|
||||
"https://github.com/love2d/love/releases/download/$LOVE_VERSION/$zip_name" \
|
||||
-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
|
||||
|
||||
local extract_dir="$WORK/love-win64"
|
||||
@@ -174,21 +183,63 @@ build_linux() {
|
||||
say "building Linux (x86_64 AppImage) app"
|
||||
local appimage_name="love-$LOVE_VERSION-x86_64.AppImage"
|
||||
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
|
||||
say "downloading LÖVE $LOVE_VERSION Linux AppImage"
|
||||
curl -fL --progress-bar \
|
||||
"https://github.com/love2d/love/releases/download/$LOVE_VERSION/$appimage_name" \
|
||||
-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
|
||||
chmod +x "$love_appimage"
|
||||
|
||||
# Same fusion trick as the Windows exe: love looks for a zip appended to
|
||||
# its own running binary, and an AppImage is just an ELF executable, so
|
||||
# concatenating game.love onto it works the same way `cat love.exe
|
||||
# game.love` does on Windows.
|
||||
# The Windows-style `cat love.exe game.love` fusion does NOT work here:
|
||||
# an AppImage is a small runtime ELF with a squashfs appended, and at
|
||||
# launch the runtime mounts the squashfs and executes bin/love from
|
||||
# *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"
|
||||
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"
|
||||
|
||||
local zip_out="$DIST/linux/$APP_NAME-linux.zip"
|
||||
|
||||
@@ -101,7 +101,7 @@ def set_prop(text, key, value):
|
||||
text = re.sub(r"(?m)^app\.name_byte_array=.*\n?", "", text)
|
||||
text = set_prop(text, "app.name", name)
|
||||
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:
|
||||
text = set_prop(text, "app.version_name", version)
|
||||
text = set_prop(text, "app.version_code", version_code)
|
||||
|
||||
@@ -155,7 +155,7 @@ require_ios_libraries
|
||||
apply_ios_branding() {
|
||||
[ -f "$OVERLAY_PLIST" ] || fail "missing overlay plist: $OVERLAY_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"
|
||||
}
|
||||
|
||||
|
||||
+6
-5
@@ -349,17 +349,16 @@ function Game:gamepadaxis(joystick, axis, value)
|
||||
Input:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
-- Window lost focus or got minimized: any release event due while it was
|
||||
-- unfocused/hidden can be swallowed by the OS instead of delivered here,
|
||||
-- which would otherwise leave a held direction stuck on.
|
||||
-- Window focus/visibility flips: a release due while unfocused/hidden can
|
||||
-- be swallowed by the OS. Reset on both edges -- gaining focus with a
|
||||
-- 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)
|
||||
if f then return end
|
||||
Input:reset()
|
||||
TouchInput:reset()
|
||||
end
|
||||
|
||||
function Game:visible(v)
|
||||
if v then return end
|
||||
Input:reset()
|
||||
TouchInput:reset()
|
||||
end
|
||||
@@ -369,6 +368,7 @@ end
|
||||
-- flags it owned.
|
||||
function Game:joystickremoved(joystick)
|
||||
Input:reset()
|
||||
TouchInput:reset()
|
||||
end
|
||||
|
||||
function Game:touchpressed(id, x, y)
|
||||
@@ -433,6 +433,7 @@ function Game:applyOptions(opts)
|
||||
require("src.render.Tilt").applyOptions(opts)
|
||||
require("src.render.GBCFX").applyOptions(opts)
|
||||
require("src.core.VideoMode").applyOptions(opts)
|
||||
Input:applyBindings(opts.bindings)
|
||||
end
|
||||
|
||||
function Game:restoreSave(loaded, recovered)
|
||||
|
||||
+93
-16
@@ -3,7 +3,7 @@
|
||||
|
||||
local Input = {}
|
||||
|
||||
local BINDINGS = {
|
||||
local DEFAULT_BINDINGS = {
|
||||
up = "up", w = "up",
|
||||
down = "down", s = "down",
|
||||
left = "left", a = "left",
|
||||
@@ -18,8 +18,11 @@ local BINDINGS = {
|
||||
-- Escape = start for desktop friendliness.
|
||||
|
||||
-- LÖVE's standard gamepad mapping (SDL game controller DB), consistent
|
||||
-- across Xbox/PlayStation/generic controllers on desktop and mobile.
|
||||
local GAMEPAD_BINDINGS = {
|
||||
-- across Xbox/PlayStation/generic controllers on desktop and mobile. Some
|
||||
-- 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",
|
||||
a = "a", b = "b",
|
||||
start = "start", back = "select",
|
||||
@@ -32,9 +35,33 @@ local STICK_ON = 0.5
|
||||
local STICK_OFF = 0.3
|
||||
|
||||
function Input:init()
|
||||
self:applyBindings(nil)
|
||||
self:reset()
|
||||
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
|
||||
-- fallback if a release event never arrives -- focus loss, a minimized
|
||||
-- window, or a disconnected gamepad can all swallow the key-up/button-up
|
||||
@@ -44,45 +71,95 @@ function Input:reset()
|
||||
self.state = {}
|
||||
self.pressQueue = {}
|
||||
self.pressed = {}
|
||||
self.sources = {}
|
||||
self.stickAxis = { x = 0, y = 0 }
|
||||
self.stickDir = nil
|
||||
end
|
||||
|
||||
function Input:keypressed(key)
|
||||
local btn = BINDINGS[key]
|
||||
if btn then
|
||||
-- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the
|
||||
-- same GB button. Track them individually so releasing one doesn't clear
|
||||
-- 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)
|
||||
end
|
||||
self.state[btn] = true
|
||||
end
|
||||
|
||||
function Input:keyreleased(key)
|
||||
local btn = BINDINGS[key]
|
||||
if btn then
|
||||
local function release(self, btn, source)
|
||||
local sources = self.sources[btn]
|
||||
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
|
||||
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.
|
||||
-- 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()
|
||||
self.pressed = {}
|
||||
for _, btn in ipairs(self.pressQueue) do
|
||||
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
|
||||
self.pressQueue = {}
|
||||
end
|
||||
|
||||
function Input:gamepadpressed(joystick, button)
|
||||
local btn = GAMEPAD_BINDINGS[button]
|
||||
local btn = self.padBindings[button]
|
||||
if btn then
|
||||
table.insert(self.pressQueue, btn)
|
||||
press(self, btn, "pad:" .. button)
|
||||
end
|
||||
end
|
||||
|
||||
function Input:gamepadreleased(joystick, button)
|
||||
local btn = GAMEPAD_BINDINGS[button]
|
||||
local btn = self.padBindings[button]
|
||||
if btn then
|
||||
self.state[btn] = false
|
||||
release(self, btn, "pad:" .. button)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -112,10 +189,10 @@ function Input:gamepadaxis(joystick, axis, value)
|
||||
|
||||
if newDir ~= self.stickDir then
|
||||
if self.stickDir then
|
||||
self.state[self.stickDir] = false
|
||||
release(self, self.stickDir, "stick")
|
||||
end
|
||||
if newDir then
|
||||
table.insert(self.pressQueue, newDir)
|
||||
press(self, newDir, "stick")
|
||||
end
|
||||
self.stickDir = newDir
|
||||
end
|
||||
|
||||
+33
-7
@@ -83,15 +83,41 @@ local function detectPortable()
|
||||
portableChecked = true
|
||||
portableBase = false
|
||||
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 = {}
|
||||
if love.filesystem.getSourceBaseDirectory then
|
||||
candidates[#candidates + 1] = love.filesystem.getSourceBaseDirectory()
|
||||
end
|
||||
if love.filesystem.getSource then
|
||||
candidates[#candidates + 1] = love.filesystem.getSource()
|
||||
end
|
||||
local appDir = appContainer(src) or appContainer(sbd)
|
||||
if appDir then candidates[#candidates + 1] = appDir end
|
||||
if sbd then candidates[#candidates + 1] = sbd end
|
||||
if src then candidates[#candidates + 1] = src end
|
||||
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
|
||||
break
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -126,14 +126,14 @@ function ImageWriter.columnsToRows(raw, tilesWide, tilesHigh, bytesPerTile)
|
||||
end
|
||||
|
||||
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")
|
||||
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
|
||||
error("could not write " .. path .. ": " .. tostring(writeError))
|
||||
end
|
||||
|
||||
@@ -87,12 +87,11 @@ function LuaWriter.encode(value)
|
||||
end
|
||||
|
||||
function LuaWriter.write(path, value)
|
||||
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, err = love.filesystem.write(path, LuaWriter.encode(value))
|
||||
-- CacheFs routes this to the OS save directory (normal builds) or straight
|
||||
-- into the game folder (portable installs); it also creates the parent
|
||||
-- directories. See src/import/CacheFs.lua.
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local ok, err = CacheFs.write(path, LuaWriter.encode(value))
|
||||
if not ok then error("could not write " .. path .. ": " .. tostring(err)) end
|
||||
end
|
||||
|
||||
|
||||
@@ -1669,10 +1669,11 @@ function RomExtractor:extractAudio()
|
||||
chunks[index] = self.rom.data:sub(first, first + 0x3FFF)
|
||||
self:tick("Sound programs", index, #bankOrder + 2)
|
||||
end
|
||||
local ok, writeError = love.filesystem.createDirectory(
|
||||
"assets/generated/audio")
|
||||
if ok == false then error("could not create audio cache: " .. tostring(writeError)) end
|
||||
ok, writeError = love.filesystem.write(
|
||||
-- CacheFs (not love.filesystem directly) so a portable install lands this
|
||||
-- in the game folder with the rest of the cache; it creates the parent
|
||||
-- directory too.
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local ok, writeError = CacheFs.write(
|
||||
"assets/generated/audio/programs.bin", table.concat(chunks))
|
||||
if not ok then error("could not write audio programs: " .. tostring(writeError)) end
|
||||
|
||||
|
||||
+132
-130
@@ -24,8 +24,11 @@ local REQUIRED_FILES = {
|
||||
}
|
||||
|
||||
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
|
||||
if not love.filesystem.getInfo(path, "file") then return false end
|
||||
if not CacheFs.exists(path) then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
@@ -38,128 +41,21 @@ local function sourceTreeHasData()
|
||||
return real == love.filesystem.getSource()
|
||||
end
|
||||
|
||||
-- ------- portable ROM-derived asset cache
|
||||
-- ------- ROM cache location
|
||||
--
|
||||
-- The extracted cache (data/generated, assets/generated) is written
|
||||
-- exclusively through love.filesystem.write, which always targets the OS
|
||||
-- save directory -- it cannot be redirected to an arbitrary folder. So a
|
||||
-- portable install mirrors the cache both ways instead: after a fresh
|
||||
-- import, every generated file is copied out to the portable folder
|
||||
-- (SaveData.portableFs's io.* companion); on a later boot -- possibly on a
|
||||
-- different machine sharing the same USB copy -- a matching portable
|
||||
-- cache is copied back into the save directory before the normal
|
||||
-- isReady() check runs, so nothing downstream needs to know the cache
|
||||
-- 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
|
||||
-- The extracted cache (data/generated, assets/generated) plus the
|
||||
-- rom-cache.complete marker normally live in LÖVE's per-user OS save
|
||||
-- directory. A portable install instead keeps them in the game folder next
|
||||
-- to the executable (the folder holding portable.txt), so nothing is left on
|
||||
-- the host machine. Every cache write/read/remove goes through CacheFs,
|
||||
-- which writes that folder with io.* and makes it readable (mounting it via
|
||||
-- PhysFS for a fused build) -- there is no mirror step and no per-file
|
||||
-- os.execute (issue #74: that flashed a console window per file on Windows
|
||||
-- and froze the app).
|
||||
|
||||
-- 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 info = love.filesystem.getInfo(path)
|
||||
if not info then return end
|
||||
@@ -179,6 +75,48 @@ local function removeTree(path)
|
||||
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 raw, readError = love.filesystem.read("tools/rom_manifest.json")
|
||||
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
|
||||
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 platform = love.system.getOS()
|
||||
if platform == "OS X" then
|
||||
@@ -252,14 +206,16 @@ local function chooseRom()
|
||||
end
|
||||
|
||||
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
|
||||
return setmetatable({
|
||||
local android = love.system.getOS() == "Android"
|
||||
local self = setmetatable({
|
||||
onComplete = onComplete,
|
||||
logo = love.graphics.newImage("assets/logo/logo.png"),
|
||||
bcg = love.graphics.newImage("assets/logo/bcg.png"),
|
||||
state = "waiting",
|
||||
returning = returning,
|
||||
android = android,
|
||||
status = returning and "More assets are needed from your ROM"
|
||||
or "Choose or drop a Pokemon Red ROM",
|
||||
detail = returning
|
||||
@@ -272,6 +228,30 @@ function RomImporter.new(onComplete)
|
||||
pulse = 0,
|
||||
button = {},
|
||||
}, 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
|
||||
|
||||
function RomImporter:setError(message)
|
||||
@@ -308,9 +288,15 @@ function RomImporter:startData(data, displayName)
|
||||
end
|
||||
self.status = "Preparing private game data"
|
||||
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("assets/generated")
|
||||
love.filesystem.remove(MARKER_PATH)
|
||||
CacheFs.removeTree("data/generated")
|
||||
CacheFs.removeTree("assets/generated")
|
||||
CacheFs.remove(MARKER_PATH)
|
||||
|
||||
local manifest = decodeManifest()
|
||||
local RomExtractor = require("src.import.RomExtractor")
|
||||
@@ -325,13 +311,12 @@ function RomImporter:startData(data, displayName)
|
||||
extractor:run()
|
||||
self.romData = nil
|
||||
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 require("src.core.SaveData").isPortable() then
|
||||
self.status = "Copying data to the portable folder"
|
||||
coroutine.yield()
|
||||
syncCacheToPortable()
|
||||
end
|
||||
self.state = "complete"
|
||||
self.status = "Ready"
|
||||
self.detail = "Starting Pokemon Red..."
|
||||
@@ -362,6 +347,22 @@ end
|
||||
|
||||
function RomImporter:choose()
|
||||
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()
|
||||
if path then
|
||||
self:startPath(path)
|
||||
@@ -476,7 +477,8 @@ function RomImporter:draw()
|
||||
buttonWidth, "center")
|
||||
setColor255(74, 88, 72)
|
||||
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")
|
||||
end
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
-- 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"
|
||||
-- capture and the captured key or pad button lands in
|
||||
-- save.options.bindings -- the overlay src/core/Bindings.lua
|
||||
-- (04-mod-api-core) reads back over Input's fixed map.
|
||||
-- save.options.bindings, which Input:applyBindings layers over its fixed
|
||||
-- default map (see src/core/Input.lua and Game:applyOptions).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Input = require("src.core.Input")
|
||||
|
||||
local BindingsMenu = setmetatable({}, { __index = ListMenu })
|
||||
BindingsMenu.__index = BindingsMenu
|
||||
@@ -78,6 +79,7 @@ function BindingsMenu:storeBinding(slot, value)
|
||||
b[slot] = value
|
||||
opts.bindings[item.button.id] = b
|
||||
item.right = boundKey(opts.bindings, item.button):upper()
|
||||
Input:applyBindings(opts.bindings)
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
end
|
||||
|
||||
|
||||
@@ -3100,9 +3100,14 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
||||
local outdoor = Map.isOutdoor(self.map.def)
|
||||
require("src.core.Sound").play(Game.data,
|
||||
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"
|
||||
and self.map:isWarpTileCell(self.player.cellX, self.player.cellY) then
|
||||
self.warpEntryCell = nil
|
||||
self.justWarped = false
|
||||
self:scriptMove(self.player, "down", 1)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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()
|
||||
@@ -2578,6 +2578,9 @@ do
|
||||
check(status == 0 or status == true, "save_editor_mod_tests suite")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- input hold regressions
|
||||
runSuites({ "tests/input_hold_test.lua" })
|
||||
|
||||
-- ---------------------------------------------- parity workstream tests
|
||||
-- Each tests/parity_*.lua is a self-contained file (own bootstrap + check,
|
||||
-- error()s if any assertion fails). Globbed, so dropping a new parity
|
||||
|
||||
Reference in New Issue
Block a user