diff --git a/common/FileSystem.cpp b/common/FileSystem.cpp index d0fc3adc6b..a5f662649f 100644 --- a/common/FileSystem.cpp +++ b/common/FileSystem.cpp @@ -1020,6 +1020,18 @@ std::FILE* FileSystem::OpenCFile(const char* filename, const char* mode, Error* #endif std::FILE* fp = std::fopen(filename, mode); + #if defined(__ANDROID__) + // libc fopen(O_CREAT) is denied on the FUSE-emulated external storage Android + // hands out for a user-chosen data folder — the same split that forces + // CreateDirectoryViaJava for mkdir(). Pre-create the empty file via the Java + // File API and retry: opening the now-existing file for a truncating write + // ("w"/"wb") IS permitted on FUSE (which is why writing EXISTING folder-card + // saves already works), so the FIRST save to a folder card on a custom data + // folder can now create its _pcsx2_index / save-data files instead of failing. + // Write/create modes only — never "r"/"r+b" (those must fail on a missing file). + if (!fp && (errno == EACCES || errno == EPERM) && mode[0] != 'r' && CreateFileViaJava(filename)) + fp = std::fopen(filename, mode); + #endif if (!fp) Error::SetErrno(error, errno); return fp; diff --git a/common/FileSystem.h b/common/FileSystem.h index e04fc93030..c8cb2ed251 100644 --- a/common/FileSystem.h +++ b/common/FileSystem.h @@ -132,6 +132,12 @@ namespace FileSystem /// (user-picked custom data folders) despite all-files access. Returns true if /// the directory exists afterwards. bool CreateDirectoryViaJava(const char* path); + /// Create an empty file via the Java File API (NativeApp.createFilePath). + /// Fallback for when libc fopen(O_CREAT) is denied on FUSE-emulated external + /// storage (user-picked custom data folders) despite all-files access; once the + /// file exists the native truncating write that follows succeeds. Makes NEW + /// folder-card saves work on a custom data folder. Returns true if it exists after. + bool CreateFileViaJava(const char* path); #endif /// Sharing modes for OpenSharedCFile(). diff --git a/pcsx2/SIO/Memcard/MemoryCardFolder.cpp b/pcsx2/SIO/Memcard/MemoryCardFolder.cpp index 9cc325081a..feea8785ae 100644 --- a/pcsx2/SIO/Memcard/MemoryCardFolder.cpp +++ b/pcsx2/SIO/Memcard/MemoryCardFolder.cpp @@ -57,6 +57,15 @@ static std::optional loadYamlFile(const char* filePath) static void SaveYAMLToFile(const char* filename, const ryml::NodeRef& node) { auto file = FileSystem::OpenCFile(filename, "w"); + if (!file) + { + // FUSE-backed shared storage (a user-chosen data folder) denies libc file + // CREATION even though mkdir is routed through Java; opening a not-yet-existing + // _pcsx2_index here returns null, and emit_yaml()/fclose() on it segfaults on the + // first save to a folder card. Bail like WriteToFile() does instead of crashing. + Console.Error("(SaveYAMLToFile) Failed to open '%s'.", filename); + return; + } ryml::emit_yaml(node, file); std::fflush(file); std::fclose(file); diff --git a/platforms/android/app/src/main/cpp/native-lib.cpp b/platforms/android/app/src/main/cpp/native-lib.cpp index ffeea30e94..c0e4bf9601 100644 --- a/platforms/android/app/src/main/cpp/native-lib.cpp +++ b/platforms/android/app/src/main/cpp/native-lib.cpp @@ -122,6 +122,10 @@ std::atomic s_execute_exit{false}; // forever (the exit-game hang: EE breaks out, loop re-enters, repeat). Reset // at the top of runVMThread so a fresh launch starts clean. std::atomic s_stop_requested{false}; +// Set when setEnabledPatches had to CREATE gamesettings/_.ini for a game +// that booted without one: no LAYER_GAME is installed in that case, so reloadPatches +// must reinstall it before the per-game Enable list can take effect. +static std::atomic s_game_layer_needs_install{false}; static std::mutex s_cpu_thread_mutex; static std::deque> s_cpu_thread_queue; static std::thread::id s_cpu_thread_id; @@ -226,6 +230,16 @@ Java_kr_co_iefriends_pcsx2_NativeApp_setEeDiffVerify(JNIEnv*, jclass, jboolean e Cpu->Reset(); } +extern "C" +JNIEXPORT void JNICALL +Java_kr_co_iefriends_pcsx2_NativeApp_emulog(JNIEnv *env, jclass, jstring p_msg) { + // Route a Kotlin diagnostic line into the native Console so it lands in the emulog + // (the in-app Save Log export) — lets a handheld tester capture input logs with no PC. + const std::string msg = GetJavaString(env, p_msg); + if (!msg.empty()) + Console.WriteLnFmt("{}", msg); +} + // Read the real flag so the UI can reflect it. The toggle previously kept its // state in a Compose `remember`, so navigating away reset the switch to off while the native // flag stayed on — the switch was lying about whether the diagnostic was armed. @@ -1016,6 +1030,15 @@ Java_kr_co_iefriends_pcsx2_NativeApp_setFpsCap(JNIEnv *env, jclass clazz, Console.WriteLnFmt("@@ANDROID_FPSCAP@@ fps={} interval_ticks={}", fps, interval); } +extern "C" +JNIEXPORT void JNICALL +Java_kr_co_iefriends_pcsx2_NativeApp_setPortraitRenderTop(JNIEnv*, jclass, jboolean top) { + // GitHub #375: top-align the render in a portrait window instead of vertical-centering, + // so the bottom is free for touch controls. Sets a GS static read live per-present; + // safe to call with or without a running VM. + GSSetPortraitRenderTopAlign(top == JNI_TRUE); +} + extern "C" JNIEXPORT void JNICALL Java_kr_co_iefriends_pcsx2_NativeApp_setFrameSkip(JNIEnv *env, jclass clazz, @@ -1399,7 +1422,14 @@ Java_kr_co_iefriends_pcsx2_NativeApp_reloadPatches(JNIEnv *env, jclass clazz) { return -1; } - VMManager::ReloadPatches(true, true, true, true); + // setEnabledPatches may have just CREATED gamesettings/_.ini for a game + // that booted without one — no LAYER_GAME is installed then, so the per-game Enable + // list is invisible to ReloadEnabledLists. ReloadGameSettings re-reads the file, + // reinstalls the layer and reloads patches; it also runs ApplySettings, so only take + // that heavier path when the layer is actually missing. + if (!s_game_layer_needs_install.exchange(false, std::memory_order_acq_rel) || + !VMManager::ReloadGameSettings()) + VMManager::ReloadPatches(true, true, true, true); const u32 active_cheats = Patch::GetActiveCheatsCount(); Console.WriteLnFmt("@@ANDROID_PNACH@@ reload active_cheats={}", active_cheats); return static_cast(active_cheats); @@ -1565,6 +1595,22 @@ static std::vector jStringArrayToVector(JNIEnv* env, jobjectArray a // selected subset: drop the game's names from the list then re-add the selected // ones (exact per-game state without disturbing other games), and Save so it // persists across reset/relaunch. Call reloadPatches() afterward to apply. + +// Per-game settings INI for the running game, or empty when there's no VM / no CRC +// (Patch Manager opened from the library). Path computation is kept identical to +// gameIniBeginWrite's so BOTH halves of the game layer — the EmuCore overrides and the +// patch/cheat enable lists — land in the SAME file. +static std::string AndroidGameSettingsPath() { + if (!VMManager::HasValidVM()) + return {}; + u32 crc = VMManager::GetDiscCRC(); + if (crc == 0) + crc = VMManager::GetCurrentCRC(); + if (crc == 0) + return {}; + return VMManager::GetGameSettingsPath(VMManager::GetDiscSerial(), crc); +} + extern "C" JNIEXPORT void JNICALL Java_kr_co_iefriends_pcsx2_NativeApp_setEnabledPatches( @@ -1574,6 +1620,55 @@ Java_kr_co_iefriends_pcsx2_NativeApp_setEnabledPatches( const char* section = (cheats == JNI_TRUE) ? "Cheats" : "Patches"; auto lock = Host::GetSettingsLock(); + + // Scope to the running game. Upstream keys patch/cheat enable state on serial+CRC + // (FullscreenUI writes it to the game layer), and LayeredSettingsInterface returns the + // FIRST NON-EMPTY layer with LAYER_GAME ahead of LAYER_BASE — so a game-layer list + // fully shadows the base one. Writing to the base layer meant enabling e.g. "Widescreen + // 16:9" for one game auto-enabled the identically NAMED group in every other game, + // because Patch::EnablePatches matches purely by name. + const std::string game_ini = AndroidGameSettingsPath(); + if (!game_ini.empty()) { + // Load-then-modify: this file ALSO carries the EmuCore per-game overrides written + // by gameIniCommitWrite, so it must never be regenerated from scratch here. + INISettingsInterface gsi_file(game_ini); + gsi_file.Load(); + for (const auto& n : all) + gsi_file.RemoveFromStringList(section, "Enable", n.c_str()); + for (const auto& n : enabled) + gsi_file.AddToStringList(section, "Enable", n.c_str()); + Error error; + if (!gsi_file.Save(&error)) + Console.ErrorFmt("@@ANDROID_PNACH@@ game ini save failed: {}", error.GetDescription()); + + // Mirror into the live in-memory game layer so the next ReloadEnabledLists sees the + // change without re-reading the file. Null when the game booted without an INI — + // flag that so reloadPatches installs the layer. + if (SettingsInterface* gsi = Host::Internal::GetGameSettingsLayer()) { + for (const auto& n : all) + gsi->RemoveFromStringList(section, "Enable", n.c_str()); + for (const auto& n : enabled) + gsi->AddToStringList(section, "Enable", n.c_str()); + } else { + s_game_layer_needs_install.store(true, std::memory_order_release); + } + + // Migration + fall-through guard in one. GetStringList falls through to LAYER_BASE + // when the game layer's list is EMPTY, so a user who disables every cheat for game + // B would see game A's global names reappear. Dropping these names from the base + // list retires the legacy global state and closes that hole. + if (SettingsInterface* base = Host::Internal::GetBaseSettingsLayer()) { + bool changed = false; + for (const auto& n : all) + changed |= base->RemoveFromStringList(section, "Enable", n.c_str()); + if (changed) + base->Save(); + } + return; + } + + // No VM / no CRC (Patch Manager opened from the library): base layer, which is what the + // pre-boot browser has always targeted. SettingsInterface* si = Host::Internal::GetBaseSettingsLayer(); if (!si) return; @@ -1991,6 +2086,45 @@ bool FileSystem::CreateDirectoryViaJava(const char* path) return ok; } +bool FileSystem::CreateFileViaJava(const char* path) +{ + // Bridges to NativeApp.createFilePath (java.io.File.createNewFile). Fallback + // when libc fopen(O_CREAT) is denied on FUSE-emulated external storage; once + // the empty file exists the native truncating write that follows succeeds, + // which is what makes NEW folder-card saves work on a custom data folder. + // Mirrors CreateDirectoryViaJava above; same local-ref/exception discipline. + auto* env = static_cast(SDL_GetAndroidJNIEnv()); + if (env == nullptr) + return false; + jclass NativeApp = env->FindClass("kr/co/iefriends/pcsx2/NativeApp"); + if (NativeApp == nullptr) + { + env->ExceptionClear(); + return false; + } + jmethodID mid = env->GetStaticMethodID(NativeApp, "createFilePath", "(Ljava/lang/String;)Z"); + if (mid == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(NativeApp); + return false; + } + bool ok = false; + jstring j_path = env->NewStringUTF(path); + if (j_path != nullptr) + { + ok = (env->CallStaticBooleanMethod(NativeApp, mid, j_path) == JNI_TRUE); + if (env->ExceptionCheck()) + { + env->ExceptionClear(); + ok = false; + } + env->DeleteLocalRef(j_path); + } + env->DeleteLocalRef(NativeApp); + return ok; +} + void ReportTestResults(const char* label, int passed, int total) { auto* env = static_cast(SDL_GetAndroidJNIEnv()); @@ -3385,10 +3519,24 @@ Java_kr_co_iefriends_pcsx2_NativeApp_gameIniBeginWrite(JNIEnv*, jclass) { crc = VMManager::GetCurrentCRC(); if (crc == 0) return JNI_FALSE; - // Fresh interface (no Load) so the export is a clean regeneration of the - // current overrides — stale keys from a previous save never linger. - s_export_game_ini = std::make_unique( + // LOAD the existing file, then clear only the sections applyTo regenerates. + // + // This used to build a FRESH (unloaded) interface so stale per-game overrides couldn't + // linger — but that also dropped every FOREIGN key in the file, most visibly the + // [Patches]/[Cheats] "Enable" lists written by setEnabledPatches. The result was that + // changing ANY in-game setting silently wiped that game's enabled patches. Clearing just + // the sections we own still drops stale overrides (the original intent) while leaving + // anything we don't own alone — robust for future keys too, not only patches. + auto ini = std::make_unique( VMManager::GetGameSettingsPath(VMManager::GetDiscSerial(), crc)); + ini->Load(); // failure just means there was no file yet, i.e. nothing to preserve + static constexpr const char* OWNED_SECTIONS[] = { + "EmuCore", "EmuCore/CPU", "EmuCore/CPU/Recompiler", "EmuCore/GS", + "EmuCore/Gamefixes", "EmuCore/Speedhacks", "Framerate", "MemoryCards", + }; + for (const char* sec : OWNED_SECTIONS) + ini->ClearSection(sec); + s_export_game_ini = std::move(ini); return JNI_TRUE; } @@ -3415,8 +3563,16 @@ Java_kr_co_iefriends_pcsx2_NativeApp_gameIniCommitWrite(JNIEnv*, jclass) { return JNI_FALSE; Error error; bool ok = true; + + // The [Patches]/[Cheats] enable lists are preserved by gameIniBeginWrite loading the file + // instead of starting fresh; nothing to carry over here. Log what actually survives so a + // "my patches vanished" report can be diagnosed from an emulog instead of guesswork. + const size_t kept_patches = s_export_game_ini->GetStringList("Patches", "Enable").size(); + const size_t kept_cheats = s_export_game_ini->GetStringList("Cheats", "Enable").size(); + s_export_game_ini->RemoveEmptySections(); - if (s_export_game_ini->IsEmpty()) { + const bool empty = s_export_game_ini->IsEmpty(); + if (empty) { // No per-game overrides — remove the file entirely (FullscreenUI parity). const std::string fn = s_export_game_ini->GetFileName(); if (FileSystem::FileExists(fn.c_str())) @@ -3424,6 +3580,8 @@ Java_kr_co_iefriends_pcsx2_NativeApp_gameIniCommitWrite(JNIEnv*, jclass) { } else { ok = s_export_game_ini->Save(&error); } + Console.WriteLnFmt("@@ANDROID_GAMEINI@@ commit {} patches={} cheats={}", + empty ? "removed" : "saved", kept_patches, kept_cheats); s_export_game_ini.reset(); if (!ok) Console.ErrorFmt("@@ANDROID_GAMEINI@@ commit failed: {}", error.GetDescription()); diff --git a/platforms/android/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java b/platforms/android/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java index d76d690475..1cf0727109 100644 --- a/platforms/android/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java +++ b/platforms/android/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java @@ -154,6 +154,10 @@ public class NativeApp { */ public static native void commitSettings(); + /** Diagnostic: write a line to the native emulog (Console) so it shows in the in-app + * Save Log export. Used by the Joy-Con input diagnostic; no-ops if the console isn't open. */ + public static native void emulog(String msg); + /** * Live GS-only reconfigure for a running VM. Reloads the whole EmuCore/GS * section from the base settings layer and pushes it to the GS thread via @@ -571,6 +575,9 @@ public class NativeApp { /** Frame skip: present 1 frame, skip the next N (0 = off). Display-only * throttle; applies live. */ public static native void setFrameSkip(int skip); + + /** GitHub #375: top-align the render in portrait (true) vs vertical-center (false). */ + public static native void setPortraitRenderTop(boolean top); /** SPU2 output volume, percent (0..200). Applies live + persists. */ public static native void setAudioVolume(int volume); /** Mute/unmute SPU2 output. Applies live + persists. */ @@ -765,4 +772,24 @@ public class NativeApp { return false; } } + + // Fallback file creation for native FileSystem::OpenCFile. On Android 11+ + // FUSE-emulated external storage a raw libc fopen(O_CREAT) can be denied + // (EACCES/EPERM) even though the Java File API succeeds — the same split that + // forced createDirectoryPath above. Creating the empty file here lets the + // native truncating write ("w"/"wb") that follows open the now-existing file, + // which FUSE permits — which is what makes NEW folder-card saves work on a + // custom data folder instead of crashing. Returns true if the file exists after. + public static boolean createFilePath(String path) { + if (path == null || path.isEmpty()) return false; + try { + java.io.File file = new java.io.File(path); + if (file.isFile()) return true; + java.io.File parent = file.getParentFile(); + if (parent != null && !parent.isDirectory()) parent.mkdirs(); + return file.createNewFile() || file.isFile(); + } catch (Throwable t) { + return false; + } + } }