diff --git a/app/src/main/cpp/common/FileSystem.cpp b/app/src/main/cpp/common/FileSystem.cpp index 3367519..8af077a 100644 --- a/app/src/main/cpp/common/FileSystem.cpp +++ b/app/src/main/cpp/common/FileSystem.cpp @@ -95,6 +95,17 @@ static inline bool FileSystemCharacterIsSane(char32_t c, bool strip_slashes) return true; } +// Forward declaration from native-lib.cpp (Android JNI helper) +#ifdef __ANDROID__ +// JNI helpers implemented in native-lib.cpp +// Resolve a file path under the user-selected SAF data root, e.g. "textures/SLUS-12345/replacements/foo/bar.png" +std::string ResolveSafPathUriJNI(const char* relative_path, bool create); +// List files under a SAF-relative directory non-recursively: returns full relative paths (e.g., "textures/.../file.png"). +std::vector SafListFilesFlatJNI(const char* relative_dir); +// List files under a SAF-relative directory recursively: returns full relative paths. +std::vector SafListRecursiveFilesJNI(const char* relative_dir); +#endif + template static inline void PathAppendString(std::string& dst, const T& src) { @@ -970,6 +981,29 @@ std::string Path::CreateFileURL(std::string_view path) return ret; } +static bool IsWriteMode(const char* mode) +{ + if (!mode) return false; + while (*mode) + { + if (*mode == 'w' || *mode == 'a' || *mode == '+') return true; + mode++; + } + return false; +} + +static bool ParseSafPath(const std::string& path, std::string* out_subdir, std::string* out_filename) +{ + if (path.rfind("saf://", 0) != 0) return false; + std::string rest = path.substr(6); + // Expect format: subdir/filename + size_t slash = rest.find('/'); + if (slash == std::string::npos) return false; + *out_subdir = rest.substr(0, slash); + *out_filename = rest.substr(slash + 1); + return !out_subdir->empty() && !out_filename->empty(); +} + std::FILE* FileSystem::OpenCFile(const char* filename, const char* mode, Error* error) { #ifdef _WIN32 @@ -1003,8 +1037,26 @@ std::FILE* FileSystem::OpenCFile(const char* filename, const char* mode, Error* //// std::string _filename(filename); if (_filename.rfind("content://", 0) == 0) { - fp = fdopen(FileSystem::OpenFDFileContent(_filename.c_str()), "rb"); - } else { + // Direct document uri + const char* cmode = IsWriteMode(mode) ? "rw" : "r"; + int fd = FileSystem::OpenFDFileContentWithMode(_filename.c_str(), cmode); + fp = (fd >= 0) ? fdopen(fd, mode) : nullptr; + } +#ifdef __ANDROID__ + else if (_filename.rfind("saf://", 0) == 0) { + const bool create = IsWriteMode(mode); + std::string rel = _filename.substr(6); + std::string doc = ResolveSafPathUriJNI(rel.c_str(), create); + if (!doc.empty()) { + const char* cmode = IsWriteMode(mode) ? "rw" : "r"; + int fd = FileSystem::OpenFDFileContentWithMode(doc.c_str(), cmode); + fp = (fd >= 0) ? fdopen(fd, mode) : nullptr; + } else { + fp = nullptr; + } + } +#endif + else { fp = std::fopen(_filename.c_str(), mode); } //// @@ -1017,18 +1069,33 @@ std::FILE* FileSystem::OpenCFile(const char* filename, const char* mode, Error* std::FILE* FileSystem::OpenCFileTryIgnoreCase(const char* filename, const char* mode, Error* error) { #if defined(_WIN32) || defined(__APPLE__) - return OpenCFile(filename, mode, error); + return OpenCFile(filename, mode, error); #else - std::FILE* fp; - //// + std::FILE* fp = nullptr; std::string _filename(filename); if (_filename.rfind("content://", 0) == 0) { - fp = fdopen(FileSystem::OpenFDFileContent(_filename.c_str()), "rb"); - } else { + const char* cmode = IsWriteMode(mode) ? "rw" : "r"; + int fd = FileSystem::OpenFDFileContentWithMode(_filename.c_str(), cmode); + if (fd >= 0) + fp = fdopen(fd, mode); + } +#ifdef __ANDROID__ + else if (_filename.rfind("saf://", 0) == 0) { + const bool create = IsWriteMode(mode); + std::string rel = _filename.substr(6); + std::string doc = ResolveSafPathUriJNI(rel.c_str(), create); + if (!doc.empty()) { + const char* cmode = IsWriteMode(mode) ? "rw" : "r"; + int fd = FileSystem::OpenFDFileContentWithMode(doc.c_str(), cmode); + if (fd >= 0) + fp = fdopen(fd, mode); + } + } +#endif + else { fp = std::fopen(_filename.c_str(), mode); } - //// - const auto cur_errno = errno; + const auto cur_errno = errno; if (!fp) { @@ -1042,8 +1109,25 @@ std::FILE* FileSystem::OpenCFileTryIgnoreCase(const char* filename, const char* { //// if (file.FileName.rfind("content://", 0) == 0) { - fp = fdopen(FileSystem::OpenFDFileContent(file.FileName.c_str()), "rb"); - } else { + const char* cmode2 = IsWriteMode(mode) ? "rw" : "r"; + int fd2 = FileSystem::OpenFDFileContentWithMode(file.FileName.c_str(), cmode2); + if (fd2 >= 0) + fp = fdopen(fd2, mode); + } +#ifdef __ANDROID__ + else if (file.FileName.rfind("saf://", 0) == 0) { + const bool create2 = IsWriteMode(mode); + std::string rel2 = file.FileName.substr(6); + std::string doc2 = ResolveSafPathUriJNI(rel2.c_str(), create2); + if (!doc2.empty()) { + const char* cmode2 = IsWriteMode(mode) ? "rw" : "r"; + int fd2 = FileSystem::OpenFDFileContentWithMode(doc2.c_str(), cmode2); + if (fd2 >= 0) + fp = fdopen(fd2, mode); + } + } +#endif + else { fp = std::fopen(file.FileName.c_str(), mode); } //// @@ -1073,8 +1157,23 @@ int FileSystem::OpenFDFile(const char* filename, int flags, int mode, Error* err //// std::string _filename(filename); if (_filename.rfind("content://", 0) == 0) { - fd = FileSystem::OpenFDFileContent(_filename.c_str()); - } else { + const char* cmode = (flags & O_WRONLY) || (flags & O_RDWR) ? "rw" : "r"; + fd = FileSystem::OpenFDFileContentWithMode(_filename.c_str(), cmode); + } +#ifdef __ANDROID__ + else if (_filename.rfind("saf://", 0) == 0) { + const bool create = (flags & O_WRONLY) || (flags & O_RDWR) || (flags & O_CREAT); + std::string rel = _filename.substr(6); + std::string doc = ResolveSafPathUriJNI(rel.c_str(), create); + if (!doc.empty()) { + const char* cmode = (flags & O_WRONLY) || (flags & O_RDWR) ? "rw" : "r"; + fd = FileSystem::OpenFDFileContentWithMode(doc.c_str(), cmode); + } else { + fd = -1; + } + } +#endif + else { fd = open(_filename.c_str(), flags, mode); } //// @@ -1128,18 +1227,33 @@ std::FILE* FileSystem::OpenSharedCFile(const char* filename, const char* mode, F return nullptr; #else - std::FILE* fp; - //// + std::FILE* fp = nullptr; std::string _filename(filename); if (_filename.rfind("content://", 0) == 0) { - fp = fdopen(FileSystem::OpenFDFileContent(_filename.c_str()), "rb"); - } else { + const char* cmode = IsWriteMode(mode) ? "rw" : "r"; + int fd = FileSystem::OpenFDFileContentWithMode(_filename.c_str(), cmode); + if (fd >= 0) + fp = fdopen(fd, mode); + } +#ifdef __ANDROID__ + else if (_filename.rfind("saf://", 0) == 0) { + const bool create = IsWriteMode(mode); + std::string rel = _filename.substr(6); + std::string doc = ResolveSafPathUriJNI(rel.c_str(), create); + if (!doc.empty()) { + const char* cmode = IsWriteMode(mode) ? "rw" : "r"; + int fd = FileSystem::OpenFDFileContentWithMode(doc.c_str(), cmode); + if (fd >= 0) + fp = fdopen(fd, mode); + } + } +#endif + else { fp = std::fopen(_filename.c_str(), mode); } - //// - if (!fp) - Error::SetErrno(error, errno); - return fp; + if (!fp) + Error::SetErrno(error, errno); + return fp; #endif } @@ -1546,13 +1660,44 @@ static u32 RecursiveFindFiles(const char* origin_path, const char* parent_path, bool FileSystem::FindFiles(const char* path, const char* pattern, u32 flags, FindResultsArray* results, ProgressCallback* cancel) { - // has a path - if (path[0] == '\0') - return false; + // has a path + if (path[0] == '\0') + return false; - // clear result array - if (!(flags & FILESYSTEM_FIND_KEEP_ARRAY)) - results->clear(); + // clear result array + if (!(flags & FILESYSTEM_FIND_KEEP_ARRAY)) + results->clear(); + +#ifdef __ANDROID__ + // SAF listing: path of the form saf:// + if (std::string(path).rfind("saf://", 0) == 0) + { + const std::string rel = std::string(path).substr(6); + std::vector files; + if (flags & FILESYSTEM_FIND_RECURSIVE) + files = SafListRecursiveFilesJNI(rel.c_str()); + else + files = SafListFilesFlatJNI(rel.c_str()); + + const bool wildcard = (std::strpbrk(pattern, "*?") != nullptr); + for (const std::string& relpath : files) + { + if (cancel && cancel->IsCancelled()) break; + // Compare only the filename component against the pattern + const std::string_view fname = Path::GetFileName(relpath); + bool match = wildcard ? StringUtil::WildcardMatch(std::string(fname).c_str(), pattern) : (std::strcmp(std::string(fname).c_str(), pattern) == 0); + if (!match) continue; + FILESYSTEM_FIND_DATA out{}; + out.Attributes = 0; // files only for now + out.FileName = std::string("saf://") + relpath; + out.Size = -1; + out.CreationTime = 0; + out.ModificationTime = 0; + results->push_back(out); + } + return !results->empty(); + } +#endif // add self if recursive, we don't want to visit it twice std::vector visited; @@ -2685,3 +2830,7 @@ FileSystem::POSIXLock::~POSIXLock() } #endif + + + + diff --git a/app/src/main/cpp/common/FileSystem.h b/app/src/main/cpp/common/FileSystem.h index d65e41b..cb68d39 100644 --- a/app/src/main/cpp/common/FileSystem.h +++ b/app/src/main/cpp/common/FileSystem.h @@ -121,6 +121,7 @@ namespace FileSystem s64 FSize64(std::FILE* fp); int OpenFDFileContent(const char* filename); + int OpenFDFileContentWithMode(const char* filename, const char* mode); int OpenFDFile(const char* filename, int flags, int mode, Error* error = nullptr); /// Sharing modes for OpenSharedCFile(). diff --git a/app/src/main/cpp/common/ZipHelpers.h b/app/src/main/cpp/common/ZipHelpers.h index 470ac5c..040afe1 100644 --- a/app/src/main/cpp/common/ZipHelpers.h +++ b/app/src/main/cpp/common/ZipHelpers.h @@ -10,6 +10,7 @@ #include "Console.h" + static inline std::unique_ptr zip_open_managed(const char* filename, int flags, zip_error_t* ze) { zip_source_t* zs = zip_source_file_create(filename, 0, 0, ze); @@ -139,4 +140,4 @@ static inline std::optional> ReadBinaryFileInZip(zip_t* zip, con static inline std::optional> ReadBinaryFileInZip(zip_file_t* file, u32 chunk_size = 4096) { return ReadFileInZipToContainer>(file, chunk_size); -} \ No newline at end of file +} diff --git a/app/src/main/cpp/native-lib.cpp b/app/src/main/cpp/native-lib.cpp index 14de71d..3a1c615 100644 --- a/app/src/main/cpp/native-lib.cpp +++ b/app/src/main/cpp/native-lib.cpp @@ -27,6 +27,9 @@ #include "MTGS.h" #include "SDL3/SDL.h" #include +#ifdef __ANDROID__ +#include "SDL3/SDL.h" +#endif bool s_execute_exit; @@ -490,6 +493,18 @@ Java_com_izzy2lost_psx2_NativeApp_setAsyncTextureLoading(JNIEnv *env, jclass cla } } +extern "C" +JNIEXPORT void JNICALL +Java_com_izzy2lost_psx2_NativeApp_setPrecacheTextureReplacements(JNIEnv *env, jclass clazz, + jboolean p_enabled) { + s_settings_interface.SetBoolValue("EmuCore/GS", "PrecacheTextureReplacements", p_enabled); + + // Apply the settings immediately if emulation is running + if (VMManager::HasValidVM()) { + VMManager::ApplySettings(); + } +} + extern "C" JNIEXPORT void JNICALL Java_com_izzy2lost_psx2_NativeApp_setShadeBoost(JNIEnv *env, jclass clazz, @@ -673,7 +688,7 @@ Java_com_izzy2lost_psx2_NativeApp_saveGameSettingsToPath(JNIEnv *env, jclass cla // Check if file actually exists and has content if (FileSystem::FileExists(settings_path.c_str())) { s64 file_size = FileSystem::GetPathFileSize(settings_path.c_str()); - printf("PCSX2: File exists with size: %lld bytes\n", file_size); + printf("PCSX2: File exists with size: %lld bytes\n", static_cast(file_size)); } else { printf("PCSX2: File does not exist after save attempt\n"); } @@ -983,6 +998,139 @@ int FileSystem::OpenFDFileContent(const char* filename) return fd; } +#ifdef __ANDROID__ +// Helpers callable from core for SAF bridging +static jclass GetNativeAppClass(JNIEnv* env) +{ + return env->FindClass("com/izzy2lost/psx2/NativeApp"); +} + +std::string ResolveSafPathUriJNI(const char* relative_path, bool create) +{ + JNIEnv* env = reinterpret_cast(SDL_GetAndroidJNIEnv()); + if (!env) + return {}; + jclass cls = GetNativeAppClass(env); + if (!cls) + return {}; + jmethodID mid = env->GetStaticMethodID(cls, "resolveSafPathUri", "(Ljava/lang/String;Z)Ljava/lang/String;"); + if (!mid) + return {}; + jstring jrel = env->NewStringUTF(relative_path); + jobject juri = env->CallStaticObjectMethod(cls, mid, jrel, (jboolean)create); + env->DeleteLocalRef(jrel); + if (!juri) + return {}; + const char* cstr = env->GetStringUTFChars((jstring)juri, nullptr); + std::string out = cstr ? std::string(cstr) : std::string(); + if (cstr) + env->ReleaseStringUTFChars((jstring)juri, cstr); + env->DeleteLocalRef(juri); + return out; +} + +std::vector SafListRecursiveFilesJNI(const char* relative_dir) +{ + std::vector out; + JNIEnv* env = reinterpret_cast(SDL_GetAndroidJNIEnv()); + if (!env) + return out; + jclass cls = GetNativeAppClass(env); + if (!cls) + return out; + jmethodID mid = env->GetStaticMethodID(cls, "listSafRecursiveFiles", "(Ljava/lang/String;)[Ljava/lang/String;"); + if (!mid) + return out; + jstring jarg = env->NewStringUTF(relative_dir); + jobjectArray arr = (jobjectArray)env->CallStaticObjectMethod(cls, mid, jarg); + env->DeleteLocalRef(jarg); + if (!arr) + return out; + jsize len = env->GetArrayLength(arr); + out.reserve((size_t)len); + for (jsize i = 0; i < len; i++) + { + jstring js = (jstring)env->GetObjectArrayElement(arr, i); + if (!js) continue; + const char* c = env->GetStringUTFChars(js, nullptr); + if (c) + { + out.emplace_back(c); + env->ReleaseStringUTFChars(js, c); + } + env->DeleteLocalRef(js); + } + env->DeleteLocalRef(arr); + return out; +} + +std::vector SafListFilesFlatJNI(const char* relative_dir) +{ + std::vector out; + JNIEnv* env = reinterpret_cast(SDL_GetAndroidJNIEnv()); + if (!env) + return out; + jclass cls = GetNativeAppClass(env); + if (!cls) + return out; + jmethodID mid = env->GetStaticMethodID(cls, "listSafFilesFlat", "(Ljava/lang/String;)[Ljava/lang/String;"); + if (!mid) + return out; + jstring jarg = env->NewStringUTF(relative_dir); + jobjectArray arr = (jobjectArray)env->CallStaticObjectMethod(cls, mid, jarg); + env->DeleteLocalRef(jarg); + if (!arr) + return out; + jsize len = env->GetArrayLength(arr); + out.reserve((size_t)len); + for (jsize i = 0; i < len; i++) + { + jstring js = (jstring)env->GetObjectArrayElement(arr, i); + if (!js) continue; + const char* c = env->GetStringUTFChars(js, nullptr); + if (c) + { + out.emplace_back(c); + env->ReleaseStringUTFChars(js, c); + } + env->DeleteLocalRef(js); + } + env->DeleteLocalRef(arr); + return out; +} +#endif // __ANDROID__ + +int FileSystem::OpenFDFileContentWithMode(const char* filename, const char* mode) +{ + auto *env = static_cast(SDL_GetAndroidJNIEnv()); + if(env == nullptr) { + return -1; + } + jclass NativeApp = env->FindClass("com/izzy2lost/psx2/NativeApp"); + jmethodID openContentUriMode = env->GetStaticMethodID(NativeApp, "openContentUriMode", "(Ljava/lang/String;Ljava/lang/String;)I"); + jstring j_filename = env->NewStringUTF(filename); + jstring j_mode = env->NewStringUTF(mode); + int fd = env->CallStaticIntMethod(NativeApp, openContentUriMode, j_filename, j_mode); + return fd; +} + +std::string ResolveSafChildUriJNI(const char* subdir, const char* filename, bool create) +{ + auto *env = static_cast(SDL_GetAndroidJNIEnv()); + if(env == nullptr) return {}; + jclass NativeApp = env->FindClass("com/izzy2lost/psx2/NativeApp"); + jmethodID resolve = env->GetStaticMethodID(NativeApp, "resolveSafChildUri", "(Ljava/lang/String;Ljava/lang/String;Z)Ljava/lang/String;"); + jstring j_sub = env->NewStringUTF(subdir); + jstring j_file = env->NewStringUTF(filename); + jstring j_uri = (jstring)env->CallStaticObjectMethod(NativeApp, resolve, j_sub, j_file, (jboolean)create); + if (!j_uri) return {}; + const char* cstr = env->GetStringUTFChars(j_uri, nullptr); + std::string ret(cstr); + env->ReleaseStringUTFChars(j_uri, cstr); + env->DeleteLocalRef(j_uri); + return ret; +} + extern "C" JNIEXPORT jboolean JNICALL @@ -1445,3 +1593,42 @@ bool Host::InNoGUIMode() { return false; } + +// JNI: report if a SAF Data Root is configured +bool HasSafDataRootJNI() +{ + JNIEnv* env = static_cast(SDL_GetAndroidJNIEnv()); + if (!env) return false; + jclass cls = env->FindClass("com/izzy2lost/psx2/NativeApp"); + if (!cls) return false; + jmethodID mid = env->GetStaticMethodID(cls, "hasSafDataRoot", "()Z"); + if (!mid) return false; + jboolean res = env->CallStaticBooleanMethod(cls, mid); + return (res == JNI_TRUE); +} +static std::vector SafListFilesJNI(const char* subdir) +{ + std::vector ret; + JNIEnv* env = static_cast(SDL_GetAndroidJNIEnv()); + if (!env) return ret; + jclass cls = env->FindClass("com/izzy2lost/psx2/NativeApp"); + if (!cls) return ret; + jmethodID mid = env->GetStaticMethodID(cls, "listSafFilenames", "(Ljava/lang/String;)[Ljava/lang/String;"); + if (!mid) return ret; + jstring j_sub = env->NewStringUTF(subdir); + jobjectArray arr = (jobjectArray)env->CallStaticObjectMethod(cls, mid, j_sub); + env->DeleteLocalRef(j_sub); + if (!arr) return ret; + jsize n = env->GetArrayLength(arr); + ret.reserve(n); + for (jsize i = 0; i < n; i++) { + jstring s = (jstring)env->GetObjectArrayElement(arr, i); + if (!s) continue; + const char* cs = env->GetStringUTFChars(s, nullptr); + if (cs) ret.emplace_back(cs); + env->ReleaseStringUTFChars(s, cs); + env->DeleteLocalRef(s); + } + env->DeleteLocalRef(arr); + return ret; +} diff --git a/app/src/main/cpp/pcsx2/INISettingsInterface.cpp b/app/src/main/cpp/pcsx2/INISettingsInterface.cpp index e7b84f7..7cf175c 100644 --- a/app/src/main/cpp/pcsx2/INISettingsInterface.cpp +++ b/app/src/main/cpp/pcsx2/INISettingsInterface.cpp @@ -75,16 +75,16 @@ INISettingsInterface::~INISettingsInterface() bool INISettingsInterface::Load() { - if (m_filename.empty()) - return false; + if (m_filename.empty()) + return false; - std::unique_lock lock(s_ini_load_save_mutex); - SI_Error err = SI_FAIL; - auto fp = FileSystem::OpenManagedCFile(m_filename.c_str(), "rb"); - if (fp) - err = m_ini.LoadFile(fp.get()); + std::unique_lock lock(s_ini_load_save_mutex); + SI_Error err = SI_FAIL; + auto fp = FileSystem::OpenManagedCFile(m_filename.c_str(), "rb"); + if (fp) + err = m_ini.LoadFile(fp.get()); - return (err == SI_OK); + return (err == SI_OK); } bool INISettingsInterface::Save(Error* error) @@ -95,29 +95,37 @@ bool INISettingsInterface::Save(Error* error) return false; } - std::unique_lock lock(s_ini_load_save_mutex); - std::string temp_filename; - std::FILE* fp = GetTemporaryFile(&temp_filename, m_filename, "wb", error); - SI_Error err = SI_FAIL; - if (fp) - { - err = m_ini.SaveFile(fp, false); - std::fclose(fp); + std::unique_lock lock(s_ini_load_save_mutex); + SI_Error err = SI_FAIL; + if (m_filename.rfind("saf://", 0) == 0) + { + // Direct write to SAF file; no temp/rename + auto fp = FileSystem::OpenManagedCFile(m_filename.c_str(), "wb", error); + if (fp) + err = m_ini.SaveFile(fp.get(), false); + } + else + { + std::string temp_filename; + std::FILE* fp = GetTemporaryFile(&temp_filename, m_filename, "wb", error); + if (fp) + { + err = m_ini.SaveFile(fp, false); + std::fclose(fp); - if (err != SI_OK) - { - Error::SetStringFmt(error, "INI SaveFile() failed: {}", static_cast(err)); - - // remove temporary file - FileSystem::DeleteFilePath(temp_filename.c_str()); - } - else if (!FileSystem::RenamePath(temp_filename.c_str(), m_filename.c_str(), error)) - { - Console.Error("Failed to rename '%s' to '%s'", temp_filename.c_str(), m_filename.c_str()); - FileSystem::DeleteFilePath(temp_filename.c_str()); - return false; - } - } + if (err != SI_OK) + { + Error::SetStringFmt(error, "INI SaveFile() failed: {}", static_cast(err)); + FileSystem::DeleteFilePath(temp_filename.c_str()); + } + else if (!FileSystem::RenamePath(temp_filename.c_str(), m_filename.c_str(), error)) + { + Console.Error("Failed to rename '%s' to '%s'", temp_filename.c_str(), m_filename.c_str()); + FileSystem::DeleteFilePath(temp_filename.c_str()); + return false; + } + } + } if (err != SI_OK) { diff --git a/app/src/main/cpp/pcsx2/Pcsx2Config.cpp b/app/src/main/cpp/pcsx2/Pcsx2Config.cpp index 6a0a000..f6f436d 100644 --- a/app/src/main/cpp/pcsx2/Pcsx2Config.cpp +++ b/app/src/main/cpp/pcsx2/Pcsx2Config.cpp @@ -2259,7 +2259,7 @@ void EmuFolders::LoadConfig(SettingsInterface& si) Cheats = LoadPathFromSettings(si, DataRoot, "Cheats", "cheats"); Patches = LoadPathFromSettings(si, DataRoot, "Patches", "patches"); Covers = LoadPathFromSettings(si, DataRoot, "Covers", "covers"); - GameSettings = LoadPathFromSettings(si, DataRoot, "GameSettings", "gamesettings"); + GameSettings = LoadPathFromSettings(si, DataRoot, "GameSettings", "gamesettings"); UserResources = LoadPathFromSettings(si, DataRoot, "UserResources", "resources"); Cache = LoadPathFromSettings(si, DataRoot, "Cache", "cache"); Textures = LoadPathFromSettings(si, DataRoot, "Textures", "textures"); @@ -2276,7 +2276,7 @@ void EmuFolders::LoadConfig(SettingsInterface& si) Console.WriteLn("Cheats Directory: %s", Cheats.c_str()); Console.WriteLn("Patches Directory: %s", Patches.c_str()); Console.WriteLn("Covers Directory: %s", Covers.c_str()); - Console.WriteLn("Game Settings Directory: %s", GameSettings.c_str()); + Console.WriteLn("Game Settings Directory: %s", GameSettings.c_str()); Console.WriteLn("Resources Directory: %s", Resources.c_str()); Console.WriteLn("User Resources Directory: %s", UserResources.c_str()); Console.WriteLn("Cache Directory: %s", Cache.c_str()); diff --git a/app/src/main/cpp/pcsx2/SaveState.cpp b/app/src/main/cpp/pcsx2/SaveState.cpp index 75b6528..0ca1e4c 100644 --- a/app/src/main/cpp/pcsx2/SaveState.cpp +++ b/app/src/main/cpp/pcsx2/SaveState.cpp @@ -42,6 +42,11 @@ #include #include +#if defined(__ANDROID__) +// includes previously used for fd-based zip sinks; left guarded for future use +#include +#include +#endif using namespace R5900; @@ -1040,29 +1045,29 @@ static bool SaveState_AddToZip(zip_t* zf, ArchiveEntryList* srclist, SaveStateSc bool SaveState_ZipToDisk(std::unique_ptr srclist, std::unique_ptr screenshot, const char* filename) { - zip_error_t ze = {}; - zip_source_t* zs = zip_source_file_create(filename, 0, 0, &ze); - zip_t* zf = nullptr; - if (zs && !(zf = zip_open_from_source(zs, ZIP_CREATE | ZIP_TRUNCATE, &ze))) - { - Console.Error("Failed to open zip file '%s' for save state: %s", filename, zip_error_strerror(&ze)); + zip_error_t ze = {}; + zip_source_t* zs = zip_source_file_create(filename, 0, 0, &ze); + zip_t* zf = nullptr; + if (zs && !(zf = zip_open_from_source(zs, ZIP_CREATE | ZIP_TRUNCATE, &ze))) + { + Console.Error("Failed to open zip file '%s' for save state: %s", filename, zip_error_strerror(&ze)); - // have to clean up source - zip_source_free(zs); - return false; - } + // have to clean up source + zip_source_free(zs); + return false; + } - // discard zip file if we fail saving something - if (!SaveState_AddToZip(zf, srclist.get(), screenshot.get())) - { - Console.Error("Failed to save state to zip file '%s'", filename); - zip_discard(zf); - return false; - } + // discard zip file if we fail saving something + if (!SaveState_AddToZip(zf, srclist.get(), screenshot.get())) + { + Console.Error("Failed to save state to zip file '%s'", filename); + zip_discard(zf); + return false; + } - // force the zip to close, this is the expensive part with libzip. - zip_close(zf); - return true; + // force the zip to close, this is the expensive part with libzip. + zip_close(zf); + return true; } bool SaveState_ReadScreenshot(const std::string& filename, u32* out_width, u32* out_height, std::vector* out_pixels) diff --git a/app/src/main/cpp/pcsx2/VMManager.cpp b/app/src/main/cpp/pcsx2/VMManager.cpp index 3895b98..8dd5390 100644 --- a/app/src/main/cpp/pcsx2/VMManager.cpp +++ b/app/src/main/cpp/pcsx2/VMManager.cpp @@ -76,6 +76,11 @@ #include "common/Darwin/DarwinMisc.h" #endif +#ifdef __ANDROID__ +// JNI helper implemented in native-lib.cpp to query SAF Data Root presence +bool HasSafDataRootJNI(); +#endif + namespace VMManager { static void SetDefaultLoggingSettings(SettingsInterface& si); @@ -543,8 +548,19 @@ bool VMManager::Internal::CheckSettingsVersion() void VMManager::Internal::LoadStartupSettings() { SettingsInterface* bsi = Host::Internal::GetBaseSettingsLayer(); - EmuFolders::LoadConfig(*bsi); - EmuFolders::EnsureFoldersExist(); + EmuFolders::LoadConfig(*bsi); + EmuFolders::EnsureFoldersExist(); +#ifdef __ANDROID__ + // Redirect cheats and patches to SAF Data Folder if available + if (HasSafDataRootJNI()) + { + EmuFolders::Cheats = "saf://cheats"; + EmuFolders::Patches = "saf://patches"; + // Route textures and screenshots to SAF; savestates remain on internal due to libzip constraints + EmuFolders::Textures = "saf://textures"; + EmuFolders::Snapshots = "saf://snaps"; + } +#endif // We need to create the console window early, otherwise it appears behind the main window. UpdateLoggingSettings(*bsi); @@ -767,11 +783,17 @@ bool VMManager::ReloadGameSettings() std::string VMManager::GetGameSettingsPath(const std::string_view game_serial, u32 game_crc) { - std::string sanitized_serial(Path::SanitizeFileName(game_serial)); + std::string sanitized_serial(Path::SanitizeFileName(game_serial)); - return game_serial.empty() ? - Path::Combine(EmuFolders::GameSettings, fmt::format("{:08X}.ini", game_crc)) : - Path::Combine(EmuFolders::GameSettings, fmt::format("{}_{:08X}.ini", sanitized_serial, game_crc)); + std::string base = EmuFolders::GameSettings; +#ifdef __ANDROID__ + // If the app has a SAF Data Root, direct per-game settings into it via saf:// scheme + if (HasSafDataRootJNI()) + base = "saf://gamesettings"; +#endif + return game_serial.empty() ? + Path::Combine(base, fmt::format("{:08X}.ini", game_crc)) : + Path::Combine(base, fmt::format("{}_{:08X}.ini", sanitized_serial, game_crc)); } std::string VMManager::GetDiscOverrideFromGameSettings(const std::string& elf_path) @@ -3694,3 +3716,4 @@ void VMManager::PollDiscordPresence() Discord_RunCallbacks(); } + diff --git a/app/src/main/java/com/izzy2lost/psx2/CheatsDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/CheatsDialogFragment.java new file mode 100644 index 0000000..798d623 --- /dev/null +++ b/app/src/main/java/com/izzy2lost/psx2/CheatsDialogFragment.java @@ -0,0 +1,143 @@ +package com.izzy2lost.psx2; + +import android.app.Dialog; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.io.InputStream; +import java.util.ArrayList; + +public class CheatsDialogFragment extends DialogFragment { + + private ArrayAdapter adapter; + private ArrayList items = new ArrayList<>(); + + private static final int REQ_IMPORT_PNACH = 1001; + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + Context ctx = requireContext(); + View view = LayoutInflater.from(ctx).inflate(R.layout.simple_list, null, false); + ListView lv = view.findViewById(android.R.id.list); + adapter = new ArrayAdapter<>(ctx, android.R.layout.simple_list_item_1, items); + lv.setAdapter(adapter); + lv.setOnItemLongClickListener((parent, v, position, id) -> { + String name = items.get(position); + deleteCheat(name); + return true; + }); + + refreshList(); + + return new MaterialAlertDialogBuilder(ctx, com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog) + .setCustomTitle(UiUtils.centeredDialogTitle(ctx, "Manage Cheats")) + .setView(view) + .setNegativeButton("Close", null) + .setPositiveButton("Import For Game", (d, w) -> startImport()) + .create(); + } + + private void refreshList() { + items.clear(); + String[] names = NativeApp.listSafFilenames("cheats"); + if (names != null && names.length > 0) { + for (String n : names) if (n != null && n.endsWith(".pnach")) items.add(n); + } else { + java.io.File dir = new java.io.File(requireContext().getExternalFilesDir(null), "cheats"); + if (!dir.exists()) dir.mkdirs(); + java.io.File[] arr = dir.listFiles((f, n) -> n != null && n.endsWith(".pnach")); + if (arr != null) for (java.io.File f : arr) items.add(f.getName()); + } + if (adapter != null) adapter.notifyDataSetChanged(); + } + + private void startImport() { + try { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("*/*"); + startActivityForResult(intent, REQ_IMPORT_PNACH); + } catch (Throwable t) { + Toast.makeText(requireContext(), "No file picker available", Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQ_IMPORT_PNACH && resultCode == android.app.Activity.RESULT_OK && data != null) { + Uri uri = data.getData(); + if (uri == null) return; + String serial = null; + try { serial = NativeApp.getCurrentGameSerial(); } catch (Throwable ignored) {} + if (serial == null || serial.isEmpty()) { + Toast.makeText(requireContext(), "Unknown game serial", Toast.LENGTH_SHORT).show(); + return; + } + String outName = serial + ".pnach"; + android.net.Uri dataRoot = SafManager.getDataRootUri(requireContext()); + if (dataRoot != null) { + androidx.documentfile.provider.DocumentFile target = SafManager.createChild(requireContext(), new String[]{"cheats"}, outName, "application/octet-stream"); + if (target != null) { + try (InputStream in = requireContext().getContentResolver().openInputStream(uri)) { + if (in != null && SafManager.copyFromStream(requireContext(), in, target.getUri())) { + Toast.makeText(requireContext(), "Imported to Data Folder", Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(requireContext(), "Import failed", Toast.LENGTH_SHORT).show(); + } + } catch (Exception e) { + Toast.makeText(requireContext(), "Import failed", Toast.LENGTH_SHORT).show(); + } + } + } else { + java.io.File dir = new java.io.File(requireContext().getExternalFilesDir(null), "cheats"); + if (!dir.exists()) dir.mkdirs(); + java.io.File out = new java.io.File(dir, outName); + try (InputStream in = requireContext().getContentResolver().openInputStream(uri); + java.io.FileOutputStream os = new java.io.FileOutputStream(out)) { + if (in != null) { + byte[] buf = new byte[8192]; int n; while ((n = in.read(buf)) != -1) os.write(buf, 0, n); + os.flush(); + Toast.makeText(requireContext(), "Imported", Toast.LENGTH_SHORT).show(); + } + } catch (Exception e) { + Toast.makeText(requireContext(), "Import failed", Toast.LENGTH_SHORT).show(); + } + } + refreshList(); + } + } + + private void deleteCheat(String name) { + android.net.Uri dataRoot = SafManager.getDataRootUri(requireContext()); + boolean ok = false; + if (dataRoot != null) { + androidx.documentfile.provider.DocumentFile f = SafManager.getChild(requireContext(), new String[]{"cheats"}, name); + if (f != null) ok = f.delete(); + } else { + java.io.File dir = new java.io.File(requireContext().getExternalFilesDir(null), "cheats"); + java.io.File f = new java.io.File(dir, name); + ok = f.delete(); + } + if (ok) { + Toast.makeText(requireContext(), "Deleted", Toast.LENGTH_SHORT).show(); + refreshList(); + } else { + Toast.makeText(requireContext(), "Delete failed", Toast.LENGTH_SHORT).show(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/izzy2lost/psx2/GameSettingsDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/GameSettingsDialogFragment.java index dbb492b..a05ea40 100644 --- a/app/src/main/java/com/izzy2lost/psx2/GameSettingsDialogFragment.java +++ b/app/src/main/java/com/izzy2lost/psx2/GameSettingsDialogFragment.java @@ -80,7 +80,7 @@ public class GameSettingsDialogFragment extends DialogFragment { android.net.Uri dataRoot = SafManager.getDataRootUri(ctx); if (dataRoot != null) { String subdir = mImportAsCheats ? "cheats" : "patches"; - androidx.documentfile.provider.DocumentFile target = SafManager.createChild(ctx, new String[]{subdir}, gameSerial + ".pnach", "text/plain"); + androidx.documentfile.provider.DocumentFile target = SafManager.createChild(ctx, new String[]{subdir}, gameSerial + ".pnach", "application/octet-stream"); if (target != null) { try (java.io.InputStream in2 = cr.openInputStream(android.net.Uri.fromFile(outFile))) { SafManager.copyFromStream(ctx, in2, target.getUri()); @@ -428,7 +428,7 @@ public class GameSettingsDialogFragment extends DialogFragment { android.net.Uri dataRoot = SafManager.getDataRootUri(ctx); if (dataRoot != null) { try { - androidx.documentfile.provider.DocumentFile target = SafManager.createChild(ctx, new String[]{"gamesettings"}, fileName, "text/plain"); + androidx.documentfile.provider.DocumentFile target = SafManager.createChild(ctx, new String[]{"gamesettings"}, fileName, "application/octet-stream"); if (target != null) { byte[] data = sb.toString().getBytes("UTF-8"); SafManager.writeBytes(ctx, target.getUri(), data); diff --git a/app/src/main/java/com/izzy2lost/psx2/MainActivity.java b/app/src/main/java/com/izzy2lost/psx2/MainActivity.java index 3e8b58a..f3aca04 100644 --- a/app/src/main/java/com/izzy2lost/psx2/MainActivity.java +++ b/app/src/main/java/com/izzy2lost/psx2/MainActivity.java @@ -521,6 +521,13 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF SettingsDialogFragment dialog = new SettingsDialogFragment(); dialog.show(fm, "settings_dialog"); }); + // Long-press to open Cheats manager + btn_settings.setOnLongClickListener(v -> { + FragmentManager fm2 = getSupportFragmentManager(); + CheatsDialogFragment cd = new CheatsDialogFragment(); + cd.show(fm2, "cheats_dialog"); + return true; + }); } // Toggle all UI visibility (including controls) @@ -1742,3 +1749,4 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF } } } + diff --git a/app/src/main/java/com/izzy2lost/psx2/NativeApp.java b/app/src/main/java/com/izzy2lost/psx2/NativeApp.java index e84b57e..131040f 100644 --- a/app/src/main/java/com/izzy2lost/psx2/NativeApp.java +++ b/app/src/main/java/com/izzy2lost/psx2/NativeApp.java @@ -35,7 +35,7 @@ public class NativeApp { initialize(externalFilesDir.getAbsolutePath(), android.os.Build.VERSION.SDK_INT); } - public static native void initialize(String path, int apiVer); + public static native void initialize(String path, int apiVer); public static native String getGameTitle(String path); public static native String getGameTitleFromUri(String gameUri); public static native String getGameSerial(); @@ -69,6 +69,7 @@ public class NativeApp { // Texture loading options for texture packs public static native void setLoadTextures(boolean enabled); public static native void setAsyncTextureLoading(boolean enabled); + public static native void setPrecacheTextureReplacements(boolean enabled); public static native void setBlendingAccuracy(int level); // Shade Boost (brightness/contrast/saturation) @@ -154,7 +155,7 @@ public class NativeApp { public static native void onNativeSurfaceChanged(Surface surface, int w, int h); public static native void onNativeSurfaceDestroyed(); - public static native boolean runVMThread(String path); + public static native boolean runVMThread(String path); public static native void pause(); public static native void resume(); @@ -166,17 +167,149 @@ public class NativeApp { public static native byte[] getImageSlot(int slot); // Call jni - public static int openContentUri(String uriString) { - Context _context = getContext(); - if(_context != null) { - ContentResolver _contentResolver = _context.getContentResolver(); - try { - ParcelFileDescriptor filePfd = _contentResolver.openFileDescriptor(Uri.parse(uriString), "r"); - if (filePfd != null) { - return filePfd.detachFd(); // Take ownership of the fd. - } - } catch (Exception ignored) {} - } - return -1; - } + public static int openContentUri(String uriString) { + Context _context = getContext(); + if(_context != null) { + ContentResolver _contentResolver = _context.getContentResolver(); + try { + ParcelFileDescriptor filePfd = _contentResolver.openFileDescriptor(Uri.parse(uriString), "r"); + if (filePfd != null) { + return filePfd.detachFd(); // Take ownership of the fd. + } + } catch (Exception ignored) {} + } + return -1; + } + + // Indicates whether a SAF Data Root has been selected by the user. + public static boolean hasSafDataRoot() { + return SafManager.getDataRootUri(getContext()) != null; + } + + // Open a SAF content Uri with the requested mode ("r", "w", or "rw"). Returns a detached FD or -1. + public static int openContentUriMode(String uriString, String mode) { + Context _context = getContext(); + if(_context != null) { + ContentResolver _contentResolver = _context.getContentResolver(); + try { + ParcelFileDescriptor filePfd = _contentResolver.openFileDescriptor(Uri.parse(uriString), mode); + if (filePfd != null) { + return filePfd.detachFd(); + } + } catch (Exception ignored) {} + } + return -1; + } + + // Resolve a child document Uri within the SAF Data Root. + // subdir: e.g., "gamesettings", filename: e.g., "SLUS-12345.ini". If create is true, creates file. + public static String resolveSafChildUri(String subdir, String filename, boolean create) { + Uri root = SafManager.getDataRootUri(getContext()); + if (root == null) return null; + try { + androidx.documentfile.provider.DocumentFile df; + if (create) { + df = SafManager.createChild(getContext(), new String[]{subdir}, filename, "application/octet-stream"); + } else { + df = SafManager.getChild(getContext(), new String[]{subdir}, filename); + } + return (df != null) ? df.getUri().toString() : null; + } catch (Throwable ignored) { } + return null; + } + + // Resolve a file path relative to the SAF Data Root. Accepts nested paths like + // "textures/SLUS-12345/replacements/subdir/file.png". If create is true, creates the file. + public static String resolveSafPathUri(String relativePath, boolean create) { + if (relativePath == null) return null; + Uri root = SafManager.getDataRootUri(getContext()); + if (root == null) return null; + try { + String[] parts = relativePath.split("/"); + if (parts.length == 0) return null; + String[] dirSegs; + String filename; + if (parts.length == 1) { + dirSegs = new String[]{}; + filename = parts[0]; + } else { + dirSegs = new String[parts.length - 1]; + System.arraycopy(parts, 0, dirSegs, 0, parts.length - 1); + filename = parts[parts.length - 1]; + } + androidx.documentfile.provider.DocumentFile df; + if (create) { + df = SafManager.createChild(getContext(), dirSegs, filename, "application/octet-stream"); + } else { + df = SafManager.getChild(getContext(), dirSegs, filename); + } + return (df != null) ? df.getUri().toString() : null; + } catch (Throwable ignored) { } + return null; + } + + // List files under a relative SAF directory recursively. Returns full relative paths from the root. + public static String[] listSafRecursiveFiles(String relativeDir) { + java.util.ArrayList out = new java.util.ArrayList<>(); + try { + androidx.documentfile.provider.DocumentFile base = SafManager.getOrCreateDir(getContext(), relativeDir.split("/")); + if (base == null || !base.exists()) return new String[0]; + walkDirRecursive(base, relativeDir, out); + } catch (Throwable ignored) { } + return out.toArray(new String[0]); + } + + private static void walkDirRecursive(androidx.documentfile.provider.DocumentFile dir, String relPrefix, java.util.ArrayList out) { + androidx.documentfile.provider.DocumentFile[] arr = dir.listFiles(); + if (arr == null) return; + for (androidx.documentfile.provider.DocumentFile f : arr) { + if (f == null) continue; + String name = f.getName(); + if (name == null || name.isEmpty()) continue; + if (f.isDirectory()) { + walkDirRecursive(f, relPrefix + "/" + name, out); + } else if (f.isFile()) { + out.add(relPrefix + "/" + name); + } + } + } + + // List files directly under a relative SAF directory (non-recursive). Returns full relative paths. + public static String[] listSafFilesFlat(String relativeDir) { + java.util.ArrayList out = new java.util.ArrayList<>(); + try { + androidx.documentfile.provider.DocumentFile dir = SafManager.getOrCreateDir(getContext(), relativeDir.split("/")); + if (dir == null || !dir.isDirectory()) return new String[0]; + androidx.documentfile.provider.DocumentFile[] arr = dir.listFiles(); + if (arr != null) { + for (androidx.documentfile.provider.DocumentFile f : arr) { + if (f != null && f.isFile()) { + String name = f.getName(); + if (name != null && !name.isEmpty()) out.add(relativeDir + "/" + name); + } + } + } + } catch (Throwable ignored) { } + return out.toArray(new String[0]); + } + + // List filenames (files only) under a SAF subdirectory (e.g., "cheats", "patches"). + public static String[] listSafFilenames(String subdir) { + try { + androidx.documentfile.provider.DocumentFile dir = SafManager.getOrCreateDir(getContext(), subdir); + if (dir == null || !dir.isDirectory()) return new String[0]; + androidx.documentfile.provider.DocumentFile[] arr = dir.listFiles(); + java.util.ArrayList out = new java.util.ArrayList<>(); + if (arr != null) { + for (androidx.documentfile.provider.DocumentFile f : arr) { + if (f != null && f.isFile()) { + String name = f.getName(); + if (name != null && !name.isEmpty()) out.add(name); + } + } + } + return out.toArray(new String[0]); + } catch (Throwable ignored) { } + return new String[0]; + } } diff --git a/app/src/main/java/com/izzy2lost/psx2/SettingsDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/SettingsDialogFragment.java index 9073635..aad0224 100644 --- a/app/src/main/java/com/izzy2lost/psx2/SettingsDialogFragment.java +++ b/app/src/main/java/com/izzy2lost/psx2/SettingsDialogFragment.java @@ -42,6 +42,7 @@ public class SettingsDialogFragment extends DialogFragment { boolean noInterlacingPatches = prefs.getBoolean("no_interlacing_patches", true); boolean loadTextures = prefs.getBoolean("load_textures", false); boolean asyncTextureLoading = prefs.getBoolean("async_texture_loading", true); + boolean precacheTextures = prefs.getBoolean("precache_textures", false); boolean hudVisible = prefs.getBoolean("hud_visible", false); // Debug logging @@ -58,6 +59,7 @@ public class SettingsDialogFragment extends DialogFragment { NativeApp.setNoInterlacingPatches(noInterlacingPatches); NativeApp.setLoadTextures(loadTextures); NativeApp.setAsyncTextureLoading(asyncTextureLoading); + NativeApp.setPrecacheTextureReplacements(precacheTextures); NativeApp.setHudVisible(hudVisible); // Set brighter default brightness (60 instead of 50) @@ -85,6 +87,7 @@ public class SettingsDialogFragment extends DialogFragment { MaterialSwitch swNoInterlacing = view.findViewById(R.id.sw_no_interlacing); MaterialSwitch swLoadTextures = view.findViewById(R.id.sw_load_textures); MaterialSwitch swAsyncTextureLoading = view.findViewById(R.id.sw_async_texture_loading); + MaterialSwitch swPrecacheTextures = view.findViewById(R.id.sw_precache_textures); MaterialSwitch swDevHud = view.findViewById(R.id.sw_dev_hud); View btnPower = view.findViewById(R.id.btn_power); View btnReboot = view.findViewById(R.id.btn_reboot); @@ -187,6 +190,7 @@ public class SettingsDialogFragment extends DialogFragment { boolean savedNoInterlacing = prefs.getBoolean("no_interlacing_patches", true); boolean savedLoadTextures = prefs.getBoolean("load_textures", false); boolean savedAsyncTextureLoading = prefs.getBoolean("async_texture_loading", true); + boolean savedPrecacheTextures = prefs.getBoolean("precache_textures", false); boolean savedHud = prefs.getBoolean("hud_visible", false); boolean savedCheatsGlobal = prefs.getBoolean("enable_cheats", false); int savedBlending = prefs.getInt("blending_accuracy", 1); @@ -213,6 +217,7 @@ public class SettingsDialogFragment extends DialogFragment { swNoInterlacing.setChecked(savedNoInterlacing); swLoadTextures.setChecked(savedLoadTextures); swAsyncTextureLoading.setChecked(savedAsyncTextureLoading); + if (swPrecacheTextures != null) swPrecacheTextures.setChecked(savedPrecacheTextures); if (swDevHud != null) swDevHud.setChecked(savedHud); MaterialSwitch swCheatsGlobal = view.findViewById(R.id.sw_enable_cheats_global); if (swCheatsGlobal != null) swCheatsGlobal.setChecked(savedCheatsGlobal); @@ -235,7 +240,8 @@ public class SettingsDialogFragment extends DialogFragment { boolean widescreenPatches = swWidescreen.isChecked(); boolean noInterlacingPatches = swNoInterlacing.isChecked(); boolean loadTextures = swLoadTextures.isChecked(); - boolean asyncTextureLoading = swAsyncTextureLoading.isChecked(); + boolean asyncTextureLoading = swAsyncTextureLoading.isChecked(); + boolean precacheTextureReplacements = swPrecacheTextures != null && swPrecacheTextures.isChecked(); boolean hudVisible = (swDevHud != null && swDevHud.isChecked()); boolean enableCheatsGlobal = swCheatsGlobal != null && swCheatsGlobal.isChecked(); @@ -250,17 +256,20 @@ public class SettingsDialogFragment extends DialogFragment { .putBoolean("no_interlacing_patches", noInterlacingPatches) .putBoolean("load_textures", loadTextures) .putBoolean("async_texture_loading", asyncTextureLoading) + .putBoolean("precache_textures", precacheTextureReplacements) .putBoolean("hud_visible", hudVisible) .putBoolean("enable_cheats", enableCheatsGlobal) .apply(); // Apply in one batch to avoid repeated ApplySettings calls - try { - NativeApp.applyGlobalSettingsBatch(renderer, scale, aspectRatio, blendingLevel, - widescreenPatches, noInterlacingPatches, loadTextures, asyncTextureLoading, hudVisible); - } catch (Throwable t) { - android.util.Log.e("SettingsDialog", "Batch apply failed: " + t.getMessage()); - } + try { + NativeApp.applyGlobalSettingsBatch(renderer, scale, aspectRatio, blendingLevel, + widescreenPatches, noInterlacingPatches, loadTextures, asyncTextureLoading, hudVisible); + // Apply precache separately (not part of the batch JNI) + NativeApp.setPrecacheTextureReplacements(precacheTextureReplacements); + } catch (Throwable t) { + android.util.Log.e("SettingsDialog", "Apply failed: " + t.getMessage()); + } // Refresh quick UI (renderer label) if hosting activity is MainActivity try { diff --git a/app/src/main/res/layout/dialog_settings.xml b/app/src/main/res/layout/dialog_settings.xml index 57fda97..c928718 100644 --- a/app/src/main/res/layout/dialog_settings.xml +++ b/app/src/main/res/layout/dialog_settings.xml @@ -208,6 +208,14 @@ android:text="Async Texture Loading" android:layout_marginTop="8dp" style="@style/Widget.Material3.CompoundButton.MaterialSwitch"/> + + + + + \ No newline at end of file