diff --git a/.gitignore b/.gitignore index 6240e2d..323d5a5 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,8 @@ build/ *.aab *.properties release/ +mymc-pysrc-2.7/ +MEMORY_CARD_GUIDE.md +MEMCARD_QUICK_REFERENCE.md +MEMCARD_FEATURE_SUMMARY.md +MEMCARD_ARCHITECTURE.md diff --git a/app/src/main/cpp/native-lib.cpp b/app/src/main/cpp/native-lib.cpp index 98176b4..1ded09f 100644 --- a/app/src/main/cpp/native-lib.cpp +++ b/app/src/main/cpp/native-lib.cpp @@ -1638,3 +1638,104 @@ static std::vector SafListFilesJNI(const char* subdir) env->DeleteLocalRef(arr); return ret; } + +// Get list of saves on a memory card using PCSX2's native parsing +extern "C" +JNIEXPORT jobjectArray JNICALL +Java_com_izzy2lost_psx2_NativeApp_getMemoryCardSaves(JNIEnv* env, jclass, jstring p_memcard_path) +{ + if (!p_memcard_path) { + return env->NewObjectArray(0, env->FindClass("java/lang/String"), nullptr); + } + + const char* path_chars = env->GetStringUTFChars(p_memcard_path, nullptr); + if (!path_chars) { + return env->NewObjectArray(0, env->FindClass("java/lang/String"), nullptr); + } + + std::string memcard_path(path_chars); + env->ReleaseStringUTFChars(p_memcard_path, path_chars); + + std::vector saves; + + // Open the memory card file + auto fp = FileSystem::OpenManagedCFile(memcard_path.c_str(), "rb"); + if (!fp) { + return env->NewObjectArray(0, env->FindClass("java/lang/String"), nullptr); + } + + // Read superblock to get root directory cluster + u8 superblock[512]; + if (std::fread(superblock, 1, 512, fp.get()) != 512) { + return env->NewObjectArray(0, env->FindClass("java/lang/String"), nullptr); + } + + // Extract alloc_offset (at 0x34) and rootdir_cluster (at 0x3C) + u32 alloc_offset = *(u32*)&superblock[0x34]; + u32 rootdir_cluster = *(u32*)&superblock[0x3C]; + + // Calculate directory start position (each cluster is 1024 bytes) + u64 dir_start = (u64)(alloc_offset + rootdir_cluster) * 1024; + + // Seek to directory + if (FileSystem::FSeek64(fp.get(), dir_start, SEEK_SET) != 0) { + return env->NewObjectArray(0, env->FindClass("java/lang/String"), nullptr); + } + + // Read directory entries (each entry is 512 bytes) + for (int i = 0; i < 100; i++) { + u8 entry[512]; + if (std::fread(entry, 1, 512, fp.get()) != 512) break; + + // Read mode (first 4 bytes) + u32 mode = *(u32*)&entry[0]; + + // Skip empty entries + if (mode == 0 || mode == 0xFFFFFFFF) continue; + + // Check if used (0x8000 flag) + if (!(mode & 0x8000)) continue; + + // Read filename (at offset 0x40, max 32 bytes) + u8 name_bytes[32]; + std::memcpy(name_bytes, &entry[0x40], 32); + + // Convert to string, stopping at null terminator + std::string name_str; + for (int j = 0; j < 32; j++) { + if (name_bytes[j] == 0) break; + // Only include printable ASCII + if (name_bytes[j] >= 32 && name_bytes[j] <= 126) { + name_str += (char)name_bytes[j]; + } + } + + // Skip "." and ".." entries + if (name_str == "." || name_str == "..") continue; + if (name_str.empty()) continue; + + // Read length field (at offset 0x04) + u32 length = *(u32*)&entry[0x04]; + + // Check if it's a directory (0x0020 flag) + bool is_dir = (mode & 0x0020) != 0; + + // Basic sanity check - skip if length is suspiciously large + if (length > 1000000000) continue; // 1 billion is clearly wrong + + // Format: "filename|size|isDirectory" + std::string save_info = StringUtil::StdStringFromFormat("%s|%u|%d", + name_str.c_str(), length, is_dir ? 1 : 0); + saves.push_back(save_info); + } + + // Convert to Java string array + jobjectArray result = env->NewObjectArray(saves.size(), env->FindClass("java/lang/String"), nullptr); + for (size_t i = 0; i < saves.size(); i++) { + jstring str = env->NewStringUTF(saves[i].c_str()); + env->SetObjectArrayElement(result, i, str); + env->DeleteLocalRef(str); + } + + return result; +} diff --git a/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java index 421c59e..ec7a481 100644 --- a/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java +++ b/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java @@ -526,6 +526,12 @@ public class GamesCoverDialogFragment extends DialogFragment { try { new SavesDialogFragment().show(getParentFragmentManager(), "saves_dialog"); } catch (Throwable ignored) {} }); } + View btnMemcardManager = header.findViewById(R.id.drawer_btn_memcard_manager); + if (btnMemcardManager != null) { + btnMemcardManager.setOnClickListener(v -> { + try { new MemoryCardManagerDialogFragment().show(getParentFragmentManager(), "memcard_manager_dialog"); } catch (Throwable ignored) {} + }); + } View btnAbout = header.findViewById(R.id.drawer_btn_about); if (btnAbout != null) { btnAbout.setOnClickListener(v -> { diff --git a/app/src/main/java/com/izzy2lost/psx2/MainActivity.java b/app/src/main/java/com/izzy2lost/psx2/MainActivity.java index a44cf2e..c7449c4 100644 --- a/app/src/main/java/com/izzy2lost/psx2/MainActivity.java +++ b/app/src/main/java/com/izzy2lost/psx2/MainActivity.java @@ -665,6 +665,15 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF } catch (Throwable ignored) {} }); } + View btnMemcardManager = header.findViewById(R.id.drawer_btn_memcard_manager); + if (btnMemcardManager != null) { + btnMemcardManager.setOnClickListener(v -> { + try { + MemoryCardManagerDialogFragment dialog = new MemoryCardManagerDialogFragment(); + dialog.show(getSupportFragmentManager(), "memcard_manager_dialog"); + } catch (Throwable ignored) {} + }); + } View btnAbout = header.findViewById(R.id.drawer_btn_about); if (btnAbout != null) { btnAbout.setOnClickListener(v -> { diff --git a/app/src/main/java/com/izzy2lost/psx2/MemoryCardManagerDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/MemoryCardManagerDialogFragment.java new file mode 100644 index 0000000..679c748 --- /dev/null +++ b/app/src/main/java/com/izzy2lost/psx2/MemoryCardManagerDialogFragment.java @@ -0,0 +1,355 @@ +package com.izzy2lost.psx2; + +import android.app.Activity; +import android.app.Dialog; +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.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.documentfile.provider.DocumentFile; +import androidx.fragment.app.DialogFragment; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +public class MemoryCardManagerDialogFragment extends DialogFragment { + + private ListView memcardListView; + private ArrayAdapter memcardAdapter; + private List memcardFiles; + private ActivityResultLauncher importLauncher; + private ActivityResultLauncher exportLauncher; + private String selectedMemcard; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Import launcher - pick memory card files to import + importLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { + handleImport(result.getData()); + } + } + ); + + // Export launcher - pick destination folder + exportLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { + handleExport(result.getData()); + } + } + ); + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + View view = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_memcard_manager, null); + + memcardListView = view.findViewById(R.id.memcard_list); + MaterialButton btnImport = view.findViewById(R.id.btn_import_memcard); + MaterialButton btnExport = view.findViewById(R.id.btn_export_memcard); + + // Load existing memory cards + loadMemoryCards(); + + memcardAdapter = new ArrayAdapter<>(requireContext(), android.R.layout.simple_list_item_single_choice, memcardFiles); + memcardListView.setAdapter(memcardAdapter); + memcardListView.setChoiceMode(ListView.CHOICE_MODE_SINGLE); + + memcardListView.setOnItemClickListener((parent, v, position, id) -> { + selectedMemcard = memcardFiles.get(position); + }); + + btnImport.setOnClickListener(v -> showImportDialog()); + btnExport.setOnClickListener(v -> { + if (selectedMemcard == null) { + Toast.makeText(requireContext(), "Select a memory card first", Toast.LENGTH_SHORT).show(); + } else { + showExportDialog(); + } + }); + + return new MaterialAlertDialogBuilder(requireContext()) + .setCustomTitle(UiUtils.centeredDialogTitle(requireContext(), "MEMORY CARD MANAGER")) + .setView(view) + .setNegativeButton("Close", null) + .create(); + } + + private void loadMemoryCards() { + memcardFiles = new ArrayList<>(); + + // Always use the internal Android/data location where the emulator actually stores memory cards + File memcardDir = new File(requireContext().getExternalFilesDir(null), "memcards"); + if (memcardDir.exists() && memcardDir.isDirectory()) { + File[] files = memcardDir.listFiles(); + if (files != null) { + for (File file : files) { + if (file.getName().toLowerCase().endsWith(".ps2")) { + memcardFiles.add(file.getName()); + } + } + } + } + } + + private void showImportDialog() { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("*/*"); + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); + intent.addCategory(Intent.CATEGORY_OPENABLE); + importLauncher.launch(intent); + } + + private void handleImport(Intent data) { + try { + if (data.getClipData() != null) { + // Multiple files + int count = data.getClipData().getItemCount(); + int imported = 0; + for (int i = 0; i < count; i++) { + Uri uri = data.getClipData().getItemAt(i).getUri(); + if (importMemoryCard(uri)) { + imported++; + } + } + Toast.makeText(requireContext(), "Imported " + imported + " memory card(s)", Toast.LENGTH_SHORT).show(); + } else if (data.getData() != null) { + // Single file + if (importMemoryCard(data.getData())) { + Toast.makeText(requireContext(), "Memory card imported successfully", Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(requireContext(), "Failed to import memory card", Toast.LENGTH_SHORT).show(); + } + } + loadMemoryCards(); + memcardAdapter.clear(); + memcardAdapter.addAll(memcardFiles); + memcardAdapter.notifyDataSetChanged(); + } catch (Exception e) { + Toast.makeText(requireContext(), "Error importing: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + private boolean importMemoryCard(Uri sourceUri) { + try { + String filename = getFileName(sourceUri); + if (filename == null || !filename.toLowerCase().endsWith(".ps2")) { + return false; + } + + InputStream in = requireContext().getContentResolver().openInputStream(sourceUri); + if (in == null) return false; + + // Always import to internal Android/data location where emulator uses them + File memcardDir = new File(requireContext().getExternalFilesDir(null), "memcards"); + if (!memcardDir.exists()) memcardDir.mkdirs(); + File destFile = new File(memcardDir, filename); + + boolean success; + try (OutputStream out = new FileOutputStream(destFile)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + success = true; + } + in.close(); + return success; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + private void showExportDialog() { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); + exportLauncher.launch(intent); + } + + private void handleExport(Intent data) { + try { + Uri treeUri = data.getData(); + if (treeUri == null || selectedMemcard == null) return; + + DocumentFile destDir = DocumentFile.fromTreeUri(requireContext(), treeUri); + if (destDir == null) return; + + DocumentFile destFile = destDir.createFile("application/octet-stream", selectedMemcard); + if (destFile == null) return; + + // Always export from internal Android/data location + File memcardFile = new File(new File(requireContext().getExternalFilesDir(null), "memcards"), selectedMemcard); + InputStream in = new FileInputStream(memcardFile); + + if (in == null) { + Toast.makeText(requireContext(), "Failed to read memory card", Toast.LENGTH_SHORT).show(); + return; + } + + OutputStream out = requireContext().getContentResolver().openOutputStream(destFile.getUri()); + if (out == null) { + in.close(); + Toast.makeText(requireContext(), "Failed to write to destination", Toast.LENGTH_SHORT).show(); + return; + } + + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + + in.close(); + out.close(); + + Toast.makeText(requireContext(), "Memory card exported successfully", Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + Toast.makeText(requireContext(), "Export failed: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + private void showDeleteConfirmation() { + new MaterialAlertDialogBuilder(requireContext()) + .setTitle("Delete Memory Card") + .setMessage("Are you sure you want to delete " + selectedMemcard + "? This cannot be undone.") + .setNegativeButton("Cancel", null) + .setPositiveButton("Delete", (dialog, which) -> deleteMemoryCard()) + .show(); + } + + private void deleteMemoryCard() { + try { + // Always delete from internal Android/data location + File memcardFile = new File(new File(requireContext().getExternalFilesDir(null), "memcards"), selectedMemcard); + boolean success = memcardFile.delete(); + + if (success) { + Toast.makeText(requireContext(), "Memory card deleted", Toast.LENGTH_SHORT).show(); + selectedMemcard = null; + loadMemoryCards(); + memcardAdapter.clear(); + memcardAdapter.addAll(memcardFiles); + memcardAdapter.notifyDataSetChanged(); + } else { + Toast.makeText(requireContext(), "Failed to delete memory card", Toast.LENGTH_SHORT).show(); + } + } catch (Exception e) { + Toast.makeText(requireContext(), "Error: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + private void showCreateNewDialog() { + // Check which slots are available + boolean slot1Exists = memcardFiles.contains("Mcd001.ps2"); + boolean slot2Exists = memcardFiles.contains("Mcd002.ps2"); + + // Build list of available slots + List availableSlots = new ArrayList<>(); + List availableFilenames = new ArrayList<>(); + + if (!slot1Exists) { + availableSlots.add("Mcd001.ps2 (Slot 1)"); + availableFilenames.add("Mcd001.ps2"); + } + if (!slot2Exists) { + availableSlots.add("Mcd002.ps2 (Slot 2)"); + availableFilenames.add("Mcd002.ps2"); + } + + if (availableSlots.isEmpty()) { + Toast.makeText(requireContext(), "Both memory card slots are already in use", Toast.LENGTH_SHORT).show(); + return; + } + + new MaterialAlertDialogBuilder(requireContext()) + .setTitle("Create New Memory Card") + .setMessage("Choose a memory card slot:") + .setItems(availableSlots.toArray(new String[0]), (dialog, which) -> { + String filename = availableFilenames.get(which); + createNewMemoryCard(filename); + }) + .setNegativeButton("Cancel", null) + .show(); + } + + private void createNewMemoryCard(String filename) { + try { + // Create an 8MB empty memory card file + byte[] emptyCard = new byte[8 * 1024 * 1024]; + + // Always create in internal Android/data location + File memcardDir = new File(requireContext().getExternalFilesDir(null), "memcards"); + if (!memcardDir.exists()) memcardDir.mkdirs(); + File memcardFile = new File(memcardDir, filename); + + boolean success; + try (FileOutputStream out = new FileOutputStream(memcardFile)) { + out.write(emptyCard); + success = true; + } + + if (success) { + Toast.makeText(requireContext(), "Memory card created: " + filename, Toast.LENGTH_SHORT).show(); + loadMemoryCards(); + memcardAdapter.clear(); + memcardAdapter.addAll(memcardFiles); + memcardAdapter.notifyDataSetChanged(); + } else { + Toast.makeText(requireContext(), "Failed to create memory card", Toast.LENGTH_SHORT).show(); + } + } catch (Exception e) { + Toast.makeText(requireContext(), "Error: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + private String getFileName(Uri uri) { + String result = null; + if (uri.getScheme() != null && uri.getScheme().equals("content")) { + try (android.database.Cursor cursor = requireContext().getContentResolver().query(uri, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + int nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME); + if (nameIndex >= 0) { + result = cursor.getString(nameIndex); + } + } + } + } + if (result == null) { + result = uri.getPath(); + if (result != null) { + int cut = result.lastIndexOf('/'); + if (cut != -1) { + result = result.substring(cut + 1); + } + } + } + return result; + } +} diff --git a/app/src/main/java/com/izzy2lost/psx2/MemoryCardSavesDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/MemoryCardSavesDialogFragment.java new file mode 100644 index 0000000..cb75b5d --- /dev/null +++ b/app/src/main/java/com/izzy2lost/psx2/MemoryCardSavesDialogFragment.java @@ -0,0 +1,293 @@ +package com.izzy2lost.psx2; + +import android.app.Dialog; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.io.File; +import java.io.RandomAccessFile; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +public class MemoryCardSavesDialogFragment extends DialogFragment { + + private static final String ARG_MEMCARD_NAME = "memcard_name"; + + public static MemoryCardSavesDialogFragment newInstance(String memcardName) { + MemoryCardSavesDialogFragment fragment = new MemoryCardSavesDialogFragment(); + Bundle args = new Bundle(); + args.putString(ARG_MEMCARD_NAME, memcardName); + fragment.setArguments(args); + return fragment; + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + View view = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_memcard_saves, null); + + String memcardName = getArguments() != null ? getArguments().getString(ARG_MEMCARD_NAME) : ""; + + TextView tvMemcardName = view.findViewById(R.id.tv_memcard_name); + TextView tvMemcardInfo = view.findViewById(R.id.tv_memcard_info); + ListView lvSaves = view.findViewById(R.id.lv_saves); + TextView tvNoSaves = view.findViewById(R.id.tv_no_saves); + + tvMemcardName.setText(memcardName); + + // Get memory card file + File memcardDir = new File(requireContext().getExternalFilesDir(null), "memcards"); + File memcardFile = new File(memcardDir, memcardName); + + if (memcardFile.exists()) { + // Show file info + long fileSize = memcardFile.length(); + long lastModified = memcardFile.lastModified(); + SimpleDateFormat sdf = new SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()); + String dateStr = sdf.format(new Date(lastModified)); + + String info = String.format(Locale.getDefault(), + "Size: %.2f MB\nLast Modified: %s", + fileSize / (1024.0 * 1024.0), + dateStr); + tvMemcardInfo.setText(info); + + // Use native PCSX2 code to parse saves + List saves = parseSaveFilesNative(memcardFile); + + if (saves.isEmpty()) { + lvSaves.setVisibility(View.GONE); + tvNoSaves.setVisibility(View.VISIBLE); + } else { + lvSaves.setVisibility(View.VISIBLE); + tvNoSaves.setVisibility(View.GONE); + + ArrayAdapter adapter = new ArrayAdapter<>( + requireContext(), + android.R.layout.simple_list_item_1, + saves + ); + lvSaves.setAdapter(adapter); + } + } else { + tvMemcardInfo.setText("Memory card file not found"); + lvSaves.setVisibility(View.GONE); + tvNoSaves.setVisibility(View.VISIBLE); + } + + return new MaterialAlertDialogBuilder(requireContext()) + .setCustomTitle(UiUtils.centeredDialogTitle(requireContext(), "MEMORY CARD SAVES")) + .setView(view) + .setPositiveButton("Close", null) + .create(); + } + + private List parseSaveFilesNative(File memcardFile) { + List saves = new ArrayList<>(); + + try { + // Call native PCSX2 code to parse the memory card + String[] nativeSaves = NativeApp.getMemoryCardSaves(memcardFile.getAbsolutePath()); + + if (nativeSaves != null) { + for (String saveInfo : nativeSaves) { + // Format is "filename|size|isDirectory" + String[] parts = saveInfo.split("\\|"); + if (parts.length >= 3) { + String filename = parts[0]; + int size = Integer.parseInt(parts[1]); + boolean isDir = parts[2].equals("1"); + + String displayInfo; + if (isDir) { + displayInfo = String.format(Locale.getDefault(), + "%s (%d files)", + filename, + size + ); + } else { + displayInfo = String.format(Locale.getDefault(), + "%s (%d KB)", + filename, + size / 1024 + ); + } + saves.add(displayInfo); + } + } + } + } catch (Exception e) { + android.util.Log.e("MemcardSaves", "Error parsing memory card with native code: " + e.getMessage(), e); + } + + return saves; + } + + private List parseSaveFilesOld(File memcardFile) { + List saves = new ArrayList<>(); + + try (RandomAccessFile raf = new RandomAccessFile(memcardFile, "r")) { + android.util.Log.d("MemcardSaves", "Parsing memory card: " + memcardFile.getName() + " (" + memcardFile.length() + " bytes)"); + + // Verify this is a PS2 memory card by checking magic + byte[] magic = new byte[28]; + raf.seek(0); + raf.read(magic); + String magicStr = new String(magic, 0, 28).trim(); + android.util.Log.d("MemcardSaves", "Magic: " + magicStr); + + // Standard PS2 memory card: directory starts at page 13 (0x1A00) + // Each page is 512 bytes + long[] possibleDirStarts = { + 0x1A00, // Standard location (page 13) + 0x2000, // Alternative location + 0x4000 // Another possible location + }; + + for (long dirStart : possibleDirStarts) { + if (dirStart >= raf.length()) continue; + + android.util.Log.d("MemcardSaves", "Trying directory at offset: 0x" + Long.toHexString(dirStart)); + + // Scan for directory entries + // Each entry is 512 bytes + int foundCount = 0; + for (int i = 0; i < 100; i++) { + long entryPos = dirStart + (i * 512); + if (entryPos + 512 > raf.length()) break; + + raf.seek(entryPos); + + // Read entry mode (first 4 bytes, little-endian) + int mode = readInt32LE(raf); + + // Skip if empty + if (mode == 0 || mode == 0xFFFFFFFF) continue; + + // Read filename first + raf.seek(entryPos + 0x40); + byte[] nameBytes = new byte[32]; + raf.read(nameBytes); + String filename = extractString(nameBytes); + + // Skip "." and ".." entries + if (filename.equals(".") || filename.equals("..")) continue; + + // Skip if no filename + if (filename.isEmpty()) continue; + + // Read length/size field + raf.seek(entryPos + 0x04); + int lengthOrSize = readInt32LE(raf); + + // Log the entry for debugging + android.util.Log.d("MemcardSaves", String.format("Entry: mode=0x%08X, name=%s, length=%d", mode, filename, lengthOrSize)); + + // Check flags + boolean isUsed = (mode & 0x8000) != 0; + boolean isDir = (mode & 0x0020) != 0; + boolean isFile = (mode & 0x0010) != 0; + + // Accept if it's used and has a reasonable size/count + if (isUsed && lengthOrSize > 0 && lengthOrSize < 10 * 1024 * 1024) { + String saveInfo; + if (isDir) { + // Directory - show number of files + saveInfo = String.format(Locale.getDefault(), + "%s (%d files)", + filename, + lengthOrSize + ); + android.util.Log.d("MemcardSaves", "Found save directory: " + filename + " (" + lengthOrSize + " files)"); + } else { + // File - show size + saveInfo = String.format(Locale.getDefault(), + "%s (%d KB)", + filename, + lengthOrSize / 1024 + ); + android.util.Log.d("MemcardSaves", "Found file: " + filename + " (" + lengthOrSize + " bytes)"); + } + saves.add(saveInfo); + foundCount++; + } + } + + // If we found saves at this location, stop searching + if (foundCount > 0) { + android.util.Log.d("MemcardSaves", "Found " + foundCount + " saves at offset 0x" + Long.toHexString(dirStart)); + break; + } + } + + if (saves.isEmpty()) { + android.util.Log.w("MemcardSaves", "No saves found in memory card"); + } + + } catch (Exception e) { + android.util.Log.e("MemcardSaves", "Error parsing memory card: " + e.getMessage(), e); + } + + return saves; + } + + private int readInt32LE(RandomAccessFile raf) throws Exception { + byte[] bytes = new byte[4]; + raf.read(bytes); + return (bytes[0] & 0xFF) | + ((bytes[1] & 0xFF) << 8) | + ((bytes[2] & 0xFF) << 16) | + ((bytes[3] & 0xFF) << 24); + } + + private String extractString(byte[] bytes) { + // Find null terminator + int length = 0; + for (int i = 0; i < bytes.length; i++) { + if (bytes[i] == 0) { + length = i; + break; + } + } + if (length == 0) length = bytes.length; + + // Try to decode as ASCII/Latin-1 + try { + String str = new String(bytes, 0, length, "ISO-8859-1").trim(); + // Filter out non-printable characters + StringBuilder sb = new StringBuilder(); + for (char c : str.toCharArray()) { + if (c >= 32 && c < 127) { + sb.append(c); + } else if (c >= 160) { + // Extended ASCII characters + sb.append(c); + } + } + return sb.toString().trim(); + } catch (Exception e) { + // Fallback to simple ASCII extraction + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < length; i++) { + byte b = bytes[i]; + if (b >= 32 && b < 127) { + sb.append((char) b); + } + } + return sb.toString().trim(); + } + } +} diff --git a/app/src/main/java/com/izzy2lost/psx2/NativeApp.java b/app/src/main/java/com/izzy2lost/psx2/NativeApp.java index e49fc0c..55b18d0 100644 --- a/app/src/main/java/com/izzy2lost/psx2/NativeApp.java +++ b/app/src/main/java/com/izzy2lost/psx2/NativeApp.java @@ -151,6 +151,10 @@ public class NativeApp { } } + // Get list of saves on a memory card + // Returns array of strings in format "filename|size|isDirectory" + public static native String[] getMemoryCardSaves(String memcardPath); + public static native void onNativeSurfaceCreated(); public static native void onNativeSurfaceChanged(Surface surface, int w, int h); public static native void onNativeSurfaceDestroyed(); diff --git a/app/src/main/java/com/izzy2lost/psx2/QuickActionsDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/QuickActionsDialogFragment.java index df8f033..8881f9a 100644 --- a/app/src/main/java/com/izzy2lost/psx2/QuickActionsDialogFragment.java +++ b/app/src/main/java/com/izzy2lost/psx2/QuickActionsDialogFragment.java @@ -240,6 +240,15 @@ public class QuickActionsDialogFragment extends DialogFragment { }); } + // Memory Cards: open memory card manager dialog + MaterialButton btnMemcards = view.findViewById(R.id.btn_quick_memcards); + if (btnMemcards != null) { + btnMemcards.setOnClickListener(v -> { + try { new MemoryCardManagerDialogFragment().show(getParentFragmentManager(), "memcard_manager_dialog"); } catch (Throwable ignored) {} + dismissAllowingStateLoss(); + }); + } + // Exit Game: open games dialog if (btnExitGame != null) { btnExitGame.setOnClickListener(v -> { diff --git a/app/src/main/res/drawable/sd_card_24px.xml b/app/src/main/res/drawable/sd_card_24px.xml new file mode 100644 index 0000000..6dd3e5a --- /dev/null +++ b/app/src/main/res/drawable/sd_card_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/dialog_memcard_manager.xml b/app/src/main/res/layout/dialog_memcard_manager.xml new file mode 100644 index 0000000..a0df868 --- /dev/null +++ b/app/src/main/res/layout/dialog_memcard_manager.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_memcard_saves.xml b/app/src/main/res/layout/dialog_memcard_saves.xml new file mode 100644 index 0000000..42ec63e --- /dev/null +++ b/app/src/main/res/layout/dialog_memcard_saves.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_quick_actions.xml b/app/src/main/res/layout/dialog_quick_actions.xml index d44a769..138f340 100644 --- a/app/src/main/res/layout/dialog_quick_actions.xml +++ b/app/src/main/res/layout/dialog_quick_actions.xml @@ -139,6 +139,20 @@ android:drawablePadding="8dp" /> + + + + + +