Android storage: fix folder memory cards on a custom data folder

libc mkdir() is denied on the FUSE-backed emulated storage Android hands out for
a user-chosen data folder, while java.io.File.mkdirs() on the same path succeeds.
FileSystem::CreateDirectoryPath went straight to mkdir() and returned failure, so
every folder-memory-card save-data creation failed: "Format failed", and a crash
on first save in Soul Calibur 2 / Ratchet & Clank / GT4. Reproduced only with a
custom data folder, never with internal app storage.

A Java bridge for exactly this existed (NativeApp.createDirectoryPath plus the
FileSystem::CreateDirectoryViaJava JNI) but nothing called it after the monorepo
migration - the linker was dropping it as dead code. Wire it in as a fallback on
EPERM/EACCES, in both the flat and per-segment recursive paths.

Also adds folder-card import, which had no working route at all: a folder card is
a directory plus a _pcsx2_superblock marker, but the picker was OpenDocument()
(files only), so people zipped them and the importer appended ".ps2" to the
archive and copied it verbatim - producing a card the core read as unformatted.
Directories can now be imported directly, zips are unpacked, and both validate
the superblock instead of silently producing a broken card.

(cherry picked from commit 265ddb7657)
This commit is contained in:
jpolo1224
2026-07-19 21:04:34 -04:00
parent 77d008af1d
commit 30b778e9ec
3 changed files with 124 additions and 0 deletions
+23
View File
@@ -2507,6 +2507,21 @@ bool FileSystem::CreateDirectoryPath(const char* path, bool recursive, Error* er
return true;
}
#ifdef __ANDROID__
// libc mkdir() is DENIED on the FUSE-backed emulated storage Android hands out for a
// user-chosen data folder, while the Java File.mkdirs() path on the same directory
// succeeds — the syscall and the SAF/MediaProvider layer disagree about permission.
// Folder memory cards are the visible casualty: every save-data creation goes through
// here, so "Format failed" / crash-on-first-save reproduced ONLY with a custom data
// folder and never with internal app storage. The bridge below already existed and was
// implemented in the JNI, but nothing called it after the monorepo migration.
if (lastError == EPERM || lastError == EACCES)
{
if (CreateDirectoryViaJava(path))
return true;
}
#endif
if (!recursive)
{
Error::SetErrno(error, "mkdir() failed: ", lastError);
@@ -2530,6 +2545,14 @@ bool FileSystem::CreateDirectoryPath(const char* path, bool recursive, Error* er
lastError = errno;
if (lastError != EEXIST) // fine, continue to next path segment
{
#ifdef __ANDROID__
// Same FUSE mkdir denial as above, per path segment.
if ((lastError == EPERM || lastError == EACCES) &&
CreateDirectoryViaJava(tempPath.c_str()))
{
continue;
}
#endif
Error::SetErrno(error, "mkdir() failed: ", lastError);
return false;
}
@@ -55,6 +55,10 @@ fun MemoryCardScreen(onBack: () -> Unit, game: GameInfo? = null, viewModel: Memo
var createDialog by remember { mutableStateOf(false) }
var deleteTarget by remember { mutableStateOf<MemoryCardItem?>(null) }
val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> uri?.let(viewModel::import) }
// Folder memory cards are directories, which OpenDocument() cannot return — without
// this there was no way to import one at all, and zipping it produced a "card.zip.ps2"
// that read as unformatted.
val folderImporter = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> uri?.let(viewModel::importFolder) }
LaunchedEffect(Unit) { viewModel.refresh() }
ArmsBackdrop {
@@ -70,6 +74,7 @@ fun MemoryCardScreen(onBack: () -> Unit, game: GameInfo? = null, viewModel: Memo
actions = {
RoundAction("", str("memcard.newCard"), { createDialog = true })
RoundAction("", str("action.import"), { importer.launch(arrayOf("application/octet-stream", "*/*")) })
RoundAction("", str("memcard.importFolder"), { folderImporter.launch(null) })
RoundAction("", str("games.card.refresh"), viewModel::refresh)
},
horizontalPadding = 0.dp,
@@ -60,6 +60,19 @@ class MemoryCardViewModel(application: Application) : AndroidViewModel(applicati
fun import(uri: Uri) {
val context = getApplication<Application>()
val name = DocumentFile.fromSingleUri(context, uri)?.name?.ifBlank { null } ?: "Imported.ps2"
// A FOLDER memory card is a directory of save folders plus a "_pcsx2_superblock"
// marker, so it cannot travel as a single file — people share them zipped. The old
// path appended ".ps2" to whatever was picked and copied the bytes verbatim, so a
// zip landed as "card.zip.ps2" full of zip data: the core read it as an unformatted
// file card, and formatting it then failed too. Unpack instead, into a real folder
// card. (Directory picking is handled by importFolder below — OpenDocument() can
// only return files.)
if (name.endsWith(".zip", true)) {
importZip(uri, name)
return
}
val requested = if (name.endsWith(".ps2", true)) name else "$name.ps2"
val target = uniqueFile(cardDirectory(), requested)
val success = runCatching {
@@ -72,6 +85,89 @@ class MemoryCardViewModel(application: Application) : AndroidViewModel(applicati
refresh()
}
/** Unpack a zipped folder memory card into cards/<name>/. Tolerates the two common
* shapes: entries at the archive root, or nested under a single top-level directory. */
private fun importZip(uri: Uri, zipName: String) {
val context = getApplication<Application>()
val target = uniqueFile(cardDirectory(), zipName.substringBeforeLast('.'))
val ok = runCatching {
target.mkdirs()
context.contentResolver.openInputStream(uri)?.use { raw ->
java.util.zip.ZipInputStream(raw.buffered()).use { zin ->
var entry = zin.nextEntry
while (entry != null) {
// Strip a single wrapping directory so "Mcd001/_pcsx2_superblock"
// and "_pcsx2_superblock" both land correctly.
val rel = entry.name.replace('\\', '/').trimStart('/')
val stripped = if (rel.count { it == '/' } > 0 && !rel.startsWith("_pcsx2_"))
rel.substringAfter('/') else rel
if (stripped.isNotBlank()) {
val out = File(target, stripped)
// Zip-slip guard: never write outside the card directory.
if (out.canonicalPath.startsWith(target.canonicalPath + File.separator) ||
out.canonicalPath == target.canonicalPath) {
if (entry.isDirectory) {
out.mkdirs()
} else {
out.parentFile?.mkdirs()
out.outputStream().use { zin.copyTo(it) }
}
}
}
zin.closeEntry()
entry = zin.nextEntry
}
}
} ?: error("Unable to read the selected file.")
// A folder card is only valid with its superblock; without it the core would
// report "unformatted" exactly as before, so fail loudly here instead.
File(target, "_pcsx2_superblock").isFile
}.getOrDefault(false)
if (!ok) target.deleteRecursively()
state.value = if (ok) {
state.value.copy(message = "Imported folder card ${target.name}.")
} else {
state.value.copy(error = "That zip isn't a folder memory card (no _pcsx2_superblock inside).")
}
refresh()
}
/** Import a folder memory card straight from a directory the user picks. */
fun importFolder(uri: Uri) {
val context = getApplication<Application>()
val source = DocumentFile.fromTreeUri(context, uri)
if (source == null || !source.isDirectory) {
state.value = state.value.copy(error = "Unable to open the selected folder.")
return
}
val target = uniqueFile(cardDirectory(), source.name?.ifBlank { null } ?: "Imported")
val ok = runCatching {
target.mkdirs()
copyTree(source, target)
File(target, "_pcsx2_superblock").isFile
}.getOrDefault(false)
if (!ok) target.deleteRecursively()
state.value = if (ok) {
state.value.copy(message = "Imported folder card ${target.name}.")
} else {
state.value.copy(error = "That folder isn't a memory card (no _pcsx2_superblock inside).")
}
refresh()
}
private fun copyTree(source: DocumentFile, destination: File) {
source.listFiles().forEach { child ->
val childName = child.name ?: return@forEach
if (child.isDirectory) {
copyTree(child, File(destination, childName).apply { mkdirs() })
} else if (child.isFile) {
getApplication<Application>().contentResolver.openInputStream(child.uri)?.use { input ->
File(destination, childName).outputStream().use(input::copyTo)
}
}
}
}
fun assign(slot: Int, item: MemoryCardItem) {
if (!MainActivityRuntime.nativeReady.value) {
state.value = state.value.copy(error = "The emulator core is still starting.")